Include full contents of all nested repositories
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
16
openclaw/ui/index.html
Normal file
16
openclaw/ui/index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>OpenClaw Control</title>
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
</head>
|
||||
<body>
|
||||
<openclaw-app></openclaw-app>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
27
openclaw/ui/package.json
Normal file
27
openclaw/ui/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "openclaw-control-ui",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"dev": "vite",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run --config vitest.config.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lit-labs/signals": "^0.2.0",
|
||||
"@lit/context": "^1.1.6",
|
||||
"@noble/ed25519": "3.0.0",
|
||||
"dompurify": "^3.3.1",
|
||||
"lit": "^3.3.2",
|
||||
"marked": "^17.0.3",
|
||||
"signal-polyfill": "^0.2.2",
|
||||
"signal-utils": "^0.21.1",
|
||||
"vite": "7.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/browser-playwright": "4.0.18",
|
||||
"playwright": "^1.58.2",
|
||||
"vitest": "4.0.18"
|
||||
}
|
||||
}
|
||||
BIN
openclaw/ui/public/apple-touch-icon.png
Normal file
BIN
openclaw/ui/public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.8 KiB |
BIN
openclaw/ui/public/favicon-32.png
Normal file
BIN
openclaw/ui/public/favicon-32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1015 B |
BIN
openclaw/ui/public/favicon.ico
Normal file
BIN
openclaw/ui/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 94 KiB |
22
openclaw/ui/public/favicon.svg
Normal file
22
openclaw/ui/public/favicon.svg
Normal file
@@ -0,0 +1,22 @@
|
||||
<svg viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="lobster-gradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#ff4d4d"/>
|
||||
<stop offset="100%" stop-color="#991b1b"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- Body -->
|
||||
<path d="M60 10 C30 10 15 35 15 55 C15 75 30 95 45 100 L45 110 L55 110 L55 100 C55 100 60 102 65 100 L65 110 L75 110 L75 100 C90 95 105 75 105 55 C105 35 90 10 60 10Z" fill="url(#lobster-gradient)"/>
|
||||
<!-- Left Claw -->
|
||||
<path d="M20 45 C5 40 0 50 5 60 C10 70 20 65 25 55 C28 48 25 45 20 45Z" fill="url(#lobster-gradient)"/>
|
||||
<!-- Right Claw -->
|
||||
<path d="M100 45 C115 40 120 50 115 60 C110 70 100 65 95 55 C92 48 95 45 100 45Z" fill="url(#lobster-gradient)"/>
|
||||
<!-- Antenna -->
|
||||
<path d="M45 15 Q35 5 30 8" stroke="#ff4d4d" stroke-width="3" stroke-linecap="round"/>
|
||||
<path d="M75 15 Q85 5 90 8" stroke="#ff4d4d" stroke-width="3" stroke-linecap="round"/>
|
||||
<!-- Eyes -->
|
||||
<circle cx="45" cy="35" r="6" fill="#050810"/>
|
||||
<circle cx="75" cy="35" r="6" fill="#050810"/>
|
||||
<circle cx="46" cy="34" r="2.5" fill="#00e5cc"/>
|
||||
<circle cx="76" cy="34" r="2.5" fill="#00e5cc"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
1
openclaw/ui/src/css.d.ts
vendored
Normal file
1
openclaw/ui/src/css.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
declare module "*.css";
|
||||
3
openclaw/ui/src/i18n/index.ts
Normal file
3
openclaw/ui/src/i18n/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./lib/types.ts";
|
||||
export * from "./lib/translate.ts";
|
||||
export * from "./lib/lit-controller.ts";
|
||||
22
openclaw/ui/src/i18n/lib/lit-controller.ts
Normal file
22
openclaw/ui/src/i18n/lib/lit-controller.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { ReactiveController, ReactiveControllerHost } from "lit";
|
||||
import { i18n } from "./translate.ts";
|
||||
|
||||
export class I18nController implements ReactiveController {
|
||||
private host: ReactiveControllerHost;
|
||||
private unsubscribe?: () => void;
|
||||
|
||||
constructor(host: ReactiveControllerHost) {
|
||||
this.host = host;
|
||||
this.host.addController(this);
|
||||
}
|
||||
|
||||
hostConnected() {
|
||||
this.unsubscribe = i18n.subscribe(() => {
|
||||
this.host.requestUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
hostDisconnected() {
|
||||
this.unsubscribe?.();
|
||||
}
|
||||
}
|
||||
139
openclaw/ui/src/i18n/lib/translate.ts
Normal file
139
openclaw/ui/src/i18n/lib/translate.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { en } from "../locales/en.ts";
|
||||
import type { Locale, TranslationMap } from "./types.ts";
|
||||
|
||||
type Subscriber = (locale: Locale) => void;
|
||||
|
||||
export const SUPPORTED_LOCALES: ReadonlyArray<Locale> = ["en", "zh-CN", "zh-TW", "pt-BR", "de"];
|
||||
|
||||
export function isSupportedLocale(value: string | null | undefined): value is Locale {
|
||||
return value !== null && value !== undefined && SUPPORTED_LOCALES.includes(value as Locale);
|
||||
}
|
||||
|
||||
class I18nManager {
|
||||
private locale: Locale = "en";
|
||||
private translations: Record<Locale, TranslationMap> = { en } as Record<Locale, TranslationMap>;
|
||||
private subscribers: Set<Subscriber> = new Set();
|
||||
|
||||
constructor() {
|
||||
this.loadLocale();
|
||||
}
|
||||
|
||||
private resolveInitialLocale(): Locale {
|
||||
const saved = localStorage.getItem("openclaw.i18n.locale");
|
||||
if (isSupportedLocale(saved)) {
|
||||
return saved;
|
||||
}
|
||||
const navLang = navigator.language;
|
||||
if (navLang.startsWith("zh")) {
|
||||
return navLang === "zh-TW" || navLang === "zh-HK" ? "zh-TW" : "zh-CN";
|
||||
}
|
||||
if (navLang.startsWith("pt")) {
|
||||
return "pt-BR";
|
||||
}
|
||||
if (navLang.startsWith("de")) {
|
||||
return "de";
|
||||
}
|
||||
return "en";
|
||||
}
|
||||
|
||||
private loadLocale() {
|
||||
const initialLocale = this.resolveInitialLocale();
|
||||
if (initialLocale === "en") {
|
||||
this.locale = "en";
|
||||
return;
|
||||
}
|
||||
// Use the normal locale setter so startup locale loading follows the same
|
||||
// translation-loading + notify path as manual locale changes.
|
||||
void this.setLocale(initialLocale);
|
||||
}
|
||||
|
||||
public getLocale(): Locale {
|
||||
return this.locale;
|
||||
}
|
||||
|
||||
public async setLocale(locale: Locale) {
|
||||
const needsTranslationLoad = !this.translations[locale];
|
||||
if (this.locale === locale && !needsTranslationLoad) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Lazy load translations if needed
|
||||
if (needsTranslationLoad) {
|
||||
try {
|
||||
let module: Record<string, TranslationMap>;
|
||||
if (locale === "zh-CN") {
|
||||
module = await import("../locales/zh-CN.ts");
|
||||
} else if (locale === "zh-TW") {
|
||||
module = await import("../locales/zh-TW.ts");
|
||||
} else if (locale === "pt-BR") {
|
||||
module = await import("../locales/pt-BR.ts");
|
||||
} else if (locale === "de") {
|
||||
module = await import("../locales/de.ts");
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
this.translations[locale] = module[locale.replace("-", "_")];
|
||||
} catch (e) {
|
||||
console.error(`Failed to load locale: ${locale}`, e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.locale = locale;
|
||||
localStorage.setItem("openclaw.i18n.locale", locale);
|
||||
this.notify();
|
||||
}
|
||||
|
||||
public registerTranslation(locale: Locale, map: TranslationMap) {
|
||||
this.translations[locale] = map;
|
||||
}
|
||||
|
||||
public subscribe(sub: Subscriber) {
|
||||
this.subscribers.add(sub);
|
||||
return () => this.subscribers.delete(sub);
|
||||
}
|
||||
|
||||
private notify() {
|
||||
this.subscribers.forEach((sub) => sub(this.locale));
|
||||
}
|
||||
|
||||
public t(key: string, params?: Record<string, string>): string {
|
||||
const keys = key.split(".");
|
||||
let value: unknown = this.translations[this.locale] || this.translations["en"];
|
||||
|
||||
for (const k of keys) {
|
||||
if (value && typeof value === "object") {
|
||||
value = (value as Record<string, unknown>)[k];
|
||||
} else {
|
||||
value = undefined;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to English
|
||||
if (value === undefined && this.locale !== "en") {
|
||||
value = this.translations["en"];
|
||||
for (const k of keys) {
|
||||
if (value && typeof value === "object") {
|
||||
value = (value as Record<string, unknown>)[k];
|
||||
} else {
|
||||
value = undefined;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof value !== "string") {
|
||||
return key;
|
||||
}
|
||||
|
||||
if (params) {
|
||||
return value.replace(/\{(\w+)\}/g, (_, k) => params[k] || `{${k}}`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
export const i18n = new I18nManager();
|
||||
export const t = (key: string, params?: Record<string, string>) => i18n.t(key, params);
|
||||
9
openclaw/ui/src/i18n/lib/types.ts
Normal file
9
openclaw/ui/src/i18n/lib/types.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export type TranslationMap = { [key: string]: string | TranslationMap };
|
||||
|
||||
export type Locale = "en" | "zh-CN" | "zh-TW" | "pt-BR" | "de";
|
||||
|
||||
export interface I18nConfig {
|
||||
locale: Locale;
|
||||
fallbackLocale: Locale;
|
||||
translations: Record<Locale, TranslationMap>;
|
||||
}
|
||||
126
openclaw/ui/src/i18n/locales/de.ts
Normal file
126
openclaw/ui/src/i18n/locales/de.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import type { TranslationMap } from "../lib/types.ts";
|
||||
|
||||
export const de: TranslationMap = {
|
||||
common: {
|
||||
version: "Version",
|
||||
health: "Status",
|
||||
ok: "OK",
|
||||
offline: "Offline",
|
||||
connect: "Verbinden",
|
||||
refresh: "Aktualisieren",
|
||||
enabled: "Aktiviert",
|
||||
disabled: "Deaktiviert",
|
||||
na: "k. A.",
|
||||
docs: "Dokumentation",
|
||||
resources: "Ressourcen",
|
||||
},
|
||||
nav: {
|
||||
chat: "Chat",
|
||||
control: "Steuerung",
|
||||
agent: "Agent",
|
||||
settings: "Einstellungen",
|
||||
expand: "Seitenleiste ausklappen",
|
||||
collapse: "Seitenleiste einklappen",
|
||||
},
|
||||
tabs: {
|
||||
agents: "Agenten",
|
||||
overview: "Übersicht",
|
||||
channels: "Kanäle",
|
||||
instances: "Instanzen",
|
||||
sessions: "Sitzungen",
|
||||
usage: "Nutzung",
|
||||
cron: "Cron-Aufgaben",
|
||||
skills: "Fähigkeiten",
|
||||
nodes: "Geräte",
|
||||
chat: "Chat",
|
||||
config: "Konfiguration",
|
||||
debug: "Debug",
|
||||
logs: "Protokolle",
|
||||
},
|
||||
subtitles: {
|
||||
agents: "Agent-Arbeitsbereiche, Tools und Identitäten verwalten.",
|
||||
overview: "Gateway-Status, Einstiegspunkte und eine schnelle Zustandsprüfung.",
|
||||
channels: "Kanäle und Einstellungen verwalten.",
|
||||
instances: "Präsenzsignale von verbundenen Clients und Geräten.",
|
||||
sessions: "Aktive Sitzungen inspizieren und Standardeinstellungen pro Sitzung anpassen.",
|
||||
usage: "API-Nutzung und Kosten überwachen.",
|
||||
cron: "Aufweckzeiten und wiederkehrende Agent-Läufe planen.",
|
||||
skills: "Skill-Verfügbarkeit und API-Schlüsselinjektion verwalten.",
|
||||
nodes: "Gekoppelte Geräte, Fähigkeiten und Befehlsfreigabe.",
|
||||
chat: "Direkte Gateway-Chat-Sitzung für schnelle Eingriffe.",
|
||||
config: "~/.openclaw/openclaw.json sicher bearbeiten.",
|
||||
debug: "Gateway-Snapshots, Ereignisse und manuelle RPC-Aufrufe.",
|
||||
logs: "Live-Verfolgung der Gateway-Protokolldateien.",
|
||||
},
|
||||
overview: {
|
||||
access: {
|
||||
title: "Gateway-Zugang",
|
||||
subtitle: "Wo sich das Dashboard verbindet und wie es sich authentifiziert.",
|
||||
wsUrl: "WebSocket-URL",
|
||||
token: "Gateway-Token",
|
||||
password: "Passwort (nicht gespeichert)",
|
||||
sessionKey: "Standard-Sitzungsschlüssel",
|
||||
language: "Sprache",
|
||||
connectHint: "Klicken Sie auf Verbinden, um Verbindungsänderungen anzuwenden.",
|
||||
trustedProxy: "Authentifiziert über vertrauenswürdigen Proxy.",
|
||||
},
|
||||
snapshot: {
|
||||
title: "Snapshot",
|
||||
subtitle: "Neueste Gateway-Handshake-Informationen.",
|
||||
status: "Status",
|
||||
uptime: "Betriebszeit",
|
||||
tickInterval: "Tick-Intervall",
|
||||
lastChannelsRefresh: "Letzte Kanalaktualisierung",
|
||||
channelsHint:
|
||||
"Verwenden Sie Kanäle, um WhatsApp, Telegram, Discord, Signal oder iMessage zu verknüpfen.",
|
||||
},
|
||||
stats: {
|
||||
instances: "Instanzen",
|
||||
instancesHint: "Präsenzsignale in den letzten 5 Minuten.",
|
||||
sessions: "Sitzungen",
|
||||
sessionsHint: "Letzte vom Gateway verfolgte Sitzungsschlüssel.",
|
||||
cron: "Cron",
|
||||
cronNext: "Nächste Ausführung {time}",
|
||||
},
|
||||
notes: {
|
||||
title: "Notizen",
|
||||
subtitle: "Kurze Hinweise für Remote-Steuerung.",
|
||||
tailscaleTitle: "Tailscale Serve",
|
||||
tailscaleText:
|
||||
"Bevorzugen Sie den Serve-Modus, um das Gateway auf Loopback mit Tailnet-Auth zu halten.",
|
||||
sessionTitle: "Sitzungshygiene",
|
||||
sessionText: "Verwenden Sie /new oder sessions.patch, um den Kontext zurückzusetzen.",
|
||||
cronTitle: "Cron-Erinnerungen",
|
||||
cronText: "Verwenden Sie isolierte Sitzungen für wiederkehrende Läufe.",
|
||||
},
|
||||
auth: {
|
||||
required:
|
||||
"Dieses Gateway erfordert Authentifizierung. Fügen Sie ein Token oder Passwort hinzu und klicken Sie auf Verbinden.",
|
||||
failed:
|
||||
"Authentifizierung fehlgeschlagen. Kopieren Sie erneut eine URL mit Token über {command}, oder aktualisieren Sie das Token und klicken Sie auf Verbinden.",
|
||||
},
|
||||
pairing: {
|
||||
hint: "Dieses Gerät benötigt eine Pairing-Freigabe vom Gateway-Host.",
|
||||
mobileHint:
|
||||
"Auf dem Mobilgerät? Kopieren Sie die vollständige URL (einschließlich #token=...) von openclaw dashboard --no-open auf Ihrem Desktop.",
|
||||
},
|
||||
insecure: {
|
||||
hint: "Diese Seite ist HTTP, daher blockiert der Browser die Geräteidentifikation. Verwenden Sie HTTPS (Tailscale Serve) oder öffnen Sie {url} auf dem Gateway-Host.",
|
||||
stayHttp: "Wenn Sie bei HTTP bleiben müssen, setzen Sie {config} (nur Token).",
|
||||
},
|
||||
},
|
||||
chat: {
|
||||
disconnected: "Verbindung zum Gateway getrennt.",
|
||||
refreshTitle: "Chat-Daten aktualisieren",
|
||||
thinkingToggle: "Ausgabe des Assistenten ein-/ausblenden",
|
||||
focusToggle: "Fokusmodus ein-/ausschalten (Seitenleiste + Kopfzeile ausblenden)",
|
||||
onboardingDisabled: "Während der Einrichtung deaktiviert",
|
||||
},
|
||||
languages: {
|
||||
en: "English",
|
||||
zhCN: "简体中文 (Vereinfachtes Chinesisch)",
|
||||
zhTW: "繁體中文 (Traditionelles Chinesisch)",
|
||||
ptBR: "Português (Brasilianisches Portugiesisch)",
|
||||
de: "Deutsch",
|
||||
},
|
||||
};
|
||||
123
openclaw/ui/src/i18n/locales/en.ts
Normal file
123
openclaw/ui/src/i18n/locales/en.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import type { TranslationMap } from "../lib/types.ts";
|
||||
|
||||
export const en: TranslationMap = {
|
||||
common: {
|
||||
version: "Version",
|
||||
health: "Health",
|
||||
ok: "OK",
|
||||
offline: "Offline",
|
||||
connect: "Connect",
|
||||
refresh: "Refresh",
|
||||
enabled: "Enabled",
|
||||
disabled: "Disabled",
|
||||
na: "n/a",
|
||||
docs: "Docs",
|
||||
resources: "Resources",
|
||||
},
|
||||
nav: {
|
||||
chat: "Chat",
|
||||
control: "Control",
|
||||
agent: "Agent",
|
||||
settings: "Settings",
|
||||
expand: "Expand sidebar",
|
||||
collapse: "Collapse sidebar",
|
||||
},
|
||||
tabs: {
|
||||
agents: "Agents",
|
||||
overview: "Overview",
|
||||
channels: "Channels",
|
||||
instances: "Instances",
|
||||
sessions: "Sessions",
|
||||
usage: "Usage",
|
||||
cron: "Cron Jobs",
|
||||
skills: "Skills",
|
||||
nodes: "Nodes",
|
||||
chat: "Chat",
|
||||
config: "Config",
|
||||
debug: "Debug",
|
||||
logs: "Logs",
|
||||
},
|
||||
subtitles: {
|
||||
agents: "Manage agent workspaces, tools, and identities.",
|
||||
overview: "Gateway status, entry points, and a fast health read.",
|
||||
channels: "Manage channels and settings.",
|
||||
instances: "Presence beacons from connected clients and nodes.",
|
||||
sessions: "Inspect active sessions and adjust per-session defaults.",
|
||||
usage: "Monitor API usage and costs.",
|
||||
cron: "Schedule wakeups and recurring agent runs.",
|
||||
skills: "Manage skill availability and API key injection.",
|
||||
nodes: "Paired devices, capabilities, and command exposure.",
|
||||
chat: "Direct gateway chat session for quick interventions.",
|
||||
config: "Edit ~/.openclaw/openclaw.json safely.",
|
||||
debug: "Gateway snapshots, events, and manual RPC calls.",
|
||||
logs: "Live tail of the gateway file logs.",
|
||||
},
|
||||
overview: {
|
||||
access: {
|
||||
title: "Gateway Access",
|
||||
subtitle: "Where the dashboard connects and how it authenticates.",
|
||||
wsUrl: "WebSocket URL",
|
||||
token: "Gateway Token",
|
||||
password: "Password (not stored)",
|
||||
sessionKey: "Default Session Key",
|
||||
language: "Language",
|
||||
connectHint: "Click Connect to apply connection changes.",
|
||||
trustedProxy: "Authenticated via trusted proxy.",
|
||||
},
|
||||
snapshot: {
|
||||
title: "Snapshot",
|
||||
subtitle: "Latest gateway handshake information.",
|
||||
status: "Status",
|
||||
uptime: "Uptime",
|
||||
tickInterval: "Tick Interval",
|
||||
lastChannelsRefresh: "Last Channels Refresh",
|
||||
channelsHint: "Use Channels to link WhatsApp, Telegram, Discord, Signal, or iMessage.",
|
||||
},
|
||||
stats: {
|
||||
instances: "Instances",
|
||||
instancesHint: "Presence beacons in the last 5 minutes.",
|
||||
sessions: "Sessions",
|
||||
sessionsHint: "Recent session keys tracked by the gateway.",
|
||||
cron: "Cron",
|
||||
cronNext: "Next wake {time}",
|
||||
},
|
||||
notes: {
|
||||
title: "Notes",
|
||||
subtitle: "Quick reminders for remote control setups.",
|
||||
tailscaleTitle: "Tailscale serve",
|
||||
tailscaleText: "Prefer serve mode to keep the gateway on loopback with tailnet auth.",
|
||||
sessionTitle: "Session hygiene",
|
||||
sessionText: "Use /new or sessions.patch to reset context.",
|
||||
cronTitle: "Cron reminders",
|
||||
cronText: "Use isolated sessions for recurring runs.",
|
||||
},
|
||||
auth: {
|
||||
required: "This gateway requires auth. Add a token or password, then click Connect.",
|
||||
failed:
|
||||
"Auth failed. Re-copy a tokenized URL with {command}, or update the token, then click Connect.",
|
||||
},
|
||||
pairing: {
|
||||
hint: "This device needs pairing approval from the gateway host.",
|
||||
mobileHint:
|
||||
"On mobile? Copy the full URL (including #token=...) from openclaw dashboard --no-open on your desktop.",
|
||||
},
|
||||
insecure: {
|
||||
hint: "This page is HTTP, so the browser blocks device identity. Use HTTPS (Tailscale Serve) or open {url} on the gateway host.",
|
||||
stayHttp: "If you must stay on HTTP, set {config} (token-only).",
|
||||
},
|
||||
},
|
||||
chat: {
|
||||
disconnected: "Disconnected from gateway.",
|
||||
refreshTitle: "Refresh chat data",
|
||||
thinkingToggle: "Toggle assistant thinking/working output",
|
||||
focusToggle: "Toggle focus mode (hide sidebar + page header)",
|
||||
onboardingDisabled: "Disabled during onboarding",
|
||||
},
|
||||
languages: {
|
||||
en: "English",
|
||||
zhCN: "简体中文 (Simplified Chinese)",
|
||||
zhTW: "繁體中文 (Traditional Chinese)",
|
||||
ptBR: "Português (Brazilian Portuguese)",
|
||||
de: "Deutsch (German)",
|
||||
},
|
||||
};
|
||||
125
openclaw/ui/src/i18n/locales/pt-BR.ts
Normal file
125
openclaw/ui/src/i18n/locales/pt-BR.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import type { TranslationMap } from "../lib/types.ts";
|
||||
|
||||
export const pt_BR: TranslationMap = {
|
||||
common: {
|
||||
version: "Versão",
|
||||
health: "Saúde",
|
||||
ok: "OK",
|
||||
offline: "Offline",
|
||||
connect: "Conectar",
|
||||
refresh: "Atualizar",
|
||||
enabled: "Ativado",
|
||||
disabled: "Desativado",
|
||||
na: "n/a",
|
||||
docs: "Docs",
|
||||
resources: "Recursos",
|
||||
},
|
||||
nav: {
|
||||
chat: "Chat",
|
||||
control: "Controle",
|
||||
agent: "Agente",
|
||||
settings: "Configurações",
|
||||
expand: "Expandir barra lateral",
|
||||
collapse: "Recolher barra lateral",
|
||||
},
|
||||
tabs: {
|
||||
agents: "Agentes",
|
||||
overview: "Visão Geral",
|
||||
channels: "Canais",
|
||||
instances: "Instâncias",
|
||||
sessions: "Sessões",
|
||||
usage: "Uso",
|
||||
cron: "Tarefas Cron",
|
||||
skills: "Habilidades",
|
||||
nodes: "Nós",
|
||||
chat: "Chat",
|
||||
config: "Config",
|
||||
debug: "Debug",
|
||||
logs: "Logs",
|
||||
},
|
||||
subtitles: {
|
||||
agents: "Gerenciar espaços de trabalho, ferramentas e identidades de agentes.",
|
||||
overview: "Status do gateway, pontos de entrada e leitura rápida de saúde.",
|
||||
channels: "Gerenciar canais e configurações.",
|
||||
instances: "Beacons de presença de clientes e nós conectados.",
|
||||
sessions: "Inspecionar sessões ativas e ajustar padrões por sessão.",
|
||||
usage: "Monitorar uso e custos da API.",
|
||||
cron: "Agendar despertares e execuções recorrentes de agentes.",
|
||||
skills: "Gerenciar disponibilidade de habilidades e injeção de chaves de API.",
|
||||
nodes: "Dispositivos pareados, capacidades e exposição de comandos.",
|
||||
chat: "Sessão de chat direta com o gateway para intervenções rápidas.",
|
||||
config: "Editar ~/.openclaw/openclaw.json com segurança.",
|
||||
debug: "Snapshots do gateway, eventos e chamadas RPC manuais.",
|
||||
logs: "Acompanhamento ao vivo dos logs de arquivo do gateway.",
|
||||
},
|
||||
overview: {
|
||||
access: {
|
||||
title: "Acesso ao Gateway",
|
||||
subtitle: "Onde o dashboard se conecta e como ele se autentica.",
|
||||
wsUrl: "URL WebSocket",
|
||||
token: "Token do Gateway",
|
||||
password: "Senha (não armazenada)",
|
||||
sessionKey: "Chave de Sessão Padrão",
|
||||
language: "Idioma",
|
||||
connectHint: "Clique em Conectar para aplicar as alterações de conexão.",
|
||||
trustedProxy: "Autenticado por proxy confiável.",
|
||||
},
|
||||
snapshot: {
|
||||
title: "Snapshot",
|
||||
subtitle: "Informações mais recentes do handshake do gateway.",
|
||||
status: "Status",
|
||||
uptime: "Tempo de Atividade",
|
||||
tickInterval: "Intervalo de Tick",
|
||||
lastChannelsRefresh: "Última Atualização de Canais",
|
||||
channelsHint: "Use Canais para vincular WhatsApp, Telegram, Discord, Signal ou iMessage.",
|
||||
},
|
||||
stats: {
|
||||
instances: "Instâncias",
|
||||
instancesHint: "Beacons de presença nos últimos 5 minutos.",
|
||||
sessions: "Sessões",
|
||||
sessionsHint: "Chaves de sessão recentes rastreadas pelo gateway.",
|
||||
cron: "Cron",
|
||||
cronNext: "Próximo despertar {time}",
|
||||
},
|
||||
notes: {
|
||||
title: "Notas",
|
||||
subtitle: "Lembretes rápidos para configurações de controle remoto.",
|
||||
tailscaleTitle: "Tailscale serve",
|
||||
tailscaleText:
|
||||
"Prefira o modo serve para manter o gateway em loopback com autenticação tailnet.",
|
||||
sessionTitle: "Higiene de sessão",
|
||||
sessionText: "Use /new ou sessions.patch para redefinir o contexto.",
|
||||
cronTitle: "Lembretes de Cron",
|
||||
cronText: "Use sessões isoladas para execuções recorrentes.",
|
||||
},
|
||||
auth: {
|
||||
required:
|
||||
"Este gateway requer autenticação. Adicione um token ou senha e clique em Conectar.",
|
||||
failed:
|
||||
"Falha na autenticação. Recopie uma URL com token usando {command}, ou atualize o token e clique em Conectar.",
|
||||
},
|
||||
pairing: {
|
||||
hint: "Este dispositivo precisa de aprovação de pareamento do host do gateway.",
|
||||
mobileHint:
|
||||
"No celular? Copie a URL completa (incluindo #token=...) executando openclaw dashboard --no-open no desktop.",
|
||||
},
|
||||
insecure: {
|
||||
hint: "Esta página é HTTP, então o navegador bloqueia a identidade do dispositivo. Use HTTPS (Tailscale Serve) ou abra {url} no host do gateway.",
|
||||
stayHttp: "Se você precisar permanecer em HTTP, defina {config} (apenas token).",
|
||||
},
|
||||
},
|
||||
chat: {
|
||||
disconnected: "Desconectado do gateway.",
|
||||
refreshTitle: "Atualizar dados do chat",
|
||||
thinkingToggle: "Alternar saída de pensamento/trabalho do assistente",
|
||||
focusToggle: "Alternar modo de foco (ocultar barra lateral + cabeçalho da página)",
|
||||
onboardingDisabled: "Desativado durante a integração",
|
||||
},
|
||||
languages: {
|
||||
en: "English",
|
||||
zhCN: "简体中文 (Chinês Simplificado)",
|
||||
zhTW: "繁體中文 (Chinês Tradicional)",
|
||||
ptBR: "Português (Português Brasileiro)",
|
||||
de: "Deutsch (Alemão)",
|
||||
},
|
||||
};
|
||||
122
openclaw/ui/src/i18n/locales/zh-CN.ts
Normal file
122
openclaw/ui/src/i18n/locales/zh-CN.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import type { TranslationMap } from "../lib/types.ts";
|
||||
|
||||
export const zh_CN: TranslationMap = {
|
||||
common: {
|
||||
version: "版本",
|
||||
health: "健康状况",
|
||||
ok: "正常",
|
||||
offline: "离线",
|
||||
connect: "连接",
|
||||
refresh: "刷新",
|
||||
enabled: "已启用",
|
||||
disabled: "已禁用",
|
||||
na: "不适用",
|
||||
docs: "文档",
|
||||
resources: "资源",
|
||||
},
|
||||
nav: {
|
||||
chat: "聊天",
|
||||
control: "控制",
|
||||
agent: "代理",
|
||||
settings: "设置",
|
||||
expand: "展开侧边栏",
|
||||
collapse: "折叠侧边栏",
|
||||
},
|
||||
tabs: {
|
||||
agents: "代理",
|
||||
overview: "概览",
|
||||
channels: "频道",
|
||||
instances: "实例",
|
||||
sessions: "会话",
|
||||
usage: "使用情况",
|
||||
cron: "定时任务",
|
||||
skills: "技能",
|
||||
nodes: "节点",
|
||||
chat: "聊天",
|
||||
config: "配置",
|
||||
debug: "调试",
|
||||
logs: "日志",
|
||||
},
|
||||
subtitles: {
|
||||
agents: "管理代理工作区、工具和身份。",
|
||||
overview: "网关状态、入口点和快速健康读取。",
|
||||
channels: "管理频道和设置。",
|
||||
instances: "来自已连接客户端和节点的在线信号。",
|
||||
sessions: "检查活动会话并调整每个会话的默认设置。",
|
||||
usage: "监控 API 使用情况和成本。",
|
||||
cron: "安排唤醒和重复的代理运行。",
|
||||
skills: "管理技能可用性和 API 密钥注入。",
|
||||
nodes: "配对设备、功能和命令公开。",
|
||||
chat: "用于快速干预的直接网关聊天会话。",
|
||||
config: "安全地编辑 ~/.openclaw/openclaw.json。",
|
||||
debug: "网关快照、事件和手动 RPC 调用。",
|
||||
logs: "网关文件日志的实时追踪。",
|
||||
},
|
||||
overview: {
|
||||
access: {
|
||||
title: "网关访问",
|
||||
subtitle: "仪表板连接的位置及其身份验证方式。",
|
||||
wsUrl: "WebSocket URL",
|
||||
token: "网关令牌",
|
||||
password: "密码 (不存储)",
|
||||
sessionKey: "默认会话密钥",
|
||||
language: "语言",
|
||||
connectHint: "点击连接以应用连接更改。",
|
||||
trustedProxy: "通过受信任代理认证。",
|
||||
},
|
||||
snapshot: {
|
||||
title: "快照",
|
||||
subtitle: "最新的网关握手信息。",
|
||||
status: "状态",
|
||||
uptime: "运行时间",
|
||||
tickInterval: "刻度间隔",
|
||||
lastChannelsRefresh: "最后频道刷新",
|
||||
channelsHint: "使用频道链接 WhatsApp、Telegram、Discord、Signal 或 iMessage。",
|
||||
},
|
||||
stats: {
|
||||
instances: "实例",
|
||||
instancesHint: "过去 5 分钟内的在线信号。",
|
||||
sessions: "会话",
|
||||
sessionsHint: "网关跟踪的最近会话密钥。",
|
||||
cron: "定时任务",
|
||||
cronNext: "下次唤醒 {time}",
|
||||
},
|
||||
notes: {
|
||||
title: "备注",
|
||||
subtitle: "远程控制设置的快速提醒。",
|
||||
tailscaleTitle: "Tailscale serve",
|
||||
tailscaleText: "首选 serve 模式以通过 tailnet 身份验证将网关保持在回环地址。",
|
||||
sessionTitle: "会话清理",
|
||||
sessionText: "使用 /new 或 sessions.patch 重置上下文。",
|
||||
cronTitle: "定时任务提醒",
|
||||
cronText: "为重复运行使用隔离的会话。",
|
||||
},
|
||||
auth: {
|
||||
required: "此网关需要身份验证。添加令牌或密码,然后点击连接。",
|
||||
failed: "身份验证失败。请使用 {command} 重新复制令牌化 URL,或更新令牌,然后点击连接。",
|
||||
},
|
||||
pairing: {
|
||||
hint: "此设备需要网关主机的配对批准。",
|
||||
mobileHint:
|
||||
"在手机上?从桌面运行 openclaw dashboard --no-open 复制完整 URL(包括 #token=...)。",
|
||||
},
|
||||
insecure: {
|
||||
hint: "此页面为 HTTP,因此浏览器阻止设备标识。请使用 HTTPS (Tailscale Serve) 或在网关主机上打开 {url}。",
|
||||
stayHttp: "如果您必须保持 HTTP,请设置 {config} (仅限令牌)。",
|
||||
},
|
||||
},
|
||||
chat: {
|
||||
disconnected: "已断开与网关的连接。",
|
||||
refreshTitle: "刷新聊天数据",
|
||||
thinkingToggle: "切换助手思考/工作输出",
|
||||
focusToggle: "切换专注模式 (隐藏侧边栏 + 页面页眉)",
|
||||
onboardingDisabled: "引导期间禁用",
|
||||
},
|
||||
languages: {
|
||||
en: "English",
|
||||
zhCN: "简体中文 (简体中文)",
|
||||
zhTW: "繁體中文 (繁体中文)",
|
||||
ptBR: "Português (巴西葡萄牙语)",
|
||||
de: "Deutsch (德语)",
|
||||
},
|
||||
};
|
||||
122
openclaw/ui/src/i18n/locales/zh-TW.ts
Normal file
122
openclaw/ui/src/i18n/locales/zh-TW.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import type { TranslationMap } from "../lib/types.ts";
|
||||
|
||||
export const zh_TW: TranslationMap = {
|
||||
common: {
|
||||
version: "版本",
|
||||
health: "健康狀況",
|
||||
ok: "正常",
|
||||
offline: "離線",
|
||||
connect: "連接",
|
||||
refresh: "刷新",
|
||||
enabled: "已啟用",
|
||||
disabled: "已禁用",
|
||||
na: "不適用",
|
||||
docs: "文檔",
|
||||
resources: "資源",
|
||||
},
|
||||
nav: {
|
||||
chat: "聊天",
|
||||
control: "控制",
|
||||
agent: "代理",
|
||||
settings: "設置",
|
||||
expand: "展開側邊欄",
|
||||
collapse: "折疊側邊欄",
|
||||
},
|
||||
tabs: {
|
||||
agents: "代理",
|
||||
overview: "概覽",
|
||||
channels: "頻道",
|
||||
instances: "實例",
|
||||
sessions: "會話",
|
||||
usage: "使用情況",
|
||||
cron: "定時任務",
|
||||
skills: "技能",
|
||||
nodes: "節點",
|
||||
chat: "聊天",
|
||||
config: "配置",
|
||||
debug: "調試",
|
||||
logs: "日誌",
|
||||
},
|
||||
subtitles: {
|
||||
agents: "管理代理工作區、工具和身份。",
|
||||
overview: "網關狀態、入口點和快速健康讀取。",
|
||||
channels: "管理頻道和設置。",
|
||||
instances: "來自已連接客戶端和節點的在線信號。",
|
||||
sessions: "檢查活動會話並調整每個會話的默認設置。",
|
||||
usage: "監控 API 使用情況和成本。",
|
||||
cron: "安排喚醒和重複的代理運行。",
|
||||
skills: "管理技能可用性和 API 密鑰注入。",
|
||||
nodes: "配對設備、功能和命令公開。",
|
||||
chat: "用於快速干預的直接網關聊天會話。",
|
||||
config: "安全地編輯 ~/.openclaw/openclaw.json。",
|
||||
debug: "網關快照、事件和手動 RPC 調用。",
|
||||
logs: "網關文件日志的實時追蹤。",
|
||||
},
|
||||
overview: {
|
||||
access: {
|
||||
title: "網關訪問",
|
||||
subtitle: "儀表板連接的位置及其身份驗證方式。",
|
||||
wsUrl: "WebSocket URL",
|
||||
token: "網關令牌",
|
||||
password: "密碼 (不存儲)",
|
||||
sessionKey: "默認會話密鑰",
|
||||
language: "語言",
|
||||
connectHint: "點擊連接以應用連接更改。",
|
||||
trustedProxy: "通過受信任代理身份驗證。",
|
||||
},
|
||||
snapshot: {
|
||||
title: "快照",
|
||||
subtitle: "最新的網關握手信息。",
|
||||
status: "狀態",
|
||||
uptime: "運行時間",
|
||||
tickInterval: "刻度間隔",
|
||||
lastChannelsRefresh: "最後頻道刷新",
|
||||
channelsHint: "使用頻道鏈接 WhatsApp、Telegram、Discord、Signal 或 iMessage。",
|
||||
},
|
||||
stats: {
|
||||
instances: "實例",
|
||||
instancesHint: "過去 5 分鐘內的在線信號。",
|
||||
sessions: "會話",
|
||||
sessionsHint: "網關跟蹤的最近會話密鑰。",
|
||||
cron: "定時任務",
|
||||
cronNext: "下次喚醒 {time}",
|
||||
},
|
||||
notes: {
|
||||
title: "備註",
|
||||
subtitle: "遠程控制設置的快速提醒。",
|
||||
tailscaleTitle: "Tailscale serve",
|
||||
tailscaleText: "首選 serve 模式以通過 tailnet 身份驗證將網關保持在回環地址。",
|
||||
sessionTitle: "會話清理",
|
||||
sessionText: "使用 /new 或 sessions.patch 重置上下文。",
|
||||
cronTitle: "定時任務提醒",
|
||||
cronText: "為重複運行使用隔離的會話。",
|
||||
},
|
||||
auth: {
|
||||
required: "此網關需要身份驗證。添加令牌或密碼,然後點擊連接。",
|
||||
failed: "身份驗證失敗。請使用 {command} 重新複製令牌化 URL,或更新令牌,然後點擊連接。",
|
||||
},
|
||||
pairing: {
|
||||
hint: "此裝置需要閘道主機的配對批准。",
|
||||
mobileHint:
|
||||
"在手機上?從桌面執行 openclaw dashboard --no-open 複製完整 URL(包括 #token=...)。",
|
||||
},
|
||||
insecure: {
|
||||
hint: "此頁面為 HTTP,因此瀏覽器阻止設備標識。請使用 HTTPS (Tailscale Serve) 或在網關主機上打開 {url}。",
|
||||
stayHttp: "如果您必須保持 HTTP,請設置 {config} (僅限令牌)。",
|
||||
},
|
||||
},
|
||||
chat: {
|
||||
disconnected: "已斷開與網關的連接。",
|
||||
refreshTitle: "刷新聊天數據",
|
||||
thinkingToggle: "切換助手思考/工作輸出",
|
||||
focusToggle: "切換專注模式 (隱藏側邊欄 + 頁面頁眉)",
|
||||
onboardingDisabled: "引導期間禁用",
|
||||
},
|
||||
languages: {
|
||||
en: "English",
|
||||
zhCN: "简体中文 (簡體中文)",
|
||||
zhTW: "繁體中文 (繁體中文)",
|
||||
ptBR: "Português (巴西葡萄牙語)",
|
||||
de: "Deutsch (德語)",
|
||||
},
|
||||
};
|
||||
56
openclaw/ui/src/i18n/test/translate.test.ts
Normal file
56
openclaw/ui/src/i18n/test/translate.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { i18n, t } from "../lib/translate.ts";
|
||||
|
||||
describe("i18n", () => {
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
// Reset to English
|
||||
await i18n.setLocale("en");
|
||||
});
|
||||
|
||||
it("should return the key if translation is missing", () => {
|
||||
expect(t("non.existent.key")).toBe("non.existent.key");
|
||||
});
|
||||
|
||||
it("should return the correct English translation", () => {
|
||||
expect(t("common.health")).toBe("Health");
|
||||
});
|
||||
|
||||
it("should replace parameters correctly", () => {
|
||||
expect(t("overview.stats.cronNext", { time: "10:00" })).toBe("Next wake 10:00");
|
||||
});
|
||||
|
||||
it("should fallback to English if key is missing in another locale", async () => {
|
||||
// We haven't registered other locales in the test environment yet,
|
||||
// but the logic should fallback to 'en' map which is always there.
|
||||
await i18n.setLocale("zh-CN");
|
||||
// Since we don't mock the import, it might fail to load zh-CN,
|
||||
// but let's assume it falls back to English for now.
|
||||
expect(t("common.health")).toBeDefined();
|
||||
});
|
||||
|
||||
it("loads translations even when setting the same locale again", async () => {
|
||||
const internal = i18n as unknown as {
|
||||
locale: string;
|
||||
translations: Record<string, unknown>;
|
||||
};
|
||||
internal.locale = "zh-CN";
|
||||
delete internal.translations["zh-CN"];
|
||||
|
||||
await i18n.setLocale("zh-CN");
|
||||
expect(t("common.health")).toBe("健康状况");
|
||||
});
|
||||
|
||||
it("loads saved non-English locale on startup", async () => {
|
||||
localStorage.setItem("openclaw.i18n.locale", "zh-CN");
|
||||
vi.resetModules();
|
||||
const fresh = await import("../lib/translate.ts");
|
||||
|
||||
for (let index = 0; index < 5 && fresh.i18n.getLocale() !== "zh-CN"; index += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
expect(fresh.i18n.getLocale()).toBe("zh-CN");
|
||||
expect(fresh.t("common.health")).toBe("健康状况");
|
||||
});
|
||||
});
|
||||
2
openclaw/ui/src/main.ts
Normal file
2
openclaw/ui/src/main.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import "./styles.css";
|
||||
import "./ui/app.ts";
|
||||
5
openclaw/ui/src/styles.css
Normal file
5
openclaw/ui/src/styles.css
Normal file
@@ -0,0 +1,5 @@
|
||||
@import "./styles/base.css";
|
||||
@import "./styles/layout.css";
|
||||
@import "./styles/layout.mobile.css";
|
||||
@import "./styles/components.css";
|
||||
@import "./styles/config.css";
|
||||
385
openclaw/ui/src/styles/base.css
Normal file
385
openclaw/ui/src/styles/base.css
Normal file
@@ -0,0 +1,385 @@
|
||||
:root {
|
||||
/* Background - Warmer dark with depth */
|
||||
--bg: #12141a;
|
||||
--bg-accent: #14161d;
|
||||
--bg-elevated: #1a1d25;
|
||||
--bg-hover: #262a35;
|
||||
--bg-muted: #262a35;
|
||||
|
||||
/* Card / Surface - More contrast between levels */
|
||||
--card: #181b22;
|
||||
--card-foreground: #f4f4f5;
|
||||
--card-highlight: rgba(255, 255, 255, 0.05);
|
||||
--popover: #181b22;
|
||||
--popover-foreground: #f4f4f5;
|
||||
|
||||
/* Panel */
|
||||
--panel: #12141a;
|
||||
--panel-strong: #1a1d25;
|
||||
--panel-hover: #262a35;
|
||||
--chrome: rgba(18, 20, 26, 0.95);
|
||||
--chrome-strong: rgba(18, 20, 26, 0.98);
|
||||
|
||||
/* Text - Slightly warmer */
|
||||
--text: #e4e4e7;
|
||||
--text-strong: #fafafa;
|
||||
--chat-text: #e4e4e7;
|
||||
--muted: #71717a;
|
||||
--muted-strong: #52525b;
|
||||
--muted-foreground: #71717a;
|
||||
|
||||
/* Border - Subtle but defined */
|
||||
--border: #27272a;
|
||||
--border-strong: #3f3f46;
|
||||
--border-hover: #52525b;
|
||||
--input: #27272a;
|
||||
--ring: #ff5c5c;
|
||||
|
||||
/* Accent - Punchy signature red */
|
||||
--accent: #ff5c5c;
|
||||
--accent-hover: #ff7070;
|
||||
--accent-muted: #ff5c5c;
|
||||
--accent-subtle: rgba(255, 92, 92, 0.15);
|
||||
--accent-foreground: #fafafa;
|
||||
--accent-glow: rgba(255, 92, 92, 0.25);
|
||||
--primary: #ff5c5c;
|
||||
--primary-foreground: #ffffff;
|
||||
|
||||
/* Secondary - Teal accent for variety */
|
||||
--secondary: #1e2028;
|
||||
--secondary-foreground: #f4f4f5;
|
||||
--accent-2: #14b8a6;
|
||||
--accent-2-muted: rgba(20, 184, 166, 0.7);
|
||||
--accent-2-subtle: rgba(20, 184, 166, 0.15);
|
||||
|
||||
/* Semantic - More saturated */
|
||||
--ok: #22c55e;
|
||||
--ok-muted: rgba(34, 197, 94, 0.75);
|
||||
--ok-subtle: rgba(34, 197, 94, 0.12);
|
||||
--destructive: #ef4444;
|
||||
--destructive-foreground: #fafafa;
|
||||
--warn: #f59e0b;
|
||||
--warn-muted: rgba(245, 158, 11, 0.75);
|
||||
--warn-subtle: rgba(245, 158, 11, 0.12);
|
||||
--danger: #ef4444;
|
||||
--danger-muted: rgba(239, 68, 68, 0.75);
|
||||
--danger-subtle: rgba(239, 68, 68, 0.12);
|
||||
--info: #3b82f6;
|
||||
|
||||
/* Focus - With glow */
|
||||
--focus: rgba(255, 92, 92, 0.25);
|
||||
--focus-ring: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring);
|
||||
--focus-glow: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring), 0 0 20px var(--accent-glow);
|
||||
|
||||
/* Grid */
|
||||
--grid-line: rgba(255, 255, 255, 0.04);
|
||||
|
||||
/* Theme transition */
|
||||
--theme-switch-x: 50%;
|
||||
--theme-switch-y: 50%;
|
||||
|
||||
/* Typography */
|
||||
--mono:
|
||||
"JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace;
|
||||
--font-body: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
--font-display: var(--font-body);
|
||||
|
||||
/* Shadows - Richer with subtle color */
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.25), 0 0 0 1px rgba(255, 255, 255, 0.03);
|
||||
--shadow-lg: 0 12px 28px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(255, 255, 255, 0.03);
|
||||
--shadow-xl: 0 24px 48px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.03);
|
||||
--shadow-glow: 0 0 30px var(--accent-glow);
|
||||
|
||||
/* Radii - Slightly larger for friendlier feel */
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--radius-xl: 16px;
|
||||
--radius-full: 9999px;
|
||||
--radius: 8px;
|
||||
|
||||
/* Transitions - Snappy but smooth */
|
||||
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
--duration-fast: 120ms;
|
||||
--duration-normal: 200ms;
|
||||
--duration-slow: 350ms;
|
||||
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* Light theme - Clean with subtle warmth */
|
||||
:root[data-theme="light"] {
|
||||
--bg: #fafafa;
|
||||
--bg-accent: #f5f5f5;
|
||||
--bg-elevated: #ffffff;
|
||||
--bg-hover: #f0f0f0;
|
||||
--bg-muted: #f0f0f0;
|
||||
--bg-content: #f5f5f5;
|
||||
|
||||
--card: #ffffff;
|
||||
--card-foreground: #18181b;
|
||||
--card-highlight: rgba(0, 0, 0, 0.03);
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #18181b;
|
||||
|
||||
--panel: #fafafa;
|
||||
--panel-strong: #f5f5f5;
|
||||
--panel-hover: #ebebeb;
|
||||
--chrome: rgba(250, 250, 250, 0.95);
|
||||
--chrome-strong: rgba(250, 250, 250, 0.98);
|
||||
|
||||
--text: #3f3f46;
|
||||
--text-strong: #18181b;
|
||||
--chat-text: #3f3f46;
|
||||
--muted: #71717a;
|
||||
--muted-strong: #52525b;
|
||||
--muted-foreground: #71717a;
|
||||
|
||||
--border: #e4e4e7;
|
||||
--border-strong: #d4d4d8;
|
||||
--border-hover: #a1a1aa;
|
||||
--input: #e4e4e7;
|
||||
|
||||
--accent: #dc2626;
|
||||
--accent-hover: #ef4444;
|
||||
--accent-muted: #dc2626;
|
||||
--accent-subtle: rgba(220, 38, 38, 0.12);
|
||||
--accent-foreground: #ffffff;
|
||||
--accent-glow: rgba(220, 38, 38, 0.15);
|
||||
--primary: #dc2626;
|
||||
--primary-foreground: #ffffff;
|
||||
|
||||
--secondary: #f4f4f5;
|
||||
--secondary-foreground: #3f3f46;
|
||||
--accent-2: #0d9488;
|
||||
--accent-2-muted: rgba(13, 148, 136, 0.75);
|
||||
--accent-2-subtle: rgba(13, 148, 136, 0.12);
|
||||
|
||||
--ok: #16a34a;
|
||||
--ok-muted: rgba(22, 163, 74, 0.75);
|
||||
--ok-subtle: rgba(22, 163, 74, 0.1);
|
||||
--destructive: #dc2626;
|
||||
--destructive-foreground: #fafafa;
|
||||
--warn: #d97706;
|
||||
--warn-muted: rgba(217, 119, 6, 0.75);
|
||||
--warn-subtle: rgba(217, 119, 6, 0.1);
|
||||
--danger: #dc2626;
|
||||
--danger-muted: rgba(220, 38, 38, 0.75);
|
||||
--danger-subtle: rgba(220, 38, 38, 0.1);
|
||||
--info: #2563eb;
|
||||
|
||||
--focus: rgba(220, 38, 38, 0.2);
|
||||
--focus-glow: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring), 0 0 16px var(--accent-glow);
|
||||
|
||||
--grid-line: rgba(0, 0, 0, 0.05);
|
||||
|
||||
/* Light shadows */
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(0, 0, 0, 0.04);
|
||||
--shadow-lg: 0 12px 28px rgba(0, 0, 0, 0.12), 0 0 0 1px rgba(0, 0, 0, 0.04);
|
||||
--shadow-xl: 0 24px 48px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(0, 0, 0, 0.04);
|
||||
--shadow-glow: 0 0 24px var(--accent-glow);
|
||||
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font: 400 14px/1.55 var(--font-body);
|
||||
letter-spacing: -0.02em;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Theme transition */
|
||||
@keyframes theme-circle-transition {
|
||||
0% {
|
||||
clip-path: circle(0% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%));
|
||||
}
|
||||
100% {
|
||||
clip-path: circle(150% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%));
|
||||
}
|
||||
}
|
||||
|
||||
html.theme-transition {
|
||||
view-transition-name: theme;
|
||||
}
|
||||
|
||||
html.theme-transition::view-transition-old(theme) {
|
||||
mix-blend-mode: normal;
|
||||
animation: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
html.theme-transition::view-transition-new(theme) {
|
||||
mix-blend-mode: normal;
|
||||
z-index: 2;
|
||||
animation: theme-circle-transition 0.4s var(--ease-out) forwards;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html.theme-transition::view-transition-old(theme),
|
||||
html.theme-transition::view-transition-new(theme) {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
openclaw-app {
|
||||
display: block;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--accent-subtle);
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--border-strong);
|
||||
}
|
||||
|
||||
/* Animations - Polished with spring feel */
|
||||
@keyframes rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scale-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dashboard-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-subtle {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes glow-pulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 rgba(255, 92, 92, 0);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 20px var(--accent-glow);
|
||||
}
|
||||
}
|
||||
|
||||
/* Stagger animation delays for grouped elements */
|
||||
.stagger-1 {
|
||||
animation-delay: 0ms;
|
||||
}
|
||||
.stagger-2 {
|
||||
animation-delay: 50ms;
|
||||
}
|
||||
.stagger-3 {
|
||||
animation-delay: 100ms;
|
||||
}
|
||||
.stagger-4 {
|
||||
animation-delay: 150ms;
|
||||
}
|
||||
.stagger-5 {
|
||||
animation-delay: 200ms;
|
||||
}
|
||||
.stagger-6 {
|
||||
animation-delay: 250ms;
|
||||
}
|
||||
|
||||
/* Focus visible styles */
|
||||
:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
5
openclaw/ui/src/styles/chat.css
Normal file
5
openclaw/ui/src/styles/chat.css
Normal file
@@ -0,0 +1,5 @@
|
||||
@import "./chat/layout.css";
|
||||
@import "./chat/text.css";
|
||||
@import "./chat/grouped.css";
|
||||
@import "./chat/tool-cards.css";
|
||||
@import "./chat/sidebar.css";
|
||||
300
openclaw/ui/src/styles/chat/grouped.css
Normal file
300
openclaw/ui/src/styles/chat/grouped.css
Normal file
@@ -0,0 +1,300 @@
|
||||
/* =============================================
|
||||
GROUPED CHAT LAYOUT (Slack-style)
|
||||
============================================= */
|
||||
|
||||
/* Chat Group Layout - default (assistant/other on left) */
|
||||
.chat-group {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 16px;
|
||||
margin-left: 4px;
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
/* User messages on right */
|
||||
.chat-group.user {
|
||||
flex-direction: row-reverse;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.chat-group-messages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
max-width: min(900px, calc(100% - 60px));
|
||||
}
|
||||
|
||||
/* User messages align content right */
|
||||
.chat-group.user .chat-group-messages {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.chat-group.user .chat-group-footer {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Footer at bottom of message group (role + time) */
|
||||
.chat-group-footer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: baseline;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.chat-sender-name {
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.chat-group-timestamp {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Chat divider (e.g., compaction marker) */
|
||||
.chat-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 18px 8px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.chat-divider__line {
|
||||
flex: 1 1 0;
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.chat-divider__label {
|
||||
padding: 2px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
/* Avatar Styles */
|
||||
.chat-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
background: var(--panel-strong);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
align-self: flex-end; /* Align with last message in group */
|
||||
margin-bottom: 4px; /* Optical alignment */
|
||||
}
|
||||
|
||||
.chat-avatar.user {
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.chat-avatar.assistant {
|
||||
background: var(--secondary);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.chat-avatar.other {
|
||||
background: var(--secondary);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.chat-avatar.tool {
|
||||
background: var(--secondary);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Image avatar support */
|
||||
img.chat-avatar {
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
/* Minimal Bubble Design - dynamic width based on content */
|
||||
.chat-bubble {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
border: 1px solid transparent;
|
||||
background: var(--card);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 10px 14px;
|
||||
box-shadow: none;
|
||||
transition:
|
||||
background 150ms ease-out,
|
||||
border-color 150ms ease-out;
|
||||
max-width: 100%;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.chat-bubble.has-copy {
|
||||
padding-right: 36px;
|
||||
}
|
||||
|
||||
.chat-copy-btn {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
color: var(--muted);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 4px 6px;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 120ms ease-out,
|
||||
background 120ms ease-out;
|
||||
}
|
||||
|
||||
.chat-copy-btn__icon {
|
||||
display: inline-flex;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chat-copy-btn__icon svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
stroke-width: 1.5px;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.chat-copy-btn__icon-copy,
|
||||
.chat-copy-btn__icon-check {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
.chat-copy-btn__icon-check {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.chat-copy-btn[data-copied="1"] .chat-copy-btn__icon-copy {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.chat-copy-btn[data-copied="1"] .chat-copy-btn__icon-check {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.chat-bubble:hover .chat-copy-btn {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.chat-copy-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.chat-copy-btn[data-copying="1"] {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.chat-copy-btn[data-error="1"] {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
border-color: var(--danger-subtle);
|
||||
background: var(--danger-subtle);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.chat-copy-btn[data-copied="1"] {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
border-color: var(--ok-subtle);
|
||||
background: var(--ok-subtle);
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.chat-copy-btn:focus-visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.chat-copy-btn {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* Light mode: restore borders */
|
||||
:root[data-theme="light"] .chat-bubble {
|
||||
border-color: var(--border);
|
||||
box-shadow: inset 0 1px 0 var(--card-highlight);
|
||||
}
|
||||
|
||||
.chat-bubble:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* User bubbles have different styling */
|
||||
.chat-group.user .chat-bubble {
|
||||
background: var(--accent-subtle);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .chat-group.user .chat-bubble {
|
||||
border-color: rgba(234, 88, 12, 0.2);
|
||||
background: rgba(251, 146, 60, 0.12);
|
||||
}
|
||||
|
||||
.chat-group.user .chat-bubble:hover {
|
||||
background: rgba(255, 77, 77, 0.15);
|
||||
}
|
||||
|
||||
/* Streaming animation */
|
||||
.chat-bubble.streaming {
|
||||
animation: pulsing-border 1.5s ease-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulsing-border {
|
||||
0%,
|
||||
100% {
|
||||
border-color: var(--border);
|
||||
}
|
||||
50% {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
}
|
||||
|
||||
/* Fade-in animation for new messages */
|
||||
.chat-bubble.fade-in {
|
||||
animation: fade-in 200ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
481
openclaw/ui/src/styles/chat/layout.css
Normal file
481
openclaw/ui/src/styles/chat/layout.css
Normal file
@@ -0,0 +1,481 @@
|
||||
/* =============================================
|
||||
CHAT CARD LAYOUT - Flex container with sticky compose
|
||||
============================================= */
|
||||
|
||||
/* Main chat card - flex column layout, transparent background */
|
||||
.chat {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 0;
|
||||
height: 100%;
|
||||
min-height: 0; /* Allow flex shrinking */
|
||||
overflow: hidden;
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Chat header - fixed at top, transparent */
|
||||
.chat-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: nowrap;
|
||||
flex-shrink: 0;
|
||||
padding-bottom: 12px;
|
||||
margin-bottom: 12px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.chat-header__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-header__right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-session {
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
/* Chat thread - scrollable middle section, transparent */
|
||||
.chat-thread {
|
||||
flex: 1 1 0; /* Grow, shrink, and use 0 base for proper scrolling */
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 12px 4px;
|
||||
margin: 0 -4px;
|
||||
min-height: 0; /* Allow shrinking for flex scroll behavior */
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Focus mode exit button */
|
||||
.chat-focus-exit {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
z-index: 100;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
color: var(--muted);
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition:
|
||||
background 150ms ease-out,
|
||||
color 150ms ease-out,
|
||||
border-color 150ms ease-out;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.chat-focus-exit:hover {
|
||||
background: var(--panel-strong);
|
||||
color: var(--text);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.chat-focus-exit svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
stroke-width: 2px;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
/* New messages indicator - floating pill above compose */
|
||||
.chat-new-messages {
|
||||
align-self: center;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
margin: 8px auto;
|
||||
font-size: 13px;
|
||||
font-family: var(--font-body);
|
||||
color: var(--text);
|
||||
background: var(--panel-strong);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
z-index: 10;
|
||||
transition:
|
||||
background 150ms ease-out,
|
||||
border-color 150ms ease-out;
|
||||
}
|
||||
|
||||
.chat-new-messages:hover {
|
||||
background: var(--panel);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.chat-new-messages svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
stroke-width: 1.5px;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Chat compose - sticky at bottom */
|
||||
.chat-compose {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-top: auto; /* Push to bottom of flex container */
|
||||
padding: 12px 4px 4px;
|
||||
background: linear-gradient(to bottom, transparent, var(--bg) 20%);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Image attachments preview */
|
||||
.chat-attachments {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
background: var(--panel);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
align-self: flex-start; /* Don't stretch in flex column parent */
|
||||
}
|
||||
|
||||
.chat-attachment {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.chat-attachment__img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.chat-attachment__remove {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease-out;
|
||||
}
|
||||
|
||||
.chat-attachment:hover .chat-attachment__remove {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.chat-attachment__remove:hover {
|
||||
background: rgba(220, 38, 38, 0.9);
|
||||
}
|
||||
|
||||
.chat-attachment__remove svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
stroke-width: 2px;
|
||||
}
|
||||
|
||||
/* Light theme attachment overrides */
|
||||
:root[data-theme="light"] .chat-attachments {
|
||||
background: #f8fafc;
|
||||
border-color: rgba(16, 24, 40, 0.1);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .chat-attachment {
|
||||
border-color: rgba(16, 24, 40, 0.15);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .chat-attachment__remove {
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
/* Message images (sent images displayed in chat) */
|
||||
.chat-message-images {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.chat-message-image {
|
||||
max-width: 300px;
|
||||
max-height: 200px;
|
||||
border-radius: 8px;
|
||||
object-fit: contain;
|
||||
cursor: pointer;
|
||||
transition: transform 150ms ease-out;
|
||||
}
|
||||
|
||||
.chat-message-image:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
/* User message images align right */
|
||||
.chat-group.user .chat-message-images {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Compose input row - horizontal layout */
|
||||
.chat-compose__row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .chat-compose {
|
||||
background: linear-gradient(to bottom, transparent, var(--bg-content) 20%);
|
||||
}
|
||||
|
||||
.chat-compose__field {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
/* Hide the "Message" label - keep textarea only */
|
||||
.chat-compose__field > span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Override .field textarea min-height (180px) from components.css */
|
||||
.chat-compose .chat-compose__field textarea {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
min-height: 40px;
|
||||
max-height: 150px;
|
||||
padding: 9px 12px;
|
||||
border-radius: 8px;
|
||||
overflow-y: auto;
|
||||
resize: none;
|
||||
white-space: pre-wrap;
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.chat-compose__field textarea:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.chat-compose__actions {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-compose .chat-compose__actions .btn {
|
||||
padding: 0 16px;
|
||||
font-size: 13px;
|
||||
height: 40px;
|
||||
min-height: 40px;
|
||||
max-height: 40px;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Chat controls - moved to content-header area, left aligned */
|
||||
.chat-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.chat-controls__session {
|
||||
min-width: 140px;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.chat-controls__thinking {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Icon button style */
|
||||
.btn--icon {
|
||||
padding: 8px !important;
|
||||
min-width: 36px;
|
||||
height: 36px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
/* Controls separator */
|
||||
.chat-controls__separator {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-size: 18px;
|
||||
margin: 0 8px;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .chat-controls__separator {
|
||||
color: rgba(16, 24, 40, 0.3);
|
||||
}
|
||||
|
||||
.btn--icon:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
/* Light theme icon button overrides */
|
||||
:root[data-theme="light"] .btn--icon {
|
||||
background: #ffffff;
|
||||
border-color: var(--border);
|
||||
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.05);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .btn--icon:hover {
|
||||
background: #ffffff;
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Light theme icon button overrides */
|
||||
:root[data-theme="light"] .btn--icon {
|
||||
background: #ffffff;
|
||||
border-color: var(--border);
|
||||
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.05);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .btn--icon:hover {
|
||||
background: #ffffff;
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .chat-controls .btn--icon.active {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent);
|
||||
box-shadow: 0 0 0 1px var(--accent-subtle);
|
||||
}
|
||||
|
||||
.btn--icon svg {
|
||||
display: block;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
stroke-width: 1.5px;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.chat-controls__session select {
|
||||
padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.chat-controls__thinking {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
padding: 4px 10px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Light theme thinking indicator override */
|
||||
:root[data-theme="light"] .chat-controls__thinking {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-color: rgba(16, 24, 40, 0.15);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.chat-session {
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.chat-compose {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
/* Mobile: stack compose row vertically */
|
||||
.chat-compose__row {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Mobile: stack action buttons vertically */
|
||||
.chat-compose__actions {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Mobile: full-width buttons */
|
||||
.chat-compose .chat-compose__actions .btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chat-controls {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-controls__session {
|
||||
min-width: 120px;
|
||||
}
|
||||
}
|
||||
117
openclaw/ui/src/styles/chat/sidebar.css
Normal file
117
openclaw/ui/src/styles/chat/sidebar.css
Normal file
@@ -0,0 +1,117 @@
|
||||
/* Split View Layout */
|
||||
.chat-split-container {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.chat-main {
|
||||
min-width: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
/* Smooth transition when sidebar opens/closes */
|
||||
transition: flex 250ms ease-out;
|
||||
}
|
||||
|
||||
.chat-sidebar {
|
||||
flex: 1;
|
||||
min-width: 300px;
|
||||
border-left: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: slide-in 200ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes slide-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Sidebar Panel */
|
||||
.sidebar-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
/* Smaller close button for sidebar */
|
||||
.sidebar-header .btn {
|
||||
padding: 4px 8px;
|
||||
font-size: 14px;
|
||||
min-width: auto;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.sidebar-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.sidebar-markdown {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.sidebar-markdown pre {
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.sidebar-markdown code {
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Mobile: Full-screen modal */
|
||||
@media (max-width: 768px) {
|
||||
.chat-split-container--open {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.chat-split-container--open .chat-main {
|
||||
display: none; /* Hide chat on mobile when sidebar open */
|
||||
}
|
||||
|
||||
.chat-split-container--open .chat-sidebar {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border-left: none;
|
||||
}
|
||||
}
|
||||
144
openclaw/ui/src/styles/chat/text.css
Normal file
144
openclaw/ui/src/styles/chat/text.css
Normal file
@@ -0,0 +1,144 @@
|
||||
/* =============================================
|
||||
CHAT TEXT STYLING
|
||||
============================================= */
|
||||
|
||||
.chat-thinking {
|
||||
margin-bottom: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px dashed rgba(255, 255, 255, 0.18);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .chat-thinking {
|
||||
border-color: rgba(16, 24, 40, 0.25);
|
||||
background: rgba(16, 24, 40, 0.04);
|
||||
}
|
||||
|
||||
.chat-text {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.chat-text :where(p, ul, ol, pre, blockquote, table) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.chat-text :where(p + p, p + ul, p + ol, p + pre, p + blockquote) {
|
||||
margin-top: 0.75em;
|
||||
}
|
||||
|
||||
.chat-text :where(ul, ol) {
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
|
||||
.chat-text :where(li + li) {
|
||||
margin-top: 0.25em;
|
||||
}
|
||||
|
||||
.chat-text :where(a) {
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.chat-text :where(a:hover) {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.chat-text :where(code) {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.chat-text :where(:not(pre) > code) {
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
padding: 0.15em 0.4em;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.chat-text :where(pre) {
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.chat-text :where(pre code) {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.chat-text :where(blockquote) {
|
||||
border-left: 3px solid var(--border-strong);
|
||||
padding-left: 12px;
|
||||
margin-left: 0;
|
||||
color: var(--muted);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
padding: 8px 12px;
|
||||
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
||||
}
|
||||
|
||||
.chat-text :where(blockquote blockquote) {
|
||||
margin-top: 8px;
|
||||
border-left-color: var(--border-hover);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.chat-text :where(blockquote blockquote blockquote) {
|
||||
border-left-color: var(--muted-strong);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .chat-text :where(blockquote) {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .chat-text :where(blockquote blockquote) {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .chat-text :where(blockquote blockquote blockquote) {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .chat-text :where(:not(pre) > code) {
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .chat-text :where(pre) {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.chat-text :where(hr) {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
/* =============================================
|
||||
RTL (Right-to-Left) SUPPORT
|
||||
============================================= */
|
||||
|
||||
.chat-text[dir="rtl"] {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.chat-text[dir="rtl"] :where(ul, ol) {
|
||||
padding-left: 0;
|
||||
padding-right: 1.5em;
|
||||
}
|
||||
|
||||
.chat-text[dir="rtl"] :where(blockquote) {
|
||||
border-left: none;
|
||||
border-right: 3px solid var(--border);
|
||||
padding-left: 0;
|
||||
padding-right: 1em;
|
||||
}
|
||||
202
openclaw/ui/src/styles/chat/tool-cards.css
Normal file
202
openclaw/ui/src/styles/chat/tool-cards.css
Normal file
@@ -0,0 +1,202 @@
|
||||
/* Tool Card Styles */
|
||||
.chat-tool-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-top: 8px;
|
||||
background: var(--card);
|
||||
box-shadow: inset 0 1px 0 var(--card-highlight);
|
||||
transition:
|
||||
border-color 150ms ease-out,
|
||||
background 150ms ease-out;
|
||||
/* Fixed max-height to ensure cards don't expand too much */
|
||||
max-height: 120px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-tool-card:hover {
|
||||
border-color: var(--border-strong);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* First tool card in a group - no top margin */
|
||||
.chat-tool-card:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.chat-tool-card--clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chat-tool-card--clickable:focus {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Header with title and chevron */
|
||||
.chat-tool-card__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-tool-card__title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.chat-tool-card__icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-tool-card__icon svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
stroke-width: 1.5px;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
/* "View >" action link */
|
||||
.chat-tool-card__action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
opacity: 0.8;
|
||||
transition: opacity 150ms ease-out;
|
||||
}
|
||||
|
||||
.chat-tool-card__action svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
stroke-width: 1.5px;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.chat-tool-card--clickable:hover .chat-tool-card__action {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Status indicator for completed/empty results */
|
||||
.chat-tool-card__status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.chat-tool-card__status svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
stroke-width: 2px;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.chat-tool-card__status-text {
|
||||
font-size: 11px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.chat-tool-card__detail {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Collapsed preview - fixed height with truncation */
|
||||
.chat-tool-card__preview {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
margin-top: 8px;
|
||||
padding: 8px 10px;
|
||||
background: var(--secondary);
|
||||
border-radius: var(--radius-md);
|
||||
white-space: pre-wrap;
|
||||
overflow: hidden;
|
||||
max-height: 44px;
|
||||
line-height: 1.4;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.chat-tool-card--clickable:hover .chat-tool-card__preview {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
/* Short inline output */
|
||||
.chat-tool-card__inline {
|
||||
font-size: 11px;
|
||||
color: var(--text);
|
||||
margin-top: 6px;
|
||||
padding: 6px 8px;
|
||||
background: var(--secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Reading Indicator */
|
||||
.chat-reading-indicator {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
padding: 12px;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.chat-reading-indicator__dots {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chat-reading-indicator__dots span {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--muted);
|
||||
animation: reading-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.chat-reading-indicator__dots span:nth-child(1) {
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.chat-reading-indicator__dots span:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.chat-reading-indicator__dots span:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
@keyframes reading-pulse {
|
||||
0%,
|
||||
60%,
|
||||
100% {
|
||||
opacity: 0.3;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
30% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
2592
openclaw/ui/src/styles/components.css
Normal file
2592
openclaw/ui/src/styles/components.css
Normal file
File diff suppressed because it is too large
Load Diff
1638
openclaw/ui/src/styles/config.css
Normal file
1638
openclaw/ui/src/styles/config.css
Normal file
File diff suppressed because it is too large
Load Diff
621
openclaw/ui/src/styles/layout.css
Normal file
621
openclaw/ui/src/styles/layout.css
Normal file
@@ -0,0 +1,621 @@
|
||||
/* ===========================================
|
||||
Shell Layout
|
||||
=========================================== */
|
||||
|
||||
.shell {
|
||||
--shell-pad: 16px;
|
||||
--shell-gap: 16px;
|
||||
--shell-nav-width: 220px;
|
||||
--shell-topbar-height: 56px;
|
||||
--shell-focus-duration: 200ms;
|
||||
--shell-focus-ease: var(--ease-out);
|
||||
height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: var(--shell-nav-width) minmax(0, 1fr);
|
||||
grid-template-rows: var(--shell-topbar-height) 1fr;
|
||||
grid-template-areas:
|
||||
"topbar topbar"
|
||||
"nav content";
|
||||
gap: 0;
|
||||
animation: dashboard-enter 0.4s var(--ease-out);
|
||||
transition: grid-template-columns var(--shell-focus-duration) var(--shell-focus-ease);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@supports (height: 100dvh) {
|
||||
.shell {
|
||||
height: 100dvh;
|
||||
}
|
||||
}
|
||||
|
||||
.shell--chat {
|
||||
min-height: 100vh;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@supports (height: 100dvh) {
|
||||
.shell--chat {
|
||||
height: 100dvh;
|
||||
}
|
||||
}
|
||||
|
||||
.shell--nav-collapsed {
|
||||
grid-template-columns: 0px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.shell--chat-focus {
|
||||
grid-template-columns: 0px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.shell--onboarding {
|
||||
grid-template-rows: 0 1fr;
|
||||
}
|
||||
|
||||
.shell--onboarding .topbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.shell--onboarding .content {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.shell--chat-focus .content {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.shell--chat-focus .content > * + * {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
Topbar
|
||||
=========================================== */
|
||||
|
||||
.topbar {
|
||||
grid-area: topbar;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 0 20px;
|
||||
height: var(--shell-topbar-height);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.topbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.topbar .nav-collapse-toggle {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.topbar .nav-collapse-toggle__icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.topbar .nav-collapse-toggle__icon svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
/* Brand */
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.brand-logo img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 1.1;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.brand-sub {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Topbar status */
|
||||
.topbar-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.topbar-status .pill {
|
||||
padding: 6px 10px;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
height: 32px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.topbar-status .pill .mono {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
line-height: 1;
|
||||
margin-top: 0px;
|
||||
}
|
||||
|
||||
.topbar-status .statusDot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.topbar-status .theme-toggle {
|
||||
--theme-item: 24px;
|
||||
--theme-gap: 2px;
|
||||
--theme-pad: 3px;
|
||||
}
|
||||
|
||||
.topbar-status .theme-icon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
Navigation Sidebar
|
||||
=========================================== */
|
||||
|
||||
.nav {
|
||||
grid-area: nav;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 16px 12px;
|
||||
background: var(--bg);
|
||||
scrollbar-width: none; /* Firefox */
|
||||
transition:
|
||||
width var(--shell-focus-duration) var(--shell-focus-ease),
|
||||
padding var(--shell-focus-duration) var(--shell-focus-ease),
|
||||
opacity var(--shell-focus-duration) var(--shell-focus-ease);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.nav::-webkit-scrollbar {
|
||||
display: none; /* Chrome/Safari */
|
||||
}
|
||||
|
||||
.shell--chat-focus .nav {
|
||||
width: 0;
|
||||
padding: 0;
|
||||
border-width: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.nav--collapsed {
|
||||
width: 0;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Nav collapse toggle */
|
||||
.nav-collapse-toggle {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--duration-fast) ease,
|
||||
border-color var(--duration-fast) ease;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.nav-collapse-toggle:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.nav-collapse-toggle__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--muted);
|
||||
transition: color var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.nav-collapse-toggle__icon svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
stroke-width: 1.5px;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.nav-collapse-toggle:hover .nav-collapse-toggle__icon {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Nav groups */
|
||||
.nav-group {
|
||||
margin-bottom: 20px;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.nav-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.nav-group__items {
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.nav-group--collapsed .nav-group__items {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Nav label */
|
||||
.nav-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
margin-bottom: 4px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
border-radius: var(--radius-sm);
|
||||
transition:
|
||||
color var(--duration-fast) ease,
|
||||
background var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.nav-label:hover {
|
||||
color: var(--text);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.nav-label--static {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.nav-label--static:hover {
|
||||
color: var(--muted);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.nav-label__text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.nav-label__chevron {
|
||||
font-size: 10px;
|
||||
opacity: 0.5;
|
||||
transition: transform var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.nav-group--collapsed .nav-label__chevron {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
/* Nav items */
|
||||
.nav-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
border-color var(--duration-fast) ease,
|
||||
background var(--duration-fast) ease,
|
||||
color var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.nav-item__icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.7;
|
||||
transition: opacity var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.nav-item__icon svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
stroke-width: 1.5px;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.nav-item__text {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
color: var(--text);
|
||||
background: var(--bg-hover);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav-item:hover .nav-item__icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
color: var(--text-strong);
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
.nav-item.active .nav-item__icon {
|
||||
opacity: 1;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
Content Area
|
||||
=========================================== */
|
||||
|
||||
.content {
|
||||
grid-area: content;
|
||||
padding: 12px 16px 32px;
|
||||
display: block;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.content > * + * {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .content {
|
||||
background: var(--bg-content);
|
||||
}
|
||||
|
||||
.content--chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
overflow: hidden;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.content--chat > * + * {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Content header */
|
||||
.content-header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 4px 8px;
|
||||
overflow: hidden;
|
||||
transform-origin: top center;
|
||||
transition:
|
||||
opacity var(--shell-focus-duration) var(--shell-focus-ease),
|
||||
transform var(--shell-focus-duration) var(--shell-focus-ease),
|
||||
max-height var(--shell-focus-duration) var(--shell-focus-ease),
|
||||
padding var(--shell-focus-duration) var(--shell-focus-ease);
|
||||
max-height: 80px;
|
||||
}
|
||||
|
||||
.shell--chat-focus .content-header {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
max-height: 0px;
|
||||
padding: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.035em;
|
||||
line-height: 1.15;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.page-sub {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
margin-top: 6px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.page-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Chat view header adjustments */
|
||||
.content--chat .content-header {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.content--chat .content-header > div:first-child {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.content--chat .page-meta {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.content--chat .chat-controls {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
Grid Utilities
|
||||
=========================================== */
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.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: 14px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
}
|
||||
|
||||
.note-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
Responsive - Tablet
|
||||
=========================================== */
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.shell {
|
||||
--shell-pad: 12px;
|
||||
--shell-gap: 12px;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto auto 1fr;
|
||||
grid-template-areas:
|
||||
"topbar"
|
||||
"nav"
|
||||
"content";
|
||||
}
|
||||
|
||||
.nav {
|
||||
position: static;
|
||||
max-height: none;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
overflow-x: auto;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 10px 14px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.nav-group {
|
||||
grid-auto-flow: column;
|
||||
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.grid-cols-2,
|
||||
.grid-cols-3 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: static;
|
||||
padding: 12px 14px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.topbar-status {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.table-head,
|
||||
.table-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
374
openclaw/ui/src/styles/layout.mobile.css
Normal file
374
openclaw/ui/src/styles/layout.mobile.css
Normal file
@@ -0,0 +1,374 @@
|
||||
/* ===========================================
|
||||
Mobile Layout
|
||||
=========================================== */
|
||||
|
||||
/* Tablet: Horizontal nav */
|
||||
@media (max-width: 1100px) {
|
||||
.nav {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
gap: 4px;
|
||||
padding: 10px 14px;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.nav::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-group {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.nav-group__items {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-group--collapsed .nav-group__items {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
border-radius: var(--radius-md);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile-specific styles */
|
||||
@media (max-width: 600px) {
|
||||
.shell {
|
||||
--shell-pad: 8px;
|
||||
--shell-gap: 8px;
|
||||
}
|
||||
|
||||
/* Topbar */
|
||||
.topbar {
|
||||
padding: 10px 12px;
|
||||
gap: 8px;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.brand {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.brand-sub {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.topbar-status {
|
||||
gap: 6px;
|
||||
width: auto;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.topbar-status .pill {
|
||||
padding: 4px 8px;
|
||||
font-size: 11px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.topbar-status .pill .mono {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.topbar-status .pill span:nth-child(2) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Nav */
|
||||
.nav {
|
||||
padding: 8px 10px;
|
||||
gap: 4px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.nav::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-group {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
border-radius: var(--radius-md);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.content-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 4px 4px 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Stats */
|
||||
.stat-grid {
|
||||
gap: 8px;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.stat {
|
||||
padding: 10px;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* Notes */
|
||||
.note-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field textarea,
|
||||
.field select {
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Pills */
|
||||
.pill {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Chat */
|
||||
.chat-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-header__left {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.chat-header__right {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.chat-session {
|
||||
min-width: unset;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chat-thread {
|
||||
margin-top: 8px;
|
||||
padding: 12px 8px;
|
||||
}
|
||||
|
||||
.chat-msg {
|
||||
max-width: 90%;
|
||||
}
|
||||
|
||||
.chat-bubble {
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.chat-compose {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-compose__field textarea {
|
||||
min-height: 60px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Log stream */
|
||||
.log-stream {
|
||||
border-radius: var(--radius-md);
|
||||
max-height: 380px;
|
||||
}
|
||||
|
||||
.log-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 4px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.log-time {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.log-level {
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.log-subsystem {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.log-message {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Lists */
|
||||
.list-item {
|
||||
padding: 10px;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.list-title {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.list-sub {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* Code blocks */
|
||||
.code-block {
|
||||
padding: 8px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* Theme toggle */
|
||||
.theme-toggle {
|
||||
--theme-item: 24px;
|
||||
--theme-gap: 2px;
|
||||
--theme-pad: 3px;
|
||||
}
|
||||
|
||||
.theme-icon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Small mobile */
|
||||
@media (max-width: 400px) {
|
||||
.shell {
|
||||
--shell-pad: 4px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.nav {
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
padding: 6px 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 4px 4px 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.stat {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.chat-bubble {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.chat-compose__field textarea {
|
||||
min-height: 52px;
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 6px 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.topbar-status .pill {
|
||||
padding: 3px 6px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
--theme-item: 22px;
|
||||
--theme-gap: 2px;
|
||||
--theme-pad: 2px;
|
||||
}
|
||||
|
||||
.theme-icon {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
}
|
||||
}
|
||||
279
openclaw/ui/src/ui/app-channels.ts
Normal file
279
openclaw/ui/src/ui/app-channels.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
import type { OpenClawApp } from "./app.ts";
|
||||
import {
|
||||
loadChannels,
|
||||
logoutWhatsApp,
|
||||
startWhatsAppLogin,
|
||||
waitWhatsAppLogin,
|
||||
} from "./controllers/channels.ts";
|
||||
import { loadConfig, saveConfig } from "./controllers/config.ts";
|
||||
import type { NostrProfile } from "./types.ts";
|
||||
import { createNostrProfileFormState } from "./views/channels.nostr-profile-form.ts";
|
||||
|
||||
export async function handleWhatsAppStart(host: OpenClawApp, force: boolean) {
|
||||
await startWhatsAppLogin(host, force);
|
||||
await loadChannels(host, true);
|
||||
}
|
||||
|
||||
export async function handleWhatsAppWait(host: OpenClawApp) {
|
||||
await waitWhatsAppLogin(host);
|
||||
await loadChannels(host, true);
|
||||
}
|
||||
|
||||
export async function handleWhatsAppLogout(host: OpenClawApp) {
|
||||
await logoutWhatsApp(host);
|
||||
await loadChannels(host, true);
|
||||
}
|
||||
|
||||
export async function handleChannelConfigSave(host: OpenClawApp) {
|
||||
await saveConfig(host);
|
||||
await loadConfig(host);
|
||||
await loadChannels(host, true);
|
||||
}
|
||||
|
||||
export async function handleChannelConfigReload(host: OpenClawApp) {
|
||||
await loadConfig(host);
|
||||
await loadChannels(host, true);
|
||||
}
|
||||
|
||||
function parseValidationErrors(details: unknown): Record<string, string> {
|
||||
if (!Array.isArray(details)) {
|
||||
return {};
|
||||
}
|
||||
const errors: Record<string, string> = {};
|
||||
for (const entry of details) {
|
||||
if (typeof entry !== "string") {
|
||||
continue;
|
||||
}
|
||||
const [rawField, ...rest] = entry.split(":");
|
||||
if (!rawField || rest.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const field = rawField.trim();
|
||||
const message = rest.join(":").trim();
|
||||
if (field && message) {
|
||||
errors[field] = message;
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
function resolveNostrAccountId(host: OpenClawApp): string {
|
||||
const accounts = host.channelsSnapshot?.channelAccounts?.nostr ?? [];
|
||||
return accounts[0]?.accountId ?? host.nostrProfileAccountId ?? "default";
|
||||
}
|
||||
|
||||
function buildNostrProfileUrl(accountId: string, suffix = ""): string {
|
||||
return `/api/channels/nostr/${encodeURIComponent(accountId)}/profile${suffix}`;
|
||||
}
|
||||
|
||||
function resolveGatewayHttpAuthHeader(host: OpenClawApp): string | null {
|
||||
const deviceToken = host.hello?.auth?.deviceToken?.trim();
|
||||
if (deviceToken) {
|
||||
return `Bearer ${deviceToken}`;
|
||||
}
|
||||
const token = host.settings.token.trim();
|
||||
if (token) {
|
||||
return `Bearer ${token}`;
|
||||
}
|
||||
const password = host.password.trim();
|
||||
if (password) {
|
||||
return `Bearer ${password}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildGatewayHttpHeaders(host: OpenClawApp): Record<string, string> {
|
||||
const authorization = resolveGatewayHttpAuthHeader(host);
|
||||
return authorization ? { Authorization: authorization } : {};
|
||||
}
|
||||
|
||||
export function handleNostrProfileEdit(
|
||||
host: OpenClawApp,
|
||||
accountId: string,
|
||||
profile: NostrProfile | null,
|
||||
) {
|
||||
host.nostrProfileAccountId = accountId;
|
||||
host.nostrProfileFormState = createNostrProfileFormState(profile ?? undefined);
|
||||
}
|
||||
|
||||
export function handleNostrProfileCancel(host: OpenClawApp) {
|
||||
host.nostrProfileFormState = null;
|
||||
host.nostrProfileAccountId = null;
|
||||
}
|
||||
|
||||
export function handleNostrProfileFieldChange(
|
||||
host: OpenClawApp,
|
||||
field: keyof NostrProfile,
|
||||
value: string,
|
||||
) {
|
||||
const state = host.nostrProfileFormState;
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
host.nostrProfileFormState = {
|
||||
...state,
|
||||
values: {
|
||||
...state.values,
|
||||
[field]: value,
|
||||
},
|
||||
fieldErrors: {
|
||||
...state.fieldErrors,
|
||||
[field]: "",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function handleNostrProfileToggleAdvanced(host: OpenClawApp) {
|
||||
const state = host.nostrProfileFormState;
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
host.nostrProfileFormState = {
|
||||
...state,
|
||||
showAdvanced: !state.showAdvanced,
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleNostrProfileSave(host: OpenClawApp) {
|
||||
const state = host.nostrProfileFormState;
|
||||
if (!state || state.saving) {
|
||||
return;
|
||||
}
|
||||
const accountId = resolveNostrAccountId(host);
|
||||
|
||||
host.nostrProfileFormState = {
|
||||
...state,
|
||||
saving: true,
|
||||
error: null,
|
||||
success: null,
|
||||
fieldErrors: {},
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(buildNostrProfileUrl(accountId), {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...buildGatewayHttpHeaders(host),
|
||||
},
|
||||
body: JSON.stringify(state.values),
|
||||
});
|
||||
const data = (await response.json().catch(() => null)) as {
|
||||
ok?: boolean;
|
||||
error?: string;
|
||||
details?: unknown;
|
||||
persisted?: boolean;
|
||||
} | null;
|
||||
|
||||
if (!response.ok || data?.ok === false || !data) {
|
||||
const errorMessage = data?.error ?? `Profile update failed (${response.status})`;
|
||||
host.nostrProfileFormState = {
|
||||
...state,
|
||||
saving: false,
|
||||
error: errorMessage,
|
||||
success: null,
|
||||
fieldErrors: parseValidationErrors(data?.details),
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data.persisted) {
|
||||
host.nostrProfileFormState = {
|
||||
...state,
|
||||
saving: false,
|
||||
error: "Profile publish failed on all relays.",
|
||||
success: null,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
host.nostrProfileFormState = {
|
||||
...state,
|
||||
saving: false,
|
||||
error: null,
|
||||
success: "Profile published to relays.",
|
||||
fieldErrors: {},
|
||||
original: { ...state.values },
|
||||
};
|
||||
await loadChannels(host, true);
|
||||
} catch (err) {
|
||||
host.nostrProfileFormState = {
|
||||
...state,
|
||||
saving: false,
|
||||
error: `Profile update failed: ${String(err)}`,
|
||||
success: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleNostrProfileImport(host: OpenClawApp) {
|
||||
const state = host.nostrProfileFormState;
|
||||
if (!state || state.importing) {
|
||||
return;
|
||||
}
|
||||
const accountId = resolveNostrAccountId(host);
|
||||
|
||||
host.nostrProfileFormState = {
|
||||
...state,
|
||||
importing: true,
|
||||
error: null,
|
||||
success: null,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(buildNostrProfileUrl(accountId, "/import"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...buildGatewayHttpHeaders(host),
|
||||
},
|
||||
body: JSON.stringify({ autoMerge: true }),
|
||||
});
|
||||
const data = (await response.json().catch(() => null)) as {
|
||||
ok?: boolean;
|
||||
error?: string;
|
||||
imported?: NostrProfile;
|
||||
merged?: NostrProfile;
|
||||
saved?: boolean;
|
||||
} | null;
|
||||
|
||||
if (!response.ok || data?.ok === false || !data) {
|
||||
const errorMessage = data?.error ?? `Profile import failed (${response.status})`;
|
||||
host.nostrProfileFormState = {
|
||||
...state,
|
||||
importing: false,
|
||||
error: errorMessage,
|
||||
success: null,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const merged = data.merged ?? data.imported ?? null;
|
||||
const nextValues = merged ? { ...state.values, ...merged } : state.values;
|
||||
const showAdvanced = Boolean(
|
||||
nextValues.banner || nextValues.website || nextValues.nip05 || nextValues.lud16,
|
||||
);
|
||||
|
||||
host.nostrProfileFormState = {
|
||||
...state,
|
||||
importing: false,
|
||||
values: nextValues,
|
||||
error: null,
|
||||
success: data.saved
|
||||
? "Profile imported from relays. Review and publish."
|
||||
: "Profile imported. Review and publish.",
|
||||
showAdvanced,
|
||||
};
|
||||
|
||||
if (data.saved) {
|
||||
await loadChannels(host, true);
|
||||
}
|
||||
} catch (err) {
|
||||
host.nostrProfileFormState = {
|
||||
...state,
|
||||
importing: false,
|
||||
error: `Profile import failed: ${String(err)}`,
|
||||
success: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
266
openclaw/ui/src/ui/app-chat.ts
Normal file
266
openclaw/ui/src/ui/app-chat.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import { parseAgentSessionKey } from "../../../src/sessions/session-key-utils.js";
|
||||
import { scheduleChatScroll } from "./app-scroll.ts";
|
||||
import { setLastActiveSessionKey } from "./app-settings.ts";
|
||||
import { resetToolStream } from "./app-tool-stream.ts";
|
||||
import type { OpenClawApp } from "./app.ts";
|
||||
import { abortChatRun, loadChatHistory, sendChatMessage } from "./controllers/chat.ts";
|
||||
import { loadSessions } from "./controllers/sessions.ts";
|
||||
import type { GatewayHelloOk } from "./gateway.ts";
|
||||
import { normalizeBasePath } from "./navigation.ts";
|
||||
import type { ChatAttachment, ChatQueueItem } from "./ui-types.ts";
|
||||
import { generateUUID } from "./uuid.ts";
|
||||
|
||||
export type ChatHost = {
|
||||
connected: boolean;
|
||||
chatMessage: string;
|
||||
chatAttachments: ChatAttachment[];
|
||||
chatQueue: ChatQueueItem[];
|
||||
chatRunId: string | null;
|
||||
chatSending: boolean;
|
||||
sessionKey: string;
|
||||
basePath: string;
|
||||
hello: GatewayHelloOk | null;
|
||||
chatAvatarUrl: string | null;
|
||||
refreshSessionsAfterChat: Set<string>;
|
||||
};
|
||||
|
||||
export const CHAT_SESSIONS_ACTIVE_MINUTES = 120;
|
||||
|
||||
export function isChatBusy(host: ChatHost) {
|
||||
return host.chatSending || Boolean(host.chatRunId);
|
||||
}
|
||||
|
||||
export function isChatStopCommand(text: string) {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
const normalized = trimmed.toLowerCase();
|
||||
if (normalized === "/stop") {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
normalized === "stop" ||
|
||||
normalized === "esc" ||
|
||||
normalized === "abort" ||
|
||||
normalized === "wait" ||
|
||||
normalized === "exit"
|
||||
);
|
||||
}
|
||||
|
||||
function isChatResetCommand(text: string) {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
const normalized = trimmed.toLowerCase();
|
||||
if (normalized === "/new" || normalized === "/reset") {
|
||||
return true;
|
||||
}
|
||||
return normalized.startsWith("/new ") || normalized.startsWith("/reset ");
|
||||
}
|
||||
|
||||
export async function handleAbortChat(host: ChatHost) {
|
||||
if (!host.connected) {
|
||||
return;
|
||||
}
|
||||
host.chatMessage = "";
|
||||
await abortChatRun(host as unknown as OpenClawApp);
|
||||
}
|
||||
|
||||
function enqueueChatMessage(
|
||||
host: ChatHost,
|
||||
text: string,
|
||||
attachments?: ChatAttachment[],
|
||||
refreshSessions?: boolean,
|
||||
) {
|
||||
const trimmed = text.trim();
|
||||
const hasAttachments = Boolean(attachments && attachments.length > 0);
|
||||
if (!trimmed && !hasAttachments) {
|
||||
return;
|
||||
}
|
||||
host.chatQueue = [
|
||||
...host.chatQueue,
|
||||
{
|
||||
id: generateUUID(),
|
||||
text: trimmed,
|
||||
createdAt: Date.now(),
|
||||
attachments: hasAttachments ? attachments?.map((att) => ({ ...att })) : undefined,
|
||||
refreshSessions,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async function sendChatMessageNow(
|
||||
host: ChatHost,
|
||||
message: string,
|
||||
opts?: {
|
||||
previousDraft?: string;
|
||||
restoreDraft?: boolean;
|
||||
attachments?: ChatAttachment[];
|
||||
previousAttachments?: ChatAttachment[];
|
||||
restoreAttachments?: boolean;
|
||||
refreshSessions?: boolean;
|
||||
},
|
||||
) {
|
||||
resetToolStream(host as unknown as Parameters<typeof resetToolStream>[0]);
|
||||
const runId = await sendChatMessage(host as unknown as OpenClawApp, message, opts?.attachments);
|
||||
const ok = Boolean(runId);
|
||||
if (!ok && opts?.previousDraft != null) {
|
||||
host.chatMessage = opts.previousDraft;
|
||||
}
|
||||
if (!ok && opts?.previousAttachments) {
|
||||
host.chatAttachments = opts.previousAttachments;
|
||||
}
|
||||
if (ok) {
|
||||
setLastActiveSessionKey(
|
||||
host as unknown as Parameters<typeof setLastActiveSessionKey>[0],
|
||||
host.sessionKey,
|
||||
);
|
||||
}
|
||||
if (ok && opts?.restoreDraft && opts.previousDraft?.trim()) {
|
||||
host.chatMessage = opts.previousDraft;
|
||||
}
|
||||
if (ok && opts?.restoreAttachments && opts.previousAttachments?.length) {
|
||||
host.chatAttachments = opts.previousAttachments;
|
||||
}
|
||||
scheduleChatScroll(host as unknown as Parameters<typeof scheduleChatScroll>[0]);
|
||||
if (ok && !host.chatRunId) {
|
||||
void flushChatQueue(host);
|
||||
}
|
||||
if (ok && opts?.refreshSessions && runId) {
|
||||
host.refreshSessionsAfterChat.add(runId);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
async function flushChatQueue(host: ChatHost) {
|
||||
if (!host.connected || isChatBusy(host)) {
|
||||
return;
|
||||
}
|
||||
const [next, ...rest] = host.chatQueue;
|
||||
if (!next) {
|
||||
return;
|
||||
}
|
||||
host.chatQueue = rest;
|
||||
const ok = await sendChatMessageNow(host, next.text, {
|
||||
attachments: next.attachments,
|
||||
refreshSessions: next.refreshSessions,
|
||||
});
|
||||
if (!ok) {
|
||||
host.chatQueue = [next, ...host.chatQueue];
|
||||
}
|
||||
}
|
||||
|
||||
export function removeQueuedMessage(host: ChatHost, id: string) {
|
||||
host.chatQueue = host.chatQueue.filter((item) => item.id !== id);
|
||||
}
|
||||
|
||||
export async function handleSendChat(
|
||||
host: ChatHost,
|
||||
messageOverride?: string,
|
||||
opts?: { restoreDraft?: boolean },
|
||||
) {
|
||||
if (!host.connected) {
|
||||
return;
|
||||
}
|
||||
const previousDraft = host.chatMessage;
|
||||
const message = (messageOverride ?? host.chatMessage).trim();
|
||||
const attachments = host.chatAttachments ?? [];
|
||||
const attachmentsToSend = messageOverride == null ? attachments : [];
|
||||
const hasAttachments = attachmentsToSend.length > 0;
|
||||
|
||||
// Allow sending with just attachments (no message text required)
|
||||
if (!message && !hasAttachments) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isChatStopCommand(message)) {
|
||||
await handleAbortChat(host);
|
||||
return;
|
||||
}
|
||||
|
||||
const refreshSessions = isChatResetCommand(message);
|
||||
if (messageOverride == null) {
|
||||
host.chatMessage = "";
|
||||
// Clear attachments when sending
|
||||
host.chatAttachments = [];
|
||||
}
|
||||
|
||||
if (isChatBusy(host)) {
|
||||
enqueueChatMessage(host, message, attachmentsToSend, refreshSessions);
|
||||
return;
|
||||
}
|
||||
|
||||
await sendChatMessageNow(host, message, {
|
||||
previousDraft: messageOverride == null ? previousDraft : undefined,
|
||||
restoreDraft: Boolean(messageOverride && opts?.restoreDraft),
|
||||
attachments: hasAttachments ? attachmentsToSend : undefined,
|
||||
previousAttachments: messageOverride == null ? attachments : undefined,
|
||||
restoreAttachments: Boolean(messageOverride && opts?.restoreDraft),
|
||||
refreshSessions,
|
||||
});
|
||||
}
|
||||
|
||||
export async function refreshChat(host: ChatHost, opts?: { scheduleScroll?: boolean }) {
|
||||
await Promise.all([
|
||||
loadChatHistory(host as unknown as OpenClawApp),
|
||||
loadSessions(host as unknown as OpenClawApp, {
|
||||
activeMinutes: CHAT_SESSIONS_ACTIVE_MINUTES,
|
||||
}),
|
||||
refreshChatAvatar(host),
|
||||
]);
|
||||
if (opts?.scheduleScroll !== false) {
|
||||
scheduleChatScroll(host as unknown as Parameters<typeof scheduleChatScroll>[0]);
|
||||
}
|
||||
}
|
||||
|
||||
export const flushChatQueueForEvent = flushChatQueue;
|
||||
|
||||
type SessionDefaultsSnapshot = {
|
||||
defaultAgentId?: string;
|
||||
};
|
||||
|
||||
function resolveAgentIdForSession(host: ChatHost): string | null {
|
||||
const parsed = parseAgentSessionKey(host.sessionKey);
|
||||
if (parsed?.agentId) {
|
||||
return parsed.agentId;
|
||||
}
|
||||
const snapshot = host.hello?.snapshot as
|
||||
| { sessionDefaults?: SessionDefaultsSnapshot }
|
||||
| undefined;
|
||||
const fallback = snapshot?.sessionDefaults?.defaultAgentId?.trim();
|
||||
return fallback || "main";
|
||||
}
|
||||
|
||||
function buildAvatarMetaUrl(basePath: string, agentId: string): string {
|
||||
const base = normalizeBasePath(basePath);
|
||||
const encoded = encodeURIComponent(agentId);
|
||||
return base ? `${base}/avatar/${encoded}?meta=1` : `/avatar/${encoded}?meta=1`;
|
||||
}
|
||||
|
||||
export async function refreshChatAvatar(host: ChatHost) {
|
||||
if (!host.connected) {
|
||||
host.chatAvatarUrl = null;
|
||||
return;
|
||||
}
|
||||
const agentId = resolveAgentIdForSession(host);
|
||||
if (!agentId) {
|
||||
host.chatAvatarUrl = null;
|
||||
return;
|
||||
}
|
||||
host.chatAvatarUrl = null;
|
||||
const url = buildAvatarMetaUrl(host.basePath, agentId);
|
||||
try {
|
||||
const res = await fetch(url, { method: "GET" });
|
||||
if (!res.ok) {
|
||||
host.chatAvatarUrl = null;
|
||||
return;
|
||||
}
|
||||
const data = (await res.json()) as { avatarUrl?: unknown };
|
||||
const avatarUrl = typeof data.avatarUrl === "string" ? data.avatarUrl.trim() : "";
|
||||
host.chatAvatarUrl = avatarUrl || null;
|
||||
} catch {
|
||||
host.chatAvatarUrl = null;
|
||||
}
|
||||
}
|
||||
40
openclaw/ui/src/ui/app-defaults.ts
Normal file
40
openclaw/ui/src/ui/app-defaults.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { LogLevel } from "./types.ts";
|
||||
import type { CronFormState } from "./ui-types.ts";
|
||||
|
||||
export const DEFAULT_LOG_LEVEL_FILTERS: Record<LogLevel, boolean> = {
|
||||
trace: true,
|
||||
debug: true,
|
||||
info: true,
|
||||
warn: true,
|
||||
error: true,
|
||||
fatal: true,
|
||||
};
|
||||
|
||||
export const DEFAULT_CRON_FORM: CronFormState = {
|
||||
name: "",
|
||||
description: "",
|
||||
agentId: "",
|
||||
clearAgent: false,
|
||||
enabled: true,
|
||||
deleteAfterRun: true,
|
||||
scheduleKind: "every",
|
||||
scheduleAt: "",
|
||||
everyAmount: "30",
|
||||
everyUnit: "minutes",
|
||||
cronExpr: "0 7 * * *",
|
||||
cronTz: "",
|
||||
scheduleExact: false,
|
||||
staggerAmount: "",
|
||||
staggerUnit: "seconds",
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "now",
|
||||
payloadKind: "agentTurn",
|
||||
payloadText: "",
|
||||
payloadModel: "",
|
||||
payloadThinking: "",
|
||||
deliveryMode: "announce",
|
||||
deliveryChannel: "last",
|
||||
deliveryTo: "",
|
||||
deliveryBestEffort: false,
|
||||
timeoutSeconds: "",
|
||||
};
|
||||
5
openclaw/ui/src/ui/app-events.ts
Normal file
5
openclaw/ui/src/ui/app-events.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type EventLogEntry = {
|
||||
ts: number;
|
||||
event: string;
|
||||
payload?: unknown;
|
||||
};
|
||||
229
openclaw/ui/src/ui/app-gateway.node.test.ts
Normal file
229
openclaw/ui/src/ui/app-gateway.node.test.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { GATEWAY_EVENT_UPDATE_AVAILABLE } from "../../../src/gateway/events.js";
|
||||
import { connectGateway } from "./app-gateway.ts";
|
||||
|
||||
type GatewayClientMock = {
|
||||
start: ReturnType<typeof vi.fn>;
|
||||
stop: ReturnType<typeof vi.fn>;
|
||||
emitClose: (info: {
|
||||
code: number;
|
||||
reason?: string;
|
||||
error?: { code: string; message: string; details?: unknown };
|
||||
}) => void;
|
||||
emitGap: (expected: number, received: number) => void;
|
||||
emitEvent: (evt: { event: string; payload?: unknown; seq?: number }) => void;
|
||||
};
|
||||
|
||||
const gatewayClientInstances: GatewayClientMock[] = [];
|
||||
|
||||
vi.mock("./gateway.ts", () => {
|
||||
function resolveGatewayErrorDetailCode(
|
||||
error: { details?: unknown } | null | undefined,
|
||||
): string | null {
|
||||
const details = error?.details;
|
||||
if (!details || typeof details !== "object") {
|
||||
return null;
|
||||
}
|
||||
const code = (details as { code?: unknown }).code;
|
||||
return typeof code === "string" ? code : null;
|
||||
}
|
||||
|
||||
class GatewayBrowserClient {
|
||||
readonly start = vi.fn();
|
||||
readonly stop = vi.fn();
|
||||
|
||||
constructor(
|
||||
private opts: {
|
||||
onClose?: (info: {
|
||||
code: number;
|
||||
reason: string;
|
||||
error?: { code: string; message: string; details?: unknown };
|
||||
}) => void;
|
||||
onGap?: (info: { expected: number; received: number }) => void;
|
||||
onEvent?: (evt: { event: string; payload?: unknown; seq?: number }) => void;
|
||||
},
|
||||
) {
|
||||
gatewayClientInstances.push({
|
||||
start: this.start,
|
||||
stop: this.stop,
|
||||
emitClose: (info) => {
|
||||
this.opts.onClose?.({
|
||||
code: info.code,
|
||||
reason: info.reason ?? "",
|
||||
error: info.error,
|
||||
});
|
||||
},
|
||||
emitGap: (expected, received) => {
|
||||
this.opts.onGap?.({ expected, received });
|
||||
},
|
||||
emitEvent: (evt) => {
|
||||
this.opts.onEvent?.(evt);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { GatewayBrowserClient, resolveGatewayErrorDetailCode };
|
||||
});
|
||||
|
||||
function createHost() {
|
||||
return {
|
||||
settings: {
|
||||
gatewayUrl: "ws://127.0.0.1:18789",
|
||||
token: "",
|
||||
sessionKey: "main",
|
||||
lastActiveSessionKey: "main",
|
||||
theme: "system",
|
||||
chatFocusMode: false,
|
||||
chatShowThinking: true,
|
||||
splitRatio: 0.6,
|
||||
navCollapsed: false,
|
||||
navGroupsCollapsed: {},
|
||||
},
|
||||
password: "",
|
||||
clientInstanceId: "instance-test",
|
||||
client: null,
|
||||
connected: false,
|
||||
hello: null,
|
||||
lastError: null,
|
||||
lastErrorCode: null,
|
||||
eventLogBuffer: [],
|
||||
eventLog: [],
|
||||
tab: "overview",
|
||||
presenceEntries: [],
|
||||
presenceError: null,
|
||||
presenceStatus: null,
|
||||
agentsLoading: false,
|
||||
agentsList: null,
|
||||
agentsError: null,
|
||||
debugHealth: null,
|
||||
assistantName: "OpenClaw",
|
||||
assistantAvatar: null,
|
||||
assistantAgentId: null,
|
||||
sessionKey: "main",
|
||||
chatRunId: null,
|
||||
refreshSessionsAfterChat: new Set<string>(),
|
||||
execApprovalQueue: [],
|
||||
execApprovalError: null,
|
||||
updateAvailable: null,
|
||||
} as unknown as Parameters<typeof connectGateway>[0];
|
||||
}
|
||||
|
||||
describe("connectGateway", () => {
|
||||
beforeEach(() => {
|
||||
gatewayClientInstances.length = 0;
|
||||
});
|
||||
|
||||
it("ignores stale client onGap callbacks after reconnect", () => {
|
||||
const host = createHost();
|
||||
|
||||
connectGateway(host);
|
||||
const firstClient = gatewayClientInstances[0];
|
||||
expect(firstClient).toBeDefined();
|
||||
|
||||
connectGateway(host);
|
||||
const secondClient = gatewayClientInstances[1];
|
||||
expect(secondClient).toBeDefined();
|
||||
|
||||
firstClient.emitGap(10, 13);
|
||||
expect(host.lastError).toBeNull();
|
||||
|
||||
secondClient.emitGap(20, 24);
|
||||
expect(host.lastError).toBe(
|
||||
"event gap detected (expected seq 20, got 24); refresh recommended",
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores stale client onEvent callbacks after reconnect", () => {
|
||||
const host = createHost();
|
||||
|
||||
connectGateway(host);
|
||||
const firstClient = gatewayClientInstances[0];
|
||||
expect(firstClient).toBeDefined();
|
||||
|
||||
connectGateway(host);
|
||||
const secondClient = gatewayClientInstances[1];
|
||||
expect(secondClient).toBeDefined();
|
||||
|
||||
firstClient.emitEvent({ event: "presence", payload: { presence: [{ host: "stale" }] } });
|
||||
expect(host.eventLogBuffer).toHaveLength(0);
|
||||
|
||||
secondClient.emitEvent({ event: "presence", payload: { presence: [{ host: "active" }] } });
|
||||
expect(host.eventLogBuffer).toHaveLength(1);
|
||||
expect(host.eventLogBuffer[0]?.event).toBe("presence");
|
||||
});
|
||||
|
||||
it("applies update.available only from active client", () => {
|
||||
const host = createHost();
|
||||
|
||||
connectGateway(host);
|
||||
const firstClient = gatewayClientInstances[0];
|
||||
expect(firstClient).toBeDefined();
|
||||
|
||||
connectGateway(host);
|
||||
const secondClient = gatewayClientInstances[1];
|
||||
expect(secondClient).toBeDefined();
|
||||
|
||||
firstClient.emitEvent({
|
||||
event: GATEWAY_EVENT_UPDATE_AVAILABLE,
|
||||
payload: {
|
||||
updateAvailable: { currentVersion: "1.0.0", latestVersion: "9.9.9", channel: "latest" },
|
||||
},
|
||||
});
|
||||
expect(host.updateAvailable).toBeNull();
|
||||
|
||||
secondClient.emitEvent({
|
||||
event: GATEWAY_EVENT_UPDATE_AVAILABLE,
|
||||
payload: {
|
||||
updateAvailable: { currentVersion: "1.0.0", latestVersion: "2.0.0", channel: "latest" },
|
||||
},
|
||||
});
|
||||
expect(host.updateAvailable).toEqual({
|
||||
currentVersion: "1.0.0",
|
||||
latestVersion: "2.0.0",
|
||||
channel: "latest",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores stale client onClose callbacks after reconnect", () => {
|
||||
const host = createHost();
|
||||
|
||||
connectGateway(host);
|
||||
const firstClient = gatewayClientInstances[0];
|
||||
expect(firstClient).toBeDefined();
|
||||
|
||||
connectGateway(host);
|
||||
const secondClient = gatewayClientInstances[1];
|
||||
expect(secondClient).toBeDefined();
|
||||
|
||||
firstClient.emitClose({ code: 1005 });
|
||||
expect(host.lastError).toBeNull();
|
||||
expect(host.lastErrorCode).toBeNull();
|
||||
|
||||
secondClient.emitClose({ code: 1005 });
|
||||
expect(host.lastError).toBe("disconnected (1005): no reason");
|
||||
expect(host.lastErrorCode).toBeNull();
|
||||
});
|
||||
|
||||
it("prefers structured connect errors over close reason", () => {
|
||||
const host = createHost();
|
||||
|
||||
connectGateway(host);
|
||||
const client = gatewayClientInstances[0];
|
||||
expect(client).toBeDefined();
|
||||
|
||||
client.emitClose({
|
||||
code: 4008,
|
||||
reason: "connect failed",
|
||||
error: {
|
||||
code: "INVALID_REQUEST",
|
||||
message:
|
||||
"unauthorized: gateway token mismatch (open the dashboard URL and paste the token in Control UI settings)",
|
||||
details: { code: "AUTH_TOKEN_MISMATCH" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(host.lastError).toContain("gateway token mismatch");
|
||||
expect(host.lastErrorCode).toBe("AUTH_TOKEN_MISMATCH");
|
||||
});
|
||||
});
|
||||
349
openclaw/ui/src/ui/app-gateway.ts
Normal file
349
openclaw/ui/src/ui/app-gateway.ts
Normal file
@@ -0,0 +1,349 @@
|
||||
import {
|
||||
GATEWAY_EVENT_UPDATE_AVAILABLE,
|
||||
type GatewayUpdateAvailableEventPayload,
|
||||
} from "../../../src/gateway/events.js";
|
||||
import { CHAT_SESSIONS_ACTIVE_MINUTES, flushChatQueueForEvent } from "./app-chat.ts";
|
||||
import type { EventLogEntry } from "./app-events.ts";
|
||||
import {
|
||||
applySettings,
|
||||
loadCron,
|
||||
refreshActiveTab,
|
||||
setLastActiveSessionKey,
|
||||
} from "./app-settings.ts";
|
||||
import { handleAgentEvent, resetToolStream, type AgentEventPayload } from "./app-tool-stream.ts";
|
||||
import type { OpenClawApp } from "./app.ts";
|
||||
import { shouldReloadHistoryForFinalEvent } from "./chat-event-reload.ts";
|
||||
import { loadAgents, loadToolsCatalog } from "./controllers/agents.ts";
|
||||
import { loadAssistantIdentity } from "./controllers/assistant-identity.ts";
|
||||
import { loadChatHistory } from "./controllers/chat.ts";
|
||||
import { handleChatEvent, type ChatEventPayload } from "./controllers/chat.ts";
|
||||
import { loadDevices } from "./controllers/devices.ts";
|
||||
import type { ExecApprovalRequest } from "./controllers/exec-approval.ts";
|
||||
import {
|
||||
addExecApproval,
|
||||
parseExecApprovalRequested,
|
||||
parseExecApprovalResolved,
|
||||
removeExecApproval,
|
||||
} from "./controllers/exec-approval.ts";
|
||||
import { loadNodes } from "./controllers/nodes.ts";
|
||||
import { loadSessions } from "./controllers/sessions.ts";
|
||||
import {
|
||||
resolveGatewayErrorDetailCode,
|
||||
type GatewayEventFrame,
|
||||
type GatewayHelloOk,
|
||||
} from "./gateway.ts";
|
||||
import { GatewayBrowserClient } from "./gateway.ts";
|
||||
import type { Tab } from "./navigation.ts";
|
||||
import type { UiSettings } from "./storage.ts";
|
||||
import type {
|
||||
AgentsListResult,
|
||||
PresenceEntry,
|
||||
HealthSnapshot,
|
||||
StatusSummary,
|
||||
UpdateAvailable,
|
||||
} from "./types.ts";
|
||||
|
||||
type GatewayHost = {
|
||||
settings: UiSettings;
|
||||
password: string;
|
||||
clientInstanceId: string;
|
||||
client: GatewayBrowserClient | null;
|
||||
connected: boolean;
|
||||
hello: GatewayHelloOk | null;
|
||||
lastError: string | null;
|
||||
lastErrorCode: string | null;
|
||||
onboarding?: boolean;
|
||||
eventLogBuffer: EventLogEntry[];
|
||||
eventLog: EventLogEntry[];
|
||||
tab: Tab;
|
||||
presenceEntries: PresenceEntry[];
|
||||
presenceError: string | null;
|
||||
presenceStatus: StatusSummary | null;
|
||||
agentsLoading: boolean;
|
||||
agentsList: AgentsListResult | null;
|
||||
agentsError: string | null;
|
||||
toolsCatalogLoading: boolean;
|
||||
toolsCatalogError: string | null;
|
||||
toolsCatalogResult: import("./types.ts").ToolsCatalogResult | null;
|
||||
debugHealth: HealthSnapshot | null;
|
||||
assistantName: string;
|
||||
assistantAvatar: string | null;
|
||||
assistantAgentId: string | null;
|
||||
sessionKey: string;
|
||||
chatRunId: string | null;
|
||||
refreshSessionsAfterChat: Set<string>;
|
||||
execApprovalQueue: ExecApprovalRequest[];
|
||||
execApprovalError: string | null;
|
||||
updateAvailable: UpdateAvailable | null;
|
||||
};
|
||||
|
||||
type SessionDefaultsSnapshot = {
|
||||
defaultAgentId?: string;
|
||||
mainKey?: string;
|
||||
mainSessionKey?: string;
|
||||
scope?: string;
|
||||
};
|
||||
|
||||
function normalizeSessionKeyForDefaults(
|
||||
value: string | undefined,
|
||||
defaults: SessionDefaultsSnapshot,
|
||||
): string {
|
||||
const raw = (value ?? "").trim();
|
||||
const mainSessionKey = defaults.mainSessionKey?.trim();
|
||||
if (!mainSessionKey) {
|
||||
return raw;
|
||||
}
|
||||
if (!raw) {
|
||||
return mainSessionKey;
|
||||
}
|
||||
const mainKey = defaults.mainKey?.trim() || "main";
|
||||
const defaultAgentId = defaults.defaultAgentId?.trim();
|
||||
const isAlias =
|
||||
raw === "main" ||
|
||||
raw === mainKey ||
|
||||
(defaultAgentId &&
|
||||
(raw === `agent:${defaultAgentId}:main` || raw === `agent:${defaultAgentId}:${mainKey}`));
|
||||
return isAlias ? mainSessionKey : raw;
|
||||
}
|
||||
|
||||
function applySessionDefaults(host: GatewayHost, defaults?: SessionDefaultsSnapshot) {
|
||||
if (!defaults?.mainSessionKey) {
|
||||
return;
|
||||
}
|
||||
const resolvedSessionKey = normalizeSessionKeyForDefaults(host.sessionKey, defaults);
|
||||
const resolvedSettingsSessionKey = normalizeSessionKeyForDefaults(
|
||||
host.settings.sessionKey,
|
||||
defaults,
|
||||
);
|
||||
const resolvedLastActiveSessionKey = normalizeSessionKeyForDefaults(
|
||||
host.settings.lastActiveSessionKey,
|
||||
defaults,
|
||||
);
|
||||
const nextSessionKey = resolvedSessionKey || resolvedSettingsSessionKey || host.sessionKey;
|
||||
const nextSettings = {
|
||||
...host.settings,
|
||||
sessionKey: resolvedSettingsSessionKey || nextSessionKey,
|
||||
lastActiveSessionKey: resolvedLastActiveSessionKey || nextSessionKey,
|
||||
};
|
||||
const shouldUpdateSettings =
|
||||
nextSettings.sessionKey !== host.settings.sessionKey ||
|
||||
nextSettings.lastActiveSessionKey !== host.settings.lastActiveSessionKey;
|
||||
if (nextSessionKey !== host.sessionKey) {
|
||||
host.sessionKey = nextSessionKey;
|
||||
}
|
||||
if (shouldUpdateSettings) {
|
||||
applySettings(host as unknown as Parameters<typeof applySettings>[0], nextSettings);
|
||||
}
|
||||
}
|
||||
|
||||
export function connectGateway(host: GatewayHost) {
|
||||
host.lastError = null;
|
||||
host.lastErrorCode = null;
|
||||
host.hello = null;
|
||||
host.connected = false;
|
||||
host.execApprovalQueue = [];
|
||||
host.execApprovalError = null;
|
||||
|
||||
const previousClient = host.client;
|
||||
const client = new GatewayBrowserClient({
|
||||
url: host.settings.gatewayUrl,
|
||||
token: host.settings.token.trim() ? host.settings.token : undefined,
|
||||
password: host.password.trim() ? host.password : undefined,
|
||||
clientName: "openclaw-control-ui",
|
||||
mode: "webchat",
|
||||
instanceId: host.clientInstanceId,
|
||||
onHello: (hello) => {
|
||||
if (host.client !== client) {
|
||||
return;
|
||||
}
|
||||
host.connected = true;
|
||||
host.lastError = null;
|
||||
host.lastErrorCode = null;
|
||||
host.hello = hello;
|
||||
applySnapshot(host, hello);
|
||||
// Reset orphaned chat run state from before disconnect.
|
||||
// Any in-flight run's final event was lost during the disconnect window.
|
||||
host.chatRunId = null;
|
||||
(host as unknown as { chatStream: string | null }).chatStream = null;
|
||||
(host as unknown as { chatStreamStartedAt: number | null }).chatStreamStartedAt = null;
|
||||
resetToolStream(host as unknown as Parameters<typeof resetToolStream>[0]);
|
||||
void loadAssistantIdentity(host as unknown as OpenClawApp);
|
||||
void loadAgents(host as unknown as OpenClawApp);
|
||||
void loadToolsCatalog(host as unknown as OpenClawApp);
|
||||
void loadNodes(host as unknown as OpenClawApp, { quiet: true });
|
||||
void loadDevices(host as unknown as OpenClawApp, { quiet: true });
|
||||
void refreshActiveTab(host as unknown as Parameters<typeof refreshActiveTab>[0]);
|
||||
},
|
||||
onClose: ({ code, reason, error }) => {
|
||||
if (host.client !== client) {
|
||||
return;
|
||||
}
|
||||
host.connected = false;
|
||||
// Code 1012 = Service Restart (expected during config saves, don't show as error)
|
||||
host.lastErrorCode =
|
||||
resolveGatewayErrorDetailCode(error) ??
|
||||
(typeof error?.code === "string" ? error.code : null);
|
||||
if (code !== 1012) {
|
||||
if (error?.message) {
|
||||
host.lastError = error.message;
|
||||
return;
|
||||
}
|
||||
host.lastError = `disconnected (${code}): ${reason || "no reason"}`;
|
||||
} else {
|
||||
host.lastError = null;
|
||||
host.lastErrorCode = null;
|
||||
}
|
||||
},
|
||||
onEvent: (evt) => {
|
||||
if (host.client !== client) {
|
||||
return;
|
||||
}
|
||||
handleGatewayEvent(host, evt);
|
||||
},
|
||||
onGap: ({ expected, received }) => {
|
||||
if (host.client !== client) {
|
||||
return;
|
||||
}
|
||||
host.lastError = `event gap detected (expected seq ${expected}, got ${received}); refresh recommended`;
|
||||
host.lastErrorCode = null;
|
||||
},
|
||||
});
|
||||
host.client = client;
|
||||
previousClient?.stop();
|
||||
client.start();
|
||||
}
|
||||
|
||||
export function handleGatewayEvent(host: GatewayHost, evt: GatewayEventFrame) {
|
||||
try {
|
||||
handleGatewayEventUnsafe(host, evt);
|
||||
} catch (err) {
|
||||
console.error("[gateway] handleGatewayEvent error:", evt.event, err);
|
||||
}
|
||||
}
|
||||
|
||||
function handleTerminalChatEvent(
|
||||
host: GatewayHost,
|
||||
payload: ChatEventPayload | undefined,
|
||||
state: ReturnType<typeof handleChatEvent>,
|
||||
) {
|
||||
if (state !== "final" && state !== "error" && state !== "aborted") {
|
||||
return;
|
||||
}
|
||||
resetToolStream(host as unknown as Parameters<typeof resetToolStream>[0]);
|
||||
void flushChatQueueForEvent(host as unknown as Parameters<typeof flushChatQueueForEvent>[0]);
|
||||
const runId = payload?.runId;
|
||||
if (!runId || !host.refreshSessionsAfterChat.has(runId)) {
|
||||
return;
|
||||
}
|
||||
host.refreshSessionsAfterChat.delete(runId);
|
||||
if (state === "final") {
|
||||
void loadSessions(host as unknown as OpenClawApp, {
|
||||
activeMinutes: CHAT_SESSIONS_ACTIVE_MINUTES,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleChatGatewayEvent(host: GatewayHost, payload: ChatEventPayload | undefined) {
|
||||
if (payload?.sessionKey) {
|
||||
setLastActiveSessionKey(
|
||||
host as unknown as Parameters<typeof setLastActiveSessionKey>[0],
|
||||
payload.sessionKey,
|
||||
);
|
||||
}
|
||||
const state = handleChatEvent(host as unknown as OpenClawApp, payload);
|
||||
handleTerminalChatEvent(host, payload, state);
|
||||
if (state === "final" && shouldReloadHistoryForFinalEvent(payload)) {
|
||||
void loadChatHistory(host as unknown as OpenClawApp);
|
||||
}
|
||||
}
|
||||
|
||||
function handleGatewayEventUnsafe(host: GatewayHost, evt: GatewayEventFrame) {
|
||||
host.eventLogBuffer = [
|
||||
{ ts: Date.now(), event: evt.event, payload: evt.payload },
|
||||
...host.eventLogBuffer,
|
||||
].slice(0, 250);
|
||||
if (host.tab === "debug") {
|
||||
host.eventLog = host.eventLogBuffer;
|
||||
}
|
||||
|
||||
if (evt.event === "agent") {
|
||||
if (host.onboarding) {
|
||||
return;
|
||||
}
|
||||
handleAgentEvent(
|
||||
host as unknown as Parameters<typeof handleAgentEvent>[0],
|
||||
evt.payload as AgentEventPayload | undefined,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.event === "chat") {
|
||||
handleChatGatewayEvent(host, evt.payload as ChatEventPayload | undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.event === "presence") {
|
||||
const payload = evt.payload as { presence?: PresenceEntry[] } | undefined;
|
||||
if (payload?.presence && Array.isArray(payload.presence)) {
|
||||
host.presenceEntries = payload.presence;
|
||||
host.presenceError = null;
|
||||
host.presenceStatus = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.event === "cron" && host.tab === "cron") {
|
||||
void loadCron(host as unknown as Parameters<typeof loadCron>[0]);
|
||||
}
|
||||
|
||||
if (evt.event === "device.pair.requested" || evt.event === "device.pair.resolved") {
|
||||
void loadDevices(host as unknown as OpenClawApp, { quiet: true });
|
||||
}
|
||||
|
||||
if (evt.event === "exec.approval.requested") {
|
||||
const entry = parseExecApprovalRequested(evt.payload);
|
||||
if (entry) {
|
||||
host.execApprovalQueue = addExecApproval(host.execApprovalQueue, entry);
|
||||
host.execApprovalError = null;
|
||||
const delay = Math.max(0, entry.expiresAtMs - Date.now() + 500);
|
||||
window.setTimeout(() => {
|
||||
host.execApprovalQueue = removeExecApproval(host.execApprovalQueue, entry.id);
|
||||
}, delay);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.event === "exec.approval.resolved") {
|
||||
const resolved = parseExecApprovalResolved(evt.payload);
|
||||
if (resolved) {
|
||||
host.execApprovalQueue = removeExecApproval(host.execApprovalQueue, resolved.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.event === GATEWAY_EVENT_UPDATE_AVAILABLE) {
|
||||
const payload = evt.payload as GatewayUpdateAvailableEventPayload | undefined;
|
||||
host.updateAvailable = payload?.updateAvailable ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
export function applySnapshot(host: GatewayHost, hello: GatewayHelloOk) {
|
||||
const snapshot = hello.snapshot as
|
||||
| {
|
||||
presence?: PresenceEntry[];
|
||||
health?: HealthSnapshot;
|
||||
sessionDefaults?: SessionDefaultsSnapshot;
|
||||
updateAvailable?: UpdateAvailable;
|
||||
}
|
||||
| undefined;
|
||||
if (snapshot?.presence && Array.isArray(snapshot.presence)) {
|
||||
host.presenceEntries = snapshot.presence;
|
||||
}
|
||||
if (snapshot?.health) {
|
||||
host.debugHealth = snapshot.health;
|
||||
}
|
||||
if (snapshot?.sessionDefaults) {
|
||||
applySessionDefaults(host, snapshot.sessionDefaults);
|
||||
}
|
||||
host.updateAvailable = snapshot?.updateAvailable ?? null;
|
||||
}
|
||||
44
openclaw/ui/src/ui/app-lifecycle.node.test.ts
Normal file
44
openclaw/ui/src/ui/app-lifecycle.node.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { handleDisconnected } from "./app-lifecycle.ts";
|
||||
|
||||
function createHost() {
|
||||
return {
|
||||
basePath: "",
|
||||
client: { stop: vi.fn() },
|
||||
connected: true,
|
||||
tab: "chat",
|
||||
assistantName: "OpenClaw",
|
||||
assistantAvatar: null,
|
||||
assistantAgentId: null,
|
||||
chatHasAutoScrolled: false,
|
||||
chatManualRefreshInFlight: false,
|
||||
chatLoading: false,
|
||||
chatMessages: [],
|
||||
chatToolMessages: [],
|
||||
chatStream: null,
|
||||
logsAutoFollow: false,
|
||||
logsAtBottom: true,
|
||||
logsEntries: [],
|
||||
popStateHandler: vi.fn(),
|
||||
topbarObserver: { disconnect: vi.fn() } as unknown as ResizeObserver,
|
||||
};
|
||||
}
|
||||
|
||||
describe("handleDisconnected", () => {
|
||||
it("stops and clears gateway client on teardown", () => {
|
||||
const removeSpy = vi.spyOn(window, "removeEventListener").mockImplementation(() => undefined);
|
||||
const host = createHost();
|
||||
const disconnectSpy = (
|
||||
host.topbarObserver as unknown as { disconnect: ReturnType<typeof vi.fn> }
|
||||
).disconnect;
|
||||
|
||||
handleDisconnected(host as unknown as Parameters<typeof handleDisconnected>[0]);
|
||||
|
||||
expect(removeSpy).toHaveBeenCalledWith("popstate", host.popStateHandler);
|
||||
expect(host.client).toBeNull();
|
||||
expect(host.connected).toBe(false);
|
||||
expect(disconnectSpy).toHaveBeenCalledTimes(1);
|
||||
expect(host.topbarObserver).toBeNull();
|
||||
removeSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
109
openclaw/ui/src/ui/app-lifecycle.ts
Normal file
109
openclaw/ui/src/ui/app-lifecycle.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { connectGateway } from "./app-gateway.ts";
|
||||
import {
|
||||
startLogsPolling,
|
||||
startNodesPolling,
|
||||
stopLogsPolling,
|
||||
stopNodesPolling,
|
||||
startDebugPolling,
|
||||
stopDebugPolling,
|
||||
} from "./app-polling.ts";
|
||||
import { observeTopbar, scheduleChatScroll, scheduleLogsScroll } from "./app-scroll.ts";
|
||||
import {
|
||||
applySettingsFromUrl,
|
||||
attachThemeListener,
|
||||
detachThemeListener,
|
||||
inferBasePath,
|
||||
syncTabWithLocation,
|
||||
syncThemeWithSettings,
|
||||
} from "./app-settings.ts";
|
||||
import { loadControlUiBootstrapConfig } from "./controllers/control-ui-bootstrap.ts";
|
||||
import type { Tab } from "./navigation.ts";
|
||||
|
||||
type LifecycleHost = {
|
||||
basePath: string;
|
||||
client?: { stop: () => void } | null;
|
||||
connected?: boolean;
|
||||
tab: Tab;
|
||||
assistantName: string;
|
||||
assistantAvatar: string | null;
|
||||
assistantAgentId: string | null;
|
||||
chatHasAutoScrolled: boolean;
|
||||
chatManualRefreshInFlight: boolean;
|
||||
chatLoading: boolean;
|
||||
chatMessages: unknown[];
|
||||
chatToolMessages: unknown[];
|
||||
chatStream: string;
|
||||
logsAutoFollow: boolean;
|
||||
logsAtBottom: boolean;
|
||||
logsEntries: unknown[];
|
||||
popStateHandler: () => void;
|
||||
topbarObserver: ResizeObserver | null;
|
||||
};
|
||||
|
||||
export function handleConnected(host: LifecycleHost) {
|
||||
host.basePath = inferBasePath();
|
||||
void loadControlUiBootstrapConfig(host);
|
||||
applySettingsFromUrl(host as unknown as Parameters<typeof applySettingsFromUrl>[0]);
|
||||
syncTabWithLocation(host as unknown as Parameters<typeof syncTabWithLocation>[0], true);
|
||||
syncThemeWithSettings(host as unknown as Parameters<typeof syncThemeWithSettings>[0]);
|
||||
attachThemeListener(host as unknown as Parameters<typeof attachThemeListener>[0]);
|
||||
window.addEventListener("popstate", host.popStateHandler);
|
||||
connectGateway(host as unknown as Parameters<typeof connectGateway>[0]);
|
||||
startNodesPolling(host as unknown as Parameters<typeof startNodesPolling>[0]);
|
||||
if (host.tab === "logs") {
|
||||
startLogsPolling(host as unknown as Parameters<typeof startLogsPolling>[0]);
|
||||
}
|
||||
if (host.tab === "debug") {
|
||||
startDebugPolling(host as unknown as Parameters<typeof startDebugPolling>[0]);
|
||||
}
|
||||
}
|
||||
|
||||
export function handleFirstUpdated(host: LifecycleHost) {
|
||||
observeTopbar(host as unknown as Parameters<typeof observeTopbar>[0]);
|
||||
}
|
||||
|
||||
export function handleDisconnected(host: LifecycleHost) {
|
||||
window.removeEventListener("popstate", host.popStateHandler);
|
||||
stopNodesPolling(host as unknown as Parameters<typeof stopNodesPolling>[0]);
|
||||
stopLogsPolling(host as unknown as Parameters<typeof stopLogsPolling>[0]);
|
||||
stopDebugPolling(host as unknown as Parameters<typeof stopDebugPolling>[0]);
|
||||
host.client?.stop();
|
||||
host.client = null;
|
||||
host.connected = false;
|
||||
detachThemeListener(host as unknown as Parameters<typeof detachThemeListener>[0]);
|
||||
host.topbarObserver?.disconnect();
|
||||
host.topbarObserver = null;
|
||||
}
|
||||
|
||||
export function handleUpdated(host: LifecycleHost, changed: Map<PropertyKey, unknown>) {
|
||||
if (host.tab === "chat" && host.chatManualRefreshInFlight) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
host.tab === "chat" &&
|
||||
(changed.has("chatMessages") ||
|
||||
changed.has("chatToolMessages") ||
|
||||
changed.has("chatStream") ||
|
||||
changed.has("chatLoading") ||
|
||||
changed.has("tab"))
|
||||
) {
|
||||
const forcedByTab = changed.has("tab");
|
||||
const forcedByLoad =
|
||||
changed.has("chatLoading") && changed.get("chatLoading") === true && !host.chatLoading;
|
||||
scheduleChatScroll(
|
||||
host as unknown as Parameters<typeof scheduleChatScroll>[0],
|
||||
forcedByTab || forcedByLoad || !host.chatHasAutoScrolled,
|
||||
);
|
||||
}
|
||||
if (
|
||||
host.tab === "logs" &&
|
||||
(changed.has("logsEntries") || changed.has("logsAutoFollow") || changed.has("tab"))
|
||||
) {
|
||||
if (host.logsAutoFollow && host.logsAtBottom) {
|
||||
scheduleLogsScroll(
|
||||
host as unknown as Parameters<typeof scheduleLogsScroll>[0],
|
||||
changed.has("tab") || changed.has("logsAutoFollow"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
69
openclaw/ui/src/ui/app-polling.ts
Normal file
69
openclaw/ui/src/ui/app-polling.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { OpenClawApp } from "./app.ts";
|
||||
import { loadDebug } from "./controllers/debug.ts";
|
||||
import { loadLogs } from "./controllers/logs.ts";
|
||||
import { loadNodes } from "./controllers/nodes.ts";
|
||||
|
||||
type PollingHost = {
|
||||
nodesPollInterval: number | null;
|
||||
logsPollInterval: number | null;
|
||||
debugPollInterval: number | null;
|
||||
tab: string;
|
||||
};
|
||||
|
||||
export function startNodesPolling(host: PollingHost) {
|
||||
if (host.nodesPollInterval != null) {
|
||||
return;
|
||||
}
|
||||
host.nodesPollInterval = window.setInterval(
|
||||
() => void loadNodes(host as unknown as OpenClawApp, { quiet: true }),
|
||||
5000,
|
||||
);
|
||||
}
|
||||
|
||||
export function stopNodesPolling(host: PollingHost) {
|
||||
if (host.nodesPollInterval == null) {
|
||||
return;
|
||||
}
|
||||
clearInterval(host.nodesPollInterval);
|
||||
host.nodesPollInterval = null;
|
||||
}
|
||||
|
||||
export function startLogsPolling(host: PollingHost) {
|
||||
if (host.logsPollInterval != null) {
|
||||
return;
|
||||
}
|
||||
host.logsPollInterval = window.setInterval(() => {
|
||||
if (host.tab !== "logs") {
|
||||
return;
|
||||
}
|
||||
void loadLogs(host as unknown as OpenClawApp, { quiet: true });
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
export function stopLogsPolling(host: PollingHost) {
|
||||
if (host.logsPollInterval == null) {
|
||||
return;
|
||||
}
|
||||
clearInterval(host.logsPollInterval);
|
||||
host.logsPollInterval = null;
|
||||
}
|
||||
|
||||
export function startDebugPolling(host: PollingHost) {
|
||||
if (host.debugPollInterval != null) {
|
||||
return;
|
||||
}
|
||||
host.debugPollInterval = window.setInterval(() => {
|
||||
if (host.tab !== "debug") {
|
||||
return;
|
||||
}
|
||||
void loadDebug(host as unknown as OpenClawApp);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
export function stopDebugPolling(host: PollingHost) {
|
||||
if (host.debugPollInterval == null) {
|
||||
return;
|
||||
}
|
||||
clearInterval(host.debugPollInterval);
|
||||
host.debugPollInterval = null;
|
||||
}
|
||||
273
openclaw/ui/src/ui/app-render-usage-tab.ts
Normal file
273
openclaw/ui/src/ui/app-render-usage-tab.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import { nothing } from "lit";
|
||||
import type { AppViewState } from "./app-view-state.ts";
|
||||
import type { UsageState } from "./controllers/usage.ts";
|
||||
import { loadUsage, loadSessionTimeSeries, loadSessionLogs } from "./controllers/usage.ts";
|
||||
import { renderUsage } from "./views/usage.ts";
|
||||
|
||||
// Module-scope debounce for usage date changes (avoids type-unsafe hacks on state object)
|
||||
let usageDateDebounceTimeout: number | null = null;
|
||||
const debouncedLoadUsage = (state: UsageState) => {
|
||||
if (usageDateDebounceTimeout) {
|
||||
clearTimeout(usageDateDebounceTimeout);
|
||||
}
|
||||
usageDateDebounceTimeout = window.setTimeout(() => void loadUsage(state), 400);
|
||||
};
|
||||
|
||||
export function renderUsageTab(state: AppViewState) {
|
||||
if (state.tab !== "usage") {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return renderUsage({
|
||||
loading: state.usageLoading,
|
||||
error: state.usageError,
|
||||
startDate: state.usageStartDate,
|
||||
endDate: state.usageEndDate,
|
||||
sessions: state.usageResult?.sessions ?? [],
|
||||
sessionsLimitReached: (state.usageResult?.sessions?.length ?? 0) >= 1000,
|
||||
totals: state.usageResult?.totals ?? null,
|
||||
aggregates: state.usageResult?.aggregates ?? null,
|
||||
costDaily: state.usageCostSummary?.daily ?? [],
|
||||
selectedSessions: state.usageSelectedSessions,
|
||||
selectedDays: state.usageSelectedDays,
|
||||
selectedHours: state.usageSelectedHours,
|
||||
chartMode: state.usageChartMode,
|
||||
dailyChartMode: state.usageDailyChartMode,
|
||||
timeSeriesMode: state.usageTimeSeriesMode,
|
||||
timeSeriesBreakdownMode: state.usageTimeSeriesBreakdownMode,
|
||||
timeSeries: state.usageTimeSeries,
|
||||
timeSeriesLoading: state.usageTimeSeriesLoading,
|
||||
timeSeriesCursorStart: state.usageTimeSeriesCursorStart,
|
||||
timeSeriesCursorEnd: state.usageTimeSeriesCursorEnd,
|
||||
sessionLogs: state.usageSessionLogs,
|
||||
sessionLogsLoading: state.usageSessionLogsLoading,
|
||||
sessionLogsExpanded: state.usageSessionLogsExpanded,
|
||||
logFilterRoles: state.usageLogFilterRoles,
|
||||
logFilterTools: state.usageLogFilterTools,
|
||||
logFilterHasTools: state.usageLogFilterHasTools,
|
||||
logFilterQuery: state.usageLogFilterQuery,
|
||||
query: state.usageQuery,
|
||||
queryDraft: state.usageQueryDraft,
|
||||
sessionSort: state.usageSessionSort,
|
||||
sessionSortDir: state.usageSessionSortDir,
|
||||
recentSessions: state.usageRecentSessions,
|
||||
sessionsTab: state.usageSessionsTab,
|
||||
visibleColumns: state.usageVisibleColumns as import("./views/usage.ts").UsageColumnId[],
|
||||
timeZone: state.usageTimeZone,
|
||||
contextExpanded: state.usageContextExpanded,
|
||||
headerPinned: state.usageHeaderPinned,
|
||||
onStartDateChange: (date) => {
|
||||
state.usageStartDate = date;
|
||||
state.usageSelectedDays = [];
|
||||
state.usageSelectedHours = [];
|
||||
state.usageSelectedSessions = [];
|
||||
debouncedLoadUsage(state);
|
||||
},
|
||||
onEndDateChange: (date) => {
|
||||
state.usageEndDate = date;
|
||||
state.usageSelectedDays = [];
|
||||
state.usageSelectedHours = [];
|
||||
state.usageSelectedSessions = [];
|
||||
debouncedLoadUsage(state);
|
||||
},
|
||||
onRefresh: () => loadUsage(state),
|
||||
onTimeZoneChange: (zone) => {
|
||||
state.usageTimeZone = zone;
|
||||
state.usageSelectedDays = [];
|
||||
state.usageSelectedHours = [];
|
||||
state.usageSelectedSessions = [];
|
||||
void loadUsage(state);
|
||||
},
|
||||
onToggleContextExpanded: () => {
|
||||
state.usageContextExpanded = !state.usageContextExpanded;
|
||||
},
|
||||
onToggleSessionLogsExpanded: () => {
|
||||
state.usageSessionLogsExpanded = !state.usageSessionLogsExpanded;
|
||||
},
|
||||
onLogFilterRolesChange: (next) => {
|
||||
state.usageLogFilterRoles = next;
|
||||
},
|
||||
onLogFilterToolsChange: (next) => {
|
||||
state.usageLogFilterTools = next;
|
||||
},
|
||||
onLogFilterHasToolsChange: (next) => {
|
||||
state.usageLogFilterHasTools = next;
|
||||
},
|
||||
onLogFilterQueryChange: (next) => {
|
||||
state.usageLogFilterQuery = next;
|
||||
},
|
||||
onLogFilterClear: () => {
|
||||
state.usageLogFilterRoles = [];
|
||||
state.usageLogFilterTools = [];
|
||||
state.usageLogFilterHasTools = false;
|
||||
state.usageLogFilterQuery = "";
|
||||
},
|
||||
onToggleHeaderPinned: () => {
|
||||
state.usageHeaderPinned = !state.usageHeaderPinned;
|
||||
},
|
||||
onSelectHour: (hour, shiftKey) => {
|
||||
if (shiftKey && state.usageSelectedHours.length > 0) {
|
||||
const allHours = Array.from({ length: 24 }, (_, i) => i);
|
||||
const lastSelected = state.usageSelectedHours[state.usageSelectedHours.length - 1];
|
||||
const lastIdx = allHours.indexOf(lastSelected);
|
||||
const thisIdx = allHours.indexOf(hour);
|
||||
if (lastIdx !== -1 && thisIdx !== -1) {
|
||||
const [start, end] = lastIdx < thisIdx ? [lastIdx, thisIdx] : [thisIdx, lastIdx];
|
||||
const range = allHours.slice(start, end + 1);
|
||||
state.usageSelectedHours = [...new Set([...state.usageSelectedHours, ...range])];
|
||||
}
|
||||
} else {
|
||||
if (state.usageSelectedHours.includes(hour)) {
|
||||
state.usageSelectedHours = state.usageSelectedHours.filter((h) => h !== hour);
|
||||
} else {
|
||||
state.usageSelectedHours = [...state.usageSelectedHours, hour];
|
||||
}
|
||||
}
|
||||
},
|
||||
onQueryDraftChange: (query) => {
|
||||
state.usageQueryDraft = query;
|
||||
if (state.usageQueryDebounceTimer) {
|
||||
window.clearTimeout(state.usageQueryDebounceTimer);
|
||||
}
|
||||
state.usageQueryDebounceTimer = window.setTimeout(() => {
|
||||
state.usageQuery = state.usageQueryDraft;
|
||||
state.usageQueryDebounceTimer = null;
|
||||
}, 250);
|
||||
},
|
||||
onApplyQuery: () => {
|
||||
if (state.usageQueryDebounceTimer) {
|
||||
window.clearTimeout(state.usageQueryDebounceTimer);
|
||||
state.usageQueryDebounceTimer = null;
|
||||
}
|
||||
state.usageQuery = state.usageQueryDraft;
|
||||
},
|
||||
onClearQuery: () => {
|
||||
if (state.usageQueryDebounceTimer) {
|
||||
window.clearTimeout(state.usageQueryDebounceTimer);
|
||||
state.usageQueryDebounceTimer = null;
|
||||
}
|
||||
state.usageQueryDraft = "";
|
||||
state.usageQuery = "";
|
||||
},
|
||||
onSessionSortChange: (sort) => {
|
||||
state.usageSessionSort = sort;
|
||||
},
|
||||
onSessionSortDirChange: (dir) => {
|
||||
state.usageSessionSortDir = dir;
|
||||
},
|
||||
onSessionsTabChange: (tab) => {
|
||||
state.usageSessionsTab = tab;
|
||||
},
|
||||
onToggleColumn: (column) => {
|
||||
if (state.usageVisibleColumns.includes(column)) {
|
||||
state.usageVisibleColumns = state.usageVisibleColumns.filter((entry) => entry !== column);
|
||||
} else {
|
||||
state.usageVisibleColumns = [...state.usageVisibleColumns, column];
|
||||
}
|
||||
},
|
||||
onSelectSession: (key, shiftKey) => {
|
||||
state.usageTimeSeries = null;
|
||||
state.usageSessionLogs = null;
|
||||
state.usageRecentSessions = [
|
||||
key,
|
||||
...state.usageRecentSessions.filter((entry) => entry !== key),
|
||||
].slice(0, 8);
|
||||
|
||||
if (shiftKey && state.usageSelectedSessions.length > 0) {
|
||||
// Shift-click: select range from last selected to this session
|
||||
// Sort sessions same way as displayed (by tokens or cost descending)
|
||||
const isTokenMode = state.usageChartMode === "tokens";
|
||||
const sortedSessions = [...(state.usageResult?.sessions ?? [])].toSorted((a, b) => {
|
||||
const valA = isTokenMode ? (a.usage?.totalTokens ?? 0) : (a.usage?.totalCost ?? 0);
|
||||
const valB = isTokenMode ? (b.usage?.totalTokens ?? 0) : (b.usage?.totalCost ?? 0);
|
||||
return valB - valA;
|
||||
});
|
||||
const allKeys = sortedSessions.map((s) => s.key);
|
||||
const lastSelected = state.usageSelectedSessions[state.usageSelectedSessions.length - 1];
|
||||
const lastIdx = allKeys.indexOf(lastSelected);
|
||||
const thisIdx = allKeys.indexOf(key);
|
||||
if (lastIdx !== -1 && thisIdx !== -1) {
|
||||
const [start, end] = lastIdx < thisIdx ? [lastIdx, thisIdx] : [thisIdx, lastIdx];
|
||||
const range = allKeys.slice(start, end + 1);
|
||||
const newSelection = [...new Set([...state.usageSelectedSessions, ...range])];
|
||||
state.usageSelectedSessions = newSelection;
|
||||
}
|
||||
} else {
|
||||
// Regular click: focus a single session (so details always open).
|
||||
// Click the focused session again to clear selection.
|
||||
if (state.usageSelectedSessions.length === 1 && state.usageSelectedSessions[0] === key) {
|
||||
state.usageSelectedSessions = [];
|
||||
} else {
|
||||
state.usageSelectedSessions = [key];
|
||||
}
|
||||
}
|
||||
|
||||
// Reset range selection when switching sessions
|
||||
state.usageTimeSeriesCursorStart = null;
|
||||
state.usageTimeSeriesCursorEnd = null;
|
||||
|
||||
// Load timeseries/logs only if exactly one session selected
|
||||
if (state.usageSelectedSessions.length === 1) {
|
||||
void loadSessionTimeSeries(state, state.usageSelectedSessions[0]);
|
||||
void loadSessionLogs(state, state.usageSelectedSessions[0]);
|
||||
}
|
||||
},
|
||||
onSelectDay: (day, shiftKey) => {
|
||||
if (shiftKey && state.usageSelectedDays.length > 0) {
|
||||
// Shift-click: select range from last selected to this day
|
||||
const allDays = (state.usageCostSummary?.daily ?? []).map((d) => d.date);
|
||||
const lastSelected = state.usageSelectedDays[state.usageSelectedDays.length - 1];
|
||||
const lastIdx = allDays.indexOf(lastSelected);
|
||||
const thisIdx = allDays.indexOf(day);
|
||||
if (lastIdx !== -1 && thisIdx !== -1) {
|
||||
const [start, end] = lastIdx < thisIdx ? [lastIdx, thisIdx] : [thisIdx, lastIdx];
|
||||
const range = allDays.slice(start, end + 1);
|
||||
// Merge with existing selection
|
||||
const newSelection = [...new Set([...state.usageSelectedDays, ...range])];
|
||||
state.usageSelectedDays = newSelection;
|
||||
}
|
||||
} else {
|
||||
// Regular click: toggle single day
|
||||
if (state.usageSelectedDays.includes(day)) {
|
||||
state.usageSelectedDays = state.usageSelectedDays.filter((d) => d !== day);
|
||||
} else {
|
||||
state.usageSelectedDays = [day];
|
||||
}
|
||||
}
|
||||
},
|
||||
onChartModeChange: (mode) => {
|
||||
state.usageChartMode = mode;
|
||||
},
|
||||
onDailyChartModeChange: (mode) => {
|
||||
state.usageDailyChartMode = mode;
|
||||
},
|
||||
onTimeSeriesModeChange: (mode) => {
|
||||
state.usageTimeSeriesMode = mode;
|
||||
},
|
||||
onTimeSeriesBreakdownChange: (mode) => {
|
||||
state.usageTimeSeriesBreakdownMode = mode;
|
||||
},
|
||||
onTimeSeriesCursorRangeChange: (start, end) => {
|
||||
state.usageTimeSeriesCursorStart = start;
|
||||
state.usageTimeSeriesCursorEnd = end;
|
||||
},
|
||||
onClearDays: () => {
|
||||
state.usageSelectedDays = [];
|
||||
},
|
||||
onClearHours: () => {
|
||||
state.usageSelectedHours = [];
|
||||
},
|
||||
onClearSessions: () => {
|
||||
state.usageSelectedSessions = [];
|
||||
state.usageTimeSeries = null;
|
||||
state.usageSessionLogs = null;
|
||||
},
|
||||
onClearFilters: () => {
|
||||
state.usageSelectedDays = [];
|
||||
state.usageSelectedHours = [];
|
||||
state.usageSelectedSessions = [];
|
||||
state.usageTimeSeries = null;
|
||||
state.usageSessionLogs = null;
|
||||
},
|
||||
});
|
||||
}
|
||||
263
openclaw/ui/src/ui/app-render.helpers.node.test.ts
Normal file
263
openclaw/ui/src/ui/app-render.helpers.node.test.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseSessionKey, resolveSessionDisplayName } from "./app-render.helpers.ts";
|
||||
import type { SessionsListResult } from "./types.ts";
|
||||
|
||||
type SessionRow = SessionsListResult["sessions"][number];
|
||||
|
||||
function row(overrides: Partial<SessionRow> & { key: string }): SessionRow {
|
||||
return { kind: "direct", updatedAt: 0, ...overrides };
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
* parseSessionKey – low-level key → type / fallback mapping
|
||||
* ================================================================ */
|
||||
|
||||
describe("parseSessionKey", () => {
|
||||
it("identifies main session (bare 'main')", () => {
|
||||
expect(parseSessionKey("main")).toEqual({ prefix: "", fallbackName: "Main Session" });
|
||||
});
|
||||
|
||||
it("identifies main session (agent:main:main)", () => {
|
||||
expect(parseSessionKey("agent:main:main")).toEqual({
|
||||
prefix: "",
|
||||
fallbackName: "Main Session",
|
||||
});
|
||||
});
|
||||
|
||||
it("identifies subagent sessions", () => {
|
||||
expect(parseSessionKey("agent:main:subagent:18abfefe-1fa6-43cb-8ba8-ebdc9b43e253")).toEqual({
|
||||
prefix: "Subagent:",
|
||||
fallbackName: "Subagent:",
|
||||
});
|
||||
});
|
||||
|
||||
it("identifies cron sessions", () => {
|
||||
expect(parseSessionKey("agent:main:cron:daily-briefing-uuid")).toEqual({
|
||||
prefix: "Cron:",
|
||||
fallbackName: "Cron Job:",
|
||||
});
|
||||
});
|
||||
|
||||
it("identifies direct chat with known channel", () => {
|
||||
expect(parseSessionKey("agent:main:bluebubbles:direct:+19257864429")).toEqual({
|
||||
prefix: "",
|
||||
fallbackName: "iMessage · +19257864429",
|
||||
});
|
||||
});
|
||||
|
||||
it("identifies direct chat with telegram", () => {
|
||||
expect(parseSessionKey("agent:main:telegram:direct:user123")).toEqual({
|
||||
prefix: "",
|
||||
fallbackName: "Telegram · user123",
|
||||
});
|
||||
});
|
||||
|
||||
it("identifies group chat with known channel", () => {
|
||||
expect(parseSessionKey("agent:main:discord:group:guild-chan")).toEqual({
|
||||
prefix: "",
|
||||
fallbackName: "Discord Group",
|
||||
});
|
||||
});
|
||||
|
||||
it("capitalises unknown channels in direct/group patterns", () => {
|
||||
expect(parseSessionKey("agent:main:mychannel:direct:user1")).toEqual({
|
||||
prefix: "",
|
||||
fallbackName: "Mychannel · user1",
|
||||
});
|
||||
});
|
||||
|
||||
it("identifies channel-prefixed legacy keys", () => {
|
||||
expect(parseSessionKey("bluebubbles:g-agent-main-bluebubbles-direct-+19257864429")).toEqual({
|
||||
prefix: "",
|
||||
fallbackName: "iMessage Session",
|
||||
});
|
||||
expect(parseSessionKey("discord:123:456")).toEqual({
|
||||
prefix: "",
|
||||
fallbackName: "Discord Session",
|
||||
});
|
||||
});
|
||||
|
||||
it("handles bare channel name as key", () => {
|
||||
expect(parseSessionKey("telegram")).toEqual({
|
||||
prefix: "",
|
||||
fallbackName: "Telegram Session",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns raw key for unknown patterns", () => {
|
||||
expect(parseSessionKey("something-unknown")).toEqual({
|
||||
prefix: "",
|
||||
fallbackName: "something-unknown",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/* ================================================================
|
||||
* resolveSessionDisplayName – full resolution with row data
|
||||
* ================================================================ */
|
||||
|
||||
describe("resolveSessionDisplayName", () => {
|
||||
// ── Key-only fallbacks (no row) ──────────────────
|
||||
|
||||
it("returns 'Main Session' for agent:main:main key", () => {
|
||||
expect(resolveSessionDisplayName("agent:main:main")).toBe("Main Session");
|
||||
});
|
||||
|
||||
it("returns 'Main Session' for bare 'main' key", () => {
|
||||
expect(resolveSessionDisplayName("main")).toBe("Main Session");
|
||||
});
|
||||
|
||||
it("returns 'Subagent:' for subagent key without row", () => {
|
||||
expect(resolveSessionDisplayName("agent:main:subagent:abc-123")).toBe("Subagent:");
|
||||
});
|
||||
|
||||
it("returns 'Cron Job:' for cron key without row", () => {
|
||||
expect(resolveSessionDisplayName("agent:main:cron:abc-123")).toBe("Cron Job:");
|
||||
});
|
||||
|
||||
it("parses direct chat key with channel", () => {
|
||||
expect(resolveSessionDisplayName("agent:main:bluebubbles:direct:+19257864429")).toBe(
|
||||
"iMessage · +19257864429",
|
||||
);
|
||||
});
|
||||
|
||||
it("parses channel-prefixed legacy key", () => {
|
||||
expect(resolveSessionDisplayName("discord:123:456")).toBe("Discord Session");
|
||||
});
|
||||
|
||||
it("returns raw key for unknown patterns", () => {
|
||||
expect(resolveSessionDisplayName("something-custom")).toBe("something-custom");
|
||||
});
|
||||
|
||||
// ── With row data (label / displayName) ──────────
|
||||
|
||||
it("returns parsed fallback when row has no label or displayName", () => {
|
||||
expect(resolveSessionDisplayName("agent:main:main", row({ key: "agent:main:main" }))).toBe(
|
||||
"Main Session",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns parsed fallback when displayName matches key", () => {
|
||||
expect(resolveSessionDisplayName("mykey", row({ key: "mykey", displayName: "mykey" }))).toBe(
|
||||
"mykey",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns parsed fallback when label matches key", () => {
|
||||
expect(resolveSessionDisplayName("mykey", row({ key: "mykey", label: "mykey" }))).toBe("mykey");
|
||||
});
|
||||
|
||||
it("uses label alone when available", () => {
|
||||
expect(
|
||||
resolveSessionDisplayName(
|
||||
"discord:123:456",
|
||||
row({ key: "discord:123:456", label: "General" }),
|
||||
),
|
||||
).toBe("General");
|
||||
});
|
||||
|
||||
it("falls back to displayName when label is absent", () => {
|
||||
expect(
|
||||
resolveSessionDisplayName(
|
||||
"discord:123:456",
|
||||
row({ key: "discord:123:456", displayName: "My Chat" }),
|
||||
),
|
||||
).toBe("My Chat");
|
||||
});
|
||||
|
||||
it("prefers label over displayName when both are present", () => {
|
||||
expect(
|
||||
resolveSessionDisplayName(
|
||||
"discord:123:456",
|
||||
row({ key: "discord:123:456", displayName: "My Chat", label: "General" }),
|
||||
),
|
||||
).toBe("General");
|
||||
});
|
||||
|
||||
it("ignores whitespace-only label and falls back to displayName", () => {
|
||||
expect(
|
||||
resolveSessionDisplayName(
|
||||
"discord:123:456",
|
||||
row({ key: "discord:123:456", displayName: "My Chat", label: " " }),
|
||||
),
|
||||
).toBe("My Chat");
|
||||
});
|
||||
|
||||
it("uses parsed fallback when whitespace-only label and no displayName", () => {
|
||||
expect(
|
||||
resolveSessionDisplayName("discord:123:456", row({ key: "discord:123:456", label: " " })),
|
||||
).toBe("Discord Session");
|
||||
});
|
||||
|
||||
it("trims label and displayName", () => {
|
||||
expect(resolveSessionDisplayName("k", row({ key: "k", label: " General " }))).toBe("General");
|
||||
expect(resolveSessionDisplayName("k", row({ key: "k", displayName: " My Chat " }))).toBe(
|
||||
"My Chat",
|
||||
);
|
||||
});
|
||||
|
||||
// ── Type prefixes applied to labels / displayNames ──
|
||||
|
||||
it("prefixes subagent label with Subagent:", () => {
|
||||
expect(
|
||||
resolveSessionDisplayName(
|
||||
"agent:main:subagent:abc-123",
|
||||
row({ key: "agent:main:subagent:abc-123", label: "maintainer-v2" }),
|
||||
),
|
||||
).toBe("Subagent: maintainer-v2");
|
||||
});
|
||||
|
||||
it("prefixes subagent displayName with Subagent:", () => {
|
||||
expect(
|
||||
resolveSessionDisplayName(
|
||||
"agent:main:subagent:abc-123",
|
||||
row({ key: "agent:main:subagent:abc-123", displayName: "Task Runner" }),
|
||||
),
|
||||
).toBe("Subagent: Task Runner");
|
||||
});
|
||||
|
||||
it("prefixes cron label with Cron:", () => {
|
||||
expect(
|
||||
resolveSessionDisplayName(
|
||||
"agent:main:cron:abc-123",
|
||||
row({ key: "agent:main:cron:abc-123", label: "daily-briefing" }),
|
||||
),
|
||||
).toBe("Cron: daily-briefing");
|
||||
});
|
||||
|
||||
it("prefixes cron displayName with Cron:", () => {
|
||||
expect(
|
||||
resolveSessionDisplayName(
|
||||
"agent:main:cron:abc-123",
|
||||
row({ key: "agent:main:cron:abc-123", displayName: "Nightly Sync" }),
|
||||
),
|
||||
).toBe("Cron: Nightly Sync");
|
||||
});
|
||||
|
||||
it("does not double-prefix cron labels that already include Cron:", () => {
|
||||
expect(
|
||||
resolveSessionDisplayName(
|
||||
"agent:main:cron:abc-123",
|
||||
row({ key: "agent:main:cron:abc-123", label: "Cron: Nightly Sync" }),
|
||||
),
|
||||
).toBe("Cron: Nightly Sync");
|
||||
});
|
||||
|
||||
it("does not double-prefix subagent display names that already include Subagent:", () => {
|
||||
expect(
|
||||
resolveSessionDisplayName(
|
||||
"agent:main:subagent:abc-123",
|
||||
row({ key: "agent:main:subagent:abc-123", displayName: "Subagent: Runner" }),
|
||||
),
|
||||
).toBe("Subagent: Runner");
|
||||
});
|
||||
|
||||
it("does not prefix non-typed sessions with labels", () => {
|
||||
expect(
|
||||
resolveSessionDisplayName(
|
||||
"agent:main:bluebubbles:direct:+19257864429",
|
||||
row({ key: "agent:main:bluebubbles:direct:+19257864429", label: "Tyler" }),
|
||||
),
|
||||
).toBe("Tyler");
|
||||
});
|
||||
});
|
||||
481
openclaw/ui/src/ui/app-render.helpers.ts
Normal file
481
openclaw/ui/src/ui/app-render.helpers.ts
Normal file
@@ -0,0 +1,481 @@
|
||||
import { html } from "lit";
|
||||
import { repeat } from "lit/directives/repeat.js";
|
||||
import { t } from "../i18n/index.ts";
|
||||
import { refreshChat } from "./app-chat.ts";
|
||||
import { syncUrlWithSessionKey } from "./app-settings.ts";
|
||||
import type { AppViewState } from "./app-view-state.ts";
|
||||
import { OpenClawApp } from "./app.ts";
|
||||
import { ChatState, loadChatHistory } from "./controllers/chat.ts";
|
||||
import { icons } from "./icons.ts";
|
||||
import { iconForTab, pathForTab, titleForTab, type Tab } from "./navigation.ts";
|
||||
import type { ThemeTransitionContext } from "./theme-transition.ts";
|
||||
import type { ThemeMode } from "./theme.ts";
|
||||
import type { SessionsListResult } from "./types.ts";
|
||||
|
||||
type SessionDefaultsSnapshot = {
|
||||
mainSessionKey?: string;
|
||||
mainKey?: string;
|
||||
};
|
||||
|
||||
function resolveSidebarChatSessionKey(state: AppViewState): string {
|
||||
const snapshot = state.hello?.snapshot as
|
||||
| { sessionDefaults?: SessionDefaultsSnapshot }
|
||||
| undefined;
|
||||
const mainSessionKey = snapshot?.sessionDefaults?.mainSessionKey?.trim();
|
||||
if (mainSessionKey) {
|
||||
return mainSessionKey;
|
||||
}
|
||||
const mainKey = snapshot?.sessionDefaults?.mainKey?.trim();
|
||||
if (mainKey) {
|
||||
return mainKey;
|
||||
}
|
||||
return "main";
|
||||
}
|
||||
|
||||
function resetChatStateForSessionSwitch(state: AppViewState, sessionKey: string) {
|
||||
state.sessionKey = sessionKey;
|
||||
state.chatMessage = "";
|
||||
state.chatStream = null;
|
||||
(state as unknown as OpenClawApp).chatStreamStartedAt = null;
|
||||
state.chatRunId = null;
|
||||
(state as unknown as OpenClawApp).resetToolStream();
|
||||
(state as unknown as OpenClawApp).resetChatScroll();
|
||||
state.applySettings({
|
||||
...state.settings,
|
||||
sessionKey,
|
||||
lastActiveSessionKey: sessionKey,
|
||||
});
|
||||
}
|
||||
|
||||
export function renderTab(state: AppViewState, tab: Tab) {
|
||||
const href = pathForTab(tab, state.basePath);
|
||||
return html`
|
||||
<a
|
||||
href=${href}
|
||||
class="nav-item ${state.tab === tab ? "active" : ""}"
|
||||
@click=${(event: MouseEvent) => {
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.button !== 0 ||
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
event.shiftKey ||
|
||||
event.altKey
|
||||
) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
if (tab === "chat") {
|
||||
const mainSessionKey = resolveSidebarChatSessionKey(state);
|
||||
if (state.sessionKey !== mainSessionKey) {
|
||||
resetChatStateForSessionSwitch(state, mainSessionKey);
|
||||
void state.loadAssistantIdentity();
|
||||
}
|
||||
}
|
||||
state.setTab(tab);
|
||||
}}
|
||||
title=${titleForTab(tab)}
|
||||
>
|
||||
<span class="nav-item__icon" aria-hidden="true">${icons[iconForTab(tab)]}</span>
|
||||
<span class="nav-item__text">${titleForTab(tab)}</span>
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderChatControls(state: AppViewState) {
|
||||
const mainSessionKey = resolveMainSessionKey(state.hello, state.sessionsResult);
|
||||
const sessionOptions = resolveSessionOptions(
|
||||
state.sessionKey,
|
||||
state.sessionsResult,
|
||||
mainSessionKey,
|
||||
);
|
||||
const disableThinkingToggle = state.onboarding;
|
||||
const disableFocusToggle = state.onboarding;
|
||||
const showThinking = state.onboarding ? false : state.settings.chatShowThinking;
|
||||
const focusActive = state.onboarding ? true : state.settings.chatFocusMode;
|
||||
// Refresh icon
|
||||
const refreshIcon = html`
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"></path>
|
||||
<path d="M21 3v5h-5"></path>
|
||||
</svg>
|
||||
`;
|
||||
const focusIcon = html`
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M4 7V4h3"></path>
|
||||
<path d="M20 7V4h-3"></path>
|
||||
<path d="M4 17v3h3"></path>
|
||||
<path d="M20 17v3h-3"></path>
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
</svg>
|
||||
`;
|
||||
return html`
|
||||
<div class="chat-controls">
|
||||
<label class="field chat-controls__session">
|
||||
<select
|
||||
.value=${state.sessionKey}
|
||||
?disabled=${!state.connected}
|
||||
@change=${(e: Event) => {
|
||||
const next = (e.target as HTMLSelectElement).value;
|
||||
state.sessionKey = next;
|
||||
state.chatMessage = "";
|
||||
state.chatStream = null;
|
||||
(state as unknown as OpenClawApp).chatStreamStartedAt = null;
|
||||
state.chatRunId = null;
|
||||
(state as unknown as OpenClawApp).resetToolStream();
|
||||
(state as unknown as OpenClawApp).resetChatScroll();
|
||||
state.applySettings({
|
||||
...state.settings,
|
||||
sessionKey: next,
|
||||
lastActiveSessionKey: next,
|
||||
});
|
||||
void state.loadAssistantIdentity();
|
||||
syncUrlWithSessionKey(
|
||||
state as unknown as Parameters<typeof syncUrlWithSessionKey>[0],
|
||||
next,
|
||||
true,
|
||||
);
|
||||
void loadChatHistory(state as unknown as ChatState);
|
||||
}}
|
||||
>
|
||||
${repeat(
|
||||
sessionOptions,
|
||||
(entry) => entry.key,
|
||||
(entry) =>
|
||||
html`<option value=${entry.key} title=${entry.key}>
|
||||
${entry.displayName ?? entry.key}
|
||||
</option>`,
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
class="btn btn--sm btn--icon"
|
||||
?disabled=${state.chatLoading || !state.connected}
|
||||
@click=${async () => {
|
||||
const app = state as unknown as OpenClawApp;
|
||||
app.chatManualRefreshInFlight = true;
|
||||
app.chatNewMessagesBelow = false;
|
||||
await app.updateComplete;
|
||||
app.resetToolStream();
|
||||
try {
|
||||
await refreshChat(state as unknown as Parameters<typeof refreshChat>[0], {
|
||||
scheduleScroll: false,
|
||||
});
|
||||
app.scrollToBottom({ smooth: true });
|
||||
} finally {
|
||||
requestAnimationFrame(() => {
|
||||
app.chatManualRefreshInFlight = false;
|
||||
app.chatNewMessagesBelow = false;
|
||||
});
|
||||
}
|
||||
}}
|
||||
title=${t("chat.refreshTitle")}
|
||||
>
|
||||
${refreshIcon}
|
||||
</button>
|
||||
<span class="chat-controls__separator">|</span>
|
||||
<button
|
||||
class="btn btn--sm btn--icon ${showThinking ? "active" : ""}"
|
||||
?disabled=${disableThinkingToggle}
|
||||
@click=${() => {
|
||||
if (disableThinkingToggle) {
|
||||
return;
|
||||
}
|
||||
state.applySettings({
|
||||
...state.settings,
|
||||
chatShowThinking: !state.settings.chatShowThinking,
|
||||
});
|
||||
}}
|
||||
aria-pressed=${showThinking}
|
||||
title=${disableThinkingToggle ? t("chat.onboardingDisabled") : t("chat.thinkingToggle")}
|
||||
>
|
||||
${icons.brain}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn--sm btn--icon ${focusActive ? "active" : ""}"
|
||||
?disabled=${disableFocusToggle}
|
||||
@click=${() => {
|
||||
if (disableFocusToggle) {
|
||||
return;
|
||||
}
|
||||
state.applySettings({
|
||||
...state.settings,
|
||||
chatFocusMode: !state.settings.chatFocusMode,
|
||||
});
|
||||
}}
|
||||
aria-pressed=${focusActive}
|
||||
title=${disableFocusToggle ? t("chat.onboardingDisabled") : t("chat.focusToggle")}
|
||||
>
|
||||
${focusIcon}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function resolveMainSessionKey(
|
||||
hello: AppViewState["hello"],
|
||||
sessions: SessionsListResult | null,
|
||||
): string | null {
|
||||
const snapshot = hello?.snapshot as { sessionDefaults?: SessionDefaultsSnapshot } | undefined;
|
||||
const mainSessionKey = snapshot?.sessionDefaults?.mainSessionKey?.trim();
|
||||
if (mainSessionKey) {
|
||||
return mainSessionKey;
|
||||
}
|
||||
const mainKey = snapshot?.sessionDefaults?.mainKey?.trim();
|
||||
if (mainKey) {
|
||||
return mainKey;
|
||||
}
|
||||
if (sessions?.sessions?.some((row) => row.key === "main")) {
|
||||
return "main";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ── Channel display labels ────────────────────────────── */
|
||||
const CHANNEL_LABELS: Record<string, string> = {
|
||||
bluebubbles: "iMessage",
|
||||
telegram: "Telegram",
|
||||
discord: "Discord",
|
||||
signal: "Signal",
|
||||
slack: "Slack",
|
||||
whatsapp: "WhatsApp",
|
||||
matrix: "Matrix",
|
||||
email: "Email",
|
||||
sms: "SMS",
|
||||
};
|
||||
|
||||
const KNOWN_CHANNEL_KEYS = Object.keys(CHANNEL_LABELS);
|
||||
|
||||
/** Parsed type / context extracted from a session key. */
|
||||
export type SessionKeyInfo = {
|
||||
/** Prefix for typed sessions (Subagent:/Cron:). Empty for others. */
|
||||
prefix: string;
|
||||
/** Human-readable fallback when no label / displayName is available. */
|
||||
fallbackName: string;
|
||||
};
|
||||
|
||||
function capitalize(s: string): string {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a session key to extract type information and a human-readable
|
||||
* fallback display name. Exported for testing.
|
||||
*/
|
||||
export function parseSessionKey(key: string): SessionKeyInfo {
|
||||
// ── Main session ─────────────────────────────────
|
||||
if (key === "main" || key === "agent:main:main") {
|
||||
return { prefix: "", fallbackName: "Main Session" };
|
||||
}
|
||||
|
||||
// ── Subagent ─────────────────────────────────────
|
||||
if (key.includes(":subagent:")) {
|
||||
return { prefix: "Subagent:", fallbackName: "Subagent:" };
|
||||
}
|
||||
|
||||
// ── Cron job ─────────────────────────────────────
|
||||
if (key.includes(":cron:")) {
|
||||
return { prefix: "Cron:", fallbackName: "Cron Job:" };
|
||||
}
|
||||
|
||||
// ── Direct chat (agent:<x>:<channel>:direct:<id>) ──
|
||||
const directMatch = key.match(/^agent:[^:]+:([^:]+):direct:(.+)$/);
|
||||
if (directMatch) {
|
||||
const channel = directMatch[1];
|
||||
const identifier = directMatch[2];
|
||||
const channelLabel = CHANNEL_LABELS[channel] ?? capitalize(channel);
|
||||
return { prefix: "", fallbackName: `${channelLabel} · ${identifier}` };
|
||||
}
|
||||
|
||||
// ── Group chat (agent:<x>:<channel>:group:<id>) ────
|
||||
const groupMatch = key.match(/^agent:[^:]+:([^:]+):group:(.+)$/);
|
||||
if (groupMatch) {
|
||||
const channel = groupMatch[1];
|
||||
const channelLabel = CHANNEL_LABELS[channel] ?? capitalize(channel);
|
||||
return { prefix: "", fallbackName: `${channelLabel} Group` };
|
||||
}
|
||||
|
||||
// ── Channel-prefixed legacy keys (e.g. "bluebubbles:g-…") ──
|
||||
for (const ch of KNOWN_CHANNEL_KEYS) {
|
||||
if (key === ch || key.startsWith(`${ch}:`)) {
|
||||
return { prefix: "", fallbackName: `${CHANNEL_LABELS[ch]} Session` };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Unknown — return key as-is ───────────────────
|
||||
return { prefix: "", fallbackName: key };
|
||||
}
|
||||
|
||||
export function resolveSessionDisplayName(
|
||||
key: string,
|
||||
row?: SessionsListResult["sessions"][number],
|
||||
): string {
|
||||
const label = row?.label?.trim() || "";
|
||||
const displayName = row?.displayName?.trim() || "";
|
||||
const { prefix, fallbackName } = parseSessionKey(key);
|
||||
|
||||
const applyTypedPrefix = (name: string): string => {
|
||||
if (!prefix) {
|
||||
return name;
|
||||
}
|
||||
const prefixPattern = new RegExp(`^${prefix.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&")}\\s*`, "i");
|
||||
return prefixPattern.test(name) ? name : `${prefix} ${name}`;
|
||||
};
|
||||
|
||||
if (label && label !== key) {
|
||||
return applyTypedPrefix(label);
|
||||
}
|
||||
if (displayName && displayName !== key) {
|
||||
return applyTypedPrefix(displayName);
|
||||
}
|
||||
return fallbackName;
|
||||
}
|
||||
|
||||
function resolveSessionOptions(
|
||||
sessionKey: string,
|
||||
sessions: SessionsListResult | null,
|
||||
mainSessionKey?: string | null,
|
||||
) {
|
||||
const seen = new Set<string>();
|
||||
const options: Array<{ key: string; displayName?: string }> = [];
|
||||
|
||||
const resolvedMain = mainSessionKey && sessions?.sessions?.find((s) => s.key === mainSessionKey);
|
||||
const resolvedCurrent = sessions?.sessions?.find((s) => s.key === sessionKey);
|
||||
|
||||
// Add main session key first
|
||||
if (mainSessionKey) {
|
||||
seen.add(mainSessionKey);
|
||||
options.push({
|
||||
key: mainSessionKey,
|
||||
displayName: resolveSessionDisplayName(mainSessionKey, resolvedMain || undefined),
|
||||
});
|
||||
}
|
||||
|
||||
// Add current session key next
|
||||
if (!seen.has(sessionKey)) {
|
||||
seen.add(sessionKey);
|
||||
options.push({
|
||||
key: sessionKey,
|
||||
displayName: resolveSessionDisplayName(sessionKey, resolvedCurrent),
|
||||
});
|
||||
}
|
||||
|
||||
// Add sessions from the result
|
||||
if (sessions?.sessions) {
|
||||
for (const s of sessions.sessions) {
|
||||
if (!seen.has(s.key)) {
|
||||
seen.add(s.key);
|
||||
options.push({
|
||||
key: s.key,
|
||||
displayName: resolveSessionDisplayName(s.key, s),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
const THEME_ORDER: ThemeMode[] = ["system", "light", "dark"];
|
||||
|
||||
export function renderThemeToggle(state: AppViewState) {
|
||||
const index = Math.max(0, THEME_ORDER.indexOf(state.theme));
|
||||
const applyTheme = (next: ThemeMode) => (event: MouseEvent) => {
|
||||
const element = event.currentTarget as HTMLElement;
|
||||
const context: ThemeTransitionContext = { element };
|
||||
if (event.clientX || event.clientY) {
|
||||
context.pointerClientX = event.clientX;
|
||||
context.pointerClientY = event.clientY;
|
||||
}
|
||||
state.setTheme(next, context);
|
||||
};
|
||||
|
||||
return html`
|
||||
<div class="theme-toggle" style="--theme-index: ${index};">
|
||||
<div class="theme-toggle__track" role="group" aria-label="Theme">
|
||||
<span class="theme-toggle__indicator"></span>
|
||||
<button
|
||||
class="theme-toggle__button ${state.theme === "system" ? "active" : ""}"
|
||||
@click=${applyTheme("system")}
|
||||
aria-pressed=${state.theme === "system"}
|
||||
aria-label="System theme"
|
||||
title="System"
|
||||
>
|
||||
${renderMonitorIcon()}
|
||||
</button>
|
||||
<button
|
||||
class="theme-toggle__button ${state.theme === "light" ? "active" : ""}"
|
||||
@click=${applyTheme("light")}
|
||||
aria-pressed=${state.theme === "light"}
|
||||
aria-label="Light theme"
|
||||
title="Light"
|
||||
>
|
||||
${renderSunIcon()}
|
||||
</button>
|
||||
<button
|
||||
class="theme-toggle__button ${state.theme === "dark" ? "active" : ""}"
|
||||
@click=${applyTheme("dark")}
|
||||
aria-pressed=${state.theme === "dark"}
|
||||
aria-label="Dark theme"
|
||||
title="Dark"
|
||||
>
|
||||
${renderMoonIcon()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderSunIcon() {
|
||||
return html`
|
||||
<svg class="theme-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="4"></circle>
|
||||
<path d="M12 2v2"></path>
|
||||
<path d="M12 20v2"></path>
|
||||
<path d="m4.93 4.93 1.41 1.41"></path>
|
||||
<path d="m17.66 17.66 1.41 1.41"></path>
|
||||
<path d="M2 12h2"></path>
|
||||
<path d="M20 12h2"></path>
|
||||
<path d="m6.34 17.66-1.41 1.41"></path>
|
||||
<path d="m19.07 4.93-1.41 1.41"></path>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderMoonIcon() {
|
||||
return html`
|
||||
<svg class="theme-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401"
|
||||
></path>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderMonitorIcon() {
|
||||
return html`
|
||||
<svg class="theme-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect width="20" height="14" x="2" y="3" rx="2"></rect>
|
||||
<line x1="8" x2="16" y1="21" y2="21"></line>
|
||||
<line x1="12" x2="12" y1="17" y2="21"></line>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
1142
openclaw/ui/src/ui/app-render.ts
Normal file
1142
openclaw/ui/src/ui/app-render.ts
Normal file
File diff suppressed because it is too large
Load Diff
275
openclaw/ui/src/ui/app-scroll.test.ts
Normal file
275
openclaw/ui/src/ui/app-scroll.test.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { handleChatScroll, scheduleChatScroll, resetChatScroll } from "./app-scroll.ts";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** Minimal ScrollHost stub for unit tests. */
|
||||
function createScrollHost(
|
||||
overrides: {
|
||||
scrollHeight?: number;
|
||||
scrollTop?: number;
|
||||
clientHeight?: number;
|
||||
overflowY?: string;
|
||||
} = {},
|
||||
) {
|
||||
const {
|
||||
scrollHeight = 2000,
|
||||
scrollTop = 1500,
|
||||
clientHeight = 500,
|
||||
overflowY = "auto",
|
||||
} = overrides;
|
||||
|
||||
const container = {
|
||||
scrollHeight,
|
||||
scrollTop,
|
||||
clientHeight,
|
||||
style: { overflowY } as unknown as CSSStyleDeclaration,
|
||||
};
|
||||
|
||||
// Make getComputedStyle return the overflowY value
|
||||
vi.spyOn(window, "getComputedStyle").mockReturnValue({
|
||||
overflowY,
|
||||
} as unknown as CSSStyleDeclaration);
|
||||
|
||||
const host = {
|
||||
updateComplete: Promise.resolve(),
|
||||
querySelector: vi.fn().mockReturnValue(container),
|
||||
style: { setProperty: vi.fn() } as unknown as CSSStyleDeclaration,
|
||||
chatScrollFrame: null as number | null,
|
||||
chatScrollTimeout: null as number | null,
|
||||
chatHasAutoScrolled: false,
|
||||
chatUserNearBottom: true,
|
||||
chatNewMessagesBelow: false,
|
||||
logsScrollFrame: null as number | null,
|
||||
logsAtBottom: true,
|
||||
topbarObserver: null as ResizeObserver | null,
|
||||
};
|
||||
|
||||
return { host, container };
|
||||
}
|
||||
|
||||
function createScrollEvent(scrollHeight: number, scrollTop: number, clientHeight: number) {
|
||||
return {
|
||||
currentTarget: { scrollHeight, scrollTop, clientHeight },
|
||||
} as unknown as Event;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* handleChatScroll – threshold tests */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("handleChatScroll", () => {
|
||||
it("sets chatUserNearBottom=true when within the 450px threshold", () => {
|
||||
const { host } = createScrollHost({});
|
||||
// distanceFromBottom = 2000 - 1600 - 400 = 0 → clearly near bottom
|
||||
const event = createScrollEvent(2000, 1600, 400);
|
||||
handleChatScroll(host, event);
|
||||
expect(host.chatUserNearBottom).toBe(true);
|
||||
});
|
||||
|
||||
it("sets chatUserNearBottom=true when distance is just under threshold", () => {
|
||||
const { host } = createScrollHost({});
|
||||
// distanceFromBottom = 2000 - 1151 - 400 = 449 → just under threshold
|
||||
const event = createScrollEvent(2000, 1151, 400);
|
||||
handleChatScroll(host, event);
|
||||
expect(host.chatUserNearBottom).toBe(true);
|
||||
});
|
||||
|
||||
it("sets chatUserNearBottom=false when distance is exactly at threshold", () => {
|
||||
const { host } = createScrollHost({});
|
||||
// distanceFromBottom = 2000 - 1150 - 400 = 450 → at threshold (uses strict <)
|
||||
const event = createScrollEvent(2000, 1150, 400);
|
||||
handleChatScroll(host, event);
|
||||
expect(host.chatUserNearBottom).toBe(false);
|
||||
});
|
||||
|
||||
it("sets chatUserNearBottom=false when scrolled well above threshold", () => {
|
||||
const { host } = createScrollHost({});
|
||||
// distanceFromBottom = 2000 - 500 - 400 = 1100 → way above threshold
|
||||
const event = createScrollEvent(2000, 500, 400);
|
||||
handleChatScroll(host, event);
|
||||
expect(host.chatUserNearBottom).toBe(false);
|
||||
});
|
||||
|
||||
it("sets chatUserNearBottom=false when user scrolled up past one long message (>200px <450px)", () => {
|
||||
const { host } = createScrollHost({});
|
||||
// distanceFromBottom = 2000 - 1250 - 400 = 350 → old threshold would say "near", new says "near"
|
||||
// distanceFromBottom = 2000 - 1100 - 400 = 500 → old threshold would say "not near", new also "not near"
|
||||
const event = createScrollEvent(2000, 1100, 400);
|
||||
handleChatScroll(host, event);
|
||||
expect(host.chatUserNearBottom).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* scheduleChatScroll – respects user scroll position */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("scheduleChatScroll", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => {
|
||||
cb(0);
|
||||
return 1;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("scrolls to bottom when user is near bottom (no force)", async () => {
|
||||
const { host, container } = createScrollHost({
|
||||
scrollHeight: 2000,
|
||||
scrollTop: 1600,
|
||||
clientHeight: 400,
|
||||
});
|
||||
// distanceFromBottom = 2000 - 1600 - 400 = 0 → near bottom
|
||||
host.chatUserNearBottom = true;
|
||||
|
||||
scheduleChatScroll(host);
|
||||
await host.updateComplete;
|
||||
|
||||
expect(container.scrollTop).toBe(container.scrollHeight);
|
||||
});
|
||||
|
||||
it("does NOT scroll when user is scrolled up and no force", async () => {
|
||||
const { host, container } = createScrollHost({
|
||||
scrollHeight: 2000,
|
||||
scrollTop: 500,
|
||||
clientHeight: 400,
|
||||
});
|
||||
// distanceFromBottom = 2000 - 500 - 400 = 1100 → not near bottom
|
||||
host.chatUserNearBottom = false;
|
||||
const originalScrollTop = container.scrollTop;
|
||||
|
||||
scheduleChatScroll(host);
|
||||
await host.updateComplete;
|
||||
|
||||
expect(container.scrollTop).toBe(originalScrollTop);
|
||||
});
|
||||
|
||||
it("does NOT scroll with force=true when user has explicitly scrolled up", async () => {
|
||||
const { host, container } = createScrollHost({
|
||||
scrollHeight: 2000,
|
||||
scrollTop: 500,
|
||||
clientHeight: 400,
|
||||
});
|
||||
// User has scrolled up — chatUserNearBottom is false
|
||||
host.chatUserNearBottom = false;
|
||||
host.chatHasAutoScrolled = true; // Already past initial load
|
||||
const originalScrollTop = container.scrollTop;
|
||||
|
||||
scheduleChatScroll(host, true);
|
||||
await host.updateComplete;
|
||||
|
||||
// force=true should still NOT override explicit user scroll-up after initial load
|
||||
expect(container.scrollTop).toBe(originalScrollTop);
|
||||
});
|
||||
|
||||
it("DOES scroll with force=true on initial load (chatHasAutoScrolled=false)", async () => {
|
||||
const { host, container } = createScrollHost({
|
||||
scrollHeight: 2000,
|
||||
scrollTop: 500,
|
||||
clientHeight: 400,
|
||||
});
|
||||
host.chatUserNearBottom = false;
|
||||
host.chatHasAutoScrolled = false; // Initial load
|
||||
|
||||
scheduleChatScroll(host, true);
|
||||
await host.updateComplete;
|
||||
|
||||
// On initial load, force should work regardless
|
||||
expect(container.scrollTop).toBe(container.scrollHeight);
|
||||
});
|
||||
|
||||
it("sets chatNewMessagesBelow when not scrolling due to user position", async () => {
|
||||
const { host } = createScrollHost({
|
||||
scrollHeight: 2000,
|
||||
scrollTop: 500,
|
||||
clientHeight: 400,
|
||||
});
|
||||
host.chatUserNearBottom = false;
|
||||
host.chatHasAutoScrolled = true;
|
||||
host.chatNewMessagesBelow = false;
|
||||
|
||||
scheduleChatScroll(host);
|
||||
await host.updateComplete;
|
||||
|
||||
expect(host.chatNewMessagesBelow).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Streaming: rapid chatStream changes should not reset scroll */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("streaming scroll behavior", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => {
|
||||
cb(0);
|
||||
return 1;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("multiple rapid scheduleChatScroll calls do not scroll when user is scrolled up", async () => {
|
||||
const { host, container } = createScrollHost({
|
||||
scrollHeight: 2000,
|
||||
scrollTop: 500,
|
||||
clientHeight: 400,
|
||||
});
|
||||
host.chatUserNearBottom = false;
|
||||
host.chatHasAutoScrolled = true;
|
||||
const originalScrollTop = container.scrollTop;
|
||||
|
||||
// Simulate rapid streaming token updates
|
||||
scheduleChatScroll(host);
|
||||
scheduleChatScroll(host);
|
||||
scheduleChatScroll(host);
|
||||
await host.updateComplete;
|
||||
|
||||
expect(container.scrollTop).toBe(originalScrollTop);
|
||||
});
|
||||
|
||||
it("streaming scrolls correctly when user IS at bottom", async () => {
|
||||
const { host, container } = createScrollHost({
|
||||
scrollHeight: 2000,
|
||||
scrollTop: 1600,
|
||||
clientHeight: 400,
|
||||
});
|
||||
host.chatUserNearBottom = true;
|
||||
host.chatHasAutoScrolled = true;
|
||||
|
||||
// Simulate streaming
|
||||
scheduleChatScroll(host);
|
||||
await host.updateComplete;
|
||||
|
||||
expect(container.scrollTop).toBe(container.scrollHeight);
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* resetChatScroll */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("resetChatScroll", () => {
|
||||
it("resets state for new chat session", () => {
|
||||
const { host } = createScrollHost({});
|
||||
host.chatHasAutoScrolled = true;
|
||||
host.chatUserNearBottom = false;
|
||||
|
||||
resetChatScroll(host);
|
||||
|
||||
expect(host.chatHasAutoScrolled).toBe(false);
|
||||
expect(host.chatUserNearBottom).toBe(true);
|
||||
});
|
||||
});
|
||||
179
openclaw/ui/src/ui/app-scroll.ts
Normal file
179
openclaw/ui/src/ui/app-scroll.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
/** Distance (px) from the bottom within which we consider the user "near bottom". */
|
||||
const NEAR_BOTTOM_THRESHOLD = 450;
|
||||
|
||||
type ScrollHost = {
|
||||
updateComplete: Promise<unknown>;
|
||||
querySelector: (selectors: string) => Element | null;
|
||||
style: CSSStyleDeclaration;
|
||||
chatScrollFrame: number | null;
|
||||
chatScrollTimeout: number | null;
|
||||
chatHasAutoScrolled: boolean;
|
||||
chatUserNearBottom: boolean;
|
||||
chatNewMessagesBelow: boolean;
|
||||
logsScrollFrame: number | null;
|
||||
logsAtBottom: boolean;
|
||||
topbarObserver: ResizeObserver | null;
|
||||
};
|
||||
|
||||
export function scheduleChatScroll(host: ScrollHost, force = false, smooth = false) {
|
||||
if (host.chatScrollFrame) {
|
||||
cancelAnimationFrame(host.chatScrollFrame);
|
||||
}
|
||||
if (host.chatScrollTimeout != null) {
|
||||
clearTimeout(host.chatScrollTimeout);
|
||||
host.chatScrollTimeout = null;
|
||||
}
|
||||
const pickScrollTarget = () => {
|
||||
const container = host.querySelector(".chat-thread") as HTMLElement | null;
|
||||
if (container) {
|
||||
const overflowY = getComputedStyle(container).overflowY;
|
||||
const canScroll =
|
||||
overflowY === "auto" ||
|
||||
overflowY === "scroll" ||
|
||||
container.scrollHeight - container.clientHeight > 1;
|
||||
if (canScroll) {
|
||||
return container;
|
||||
}
|
||||
}
|
||||
return (document.scrollingElement ?? document.documentElement) as HTMLElement | null;
|
||||
};
|
||||
// Wait for Lit render to complete, then scroll
|
||||
void host.updateComplete.then(() => {
|
||||
host.chatScrollFrame = requestAnimationFrame(() => {
|
||||
host.chatScrollFrame = null;
|
||||
const target = pickScrollTarget();
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
const distanceFromBottom = target.scrollHeight - target.scrollTop - target.clientHeight;
|
||||
|
||||
// force=true only overrides when we haven't auto-scrolled yet (initial load).
|
||||
// After initial load, respect the user's scroll position.
|
||||
const effectiveForce = force && !host.chatHasAutoScrolled;
|
||||
const shouldStick =
|
||||
effectiveForce || host.chatUserNearBottom || distanceFromBottom < NEAR_BOTTOM_THRESHOLD;
|
||||
|
||||
if (!shouldStick) {
|
||||
// User is scrolled up — flag that new content arrived below.
|
||||
host.chatNewMessagesBelow = true;
|
||||
return;
|
||||
}
|
||||
if (effectiveForce) {
|
||||
host.chatHasAutoScrolled = true;
|
||||
}
|
||||
const smoothEnabled =
|
||||
smooth &&
|
||||
(typeof window === "undefined" ||
|
||||
typeof window.matchMedia !== "function" ||
|
||||
!window.matchMedia("(prefers-reduced-motion: reduce)").matches);
|
||||
const scrollTop = target.scrollHeight;
|
||||
if (typeof target.scrollTo === "function") {
|
||||
target.scrollTo({ top: scrollTop, behavior: smoothEnabled ? "smooth" : "auto" });
|
||||
} else {
|
||||
target.scrollTop = scrollTop;
|
||||
}
|
||||
host.chatUserNearBottom = true;
|
||||
host.chatNewMessagesBelow = false;
|
||||
const retryDelay = effectiveForce ? 150 : 120;
|
||||
host.chatScrollTimeout = window.setTimeout(() => {
|
||||
host.chatScrollTimeout = null;
|
||||
const latest = pickScrollTarget();
|
||||
if (!latest) {
|
||||
return;
|
||||
}
|
||||
const latestDistanceFromBottom =
|
||||
latest.scrollHeight - latest.scrollTop - latest.clientHeight;
|
||||
const shouldStickRetry =
|
||||
effectiveForce ||
|
||||
host.chatUserNearBottom ||
|
||||
latestDistanceFromBottom < NEAR_BOTTOM_THRESHOLD;
|
||||
if (!shouldStickRetry) {
|
||||
return;
|
||||
}
|
||||
latest.scrollTop = latest.scrollHeight;
|
||||
host.chatUserNearBottom = true;
|
||||
}, retryDelay);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function scheduleLogsScroll(host: ScrollHost, force = false) {
|
||||
if (host.logsScrollFrame) {
|
||||
cancelAnimationFrame(host.logsScrollFrame);
|
||||
}
|
||||
void host.updateComplete.then(() => {
|
||||
host.logsScrollFrame = requestAnimationFrame(() => {
|
||||
host.logsScrollFrame = null;
|
||||
const container = host.querySelector(".log-stream") as HTMLElement | null;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
const distanceFromBottom =
|
||||
container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||
const shouldStick = force || distanceFromBottom < 80;
|
||||
if (!shouldStick) {
|
||||
return;
|
||||
}
|
||||
container.scrollTop = container.scrollHeight;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function handleChatScroll(host: ScrollHost, event: Event) {
|
||||
const container = event.currentTarget as HTMLElement | null;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||
host.chatUserNearBottom = distanceFromBottom < NEAR_BOTTOM_THRESHOLD;
|
||||
// Clear the "new messages below" indicator when user scrolls back to bottom.
|
||||
if (host.chatUserNearBottom) {
|
||||
host.chatNewMessagesBelow = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function handleLogsScroll(host: ScrollHost, event: Event) {
|
||||
const container = event.currentTarget as HTMLElement | null;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||
host.logsAtBottom = distanceFromBottom < 80;
|
||||
}
|
||||
|
||||
export function resetChatScroll(host: ScrollHost) {
|
||||
host.chatHasAutoScrolled = false;
|
||||
host.chatUserNearBottom = true;
|
||||
host.chatNewMessagesBelow = false;
|
||||
}
|
||||
|
||||
export function exportLogs(lines: string[], label: string) {
|
||||
if (lines.length === 0) {
|
||||
return;
|
||||
}
|
||||
const blob = new Blob([`${lines.join("\n")}\n`], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-");
|
||||
anchor.href = url;
|
||||
anchor.download = `openclaw-logs-${label}-${stamp}.log`;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function observeTopbar(host: ScrollHost) {
|
||||
if (typeof ResizeObserver === "undefined") {
|
||||
return;
|
||||
}
|
||||
const topbar = host.querySelector(".topbar");
|
||||
if (!topbar) {
|
||||
return;
|
||||
}
|
||||
const update = () => {
|
||||
const { height } = topbar.getBoundingClientRect();
|
||||
host.style.setProperty("--topbar-height", `${height}px`);
|
||||
};
|
||||
update();
|
||||
host.topbarObserver = new ResizeObserver(() => update());
|
||||
host.topbarObserver.observe(topbar);
|
||||
}
|
||||
70
openclaw/ui/src/ui/app-settings.test.ts
Normal file
70
openclaw/ui/src/ui/app-settings.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { setTabFromRoute } from "./app-settings.ts";
|
||||
import type { Tab } from "./navigation.ts";
|
||||
|
||||
type SettingsHost = Parameters<typeof setTabFromRoute>[0] & {
|
||||
logsPollInterval: number | null;
|
||||
debugPollInterval: number | null;
|
||||
};
|
||||
|
||||
const createHost = (tab: Tab): SettingsHost => ({
|
||||
settings: {
|
||||
gatewayUrl: "",
|
||||
token: "",
|
||||
sessionKey: "main",
|
||||
lastActiveSessionKey: "main",
|
||||
theme: "system",
|
||||
chatFocusMode: false,
|
||||
chatShowThinking: true,
|
||||
splitRatio: 0.6,
|
||||
navCollapsed: false,
|
||||
navGroupsCollapsed: {},
|
||||
},
|
||||
theme: "system",
|
||||
themeResolved: "dark",
|
||||
applySessionKey: "main",
|
||||
sessionKey: "main",
|
||||
tab,
|
||||
connected: false,
|
||||
chatHasAutoScrolled: false,
|
||||
logsAtBottom: false,
|
||||
eventLog: [],
|
||||
eventLogBuffer: [],
|
||||
basePath: "",
|
||||
themeMedia: null,
|
||||
themeMediaHandler: null,
|
||||
logsPollInterval: null,
|
||||
debugPollInterval: null,
|
||||
});
|
||||
|
||||
describe("setTabFromRoute", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("starts and stops log polling based on the tab", () => {
|
||||
const host = createHost("chat");
|
||||
|
||||
setTabFromRoute(host, "logs");
|
||||
expect(host.logsPollInterval).not.toBeNull();
|
||||
expect(host.debugPollInterval).toBeNull();
|
||||
|
||||
setTabFromRoute(host, "chat");
|
||||
expect(host.logsPollInterval).toBeNull();
|
||||
});
|
||||
|
||||
it("starts and stops debug polling based on the tab", () => {
|
||||
const host = createHost("chat");
|
||||
|
||||
setTabFromRoute(host, "debug");
|
||||
expect(host.debugPollInterval).not.toBeNull();
|
||||
expect(host.logsPollInterval).toBeNull();
|
||||
|
||||
setTabFromRoute(host, "chat");
|
||||
expect(host.debugPollInterval).toBeNull();
|
||||
});
|
||||
});
|
||||
444
openclaw/ui/src/ui/app-settings.ts
Normal file
444
openclaw/ui/src/ui/app-settings.ts
Normal file
@@ -0,0 +1,444 @@
|
||||
import { refreshChat } from "./app-chat.ts";
|
||||
import {
|
||||
startLogsPolling,
|
||||
stopLogsPolling,
|
||||
startDebugPolling,
|
||||
stopDebugPolling,
|
||||
} from "./app-polling.ts";
|
||||
import { scheduleChatScroll, scheduleLogsScroll } from "./app-scroll.ts";
|
||||
import type { OpenClawApp } from "./app.ts";
|
||||
import { loadAgentIdentities, loadAgentIdentity } from "./controllers/agent-identity.ts";
|
||||
import { loadAgentSkills } from "./controllers/agent-skills.ts";
|
||||
import { loadAgents, loadToolsCatalog } from "./controllers/agents.ts";
|
||||
import { loadChannels } from "./controllers/channels.ts";
|
||||
import { loadConfig, loadConfigSchema } from "./controllers/config.ts";
|
||||
import {
|
||||
loadCronJobs,
|
||||
loadCronModelSuggestions,
|
||||
loadCronRuns,
|
||||
loadCronStatus,
|
||||
} from "./controllers/cron.ts";
|
||||
import { loadDebug } from "./controllers/debug.ts";
|
||||
import { loadDevices } from "./controllers/devices.ts";
|
||||
import { loadExecApprovals } from "./controllers/exec-approvals.ts";
|
||||
import { loadLogs } from "./controllers/logs.ts";
|
||||
import { loadNodes } from "./controllers/nodes.ts";
|
||||
import { loadPresence } from "./controllers/presence.ts";
|
||||
import { loadSessions } from "./controllers/sessions.ts";
|
||||
import { loadSkills } from "./controllers/skills.ts";
|
||||
import {
|
||||
inferBasePathFromPathname,
|
||||
normalizeBasePath,
|
||||
normalizePath,
|
||||
pathForTab,
|
||||
tabFromPath,
|
||||
type Tab,
|
||||
} from "./navigation.ts";
|
||||
import { saveSettings, type UiSettings } from "./storage.ts";
|
||||
import { startThemeTransition, type ThemeTransitionContext } from "./theme-transition.ts";
|
||||
import { resolveTheme, type ResolvedTheme, type ThemeMode } from "./theme.ts";
|
||||
import type { AgentsListResult } from "./types.ts";
|
||||
|
||||
type SettingsHost = {
|
||||
settings: UiSettings;
|
||||
password?: string;
|
||||
theme: ThemeMode;
|
||||
themeResolved: ResolvedTheme;
|
||||
applySessionKey: string;
|
||||
sessionKey: string;
|
||||
tab: Tab;
|
||||
connected: boolean;
|
||||
chatHasAutoScrolled: boolean;
|
||||
logsAtBottom: boolean;
|
||||
eventLog: unknown[];
|
||||
eventLogBuffer: unknown[];
|
||||
basePath: string;
|
||||
agentsList?: AgentsListResult | null;
|
||||
agentsSelectedId?: string | null;
|
||||
agentsPanel?: "overview" | "files" | "tools" | "skills" | "channels" | "cron";
|
||||
themeMedia: MediaQueryList | null;
|
||||
themeMediaHandler: ((event: MediaQueryListEvent) => void) | null;
|
||||
pendingGatewayUrl?: string | null;
|
||||
};
|
||||
|
||||
export function applySettings(host: SettingsHost, next: UiSettings) {
|
||||
const normalized = {
|
||||
...next,
|
||||
lastActiveSessionKey: next.lastActiveSessionKey?.trim() || next.sessionKey.trim() || "main",
|
||||
};
|
||||
host.settings = normalized;
|
||||
saveSettings(normalized);
|
||||
if (next.theme !== host.theme) {
|
||||
host.theme = next.theme;
|
||||
applyResolvedTheme(host, resolveTheme(next.theme));
|
||||
}
|
||||
host.applySessionKey = host.settings.lastActiveSessionKey;
|
||||
}
|
||||
|
||||
export function setLastActiveSessionKey(host: SettingsHost, next: string) {
|
||||
const trimmed = next.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
if (host.settings.lastActiveSessionKey === trimmed) {
|
||||
return;
|
||||
}
|
||||
applySettings(host, { ...host.settings, lastActiveSessionKey: trimmed });
|
||||
}
|
||||
|
||||
export function applySettingsFromUrl(host: SettingsHost) {
|
||||
if (!window.location.search && !window.location.hash) {
|
||||
return;
|
||||
}
|
||||
const url = new URL(window.location.href);
|
||||
const params = new URLSearchParams(url.search);
|
||||
const hashParams = new URLSearchParams(url.hash.startsWith("#") ? url.hash.slice(1) : url.hash);
|
||||
|
||||
const tokenRaw = params.get("token") ?? hashParams.get("token");
|
||||
const passwordRaw = params.get("password") ?? hashParams.get("password");
|
||||
const sessionRaw = params.get("session") ?? hashParams.get("session");
|
||||
const gatewayUrlRaw = params.get("gatewayUrl") ?? hashParams.get("gatewayUrl");
|
||||
let shouldCleanUrl = false;
|
||||
|
||||
if (tokenRaw != null) {
|
||||
const token = tokenRaw.trim();
|
||||
if (token && token !== host.settings.token) {
|
||||
applySettings(host, { ...host.settings, token });
|
||||
}
|
||||
params.delete("token");
|
||||
hashParams.delete("token");
|
||||
shouldCleanUrl = true;
|
||||
}
|
||||
|
||||
if (passwordRaw != null) {
|
||||
// Never hydrate password from URL params; strip only.
|
||||
params.delete("password");
|
||||
hashParams.delete("password");
|
||||
shouldCleanUrl = true;
|
||||
}
|
||||
|
||||
if (sessionRaw != null) {
|
||||
const session = sessionRaw.trim();
|
||||
if (session) {
|
||||
host.sessionKey = session;
|
||||
applySettings(host, {
|
||||
...host.settings,
|
||||
sessionKey: session,
|
||||
lastActiveSessionKey: session,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (gatewayUrlRaw != null) {
|
||||
const gatewayUrl = gatewayUrlRaw.trim();
|
||||
if (gatewayUrl && gatewayUrl !== host.settings.gatewayUrl) {
|
||||
host.pendingGatewayUrl = gatewayUrl;
|
||||
}
|
||||
params.delete("gatewayUrl");
|
||||
hashParams.delete("gatewayUrl");
|
||||
shouldCleanUrl = true;
|
||||
}
|
||||
|
||||
if (!shouldCleanUrl) {
|
||||
return;
|
||||
}
|
||||
url.search = params.toString();
|
||||
const nextHash = hashParams.toString();
|
||||
url.hash = nextHash ? `#${nextHash}` : "";
|
||||
window.history.replaceState({}, "", url.toString());
|
||||
}
|
||||
|
||||
export function setTab(host: SettingsHost, next: Tab) {
|
||||
if (host.tab !== next) {
|
||||
host.tab = next;
|
||||
}
|
||||
if (next === "chat") {
|
||||
host.chatHasAutoScrolled = false;
|
||||
}
|
||||
if (next === "logs") {
|
||||
startLogsPolling(host as unknown as Parameters<typeof startLogsPolling>[0]);
|
||||
} else {
|
||||
stopLogsPolling(host as unknown as Parameters<typeof stopLogsPolling>[0]);
|
||||
}
|
||||
if (next === "debug") {
|
||||
startDebugPolling(host as unknown as Parameters<typeof startDebugPolling>[0]);
|
||||
} else {
|
||||
stopDebugPolling(host as unknown as Parameters<typeof stopDebugPolling>[0]);
|
||||
}
|
||||
void refreshActiveTab(host);
|
||||
syncUrlWithTab(host, next, false);
|
||||
}
|
||||
|
||||
export function setTheme(host: SettingsHost, next: ThemeMode, context?: ThemeTransitionContext) {
|
||||
const applyTheme = () => {
|
||||
host.theme = next;
|
||||
applySettings(host, { ...host.settings, theme: next });
|
||||
applyResolvedTheme(host, resolveTheme(next));
|
||||
};
|
||||
startThemeTransition({
|
||||
nextTheme: next,
|
||||
applyTheme,
|
||||
context,
|
||||
currentTheme: host.theme,
|
||||
});
|
||||
}
|
||||
|
||||
export async function refreshActiveTab(host: SettingsHost) {
|
||||
if (host.tab === "overview") {
|
||||
await loadOverview(host);
|
||||
}
|
||||
if (host.tab === "channels") {
|
||||
await loadChannelsTab(host);
|
||||
}
|
||||
if (host.tab === "instances") {
|
||||
await loadPresence(host as unknown as OpenClawApp);
|
||||
}
|
||||
if (host.tab === "sessions") {
|
||||
await loadSessions(host as unknown as OpenClawApp);
|
||||
}
|
||||
if (host.tab === "cron") {
|
||||
await loadCron(host);
|
||||
}
|
||||
if (host.tab === "skills") {
|
||||
await loadSkills(host as unknown as OpenClawApp);
|
||||
}
|
||||
if (host.tab === "agents") {
|
||||
await loadAgents(host as unknown as OpenClawApp);
|
||||
await loadToolsCatalog(host as unknown as OpenClawApp);
|
||||
await loadConfig(host as unknown as OpenClawApp);
|
||||
const agentIds = host.agentsList?.agents?.map((entry) => entry.id) ?? [];
|
||||
if (agentIds.length > 0) {
|
||||
void loadAgentIdentities(host as unknown as OpenClawApp, agentIds);
|
||||
}
|
||||
const agentId =
|
||||
host.agentsSelectedId ?? host.agentsList?.defaultId ?? host.agentsList?.agents?.[0]?.id;
|
||||
if (agentId) {
|
||||
void loadAgentIdentity(host as unknown as OpenClawApp, agentId);
|
||||
if (host.agentsPanel === "skills") {
|
||||
void loadAgentSkills(host as unknown as OpenClawApp, agentId);
|
||||
}
|
||||
if (host.agentsPanel === "channels") {
|
||||
void loadChannels(host as unknown as OpenClawApp, false);
|
||||
}
|
||||
if (host.agentsPanel === "cron") {
|
||||
void loadCron(host);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (host.tab === "nodes") {
|
||||
await loadNodes(host as unknown as OpenClawApp);
|
||||
await loadDevices(host as unknown as OpenClawApp);
|
||||
await loadConfig(host as unknown as OpenClawApp);
|
||||
await loadExecApprovals(host as unknown as OpenClawApp);
|
||||
}
|
||||
if (host.tab === "chat") {
|
||||
await refreshChat(host as unknown as Parameters<typeof refreshChat>[0]);
|
||||
scheduleChatScroll(
|
||||
host as unknown as Parameters<typeof scheduleChatScroll>[0],
|
||||
!host.chatHasAutoScrolled,
|
||||
);
|
||||
}
|
||||
if (host.tab === "config") {
|
||||
await loadConfigSchema(host as unknown as OpenClawApp);
|
||||
await loadConfig(host as unknown as OpenClawApp);
|
||||
}
|
||||
if (host.tab === "debug") {
|
||||
await loadDebug(host as unknown as OpenClawApp);
|
||||
host.eventLog = host.eventLogBuffer;
|
||||
}
|
||||
if (host.tab === "logs") {
|
||||
host.logsAtBottom = true;
|
||||
await loadLogs(host as unknown as OpenClawApp, { reset: true });
|
||||
scheduleLogsScroll(host as unknown as Parameters<typeof scheduleLogsScroll>[0], true);
|
||||
}
|
||||
}
|
||||
|
||||
export function inferBasePath() {
|
||||
if (typeof window === "undefined") {
|
||||
return "";
|
||||
}
|
||||
const configured = window.__OPENCLAW_CONTROL_UI_BASE_PATH__;
|
||||
if (typeof configured === "string" && configured.trim()) {
|
||||
return normalizeBasePath(configured);
|
||||
}
|
||||
return inferBasePathFromPathname(window.location.pathname);
|
||||
}
|
||||
|
||||
export function syncThemeWithSettings(host: SettingsHost) {
|
||||
host.theme = host.settings.theme ?? "system";
|
||||
applyResolvedTheme(host, resolveTheme(host.theme));
|
||||
}
|
||||
|
||||
export function applyResolvedTheme(host: SettingsHost, resolved: ResolvedTheme) {
|
||||
host.themeResolved = resolved;
|
||||
if (typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
const root = document.documentElement;
|
||||
root.dataset.theme = resolved;
|
||||
root.style.colorScheme = resolved;
|
||||
}
|
||||
|
||||
export function attachThemeListener(host: SettingsHost) {
|
||||
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
|
||||
return;
|
||||
}
|
||||
host.themeMedia = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
host.themeMediaHandler = (event) => {
|
||||
if (host.theme !== "system") {
|
||||
return;
|
||||
}
|
||||
applyResolvedTheme(host, event.matches ? "dark" : "light");
|
||||
};
|
||||
if (typeof host.themeMedia.addEventListener === "function") {
|
||||
host.themeMedia.addEventListener("change", host.themeMediaHandler);
|
||||
return;
|
||||
}
|
||||
const legacy = host.themeMedia as MediaQueryList & {
|
||||
addListener: (cb: (event: MediaQueryListEvent) => void) => void;
|
||||
};
|
||||
legacy.addListener(host.themeMediaHandler);
|
||||
}
|
||||
|
||||
export function detachThemeListener(host: SettingsHost) {
|
||||
if (!host.themeMedia || !host.themeMediaHandler) {
|
||||
return;
|
||||
}
|
||||
if (typeof host.themeMedia.removeEventListener === "function") {
|
||||
host.themeMedia.removeEventListener("change", host.themeMediaHandler);
|
||||
return;
|
||||
}
|
||||
const legacy = host.themeMedia as MediaQueryList & {
|
||||
removeListener: (cb: (event: MediaQueryListEvent) => void) => void;
|
||||
};
|
||||
legacy.removeListener(host.themeMediaHandler);
|
||||
host.themeMedia = null;
|
||||
host.themeMediaHandler = null;
|
||||
}
|
||||
|
||||
export function syncTabWithLocation(host: SettingsHost, replace: boolean) {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
const resolved = tabFromPath(window.location.pathname, host.basePath) ?? "chat";
|
||||
setTabFromRoute(host, resolved);
|
||||
syncUrlWithTab(host, resolved, replace);
|
||||
}
|
||||
|
||||
export function onPopState(host: SettingsHost) {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
const resolved = tabFromPath(window.location.pathname, host.basePath);
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(window.location.href);
|
||||
const session = url.searchParams.get("session")?.trim();
|
||||
if (session) {
|
||||
host.sessionKey = session;
|
||||
applySettings(host, {
|
||||
...host.settings,
|
||||
sessionKey: session,
|
||||
lastActiveSessionKey: session,
|
||||
});
|
||||
}
|
||||
|
||||
setTabFromRoute(host, resolved);
|
||||
}
|
||||
|
||||
export function setTabFromRoute(host: SettingsHost, next: Tab) {
|
||||
if (host.tab !== next) {
|
||||
host.tab = next;
|
||||
}
|
||||
if (next === "chat") {
|
||||
host.chatHasAutoScrolled = false;
|
||||
}
|
||||
if (next === "logs") {
|
||||
startLogsPolling(host as unknown as Parameters<typeof startLogsPolling>[0]);
|
||||
} else {
|
||||
stopLogsPolling(host as unknown as Parameters<typeof stopLogsPolling>[0]);
|
||||
}
|
||||
if (next === "debug") {
|
||||
startDebugPolling(host as unknown as Parameters<typeof startDebugPolling>[0]);
|
||||
} else {
|
||||
stopDebugPolling(host as unknown as Parameters<typeof stopDebugPolling>[0]);
|
||||
}
|
||||
if (host.connected) {
|
||||
void refreshActiveTab(host);
|
||||
}
|
||||
}
|
||||
|
||||
export function syncUrlWithTab(host: SettingsHost, tab: Tab, replace: boolean) {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
const targetPath = normalizePath(pathForTab(tab, host.basePath));
|
||||
const currentPath = normalizePath(window.location.pathname);
|
||||
const url = new URL(window.location.href);
|
||||
|
||||
if (tab === "chat" && host.sessionKey) {
|
||||
url.searchParams.set("session", host.sessionKey);
|
||||
} else {
|
||||
url.searchParams.delete("session");
|
||||
}
|
||||
|
||||
if (currentPath !== targetPath) {
|
||||
url.pathname = targetPath;
|
||||
}
|
||||
|
||||
if (replace) {
|
||||
window.history.replaceState({}, "", url.toString());
|
||||
} else {
|
||||
window.history.pushState({}, "", url.toString());
|
||||
}
|
||||
}
|
||||
|
||||
export function syncUrlWithSessionKey(host: SettingsHost, sessionKey: string, replace: boolean) {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("session", sessionKey);
|
||||
if (replace) {
|
||||
window.history.replaceState({}, "", url.toString());
|
||||
} else {
|
||||
window.history.pushState({}, "", url.toString());
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadOverview(host: SettingsHost) {
|
||||
await Promise.all([
|
||||
loadChannels(host as unknown as OpenClawApp, false),
|
||||
loadPresence(host as unknown as OpenClawApp),
|
||||
loadSessions(host as unknown as OpenClawApp),
|
||||
loadCronStatus(host as unknown as OpenClawApp),
|
||||
loadDebug(host as unknown as OpenClawApp),
|
||||
]);
|
||||
}
|
||||
|
||||
export async function loadChannelsTab(host: SettingsHost) {
|
||||
await Promise.all([
|
||||
loadChannels(host as unknown as OpenClawApp, true),
|
||||
loadConfigSchema(host as unknown as OpenClawApp),
|
||||
loadConfig(host as unknown as OpenClawApp),
|
||||
]);
|
||||
}
|
||||
|
||||
export async function loadCron(host: SettingsHost) {
|
||||
const cronHost = host as unknown as OpenClawApp;
|
||||
await Promise.all([
|
||||
loadChannels(host as unknown as OpenClawApp, false),
|
||||
loadCronStatus(cronHost),
|
||||
loadCronJobs(cronHost),
|
||||
loadCronModelSuggestions(cronHost),
|
||||
]);
|
||||
if (cronHost.cronRunsScope === "all") {
|
||||
await loadCronRuns(cronHost, null);
|
||||
return;
|
||||
}
|
||||
if (cronHost.cronRunsJobId) {
|
||||
await loadCronRuns(cronHost, cronHost.cronRunsJobId);
|
||||
}
|
||||
}
|
||||
139
openclaw/ui/src/ui/app-tool-stream.node.test.ts
Normal file
139
openclaw/ui/src/ui/app-tool-stream.node.test.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { handleAgentEvent, type FallbackStatus, type ToolStreamEntry } from "./app-tool-stream.ts";
|
||||
|
||||
type ToolStreamHost = Parameters<typeof handleAgentEvent>[0];
|
||||
type MutableHost = ToolStreamHost & {
|
||||
compactionStatus?: unknown;
|
||||
compactionClearTimer?: number | null;
|
||||
fallbackStatus?: FallbackStatus | null;
|
||||
fallbackClearTimer?: number | null;
|
||||
};
|
||||
|
||||
function createHost(overrides?: Partial<MutableHost>): MutableHost {
|
||||
return {
|
||||
sessionKey: "main",
|
||||
chatRunId: null,
|
||||
toolStreamById: new Map<string, ToolStreamEntry>(),
|
||||
toolStreamOrder: [],
|
||||
chatToolMessages: [],
|
||||
toolStreamSyncTimer: null,
|
||||
compactionStatus: null,
|
||||
compactionClearTimer: null,
|
||||
fallbackStatus: null,
|
||||
fallbackClearTimer: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("app-tool-stream fallback lifecycle handling", () => {
|
||||
beforeAll(() => {
|
||||
const globalWithWindow = globalThis as typeof globalThis & {
|
||||
window?: Window & typeof globalThis;
|
||||
};
|
||||
if (!globalWithWindow.window) {
|
||||
globalWithWindow.window = globalThis as unknown as Window & typeof globalThis;
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts session-scoped fallback lifecycle events when no run is active", () => {
|
||||
vi.useFakeTimers();
|
||||
const host = createHost();
|
||||
|
||||
handleAgentEvent(host, {
|
||||
runId: "run-1",
|
||||
seq: 1,
|
||||
stream: "lifecycle",
|
||||
ts: Date.now(),
|
||||
sessionKey: "main",
|
||||
data: {
|
||||
phase: "fallback",
|
||||
selectedProvider: "fireworks",
|
||||
selectedModel: "fireworks/minimax-m2p5",
|
||||
activeProvider: "deepinfra",
|
||||
activeModel: "moonshotai/Kimi-K2.5",
|
||||
reasonSummary: "rate limit",
|
||||
},
|
||||
});
|
||||
|
||||
expect(host.fallbackStatus?.selected).toBe("fireworks/minimax-m2p5");
|
||||
expect(host.fallbackStatus?.active).toBe("deepinfra/moonshotai/Kimi-K2.5");
|
||||
expect(host.fallbackStatus?.reason).toBe("rate limit");
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("rejects idle fallback lifecycle events for other sessions", () => {
|
||||
vi.useFakeTimers();
|
||||
const host = createHost();
|
||||
|
||||
handleAgentEvent(host, {
|
||||
runId: "run-1",
|
||||
seq: 1,
|
||||
stream: "lifecycle",
|
||||
ts: Date.now(),
|
||||
sessionKey: "agent:other:main",
|
||||
data: {
|
||||
phase: "fallback",
|
||||
selectedProvider: "fireworks",
|
||||
selectedModel: "fireworks/minimax-m2p5",
|
||||
activeProvider: "deepinfra",
|
||||
activeModel: "moonshotai/Kimi-K2.5",
|
||||
},
|
||||
});
|
||||
|
||||
expect(host.fallbackStatus).toBeNull();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("auto-clears fallback status after toast duration", () => {
|
||||
vi.useFakeTimers();
|
||||
const host = createHost();
|
||||
|
||||
handleAgentEvent(host, {
|
||||
runId: "run-1",
|
||||
seq: 1,
|
||||
stream: "lifecycle",
|
||||
ts: Date.now(),
|
||||
sessionKey: "main",
|
||||
data: {
|
||||
phase: "fallback",
|
||||
selectedProvider: "fireworks",
|
||||
selectedModel: "fireworks/minimax-m2p5",
|
||||
activeProvider: "deepinfra",
|
||||
activeModel: "moonshotai/Kimi-K2.5",
|
||||
},
|
||||
});
|
||||
|
||||
expect(host.fallbackStatus).not.toBeNull();
|
||||
vi.advanceTimersByTime(7_999);
|
||||
expect(host.fallbackStatus).not.toBeNull();
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(host.fallbackStatus).toBeNull();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("builds previous fallback label from provider + model on fallback_cleared", () => {
|
||||
vi.useFakeTimers();
|
||||
const host = createHost();
|
||||
|
||||
handleAgentEvent(host, {
|
||||
runId: "run-1",
|
||||
seq: 1,
|
||||
stream: "lifecycle",
|
||||
ts: Date.now(),
|
||||
sessionKey: "main",
|
||||
data: {
|
||||
phase: "fallback_cleared",
|
||||
selectedProvider: "fireworks",
|
||||
selectedModel: "fireworks/minimax-m2p5",
|
||||
activeProvider: "fireworks",
|
||||
activeModel: "fireworks/minimax-m2p5",
|
||||
previousActiveProvider: "deepinfra",
|
||||
previousActiveModel: "moonshotai/Kimi-K2.5",
|
||||
},
|
||||
});
|
||||
|
||||
expect(host.fallbackStatus?.phase).toBe("cleared");
|
||||
expect(host.fallbackStatus?.previous).toBe("deepinfra/moonshotai/Kimi-K2.5");
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
455
openclaw/ui/src/ui/app-tool-stream.ts
Normal file
455
openclaw/ui/src/ui/app-tool-stream.ts
Normal file
@@ -0,0 +1,455 @@
|
||||
import { truncateText } from "./format.ts";
|
||||
|
||||
const TOOL_STREAM_LIMIT = 50;
|
||||
const TOOL_STREAM_THROTTLE_MS = 80;
|
||||
const TOOL_OUTPUT_CHAR_LIMIT = 120_000;
|
||||
|
||||
export type AgentEventPayload = {
|
||||
runId: string;
|
||||
seq: number;
|
||||
stream: string;
|
||||
ts: number;
|
||||
sessionKey?: string;
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type ToolStreamEntry = {
|
||||
toolCallId: string;
|
||||
runId: string;
|
||||
sessionKey?: string;
|
||||
name: string;
|
||||
args?: unknown;
|
||||
output?: string;
|
||||
startedAt: number;
|
||||
updatedAt: number;
|
||||
message: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type ToolStreamHost = {
|
||||
sessionKey: string;
|
||||
chatRunId: string | null;
|
||||
toolStreamById: Map<string, ToolStreamEntry>;
|
||||
toolStreamOrder: string[];
|
||||
chatToolMessages: Record<string, unknown>[];
|
||||
toolStreamSyncTimer: number | null;
|
||||
};
|
||||
|
||||
function toTrimmedString(value: unknown): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
function resolveModelLabel(provider: unknown, model: unknown): string | null {
|
||||
const modelValue = toTrimmedString(model);
|
||||
if (!modelValue) {
|
||||
return null;
|
||||
}
|
||||
const providerValue = toTrimmedString(provider);
|
||||
if (providerValue) {
|
||||
const prefix = `${providerValue}/`;
|
||||
if (modelValue.toLowerCase().startsWith(prefix.toLowerCase())) {
|
||||
const trimmedModel = modelValue.slice(prefix.length).trim();
|
||||
if (trimmedModel) {
|
||||
return `${providerValue}/${trimmedModel}`;
|
||||
}
|
||||
}
|
||||
return `${providerValue}/${modelValue}`;
|
||||
}
|
||||
const slashIndex = modelValue.indexOf("/");
|
||||
if (slashIndex > 0) {
|
||||
const p = modelValue.slice(0, slashIndex).trim();
|
||||
const m = modelValue.slice(slashIndex + 1).trim();
|
||||
if (p && m) {
|
||||
return `${p}/${m}`;
|
||||
}
|
||||
}
|
||||
return modelValue;
|
||||
}
|
||||
|
||||
type FallbackAttempt = {
|
||||
provider: string;
|
||||
model: string;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
function parseFallbackAttemptSummaries(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value
|
||||
.map((entry) => toTrimmedString(entry))
|
||||
.filter((entry): entry is string => Boolean(entry));
|
||||
}
|
||||
|
||||
function parseFallbackAttempts(value: unknown): FallbackAttempt[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
const out: FallbackAttempt[] = [];
|
||||
for (const entry of value) {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
continue;
|
||||
}
|
||||
const item = entry as Record<string, unknown>;
|
||||
const provider = toTrimmedString(item.provider);
|
||||
const model = toTrimmedString(item.model);
|
||||
if (!provider || !model) {
|
||||
continue;
|
||||
}
|
||||
const reason =
|
||||
toTrimmedString(item.reason)?.replace(/_/g, " ") ??
|
||||
toTrimmedString(item.code) ??
|
||||
(typeof item.status === "number" ? `HTTP ${item.status}` : null) ??
|
||||
toTrimmedString(item.error) ??
|
||||
"error";
|
||||
out.push({ provider, model, reason });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function extractToolOutputText(value: unknown): string | null {
|
||||
if (!value || typeof value !== "object") {
|
||||
return null;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if (typeof record.text === "string") {
|
||||
return record.text;
|
||||
}
|
||||
const content = record.content;
|
||||
if (!Array.isArray(content)) {
|
||||
return null;
|
||||
}
|
||||
const parts = content
|
||||
.map((item) => {
|
||||
if (!item || typeof item !== "object") {
|
||||
return null;
|
||||
}
|
||||
const entry = item as Record<string, unknown>;
|
||||
if (entry.type === "text" && typeof entry.text === "string") {
|
||||
return entry.text;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((part): part is string => Boolean(part));
|
||||
if (parts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
function formatToolOutput(value: unknown): string | null {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
const contentText = extractToolOutputText(value);
|
||||
let text: string;
|
||||
if (typeof value === "string") {
|
||||
text = value;
|
||||
} else if (contentText) {
|
||||
text = contentText;
|
||||
} else {
|
||||
try {
|
||||
text = JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
// oxlint-disable typescript/no-base-to-string
|
||||
text = String(value);
|
||||
}
|
||||
}
|
||||
const truncated = truncateText(text, TOOL_OUTPUT_CHAR_LIMIT);
|
||||
if (!truncated.truncated) {
|
||||
return truncated.text;
|
||||
}
|
||||
return `${truncated.text}\n\n… truncated (${truncated.total} chars, showing first ${truncated.text.length}).`;
|
||||
}
|
||||
|
||||
function buildToolStreamMessage(entry: ToolStreamEntry): Record<string, unknown> {
|
||||
const content: Array<Record<string, unknown>> = [];
|
||||
content.push({
|
||||
type: "toolcall",
|
||||
name: entry.name,
|
||||
arguments: entry.args ?? {},
|
||||
});
|
||||
if (entry.output) {
|
||||
content.push({
|
||||
type: "toolresult",
|
||||
name: entry.name,
|
||||
text: entry.output,
|
||||
});
|
||||
}
|
||||
return {
|
||||
role: "assistant",
|
||||
toolCallId: entry.toolCallId,
|
||||
runId: entry.runId,
|
||||
content,
|
||||
timestamp: entry.startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function trimToolStream(host: ToolStreamHost) {
|
||||
if (host.toolStreamOrder.length <= TOOL_STREAM_LIMIT) {
|
||||
return;
|
||||
}
|
||||
const overflow = host.toolStreamOrder.length - TOOL_STREAM_LIMIT;
|
||||
const removed = host.toolStreamOrder.splice(0, overflow);
|
||||
for (const id of removed) {
|
||||
host.toolStreamById.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
function syncToolStreamMessages(host: ToolStreamHost) {
|
||||
host.chatToolMessages = host.toolStreamOrder
|
||||
.map((id) => host.toolStreamById.get(id)?.message)
|
||||
.filter((msg): msg is Record<string, unknown> => Boolean(msg));
|
||||
}
|
||||
|
||||
export function flushToolStreamSync(host: ToolStreamHost) {
|
||||
if (host.toolStreamSyncTimer != null) {
|
||||
clearTimeout(host.toolStreamSyncTimer);
|
||||
host.toolStreamSyncTimer = null;
|
||||
}
|
||||
syncToolStreamMessages(host);
|
||||
}
|
||||
|
||||
export function scheduleToolStreamSync(host: ToolStreamHost, force = false) {
|
||||
if (force) {
|
||||
flushToolStreamSync(host);
|
||||
return;
|
||||
}
|
||||
if (host.toolStreamSyncTimer != null) {
|
||||
return;
|
||||
}
|
||||
host.toolStreamSyncTimer = window.setTimeout(
|
||||
() => flushToolStreamSync(host),
|
||||
TOOL_STREAM_THROTTLE_MS,
|
||||
);
|
||||
}
|
||||
|
||||
export function resetToolStream(host: ToolStreamHost) {
|
||||
host.toolStreamById.clear();
|
||||
host.toolStreamOrder = [];
|
||||
host.chatToolMessages = [];
|
||||
flushToolStreamSync(host);
|
||||
}
|
||||
|
||||
export type CompactionStatus = {
|
||||
active: boolean;
|
||||
startedAt: number | null;
|
||||
completedAt: number | null;
|
||||
};
|
||||
|
||||
export type FallbackStatus = {
|
||||
phase?: "active" | "cleared";
|
||||
selected: string;
|
||||
active: string;
|
||||
previous?: string;
|
||||
reason?: string;
|
||||
attempts: string[];
|
||||
occurredAt: number;
|
||||
};
|
||||
|
||||
type CompactionHost = ToolStreamHost & {
|
||||
compactionStatus?: CompactionStatus | null;
|
||||
compactionClearTimer?: number | null;
|
||||
fallbackStatus?: FallbackStatus | null;
|
||||
fallbackClearTimer?: number | null;
|
||||
};
|
||||
|
||||
const COMPACTION_TOAST_DURATION_MS = 5000;
|
||||
const FALLBACK_TOAST_DURATION_MS = 8000;
|
||||
|
||||
export function handleCompactionEvent(host: CompactionHost, payload: AgentEventPayload) {
|
||||
const data = payload.data ?? {};
|
||||
const phase = typeof data.phase === "string" ? data.phase : "";
|
||||
|
||||
// Clear any existing timer
|
||||
if (host.compactionClearTimer != null) {
|
||||
window.clearTimeout(host.compactionClearTimer);
|
||||
host.compactionClearTimer = null;
|
||||
}
|
||||
|
||||
if (phase === "start") {
|
||||
host.compactionStatus = {
|
||||
active: true,
|
||||
startedAt: Date.now(),
|
||||
completedAt: null,
|
||||
};
|
||||
} else if (phase === "end") {
|
||||
host.compactionStatus = {
|
||||
active: false,
|
||||
startedAt: host.compactionStatus?.startedAt ?? null,
|
||||
completedAt: Date.now(),
|
||||
};
|
||||
// Auto-clear the toast after duration
|
||||
host.compactionClearTimer = window.setTimeout(() => {
|
||||
host.compactionStatus = null;
|
||||
host.compactionClearTimer = null;
|
||||
}, COMPACTION_TOAST_DURATION_MS);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAcceptedSession(
|
||||
host: ToolStreamHost,
|
||||
payload: AgentEventPayload,
|
||||
options?: {
|
||||
allowSessionScopedWhenIdle?: boolean;
|
||||
},
|
||||
): { accepted: boolean; sessionKey?: string } {
|
||||
const sessionKey = typeof payload.sessionKey === "string" ? payload.sessionKey : undefined;
|
||||
if (sessionKey && sessionKey !== host.sessionKey) {
|
||||
return { accepted: false };
|
||||
}
|
||||
if (!host.chatRunId && options?.allowSessionScopedWhenIdle && sessionKey) {
|
||||
return { accepted: true, sessionKey };
|
||||
}
|
||||
// Fallback: only accept session-less events for the active run.
|
||||
if (!sessionKey && host.chatRunId && payload.runId !== host.chatRunId) {
|
||||
return { accepted: false };
|
||||
}
|
||||
if (host.chatRunId && payload.runId !== host.chatRunId) {
|
||||
return { accepted: false };
|
||||
}
|
||||
if (!host.chatRunId) {
|
||||
return { accepted: false };
|
||||
}
|
||||
return { accepted: true, sessionKey };
|
||||
}
|
||||
|
||||
function handleLifecycleFallbackEvent(host: CompactionHost, payload: AgentEventPayload) {
|
||||
const data = payload.data ?? {};
|
||||
const phase = payload.stream === "fallback" ? "fallback" : toTrimmedString(data.phase);
|
||||
if (payload.stream === "lifecycle" && phase !== "fallback" && phase !== "fallback_cleared") {
|
||||
return;
|
||||
}
|
||||
|
||||
const accepted = resolveAcceptedSession(host, payload, { allowSessionScopedWhenIdle: true });
|
||||
if (!accepted.accepted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selected =
|
||||
resolveModelLabel(data.selectedProvider, data.selectedModel) ??
|
||||
resolveModelLabel(data.fromProvider, data.fromModel);
|
||||
const active =
|
||||
resolveModelLabel(data.activeProvider, data.activeModel) ??
|
||||
resolveModelLabel(data.toProvider, data.toModel);
|
||||
const previous =
|
||||
resolveModelLabel(data.previousActiveProvider, data.previousActiveModel) ??
|
||||
toTrimmedString(data.previousActiveModel);
|
||||
if (!selected || !active) {
|
||||
return;
|
||||
}
|
||||
if (phase === "fallback" && selected === active) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reason = toTrimmedString(data.reasonSummary) ?? toTrimmedString(data.reason);
|
||||
const attempts = (() => {
|
||||
const summaries = parseFallbackAttemptSummaries(data.attemptSummaries);
|
||||
if (summaries.length > 0) {
|
||||
return summaries;
|
||||
}
|
||||
return parseFallbackAttempts(data.attempts).map((attempt) => {
|
||||
const modelRef = resolveModelLabel(attempt.provider, attempt.model);
|
||||
return `${modelRef ?? `${attempt.provider}/${attempt.model}`}: ${attempt.reason}`;
|
||||
});
|
||||
})();
|
||||
|
||||
if (host.fallbackClearTimer != null) {
|
||||
window.clearTimeout(host.fallbackClearTimer);
|
||||
host.fallbackClearTimer = null;
|
||||
}
|
||||
host.fallbackStatus = {
|
||||
phase: phase === "fallback_cleared" ? "cleared" : "active",
|
||||
selected,
|
||||
active: phase === "fallback_cleared" ? selected : active,
|
||||
previous:
|
||||
phase === "fallback_cleared"
|
||||
? (previous ?? (active !== selected ? active : undefined))
|
||||
: undefined,
|
||||
reason: reason ?? undefined,
|
||||
attempts,
|
||||
occurredAt: Date.now(),
|
||||
};
|
||||
host.fallbackClearTimer = window.setTimeout(() => {
|
||||
host.fallbackStatus = null;
|
||||
host.fallbackClearTimer = null;
|
||||
}, FALLBACK_TOAST_DURATION_MS);
|
||||
}
|
||||
|
||||
export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPayload) {
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle compaction events
|
||||
if (payload.stream === "compaction") {
|
||||
handleCompactionEvent(host as CompactionHost, payload);
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.stream === "lifecycle" || payload.stream === "fallback") {
|
||||
handleLifecycleFallbackEvent(host as CompactionHost, payload);
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.stream !== "tool") {
|
||||
return;
|
||||
}
|
||||
const accepted = resolveAcceptedSession(host, payload);
|
||||
if (!accepted.accepted) {
|
||||
return;
|
||||
}
|
||||
const sessionKey = accepted.sessionKey;
|
||||
|
||||
const data = payload.data ?? {};
|
||||
const toolCallId = typeof data.toolCallId === "string" ? data.toolCallId : "";
|
||||
if (!toolCallId) {
|
||||
return;
|
||||
}
|
||||
const name = typeof data.name === "string" ? data.name : "tool";
|
||||
const phase = typeof data.phase === "string" ? data.phase : "";
|
||||
const args = phase === "start" ? data.args : undefined;
|
||||
const output =
|
||||
phase === "update"
|
||||
? formatToolOutput(data.partialResult)
|
||||
: phase === "result"
|
||||
? formatToolOutput(data.result)
|
||||
: undefined;
|
||||
|
||||
const now = Date.now();
|
||||
let entry = host.toolStreamById.get(toolCallId);
|
||||
if (!entry) {
|
||||
entry = {
|
||||
toolCallId,
|
||||
runId: payload.runId,
|
||||
sessionKey,
|
||||
name,
|
||||
args,
|
||||
output: output || undefined,
|
||||
startedAt: typeof payload.ts === "number" ? payload.ts : now,
|
||||
updatedAt: now,
|
||||
message: {},
|
||||
};
|
||||
host.toolStreamById.set(toolCallId, entry);
|
||||
host.toolStreamOrder.push(toolCallId);
|
||||
} else {
|
||||
entry.name = name;
|
||||
if (args !== undefined) {
|
||||
entry.args = args;
|
||||
}
|
||||
if (output !== undefined) {
|
||||
entry.output = output || undefined;
|
||||
}
|
||||
entry.updatedAt = now;
|
||||
}
|
||||
|
||||
entry.message = buildToolStreamMessage(entry);
|
||||
trimToolStream(host);
|
||||
scheduleToolStreamSync(host, phase === "result");
|
||||
}
|
||||
325
openclaw/ui/src/ui/app-view-state.ts
Normal file
325
openclaw/ui/src/ui/app-view-state.ts
Normal file
@@ -0,0 +1,325 @@
|
||||
import type { EventLogEntry } from "./app-events.ts";
|
||||
import type { CompactionStatus, FallbackStatus } from "./app-tool-stream.ts";
|
||||
import type { CronFieldErrors } from "./controllers/cron.ts";
|
||||
import type { DevicePairingList } from "./controllers/devices.ts";
|
||||
import type { ExecApprovalRequest } from "./controllers/exec-approval.ts";
|
||||
import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "./controllers/exec-approvals.ts";
|
||||
import type { SkillMessage } from "./controllers/skills.ts";
|
||||
import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts";
|
||||
import type { Tab } from "./navigation.ts";
|
||||
import type { UiSettings } from "./storage.ts";
|
||||
import type { ThemeTransitionContext } from "./theme-transition.ts";
|
||||
import type { ThemeMode } from "./theme.ts";
|
||||
import type {
|
||||
AgentsListResult,
|
||||
AgentsFilesListResult,
|
||||
AgentIdentityResult,
|
||||
ChannelsStatusSnapshot,
|
||||
ConfigSnapshot,
|
||||
ConfigUiHints,
|
||||
CronJob,
|
||||
CronJobsEnabledFilter,
|
||||
CronJobsSortBy,
|
||||
CronDeliveryStatus,
|
||||
CronRunScope,
|
||||
CronSortDir,
|
||||
CronRunsStatusValue,
|
||||
CronRunsStatusFilter,
|
||||
CronRunLogEntry,
|
||||
CronStatus,
|
||||
HealthSnapshot,
|
||||
LogEntry,
|
||||
LogLevel,
|
||||
NostrProfile,
|
||||
PresenceEntry,
|
||||
SessionsUsageResult,
|
||||
CostUsageSummary,
|
||||
SessionUsageTimeSeries,
|
||||
SessionsListResult,
|
||||
SkillStatusReport,
|
||||
ToolsCatalogResult,
|
||||
StatusSummary,
|
||||
} from "./types.ts";
|
||||
import type { ChatAttachment, ChatQueueItem, CronFormState } from "./ui-types.ts";
|
||||
import type { NostrProfileFormState } from "./views/channels.nostr-profile-form.ts";
|
||||
import type { SessionLogEntry } from "./views/usage.ts";
|
||||
|
||||
export type AppViewState = {
|
||||
settings: UiSettings;
|
||||
password: string;
|
||||
tab: Tab;
|
||||
onboarding: boolean;
|
||||
basePath: string;
|
||||
connected: boolean;
|
||||
theme: ThemeMode;
|
||||
themeResolved: "light" | "dark";
|
||||
hello: GatewayHelloOk | null;
|
||||
lastError: string | null;
|
||||
lastErrorCode: string | null;
|
||||
eventLog: EventLogEntry[];
|
||||
assistantName: string;
|
||||
assistantAvatar: string | null;
|
||||
assistantAgentId: string | null;
|
||||
sessionKey: string;
|
||||
chatLoading: boolean;
|
||||
chatSending: boolean;
|
||||
chatMessage: string;
|
||||
chatAttachments: ChatAttachment[];
|
||||
chatMessages: unknown[];
|
||||
chatToolMessages: unknown[];
|
||||
chatStream: string | null;
|
||||
chatStreamStartedAt: number | null;
|
||||
chatRunId: string | null;
|
||||
compactionStatus: CompactionStatus | null;
|
||||
fallbackStatus: FallbackStatus | null;
|
||||
chatAvatarUrl: string | null;
|
||||
chatThinkingLevel: string | null;
|
||||
chatQueue: ChatQueueItem[];
|
||||
chatManualRefreshInFlight: boolean;
|
||||
nodesLoading: boolean;
|
||||
nodes: Array<Record<string, unknown>>;
|
||||
chatNewMessagesBelow: boolean;
|
||||
sidebarOpen: boolean;
|
||||
sidebarContent: string | null;
|
||||
sidebarError: string | null;
|
||||
splitRatio: number;
|
||||
scrollToBottom: (opts?: { smooth?: boolean }) => void;
|
||||
devicesLoading: boolean;
|
||||
devicesError: string | null;
|
||||
devicesList: DevicePairingList | null;
|
||||
execApprovalsLoading: boolean;
|
||||
execApprovalsSaving: boolean;
|
||||
execApprovalsDirty: boolean;
|
||||
execApprovalsSnapshot: ExecApprovalsSnapshot | null;
|
||||
execApprovalsForm: ExecApprovalsFile | null;
|
||||
execApprovalsSelectedAgent: string | null;
|
||||
execApprovalsTarget: "gateway" | "node";
|
||||
execApprovalsTargetNodeId: string | null;
|
||||
execApprovalQueue: ExecApprovalRequest[];
|
||||
execApprovalBusy: boolean;
|
||||
execApprovalError: string | null;
|
||||
pendingGatewayUrl: string | null;
|
||||
configLoading: boolean;
|
||||
configRaw: string;
|
||||
configRawOriginal: string;
|
||||
configValid: boolean | null;
|
||||
configIssues: unknown[];
|
||||
configSaving: boolean;
|
||||
configApplying: boolean;
|
||||
updateRunning: boolean;
|
||||
applySessionKey: string;
|
||||
configSnapshot: ConfigSnapshot | null;
|
||||
configSchema: unknown;
|
||||
configSchemaVersion: string | null;
|
||||
configSchemaLoading: boolean;
|
||||
configUiHints: ConfigUiHints;
|
||||
configForm: Record<string, unknown> | null;
|
||||
configFormOriginal: Record<string, unknown> | null;
|
||||
configFormMode: "form" | "raw";
|
||||
configSearchQuery: string;
|
||||
configActiveSection: string | null;
|
||||
configActiveSubsection: string | null;
|
||||
channelsLoading: boolean;
|
||||
channelsSnapshot: ChannelsStatusSnapshot | null;
|
||||
channelsError: string | null;
|
||||
channelsLastSuccess: number | null;
|
||||
whatsappLoginMessage: string | null;
|
||||
whatsappLoginQrDataUrl: string | null;
|
||||
whatsappLoginConnected: boolean | null;
|
||||
whatsappBusy: boolean;
|
||||
nostrProfileFormState: NostrProfileFormState | null;
|
||||
nostrProfileAccountId: string | null;
|
||||
configFormDirty: boolean;
|
||||
presenceLoading: boolean;
|
||||
presenceEntries: PresenceEntry[];
|
||||
presenceError: string | null;
|
||||
presenceStatus: string | null;
|
||||
agentsLoading: boolean;
|
||||
agentsList: AgentsListResult | null;
|
||||
agentsError: string | null;
|
||||
agentsSelectedId: string | null;
|
||||
toolsCatalogLoading: boolean;
|
||||
toolsCatalogError: string | null;
|
||||
toolsCatalogResult: ToolsCatalogResult | null;
|
||||
agentsPanel: "overview" | "files" | "tools" | "skills" | "channels" | "cron";
|
||||
agentFilesLoading: boolean;
|
||||
agentFilesError: string | null;
|
||||
agentFilesList: AgentsFilesListResult | null;
|
||||
agentFileContents: Record<string, string>;
|
||||
agentFileDrafts: Record<string, string>;
|
||||
agentFileActive: string | null;
|
||||
agentFileSaving: boolean;
|
||||
agentIdentityLoading: boolean;
|
||||
agentIdentityError: string | null;
|
||||
agentIdentityById: Record<string, AgentIdentityResult>;
|
||||
agentSkillsLoading: boolean;
|
||||
agentSkillsError: string | null;
|
||||
agentSkillsReport: SkillStatusReport | null;
|
||||
agentSkillsAgentId: string | null;
|
||||
sessionsLoading: boolean;
|
||||
sessionsResult: SessionsListResult | null;
|
||||
sessionsError: string | null;
|
||||
sessionsFilterActive: string;
|
||||
sessionsFilterLimit: string;
|
||||
sessionsIncludeGlobal: boolean;
|
||||
sessionsIncludeUnknown: boolean;
|
||||
usageLoading: boolean;
|
||||
usageResult: SessionsUsageResult | null;
|
||||
usageCostSummary: CostUsageSummary | null;
|
||||
usageError: string | null;
|
||||
usageStartDate: string;
|
||||
usageEndDate: string;
|
||||
usageSelectedSessions: string[];
|
||||
usageSelectedDays: string[];
|
||||
usageSelectedHours: number[];
|
||||
usageChartMode: "tokens" | "cost";
|
||||
usageDailyChartMode: "total" | "by-type";
|
||||
usageTimeSeriesMode: "cumulative" | "per-turn";
|
||||
usageTimeSeriesBreakdownMode: "total" | "by-type";
|
||||
usageTimeSeries: SessionUsageTimeSeries | null;
|
||||
usageTimeSeriesLoading: boolean;
|
||||
usageTimeSeriesCursorStart: number | null;
|
||||
usageTimeSeriesCursorEnd: number | null;
|
||||
usageSessionLogs: SessionLogEntry[] | null;
|
||||
usageSessionLogsLoading: boolean;
|
||||
usageSessionLogsExpanded: boolean;
|
||||
usageQuery: string;
|
||||
usageQueryDraft: string;
|
||||
usageQueryDebounceTimer: number | null;
|
||||
usageSessionSort: "tokens" | "cost" | "recent" | "messages" | "errors";
|
||||
usageSessionSortDir: "asc" | "desc";
|
||||
usageRecentSessions: string[];
|
||||
usageTimeZone: "local" | "utc";
|
||||
usageContextExpanded: boolean;
|
||||
usageHeaderPinned: boolean;
|
||||
usageSessionsTab: "all" | "recent";
|
||||
usageVisibleColumns: string[];
|
||||
usageLogFilterRoles: import("./views/usage.js").SessionLogRole[];
|
||||
usageLogFilterTools: string[];
|
||||
usageLogFilterHasTools: boolean;
|
||||
usageLogFilterQuery: string;
|
||||
cronLoading: boolean;
|
||||
cronJobsLoadingMore: boolean;
|
||||
cronJobs: CronJob[];
|
||||
cronJobsTotal: number;
|
||||
cronJobsHasMore: boolean;
|
||||
cronJobsNextOffset: number | null;
|
||||
cronJobsLimit: number;
|
||||
cronJobsQuery: string;
|
||||
cronJobsEnabledFilter: CronJobsEnabledFilter;
|
||||
cronJobsSortBy: CronJobsSortBy;
|
||||
cronJobsSortDir: CronSortDir;
|
||||
cronStatus: CronStatus | null;
|
||||
cronError: string | null;
|
||||
cronForm: CronFormState;
|
||||
cronFieldErrors: CronFieldErrors;
|
||||
cronEditingJobId: string | null;
|
||||
cronRunsJobId: string | null;
|
||||
cronRunsLoadingMore: boolean;
|
||||
cronRuns: CronRunLogEntry[];
|
||||
cronRunsTotal: number;
|
||||
cronRunsHasMore: boolean;
|
||||
cronRunsNextOffset: number | null;
|
||||
cronRunsLimit: number;
|
||||
cronRunsScope: CronRunScope;
|
||||
cronRunsStatuses: CronRunsStatusValue[];
|
||||
cronRunsDeliveryStatuses: CronDeliveryStatus[];
|
||||
cronRunsStatusFilter: CronRunsStatusFilter;
|
||||
cronRunsQuery: string;
|
||||
cronRunsSortDir: CronSortDir;
|
||||
cronModelSuggestions: string[];
|
||||
cronBusy: boolean;
|
||||
skillsLoading: boolean;
|
||||
skillsReport: SkillStatusReport | null;
|
||||
skillsError: string | null;
|
||||
skillsFilter: string;
|
||||
skillEdits: Record<string, string>;
|
||||
skillMessages: Record<string, SkillMessage>;
|
||||
skillsBusyKey: string | null;
|
||||
debugLoading: boolean;
|
||||
debugStatus: StatusSummary | null;
|
||||
debugHealth: HealthSnapshot | null;
|
||||
debugModels: unknown[];
|
||||
debugHeartbeat: unknown;
|
||||
debugCallMethod: string;
|
||||
debugCallParams: string;
|
||||
debugCallResult: string | null;
|
||||
debugCallError: string | null;
|
||||
logsLoading: boolean;
|
||||
logsError: string | null;
|
||||
logsFile: string | null;
|
||||
logsEntries: LogEntry[];
|
||||
logsFilterText: string;
|
||||
logsLevelFilters: Record<LogLevel, boolean>;
|
||||
logsAutoFollow: boolean;
|
||||
logsTruncated: boolean;
|
||||
logsCursor: number | null;
|
||||
logsLastFetchAt: number | null;
|
||||
logsLimit: number;
|
||||
logsMaxBytes: number;
|
||||
logsAtBottom: boolean;
|
||||
updateAvailable: import("./types.js").UpdateAvailable | null;
|
||||
client: GatewayBrowserClient | null;
|
||||
refreshSessionsAfterChat: Set<string>;
|
||||
connect: () => void;
|
||||
setTab: (tab: Tab) => void;
|
||||
setTheme: (theme: ThemeMode, context?: ThemeTransitionContext) => void;
|
||||
applySettings: (next: UiSettings) => void;
|
||||
loadOverview: () => Promise<void>;
|
||||
loadAssistantIdentity: () => Promise<void>;
|
||||
loadCron: () => Promise<void>;
|
||||
handleWhatsAppStart: (force: boolean) => Promise<void>;
|
||||
handleWhatsAppWait: () => Promise<void>;
|
||||
handleWhatsAppLogout: () => Promise<void>;
|
||||
handleChannelConfigSave: () => Promise<void>;
|
||||
handleChannelConfigReload: () => Promise<void>;
|
||||
handleNostrProfileEdit: (accountId: string, profile: NostrProfile | null) => void;
|
||||
handleNostrProfileCancel: () => void;
|
||||
handleNostrProfileFieldChange: (field: keyof NostrProfile, value: string) => void;
|
||||
handleNostrProfileSave: () => Promise<void>;
|
||||
handleNostrProfileImport: () => Promise<void>;
|
||||
handleNostrProfileToggleAdvanced: () => void;
|
||||
handleExecApprovalDecision: (decision: "allow-once" | "allow-always" | "deny") => Promise<void>;
|
||||
handleGatewayUrlConfirm: () => void;
|
||||
handleGatewayUrlCancel: () => void;
|
||||
handleConfigLoad: () => Promise<void>;
|
||||
handleConfigSave: () => Promise<void>;
|
||||
handleConfigApply: () => Promise<void>;
|
||||
handleConfigFormUpdate: (path: string, value: unknown) => void;
|
||||
handleConfigFormModeChange: (mode: "form" | "raw") => void;
|
||||
handleConfigRawChange: (raw: string) => void;
|
||||
handleInstallSkill: (key: string) => Promise<void>;
|
||||
handleUpdateSkill: (key: string) => Promise<void>;
|
||||
handleToggleSkillEnabled: (key: string, enabled: boolean) => Promise<void>;
|
||||
handleUpdateSkillEdit: (key: string, value: string) => void;
|
||||
handleSaveSkillApiKey: (key: string, apiKey: string) => Promise<void>;
|
||||
handleCronToggle: (jobId: string, enabled: boolean) => Promise<void>;
|
||||
handleCronRun: (jobId: string) => Promise<void>;
|
||||
handleCronRemove: (jobId: string) => Promise<void>;
|
||||
handleCronAdd: () => Promise<void>;
|
||||
handleCronRunsLoad: (jobId: string) => Promise<void>;
|
||||
handleCronFormUpdate: (path: string, value: unknown) => void;
|
||||
handleSessionsLoad: () => Promise<void>;
|
||||
handleSessionsPatch: (key: string, patch: unknown) => Promise<void>;
|
||||
handleLoadNodes: () => Promise<void>;
|
||||
handleLoadPresence: () => Promise<void>;
|
||||
handleLoadSkills: () => Promise<void>;
|
||||
handleLoadDebug: () => Promise<void>;
|
||||
handleLoadLogs: () => Promise<void>;
|
||||
handleDebugCall: () => Promise<void>;
|
||||
handleRunUpdate: () => Promise<void>;
|
||||
setPassword: (next: string) => void;
|
||||
setSessionKey: (next: string) => void;
|
||||
setChatMessage: (next: string) => void;
|
||||
handleSendChat: (messageOverride?: string, opts?: { restoreDraft?: boolean }) => Promise<void>;
|
||||
handleAbortChat: () => Promise<void>;
|
||||
removeQueuedMessage: (id: string) => void;
|
||||
handleChatScroll: (event: Event) => void;
|
||||
resetToolStream: () => void;
|
||||
resetChatScroll: () => void;
|
||||
exportLogs: (lines: string[], label: string) => void;
|
||||
handleLogsScroll: (event: Event) => void;
|
||||
handleOpenSidebar: (content: string) => void;
|
||||
handleCloseSidebar: () => void;
|
||||
handleSplitRatioChange: (ratio: number) => void;
|
||||
};
|
||||
616
openclaw/ui/src/ui/app.ts
Normal file
616
openclaw/ui/src/ui/app.ts
Normal file
@@ -0,0 +1,616 @@
|
||||
import { LitElement } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { i18n, I18nController, isSupportedLocale } from "../i18n/index.ts";
|
||||
import {
|
||||
handleChannelConfigReload as handleChannelConfigReloadInternal,
|
||||
handleChannelConfigSave as handleChannelConfigSaveInternal,
|
||||
handleNostrProfileCancel as handleNostrProfileCancelInternal,
|
||||
handleNostrProfileEdit as handleNostrProfileEditInternal,
|
||||
handleNostrProfileFieldChange as handleNostrProfileFieldChangeInternal,
|
||||
handleNostrProfileImport as handleNostrProfileImportInternal,
|
||||
handleNostrProfileSave as handleNostrProfileSaveInternal,
|
||||
handleNostrProfileToggleAdvanced as handleNostrProfileToggleAdvancedInternal,
|
||||
handleWhatsAppLogout as handleWhatsAppLogoutInternal,
|
||||
handleWhatsAppStart as handleWhatsAppStartInternal,
|
||||
handleWhatsAppWait as handleWhatsAppWaitInternal,
|
||||
} from "./app-channels.ts";
|
||||
import {
|
||||
handleAbortChat as handleAbortChatInternal,
|
||||
handleSendChat as handleSendChatInternal,
|
||||
removeQueuedMessage as removeQueuedMessageInternal,
|
||||
} from "./app-chat.ts";
|
||||
import { DEFAULT_CRON_FORM, DEFAULT_LOG_LEVEL_FILTERS } from "./app-defaults.ts";
|
||||
import type { EventLogEntry } from "./app-events.ts";
|
||||
import { connectGateway as connectGatewayInternal } from "./app-gateway.ts";
|
||||
import {
|
||||
handleConnected,
|
||||
handleDisconnected,
|
||||
handleFirstUpdated,
|
||||
handleUpdated,
|
||||
} from "./app-lifecycle.ts";
|
||||
import { renderApp } from "./app-render.ts";
|
||||
import {
|
||||
exportLogs as exportLogsInternal,
|
||||
handleChatScroll as handleChatScrollInternal,
|
||||
handleLogsScroll as handleLogsScrollInternal,
|
||||
resetChatScroll as resetChatScrollInternal,
|
||||
scheduleChatScroll as scheduleChatScrollInternal,
|
||||
} from "./app-scroll.ts";
|
||||
import {
|
||||
applySettings as applySettingsInternal,
|
||||
loadCron as loadCronInternal,
|
||||
loadOverview as loadOverviewInternal,
|
||||
setTab as setTabInternal,
|
||||
setTheme as setThemeInternal,
|
||||
onPopState as onPopStateInternal,
|
||||
} from "./app-settings.ts";
|
||||
import {
|
||||
resetToolStream as resetToolStreamInternal,
|
||||
type ToolStreamEntry,
|
||||
type CompactionStatus,
|
||||
type FallbackStatus,
|
||||
} from "./app-tool-stream.ts";
|
||||
import type { AppViewState } from "./app-view-state.ts";
|
||||
import { normalizeAssistantIdentity } from "./assistant-identity.ts";
|
||||
import { loadAssistantIdentity as loadAssistantIdentityInternal } from "./controllers/assistant-identity.ts";
|
||||
import type { CronFieldErrors } from "./controllers/cron.ts";
|
||||
import type { DevicePairingList } from "./controllers/devices.ts";
|
||||
import type { ExecApprovalRequest } from "./controllers/exec-approval.ts";
|
||||
import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "./controllers/exec-approvals.ts";
|
||||
import type { SkillMessage } from "./controllers/skills.ts";
|
||||
import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts";
|
||||
import type { Tab } from "./navigation.ts";
|
||||
import { loadSettings, type UiSettings } from "./storage.ts";
|
||||
import type { ResolvedTheme, ThemeMode } from "./theme.ts";
|
||||
import type {
|
||||
AgentsListResult,
|
||||
AgentsFilesListResult,
|
||||
AgentIdentityResult,
|
||||
ConfigSnapshot,
|
||||
ConfigUiHints,
|
||||
CronJob,
|
||||
CronRunLogEntry,
|
||||
CronStatus,
|
||||
HealthSnapshot,
|
||||
LogEntry,
|
||||
LogLevel,
|
||||
PresenceEntry,
|
||||
ChannelsStatusSnapshot,
|
||||
SessionsListResult,
|
||||
SkillStatusReport,
|
||||
ToolsCatalogResult,
|
||||
StatusSummary,
|
||||
NostrProfile,
|
||||
} from "./types.ts";
|
||||
import { type ChatAttachment, type ChatQueueItem, type CronFormState } from "./ui-types.ts";
|
||||
import { generateUUID } from "./uuid.ts";
|
||||
import type { NostrProfileFormState } from "./views/channels.nostr-profile-form.ts";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__OPENCLAW_CONTROL_UI_BASE_PATH__?: string;
|
||||
}
|
||||
}
|
||||
|
||||
const bootAssistantIdentity = normalizeAssistantIdentity({});
|
||||
|
||||
function resolveOnboardingMode(): boolean {
|
||||
if (!window.location.search) {
|
||||
return false;
|
||||
}
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const raw = params.get("onboarding");
|
||||
if (!raw) {
|
||||
return false;
|
||||
}
|
||||
const normalized = raw.trim().toLowerCase();
|
||||
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
|
||||
}
|
||||
|
||||
@customElement("openclaw-app")
|
||||
export class OpenClawApp extends LitElement {
|
||||
private i18nController = new I18nController(this);
|
||||
clientInstanceId = generateUUID();
|
||||
@state() settings: UiSettings = loadSettings();
|
||||
constructor() {
|
||||
super();
|
||||
if (isSupportedLocale(this.settings.locale)) {
|
||||
void i18n.setLocale(this.settings.locale);
|
||||
}
|
||||
}
|
||||
@state() password = "";
|
||||
@state() tab: Tab = "chat";
|
||||
@state() onboarding = resolveOnboardingMode();
|
||||
@state() connected = false;
|
||||
@state() theme: ThemeMode = this.settings.theme ?? "system";
|
||||
@state() themeResolved: ResolvedTheme = "dark";
|
||||
@state() hello: GatewayHelloOk | null = null;
|
||||
@state() lastError: string | null = null;
|
||||
@state() lastErrorCode: string | null = null;
|
||||
@state() eventLog: EventLogEntry[] = [];
|
||||
private eventLogBuffer: EventLogEntry[] = [];
|
||||
private toolStreamSyncTimer: number | null = null;
|
||||
private sidebarCloseTimer: number | null = null;
|
||||
|
||||
@state() assistantName = bootAssistantIdentity.name;
|
||||
@state() assistantAvatar = bootAssistantIdentity.avatar;
|
||||
@state() assistantAgentId = bootAssistantIdentity.agentId ?? null;
|
||||
|
||||
@state() sessionKey = this.settings.sessionKey;
|
||||
@state() chatLoading = false;
|
||||
@state() chatSending = false;
|
||||
@state() chatMessage = "";
|
||||
@state() chatMessages: unknown[] = [];
|
||||
@state() chatToolMessages: unknown[] = [];
|
||||
@state() chatStream: string | null = null;
|
||||
@state() chatStreamStartedAt: number | null = null;
|
||||
@state() chatRunId: string | null = null;
|
||||
@state() compactionStatus: CompactionStatus | null = null;
|
||||
@state() fallbackStatus: FallbackStatus | null = null;
|
||||
@state() chatAvatarUrl: string | null = null;
|
||||
@state() chatThinkingLevel: string | null = null;
|
||||
@state() chatQueue: ChatQueueItem[] = [];
|
||||
@state() chatAttachments: ChatAttachment[] = [];
|
||||
@state() chatManualRefreshInFlight = false;
|
||||
// Sidebar state for tool output viewing
|
||||
@state() sidebarOpen = false;
|
||||
@state() sidebarContent: string | null = null;
|
||||
@state() sidebarError: string | null = null;
|
||||
@state() splitRatio = this.settings.splitRatio;
|
||||
|
||||
@state() nodesLoading = false;
|
||||
@state() nodes: Array<Record<string, unknown>> = [];
|
||||
@state() devicesLoading = false;
|
||||
@state() devicesError: string | null = null;
|
||||
@state() devicesList: DevicePairingList | null = null;
|
||||
@state() execApprovalsLoading = false;
|
||||
@state() execApprovalsSaving = false;
|
||||
@state() execApprovalsDirty = false;
|
||||
@state() execApprovalsSnapshot: ExecApprovalsSnapshot | null = null;
|
||||
@state() execApprovalsForm: ExecApprovalsFile | null = null;
|
||||
@state() execApprovalsSelectedAgent: string | null = null;
|
||||
@state() execApprovalsTarget: "gateway" | "node" = "gateway";
|
||||
@state() execApprovalsTargetNodeId: string | null = null;
|
||||
@state() execApprovalQueue: ExecApprovalRequest[] = [];
|
||||
@state() execApprovalBusy = false;
|
||||
@state() execApprovalError: string | null = null;
|
||||
@state() pendingGatewayUrl: string | null = null;
|
||||
|
||||
@state() configLoading = false;
|
||||
@state() configRaw = "{\n}\n";
|
||||
@state() configRawOriginal = "";
|
||||
@state() configValid: boolean | null = null;
|
||||
@state() configIssues: unknown[] = [];
|
||||
@state() configSaving = false;
|
||||
@state() configApplying = false;
|
||||
@state() updateRunning = false;
|
||||
@state() applySessionKey = this.settings.lastActiveSessionKey;
|
||||
@state() configSnapshot: ConfigSnapshot | null = null;
|
||||
@state() configSchema: unknown = null;
|
||||
@state() configSchemaVersion: string | null = null;
|
||||
@state() configSchemaLoading = false;
|
||||
@state() configUiHints: ConfigUiHints = {};
|
||||
@state() configForm: Record<string, unknown> | null = null;
|
||||
@state() configFormOriginal: Record<string, unknown> | null = null;
|
||||
@state() configFormDirty = false;
|
||||
@state() configFormMode: "form" | "raw" = "form";
|
||||
@state() configSearchQuery = "";
|
||||
@state() configActiveSection: string | null = null;
|
||||
@state() configActiveSubsection: string | null = null;
|
||||
|
||||
@state() channelsLoading = false;
|
||||
@state() channelsSnapshot: ChannelsStatusSnapshot | null = null;
|
||||
@state() channelsError: string | null = null;
|
||||
@state() channelsLastSuccess: number | null = null;
|
||||
@state() whatsappLoginMessage: string | null = null;
|
||||
@state() whatsappLoginQrDataUrl: string | null = null;
|
||||
@state() whatsappLoginConnected: boolean | null = null;
|
||||
@state() whatsappBusy = false;
|
||||
@state() nostrProfileFormState: NostrProfileFormState | null = null;
|
||||
@state() nostrProfileAccountId: string | null = null;
|
||||
|
||||
@state() presenceLoading = false;
|
||||
@state() presenceEntries: PresenceEntry[] = [];
|
||||
@state() presenceError: string | null = null;
|
||||
@state() presenceStatus: string | null = null;
|
||||
|
||||
@state() agentsLoading = false;
|
||||
@state() agentsList: AgentsListResult | null = null;
|
||||
@state() agentsError: string | null = null;
|
||||
@state() agentsSelectedId: string | null = null;
|
||||
@state() toolsCatalogLoading = false;
|
||||
@state() toolsCatalogError: string | null = null;
|
||||
@state() toolsCatalogResult: ToolsCatalogResult | null = null;
|
||||
@state() agentsPanel: "overview" | "files" | "tools" | "skills" | "channels" | "cron" =
|
||||
"overview";
|
||||
@state() agentFilesLoading = false;
|
||||
@state() agentFilesError: string | null = null;
|
||||
@state() agentFilesList: AgentsFilesListResult | null = null;
|
||||
@state() agentFileContents: Record<string, string> = {};
|
||||
@state() agentFileDrafts: Record<string, string> = {};
|
||||
@state() agentFileActive: string | null = null;
|
||||
@state() agentFileSaving = false;
|
||||
@state() agentIdentityLoading = false;
|
||||
@state() agentIdentityError: string | null = null;
|
||||
@state() agentIdentityById: Record<string, AgentIdentityResult> = {};
|
||||
@state() agentSkillsLoading = false;
|
||||
@state() agentSkillsError: string | null = null;
|
||||
@state() agentSkillsReport: SkillStatusReport | null = null;
|
||||
@state() agentSkillsAgentId: 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() usageLoading = false;
|
||||
@state() usageResult: import("./types.js").SessionsUsageResult | null = null;
|
||||
@state() usageCostSummary: import("./types.js").CostUsageSummary | null = null;
|
||||
@state() usageError: string | null = null;
|
||||
@state() usageStartDate = (() => {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
})();
|
||||
@state() usageEndDate = (() => {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
})();
|
||||
@state() usageSelectedSessions: string[] = [];
|
||||
@state() usageSelectedDays: string[] = [];
|
||||
@state() usageSelectedHours: number[] = [];
|
||||
@state() usageChartMode: "tokens" | "cost" = "tokens";
|
||||
@state() usageDailyChartMode: "total" | "by-type" = "by-type";
|
||||
@state() usageTimeSeriesMode: "cumulative" | "per-turn" = "per-turn";
|
||||
@state() usageTimeSeriesBreakdownMode: "total" | "by-type" = "by-type";
|
||||
@state() usageTimeSeries: import("./types.js").SessionUsageTimeSeries | null = null;
|
||||
@state() usageTimeSeriesLoading = false;
|
||||
@state() usageTimeSeriesCursorStart: number | null = null;
|
||||
@state() usageTimeSeriesCursorEnd: number | null = null;
|
||||
@state() usageSessionLogs: import("./views/usage.js").SessionLogEntry[] | null = null;
|
||||
@state() usageSessionLogsLoading = false;
|
||||
@state() usageSessionLogsExpanded = false;
|
||||
// Applied query (used to filter the already-loaded sessions list client-side).
|
||||
@state() usageQuery = "";
|
||||
// Draft query text (updates immediately as the user types; applied via debounce or "Search").
|
||||
@state() usageQueryDraft = "";
|
||||
@state() usageSessionSort: "tokens" | "cost" | "recent" | "messages" | "errors" = "recent";
|
||||
@state() usageSessionSortDir: "desc" | "asc" = "desc";
|
||||
@state() usageRecentSessions: string[] = [];
|
||||
@state() usageTimeZone: "local" | "utc" = "local";
|
||||
@state() usageContextExpanded = false;
|
||||
@state() usageHeaderPinned = false;
|
||||
@state() usageSessionsTab: "all" | "recent" = "all";
|
||||
@state() usageVisibleColumns: string[] = [
|
||||
"channel",
|
||||
"agent",
|
||||
"provider",
|
||||
"model",
|
||||
"messages",
|
||||
"tools",
|
||||
"errors",
|
||||
"duration",
|
||||
];
|
||||
@state() usageLogFilterRoles: import("./views/usage.js").SessionLogRole[] = [];
|
||||
@state() usageLogFilterTools: string[] = [];
|
||||
@state() usageLogFilterHasTools = false;
|
||||
@state() usageLogFilterQuery = "";
|
||||
|
||||
// Non-reactive (don’t trigger renders just for timer bookkeeping).
|
||||
usageQueryDebounceTimer: number | null = null;
|
||||
|
||||
@state() cronLoading = false;
|
||||
@state() cronJobsLoadingMore = false;
|
||||
@state() cronJobs: CronJob[] = [];
|
||||
@state() cronJobsTotal = 0;
|
||||
@state() cronJobsHasMore = false;
|
||||
@state() cronJobsNextOffset: number | null = null;
|
||||
@state() cronJobsLimit = 50;
|
||||
@state() cronJobsQuery = "";
|
||||
@state() cronJobsEnabledFilter: import("./types.js").CronJobsEnabledFilter = "all";
|
||||
@state() cronJobsSortBy: import("./types.js").CronJobsSortBy = "nextRunAtMs";
|
||||
@state() cronJobsSortDir: import("./types.js").CronSortDir = "asc";
|
||||
@state() cronStatus: CronStatus | null = null;
|
||||
@state() cronError: string | null = null;
|
||||
@state() cronForm: CronFormState = { ...DEFAULT_CRON_FORM };
|
||||
@state() cronFieldErrors: CronFieldErrors = {};
|
||||
@state() cronEditingJobId: string | null = null;
|
||||
@state() cronRunsJobId: string | null = null;
|
||||
@state() cronRunsLoadingMore = false;
|
||||
@state() cronRuns: CronRunLogEntry[] = [];
|
||||
@state() cronRunsTotal = 0;
|
||||
@state() cronRunsHasMore = false;
|
||||
@state() cronRunsNextOffset: number | null = null;
|
||||
@state() cronRunsLimit = 50;
|
||||
@state() cronRunsScope: import("./types.js").CronRunScope = "all";
|
||||
@state() cronRunsStatuses: import("./types.js").CronRunsStatusValue[] = [];
|
||||
@state() cronRunsDeliveryStatuses: import("./types.js").CronDeliveryStatus[] = [];
|
||||
@state() cronRunsStatusFilter: import("./types.js").CronRunsStatusFilter = "all";
|
||||
@state() cronRunsQuery = "";
|
||||
@state() cronRunsSortDir: import("./types.js").CronSortDir = "desc";
|
||||
@state() cronModelSuggestions: string[] = [];
|
||||
@state() cronBusy = false;
|
||||
|
||||
@state() updateAvailable: import("./types.js").UpdateAvailable | null = null;
|
||||
|
||||
@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() skillMessages: Record<string, SkillMessage> = {};
|
||||
|
||||
@state() debugLoading = false;
|
||||
@state() debugStatus: StatusSummary | null = null;
|
||||
@state() debugHealth: HealthSnapshot | null = null;
|
||||
@state() debugModels: unknown[] = [];
|
||||
@state() debugHeartbeat: unknown = null;
|
||||
@state() debugCallMethod = "";
|
||||
@state() debugCallParams = "{}";
|
||||
@state() debugCallResult: string | null = null;
|
||||
@state() debugCallError: string | null = null;
|
||||
|
||||
@state() logsLoading = false;
|
||||
@state() logsError: string | null = null;
|
||||
@state() logsFile: string | null = null;
|
||||
@state() logsEntries: LogEntry[] = [];
|
||||
@state() logsFilterText = "";
|
||||
@state() logsLevelFilters: Record<LogLevel, boolean> = {
|
||||
...DEFAULT_LOG_LEVEL_FILTERS,
|
||||
};
|
||||
@state() logsAutoFollow = true;
|
||||
@state() logsTruncated = false;
|
||||
@state() logsCursor: number | null = null;
|
||||
@state() logsLastFetchAt: number | null = null;
|
||||
@state() logsLimit = 500;
|
||||
@state() logsMaxBytes = 250_000;
|
||||
@state() logsAtBottom = true;
|
||||
|
||||
client: GatewayBrowserClient | null = null;
|
||||
private chatScrollFrame: number | null = null;
|
||||
private chatScrollTimeout: number | null = null;
|
||||
private chatHasAutoScrolled = false;
|
||||
private chatUserNearBottom = true;
|
||||
@state() chatNewMessagesBelow = false;
|
||||
private nodesPollInterval: number | null = null;
|
||||
private logsPollInterval: number | null = null;
|
||||
private debugPollInterval: number | null = null;
|
||||
private logsScrollFrame: number | null = null;
|
||||
private toolStreamById = new Map<string, ToolStreamEntry>();
|
||||
private toolStreamOrder: string[] = [];
|
||||
refreshSessionsAfterChat = new Set<string>();
|
||||
basePath = "";
|
||||
private popStateHandler = () =>
|
||||
onPopStateInternal(this as unknown as Parameters<typeof onPopStateInternal>[0]);
|
||||
private themeMedia: MediaQueryList | null = null;
|
||||
private themeMediaHandler: ((event: MediaQueryListEvent) => void) | null = null;
|
||||
private topbarObserver: ResizeObserver | null = null;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
handleConnected(this as unknown as Parameters<typeof handleConnected>[0]);
|
||||
}
|
||||
|
||||
protected firstUpdated() {
|
||||
handleFirstUpdated(this as unknown as Parameters<typeof handleFirstUpdated>[0]);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
handleDisconnected(this as unknown as Parameters<typeof handleDisconnected>[0]);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
protected updated(changed: Map<PropertyKey, unknown>) {
|
||||
handleUpdated(this as unknown as Parameters<typeof handleUpdated>[0], changed);
|
||||
}
|
||||
|
||||
connect() {
|
||||
connectGatewayInternal(this as unknown as Parameters<typeof connectGatewayInternal>[0]);
|
||||
}
|
||||
|
||||
handleChatScroll(event: Event) {
|
||||
handleChatScrollInternal(
|
||||
this as unknown as Parameters<typeof handleChatScrollInternal>[0],
|
||||
event,
|
||||
);
|
||||
}
|
||||
|
||||
handleLogsScroll(event: Event) {
|
||||
handleLogsScrollInternal(
|
||||
this as unknown as Parameters<typeof handleLogsScrollInternal>[0],
|
||||
event,
|
||||
);
|
||||
}
|
||||
|
||||
exportLogs(lines: string[], label: string) {
|
||||
exportLogsInternal(lines, label);
|
||||
}
|
||||
|
||||
resetToolStream() {
|
||||
resetToolStreamInternal(this as unknown as Parameters<typeof resetToolStreamInternal>[0]);
|
||||
}
|
||||
|
||||
resetChatScroll() {
|
||||
resetChatScrollInternal(this as unknown as Parameters<typeof resetChatScrollInternal>[0]);
|
||||
}
|
||||
|
||||
scrollToBottom(opts?: { smooth?: boolean }) {
|
||||
resetChatScrollInternal(this as unknown as Parameters<typeof resetChatScrollInternal>[0]);
|
||||
scheduleChatScrollInternal(
|
||||
this as unknown as Parameters<typeof scheduleChatScrollInternal>[0],
|
||||
true,
|
||||
Boolean(opts?.smooth),
|
||||
);
|
||||
}
|
||||
|
||||
async loadAssistantIdentity() {
|
||||
await loadAssistantIdentityInternal(this);
|
||||
}
|
||||
|
||||
applySettings(next: UiSettings) {
|
||||
applySettingsInternal(this as unknown as Parameters<typeof applySettingsInternal>[0], next);
|
||||
}
|
||||
|
||||
setTab(next: Tab) {
|
||||
setTabInternal(this as unknown as Parameters<typeof setTabInternal>[0], next);
|
||||
}
|
||||
|
||||
setTheme(next: ThemeMode, context?: Parameters<typeof setThemeInternal>[2]) {
|
||||
setThemeInternal(this as unknown as Parameters<typeof setThemeInternal>[0], next, context);
|
||||
}
|
||||
|
||||
async loadOverview() {
|
||||
await loadOverviewInternal(this as unknown as Parameters<typeof loadOverviewInternal>[0]);
|
||||
}
|
||||
|
||||
async loadCron() {
|
||||
await loadCronInternal(this as unknown as Parameters<typeof loadCronInternal>[0]);
|
||||
}
|
||||
|
||||
async handleAbortChat() {
|
||||
await handleAbortChatInternal(this as unknown as Parameters<typeof handleAbortChatInternal>[0]);
|
||||
}
|
||||
|
||||
removeQueuedMessage(id: string) {
|
||||
removeQueuedMessageInternal(
|
||||
this as unknown as Parameters<typeof removeQueuedMessageInternal>[0],
|
||||
id,
|
||||
);
|
||||
}
|
||||
|
||||
async handleSendChat(
|
||||
messageOverride?: string,
|
||||
opts?: Parameters<typeof handleSendChatInternal>[2],
|
||||
) {
|
||||
await handleSendChatInternal(
|
||||
this as unknown as Parameters<typeof handleSendChatInternal>[0],
|
||||
messageOverride,
|
||||
opts,
|
||||
);
|
||||
}
|
||||
|
||||
async handleWhatsAppStart(force: boolean) {
|
||||
await handleWhatsAppStartInternal(this, force);
|
||||
}
|
||||
|
||||
async handleWhatsAppWait() {
|
||||
await handleWhatsAppWaitInternal(this);
|
||||
}
|
||||
|
||||
async handleWhatsAppLogout() {
|
||||
await handleWhatsAppLogoutInternal(this);
|
||||
}
|
||||
|
||||
async handleChannelConfigSave() {
|
||||
await handleChannelConfigSaveInternal(this);
|
||||
}
|
||||
|
||||
async handleChannelConfigReload() {
|
||||
await handleChannelConfigReloadInternal(this);
|
||||
}
|
||||
|
||||
handleNostrProfileEdit(accountId: string, profile: NostrProfile | null) {
|
||||
handleNostrProfileEditInternal(this, accountId, profile);
|
||||
}
|
||||
|
||||
handleNostrProfileCancel() {
|
||||
handleNostrProfileCancelInternal(this);
|
||||
}
|
||||
|
||||
handleNostrProfileFieldChange(field: keyof NostrProfile, value: string) {
|
||||
handleNostrProfileFieldChangeInternal(this, field, value);
|
||||
}
|
||||
|
||||
async handleNostrProfileSave() {
|
||||
await handleNostrProfileSaveInternal(this);
|
||||
}
|
||||
|
||||
async handleNostrProfileImport() {
|
||||
await handleNostrProfileImportInternal(this);
|
||||
}
|
||||
|
||||
handleNostrProfileToggleAdvanced() {
|
||||
handleNostrProfileToggleAdvancedInternal(this);
|
||||
}
|
||||
|
||||
async handleExecApprovalDecision(decision: "allow-once" | "allow-always" | "deny") {
|
||||
const active = this.execApprovalQueue[0];
|
||||
if (!active || !this.client || this.execApprovalBusy) {
|
||||
return;
|
||||
}
|
||||
this.execApprovalBusy = true;
|
||||
this.execApprovalError = null;
|
||||
try {
|
||||
await this.client.request("exec.approval.resolve", {
|
||||
id: active.id,
|
||||
decision,
|
||||
});
|
||||
this.execApprovalQueue = this.execApprovalQueue.filter((entry) => entry.id !== active.id);
|
||||
} catch (err) {
|
||||
this.execApprovalError = `Exec approval failed: ${String(err)}`;
|
||||
} finally {
|
||||
this.execApprovalBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
handleGatewayUrlConfirm() {
|
||||
const nextGatewayUrl = this.pendingGatewayUrl;
|
||||
if (!nextGatewayUrl) {
|
||||
return;
|
||||
}
|
||||
this.pendingGatewayUrl = null;
|
||||
applySettingsInternal(this as unknown as Parameters<typeof applySettingsInternal>[0], {
|
||||
...this.settings,
|
||||
gatewayUrl: nextGatewayUrl,
|
||||
});
|
||||
this.connect();
|
||||
}
|
||||
|
||||
handleGatewayUrlCancel() {
|
||||
this.pendingGatewayUrl = null;
|
||||
}
|
||||
|
||||
// Sidebar handlers for tool output viewing
|
||||
handleOpenSidebar(content: string) {
|
||||
if (this.sidebarCloseTimer != null) {
|
||||
window.clearTimeout(this.sidebarCloseTimer);
|
||||
this.sidebarCloseTimer = null;
|
||||
}
|
||||
this.sidebarContent = content;
|
||||
this.sidebarError = null;
|
||||
this.sidebarOpen = true;
|
||||
}
|
||||
|
||||
handleCloseSidebar() {
|
||||
this.sidebarOpen = false;
|
||||
// Clear content after transition
|
||||
if (this.sidebarCloseTimer != null) {
|
||||
window.clearTimeout(this.sidebarCloseTimer);
|
||||
}
|
||||
this.sidebarCloseTimer = window.setTimeout(() => {
|
||||
if (this.sidebarOpen) {
|
||||
return;
|
||||
}
|
||||
this.sidebarContent = null;
|
||||
this.sidebarError = null;
|
||||
this.sidebarCloseTimer = null;
|
||||
}, 200);
|
||||
}
|
||||
|
||||
handleSplitRatioChange(ratio: number) {
|
||||
const newRatio = Math.max(0.4, Math.min(0.7, ratio));
|
||||
this.splitRatio = newRatio;
|
||||
this.applySettings({ ...this.settings, splitRatio: newRatio });
|
||||
}
|
||||
|
||||
render() {
|
||||
return renderApp(this as unknown as AppViewState);
|
||||
}
|
||||
}
|
||||
35
openclaw/ui/src/ui/assistant-identity.ts
Normal file
35
openclaw/ui/src/ui/assistant-identity.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
const MAX_ASSISTANT_NAME = 50;
|
||||
const MAX_ASSISTANT_AVATAR = 200;
|
||||
|
||||
export const DEFAULT_ASSISTANT_NAME = "Assistant";
|
||||
export const DEFAULT_ASSISTANT_AVATAR = "A";
|
||||
|
||||
export type AssistantIdentity = {
|
||||
agentId?: string | null;
|
||||
name: string;
|
||||
avatar: string | null;
|
||||
};
|
||||
|
||||
function coerceIdentityValue(value: string | undefined, maxLength: number): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
if (trimmed.length <= maxLength) {
|
||||
return trimmed;
|
||||
}
|
||||
return trimmed.slice(0, maxLength);
|
||||
}
|
||||
|
||||
export function normalizeAssistantIdentity(
|
||||
input?: Partial<AssistantIdentity> | null,
|
||||
): AssistantIdentity {
|
||||
const name = coerceIdentityValue(input?.name, MAX_ASSISTANT_NAME) ?? DEFAULT_ASSISTANT_NAME;
|
||||
const avatar = coerceIdentityValue(input?.avatar ?? undefined, MAX_ASSISTANT_AVATAR) ?? null;
|
||||
const agentId =
|
||||
typeof input?.agentId === "string" && input.agentId.trim() ? input.agentId.trim() : null;
|
||||
return { agentId, name, avatar };
|
||||
}
|
||||
47
openclaw/ui/src/ui/chat-event-reload.test.ts
Normal file
47
openclaw/ui/src/ui/chat-event-reload.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { shouldReloadHistoryForFinalEvent } from "./chat-event-reload.ts";
|
||||
|
||||
describe("shouldReloadHistoryForFinalEvent", () => {
|
||||
it("returns false for non-final events", () => {
|
||||
expect(
|
||||
shouldReloadHistoryForFinalEvent({
|
||||
runId: "run-1",
|
||||
sessionKey: "main",
|
||||
state: "delta",
|
||||
message: { role: "assistant", content: [{ type: "text", text: "x" }] },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when final event has no message payload", () => {
|
||||
expect(
|
||||
shouldReloadHistoryForFinalEvent({
|
||||
runId: "run-1",
|
||||
sessionKey: "main",
|
||||
state: "final",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when final event includes assistant payload", () => {
|
||||
expect(
|
||||
shouldReloadHistoryForFinalEvent({
|
||||
runId: "run-1",
|
||||
sessionKey: "main",
|
||||
state: "final",
|
||||
message: { role: "assistant", content: [{ type: "text", text: "done" }] },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when final event message role is non-assistant", () => {
|
||||
expect(
|
||||
shouldReloadHistoryForFinalEvent({
|
||||
runId: "run-1",
|
||||
sessionKey: "main",
|
||||
state: "final",
|
||||
message: { role: "user", content: [{ type: "text", text: "echo" }] },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
16
openclaw/ui/src/ui/chat-event-reload.ts
Normal file
16
openclaw/ui/src/ui/chat-event-reload.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { ChatEventPayload } from "./controllers/chat.ts";
|
||||
|
||||
export function shouldReloadHistoryForFinalEvent(payload?: ChatEventPayload): boolean {
|
||||
if (!payload || payload.state !== "final") {
|
||||
return false;
|
||||
}
|
||||
if (!payload.message || typeof payload.message !== "object") {
|
||||
return true;
|
||||
}
|
||||
const message = payload.message as Record<string, unknown>;
|
||||
const role = typeof message.role === "string" ? message.role.toLowerCase() : "";
|
||||
if (role && role !== "assistant") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
37
openclaw/ui/src/ui/chat-markdown.browser.test.ts
Normal file
37
openclaw/ui/src/ui/chat-markdown.browser.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mountApp, registerAppMountHooks } from "./test-helpers/app-mount.ts";
|
||||
|
||||
registerAppMountHooks();
|
||||
|
||||
describe("chat markdown rendering", () => {
|
||||
it("renders markdown inside tool output sidebar", async () => {
|
||||
const app = mountApp("/chat");
|
||||
await app.updateComplete;
|
||||
|
||||
const timestamp = Date.now();
|
||||
app.chatMessages = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "toolcall", name: "noop", arguments: {} },
|
||||
{ type: "toolresult", name: "noop", text: "Hello **world**" },
|
||||
],
|
||||
timestamp,
|
||||
},
|
||||
];
|
||||
|
||||
await app.updateComplete;
|
||||
|
||||
const toolCards = Array.from(app.querySelectorAll<HTMLElement>(".chat-tool-card"));
|
||||
const toolCard = toolCards.find((card) =>
|
||||
card.querySelector(".chat-tool-card__preview, .chat-tool-card__inline"),
|
||||
);
|
||||
expect(toolCard).not.toBeUndefined();
|
||||
toolCard?.click();
|
||||
|
||||
await app.updateComplete;
|
||||
|
||||
const strong = app.querySelector(".sidebar-markdown strong");
|
||||
expect(strong?.textContent).toBe("world");
|
||||
});
|
||||
});
|
||||
12
openclaw/ui/src/ui/chat/constants.ts
Normal file
12
openclaw/ui/src/ui/chat/constants.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Chat-related constants for the UI layer.
|
||||
*/
|
||||
|
||||
/** Character threshold for showing tool output inline vs collapsed */
|
||||
export const TOOL_INLINE_THRESHOLD = 80;
|
||||
|
||||
/** Maximum lines to show in collapsed preview */
|
||||
export const PREVIEW_MAX_LINES = 2;
|
||||
|
||||
/** Maximum characters to show in collapsed preview */
|
||||
export const PREVIEW_MAX_CHARS = 100;
|
||||
97
openclaw/ui/src/ui/chat/copy-as-markdown.ts
Normal file
97
openclaw/ui/src/ui/chat/copy-as-markdown.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { html, type TemplateResult } from "lit";
|
||||
import { icons } from "../icons.ts";
|
||||
|
||||
const COPIED_FOR_MS = 1500;
|
||||
const ERROR_FOR_MS = 2000;
|
||||
const COPY_LABEL = "Copy as markdown";
|
||||
const COPIED_LABEL = "Copied";
|
||||
const ERROR_LABEL = "Copy failed";
|
||||
|
||||
type CopyButtonOptions = {
|
||||
text: () => string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
async function copyTextToClipboard(text: string): Promise<boolean> {
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function setButtonLabel(button: HTMLButtonElement, label: string) {
|
||||
button.title = label;
|
||||
button.setAttribute("aria-label", label);
|
||||
}
|
||||
|
||||
function createCopyButton(options: CopyButtonOptions): TemplateResult {
|
||||
const idleLabel = options.label ?? COPY_LABEL;
|
||||
return html`
|
||||
<button
|
||||
class="chat-copy-btn"
|
||||
type="button"
|
||||
title=${idleLabel}
|
||||
aria-label=${idleLabel}
|
||||
@click=${async (e: Event) => {
|
||||
const btn = e.currentTarget as HTMLButtonElement | null;
|
||||
|
||||
if (!btn || btn.dataset.copying === "1") {
|
||||
return;
|
||||
}
|
||||
|
||||
btn.dataset.copying = "1";
|
||||
btn.setAttribute("aria-busy", "true");
|
||||
btn.disabled = true;
|
||||
|
||||
const copied = await copyTextToClipboard(options.text());
|
||||
if (!btn.isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
delete btn.dataset.copying;
|
||||
btn.removeAttribute("aria-busy");
|
||||
btn.disabled = false;
|
||||
|
||||
if (!copied) {
|
||||
btn.dataset.error = "1";
|
||||
setButtonLabel(btn, ERROR_LABEL);
|
||||
|
||||
window.setTimeout(() => {
|
||||
if (!btn.isConnected) {
|
||||
return;
|
||||
}
|
||||
delete btn.dataset.error;
|
||||
setButtonLabel(btn, idleLabel);
|
||||
}, ERROR_FOR_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
btn.dataset.copied = "1";
|
||||
setButtonLabel(btn, COPIED_LABEL);
|
||||
|
||||
window.setTimeout(() => {
|
||||
if (!btn.isConnected) {
|
||||
return;
|
||||
}
|
||||
delete btn.dataset.copied;
|
||||
setButtonLabel(btn, idleLabel);
|
||||
}, COPIED_FOR_MS);
|
||||
}}
|
||||
>
|
||||
<span class="chat-copy-btn__icon" aria-hidden="true">
|
||||
<span class="chat-copy-btn__icon-copy">${icons.copy}</span>
|
||||
<span class="chat-copy-btn__icon-check">${icons.check}</span>
|
||||
</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderCopyAsMarkdownButton(markdown: string): TemplateResult {
|
||||
return createCopyButton({ text: () => markdown, label: COPY_LABEL });
|
||||
}
|
||||
287
openclaw/ui/src/ui/chat/grouped-render.ts
Normal file
287
openclaw/ui/src/ui/chat/grouped-render.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
import { html, nothing } from "lit";
|
||||
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
||||
import type { AssistantIdentity } from "../assistant-identity.ts";
|
||||
import { toSanitizedMarkdownHtml } from "../markdown.ts";
|
||||
import { openExternalUrlSafe } from "../open-external-url.ts";
|
||||
import { detectTextDirection } from "../text-direction.ts";
|
||||
import type { MessageGroup } from "../types/chat-types.ts";
|
||||
import { renderCopyAsMarkdownButton } from "./copy-as-markdown.ts";
|
||||
import {
|
||||
extractTextCached,
|
||||
extractThinkingCached,
|
||||
formatReasoningMarkdown,
|
||||
} from "./message-extract.ts";
|
||||
import { isToolResultMessage, normalizeRoleForGrouping } from "./message-normalizer.ts";
|
||||
import { extractToolCards, renderToolCardSidebar } from "./tool-cards.ts";
|
||||
|
||||
type ImageBlock = {
|
||||
url: string;
|
||||
alt?: string;
|
||||
};
|
||||
|
||||
function extractImages(message: unknown): ImageBlock[] {
|
||||
const m = message as Record<string, unknown>;
|
||||
const content = m.content;
|
||||
const images: ImageBlock[] = [];
|
||||
|
||||
if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
if (typeof block !== "object" || block === null) {
|
||||
continue;
|
||||
}
|
||||
const b = block as Record<string, unknown>;
|
||||
|
||||
if (b.type === "image") {
|
||||
// Handle source object format (from sendChatMessage)
|
||||
const source = b.source as Record<string, unknown> | undefined;
|
||||
if (source?.type === "base64" && typeof source.data === "string") {
|
||||
const data = source.data;
|
||||
const mediaType = (source.media_type as string) || "image/png";
|
||||
// If data is already a data URL, use it directly
|
||||
const url = data.startsWith("data:") ? data : `data:${mediaType};base64,${data}`;
|
||||
images.push({ url });
|
||||
} else if (typeof b.url === "string") {
|
||||
images.push({ url: b.url });
|
||||
}
|
||||
} else if (b.type === "image_url") {
|
||||
// OpenAI format
|
||||
const imageUrl = b.image_url as Record<string, unknown> | undefined;
|
||||
if (typeof imageUrl?.url === "string") {
|
||||
images.push({ url: imageUrl.url });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return images;
|
||||
}
|
||||
|
||||
export function renderReadingIndicatorGroup(assistant?: AssistantIdentity) {
|
||||
return html`
|
||||
<div class="chat-group assistant">
|
||||
${renderAvatar("assistant", assistant)}
|
||||
<div class="chat-group-messages">
|
||||
<div class="chat-bubble chat-reading-indicator" aria-hidden="true">
|
||||
<span class="chat-reading-indicator__dots">
|
||||
<span></span><span></span><span></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderStreamingGroup(
|
||||
text: string,
|
||||
startedAt: number,
|
||||
onOpenSidebar?: (content: string) => void,
|
||||
assistant?: AssistantIdentity,
|
||||
) {
|
||||
const timestamp = new Date(startedAt).toLocaleTimeString([], {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
const name = assistant?.name ?? "Assistant";
|
||||
|
||||
return html`
|
||||
<div class="chat-group assistant">
|
||||
${renderAvatar("assistant", assistant)}
|
||||
<div class="chat-group-messages">
|
||||
${renderGroupedMessage(
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text }],
|
||||
timestamp: startedAt,
|
||||
},
|
||||
{ isStreaming: true, showReasoning: false },
|
||||
onOpenSidebar,
|
||||
)}
|
||||
<div class="chat-group-footer">
|
||||
<span class="chat-sender-name">${name}</span>
|
||||
<span class="chat-group-timestamp">${timestamp}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderMessageGroup(
|
||||
group: MessageGroup,
|
||||
opts: {
|
||||
onOpenSidebar?: (content: string) => void;
|
||||
showReasoning: boolean;
|
||||
assistantName?: string;
|
||||
assistantAvatar?: string | null;
|
||||
},
|
||||
) {
|
||||
const normalizedRole = normalizeRoleForGrouping(group.role);
|
||||
const assistantName = opts.assistantName ?? "Assistant";
|
||||
const who =
|
||||
normalizedRole === "user"
|
||||
? "You"
|
||||
: normalizedRole === "assistant"
|
||||
? assistantName
|
||||
: normalizedRole;
|
||||
const roleClass =
|
||||
normalizedRole === "user" ? "user" : normalizedRole === "assistant" ? "assistant" : "other";
|
||||
const timestamp = new Date(group.timestamp).toLocaleTimeString([], {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
return html`
|
||||
<div class="chat-group ${roleClass}">
|
||||
${renderAvatar(group.role, {
|
||||
name: assistantName,
|
||||
avatar: opts.assistantAvatar ?? null,
|
||||
})}
|
||||
<div class="chat-group-messages">
|
||||
${group.messages.map((item, index) =>
|
||||
renderGroupedMessage(
|
||||
item.message,
|
||||
{
|
||||
isStreaming: group.isStreaming && index === group.messages.length - 1,
|
||||
showReasoning: opts.showReasoning,
|
||||
},
|
||||
opts.onOpenSidebar,
|
||||
),
|
||||
)}
|
||||
<div class="chat-group-footer">
|
||||
<span class="chat-sender-name">${who}</span>
|
||||
<span class="chat-group-timestamp">${timestamp}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderAvatar(role: string, assistant?: Pick<AssistantIdentity, "name" | "avatar">) {
|
||||
const normalized = normalizeRoleForGrouping(role);
|
||||
const assistantName = assistant?.name?.trim() || "Assistant";
|
||||
const assistantAvatar = assistant?.avatar?.trim() || "";
|
||||
const initial =
|
||||
normalized === "user"
|
||||
? "U"
|
||||
: normalized === "assistant"
|
||||
? assistantName.charAt(0).toUpperCase() || "A"
|
||||
: normalized === "tool"
|
||||
? "⚙"
|
||||
: "?";
|
||||
const className =
|
||||
normalized === "user"
|
||||
? "user"
|
||||
: normalized === "assistant"
|
||||
? "assistant"
|
||||
: normalized === "tool"
|
||||
? "tool"
|
||||
: "other";
|
||||
|
||||
if (assistantAvatar && normalized === "assistant") {
|
||||
if (isAvatarUrl(assistantAvatar)) {
|
||||
return html`<img
|
||||
class="chat-avatar ${className}"
|
||||
src="${assistantAvatar}"
|
||||
alt="${assistantName}"
|
||||
/>`;
|
||||
}
|
||||
return html`<div class="chat-avatar ${className}">${assistantAvatar}</div>`;
|
||||
}
|
||||
|
||||
return html`<div class="chat-avatar ${className}">${initial}</div>`;
|
||||
}
|
||||
|
||||
function isAvatarUrl(value: string): boolean {
|
||||
return (
|
||||
/^https?:\/\//i.test(value) || /^data:image\//i.test(value) || value.startsWith("/") // Relative paths from avatar endpoint
|
||||
);
|
||||
}
|
||||
|
||||
function renderMessageImages(images: ImageBlock[]) {
|
||||
if (images.length === 0) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const openImage = (url: string) => {
|
||||
openExternalUrlSafe(url, { allowDataImage: true });
|
||||
};
|
||||
|
||||
return html`
|
||||
<div class="chat-message-images">
|
||||
${images.map(
|
||||
(img) => html`
|
||||
<img
|
||||
src=${img.url}
|
||||
alt=${img.alt ?? "Attached image"}
|
||||
class="chat-message-image"
|
||||
@click=${() => openImage(img.url)}
|
||||
/>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderGroupedMessage(
|
||||
message: unknown,
|
||||
opts: { isStreaming: boolean; showReasoning: boolean },
|
||||
onOpenSidebar?: (content: string) => void,
|
||||
) {
|
||||
const m = message as Record<string, unknown>;
|
||||
const role = typeof m.role === "string" ? m.role : "unknown";
|
||||
const isToolResult =
|
||||
isToolResultMessage(message) ||
|
||||
role.toLowerCase() === "toolresult" ||
|
||||
role.toLowerCase() === "tool_result" ||
|
||||
typeof m.toolCallId === "string" ||
|
||||
typeof m.tool_call_id === "string";
|
||||
|
||||
const toolCards = extractToolCards(message);
|
||||
const hasToolCards = toolCards.length > 0;
|
||||
const images = extractImages(message);
|
||||
const hasImages = images.length > 0;
|
||||
|
||||
const extractedText = extractTextCached(message);
|
||||
const extractedThinking =
|
||||
opts.showReasoning && role === "assistant" ? extractThinkingCached(message) : null;
|
||||
const markdownBase = extractedText?.trim() ? extractedText : null;
|
||||
const reasoningMarkdown = extractedThinking ? formatReasoningMarkdown(extractedThinking) : null;
|
||||
const markdown = markdownBase;
|
||||
const canCopyMarkdown = role === "assistant" && Boolean(markdown?.trim());
|
||||
|
||||
const bubbleClasses = [
|
||||
"chat-bubble",
|
||||
canCopyMarkdown ? "has-copy" : "",
|
||||
opts.isStreaming ? "streaming" : "",
|
||||
"fade-in",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
if (!markdown && hasToolCards && isToolResult) {
|
||||
return html`${toolCards.map((card) => renderToolCardSidebar(card, onOpenSidebar))}`;
|
||||
}
|
||||
|
||||
if (!markdown && !hasToolCards && !hasImages) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="${bubbleClasses}">
|
||||
${canCopyMarkdown ? renderCopyAsMarkdownButton(markdown!) : nothing}
|
||||
${renderMessageImages(images)}
|
||||
${
|
||||
reasoningMarkdown
|
||||
? html`<div class="chat-thinking">${unsafeHTML(
|
||||
toSanitizedMarkdownHtml(reasoningMarkdown),
|
||||
)}</div>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
markdown
|
||||
? html`<div class="chat-text" dir="${detectTextDirection(markdown)}">${unsafeHTML(toSanitizedMarkdownHtml(markdown))}</div>`
|
||||
: nothing
|
||||
}
|
||||
${toolCards.map((card) => renderToolCardSidebar(card, onOpenSidebar))}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
45
openclaw/ui/src/ui/chat/message-extract.test.ts
Normal file
45
openclaw/ui/src/ui/chat/message-extract.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
extractText,
|
||||
extractTextCached,
|
||||
extractThinking,
|
||||
extractThinkingCached,
|
||||
} from "./message-extract.ts";
|
||||
|
||||
describe("extractTextCached", () => {
|
||||
it("matches extractText output", () => {
|
||||
const message = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Hello there" }],
|
||||
};
|
||||
expect(extractTextCached(message)).toBe(extractText(message));
|
||||
});
|
||||
|
||||
it("returns consistent output for repeated calls", () => {
|
||||
const message = {
|
||||
role: "user",
|
||||
content: "plain text",
|
||||
};
|
||||
expect(extractTextCached(message)).toBe("plain text");
|
||||
expect(extractTextCached(message)).toBe("plain text");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractThinkingCached", () => {
|
||||
it("matches extractThinking output", () => {
|
||||
const message = {
|
||||
role: "assistant",
|
||||
content: [{ type: "thinking", thinking: "Plan A" }],
|
||||
};
|
||||
expect(extractThinkingCached(message)).toBe(extractThinking(message));
|
||||
});
|
||||
|
||||
it("returns consistent output for repeated calls", () => {
|
||||
const message = {
|
||||
role: "assistant",
|
||||
content: [{ type: "thinking", thinking: "Plan A" }],
|
||||
};
|
||||
expect(extractThinkingCached(message)).toBe("Plan A");
|
||||
expect(extractThinkingCached(message)).toBe("Plan A");
|
||||
});
|
||||
});
|
||||
149
openclaw/ui/src/ui/chat/message-extract.ts
Normal file
149
openclaw/ui/src/ui/chat/message-extract.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { stripInboundMetadata } from "../../../../src/auto-reply/reply/strip-inbound-meta.js";
|
||||
import { stripEnvelope } from "../../../../src/shared/chat-envelope.js";
|
||||
import { stripThinkingTags } from "../format.ts";
|
||||
|
||||
const textCache = new WeakMap<object, string | null>();
|
||||
const thinkingCache = new WeakMap<object, string | null>();
|
||||
|
||||
export function extractText(message: unknown): string | null {
|
||||
const m = message as Record<string, unknown>;
|
||||
const role = typeof m.role === "string" ? m.role : "";
|
||||
const shouldStripInboundMetadata = role.toLowerCase() === "user";
|
||||
const content = m.content;
|
||||
if (typeof content === "string") {
|
||||
const processed =
|
||||
role === "assistant"
|
||||
? stripThinkingTags(content)
|
||||
: shouldStripInboundMetadata
|
||||
? stripInboundMetadata(stripEnvelope(content))
|
||||
: stripEnvelope(content);
|
||||
return processed;
|
||||
}
|
||||
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) {
|
||||
const joined = parts.join("\n");
|
||||
const processed =
|
||||
role === "assistant"
|
||||
? stripThinkingTags(joined)
|
||||
: shouldStripInboundMetadata
|
||||
? stripInboundMetadata(stripEnvelope(joined))
|
||||
: stripEnvelope(joined);
|
||||
return processed;
|
||||
}
|
||||
}
|
||||
if (typeof m.text === "string") {
|
||||
const processed =
|
||||
role === "assistant"
|
||||
? stripThinkingTags(m.text)
|
||||
: shouldStripInboundMetadata
|
||||
? stripInboundMetadata(stripEnvelope(m.text))
|
||||
: stripEnvelope(m.text);
|
||||
return processed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function extractTextCached(message: unknown): string | null {
|
||||
if (!message || typeof message !== "object") {
|
||||
return extractText(message);
|
||||
}
|
||||
const obj = message;
|
||||
if (textCache.has(obj)) {
|
||||
return textCache.get(obj) ?? null;
|
||||
}
|
||||
const value = extractText(message);
|
||||
textCache.set(obj, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function extractThinking(message: unknown): string | null {
|
||||
const m = message as Record<string, unknown>;
|
||||
const content = m.content;
|
||||
const parts: string[] = [];
|
||||
if (Array.isArray(content)) {
|
||||
for (const p of content) {
|
||||
const item = p as Record<string, unknown>;
|
||||
if (item.type === "thinking" && typeof item.thinking === "string") {
|
||||
const cleaned = item.thinking.trim();
|
||||
if (cleaned) {
|
||||
parts.push(cleaned);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (parts.length > 0) {
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
// Back-compat: older logs may still have <think> tags inside text blocks.
|
||||
const rawText = extractRawText(message);
|
||||
if (!rawText) {
|
||||
return null;
|
||||
}
|
||||
const matches = [
|
||||
...rawText.matchAll(/<\s*think(?:ing)?\s*>([\s\S]*?)<\s*\/\s*think(?:ing)?\s*>/gi),
|
||||
];
|
||||
const extracted = matches.map((m) => (m[1] ?? "").trim()).filter(Boolean);
|
||||
return extracted.length > 0 ? extracted.join("\n") : null;
|
||||
}
|
||||
|
||||
export function extractThinkingCached(message: unknown): string | null {
|
||||
if (!message || typeof message !== "object") {
|
||||
return extractThinking(message);
|
||||
}
|
||||
const obj = message;
|
||||
if (thinkingCache.has(obj)) {
|
||||
return thinkingCache.get(obj) ?? null;
|
||||
}
|
||||
const value = extractThinking(message);
|
||||
thinkingCache.set(obj, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function extractRawText(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;
|
||||
}
|
||||
|
||||
export function formatReasoningMarkdown(text: string): string {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
const lines = trimmed
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => `_${line}_`);
|
||||
return lines.length ? ["_Reasoning:_", ...lines].join("\n") : "";
|
||||
}
|
||||
179
openclaw/ui/src/ui/chat/message-normalizer.test.ts
Normal file
179
openclaw/ui/src/ui/chat/message-normalizer.test.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
normalizeMessage,
|
||||
normalizeRoleForGrouping,
|
||||
isToolResultMessage,
|
||||
} from "./message-normalizer.ts";
|
||||
|
||||
describe("message-normalizer", () => {
|
||||
describe("normalizeMessage", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2024-01-01T00:00:00Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("normalizes message with string content", () => {
|
||||
const result = normalizeMessage({
|
||||
role: "user",
|
||||
content: "Hello world",
|
||||
timestamp: 1000,
|
||||
id: "msg-1",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Hello world" }],
|
||||
timestamp: 1000,
|
||||
id: "msg-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes message with array content", () => {
|
||||
const result = normalizeMessage({
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "Here is the result" },
|
||||
{ type: "tool_use", name: "bash", args: { command: "ls" } },
|
||||
],
|
||||
timestamp: 2000,
|
||||
});
|
||||
|
||||
expect(result.role).toBe("assistant");
|
||||
expect(result.content).toHaveLength(2);
|
||||
expect(result.content[0]).toEqual({
|
||||
type: "text",
|
||||
text: "Here is the result",
|
||||
name: undefined,
|
||||
args: undefined,
|
||||
});
|
||||
expect(result.content[1]).toEqual({
|
||||
type: "tool_use",
|
||||
text: undefined,
|
||||
name: "bash",
|
||||
args: { command: "ls" },
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes message with text field (alternative format)", () => {
|
||||
const result = normalizeMessage({
|
||||
role: "user",
|
||||
text: "Alternative format",
|
||||
});
|
||||
|
||||
expect(result.content).toEqual([{ type: "text", text: "Alternative format" }]);
|
||||
});
|
||||
|
||||
it("detects tool result by toolCallId", () => {
|
||||
const result = normalizeMessage({
|
||||
role: "assistant",
|
||||
toolCallId: "call-123",
|
||||
content: "Tool output",
|
||||
});
|
||||
|
||||
expect(result.role).toBe("toolResult");
|
||||
});
|
||||
|
||||
it("detects tool result by tool_call_id (snake_case)", () => {
|
||||
const result = normalizeMessage({
|
||||
role: "assistant",
|
||||
tool_call_id: "call-456",
|
||||
content: "Tool output",
|
||||
});
|
||||
|
||||
expect(result.role).toBe("toolResult");
|
||||
});
|
||||
|
||||
it("handles missing role", () => {
|
||||
const result = normalizeMessage({ content: "No role" });
|
||||
expect(result.role).toBe("unknown");
|
||||
});
|
||||
|
||||
it("handles missing content", () => {
|
||||
const result = normalizeMessage({ role: "user" });
|
||||
expect(result.content).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses current timestamp when not provided", () => {
|
||||
const result = normalizeMessage({ role: "user", content: "Test" });
|
||||
expect(result.timestamp).toBe(Date.now());
|
||||
});
|
||||
|
||||
it("handles arguments field (alternative to args)", () => {
|
||||
const result = normalizeMessage({
|
||||
role: "assistant",
|
||||
content: [{ type: "tool_use", name: "test", arguments: { foo: "bar" } }],
|
||||
});
|
||||
|
||||
expect(result.content[0].args).toEqual({ foo: "bar" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeRoleForGrouping", () => {
|
||||
it("returns tool for toolresult", () => {
|
||||
expect(normalizeRoleForGrouping("toolresult")).toBe("tool");
|
||||
expect(normalizeRoleForGrouping("toolResult")).toBe("tool");
|
||||
expect(normalizeRoleForGrouping("TOOLRESULT")).toBe("tool");
|
||||
});
|
||||
|
||||
it("returns tool for tool_result", () => {
|
||||
expect(normalizeRoleForGrouping("tool_result")).toBe("tool");
|
||||
expect(normalizeRoleForGrouping("TOOL_RESULT")).toBe("tool");
|
||||
});
|
||||
|
||||
it("returns tool for tool", () => {
|
||||
expect(normalizeRoleForGrouping("tool")).toBe("tool");
|
||||
expect(normalizeRoleForGrouping("Tool")).toBe("tool");
|
||||
});
|
||||
|
||||
it("returns tool for function", () => {
|
||||
expect(normalizeRoleForGrouping("function")).toBe("tool");
|
||||
expect(normalizeRoleForGrouping("Function")).toBe("tool");
|
||||
});
|
||||
|
||||
it("preserves user role", () => {
|
||||
expect(normalizeRoleForGrouping("user")).toBe("user");
|
||||
expect(normalizeRoleForGrouping("User")).toBe("User");
|
||||
});
|
||||
|
||||
it("preserves assistant role", () => {
|
||||
expect(normalizeRoleForGrouping("assistant")).toBe("assistant");
|
||||
});
|
||||
|
||||
it("preserves system role", () => {
|
||||
expect(normalizeRoleForGrouping("system")).toBe("system");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isToolResultMessage", () => {
|
||||
it("returns true for toolresult role", () => {
|
||||
expect(isToolResultMessage({ role: "toolresult" })).toBe(true);
|
||||
expect(isToolResultMessage({ role: "toolResult" })).toBe(true);
|
||||
expect(isToolResultMessage({ role: "TOOLRESULT" })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for tool_result role", () => {
|
||||
expect(isToolResultMessage({ role: "tool_result" })).toBe(true);
|
||||
expect(isToolResultMessage({ role: "TOOL_RESULT" })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for other roles", () => {
|
||||
expect(isToolResultMessage({ role: "user" })).toBe(false);
|
||||
expect(isToolResultMessage({ role: "assistant" })).toBe(false);
|
||||
expect(isToolResultMessage({ role: "tool" })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for missing role", () => {
|
||||
expect(isToolResultMessage({})).toBe(false);
|
||||
expect(isToolResultMessage({ content: "test" })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for non-string role", () => {
|
||||
expect(isToolResultMessage({ role: 123 })).toBe(false);
|
||||
expect(isToolResultMessage({ role: null })).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
101
openclaw/ui/src/ui/chat/message-normalizer.ts
Normal file
101
openclaw/ui/src/ui/chat/message-normalizer.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Message normalization utilities for chat rendering.
|
||||
*/
|
||||
|
||||
import { stripInboundMetadata } from "../../../../src/auto-reply/reply/strip-inbound-meta.js";
|
||||
import type { NormalizedMessage, MessageContentItem } from "../types/chat-types.ts";
|
||||
|
||||
/**
|
||||
* Normalize a raw message object into a consistent structure.
|
||||
*/
|
||||
export function normalizeMessage(message: unknown): NormalizedMessage {
|
||||
const m = message as Record<string, unknown>;
|
||||
let role = typeof m.role === "string" ? m.role : "unknown";
|
||||
|
||||
// Detect tool messages by common gateway shapes.
|
||||
// Some tool events come through as assistant role with tool_* items in the content array.
|
||||
const hasToolId = typeof m.toolCallId === "string" || typeof m.tool_call_id === "string";
|
||||
|
||||
const contentRaw = m.content;
|
||||
const contentItems = Array.isArray(contentRaw) ? contentRaw : null;
|
||||
const hasToolContent =
|
||||
Array.isArray(contentItems) &&
|
||||
contentItems.some((item) => {
|
||||
const x = item as Record<string, unknown>;
|
||||
const t = (typeof x.type === "string" ? x.type : "").toLowerCase();
|
||||
return t === "toolresult" || t === "tool_result";
|
||||
});
|
||||
|
||||
const hasToolName = typeof m.toolName === "string" || typeof m.tool_name === "string";
|
||||
|
||||
if (hasToolId || hasToolContent || hasToolName) {
|
||||
role = "toolResult";
|
||||
}
|
||||
|
||||
// Extract content
|
||||
let content: MessageContentItem[] = [];
|
||||
|
||||
if (typeof m.content === "string") {
|
||||
content = [{ type: "text", text: m.content }];
|
||||
} else if (Array.isArray(m.content)) {
|
||||
content = m.content.map((item: Record<string, unknown>) => ({
|
||||
type: (item.type as MessageContentItem["type"]) || "text",
|
||||
text: item.text as string | undefined,
|
||||
name: item.name as string | undefined,
|
||||
args: item.args || item.arguments,
|
||||
}));
|
||||
} else if (typeof m.text === "string") {
|
||||
content = [{ type: "text", text: m.text }];
|
||||
}
|
||||
|
||||
const timestamp = typeof m.timestamp === "number" ? m.timestamp : Date.now();
|
||||
const id = typeof m.id === "string" ? m.id : undefined;
|
||||
|
||||
// Strip AI-injected metadata prefix blocks from user messages before display.
|
||||
if (role === "user" || role === "User") {
|
||||
content = content.map((item) => {
|
||||
if (item.type === "text" && typeof item.text === "string") {
|
||||
return { ...item, text: stripInboundMetadata(item.text) };
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
return { role, content, timestamp, id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize role for grouping purposes.
|
||||
*/
|
||||
export function normalizeRoleForGrouping(role: string): string {
|
||||
const lower = role.toLowerCase();
|
||||
// Preserve original casing when it's already a core role.
|
||||
if (role === "user" || role === "User") {
|
||||
return role;
|
||||
}
|
||||
if (role === "assistant") {
|
||||
return "assistant";
|
||||
}
|
||||
if (role === "system") {
|
||||
return "system";
|
||||
}
|
||||
// Keep tool-related roles distinct so the UI can style/toggle them.
|
||||
if (
|
||||
lower === "toolresult" ||
|
||||
lower === "tool_result" ||
|
||||
lower === "tool" ||
|
||||
lower === "function"
|
||||
) {
|
||||
return "tool";
|
||||
}
|
||||
return role;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a message is a tool result message based on its role.
|
||||
*/
|
||||
export function isToolResultMessage(message: unknown): boolean {
|
||||
const m = message as Record<string, unknown>;
|
||||
const role = typeof m.role === "string" ? m.role.toLowerCase() : "";
|
||||
return role === "toolresult" || role === "tool_result";
|
||||
}
|
||||
156
openclaw/ui/src/ui/chat/tool-cards.ts
Normal file
156
openclaw/ui/src/ui/chat/tool-cards.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { html, nothing } from "lit";
|
||||
import { icons } from "../icons.ts";
|
||||
import { formatToolDetail, resolveToolDisplay } from "../tool-display.ts";
|
||||
import type { ToolCard } from "../types/chat-types.ts";
|
||||
import { TOOL_INLINE_THRESHOLD } from "./constants.ts";
|
||||
import { extractTextCached } from "./message-extract.ts";
|
||||
import { isToolResultMessage } from "./message-normalizer.ts";
|
||||
import { formatToolOutputForSidebar, getTruncatedPreview } from "./tool-helpers.ts";
|
||||
|
||||
export function extractToolCards(message: unknown): ToolCard[] {
|
||||
const m = message as Record<string, unknown>;
|
||||
const content = normalizeContent(m.content);
|
||||
const cards: ToolCard[] = [];
|
||||
|
||||
for (const item of content) {
|
||||
const kind = (typeof item.type === "string" ? item.type : "").toLowerCase();
|
||||
const isToolCall =
|
||||
["toolcall", "tool_call", "tooluse", "tool_use"].includes(kind) ||
|
||||
(typeof item.name === "string" && item.arguments != null);
|
||||
if (isToolCall) {
|
||||
cards.push({
|
||||
kind: "call",
|
||||
name: (item.name as string) ?? "tool",
|
||||
args: coerceArgs(item.arguments ?? item.args),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of content) {
|
||||
const kind = (typeof item.type === "string" ? item.type : "").toLowerCase();
|
||||
if (kind !== "toolresult" && kind !== "tool_result") {
|
||||
continue;
|
||||
}
|
||||
const text = extractToolText(item);
|
||||
const name = typeof item.name === "string" ? item.name : "tool";
|
||||
cards.push({ kind: "result", name, text });
|
||||
}
|
||||
|
||||
if (isToolResultMessage(message) && !cards.some((card) => card.kind === "result")) {
|
||||
const name =
|
||||
(typeof m.toolName === "string" && m.toolName) ||
|
||||
(typeof m.tool_name === "string" && m.tool_name) ||
|
||||
"tool";
|
||||
const text = extractTextCached(message) ?? undefined;
|
||||
cards.push({ kind: "result", name, text });
|
||||
}
|
||||
|
||||
return cards;
|
||||
}
|
||||
|
||||
export function renderToolCardSidebar(card: ToolCard, onOpenSidebar?: (content: string) => void) {
|
||||
const display = resolveToolDisplay({ name: card.name, args: card.args });
|
||||
const detail = formatToolDetail(display);
|
||||
const hasText = Boolean(card.text?.trim());
|
||||
|
||||
const canClick = Boolean(onOpenSidebar);
|
||||
const handleClick = canClick
|
||||
? () => {
|
||||
if (hasText) {
|
||||
onOpenSidebar!(formatToolOutputForSidebar(card.text!));
|
||||
return;
|
||||
}
|
||||
const info = `## ${display.label}\n\n${
|
||||
detail ? `**Command:** \`${detail}\`\n\n` : ""
|
||||
}*No output — tool completed successfully.*`;
|
||||
onOpenSidebar!(info);
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const isShort = hasText && (card.text?.length ?? 0) <= TOOL_INLINE_THRESHOLD;
|
||||
const showCollapsed = hasText && !isShort;
|
||||
const showInline = hasText && isShort;
|
||||
const isEmpty = !hasText;
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="chat-tool-card ${canClick ? "chat-tool-card--clickable" : ""}"
|
||||
@click=${handleClick}
|
||||
role=${canClick ? "button" : nothing}
|
||||
tabindex=${canClick ? "0" : nothing}
|
||||
@keydown=${
|
||||
canClick
|
||||
? (e: KeyboardEvent) => {
|
||||
if (e.key !== "Enter" && e.key !== " ") {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
handleClick?.();
|
||||
}
|
||||
: nothing
|
||||
}
|
||||
>
|
||||
<div class="chat-tool-card__header">
|
||||
<div class="chat-tool-card__title">
|
||||
<span class="chat-tool-card__icon">${icons[display.icon]}</span>
|
||||
<span>${display.label}</span>
|
||||
</div>
|
||||
${
|
||||
canClick
|
||||
? html`<span class="chat-tool-card__action">${hasText ? "View" : ""} ${icons.check}</span>`
|
||||
: nothing
|
||||
}
|
||||
${isEmpty && !canClick ? html`<span class="chat-tool-card__status">${icons.check}</span>` : nothing}
|
||||
</div>
|
||||
${detail ? html`<div class="chat-tool-card__detail">${detail}</div>` : nothing}
|
||||
${
|
||||
isEmpty
|
||||
? html`
|
||||
<div class="chat-tool-card__status-text muted">Completed</div>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
showCollapsed
|
||||
? html`<div class="chat-tool-card__preview mono">${getTruncatedPreview(card.text!)}</div>`
|
||||
: nothing
|
||||
}
|
||||
${showInline ? html`<div class="chat-tool-card__inline mono">${card.text}</div>` : nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function normalizeContent(content: unknown): Array<Record<string, unknown>> {
|
||||
if (!Array.isArray(content)) {
|
||||
return [];
|
||||
}
|
||||
return content.filter(Boolean) as Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
function coerceArgs(value: unknown): unknown {
|
||||
if (typeof value !== "string") {
|
||||
return value;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return value;
|
||||
}
|
||||
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function extractToolText(item: Record<string, unknown>): string | undefined {
|
||||
if (typeof item.text === "string") {
|
||||
return item.text;
|
||||
}
|
||||
if (typeof item.content === "string") {
|
||||
return item.content;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
141
openclaw/ui/src/ui/chat/tool-helpers.test.ts
Normal file
141
openclaw/ui/src/ui/chat/tool-helpers.test.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { formatToolOutputForSidebar, getTruncatedPreview } from "./tool-helpers.ts";
|
||||
|
||||
describe("tool-helpers", () => {
|
||||
describe("formatToolOutputForSidebar", () => {
|
||||
it("formats valid JSON object as code block", () => {
|
||||
const input = '{"name":"test","value":123}';
|
||||
const result = formatToolOutputForSidebar(input);
|
||||
|
||||
expect(result).toBe(`\`\`\`json
|
||||
{
|
||||
"name": "test",
|
||||
"value": 123
|
||||
}
|
||||
\`\`\``);
|
||||
});
|
||||
|
||||
it("formats valid JSON array as code block", () => {
|
||||
const input = "[1, 2, 3]";
|
||||
const result = formatToolOutputForSidebar(input);
|
||||
|
||||
expect(result).toBe(`\`\`\`json
|
||||
[
|
||||
1,
|
||||
2,
|
||||
3
|
||||
]
|
||||
\`\`\``);
|
||||
});
|
||||
|
||||
it("handles nested JSON objects", () => {
|
||||
const input = '{"outer":{"inner":"value"}}';
|
||||
const result = formatToolOutputForSidebar(input);
|
||||
|
||||
expect(result).toContain("```json");
|
||||
expect(result).toContain('"outer"');
|
||||
expect(result).toContain('"inner"');
|
||||
});
|
||||
|
||||
it("returns plain text for non-JSON content", () => {
|
||||
const input = "This is plain text output";
|
||||
const result = formatToolOutputForSidebar(input);
|
||||
|
||||
expect(result).toBe("This is plain text output");
|
||||
});
|
||||
|
||||
it("returns as-is for invalid JSON starting with {", () => {
|
||||
const input = "{not valid json";
|
||||
const result = formatToolOutputForSidebar(input);
|
||||
|
||||
expect(result).toBe("{not valid json");
|
||||
});
|
||||
|
||||
it("returns as-is for invalid JSON starting with [", () => {
|
||||
const input = "[not valid json";
|
||||
const result = formatToolOutputForSidebar(input);
|
||||
|
||||
expect(result).toBe("[not valid json");
|
||||
});
|
||||
|
||||
it("trims whitespace before detecting JSON", () => {
|
||||
const input = ' {"trimmed": true} ';
|
||||
const result = formatToolOutputForSidebar(input);
|
||||
|
||||
expect(result).toContain("```json");
|
||||
expect(result).toContain('"trimmed"');
|
||||
});
|
||||
|
||||
it("handles empty string", () => {
|
||||
const result = formatToolOutputForSidebar("");
|
||||
expect(result).toBe("");
|
||||
});
|
||||
|
||||
it("handles whitespace-only string", () => {
|
||||
const result = formatToolOutputForSidebar(" ");
|
||||
expect(result).toBe(" ");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTruncatedPreview", () => {
|
||||
it("returns short text unchanged", () => {
|
||||
const input = "Short text";
|
||||
const result = getTruncatedPreview(input);
|
||||
|
||||
expect(result).toBe("Short text");
|
||||
});
|
||||
|
||||
it("truncates text longer than max chars", () => {
|
||||
const input = "a".repeat(150);
|
||||
const result = getTruncatedPreview(input);
|
||||
|
||||
expect(result.length).toBe(101); // 100 chars + ellipsis
|
||||
expect(result.endsWith("…")).toBe(true);
|
||||
});
|
||||
|
||||
it("truncates to max lines", () => {
|
||||
const input = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5";
|
||||
const result = getTruncatedPreview(input);
|
||||
|
||||
// Should only show first 2 lines (PREVIEW_MAX_LINES = 2)
|
||||
expect(result).toBe("Line 1\nLine 2…");
|
||||
});
|
||||
|
||||
it("adds ellipsis when lines are truncated", () => {
|
||||
const input = "Line 1\nLine 2\nLine 3";
|
||||
const result = getTruncatedPreview(input);
|
||||
|
||||
expect(result.endsWith("…")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not add ellipsis when all lines fit", () => {
|
||||
const input = "Line 1\nLine 2";
|
||||
const result = getTruncatedPreview(input);
|
||||
|
||||
expect(result).toBe("Line 1\nLine 2");
|
||||
expect(result.endsWith("…")).toBe(false);
|
||||
});
|
||||
|
||||
it("handles single line within limits", () => {
|
||||
const input = "Single line";
|
||||
const result = getTruncatedPreview(input);
|
||||
|
||||
expect(result).toBe("Single line");
|
||||
});
|
||||
|
||||
it("handles empty string", () => {
|
||||
const result = getTruncatedPreview("");
|
||||
expect(result).toBe("");
|
||||
});
|
||||
|
||||
it("truncates by chars even within line limit", () => {
|
||||
// Two lines but very long content
|
||||
const longLine = "x".repeat(80);
|
||||
const input = `${longLine}\n${longLine}`;
|
||||
const result = getTruncatedPreview(input);
|
||||
|
||||
expect(result.length).toBe(101); // 100 + ellipsis
|
||||
expect(result.endsWith("…")).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
37
openclaw/ui/src/ui/chat/tool-helpers.ts
Normal file
37
openclaw/ui/src/ui/chat/tool-helpers.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Helper functions for tool card rendering.
|
||||
*/
|
||||
|
||||
import { PREVIEW_MAX_CHARS, PREVIEW_MAX_LINES } from "./constants.ts";
|
||||
|
||||
/**
|
||||
* Format tool output content for display in the sidebar.
|
||||
* Detects JSON and wraps it in a code block with formatting.
|
||||
*/
|
||||
export function formatToolOutputForSidebar(text: string): string {
|
||||
const trimmed = text.trim();
|
||||
// Try to detect and format JSON
|
||||
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
return "```json\n" + JSON.stringify(parsed, null, 2) + "\n```";
|
||||
} catch {
|
||||
// Not valid JSON, return as-is
|
||||
}
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a truncated preview of tool output text.
|
||||
* Truncates to first N lines or first N characters, whichever is shorter.
|
||||
*/
|
||||
export function getTruncatedPreview(text: string): string {
|
||||
const allLines = text.split("\n");
|
||||
const lines = allLines.slice(0, PREVIEW_MAX_LINES);
|
||||
const preview = lines.join("\n");
|
||||
if (preview.length > PREVIEW_MAX_CHARS) {
|
||||
return preview.slice(0, PREVIEW_MAX_CHARS) + "…";
|
||||
}
|
||||
return lines.length < allLines.length ? preview + "…" : preview;
|
||||
}
|
||||
110
openclaw/ui/src/ui/components/resizable-divider.ts
Normal file
110
openclaw/ui/src/ui/components/resizable-divider.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { LitElement, css, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
|
||||
/**
|
||||
* A draggable divider for resizable split views.
|
||||
* Dispatches 'resize' events with { splitRatio: number } detail.
|
||||
*/
|
||||
@customElement("resizable-divider")
|
||||
export class ResizableDivider extends LitElement {
|
||||
@property({ type: Number }) splitRatio = 0.6;
|
||||
@property({ type: Number }) minRatio = 0.4;
|
||||
@property({ type: Number }) maxRatio = 0.7;
|
||||
|
||||
private isDragging = false;
|
||||
private startX = 0;
|
||||
private startRatio = 0;
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
width: 4px;
|
||||
cursor: col-resize;
|
||||
background: var(--border, #333);
|
||||
transition: background 150ms ease-out;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
:host::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -4px;
|
||||
right: -4px;
|
||||
bottom: 0;
|
||||
}
|
||||
:host(:hover) {
|
||||
background: var(--accent, #007bff);
|
||||
}
|
||||
:host(.dragging) {
|
||||
background: var(--accent, #007bff);
|
||||
}
|
||||
`;
|
||||
|
||||
render() {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.addEventListener("mousedown", this.handleMouseDown);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.removeEventListener("mousedown", this.handleMouseDown);
|
||||
document.removeEventListener("mousemove", this.handleMouseMove);
|
||||
document.removeEventListener("mouseup", this.handleMouseUp);
|
||||
}
|
||||
|
||||
private handleMouseDown = (e: MouseEvent) => {
|
||||
this.isDragging = true;
|
||||
this.startX = e.clientX;
|
||||
this.startRatio = this.splitRatio;
|
||||
this.classList.add("dragging");
|
||||
|
||||
document.addEventListener("mousemove", this.handleMouseMove);
|
||||
document.addEventListener("mouseup", this.handleMouseUp);
|
||||
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
private handleMouseMove = (e: MouseEvent) => {
|
||||
if (!this.isDragging) {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = this.parentElement;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const containerWidth = container.getBoundingClientRect().width;
|
||||
const deltaX = e.clientX - this.startX;
|
||||
const deltaRatio = deltaX / containerWidth;
|
||||
|
||||
let newRatio = this.startRatio + deltaRatio;
|
||||
newRatio = Math.max(this.minRatio, Math.min(this.maxRatio, newRatio));
|
||||
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("resize", {
|
||||
detail: { splitRatio: newRatio },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
private handleMouseUp = () => {
|
||||
this.isDragging = false;
|
||||
this.classList.remove("dragging");
|
||||
|
||||
document.removeEventListener("mousemove", this.handleMouseMove);
|
||||
document.removeEventListener("mouseup", this.handleMouseUp);
|
||||
};
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"resizable-divider": ResizableDivider;
|
||||
}
|
||||
}
|
||||
366
openclaw/ui/src/ui/config-form.browser.test.ts
Normal file
366
openclaw/ui/src/ui/config-form.browser.test.ts
Normal file
@@ -0,0 +1,366 @@
|
||||
import { render } from "lit";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { analyzeConfigSchema, renderConfigForm } from "./views/config-form.ts";
|
||||
|
||||
const rootSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
gateway: {
|
||||
type: "object",
|
||||
properties: {
|
||||
auth: {
|
||||
type: "object",
|
||||
properties: {
|
||||
token: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
allowFrom: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
},
|
||||
mode: {
|
||||
type: "string",
|
||||
enum: ["off", "token"],
|
||||
},
|
||||
enabled: {
|
||||
type: "boolean",
|
||||
},
|
||||
bind: {
|
||||
anyOf: [{ const: "auto" }, { const: "lan" }, { const: "tailnet" }, { const: "loopback" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe("config form renderer", () => {
|
||||
it("renders inputs and patches values", () => {
|
||||
const onPatch = vi.fn();
|
||||
const container = document.createElement("div");
|
||||
const analysis = analyzeConfigSchema(rootSchema);
|
||||
render(
|
||||
renderConfigForm({
|
||||
schema: analysis.schema,
|
||||
uiHints: {
|
||||
"gateway.auth.token": { label: "Gateway Token", sensitive: true },
|
||||
},
|
||||
unsupportedPaths: analysis.unsupportedPaths,
|
||||
value: {},
|
||||
onPatch,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
|
||||
const tokenInput: HTMLInputElement | null = container.querySelector("input[type='password']");
|
||||
expect(tokenInput).not.toBeNull();
|
||||
if (!tokenInput) {
|
||||
return;
|
||||
}
|
||||
tokenInput.value = "abc123";
|
||||
tokenInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
expect(onPatch).toHaveBeenCalledWith(["gateway", "auth", "token"], "abc123");
|
||||
|
||||
const tokenButton = Array.from(
|
||||
container.querySelectorAll<HTMLButtonElement>(".cfg-segmented__btn"),
|
||||
).find((btn) => btn.textContent?.trim() === "token");
|
||||
expect(tokenButton).not.toBeUndefined();
|
||||
tokenButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(onPatch).toHaveBeenCalledWith(["mode"], "token");
|
||||
|
||||
const checkbox: HTMLInputElement | null = container.querySelector("input[type='checkbox']");
|
||||
expect(checkbox).not.toBeNull();
|
||||
if (!checkbox) {
|
||||
return;
|
||||
}
|
||||
checkbox.checked = true;
|
||||
checkbox.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
expect(onPatch).toHaveBeenCalledWith(["enabled"], true);
|
||||
});
|
||||
|
||||
it("adds and removes array entries", () => {
|
||||
const onPatch = vi.fn();
|
||||
const container = document.createElement("div");
|
||||
const analysis = analyzeConfigSchema(rootSchema);
|
||||
render(
|
||||
renderConfigForm({
|
||||
schema: analysis.schema,
|
||||
uiHints: {},
|
||||
unsupportedPaths: analysis.unsupportedPaths,
|
||||
value: { allowFrom: ["+1"] },
|
||||
onPatch,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
|
||||
const addButton = container.querySelector(".cfg-array__add");
|
||||
expect(addButton).not.toBeUndefined();
|
||||
addButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(onPatch).toHaveBeenCalledWith(["allowFrom"], ["+1", ""]);
|
||||
|
||||
const removeButton = container.querySelector(".cfg-array__item-remove");
|
||||
expect(removeButton).not.toBeUndefined();
|
||||
removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(onPatch).toHaveBeenCalledWith(["allowFrom"], []);
|
||||
});
|
||||
|
||||
it("renders union literals as select options", () => {
|
||||
const onPatch = vi.fn();
|
||||
const container = document.createElement("div");
|
||||
const analysis = analyzeConfigSchema(rootSchema);
|
||||
render(
|
||||
renderConfigForm({
|
||||
schema: analysis.schema,
|
||||
uiHints: {},
|
||||
unsupportedPaths: analysis.unsupportedPaths,
|
||||
value: { bind: "auto" },
|
||||
onPatch,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
|
||||
const tailnetButton = Array.from(
|
||||
container.querySelectorAll<HTMLButtonElement>(".cfg-segmented__btn"),
|
||||
).find((btn) => btn.textContent?.trim() === "tailnet");
|
||||
expect(tailnetButton).not.toBeUndefined();
|
||||
tailnetButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(onPatch).toHaveBeenCalledWith(["bind"], "tailnet");
|
||||
});
|
||||
|
||||
it("renders map fields from additionalProperties", () => {
|
||||
const onPatch = vi.fn();
|
||||
const container = document.createElement("div");
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
slack: {
|
||||
type: "object",
|
||||
additionalProperties: {
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const analysis = analyzeConfigSchema(schema);
|
||||
render(
|
||||
renderConfigForm({
|
||||
schema: analysis.schema,
|
||||
uiHints: {},
|
||||
unsupportedPaths: analysis.unsupportedPaths,
|
||||
value: { slack: { channelA: "ok" } },
|
||||
onPatch,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
|
||||
const removeButton = container.querySelector(".cfg-map__item-remove");
|
||||
expect(removeButton).not.toBeUndefined();
|
||||
removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(onPatch).toHaveBeenCalledWith(["slack"], {});
|
||||
});
|
||||
|
||||
it("supports wildcard uiHints for map entries", () => {
|
||||
const onPatch = vi.fn();
|
||||
const container = document.createElement("div");
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
plugins: {
|
||||
type: "object",
|
||||
properties: {
|
||||
entries: {
|
||||
type: "object",
|
||||
additionalProperties: {
|
||||
type: "object",
|
||||
properties: {
|
||||
enabled: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const analysis = analyzeConfigSchema(schema);
|
||||
render(
|
||||
renderConfigForm({
|
||||
schema: analysis.schema,
|
||||
uiHints: {
|
||||
"plugins.entries.*.enabled": { label: "Plugin Enabled" },
|
||||
},
|
||||
unsupportedPaths: analysis.unsupportedPaths,
|
||||
value: { plugins: { entries: { "voice-call": { enabled: true } } } },
|
||||
onPatch,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain("Plugin Enabled");
|
||||
});
|
||||
|
||||
it("renders tags from uiHints metadata", () => {
|
||||
const onPatch = vi.fn();
|
||||
const container = document.createElement("div");
|
||||
const analysis = analyzeConfigSchema(rootSchema);
|
||||
render(
|
||||
renderConfigForm({
|
||||
schema: analysis.schema,
|
||||
uiHints: {
|
||||
"gateway.auth.token": { tags: ["security", "secret"] },
|
||||
},
|
||||
unsupportedPaths: analysis.unsupportedPaths,
|
||||
value: {},
|
||||
onPatch,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
|
||||
const tags = Array.from(container.querySelectorAll(".cfg-tag")).map((node) =>
|
||||
node.textContent?.trim(),
|
||||
);
|
||||
expect(tags).toContain("security");
|
||||
expect(tags).toContain("secret");
|
||||
});
|
||||
|
||||
it("filters by tag query", () => {
|
||||
const onPatch = vi.fn();
|
||||
const container = document.createElement("div");
|
||||
const analysis = analyzeConfigSchema(rootSchema);
|
||||
render(
|
||||
renderConfigForm({
|
||||
schema: analysis.schema,
|
||||
uiHints: {
|
||||
"gateway.auth.token": { tags: ["security"] },
|
||||
},
|
||||
unsupportedPaths: analysis.unsupportedPaths,
|
||||
value: {},
|
||||
searchQuery: "tag:security",
|
||||
onPatch,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain("Gateway");
|
||||
expect(container.textContent).toContain("Token");
|
||||
expect(container.textContent).not.toContain("Allow From");
|
||||
expect(container.textContent).not.toContain("Mode");
|
||||
});
|
||||
|
||||
it("does not treat plain text as tag filter", () => {
|
||||
const onPatch = vi.fn();
|
||||
const container = document.createElement("div");
|
||||
const analysis = analyzeConfigSchema(rootSchema);
|
||||
render(
|
||||
renderConfigForm({
|
||||
schema: analysis.schema,
|
||||
uiHints: {
|
||||
"gateway.auth.token": { tags: ["security"] },
|
||||
},
|
||||
unsupportedPaths: analysis.unsupportedPaths,
|
||||
value: {},
|
||||
searchQuery: "security",
|
||||
onPatch,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain('No settings match "security"');
|
||||
});
|
||||
|
||||
it("requires both text and tag when combined", () => {
|
||||
const onPatch = vi.fn();
|
||||
const container = document.createElement("div");
|
||||
const analysis = analyzeConfigSchema(rootSchema);
|
||||
render(
|
||||
renderConfigForm({
|
||||
schema: analysis.schema,
|
||||
uiHints: {
|
||||
"gateway.auth.token": { tags: ["security"] },
|
||||
},
|
||||
unsupportedPaths: analysis.unsupportedPaths,
|
||||
value: {},
|
||||
searchQuery: "token tag:security",
|
||||
onPatch,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain("Token");
|
||||
expect(container.textContent).not.toContain('No settings match "token tag:security"');
|
||||
|
||||
const noMatchContainer = document.createElement("div");
|
||||
render(
|
||||
renderConfigForm({
|
||||
schema: analysis.schema,
|
||||
uiHints: {
|
||||
"gateway.auth.token": { tags: ["security"] },
|
||||
},
|
||||
unsupportedPaths: analysis.unsupportedPaths,
|
||||
value: {},
|
||||
searchQuery: "mode tag:security",
|
||||
onPatch,
|
||||
}),
|
||||
noMatchContainer,
|
||||
);
|
||||
expect(noMatchContainer.textContent).toContain('No settings match "mode tag:security"');
|
||||
});
|
||||
|
||||
it("flags unsupported unions", () => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
mixed: {
|
||||
anyOf: [{ type: "string" }, { type: "object", properties: {} }],
|
||||
},
|
||||
},
|
||||
};
|
||||
const analysis = analyzeConfigSchema(schema);
|
||||
expect(analysis.unsupportedPaths).toContain("mixed");
|
||||
});
|
||||
|
||||
it("supports nullable types", () => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
note: { type: ["string", "null"] },
|
||||
},
|
||||
};
|
||||
const analysis = analyzeConfigSchema(schema);
|
||||
expect(analysis.unsupportedPaths).not.toContain("note");
|
||||
});
|
||||
|
||||
it("ignores untyped additionalProperties schemas", () => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
channels: {
|
||||
type: "object",
|
||||
properties: {
|
||||
whatsapp: {
|
||||
type: "object",
|
||||
properties: {
|
||||
enabled: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
},
|
||||
additionalProperties: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
const analysis = analyzeConfigSchema(schema);
|
||||
expect(analysis.unsupportedPaths).not.toContain("channels");
|
||||
});
|
||||
|
||||
it("flags additionalProperties true", () => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
extra: {
|
||||
type: "object",
|
||||
additionalProperties: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
const analysis = analyzeConfigSchema(schema);
|
||||
expect(analysis.unsupportedPaths).toContain("extra");
|
||||
});
|
||||
});
|
||||
126
openclaw/ui/src/ui/controllers/agent-files.ts
Normal file
126
openclaw/ui/src/ui/controllers/agent-files.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import type { GatewayBrowserClient } from "../gateway.ts";
|
||||
import type {
|
||||
AgentFileEntry,
|
||||
AgentsFilesGetResult,
|
||||
AgentsFilesListResult,
|
||||
AgentsFilesSetResult,
|
||||
} from "../types.ts";
|
||||
|
||||
export type AgentFilesState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
connected: boolean;
|
||||
agentFilesLoading: boolean;
|
||||
agentFilesError: string | null;
|
||||
agentFilesList: AgentsFilesListResult | null;
|
||||
agentFileContents: Record<string, string>;
|
||||
agentFileDrafts: Record<string, string>;
|
||||
agentFileActive: string | null;
|
||||
agentFileSaving: boolean;
|
||||
};
|
||||
|
||||
function mergeFileEntry(
|
||||
list: AgentsFilesListResult | null,
|
||||
entry: AgentFileEntry,
|
||||
): AgentsFilesListResult | null {
|
||||
if (!list) {
|
||||
return list;
|
||||
}
|
||||
const hasEntry = list.files.some((file) => file.name === entry.name);
|
||||
const nextFiles = hasEntry
|
||||
? list.files.map((file) => (file.name === entry.name ? entry : file))
|
||||
: [...list.files, entry];
|
||||
return { ...list, files: nextFiles };
|
||||
}
|
||||
|
||||
export async function loadAgentFiles(state: AgentFilesState, agentId: string) {
|
||||
if (!state.client || !state.connected || state.agentFilesLoading) {
|
||||
return;
|
||||
}
|
||||
state.agentFilesLoading = true;
|
||||
state.agentFilesError = null;
|
||||
try {
|
||||
const res = await state.client.request<AgentsFilesListResult | null>("agents.files.list", {
|
||||
agentId,
|
||||
});
|
||||
if (res) {
|
||||
state.agentFilesList = res;
|
||||
if (state.agentFileActive && !res.files.some((file) => file.name === state.agentFileActive)) {
|
||||
state.agentFileActive = null;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
state.agentFilesError = String(err);
|
||||
} finally {
|
||||
state.agentFilesLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadAgentFileContent(
|
||||
state: AgentFilesState,
|
||||
agentId: string,
|
||||
name: string,
|
||||
opts?: { force?: boolean; preserveDraft?: boolean },
|
||||
) {
|
||||
if (!state.client || !state.connected || state.agentFilesLoading) {
|
||||
return;
|
||||
}
|
||||
if (!opts?.force && Object.hasOwn(state.agentFileContents, name)) {
|
||||
return;
|
||||
}
|
||||
state.agentFilesLoading = true;
|
||||
state.agentFilesError = null;
|
||||
try {
|
||||
const res = await state.client.request<AgentsFilesGetResult | null>("agents.files.get", {
|
||||
agentId,
|
||||
name,
|
||||
});
|
||||
if (res?.file) {
|
||||
const content = res.file.content ?? "";
|
||||
const previousBase = state.agentFileContents[name] ?? "";
|
||||
const currentDraft = state.agentFileDrafts[name];
|
||||
const preserveDraft = opts?.preserveDraft ?? true;
|
||||
state.agentFilesList = mergeFileEntry(state.agentFilesList, res.file);
|
||||
state.agentFileContents = { ...state.agentFileContents, [name]: content };
|
||||
if (
|
||||
!preserveDraft ||
|
||||
!Object.hasOwn(state.agentFileDrafts, name) ||
|
||||
currentDraft === previousBase
|
||||
) {
|
||||
state.agentFileDrafts = { ...state.agentFileDrafts, [name]: content };
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
state.agentFilesError = String(err);
|
||||
} finally {
|
||||
state.agentFilesLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveAgentFile(
|
||||
state: AgentFilesState,
|
||||
agentId: string,
|
||||
name: string,
|
||||
content: string,
|
||||
) {
|
||||
if (!state.client || !state.connected || state.agentFileSaving) {
|
||||
return;
|
||||
}
|
||||
state.agentFileSaving = true;
|
||||
state.agentFilesError = null;
|
||||
try {
|
||||
const res = await state.client.request<AgentsFilesSetResult | null>("agents.files.set", {
|
||||
agentId,
|
||||
name,
|
||||
content,
|
||||
});
|
||||
if (res?.file) {
|
||||
state.agentFilesList = mergeFileEntry(state.agentFilesList, res.file);
|
||||
state.agentFileContents = { ...state.agentFileContents, [name]: content };
|
||||
state.agentFileDrafts = { ...state.agentFileDrafts, [name]: content };
|
||||
}
|
||||
} catch (err) {
|
||||
state.agentFilesError = String(err);
|
||||
} finally {
|
||||
state.agentFileSaving = false;
|
||||
}
|
||||
}
|
||||
59
openclaw/ui/src/ui/controllers/agent-identity.ts
Normal file
59
openclaw/ui/src/ui/controllers/agent-identity.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { GatewayBrowserClient } from "../gateway.ts";
|
||||
import type { AgentIdentityResult } from "../types.ts";
|
||||
|
||||
export type AgentIdentityState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
connected: boolean;
|
||||
agentIdentityLoading: boolean;
|
||||
agentIdentityError: string | null;
|
||||
agentIdentityById: Record<string, AgentIdentityResult>;
|
||||
};
|
||||
|
||||
export async function loadAgentIdentity(state: AgentIdentityState, agentId: string) {
|
||||
if (!state.client || !state.connected || state.agentIdentityLoading) {
|
||||
return;
|
||||
}
|
||||
if (state.agentIdentityById[agentId]) {
|
||||
return;
|
||||
}
|
||||
state.agentIdentityLoading = true;
|
||||
state.agentIdentityError = null;
|
||||
try {
|
||||
const res = await state.client.request<AgentIdentityResult | null>("agent.identity.get", {
|
||||
agentId,
|
||||
});
|
||||
if (res) {
|
||||
state.agentIdentityById = { ...state.agentIdentityById, [agentId]: res };
|
||||
}
|
||||
} catch (err) {
|
||||
state.agentIdentityError = String(err);
|
||||
} finally {
|
||||
state.agentIdentityLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadAgentIdentities(state: AgentIdentityState, agentIds: string[]) {
|
||||
if (!state.client || !state.connected || state.agentIdentityLoading) {
|
||||
return;
|
||||
}
|
||||
const missing = agentIds.filter((id) => !state.agentIdentityById[id]);
|
||||
if (missing.length === 0) {
|
||||
return;
|
||||
}
|
||||
state.agentIdentityLoading = true;
|
||||
state.agentIdentityError = null;
|
||||
try {
|
||||
for (const agentId of missing) {
|
||||
const res = await state.client.request<AgentIdentityResult | null>("agent.identity.get", {
|
||||
agentId,
|
||||
});
|
||||
if (res) {
|
||||
state.agentIdentityById = { ...state.agentIdentityById, [agentId]: res };
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
state.agentIdentityError = String(err);
|
||||
} finally {
|
||||
state.agentIdentityLoading = false;
|
||||
}
|
||||
}
|
||||
33
openclaw/ui/src/ui/controllers/agent-skills.ts
Normal file
33
openclaw/ui/src/ui/controllers/agent-skills.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { GatewayBrowserClient } from "../gateway.ts";
|
||||
import type { SkillStatusReport } from "../types.ts";
|
||||
|
||||
export type AgentSkillsState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
connected: boolean;
|
||||
agentSkillsLoading: boolean;
|
||||
agentSkillsError: string | null;
|
||||
agentSkillsReport: SkillStatusReport | null;
|
||||
agentSkillsAgentId: string | null;
|
||||
};
|
||||
|
||||
export async function loadAgentSkills(state: AgentSkillsState, agentId: string) {
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
if (state.agentSkillsLoading) {
|
||||
return;
|
||||
}
|
||||
state.agentSkillsLoading = true;
|
||||
state.agentSkillsError = null;
|
||||
try {
|
||||
const res = await state.client.request("skills.status", { agentId });
|
||||
if (res) {
|
||||
state.agentSkillsReport = res as SkillStatusReport;
|
||||
state.agentSkillsAgentId = agentId;
|
||||
}
|
||||
} catch (err) {
|
||||
state.agentSkillsError = String(err);
|
||||
} finally {
|
||||
state.agentSkillsLoading = false;
|
||||
}
|
||||
}
|
||||
61
openclaw/ui/src/ui/controllers/agents.test.ts
Normal file
61
openclaw/ui/src/ui/controllers/agents.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { loadToolsCatalog } from "./agents.ts";
|
||||
import type { AgentsState } from "./agents.ts";
|
||||
|
||||
function createState(): { state: AgentsState; request: ReturnType<typeof vi.fn> } {
|
||||
const request = vi.fn();
|
||||
const state: AgentsState = {
|
||||
client: {
|
||||
request,
|
||||
} as unknown as AgentsState["client"],
|
||||
connected: true,
|
||||
agentsLoading: false,
|
||||
agentsError: null,
|
||||
agentsList: null,
|
||||
agentsSelectedId: "main",
|
||||
toolsCatalogLoading: false,
|
||||
toolsCatalogError: null,
|
||||
toolsCatalogResult: null,
|
||||
};
|
||||
return { state, request };
|
||||
}
|
||||
|
||||
describe("loadToolsCatalog", () => {
|
||||
it("loads catalog and stores result", async () => {
|
||||
const { state, request } = createState();
|
||||
const payload = {
|
||||
agentId: "main",
|
||||
profiles: [{ id: "full", label: "Full" }],
|
||||
groups: [
|
||||
{
|
||||
id: "media",
|
||||
label: "Media",
|
||||
source: "core",
|
||||
tools: [{ id: "tts", label: "tts", description: "Text-to-speech", source: "core" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
request.mockResolvedValue(payload);
|
||||
|
||||
await loadToolsCatalog(state, "main");
|
||||
|
||||
expect(request).toHaveBeenCalledWith("tools.catalog", {
|
||||
agentId: "main",
|
||||
includePlugins: true,
|
||||
});
|
||||
expect(state.toolsCatalogResult).toEqual(payload);
|
||||
expect(state.toolsCatalogError).toBeNull();
|
||||
expect(state.toolsCatalogLoading).toBe(false);
|
||||
});
|
||||
|
||||
it("captures request errors for fallback UI handling", async () => {
|
||||
const { state, request } = createState();
|
||||
request.mockRejectedValue(new Error("gateway unavailable"));
|
||||
|
||||
await loadToolsCatalog(state, "main");
|
||||
|
||||
expect(state.toolsCatalogResult).toBeNull();
|
||||
expect(state.toolsCatalogError).toContain("gateway unavailable");
|
||||
expect(state.toolsCatalogLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
64
openclaw/ui/src/ui/controllers/agents.ts
Normal file
64
openclaw/ui/src/ui/controllers/agents.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { GatewayBrowserClient } from "../gateway.ts";
|
||||
import type { AgentsListResult, ToolsCatalogResult } from "../types.ts";
|
||||
|
||||
export type AgentsState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
connected: boolean;
|
||||
agentsLoading: boolean;
|
||||
agentsError: string | null;
|
||||
agentsList: AgentsListResult | null;
|
||||
agentsSelectedId: string | null;
|
||||
toolsCatalogLoading: boolean;
|
||||
toolsCatalogError: string | null;
|
||||
toolsCatalogResult: ToolsCatalogResult | null;
|
||||
};
|
||||
|
||||
export async function loadAgents(state: AgentsState) {
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
if (state.agentsLoading) {
|
||||
return;
|
||||
}
|
||||
state.agentsLoading = true;
|
||||
state.agentsError = null;
|
||||
try {
|
||||
const res = await state.client.request<AgentsListResult>("agents.list", {});
|
||||
if (res) {
|
||||
state.agentsList = res;
|
||||
const selected = state.agentsSelectedId;
|
||||
const known = res.agents.some((entry) => entry.id === selected);
|
||||
if (!selected || !known) {
|
||||
state.agentsSelectedId = res.defaultId ?? res.agents[0]?.id ?? null;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
state.agentsError = String(err);
|
||||
} finally {
|
||||
state.agentsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadToolsCatalog(state: AgentsState, agentId?: string | null) {
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
if (state.toolsCatalogLoading) {
|
||||
return;
|
||||
}
|
||||
state.toolsCatalogLoading = true;
|
||||
state.toolsCatalogError = null;
|
||||
try {
|
||||
const res = await state.client.request<ToolsCatalogResult>("tools.catalog", {
|
||||
agentId: agentId ?? state.agentsSelectedId ?? undefined,
|
||||
includePlugins: true,
|
||||
});
|
||||
if (res) {
|
||||
state.toolsCatalogResult = res;
|
||||
}
|
||||
} catch (err) {
|
||||
state.toolsCatalogError = String(err);
|
||||
} finally {
|
||||
state.toolsCatalogLoading = false;
|
||||
}
|
||||
}
|
||||
34
openclaw/ui/src/ui/controllers/assistant-identity.ts
Normal file
34
openclaw/ui/src/ui/controllers/assistant-identity.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { normalizeAssistantIdentity } from "../assistant-identity.ts";
|
||||
import type { GatewayBrowserClient } from "../gateway.ts";
|
||||
|
||||
export type AssistantIdentityState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
connected: boolean;
|
||||
sessionKey: string;
|
||||
assistantName: string;
|
||||
assistantAvatar: string | null;
|
||||
assistantAgentId: string | null;
|
||||
};
|
||||
|
||||
export async function loadAssistantIdentity(
|
||||
state: AssistantIdentityState,
|
||||
opts?: { sessionKey?: string },
|
||||
) {
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
const sessionKey = opts?.sessionKey?.trim() || state.sessionKey.trim();
|
||||
const params = sessionKey ? { sessionKey } : {};
|
||||
try {
|
||||
const res = await state.client.request("agent.identity.get", params);
|
||||
if (!res) {
|
||||
return;
|
||||
}
|
||||
const normalized = normalizeAssistantIdentity(res);
|
||||
state.assistantName = normalized.name;
|
||||
state.assistantAvatar = normalized.avatar;
|
||||
state.assistantAgentId = normalized.agentId ?? null;
|
||||
} catch {
|
||||
// Ignore errors; keep last known identity.
|
||||
}
|
||||
}
|
||||
94
openclaw/ui/src/ui/controllers/channels.ts
Normal file
94
openclaw/ui/src/ui/controllers/channels.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { ChannelsStatusSnapshot } from "../types.ts";
|
||||
import type { ChannelsState } from "./channels.types.ts";
|
||||
|
||||
export type { ChannelsState };
|
||||
|
||||
export async function loadChannels(state: ChannelsState, probe: boolean) {
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
if (state.channelsLoading) {
|
||||
return;
|
||||
}
|
||||
state.channelsLoading = true;
|
||||
state.channelsError = null;
|
||||
try {
|
||||
const res = await state.client.request<ChannelsStatusSnapshot | null>("channels.status", {
|
||||
probe,
|
||||
timeoutMs: 8000,
|
||||
});
|
||||
state.channelsSnapshot = res;
|
||||
state.channelsLastSuccess = Date.now();
|
||||
} catch (err) {
|
||||
state.channelsError = String(err);
|
||||
} finally {
|
||||
state.channelsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function startWhatsAppLogin(state: ChannelsState, force: boolean) {
|
||||
if (!state.client || !state.connected || state.whatsappBusy) {
|
||||
return;
|
||||
}
|
||||
state.whatsappBusy = true;
|
||||
try {
|
||||
const res = await state.client.request<{ message?: string; qrDataUrl?: string }>(
|
||||
"web.login.start",
|
||||
{
|
||||
force,
|
||||
timeoutMs: 30000,
|
||||
},
|
||||
);
|
||||
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: ChannelsState) {
|
||||
if (!state.client || !state.connected || state.whatsappBusy) {
|
||||
return;
|
||||
}
|
||||
state.whatsappBusy = true;
|
||||
try {
|
||||
const res = await state.client.request<{ message?: string; connected?: boolean }>(
|
||||
"web.login.wait",
|
||||
{
|
||||
timeoutMs: 120000,
|
||||
},
|
||||
);
|
||||
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: ChannelsState) {
|
||||
if (!state.client || !state.connected || state.whatsappBusy) {
|
||||
return;
|
||||
}
|
||||
state.whatsappBusy = true;
|
||||
try {
|
||||
await state.client.request("channels.logout", { channel: "whatsapp" });
|
||||
state.whatsappLoginMessage = "Logged out.";
|
||||
state.whatsappLoginQrDataUrl = null;
|
||||
state.whatsappLoginConnected = null;
|
||||
} catch (err) {
|
||||
state.whatsappLoginMessage = String(err);
|
||||
} finally {
|
||||
state.whatsappBusy = false;
|
||||
}
|
||||
}
|
||||
15
openclaw/ui/src/ui/controllers/channels.types.ts
Normal file
15
openclaw/ui/src/ui/controllers/channels.types.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { GatewayBrowserClient } from "../gateway.ts";
|
||||
import type { ChannelsStatusSnapshot } from "../types.ts";
|
||||
|
||||
export type ChannelsState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
connected: boolean;
|
||||
channelsLoading: boolean;
|
||||
channelsSnapshot: ChannelsStatusSnapshot | null;
|
||||
channelsError: string | null;
|
||||
channelsLastSuccess: number | null;
|
||||
whatsappLoginMessage: string | null;
|
||||
whatsappLoginQrDataUrl: string | null;
|
||||
whatsappLoginConnected: boolean | null;
|
||||
whatsappBusy: boolean;
|
||||
};
|
||||
259
openclaw/ui/src/ui/controllers/chat.test.ts
Normal file
259
openclaw/ui/src/ui/controllers/chat.test.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { handleChatEvent, type ChatEventPayload, type ChatState } from "./chat.ts";
|
||||
|
||||
function createState(overrides: Partial<ChatState> = {}): ChatState {
|
||||
return {
|
||||
chatAttachments: [],
|
||||
chatLoading: false,
|
||||
chatMessage: "",
|
||||
chatMessages: [],
|
||||
chatRunId: null,
|
||||
chatSending: false,
|
||||
chatStream: null,
|
||||
chatStreamStartedAt: null,
|
||||
chatThinkingLevel: null,
|
||||
client: null,
|
||||
connected: true,
|
||||
lastError: null,
|
||||
sessionKey: "main",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("handleChatEvent", () => {
|
||||
it("returns null when payload is missing", () => {
|
||||
const state = createState();
|
||||
expect(handleChatEvent(state, undefined)).toBe(null);
|
||||
});
|
||||
|
||||
it("returns null when sessionKey does not match", () => {
|
||||
const state = createState({ sessionKey: "main" });
|
||||
const payload: ChatEventPayload = {
|
||||
runId: "run-1",
|
||||
sessionKey: "other",
|
||||
state: "final",
|
||||
};
|
||||
expect(handleChatEvent(state, payload)).toBe(null);
|
||||
});
|
||||
|
||||
it("returns null for delta from another run", () => {
|
||||
const state = createState({
|
||||
sessionKey: "main",
|
||||
chatRunId: "run-user",
|
||||
chatStream: "Hello",
|
||||
});
|
||||
const payload: ChatEventPayload = {
|
||||
runId: "run-announce",
|
||||
sessionKey: "main",
|
||||
state: "delta",
|
||||
message: { role: "assistant", content: [{ type: "text", text: "Done" }] },
|
||||
};
|
||||
expect(handleChatEvent(state, payload)).toBe(null);
|
||||
expect(state.chatRunId).toBe("run-user");
|
||||
expect(state.chatStream).toBe("Hello");
|
||||
});
|
||||
|
||||
it("appends final payload from another run without clearing active stream", () => {
|
||||
const state = createState({
|
||||
sessionKey: "main",
|
||||
chatRunId: "run-user",
|
||||
chatStream: "Working...",
|
||||
chatStreamStartedAt: 123,
|
||||
});
|
||||
const payload: ChatEventPayload = {
|
||||
runId: "run-announce",
|
||||
sessionKey: "main",
|
||||
state: "final",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Sub-agent findings" }],
|
||||
},
|
||||
};
|
||||
expect(handleChatEvent(state, payload)).toBe(null);
|
||||
expect(state.chatRunId).toBe("run-user");
|
||||
expect(state.chatStream).toBe("Working...");
|
||||
expect(state.chatStreamStartedAt).toBe(123);
|
||||
expect(state.chatMessages).toHaveLength(1);
|
||||
expect(state.chatMessages[0]).toEqual(payload.message);
|
||||
});
|
||||
|
||||
it("returns final for another run when payload has no message", () => {
|
||||
const state = createState({
|
||||
sessionKey: "main",
|
||||
chatRunId: "run-user",
|
||||
chatStream: "Working...",
|
||||
chatStreamStartedAt: 123,
|
||||
});
|
||||
const payload: ChatEventPayload = {
|
||||
runId: "run-announce",
|
||||
sessionKey: "main",
|
||||
state: "final",
|
||||
};
|
||||
expect(handleChatEvent(state, payload)).toBe("final");
|
||||
expect(state.chatRunId).toBe("run-user");
|
||||
expect(state.chatMessages).toEqual([]);
|
||||
});
|
||||
|
||||
it("processes final from own run and clears state", () => {
|
||||
const state = createState({
|
||||
sessionKey: "main",
|
||||
chatRunId: "run-1",
|
||||
chatStream: "Reply",
|
||||
chatStreamStartedAt: 100,
|
||||
});
|
||||
const payload: ChatEventPayload = {
|
||||
runId: "run-1",
|
||||
sessionKey: "main",
|
||||
state: "final",
|
||||
};
|
||||
expect(handleChatEvent(state, payload)).toBe("final");
|
||||
expect(state.chatRunId).toBe(null);
|
||||
expect(state.chatStream).toBe(null);
|
||||
expect(state.chatStreamStartedAt).toBe(null);
|
||||
});
|
||||
|
||||
it("appends final payload message from own run before clearing stream state", () => {
|
||||
const state = createState({
|
||||
sessionKey: "main",
|
||||
chatRunId: "run-1",
|
||||
chatStream: "Reply",
|
||||
chatStreamStartedAt: 100,
|
||||
});
|
||||
const payload: ChatEventPayload = {
|
||||
runId: "run-1",
|
||||
sessionKey: "main",
|
||||
state: "final",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Reply" }],
|
||||
timestamp: 101,
|
||||
},
|
||||
};
|
||||
expect(handleChatEvent(state, payload)).toBe("final");
|
||||
expect(state.chatMessages).toEqual([payload.message]);
|
||||
expect(state.chatRunId).toBe(null);
|
||||
expect(state.chatStream).toBe(null);
|
||||
expect(state.chatStreamStartedAt).toBe(null);
|
||||
});
|
||||
|
||||
it("processes aborted from own run and keeps partial assistant message", () => {
|
||||
const existingMessage = {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Hi" }],
|
||||
timestamp: 1,
|
||||
};
|
||||
const partialMessage = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Partial reply" }],
|
||||
timestamp: 2,
|
||||
};
|
||||
const state = createState({
|
||||
sessionKey: "main",
|
||||
chatRunId: "run-1",
|
||||
chatStream: "Partial reply",
|
||||
chatStreamStartedAt: 100,
|
||||
chatMessages: [existingMessage],
|
||||
});
|
||||
const payload: ChatEventPayload = {
|
||||
runId: "run-1",
|
||||
sessionKey: "main",
|
||||
state: "aborted",
|
||||
message: partialMessage,
|
||||
};
|
||||
|
||||
expect(handleChatEvent(state, payload)).toBe("aborted");
|
||||
expect(state.chatRunId).toBe(null);
|
||||
expect(state.chatStream).toBe(null);
|
||||
expect(state.chatStreamStartedAt).toBe(null);
|
||||
expect(state.chatMessages).toEqual([existingMessage, partialMessage]);
|
||||
});
|
||||
|
||||
it("falls back to streamed partial when aborted payload message is invalid", () => {
|
||||
const existingMessage = {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Hi" }],
|
||||
timestamp: 1,
|
||||
};
|
||||
const state = createState({
|
||||
sessionKey: "main",
|
||||
chatRunId: "run-1",
|
||||
chatStream: "Partial reply",
|
||||
chatStreamStartedAt: 100,
|
||||
chatMessages: [existingMessage],
|
||||
});
|
||||
const payload = {
|
||||
runId: "run-1",
|
||||
sessionKey: "main",
|
||||
state: "aborted",
|
||||
message: "not-an-assistant-message",
|
||||
} as unknown as ChatEventPayload;
|
||||
|
||||
expect(handleChatEvent(state, payload)).toBe("aborted");
|
||||
expect(state.chatRunId).toBe(null);
|
||||
expect(state.chatStream).toBe(null);
|
||||
expect(state.chatStreamStartedAt).toBe(null);
|
||||
expect(state.chatMessages).toHaveLength(2);
|
||||
expect(state.chatMessages[0]).toEqual(existingMessage);
|
||||
expect(state.chatMessages[1]).toMatchObject({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Partial reply" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to streamed partial when aborted payload has non-assistant role", () => {
|
||||
const existingMessage = {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Hi" }],
|
||||
timestamp: 1,
|
||||
};
|
||||
const state = createState({
|
||||
sessionKey: "main",
|
||||
chatRunId: "run-1",
|
||||
chatStream: "Partial reply",
|
||||
chatStreamStartedAt: 100,
|
||||
chatMessages: [existingMessage],
|
||||
});
|
||||
const payload: ChatEventPayload = {
|
||||
runId: "run-1",
|
||||
sessionKey: "main",
|
||||
state: "aborted",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "unexpected" }],
|
||||
},
|
||||
};
|
||||
|
||||
expect(handleChatEvent(state, payload)).toBe("aborted");
|
||||
expect(state.chatMessages).toHaveLength(2);
|
||||
expect(state.chatMessages[1]).toMatchObject({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Partial reply" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("processes aborted from own run without message and empty stream", () => {
|
||||
const existingMessage = {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Hi" }],
|
||||
timestamp: 1,
|
||||
};
|
||||
const state = createState({
|
||||
sessionKey: "main",
|
||||
chatRunId: "run-1",
|
||||
chatStream: "",
|
||||
chatStreamStartedAt: 100,
|
||||
chatMessages: [existingMessage],
|
||||
});
|
||||
const payload: ChatEventPayload = {
|
||||
runId: "run-1",
|
||||
sessionKey: "main",
|
||||
state: "aborted",
|
||||
};
|
||||
|
||||
expect(handleChatEvent(state, payload)).toBe("aborted");
|
||||
expect(state.chatRunId).toBe(null);
|
||||
expect(state.chatStream).toBe(null);
|
||||
expect(state.chatStreamStartedAt).toBe(null);
|
||||
expect(state.chatMessages).toEqual([existingMessage]);
|
||||
});
|
||||
});
|
||||
285
openclaw/ui/src/ui/controllers/chat.ts
Normal file
285
openclaw/ui/src/ui/controllers/chat.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
import { extractText } from "../chat/message-extract.ts";
|
||||
import type { GatewayBrowserClient } from "../gateway.ts";
|
||||
import type { ChatAttachment } from "../ui-types.ts";
|
||||
import { generateUUID } from "../uuid.ts";
|
||||
|
||||
export type ChatState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
connected: boolean;
|
||||
sessionKey: string;
|
||||
chatLoading: boolean;
|
||||
chatMessages: unknown[];
|
||||
chatThinkingLevel: string | null;
|
||||
chatSending: boolean;
|
||||
chatMessage: string;
|
||||
chatAttachments: ChatAttachment[];
|
||||
chatRunId: string | null;
|
||||
chatStream: string | null;
|
||||
chatStreamStartedAt: number | null;
|
||||
lastError: string | null;
|
||||
};
|
||||
|
||||
export 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<{ messages?: Array<unknown>; thinkingLevel?: string }>(
|
||||
"chat.history",
|
||||
{
|
||||
sessionKey: state.sessionKey,
|
||||
limit: 200,
|
||||
},
|
||||
);
|
||||
state.chatMessages = Array.isArray(res.messages) ? res.messages : [];
|
||||
state.chatThinkingLevel = res.thinkingLevel ?? null;
|
||||
} catch (err) {
|
||||
state.lastError = String(err);
|
||||
} finally {
|
||||
state.chatLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function dataUrlToBase64(dataUrl: string): { content: string; mimeType: string } | null {
|
||||
const match = /^data:([^;]+);base64,(.+)$/.exec(dataUrl);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
return { mimeType: match[1], content: match[2] };
|
||||
}
|
||||
|
||||
type AssistantMessageNormalizationOptions = {
|
||||
roleRequirement: "required" | "optional";
|
||||
roleCaseSensitive?: boolean;
|
||||
requireContentArray?: boolean;
|
||||
allowTextField?: boolean;
|
||||
};
|
||||
|
||||
function normalizeAssistantMessage(
|
||||
message: unknown,
|
||||
options: AssistantMessageNormalizationOptions,
|
||||
): Record<string, unknown> | null {
|
||||
if (!message || typeof message !== "object") {
|
||||
return null;
|
||||
}
|
||||
const candidate = message as Record<string, unknown>;
|
||||
const roleValue = candidate.role;
|
||||
if (typeof roleValue === "string") {
|
||||
const role = options.roleCaseSensitive ? roleValue : roleValue.toLowerCase();
|
||||
if (role !== "assistant") {
|
||||
return null;
|
||||
}
|
||||
} else if (options.roleRequirement === "required") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (options.requireContentArray) {
|
||||
return Array.isArray(candidate.content) ? candidate : null;
|
||||
}
|
||||
if (!("content" in candidate) && !(options.allowTextField && "text" in candidate)) {
|
||||
return null;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function normalizeAbortedAssistantMessage(message: unknown): Record<string, unknown> | null {
|
||||
return normalizeAssistantMessage(message, {
|
||||
roleRequirement: "required",
|
||||
roleCaseSensitive: true,
|
||||
requireContentArray: true,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeFinalAssistantMessage(message: unknown): Record<string, unknown> | null {
|
||||
return normalizeAssistantMessage(message, {
|
||||
roleRequirement: "optional",
|
||||
allowTextField: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendChatMessage(
|
||||
state: ChatState,
|
||||
message: string,
|
||||
attachments?: ChatAttachment[],
|
||||
): Promise<string | null> {
|
||||
if (!state.client || !state.connected) {
|
||||
return null;
|
||||
}
|
||||
const msg = message.trim();
|
||||
const hasAttachments = attachments && attachments.length > 0;
|
||||
if (!msg && !hasAttachments) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// Build user message content blocks
|
||||
const contentBlocks: Array<{ type: string; text?: string; source?: unknown }> = [];
|
||||
if (msg) {
|
||||
contentBlocks.push({ type: "text", text: msg });
|
||||
}
|
||||
// Add image previews to the message for display
|
||||
if (hasAttachments) {
|
||||
for (const att of attachments) {
|
||||
contentBlocks.push({
|
||||
type: "image",
|
||||
source: { type: "base64", media_type: att.mimeType, data: att.dataUrl },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
state.chatMessages = [
|
||||
...state.chatMessages,
|
||||
{
|
||||
role: "user",
|
||||
content: contentBlocks,
|
||||
timestamp: now,
|
||||
},
|
||||
];
|
||||
|
||||
state.chatSending = true;
|
||||
state.lastError = null;
|
||||
const runId = generateUUID();
|
||||
state.chatRunId = runId;
|
||||
state.chatStream = "";
|
||||
state.chatStreamStartedAt = now;
|
||||
|
||||
// Convert attachments to API format
|
||||
const apiAttachments = hasAttachments
|
||||
? attachments
|
||||
.map((att) => {
|
||||
const parsed = dataUrlToBase64(att.dataUrl);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: "image",
|
||||
mimeType: parsed.mimeType,
|
||||
content: parsed.content,
|
||||
};
|
||||
})
|
||||
.filter((a): a is NonNullable<typeof a> => a !== null)
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
await state.client.request("chat.send", {
|
||||
sessionKey: state.sessionKey,
|
||||
message: msg,
|
||||
deliver: false,
|
||||
idempotencyKey: runId,
|
||||
attachments: apiAttachments,
|
||||
});
|
||||
return runId;
|
||||
} catch (err) {
|
||||
const error = String(err);
|
||||
state.chatRunId = null;
|
||||
state.chatStream = null;
|
||||
state.chatStreamStartedAt = null;
|
||||
state.lastError = error;
|
||||
state.chatMessages = [
|
||||
...state.chatMessages,
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Error: " + error }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
return null;
|
||||
} finally {
|
||||
state.chatSending = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function abortChatRun(state: ChatState): Promise<boolean> {
|
||||
if (!state.client || !state.connected) {
|
||||
return false;
|
||||
}
|
||||
const runId = state.chatRunId;
|
||||
try {
|
||||
await state.client.request(
|
||||
"chat.abort",
|
||||
runId ? { sessionKey: state.sessionKey, runId } : { sessionKey: state.sessionKey },
|
||||
);
|
||||
return true;
|
||||
} catch (err) {
|
||||
state.lastError = String(err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function handleChatEvent(state: ChatState, payload?: ChatEventPayload) {
|
||||
if (!payload) {
|
||||
return null;
|
||||
}
|
||||
if (payload.sessionKey !== state.sessionKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Final from another run (e.g. sub-agent announce): refresh history to show new message.
|
||||
// See https://github.com/openclaw/openclaw/issues/1909
|
||||
if (payload.runId && state.chatRunId && payload.runId !== state.chatRunId) {
|
||||
if (payload.state === "final") {
|
||||
const finalMessage = normalizeFinalAssistantMessage(payload.message);
|
||||
if (finalMessage) {
|
||||
state.chatMessages = [...state.chatMessages, finalMessage];
|
||||
return null;
|
||||
}
|
||||
return "final";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (payload.state === "delta") {
|
||||
const next = extractText(payload.message);
|
||||
if (typeof next === "string") {
|
||||
const current = state.chatStream ?? "";
|
||||
if (!current || next.length >= current.length) {
|
||||
state.chatStream = next;
|
||||
}
|
||||
}
|
||||
} else if (payload.state === "final") {
|
||||
const finalMessage = normalizeFinalAssistantMessage(payload.message);
|
||||
if (finalMessage) {
|
||||
state.chatMessages = [...state.chatMessages, finalMessage];
|
||||
}
|
||||
state.chatStream = null;
|
||||
state.chatRunId = null;
|
||||
state.chatStreamStartedAt = null;
|
||||
} else if (payload.state === "aborted") {
|
||||
const normalizedMessage = normalizeAbortedAssistantMessage(payload.message);
|
||||
if (normalizedMessage) {
|
||||
state.chatMessages = [...state.chatMessages, normalizedMessage];
|
||||
} else {
|
||||
const streamedText = state.chatStream ?? "";
|
||||
if (streamedText.trim()) {
|
||||
state.chatMessages = [
|
||||
...state.chatMessages,
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: streamedText }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
state.chatStream = null;
|
||||
state.chatRunId = null;
|
||||
state.chatStreamStartedAt = null;
|
||||
} else if (payload.state === "error") {
|
||||
state.chatStream = null;
|
||||
state.chatRunId = null;
|
||||
state.chatStreamStartedAt = null;
|
||||
state.lastError = payload.errorMessage ?? "chat error";
|
||||
}
|
||||
return payload.state;
|
||||
}
|
||||
295
openclaw/ui/src/ui/controllers/config.test.ts
Normal file
295
openclaw/ui/src/ui/controllers/config.test.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
applyConfigSnapshot,
|
||||
applyConfig,
|
||||
runUpdate,
|
||||
saveConfig,
|
||||
updateConfigFormValue,
|
||||
type ConfigState,
|
||||
} from "./config.ts";
|
||||
|
||||
function createState(): ConfigState {
|
||||
return {
|
||||
applySessionKey: "main",
|
||||
client: null,
|
||||
configActiveSection: null,
|
||||
configActiveSubsection: null,
|
||||
configApplying: false,
|
||||
configForm: null,
|
||||
configFormDirty: false,
|
||||
configFormMode: "form",
|
||||
configFormOriginal: null,
|
||||
configIssues: [],
|
||||
configLoading: false,
|
||||
configRaw: "",
|
||||
configRawOriginal: "",
|
||||
configSaving: false,
|
||||
configSchema: null,
|
||||
configSchemaLoading: false,
|
||||
configSchemaVersion: null,
|
||||
configSearchQuery: "",
|
||||
configSnapshot: null,
|
||||
configUiHints: {},
|
||||
configValid: null,
|
||||
connected: false,
|
||||
lastError: null,
|
||||
updateRunning: false,
|
||||
};
|
||||
}
|
||||
|
||||
describe("applyConfigSnapshot", () => {
|
||||
it("does not clobber form edits while dirty", () => {
|
||||
const state = createState();
|
||||
state.configFormMode = "form";
|
||||
state.configFormDirty = true;
|
||||
state.configForm = { gateway: { mode: "local", port: 18789 } };
|
||||
state.configRaw = "{\n}\n";
|
||||
|
||||
applyConfigSnapshot(state, {
|
||||
config: { gateway: { mode: "remote", port: 9999 } },
|
||||
valid: true,
|
||||
issues: [],
|
||||
raw: '{\n "gateway": { "mode": "remote", "port": 9999 }\n}\n',
|
||||
});
|
||||
|
||||
expect(state.configRaw).toBe(
|
||||
'{\n "gateway": {\n "mode": "local",\n "port": 18789\n }\n}\n',
|
||||
);
|
||||
});
|
||||
|
||||
it("updates config form when clean", () => {
|
||||
const state = createState();
|
||||
applyConfigSnapshot(state, {
|
||||
config: { gateway: { mode: "local" } },
|
||||
valid: true,
|
||||
issues: [],
|
||||
raw: "{}",
|
||||
});
|
||||
|
||||
expect(state.configForm).toEqual({ gateway: { mode: "local" } });
|
||||
});
|
||||
|
||||
it("sets configRawOriginal when clean for change detection", () => {
|
||||
const state = createState();
|
||||
applyConfigSnapshot(state, {
|
||||
config: { gateway: { mode: "local" } },
|
||||
valid: true,
|
||||
issues: [],
|
||||
raw: '{ "gateway": { "mode": "local" } }',
|
||||
});
|
||||
|
||||
expect(state.configRawOriginal).toBe('{ "gateway": { "mode": "local" } }');
|
||||
expect(state.configFormOriginal).toEqual({ gateway: { mode: "local" } });
|
||||
});
|
||||
|
||||
it("preserves configRawOriginal when dirty", () => {
|
||||
const state = createState();
|
||||
state.configFormDirty = true;
|
||||
state.configRawOriginal = '{ "original": true }';
|
||||
state.configFormOriginal = { original: true };
|
||||
|
||||
applyConfigSnapshot(state, {
|
||||
config: { gateway: { mode: "local" } },
|
||||
valid: true,
|
||||
issues: [],
|
||||
raw: '{ "gateway": { "mode": "local" } }',
|
||||
});
|
||||
|
||||
// Original values should be preserved when dirty
|
||||
expect(state.configRawOriginal).toBe('{ "original": true }');
|
||||
expect(state.configFormOriginal).toEqual({ original: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateConfigFormValue", () => {
|
||||
it("seeds from snapshot when form is null", () => {
|
||||
const state = createState();
|
||||
state.configSnapshot = {
|
||||
config: { channels: { telegram: { botToken: "t" } }, gateway: { mode: "local" } },
|
||||
valid: true,
|
||||
issues: [],
|
||||
raw: "{}",
|
||||
};
|
||||
|
||||
updateConfigFormValue(state, ["gateway", "port"], 18789);
|
||||
|
||||
expect(state.configFormDirty).toBe(true);
|
||||
expect(state.configForm).toEqual({
|
||||
channels: { telegram: { botToken: "t" } },
|
||||
gateway: { mode: "local", port: 18789 },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps raw in sync while editing the form", () => {
|
||||
const state = createState();
|
||||
state.configSnapshot = {
|
||||
config: { gateway: { mode: "local" } },
|
||||
valid: true,
|
||||
issues: [],
|
||||
raw: "{\n}\n",
|
||||
};
|
||||
|
||||
updateConfigFormValue(state, ["gateway", "port"], 18789);
|
||||
|
||||
expect(state.configRaw).toBe(
|
||||
'{\n "gateway": {\n "mode": "local",\n "port": 18789\n }\n}\n',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyConfig", () => {
|
||||
it("sends config.apply with raw and session key", async () => {
|
||||
const request = vi.fn().mockResolvedValue({});
|
||||
const state = createState();
|
||||
state.connected = true;
|
||||
state.client = { request } as unknown as ConfigState["client"];
|
||||
state.applySessionKey = "agent:main:whatsapp:dm:+15555550123";
|
||||
state.configFormMode = "raw";
|
||||
state.configRaw = '{\n agent: { workspace: "~/openclaw" }\n}\n';
|
||||
state.configSnapshot = {
|
||||
hash: "hash-123",
|
||||
};
|
||||
|
||||
await applyConfig(state);
|
||||
|
||||
expect(request).toHaveBeenCalledWith("config.apply", {
|
||||
raw: '{\n agent: { workspace: "~/openclaw" }\n}\n',
|
||||
baseHash: "hash-123",
|
||||
sessionKey: "agent:main:whatsapp:dm:+15555550123",
|
||||
});
|
||||
});
|
||||
|
||||
it("coerces schema-typed values before config.apply in form mode", async () => {
|
||||
const request = vi.fn().mockImplementation(async (method: string) => {
|
||||
if (method === "config.get") {
|
||||
return { config: {}, valid: true, issues: [], raw: "{\n}\n" };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
const state = createState();
|
||||
state.connected = true;
|
||||
state.client = { request } as unknown as ConfigState["client"];
|
||||
state.applySessionKey = "agent:main:web:dm:test";
|
||||
state.configFormMode = "form";
|
||||
state.configForm = {
|
||||
gateway: { port: "18789", debug: "true" },
|
||||
};
|
||||
state.configSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
gateway: {
|
||||
type: "object",
|
||||
properties: {
|
||||
port: { type: "number" },
|
||||
debug: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
state.configSnapshot = { hash: "hash-apply-1" };
|
||||
|
||||
await applyConfig(state);
|
||||
|
||||
expect(request.mock.calls[0]?.[0]).toBe("config.apply");
|
||||
const params = request.mock.calls[0]?.[1] as {
|
||||
raw: string;
|
||||
baseHash: string;
|
||||
sessionKey: string;
|
||||
};
|
||||
const parsed = JSON.parse(params.raw) as {
|
||||
gateway: { port: unknown; debug: unknown };
|
||||
};
|
||||
expect(typeof parsed.gateway.port).toBe("number");
|
||||
expect(parsed.gateway.port).toBe(18789);
|
||||
expect(parsed.gateway.debug).toBe(true);
|
||||
expect(params.baseHash).toBe("hash-apply-1");
|
||||
expect(params.sessionKey).toBe("agent:main:web:dm:test");
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveConfig", () => {
|
||||
it("coerces schema-typed values before config.set in form mode", async () => {
|
||||
const request = vi.fn().mockImplementation(async (method: string) => {
|
||||
if (method === "config.get") {
|
||||
return { config: {}, valid: true, issues: [], raw: "{\n}\n" };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
const state = createState();
|
||||
state.connected = true;
|
||||
state.client = { request } as unknown as ConfigState["client"];
|
||||
state.configFormMode = "form";
|
||||
state.configForm = {
|
||||
gateway: { port: "18789", enabled: "false" },
|
||||
};
|
||||
state.configSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
gateway: {
|
||||
type: "object",
|
||||
properties: {
|
||||
port: { type: "number" },
|
||||
enabled: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
state.configSnapshot = { hash: "hash-save-1" };
|
||||
|
||||
await saveConfig(state);
|
||||
|
||||
expect(request.mock.calls[0]?.[0]).toBe("config.set");
|
||||
const params = request.mock.calls[0]?.[1] as { raw: string; baseHash: string };
|
||||
const parsed = JSON.parse(params.raw) as {
|
||||
gateway: { port: unknown; enabled: unknown };
|
||||
};
|
||||
expect(typeof parsed.gateway.port).toBe("number");
|
||||
expect(parsed.gateway.port).toBe(18789);
|
||||
expect(parsed.gateway.enabled).toBe(false);
|
||||
expect(params.baseHash).toBe("hash-save-1");
|
||||
});
|
||||
|
||||
it("skips coercion when schema is not an object", async () => {
|
||||
const request = vi.fn().mockImplementation(async (method: string) => {
|
||||
if (method === "config.get") {
|
||||
return { config: {}, valid: true, issues: [], raw: "{\n}\n" };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
const state = createState();
|
||||
state.connected = true;
|
||||
state.client = { request } as unknown as ConfigState["client"];
|
||||
state.configFormMode = "form";
|
||||
state.configForm = {
|
||||
gateway: { port: "18789" },
|
||||
};
|
||||
state.configSchema = "invalid-schema";
|
||||
state.configSnapshot = { hash: "hash-save-2" };
|
||||
|
||||
await saveConfig(state);
|
||||
|
||||
expect(request.mock.calls[0]?.[0]).toBe("config.set");
|
||||
const params = request.mock.calls[0]?.[1] as { raw: string; baseHash: string };
|
||||
const parsed = JSON.parse(params.raw) as {
|
||||
gateway: { port: unknown };
|
||||
};
|
||||
expect(parsed.gateway.port).toBe("18789");
|
||||
expect(params.baseHash).toBe("hash-save-2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runUpdate", () => {
|
||||
it("sends update.run with session key", async () => {
|
||||
const request = vi.fn().mockResolvedValue({});
|
||||
const state = createState();
|
||||
state.connected = true;
|
||||
state.client = { request } as unknown as ConfigState["client"];
|
||||
state.applySessionKey = "agent:main:whatsapp:dm:+15555550123";
|
||||
|
||||
await runUpdate(state);
|
||||
|
||||
expect(request).toHaveBeenCalledWith("update.run", {
|
||||
sessionKey: "agent:main:whatsapp:dm:+15555550123",
|
||||
});
|
||||
});
|
||||
});
|
||||
219
openclaw/ui/src/ui/controllers/config.ts
Normal file
219
openclaw/ui/src/ui/controllers/config.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import type { GatewayBrowserClient } from "../gateway.ts";
|
||||
import type { ConfigSchemaResponse, ConfigSnapshot, ConfigUiHints } from "../types.ts";
|
||||
import type { JsonSchema } from "../views/config-form.shared.ts";
|
||||
import { coerceFormValues } from "./config/form-coerce.ts";
|
||||
import {
|
||||
cloneConfigObject,
|
||||
removePathValue,
|
||||
serializeConfigForm,
|
||||
setPathValue,
|
||||
} from "./config/form-utils.ts";
|
||||
|
||||
export type ConfigState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
connected: boolean;
|
||||
applySessionKey: string;
|
||||
configLoading: boolean;
|
||||
configRaw: string;
|
||||
configRawOriginal: string;
|
||||
configValid: boolean | null;
|
||||
configIssues: unknown[];
|
||||
configSaving: boolean;
|
||||
configApplying: boolean;
|
||||
updateRunning: boolean;
|
||||
configSnapshot: ConfigSnapshot | null;
|
||||
configSchema: unknown;
|
||||
configSchemaVersion: string | null;
|
||||
configSchemaLoading: boolean;
|
||||
configUiHints: ConfigUiHints;
|
||||
configForm: Record<string, unknown> | null;
|
||||
configFormOriginal: Record<string, unknown> | null;
|
||||
configFormDirty: boolean;
|
||||
configFormMode: "form" | "raw";
|
||||
configSearchQuery: string;
|
||||
configActiveSection: string | null;
|
||||
configActiveSubsection: string | null;
|
||||
lastError: 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<ConfigSnapshot>("config.get", {});
|
||||
applyConfigSnapshot(state, res);
|
||||
} catch (err) {
|
||||
state.lastError = String(err);
|
||||
} finally {
|
||||
state.configLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadConfigSchema(state: ConfigState) {
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
if (state.configSchemaLoading) {
|
||||
return;
|
||||
}
|
||||
state.configSchemaLoading = true;
|
||||
try {
|
||||
const res = await state.client.request<ConfigSchemaResponse>("config.schema", {});
|
||||
applyConfigSchema(state, res);
|
||||
} catch (err) {
|
||||
state.lastError = String(err);
|
||||
} finally {
|
||||
state.configSchemaLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function applyConfigSchema(state: ConfigState, res: ConfigSchemaResponse) {
|
||||
state.configSchema = res.schema ?? null;
|
||||
state.configUiHints = res.uiHints ?? {};
|
||||
state.configSchemaVersion = res.version ?? null;
|
||||
}
|
||||
|
||||
export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot) {
|
||||
state.configSnapshot = snapshot;
|
||||
const rawFromSnapshot =
|
||||
typeof snapshot.raw === "string"
|
||||
? snapshot.raw
|
||||
: snapshot.config && typeof snapshot.config === "object"
|
||||
? serializeConfigForm(snapshot.config)
|
||||
: state.configRaw;
|
||||
if (!state.configFormDirty || state.configFormMode === "raw") {
|
||||
state.configRaw = rawFromSnapshot;
|
||||
} else if (state.configForm) {
|
||||
state.configRaw = serializeConfigForm(state.configForm);
|
||||
} else {
|
||||
state.configRaw = rawFromSnapshot;
|
||||
}
|
||||
state.configValid = typeof snapshot.valid === "boolean" ? snapshot.valid : null;
|
||||
state.configIssues = Array.isArray(snapshot.issues) ? snapshot.issues : [];
|
||||
|
||||
if (!state.configFormDirty) {
|
||||
state.configForm = cloneConfigObject(snapshot.config ?? {});
|
||||
state.configFormOriginal = cloneConfigObject(snapshot.config ?? {});
|
||||
state.configRawOriginal = rawFromSnapshot;
|
||||
}
|
||||
}
|
||||
|
||||
function asJsonSchema(value: unknown): JsonSchema | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
return value as JsonSchema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize the form state for submission to `config.set` / `config.apply`.
|
||||
*
|
||||
* HTML `<input>` elements produce string `.value` properties, so numeric and
|
||||
* boolean config fields can leak into `configForm` as strings. We coerce
|
||||
* them back to their schema-defined types before JSON serialization so the
|
||||
* gateway's Zod validation always sees correctly typed values.
|
||||
*/
|
||||
function serializeFormForSubmit(state: ConfigState): string {
|
||||
if (state.configFormMode !== "form" || !state.configForm) {
|
||||
return state.configRaw;
|
||||
}
|
||||
const schema = asJsonSchema(state.configSchema);
|
||||
const form = schema
|
||||
? (coerceFormValues(state.configForm, schema) as Record<string, unknown>)
|
||||
: state.configForm;
|
||||
return serializeConfigForm(form);
|
||||
}
|
||||
|
||||
export async function saveConfig(state: ConfigState) {
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
state.configSaving = true;
|
||||
state.lastError = null;
|
||||
try {
|
||||
const raw = serializeFormForSubmit(state);
|
||||
const baseHash = state.configSnapshot?.hash;
|
||||
if (!baseHash) {
|
||||
state.lastError = "Config hash missing; reload and retry.";
|
||||
return;
|
||||
}
|
||||
await state.client.request("config.set", { raw, baseHash });
|
||||
state.configFormDirty = false;
|
||||
await loadConfig(state);
|
||||
} catch (err) {
|
||||
state.lastError = String(err);
|
||||
} finally {
|
||||
state.configSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyConfig(state: ConfigState) {
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
state.configApplying = true;
|
||||
state.lastError = null;
|
||||
try {
|
||||
const raw = serializeFormForSubmit(state);
|
||||
const baseHash = state.configSnapshot?.hash;
|
||||
if (!baseHash) {
|
||||
state.lastError = "Config hash missing; reload and retry.";
|
||||
return;
|
||||
}
|
||||
await state.client.request("config.apply", {
|
||||
raw,
|
||||
baseHash,
|
||||
sessionKey: state.applySessionKey,
|
||||
});
|
||||
state.configFormDirty = false;
|
||||
await loadConfig(state);
|
||||
} catch (err) {
|
||||
state.lastError = String(err);
|
||||
} finally {
|
||||
state.configApplying = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runUpdate(state: ConfigState) {
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
state.updateRunning = true;
|
||||
state.lastError = null;
|
||||
try {
|
||||
await state.client.request("update.run", {
|
||||
sessionKey: state.applySessionKey,
|
||||
});
|
||||
} catch (err) {
|
||||
state.lastError = String(err);
|
||||
} finally {
|
||||
state.updateRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function updateConfigFormValue(
|
||||
state: ConfigState,
|
||||
path: Array<string | number>,
|
||||
value: unknown,
|
||||
) {
|
||||
const base = cloneConfigObject(state.configForm ?? state.configSnapshot?.config ?? {});
|
||||
setPathValue(base, path, value);
|
||||
state.configForm = base;
|
||||
state.configFormDirty = true;
|
||||
if (state.configFormMode === "form") {
|
||||
state.configRaw = serializeConfigForm(base);
|
||||
}
|
||||
}
|
||||
|
||||
export function removeConfigFormValue(state: ConfigState, path: Array<string | number>) {
|
||||
const base = cloneConfigObject(state.configForm ?? state.configSnapshot?.config ?? {});
|
||||
removePathValue(base, path);
|
||||
state.configForm = base;
|
||||
state.configFormDirty = true;
|
||||
if (state.configFormMode === "form") {
|
||||
state.configRaw = serializeConfigForm(base);
|
||||
}
|
||||
}
|
||||
160
openclaw/ui/src/ui/controllers/config/form-coerce.ts
Normal file
160
openclaw/ui/src/ui/controllers/config/form-coerce.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { schemaType, type JsonSchema } from "../../views/config-form.shared.ts";
|
||||
|
||||
function coerceNumberString(value: string, integer: boolean): number | undefined | string {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === "") {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = Number(trimmed);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return value;
|
||||
}
|
||||
if (integer && !Number.isInteger(parsed)) {
|
||||
return value;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function coerceBooleanString(value: string): boolean | string {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === "true") {
|
||||
return true;
|
||||
}
|
||||
if (trimmed === "false") {
|
||||
return false;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a form value tree alongside its JSON Schema and coerce string values
|
||||
* to their schema-defined types (number, boolean).
|
||||
*
|
||||
* HTML `<input>` elements always produce string `.value` properties. Even
|
||||
* though the form rendering code converts values correctly for most paths,
|
||||
* some interactions (map-field repopulation, re-renders, paste, etc.) can
|
||||
* leak raw strings into the config form state. This utility acts as a
|
||||
* safety net before serialization so that `config.set` always receives
|
||||
* correctly typed JSON.
|
||||
*/
|
||||
export function coerceFormValues(value: unknown, schema: JsonSchema): unknown {
|
||||
if (value === null || value === undefined) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (schema.allOf && schema.allOf.length > 0) {
|
||||
let next: unknown = value;
|
||||
for (const segment of schema.allOf) {
|
||||
next = coerceFormValues(next, segment);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
const type = schemaType(schema);
|
||||
|
||||
// Handle anyOf/oneOf — try to match the value against a variant
|
||||
if (schema.anyOf || schema.oneOf) {
|
||||
const variants = (schema.anyOf ?? schema.oneOf ?? []).filter(
|
||||
(v) => !(v.type === "null" || (Array.isArray(v.type) && v.type.includes("null"))),
|
||||
);
|
||||
|
||||
if (variants.length === 1) {
|
||||
return coerceFormValues(value, variants[0]);
|
||||
}
|
||||
|
||||
// Try number/boolean coercion for string values
|
||||
if (typeof value === "string") {
|
||||
for (const variant of variants) {
|
||||
const variantType = schemaType(variant);
|
||||
if (variantType === "number" || variantType === "integer") {
|
||||
const coerced = coerceNumberString(value, variantType === "integer");
|
||||
if (coerced === undefined || typeof coerced === "number") {
|
||||
return coerced;
|
||||
}
|
||||
}
|
||||
if (variantType === "boolean") {
|
||||
const coerced = coerceBooleanString(value);
|
||||
if (typeof coerced === "boolean") {
|
||||
return coerced;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For non-string values (objects, arrays), try to recurse into matching variant
|
||||
for (const variant of variants) {
|
||||
const variantType = schemaType(variant);
|
||||
if (variantType === "object" && typeof value === "object" && !Array.isArray(value)) {
|
||||
return coerceFormValues(value, variant);
|
||||
}
|
||||
if (variantType === "array" && Array.isArray(value)) {
|
||||
return coerceFormValues(value, variant);
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
if (type === "number" || type === "integer") {
|
||||
if (typeof value === "string") {
|
||||
const coerced = coerceNumberString(value, type === "integer");
|
||||
if (coerced === undefined || typeof coerced === "number") {
|
||||
return coerced;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
if (type === "boolean") {
|
||||
if (typeof value === "string") {
|
||||
const coerced = coerceBooleanString(value);
|
||||
if (typeof coerced === "boolean") {
|
||||
return coerced;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
if (type === "object") {
|
||||
if (typeof value !== "object" || Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
const obj = value as Record<string, unknown>;
|
||||
const props = schema.properties ?? {};
|
||||
const additional =
|
||||
schema.additionalProperties && typeof schema.additionalProperties === "object"
|
||||
? schema.additionalProperties
|
||||
: null;
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, val] of Object.entries(obj)) {
|
||||
const propSchema = props[key] ?? additional;
|
||||
const coerced = propSchema ? coerceFormValues(val, propSchema) : val;
|
||||
// Omit undefined — "clear field = unset" for optional properties
|
||||
if (coerced !== undefined) {
|
||||
result[key] = coerced;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
if (type === "array") {
|
||||
if (!Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(schema.items)) {
|
||||
// Tuple form: each index has its own schema
|
||||
const tuple = schema.items;
|
||||
return value.map((item, i) => {
|
||||
const s = i < tuple.length ? tuple[i] : undefined;
|
||||
return s ? coerceFormValues(item, s) : item;
|
||||
});
|
||||
}
|
||||
const itemsSchema = schema.items;
|
||||
if (!itemsSchema) {
|
||||
return value;
|
||||
}
|
||||
return value.map((item) => coerceFormValues(item, itemsSchema)).filter((v) => v !== undefined);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
471
openclaw/ui/src/ui/controllers/config/form-utils.node.test.ts
Normal file
471
openclaw/ui/src/ui/controllers/config/form-utils.node.test.ts
Normal file
@@ -0,0 +1,471 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { JsonSchema } from "../../views/config-form.shared.ts";
|
||||
import { coerceFormValues } from "./form-coerce.ts";
|
||||
import { cloneConfigObject, serializeConfigForm, setPathValue } from "./form-utils.ts";
|
||||
|
||||
/**
|
||||
* Minimal model provider schema matching the Zod-generated JSON Schema for
|
||||
* `models.providers` (see zod-schema.core.ts → ModelDefinitionSchema).
|
||||
*/
|
||||
const modelDefinitionSchema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
name: { type: "string" },
|
||||
reasoning: { type: "boolean" },
|
||||
contextWindow: { type: "number" },
|
||||
maxTokens: { type: "number" },
|
||||
cost: {
|
||||
type: "object",
|
||||
properties: {
|
||||
input: { type: "number" },
|
||||
output: { type: "number" },
|
||||
cacheRead: { type: "number" },
|
||||
cacheWrite: { type: "number" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const modelProviderSchema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
baseUrl: { type: "string" },
|
||||
apiKey: { type: "string" },
|
||||
models: {
|
||||
type: "array",
|
||||
items: modelDefinitionSchema,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const modelsConfigSchema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
providers: {
|
||||
type: "object",
|
||||
additionalProperties: modelProviderSchema,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const topLevelSchema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
gateway: {
|
||||
type: "object",
|
||||
properties: {
|
||||
auth: {
|
||||
type: "object",
|
||||
properties: {
|
||||
token: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
models: modelsConfigSchema,
|
||||
},
|
||||
};
|
||||
|
||||
function makeConfigWithProvider(): Record<string, unknown> {
|
||||
return {
|
||||
gateway: { auth: { token: "test-token" } },
|
||||
models: {
|
||||
providers: {
|
||||
xai: {
|
||||
baseUrl: "https://api.x.ai/v1",
|
||||
models: [
|
||||
{
|
||||
id: "grok-4",
|
||||
name: "Grok 4",
|
||||
contextWindow: 131072,
|
||||
maxTokens: 8192,
|
||||
cost: { input: 0.5, output: 1.0, cacheRead: 0.1, cacheWrite: 0.2 },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("form-utils preserves numeric types", () => {
|
||||
it("serializeConfigForm preserves numbers in JSON output", () => {
|
||||
const form = makeConfigWithProvider();
|
||||
const raw = serializeConfigForm(form);
|
||||
const parsed = JSON.parse(raw);
|
||||
const model = parsed.models.providers.xai.models[0];
|
||||
|
||||
expect(typeof model.maxTokens).toBe("number");
|
||||
expect(model.maxTokens).toBe(8192);
|
||||
expect(typeof model.contextWindow).toBe("number");
|
||||
expect(model.contextWindow).toBe(131072);
|
||||
expect(typeof model.cost.input).toBe("number");
|
||||
expect(model.cost.input).toBe(0.5);
|
||||
});
|
||||
|
||||
it("cloneConfigObject + setPathValue preserves unrelated numeric fields", () => {
|
||||
const form = makeConfigWithProvider();
|
||||
const cloned = cloneConfigObject(form);
|
||||
setPathValue(cloned, ["gateway", "auth", "token"], "new-token");
|
||||
|
||||
const model = cloned.models as Record<string, unknown>;
|
||||
const providers = model.providers as Record<string, unknown>;
|
||||
const xai = providers.xai as Record<string, unknown>;
|
||||
const models = xai.models as Array<Record<string, unknown>>;
|
||||
const first = models[0];
|
||||
|
||||
expect(typeof first.maxTokens).toBe("number");
|
||||
expect(first.maxTokens).toBe(8192);
|
||||
expect(typeof first.contextWindow).toBe("number");
|
||||
expect(typeof first.cost).toBe("object");
|
||||
expect(typeof (first.cost as Record<string, unknown>).input).toBe("number");
|
||||
});
|
||||
});
|
||||
|
||||
describe("coerceFormValues", () => {
|
||||
it("coerces string numbers to numbers based on schema", () => {
|
||||
const form = {
|
||||
models: {
|
||||
providers: {
|
||||
xai: {
|
||||
baseUrl: "https://api.x.ai/v1",
|
||||
models: [
|
||||
{
|
||||
id: "grok-4",
|
||||
name: "Grok 4",
|
||||
contextWindow: "131072",
|
||||
maxTokens: "8192",
|
||||
cost: { input: "0.5", output: "1.0", cacheRead: "0.1", cacheWrite: "0.2" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const coerced = coerceFormValues(form, topLevelSchema) as Record<string, unknown>;
|
||||
const model = (
|
||||
((coerced.models as Record<string, unknown>).providers as Record<string, unknown>)
|
||||
.xai as Record<string, unknown>
|
||||
).models as Array<Record<string, unknown>>;
|
||||
const first = model[0];
|
||||
|
||||
expect(typeof first.maxTokens).toBe("number");
|
||||
expect(first.maxTokens).toBe(8192);
|
||||
expect(typeof first.contextWindow).toBe("number");
|
||||
expect(first.contextWindow).toBe(131072);
|
||||
expect(typeof first.cost).toBe("object");
|
||||
const cost = first.cost as Record<string, number>;
|
||||
expect(typeof cost.input).toBe("number");
|
||||
expect(cost.input).toBe(0.5);
|
||||
expect(typeof cost.output).toBe("number");
|
||||
expect(cost.output).toBe(1);
|
||||
expect(typeof cost.cacheRead).toBe("number");
|
||||
expect(cost.cacheRead).toBe(0.1);
|
||||
expect(typeof cost.cacheWrite).toBe("number");
|
||||
expect(cost.cacheWrite).toBe(0.2);
|
||||
});
|
||||
|
||||
it("preserves already-correct numeric values", () => {
|
||||
const form = makeConfigWithProvider();
|
||||
const coerced = coerceFormValues(form, topLevelSchema) as Record<string, unknown>;
|
||||
const model = (
|
||||
((coerced.models as Record<string, unknown>).providers as Record<string, unknown>)
|
||||
.xai as Record<string, unknown>
|
||||
).models as Array<Record<string, unknown>>;
|
||||
const first = model[0];
|
||||
|
||||
expect(typeof first.maxTokens).toBe("number");
|
||||
expect(first.maxTokens).toBe(8192);
|
||||
});
|
||||
|
||||
it("does not coerce non-numeric strings to numbers", () => {
|
||||
const form = {
|
||||
models: {
|
||||
providers: {
|
||||
xai: {
|
||||
baseUrl: "https://api.x.ai/v1",
|
||||
models: [
|
||||
{
|
||||
id: "grok-4",
|
||||
name: "Grok 4",
|
||||
maxTokens: "not-a-number",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const coerced = coerceFormValues(form, topLevelSchema) as Record<string, unknown>;
|
||||
const model = (
|
||||
((coerced.models as Record<string, unknown>).providers as Record<string, unknown>)
|
||||
.xai as Record<string, unknown>
|
||||
).models as Array<Record<string, unknown>>;
|
||||
const first = model[0];
|
||||
|
||||
expect(first.maxTokens).toBe("not-a-number");
|
||||
});
|
||||
|
||||
it("coerces string booleans to booleans based on schema", () => {
|
||||
const form = {
|
||||
models: {
|
||||
providers: {
|
||||
xai: {
|
||||
baseUrl: "https://api.x.ai/v1",
|
||||
models: [
|
||||
{
|
||||
id: "grok-4",
|
||||
name: "Grok 4",
|
||||
reasoning: "true",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const coerced = coerceFormValues(form, topLevelSchema) as Record<string, unknown>;
|
||||
const model = (
|
||||
((coerced.models as Record<string, unknown>).providers as Record<string, unknown>)
|
||||
.xai as Record<string, unknown>
|
||||
).models as Array<Record<string, unknown>>;
|
||||
expect(model[0].reasoning).toBe(true);
|
||||
});
|
||||
|
||||
it("handles empty string for number fields as undefined", () => {
|
||||
const form = {
|
||||
models: {
|
||||
providers: {
|
||||
xai: {
|
||||
baseUrl: "https://api.x.ai/v1",
|
||||
models: [
|
||||
{
|
||||
id: "grok-4",
|
||||
name: "Grok 4",
|
||||
maxTokens: "",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const coerced = coerceFormValues(form, topLevelSchema) as Record<string, unknown>;
|
||||
const model = (
|
||||
((coerced.models as Record<string, unknown>).providers as Record<string, unknown>)
|
||||
.xai as Record<string, unknown>
|
||||
).models as Array<Record<string, unknown>>;
|
||||
expect(model[0].maxTokens).toBeUndefined();
|
||||
});
|
||||
|
||||
it("passes through null and undefined values untouched", () => {
|
||||
expect(coerceFormValues(null, topLevelSchema)).toBeNull();
|
||||
expect(coerceFormValues(undefined, topLevelSchema)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles anyOf schemas with number variant", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
timeout: {
|
||||
anyOf: [{ type: "number" }, { type: "string" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
const form = { timeout: "30" };
|
||||
const coerced = coerceFormValues(form, schema) as Record<string, unknown>;
|
||||
expect(typeof coerced.timeout).toBe("number");
|
||||
expect(coerced.timeout).toBe(30);
|
||||
});
|
||||
|
||||
it("handles integer schema type", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
count: { type: "integer" },
|
||||
},
|
||||
};
|
||||
const form = { count: "42" };
|
||||
const coerced = coerceFormValues(form, schema) as Record<string, unknown>;
|
||||
expect(typeof coerced.count).toBe("number");
|
||||
expect(coerced.count).toBe(42);
|
||||
});
|
||||
|
||||
it("rejects non-integer string for integer schema type", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
count: { type: "integer" },
|
||||
},
|
||||
};
|
||||
const form = { count: "1.5" };
|
||||
const coerced = coerceFormValues(form, schema) as Record<string, unknown>;
|
||||
expect(coerced.count).toBe("1.5");
|
||||
});
|
||||
|
||||
it("does not coerce non-finite numeric strings", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
timeout: { type: "number" },
|
||||
},
|
||||
};
|
||||
const form = { timeout: "Infinity" };
|
||||
const coerced = coerceFormValues(form, schema) as Record<string, unknown>;
|
||||
expect(coerced.timeout).toBe("Infinity");
|
||||
});
|
||||
|
||||
it("supports allOf schema composition", () => {
|
||||
const schema: JsonSchema = {
|
||||
allOf: [
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
port: { type: "number" },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
enabled: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const form = { port: "8080", enabled: "true" };
|
||||
const coerced = coerceFormValues(form, schema) as Record<string, unknown>;
|
||||
expect(coerced.port).toBe(8080);
|
||||
expect(coerced.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("recurses into object inside anyOf (nullable pattern)", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
settings: {
|
||||
anyOf: [
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
port: { type: "number" },
|
||||
enabled: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
{ type: "null" },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const form = { settings: { port: "8080", enabled: "true" } };
|
||||
const coerced = coerceFormValues(form, schema) as Record<string, unknown>;
|
||||
const settings = coerced.settings as Record<string, unknown>;
|
||||
expect(typeof settings.port).toBe("number");
|
||||
expect(settings.port).toBe(8080);
|
||||
expect(settings.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("recurses into array inside anyOf", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
items: {
|
||||
anyOf: [
|
||||
{
|
||||
type: "array",
|
||||
items: { type: "object", properties: { count: { type: "number" } } },
|
||||
},
|
||||
{ type: "null" },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const form = { items: [{ count: "5" }] };
|
||||
const coerced = coerceFormValues(form, schema) as Record<string, unknown>;
|
||||
const items = coerced.items as Array<Record<string, unknown>>;
|
||||
expect(typeof items[0].count).toBe("number");
|
||||
expect(items[0].count).toBe(5);
|
||||
});
|
||||
|
||||
it("handles tuple array schemas by index", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
pair: {
|
||||
type: "array",
|
||||
items: [{ type: "string" }, { type: "number" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
const form = { pair: ["hello", "42"] };
|
||||
const coerced = coerceFormValues(form, schema) as Record<string, unknown>;
|
||||
const pair = coerced.pair as unknown[];
|
||||
expect(pair[0]).toBe("hello");
|
||||
expect(typeof pair[1]).toBe("number");
|
||||
expect(pair[1]).toBe(42);
|
||||
});
|
||||
|
||||
it("preserves tuple indexes when a value is cleared", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
tuple: {
|
||||
type: "array",
|
||||
items: [{ type: "string" }, { type: "number" }, { type: "string" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
const form = { tuple: ["left", "", "right"] };
|
||||
const coerced = coerceFormValues(form, schema) as Record<string, unknown>;
|
||||
const tuple = coerced.tuple as unknown[];
|
||||
expect(tuple).toHaveLength(3);
|
||||
expect(tuple[0]).toBe("left");
|
||||
expect(tuple[1]).toBeUndefined();
|
||||
expect(tuple[2]).toBe("right");
|
||||
});
|
||||
|
||||
it("omits cleared number field from object output", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
port: { type: "number" },
|
||||
},
|
||||
};
|
||||
const form = { name: "test", port: "" };
|
||||
const coerced = coerceFormValues(form, schema) as Record<string, unknown>;
|
||||
expect(coerced.name).toBe("test");
|
||||
expect("port" in coerced).toBe(false);
|
||||
});
|
||||
|
||||
it("filters undefined from array when number item is cleared", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
values: {
|
||||
type: "array",
|
||||
items: { type: "number" },
|
||||
},
|
||||
},
|
||||
};
|
||||
const form = { values: ["1", "", "3"] };
|
||||
const coerced = coerceFormValues(form, schema) as Record<string, unknown>;
|
||||
const values = coerced.values as number[];
|
||||
expect(values).toEqual([1, 3]);
|
||||
});
|
||||
|
||||
it("coerces boolean in anyOf union", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
flag: {
|
||||
anyOf: [{ type: "boolean" }, { type: "string" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
const form = { flag: "true" };
|
||||
const coerced = coerceFormValues(form, schema) as Record<string, unknown>;
|
||||
expect(coerced.flag).toBe(true);
|
||||
});
|
||||
});
|
||||
90
openclaw/ui/src/ui/controllers/config/form-utils.ts
Normal file
90
openclaw/ui/src/ui/controllers/config/form-utils.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
export function cloneConfigObject<T>(value: T): T {
|
||||
if (typeof structuredClone === "function") {
|
||||
return structuredClone(value);
|
||||
}
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
export function serializeConfigForm(form: Record<string, unknown>): string {
|
||||
return `${JSON.stringify(form, null, 2).trimEnd()}\n`;
|
||||
}
|
||||
|
||||
export function setPathValue(
|
||||
obj: Record<string, unknown> | unknown[],
|
||||
path: Array<string | number>,
|
||||
value: unknown,
|
||||
) {
|
||||
if (path.length === 0) {
|
||||
return;
|
||||
}
|
||||
let current: Record<string, unknown> | unknown[] = obj;
|
||||
for (let i = 0; i < path.length - 1; i += 1) {
|
||||
const key = path[i];
|
||||
const nextKey = path[i + 1];
|
||||
if (typeof key === "number") {
|
||||
if (!Array.isArray(current)) {
|
||||
return;
|
||||
}
|
||||
if (current[key] == null) {
|
||||
current[key] = typeof nextKey === "number" ? [] : ({} as Record<string, unknown>);
|
||||
}
|
||||
current = current[key] as Record<string, unknown> | unknown[];
|
||||
} else {
|
||||
if (typeof current !== "object" || current == null) {
|
||||
return;
|
||||
}
|
||||
const record = current as Record<string, unknown>;
|
||||
if (record[key] == null) {
|
||||
record[key] = typeof nextKey === "number" ? [] : ({} as Record<string, unknown>);
|
||||
}
|
||||
current = record[key] as Record<string, unknown> | unknown[];
|
||||
}
|
||||
}
|
||||
const lastKey = path[path.length - 1];
|
||||
if (typeof lastKey === "number") {
|
||||
if (Array.isArray(current)) {
|
||||
current[lastKey] = value;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (typeof current === "object" && current != null) {
|
||||
(current as Record<string, unknown>)[lastKey] = value;
|
||||
}
|
||||
}
|
||||
|
||||
export function removePathValue(
|
||||
obj: Record<string, unknown> | unknown[],
|
||||
path: Array<string | number>,
|
||||
) {
|
||||
if (path.length === 0) {
|
||||
return;
|
||||
}
|
||||
let current: Record<string, unknown> | unknown[] = obj;
|
||||
for (let i = 0; i < path.length - 1; i += 1) {
|
||||
const key = path[i];
|
||||
if (typeof key === "number") {
|
||||
if (!Array.isArray(current)) {
|
||||
return;
|
||||
}
|
||||
current = current[key] as Record<string, unknown> | unknown[];
|
||||
} else {
|
||||
if (typeof current !== "object" || current == null) {
|
||||
return;
|
||||
}
|
||||
current = (current as Record<string, unknown>)[key] as Record<string, unknown> | unknown[];
|
||||
}
|
||||
if (current == null) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const lastKey = path[path.length - 1];
|
||||
if (typeof lastKey === "number") {
|
||||
if (Array.isArray(current)) {
|
||||
current.splice(lastKey, 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (typeof current === "object" && current != null) {
|
||||
delete (current as Record<string, unknown>)[lastKey];
|
||||
}
|
||||
}
|
||||
82
openclaw/ui/src/ui/controllers/control-ui-bootstrap.test.ts
Normal file
82
openclaw/ui/src/ui/controllers/control-ui-bootstrap.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { CONTROL_UI_BOOTSTRAP_CONFIG_PATH } from "../../../../src/gateway/control-ui-contract.js";
|
||||
import { loadControlUiBootstrapConfig } from "./control-ui-bootstrap.ts";
|
||||
|
||||
describe("loadControlUiBootstrapConfig", () => {
|
||||
it("loads assistant identity from the bootstrap endpoint", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
basePath: "/openclaw",
|
||||
assistantName: "Ops",
|
||||
assistantAvatar: "O",
|
||||
assistantAgentId: "main",
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||||
|
||||
const state = {
|
||||
basePath: "/openclaw",
|
||||
assistantName: "Assistant",
|
||||
assistantAvatar: null,
|
||||
assistantAgentId: null,
|
||||
};
|
||||
|
||||
await loadControlUiBootstrapConfig(state);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
`/openclaw${CONTROL_UI_BOOTSTRAP_CONFIG_PATH}`,
|
||||
expect.objectContaining({ method: "GET" }),
|
||||
);
|
||||
expect(state.assistantName).toBe("Ops");
|
||||
expect(state.assistantAvatar).toBe("O");
|
||||
expect(state.assistantAgentId).toBe("main");
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("ignores failures", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: false });
|
||||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||||
|
||||
const state = {
|
||||
basePath: "",
|
||||
assistantName: "Assistant",
|
||||
assistantAvatar: null,
|
||||
assistantAgentId: null,
|
||||
};
|
||||
|
||||
await loadControlUiBootstrapConfig(state);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
CONTROL_UI_BOOTSTRAP_CONFIG_PATH,
|
||||
expect.objectContaining({ method: "GET" }),
|
||||
);
|
||||
expect(state.assistantName).toBe("Assistant");
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("normalizes trailing slash basePath for bootstrap fetch path", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: false });
|
||||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||||
|
||||
const state = {
|
||||
basePath: "/openclaw/",
|
||||
assistantName: "Assistant",
|
||||
assistantAvatar: null,
|
||||
assistantAgentId: null,
|
||||
};
|
||||
|
||||
await loadControlUiBootstrapConfig(state);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
`/openclaw${CONTROL_UI_BOOTSTRAP_CONFIG_PATH}`,
|
||||
expect.objectContaining({ method: "GET" }),
|
||||
);
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
49
openclaw/ui/src/ui/controllers/control-ui-bootstrap.ts
Normal file
49
openclaw/ui/src/ui/controllers/control-ui-bootstrap.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
CONTROL_UI_BOOTSTRAP_CONFIG_PATH,
|
||||
type ControlUiBootstrapConfig,
|
||||
} from "../../../../src/gateway/control-ui-contract.js";
|
||||
import { normalizeAssistantIdentity } from "../assistant-identity.ts";
|
||||
import { normalizeBasePath } from "../navigation.ts";
|
||||
|
||||
export type ControlUiBootstrapState = {
|
||||
basePath: string;
|
||||
assistantName: string;
|
||||
assistantAvatar: string | null;
|
||||
assistantAgentId: string | null;
|
||||
};
|
||||
|
||||
export async function loadControlUiBootstrapConfig(state: ControlUiBootstrapState) {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
if (typeof fetch !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
const basePath = normalizeBasePath(state.basePath ?? "");
|
||||
const url = basePath
|
||||
? `${basePath}${CONTROL_UI_BOOTSTRAP_CONFIG_PATH}`
|
||||
: CONTROL_UI_BOOTSTRAP_CONFIG_PATH;
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (!res.ok) {
|
||||
return;
|
||||
}
|
||||
const parsed = (await res.json()) as ControlUiBootstrapConfig;
|
||||
const normalized = normalizeAssistantIdentity({
|
||||
agentId: parsed.assistantAgentId ?? null,
|
||||
name: parsed.assistantName,
|
||||
avatar: parsed.assistantAvatar ?? null,
|
||||
});
|
||||
state.assistantName = normalized.name;
|
||||
state.assistantAvatar = normalized.avatar;
|
||||
state.assistantAgentId = normalized.agentId ?? null;
|
||||
} catch {
|
||||
// Ignore bootstrap failures; UI will update identity after connecting.
|
||||
}
|
||||
}
|
||||
537
openclaw/ui/src/ui/controllers/cron.test.ts
Normal file
537
openclaw/ui/src/ui/controllers/cron.test.ts
Normal file
@@ -0,0 +1,537 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_CRON_FORM } from "../app-defaults.ts";
|
||||
import {
|
||||
addCronJob,
|
||||
cancelCronEdit,
|
||||
loadCronJobsPage,
|
||||
loadCronRuns,
|
||||
loadMoreCronRuns,
|
||||
normalizeCronFormState,
|
||||
startCronEdit,
|
||||
startCronClone,
|
||||
validateCronForm,
|
||||
type CronState,
|
||||
} from "./cron.ts";
|
||||
|
||||
function createState(overrides: Partial<CronState> = {}): CronState {
|
||||
return {
|
||||
client: null,
|
||||
connected: true,
|
||||
cronLoading: false,
|
||||
cronJobsLoadingMore: false,
|
||||
cronJobs: [],
|
||||
cronJobsTotal: 0,
|
||||
cronJobsHasMore: false,
|
||||
cronJobsNextOffset: null,
|
||||
cronJobsLimit: 50,
|
||||
cronJobsQuery: "",
|
||||
cronJobsEnabledFilter: "all",
|
||||
cronJobsSortBy: "nextRunAtMs",
|
||||
cronJobsSortDir: "asc",
|
||||
cronStatus: null,
|
||||
cronError: null,
|
||||
cronForm: { ...DEFAULT_CRON_FORM },
|
||||
cronFieldErrors: {},
|
||||
cronEditingJobId: null,
|
||||
cronRunsJobId: null,
|
||||
cronRunsLoadingMore: false,
|
||||
cronRuns: [],
|
||||
cronRunsTotal: 0,
|
||||
cronRunsHasMore: false,
|
||||
cronRunsNextOffset: null,
|
||||
cronRunsLimit: 50,
|
||||
cronRunsScope: "all",
|
||||
cronRunsStatuses: [],
|
||||
cronRunsDeliveryStatuses: [],
|
||||
cronRunsStatusFilter: "all",
|
||||
cronRunsQuery: "",
|
||||
cronRunsSortDir: "desc",
|
||||
cronBusy: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("cron controller", () => {
|
||||
it("normalizes stale announce mode when session/payload no longer support announce", () => {
|
||||
const normalized = normalizeCronFormState({
|
||||
...DEFAULT_CRON_FORM,
|
||||
sessionTarget: "main",
|
||||
payloadKind: "systemEvent",
|
||||
deliveryMode: "announce",
|
||||
});
|
||||
|
||||
expect(normalized.deliveryMode).toBe("none");
|
||||
});
|
||||
|
||||
it("keeps announce mode when isolated agentTurn supports announce", () => {
|
||||
const normalized = normalizeCronFormState({
|
||||
...DEFAULT_CRON_FORM,
|
||||
sessionTarget: "isolated",
|
||||
payloadKind: "agentTurn",
|
||||
deliveryMode: "announce",
|
||||
});
|
||||
|
||||
expect(normalized.deliveryMode).toBe("announce");
|
||||
});
|
||||
|
||||
it("forwards webhook delivery in cron.add payload", async () => {
|
||||
const request = vi.fn(async (method: string, _payload?: unknown) => {
|
||||
if (method === "cron.add") {
|
||||
return { id: "job-1" };
|
||||
}
|
||||
if (method === "cron.list") {
|
||||
return { jobs: [] };
|
||||
}
|
||||
if (method === "cron.status") {
|
||||
return { enabled: true, jobs: 0, nextWakeAtMs: null };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const state = createState({
|
||||
client: {
|
||||
request,
|
||||
} as unknown as CronState["client"],
|
||||
cronForm: {
|
||||
...DEFAULT_CRON_FORM,
|
||||
name: "webhook job",
|
||||
scheduleKind: "every",
|
||||
everyAmount: "1",
|
||||
everyUnit: "minutes",
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "next-heartbeat",
|
||||
payloadKind: "agentTurn",
|
||||
payloadText: "run this",
|
||||
deliveryMode: "webhook",
|
||||
deliveryTo: "https://example.invalid/cron",
|
||||
},
|
||||
});
|
||||
|
||||
await addCronJob(state);
|
||||
|
||||
const addCall = request.mock.calls.find(([method]) => method === "cron.add");
|
||||
expect(addCall).toBeDefined();
|
||||
expect(addCall?.[1]).toMatchObject({
|
||||
name: "webhook job",
|
||||
delivery: { mode: "webhook", to: "https://example.invalid/cron" },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not submit stale announce delivery when unsupported", async () => {
|
||||
const request = vi.fn(async (method: string, _payload?: unknown) => {
|
||||
if (method === "cron.add") {
|
||||
return { id: "job-2" };
|
||||
}
|
||||
if (method === "cron.list") {
|
||||
return { jobs: [] };
|
||||
}
|
||||
if (method === "cron.status") {
|
||||
return { enabled: true, jobs: 0, nextWakeAtMs: null };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const state = createState({
|
||||
client: {
|
||||
request,
|
||||
} as unknown as CronState["client"],
|
||||
cronForm: {
|
||||
...DEFAULT_CRON_FORM,
|
||||
name: "main job",
|
||||
scheduleKind: "every",
|
||||
everyAmount: "1",
|
||||
everyUnit: "minutes",
|
||||
sessionTarget: "main",
|
||||
wakeMode: "next-heartbeat",
|
||||
payloadKind: "systemEvent",
|
||||
payloadText: "run this",
|
||||
deliveryMode: "announce",
|
||||
deliveryTo: "buddy",
|
||||
},
|
||||
});
|
||||
|
||||
await addCronJob(state);
|
||||
|
||||
const addCall = request.mock.calls.find(([method]) => method === "cron.add");
|
||||
expect(addCall).toBeDefined();
|
||||
expect(addCall?.[1]).toMatchObject({
|
||||
name: "main job",
|
||||
});
|
||||
expect((addCall?.[1] as { delivery?: unknown } | undefined)?.delivery).toBeUndefined();
|
||||
expect(state.cronForm.deliveryMode).toBe("none");
|
||||
});
|
||||
|
||||
it("submits cron.update when editing an existing job", async () => {
|
||||
const request = vi.fn(async (method: string, _payload?: unknown) => {
|
||||
if (method === "cron.update") {
|
||||
return { id: "job-1" };
|
||||
}
|
||||
if (method === "cron.list") {
|
||||
return { jobs: [{ id: "job-1" }] };
|
||||
}
|
||||
if (method === "cron.status") {
|
||||
return { enabled: true, jobs: 1, nextWakeAtMs: null };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const state = createState({
|
||||
client: {
|
||||
request,
|
||||
} as unknown as CronState["client"],
|
||||
cronEditingJobId: "job-1",
|
||||
cronForm: {
|
||||
...DEFAULT_CRON_FORM,
|
||||
name: "edited job",
|
||||
description: "",
|
||||
clearAgent: true,
|
||||
deleteAfterRun: false,
|
||||
scheduleKind: "cron",
|
||||
cronExpr: "0 8 * * *",
|
||||
scheduleExact: true,
|
||||
payloadKind: "systemEvent",
|
||||
payloadText: "updated",
|
||||
deliveryMode: "none",
|
||||
},
|
||||
});
|
||||
|
||||
await addCronJob(state);
|
||||
|
||||
const updateCall = request.mock.calls.find(([method]) => method === "cron.update");
|
||||
expect(updateCall).toBeDefined();
|
||||
expect(updateCall?.[1]).toMatchObject({
|
||||
id: "job-1",
|
||||
patch: {
|
||||
name: "edited job",
|
||||
description: "",
|
||||
agentId: null,
|
||||
deleteAfterRun: false,
|
||||
schedule: { kind: "cron", expr: "0 8 * * *", staggerMs: 0 },
|
||||
payload: { kind: "systemEvent", text: "updated" },
|
||||
},
|
||||
});
|
||||
expect(state.cronEditingJobId).toBeNull();
|
||||
});
|
||||
|
||||
it("maps a cron job into editable form fields", () => {
|
||||
const state = createState();
|
||||
const job = {
|
||||
id: "job-9",
|
||||
name: "Weekly report",
|
||||
description: "desc",
|
||||
enabled: false,
|
||||
createdAtMs: 0,
|
||||
updatedAtMs: 0,
|
||||
schedule: { kind: "every" as const, everyMs: 7_200_000 },
|
||||
sessionTarget: "isolated" as const,
|
||||
wakeMode: "next-heartbeat" as const,
|
||||
payload: { kind: "agentTurn" as const, message: "ship it", timeoutSeconds: 45 },
|
||||
delivery: { mode: "announce" as const, channel: "telegram", to: "123" },
|
||||
state: {},
|
||||
};
|
||||
|
||||
startCronEdit(state, job);
|
||||
|
||||
expect(state.cronEditingJobId).toBe("job-9");
|
||||
expect(state.cronRunsJobId).toBe("job-9");
|
||||
expect(state.cronForm.name).toBe("Weekly report");
|
||||
expect(state.cronForm.enabled).toBe(false);
|
||||
expect(state.cronForm.scheduleKind).toBe("every");
|
||||
expect(state.cronForm.everyAmount).toBe("2");
|
||||
expect(state.cronForm.everyUnit).toBe("hours");
|
||||
expect(state.cronForm.payloadKind).toBe("agentTurn");
|
||||
expect(state.cronForm.payloadText).toBe("ship it");
|
||||
expect(state.cronForm.timeoutSeconds).toBe("45");
|
||||
expect(state.cronForm.deliveryMode).toBe("announce");
|
||||
expect(state.cronForm.deliveryChannel).toBe("telegram");
|
||||
expect(state.cronForm.deliveryTo).toBe("123");
|
||||
});
|
||||
|
||||
it("includes model/thinking/stagger/bestEffort in cron.update patch", async () => {
|
||||
const request = vi.fn(async (method: string, _payload?: unknown) => {
|
||||
if (method === "cron.update") {
|
||||
return { id: "job-2" };
|
||||
}
|
||||
if (method === "cron.list") {
|
||||
return { jobs: [{ id: "job-2" }] };
|
||||
}
|
||||
if (method === "cron.status") {
|
||||
return { enabled: true, jobs: 1, nextWakeAtMs: null };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
const state = createState({
|
||||
client: { request } as unknown as CronState["client"],
|
||||
cronEditingJobId: "job-2",
|
||||
cronForm: {
|
||||
...DEFAULT_CRON_FORM,
|
||||
name: "advanced edit",
|
||||
scheduleKind: "cron",
|
||||
cronExpr: "0 9 * * *",
|
||||
staggerAmount: "30",
|
||||
staggerUnit: "seconds",
|
||||
payloadKind: "agentTurn",
|
||||
payloadText: "run it",
|
||||
payloadModel: "opus",
|
||||
payloadThinking: "low",
|
||||
deliveryMode: "announce",
|
||||
deliveryBestEffort: true,
|
||||
},
|
||||
});
|
||||
|
||||
await addCronJob(state);
|
||||
|
||||
const updateCall = request.mock.calls.find(([method]) => method === "cron.update");
|
||||
expect(updateCall).toBeDefined();
|
||||
expect(updateCall?.[1]).toMatchObject({
|
||||
id: "job-2",
|
||||
patch: {
|
||||
schedule: { kind: "cron", expr: "0 9 * * *", staggerMs: 30_000 },
|
||||
payload: {
|
||||
kind: "agentTurn",
|
||||
message: "run it",
|
||||
model: "opus",
|
||||
thinking: "low",
|
||||
},
|
||||
delivery: { mode: "announce", bestEffort: true },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("maps cron stagger, model, thinking, and best effort into form", () => {
|
||||
const state = createState();
|
||||
const job = {
|
||||
id: "job-10",
|
||||
name: "Advanced job",
|
||||
enabled: true,
|
||||
deleteAfterRun: true,
|
||||
createdAtMs: 0,
|
||||
updatedAtMs: 0,
|
||||
schedule: { kind: "cron" as const, expr: "0 7 * * *", tz: "UTC", staggerMs: 60_000 },
|
||||
sessionTarget: "isolated" as const,
|
||||
wakeMode: "now" as const,
|
||||
payload: {
|
||||
kind: "agentTurn" as const,
|
||||
message: "hi",
|
||||
model: "opus",
|
||||
thinking: "high",
|
||||
},
|
||||
delivery: { mode: "announce" as const, bestEffort: true },
|
||||
state: {},
|
||||
};
|
||||
startCronEdit(state, job);
|
||||
|
||||
expect(state.cronForm.deleteAfterRun).toBe(true);
|
||||
expect(state.cronForm.scheduleKind).toBe("cron");
|
||||
expect(state.cronForm.scheduleExact).toBe(false);
|
||||
expect(state.cronForm.staggerAmount).toBe("1");
|
||||
expect(state.cronForm.staggerUnit).toBe("minutes");
|
||||
expect(state.cronForm.payloadModel).toBe("opus");
|
||||
expect(state.cronForm.payloadThinking).toBe("high");
|
||||
expect(state.cronForm.deliveryBestEffort).toBe(true);
|
||||
});
|
||||
|
||||
it("validates key cron form errors", () => {
|
||||
const errors = validateCronForm({
|
||||
...DEFAULT_CRON_FORM,
|
||||
name: "",
|
||||
scheduleKind: "cron",
|
||||
cronExpr: "",
|
||||
payloadKind: "agentTurn",
|
||||
payloadText: "",
|
||||
timeoutSeconds: "0",
|
||||
deliveryMode: "webhook",
|
||||
deliveryTo: "ftp://bad",
|
||||
});
|
||||
expect(errors.name).toBeDefined();
|
||||
expect(errors.cronExpr).toBeDefined();
|
||||
expect(errors.payloadText).toBeDefined();
|
||||
expect(errors.timeoutSeconds).toBe("If set, timeout must be greater than 0 seconds.");
|
||||
expect(errors.deliveryTo).toBeDefined();
|
||||
});
|
||||
|
||||
it("blocks add/update submit when validation errors exist", async () => {
|
||||
const request = vi.fn(async () => ({}));
|
||||
const state = createState({
|
||||
client: { request } as unknown as CronState["client"],
|
||||
cronForm: {
|
||||
...DEFAULT_CRON_FORM,
|
||||
name: "",
|
||||
payloadText: "",
|
||||
},
|
||||
});
|
||||
await addCronJob(state);
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
expect(state.cronFieldErrors.name).toBeDefined();
|
||||
expect(state.cronFieldErrors.payloadText).toBeDefined();
|
||||
});
|
||||
|
||||
it("canceling edit resets form to defaults and clears edit mode", () => {
|
||||
const state = createState();
|
||||
const job = {
|
||||
id: "job-cancel",
|
||||
name: "Editable",
|
||||
enabled: true,
|
||||
createdAtMs: 0,
|
||||
updatedAtMs: 0,
|
||||
schedule: { kind: "cron" as const, expr: "0 6 * * *" },
|
||||
sessionTarget: "isolated" as const,
|
||||
wakeMode: "now" as const,
|
||||
payload: { kind: "agentTurn" as const, message: "run" },
|
||||
delivery: { mode: "announce" as const, to: "123" },
|
||||
state: {},
|
||||
};
|
||||
startCronEdit(state, job);
|
||||
state.cronForm.name = "changed";
|
||||
state.cronFieldErrors = { name: "Name is required." };
|
||||
|
||||
cancelCronEdit(state);
|
||||
|
||||
expect(state.cronEditingJobId).toBeNull();
|
||||
expect(state.cronForm).toEqual({ ...DEFAULT_CRON_FORM });
|
||||
expect(state.cronFieldErrors).toEqual(validateCronForm(DEFAULT_CRON_FORM));
|
||||
});
|
||||
|
||||
it("cloning a job switches to create mode and applies copy naming", () => {
|
||||
const state = createState({
|
||||
cronJobs: [
|
||||
{
|
||||
id: "job-1",
|
||||
name: "Daily ping",
|
||||
enabled: true,
|
||||
createdAtMs: 0,
|
||||
updatedAtMs: 0,
|
||||
schedule: { kind: "cron", expr: "0 9 * * *" },
|
||||
sessionTarget: "main",
|
||||
wakeMode: "next-heartbeat",
|
||||
payload: { kind: "systemEvent", text: "ping" },
|
||||
state: {},
|
||||
},
|
||||
],
|
||||
cronEditingJobId: "job-1",
|
||||
});
|
||||
|
||||
const sourceJob = state.cronJobs[0];
|
||||
expect(sourceJob).toBeDefined();
|
||||
if (!sourceJob) {
|
||||
return;
|
||||
}
|
||||
startCronClone(state, sourceJob);
|
||||
|
||||
expect(state.cronEditingJobId).toBeNull();
|
||||
expect(state.cronRunsJobId).toBe("job-1");
|
||||
expect(state.cronForm.name).toBe("Daily ping copy");
|
||||
expect(state.cronForm.payloadText).toBe("ping");
|
||||
});
|
||||
|
||||
it("submits cron.add after cloning", async () => {
|
||||
const request = vi.fn(async (method: string, _payload?: unknown) => {
|
||||
if (method === "cron.add") {
|
||||
return { id: "job-new" };
|
||||
}
|
||||
if (method === "cron.list") {
|
||||
return { jobs: [] };
|
||||
}
|
||||
if (method === "cron.status") {
|
||||
return { enabled: true, jobs: 0, nextWakeAtMs: null };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
const sourceJob = {
|
||||
id: "job-1",
|
||||
name: "Daily ping",
|
||||
enabled: true,
|
||||
createdAtMs: 0,
|
||||
updatedAtMs: 0,
|
||||
schedule: { kind: "cron" as const, expr: "0 9 * * *" },
|
||||
sessionTarget: "main" as const,
|
||||
wakeMode: "next-heartbeat" as const,
|
||||
payload: { kind: "systemEvent" as const, text: "ping" },
|
||||
state: {},
|
||||
};
|
||||
const state = createState({
|
||||
client: { request } as unknown as CronState["client"],
|
||||
cronJobs: [sourceJob],
|
||||
cronEditingJobId: "job-1",
|
||||
});
|
||||
|
||||
startCronClone(state, sourceJob);
|
||||
await addCronJob(state);
|
||||
|
||||
const addCall = request.mock.calls.find(([method]) => method === "cron.add");
|
||||
const updateCall = request.mock.calls.find(([method]) => method === "cron.update");
|
||||
expect(addCall).toBeDefined();
|
||||
expect(updateCall).toBeUndefined();
|
||||
expect((addCall?.[1] as { name?: string } | undefined)?.name).toBe("Daily ping copy");
|
||||
});
|
||||
|
||||
it("loads paged jobs with query/filter/sort params", async () => {
|
||||
const request = vi.fn(async (method: string, payload?: unknown) => {
|
||||
if (method === "cron.list") {
|
||||
expect(payload).toMatchObject({
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
query: "daily",
|
||||
enabled: "enabled",
|
||||
sortBy: "updatedAtMs",
|
||||
sortDir: "desc",
|
||||
});
|
||||
return {
|
||||
jobs: [{ id: "job-1", name: "Daily", enabled: true }],
|
||||
total: 1,
|
||||
hasMore: false,
|
||||
nextOffset: null,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
});
|
||||
const state = createState({
|
||||
client: { request } as unknown as CronState["client"],
|
||||
cronJobsQuery: "daily",
|
||||
cronJobsEnabledFilter: "enabled",
|
||||
cronJobsSortBy: "updatedAtMs",
|
||||
cronJobsSortDir: "desc",
|
||||
});
|
||||
|
||||
await loadCronJobsPage(state);
|
||||
|
||||
expect(state.cronJobs).toHaveLength(1);
|
||||
expect(state.cronJobsTotal).toBe(1);
|
||||
expect(state.cronJobsHasMore).toBe(false);
|
||||
});
|
||||
|
||||
it("loads and appends paged run history", async () => {
|
||||
const request = vi.fn(async (method: string, payload?: unknown) => {
|
||||
if (method !== "cron.runs") {
|
||||
return {};
|
||||
}
|
||||
const offset = (payload as { offset?: number } | undefined)?.offset ?? 0;
|
||||
if (offset === 0) {
|
||||
return {
|
||||
entries: [{ ts: 2, jobId: "job-1", status: "ok", summary: "newest" }],
|
||||
total: 2,
|
||||
hasMore: true,
|
||||
nextOffset: 1,
|
||||
};
|
||||
}
|
||||
return {
|
||||
entries: [{ ts: 1, jobId: "job-1", status: "ok", summary: "older" }],
|
||||
total: 2,
|
||||
hasMore: false,
|
||||
nextOffset: null,
|
||||
};
|
||||
});
|
||||
const state = createState({
|
||||
client: { request } as unknown as CronState["client"],
|
||||
});
|
||||
|
||||
await loadCronRuns(state, "job-1");
|
||||
expect(state.cronRuns).toHaveLength(1);
|
||||
expect(state.cronRunsHasMore).toBe(true);
|
||||
|
||||
await loadMoreCronRuns(state);
|
||||
expect(state.cronRuns).toHaveLength(2);
|
||||
expect(state.cronRuns[0]?.summary).toBe("newest");
|
||||
expect(state.cronRuns[1]?.summary).toBe("older");
|
||||
});
|
||||
});
|
||||
776
openclaw/ui/src/ui/controllers/cron.ts
Normal file
776
openclaw/ui/src/ui/controllers/cron.ts
Normal file
@@ -0,0 +1,776 @@
|
||||
import { DEFAULT_CRON_FORM } from "../app-defaults.ts";
|
||||
import { toNumber } from "../format.ts";
|
||||
import type { GatewayBrowserClient } from "../gateway.ts";
|
||||
import type {
|
||||
CronJob,
|
||||
CronDeliveryStatus,
|
||||
CronJobsEnabledFilter,
|
||||
CronJobsListResult,
|
||||
CronJobsSortBy,
|
||||
CronRunScope,
|
||||
CronRunLogEntry,
|
||||
CronRunsResult,
|
||||
CronRunsStatusFilter,
|
||||
CronRunsStatusValue,
|
||||
CronSortDir,
|
||||
CronStatus,
|
||||
} from "../types.ts";
|
||||
import { CRON_CHANNEL_LAST } from "../ui-types.ts";
|
||||
import type { CronFormState } from "../ui-types.ts";
|
||||
|
||||
export type CronFieldKey =
|
||||
| "name"
|
||||
| "scheduleAt"
|
||||
| "everyAmount"
|
||||
| "cronExpr"
|
||||
| "staggerAmount"
|
||||
| "payloadText"
|
||||
| "payloadModel"
|
||||
| "payloadThinking"
|
||||
| "timeoutSeconds"
|
||||
| "deliveryTo";
|
||||
|
||||
export type CronFieldErrors = Partial<Record<CronFieldKey, string>>;
|
||||
|
||||
export type CronState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
connected: boolean;
|
||||
cronLoading: boolean;
|
||||
cronJobsLoadingMore: boolean;
|
||||
cronJobs: CronJob[];
|
||||
cronJobsTotal: number;
|
||||
cronJobsHasMore: boolean;
|
||||
cronJobsNextOffset: number | null;
|
||||
cronJobsLimit: number;
|
||||
cronJobsQuery: string;
|
||||
cronJobsEnabledFilter: CronJobsEnabledFilter;
|
||||
cronJobsSortBy: CronJobsSortBy;
|
||||
cronJobsSortDir: CronSortDir;
|
||||
cronStatus: CronStatus | null;
|
||||
cronError: string | null;
|
||||
cronForm: CronFormState;
|
||||
cronFieldErrors: CronFieldErrors;
|
||||
cronEditingJobId: string | null;
|
||||
cronRunsJobId: string | null;
|
||||
cronRunsLoadingMore: boolean;
|
||||
cronRuns: CronRunLogEntry[];
|
||||
cronRunsTotal: number;
|
||||
cronRunsHasMore: boolean;
|
||||
cronRunsNextOffset: number | null;
|
||||
cronRunsLimit: number;
|
||||
cronRunsScope: CronRunScope;
|
||||
cronRunsStatuses: CronRunsStatusValue[];
|
||||
cronRunsDeliveryStatuses: CronDeliveryStatus[];
|
||||
cronRunsStatusFilter: CronRunsStatusFilter;
|
||||
cronRunsQuery: string;
|
||||
cronRunsSortDir: CronSortDir;
|
||||
cronBusy: boolean;
|
||||
};
|
||||
|
||||
export type CronModelSuggestionsState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
connected: boolean;
|
||||
cronModelSuggestions: string[];
|
||||
};
|
||||
|
||||
export function supportsAnnounceDelivery(
|
||||
form: Pick<CronFormState, "sessionTarget" | "payloadKind">,
|
||||
) {
|
||||
return form.sessionTarget === "isolated" && form.payloadKind === "agentTurn";
|
||||
}
|
||||
|
||||
export function normalizeCronFormState(form: CronFormState): CronFormState {
|
||||
if (form.deliveryMode !== "announce") {
|
||||
return form;
|
||||
}
|
||||
if (supportsAnnounceDelivery(form)) {
|
||||
return form;
|
||||
}
|
||||
return {
|
||||
...form,
|
||||
deliveryMode: "none",
|
||||
};
|
||||
}
|
||||
|
||||
export function validateCronForm(form: CronFormState): CronFieldErrors {
|
||||
const errors: CronFieldErrors = {};
|
||||
if (!form.name.trim()) {
|
||||
errors.name = "Name is required.";
|
||||
}
|
||||
if (form.scheduleKind === "at") {
|
||||
const ms = Date.parse(form.scheduleAt);
|
||||
if (!Number.isFinite(ms)) {
|
||||
errors.scheduleAt = "Enter a valid date/time.";
|
||||
}
|
||||
} else if (form.scheduleKind === "every") {
|
||||
const amount = toNumber(form.everyAmount, 0);
|
||||
if (amount <= 0) {
|
||||
errors.everyAmount = "Interval must be greater than 0.";
|
||||
}
|
||||
} else {
|
||||
if (!form.cronExpr.trim()) {
|
||||
errors.cronExpr = "Cron expression is required.";
|
||||
}
|
||||
if (!form.scheduleExact) {
|
||||
const staggerAmount = form.staggerAmount.trim();
|
||||
if (staggerAmount) {
|
||||
const stagger = toNumber(staggerAmount, 0);
|
||||
if (stagger <= 0) {
|
||||
errors.staggerAmount = "Stagger must be greater than 0.";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!form.payloadText.trim()) {
|
||||
errors.payloadText =
|
||||
form.payloadKind === "systemEvent"
|
||||
? "System text is required."
|
||||
: "Agent message is required.";
|
||||
}
|
||||
if (form.payloadKind === "agentTurn") {
|
||||
const timeoutRaw = form.timeoutSeconds.trim();
|
||||
if (timeoutRaw) {
|
||||
const timeout = toNumber(timeoutRaw, 0);
|
||||
if (timeout <= 0) {
|
||||
errors.timeoutSeconds = "If set, timeout must be greater than 0 seconds.";
|
||||
}
|
||||
}
|
||||
}
|
||||
if (form.deliveryMode === "webhook") {
|
||||
const target = form.deliveryTo.trim();
|
||||
if (!target) {
|
||||
errors.deliveryTo = "Webhook URL is required.";
|
||||
} else if (!/^https?:\/\//i.test(target)) {
|
||||
errors.deliveryTo = "Webhook URL must start with http:// or https://.";
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function hasCronFormErrors(errors: CronFieldErrors): boolean {
|
||||
return Object.keys(errors).length > 0;
|
||||
}
|
||||
|
||||
export async function loadCronStatus(state: CronState) {
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await state.client.request<CronStatus>("cron.status", {});
|
||||
state.cronStatus = res;
|
||||
} catch (err) {
|
||||
state.cronError = String(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadCronModelSuggestions(state: CronModelSuggestionsState) {
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await state.client.request("models.list", {});
|
||||
const models = (res as { models?: unknown[] } | null)?.models;
|
||||
if (!Array.isArray(models)) {
|
||||
state.cronModelSuggestions = [];
|
||||
return;
|
||||
}
|
||||
const ids = models
|
||||
.map((entry) => {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
return "";
|
||||
}
|
||||
const id = (entry as { id?: unknown }).id;
|
||||
return typeof id === "string" ? id.trim() : "";
|
||||
})
|
||||
.filter(Boolean);
|
||||
state.cronModelSuggestions = Array.from(new Set(ids)).toSorted((a, b) => a.localeCompare(b));
|
||||
} catch {
|
||||
state.cronModelSuggestions = [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadCronJobs(state: CronState) {
|
||||
return await loadCronJobsPage(state, { append: false });
|
||||
}
|
||||
|
||||
function normalizeCronPageMeta(params: {
|
||||
totalRaw: unknown;
|
||||
limitRaw: unknown;
|
||||
offsetRaw: unknown;
|
||||
nextOffsetRaw: unknown;
|
||||
hasMoreRaw: unknown;
|
||||
pageCount: number;
|
||||
}) {
|
||||
const total =
|
||||
typeof params.totalRaw === "number" && Number.isFinite(params.totalRaw)
|
||||
? Math.max(0, Math.floor(params.totalRaw))
|
||||
: params.pageCount;
|
||||
const limit =
|
||||
typeof params.limitRaw === "number" && Number.isFinite(params.limitRaw)
|
||||
? Math.max(1, Math.floor(params.limitRaw))
|
||||
: Math.max(1, params.pageCount);
|
||||
const offset =
|
||||
typeof params.offsetRaw === "number" && Number.isFinite(params.offsetRaw)
|
||||
? Math.max(0, Math.floor(params.offsetRaw))
|
||||
: 0;
|
||||
const hasMore =
|
||||
typeof params.hasMoreRaw === "boolean"
|
||||
? params.hasMoreRaw
|
||||
: offset + params.pageCount < Math.max(total, offset + params.pageCount);
|
||||
const nextOffset =
|
||||
typeof params.nextOffsetRaw === "number" && Number.isFinite(params.nextOffsetRaw)
|
||||
? Math.max(0, Math.floor(params.nextOffsetRaw))
|
||||
: hasMore
|
||||
? offset + params.pageCount
|
||||
: null;
|
||||
return { total, limit, offset, hasMore, nextOffset };
|
||||
}
|
||||
|
||||
export async function loadCronJobsPage(state: CronState, opts?: { append?: boolean }) {
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
if (state.cronLoading || state.cronJobsLoadingMore) {
|
||||
return;
|
||||
}
|
||||
const append = opts?.append === true;
|
||||
if (append) {
|
||||
if (!state.cronJobsHasMore) {
|
||||
return;
|
||||
}
|
||||
state.cronJobsLoadingMore = true;
|
||||
} else {
|
||||
state.cronLoading = true;
|
||||
}
|
||||
state.cronError = null;
|
||||
try {
|
||||
const offset = append ? Math.max(0, state.cronJobsNextOffset ?? state.cronJobs.length) : 0;
|
||||
const res = await state.client.request<CronJobsListResult>("cron.list", {
|
||||
includeDisabled: state.cronJobsEnabledFilter === "all",
|
||||
limit: state.cronJobsLimit,
|
||||
offset,
|
||||
query: state.cronJobsQuery.trim() || undefined,
|
||||
enabled: state.cronJobsEnabledFilter,
|
||||
sortBy: state.cronJobsSortBy,
|
||||
sortDir: state.cronJobsSortDir,
|
||||
});
|
||||
const jobs = Array.isArray(res.jobs) ? res.jobs : [];
|
||||
state.cronJobs = append ? [...state.cronJobs, ...jobs] : jobs;
|
||||
const meta = normalizeCronPageMeta({
|
||||
totalRaw: res.total,
|
||||
limitRaw: res.limit,
|
||||
offsetRaw: res.offset,
|
||||
nextOffsetRaw: res.nextOffset,
|
||||
hasMoreRaw: res.hasMore,
|
||||
pageCount: jobs.length,
|
||||
});
|
||||
state.cronJobsTotal = Math.max(meta.total, state.cronJobs.length);
|
||||
state.cronJobsHasMore = meta.hasMore;
|
||||
state.cronJobsNextOffset = meta.nextOffset;
|
||||
if (
|
||||
state.cronEditingJobId &&
|
||||
!state.cronJobs.some((job) => job.id === state.cronEditingJobId)
|
||||
) {
|
||||
clearCronEditState(state);
|
||||
}
|
||||
} catch (err) {
|
||||
state.cronError = String(err);
|
||||
} finally {
|
||||
if (append) {
|
||||
state.cronJobsLoadingMore = false;
|
||||
} else {
|
||||
state.cronLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadMoreCronJobs(state: CronState) {
|
||||
await loadCronJobsPage(state, { append: true });
|
||||
}
|
||||
|
||||
export async function reloadCronJobs(state: CronState) {
|
||||
await loadCronJobsPage(state, { append: false });
|
||||
}
|
||||
|
||||
export function updateCronJobsFilter(
|
||||
state: CronState,
|
||||
patch: Partial<
|
||||
Pick<
|
||||
CronState,
|
||||
"cronJobsQuery" | "cronJobsEnabledFilter" | "cronJobsSortBy" | "cronJobsSortDir"
|
||||
>
|
||||
>,
|
||||
) {
|
||||
if (typeof patch.cronJobsQuery === "string") {
|
||||
state.cronJobsQuery = patch.cronJobsQuery;
|
||||
}
|
||||
if (patch.cronJobsEnabledFilter) {
|
||||
state.cronJobsEnabledFilter = patch.cronJobsEnabledFilter;
|
||||
}
|
||||
if (patch.cronJobsSortBy) {
|
||||
state.cronJobsSortBy = patch.cronJobsSortBy;
|
||||
}
|
||||
if (patch.cronJobsSortDir) {
|
||||
state.cronJobsSortDir = patch.cronJobsSortDir;
|
||||
}
|
||||
}
|
||||
|
||||
function clearCronEditState(state: CronState) {
|
||||
state.cronEditingJobId = null;
|
||||
}
|
||||
|
||||
function resetCronFormToDefaults(state: CronState) {
|
||||
state.cronForm = { ...DEFAULT_CRON_FORM };
|
||||
state.cronFieldErrors = validateCronForm(state.cronForm);
|
||||
}
|
||||
|
||||
function formatDateTimeLocal(input: string): string {
|
||||
const ms = Date.parse(input);
|
||||
if (!Number.isFinite(ms)) {
|
||||
return "";
|
||||
}
|
||||
const date = new Date(ms);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
const hour = String(date.getHours()).padStart(2, "0");
|
||||
const minute = String(date.getMinutes()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}T${hour}:${minute}`;
|
||||
}
|
||||
|
||||
function parseEverySchedule(everyMs: number): Pick<CronFormState, "everyAmount" | "everyUnit"> {
|
||||
if (everyMs % 86_400_000 === 0) {
|
||||
return { everyAmount: String(Math.max(1, everyMs / 86_400_000)), everyUnit: "days" };
|
||||
}
|
||||
if (everyMs % 3_600_000 === 0) {
|
||||
return { everyAmount: String(Math.max(1, everyMs / 3_600_000)), everyUnit: "hours" };
|
||||
}
|
||||
const minutes = Math.max(1, Math.ceil(everyMs / 60_000));
|
||||
return { everyAmount: String(minutes), everyUnit: "minutes" };
|
||||
}
|
||||
|
||||
function parseStaggerSchedule(
|
||||
staggerMs?: number,
|
||||
): Pick<CronFormState, "scheduleExact" | "staggerAmount" | "staggerUnit"> {
|
||||
if (staggerMs === 0) {
|
||||
return { scheduleExact: true, staggerAmount: "", staggerUnit: "seconds" };
|
||||
}
|
||||
if (typeof staggerMs !== "number" || !Number.isFinite(staggerMs) || staggerMs < 0) {
|
||||
return { scheduleExact: false, staggerAmount: "", staggerUnit: "seconds" };
|
||||
}
|
||||
if (staggerMs % 60_000 === 0) {
|
||||
return {
|
||||
scheduleExact: false,
|
||||
staggerAmount: String(Math.max(1, staggerMs / 60_000)),
|
||||
staggerUnit: "minutes",
|
||||
};
|
||||
}
|
||||
return {
|
||||
scheduleExact: false,
|
||||
staggerAmount: String(Math.max(1, Math.ceil(staggerMs / 1_000))),
|
||||
staggerUnit: "seconds",
|
||||
};
|
||||
}
|
||||
|
||||
function jobToForm(job: CronJob, prev: CronFormState): CronFormState {
|
||||
const next: CronFormState = {
|
||||
...prev,
|
||||
name: job.name,
|
||||
description: job.description ?? "",
|
||||
agentId: job.agentId ?? "",
|
||||
clearAgent: false,
|
||||
enabled: job.enabled,
|
||||
deleteAfterRun: job.deleteAfterRun ?? false,
|
||||
scheduleKind: job.schedule.kind,
|
||||
scheduleAt: "",
|
||||
everyAmount: prev.everyAmount,
|
||||
everyUnit: prev.everyUnit,
|
||||
cronExpr: prev.cronExpr,
|
||||
cronTz: "",
|
||||
scheduleExact: false,
|
||||
staggerAmount: "",
|
||||
staggerUnit: "seconds",
|
||||
sessionTarget: job.sessionTarget,
|
||||
wakeMode: job.wakeMode,
|
||||
payloadKind: job.payload.kind,
|
||||
payloadText: job.payload.kind === "systemEvent" ? job.payload.text : job.payload.message,
|
||||
payloadModel: job.payload.kind === "agentTurn" ? (job.payload.model ?? "") : "",
|
||||
payloadThinking: job.payload.kind === "agentTurn" ? (job.payload.thinking ?? "") : "",
|
||||
deliveryMode: job.delivery?.mode ?? "none",
|
||||
deliveryChannel: job.delivery?.channel ?? CRON_CHANNEL_LAST,
|
||||
deliveryTo: job.delivery?.to ?? "",
|
||||
deliveryBestEffort: job.delivery?.bestEffort ?? false,
|
||||
timeoutSeconds:
|
||||
job.payload.kind === "agentTurn" && typeof job.payload.timeoutSeconds === "number"
|
||||
? String(job.payload.timeoutSeconds)
|
||||
: "",
|
||||
};
|
||||
|
||||
if (job.schedule.kind === "at") {
|
||||
next.scheduleAt = formatDateTimeLocal(job.schedule.at);
|
||||
} else if (job.schedule.kind === "every") {
|
||||
const parsed = parseEverySchedule(job.schedule.everyMs);
|
||||
next.everyAmount = parsed.everyAmount;
|
||||
next.everyUnit = parsed.everyUnit;
|
||||
} else {
|
||||
next.cronExpr = job.schedule.expr;
|
||||
next.cronTz = job.schedule.tz ?? "";
|
||||
const staggerFields = parseStaggerSchedule(job.schedule.staggerMs);
|
||||
next.scheduleExact = staggerFields.scheduleExact;
|
||||
next.staggerAmount = staggerFields.staggerAmount;
|
||||
next.staggerUnit = staggerFields.staggerUnit;
|
||||
}
|
||||
|
||||
return normalizeCronFormState(next);
|
||||
}
|
||||
|
||||
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, at: new Date(ms).toISOString() };
|
||||
}
|
||||
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.");
|
||||
}
|
||||
if (form.scheduleExact) {
|
||||
return { kind: "cron" as const, expr, tz: form.cronTz.trim() || undefined, staggerMs: 0 };
|
||||
}
|
||||
const staggerAmount = form.staggerAmount.trim();
|
||||
if (!staggerAmount) {
|
||||
return { kind: "cron" as const, expr, tz: form.cronTz.trim() || undefined };
|
||||
}
|
||||
const staggerValue = toNumber(staggerAmount, 0);
|
||||
if (staggerValue <= 0) {
|
||||
throw new Error("Invalid stagger amount.");
|
||||
}
|
||||
const staggerMs = form.staggerUnit === "minutes" ? staggerValue * 60_000 : staggerValue * 1_000;
|
||||
return { kind: "cron" as const, expr, tz: form.cronTz.trim() || undefined, staggerMs };
|
||||
}
|
||||
|
||||
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;
|
||||
model?: string;
|
||||
thinking?: string;
|
||||
timeoutSeconds?: number;
|
||||
} = { kind: "agentTurn", message };
|
||||
const model = form.payloadModel.trim();
|
||||
if (model) {
|
||||
payload.model = model;
|
||||
}
|
||||
const thinking = form.payloadThinking.trim();
|
||||
if (thinking) {
|
||||
payload.thinking = thinking;
|
||||
}
|
||||
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 form = normalizeCronFormState(state.cronForm);
|
||||
if (form !== state.cronForm) {
|
||||
state.cronForm = form;
|
||||
}
|
||||
const fieldErrors = validateCronForm(form);
|
||||
state.cronFieldErrors = fieldErrors;
|
||||
if (hasCronFormErrors(fieldErrors)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const schedule = buildCronSchedule(form);
|
||||
const payload = buildCronPayload(form);
|
||||
const selectedDeliveryMode = form.deliveryMode;
|
||||
const delivery =
|
||||
selectedDeliveryMode && selectedDeliveryMode !== "none"
|
||||
? {
|
||||
mode: selectedDeliveryMode,
|
||||
channel:
|
||||
selectedDeliveryMode === "announce"
|
||||
? form.deliveryChannel.trim() || "last"
|
||||
: undefined,
|
||||
to: form.deliveryTo.trim() || undefined,
|
||||
bestEffort: form.deliveryBestEffort,
|
||||
}
|
||||
: undefined;
|
||||
const agentId = form.clearAgent ? null : form.agentId.trim();
|
||||
const job = {
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim(),
|
||||
agentId: agentId === null ? null : agentId || undefined,
|
||||
enabled: form.enabled,
|
||||
deleteAfterRun: form.deleteAfterRun,
|
||||
schedule,
|
||||
sessionTarget: form.sessionTarget,
|
||||
wakeMode: form.wakeMode,
|
||||
payload,
|
||||
delivery,
|
||||
};
|
||||
if (!job.name) {
|
||||
throw new Error("Name required.");
|
||||
}
|
||||
if (state.cronEditingJobId) {
|
||||
await state.client.request("cron.update", {
|
||||
id: state.cronEditingJobId,
|
||||
patch: job,
|
||||
});
|
||||
clearCronEditState(state);
|
||||
} else {
|
||||
await state.client.request("cron.add", job);
|
||||
resetCronFormToDefaults(state);
|
||||
}
|
||||
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" });
|
||||
if (state.cronRunsScope === "all") {
|
||||
await loadCronRuns(state, null);
|
||||
} else {
|
||||
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.cronEditingJobId === job.id) {
|
||||
clearCronEditState(state);
|
||||
}
|
||||
if (state.cronRunsJobId === job.id) {
|
||||
state.cronRunsJobId = null;
|
||||
state.cronRuns = [];
|
||||
state.cronRunsTotal = 0;
|
||||
state.cronRunsHasMore = false;
|
||||
state.cronRunsNextOffset = null;
|
||||
}
|
||||
await loadCronJobs(state);
|
||||
await loadCronStatus(state);
|
||||
} catch (err) {
|
||||
state.cronError = String(err);
|
||||
} finally {
|
||||
state.cronBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadCronRuns(
|
||||
state: CronState,
|
||||
jobId: string | null,
|
||||
opts?: { append?: boolean },
|
||||
) {
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
const scope = state.cronRunsScope;
|
||||
const activeJobId = jobId ?? state.cronRunsJobId;
|
||||
if (scope === "job" && !activeJobId) {
|
||||
state.cronRuns = [];
|
||||
state.cronRunsTotal = 0;
|
||||
state.cronRunsHasMore = false;
|
||||
state.cronRunsNextOffset = null;
|
||||
return;
|
||||
}
|
||||
const append = opts?.append === true;
|
||||
if (append && !state.cronRunsHasMore) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (append) {
|
||||
state.cronRunsLoadingMore = true;
|
||||
}
|
||||
const offset = append ? Math.max(0, state.cronRunsNextOffset ?? state.cronRuns.length) : 0;
|
||||
const res = await state.client.request<CronRunsResult>("cron.runs", {
|
||||
scope,
|
||||
id: scope === "job" ? (activeJobId ?? undefined) : undefined,
|
||||
limit: state.cronRunsLimit,
|
||||
offset,
|
||||
statuses: state.cronRunsStatuses.length > 0 ? state.cronRunsStatuses : undefined,
|
||||
status: state.cronRunsStatusFilter,
|
||||
deliveryStatuses:
|
||||
state.cronRunsDeliveryStatuses.length > 0 ? state.cronRunsDeliveryStatuses : undefined,
|
||||
query: state.cronRunsQuery.trim() || undefined,
|
||||
sortDir: state.cronRunsSortDir,
|
||||
});
|
||||
const entries = Array.isArray(res.entries) ? res.entries : [];
|
||||
state.cronRuns =
|
||||
append && (scope === "all" || state.cronRunsJobId === activeJobId)
|
||||
? [...state.cronRuns, ...entries]
|
||||
: entries;
|
||||
if (scope === "job") {
|
||||
state.cronRunsJobId = activeJobId ?? null;
|
||||
}
|
||||
const meta = normalizeCronPageMeta({
|
||||
totalRaw: res.total,
|
||||
limitRaw: res.limit,
|
||||
offsetRaw: res.offset,
|
||||
nextOffsetRaw: res.nextOffset,
|
||||
hasMoreRaw: res.hasMore,
|
||||
pageCount: entries.length,
|
||||
});
|
||||
state.cronRunsTotal = Math.max(meta.total, state.cronRuns.length);
|
||||
state.cronRunsHasMore = meta.hasMore;
|
||||
state.cronRunsNextOffset = meta.nextOffset;
|
||||
} catch (err) {
|
||||
state.cronError = String(err);
|
||||
} finally {
|
||||
if (append) {
|
||||
state.cronRunsLoadingMore = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadMoreCronRuns(state: CronState) {
|
||||
if (state.cronRunsScope === "job" && !state.cronRunsJobId) {
|
||||
return;
|
||||
}
|
||||
await loadCronRuns(state, state.cronRunsJobId, { append: true });
|
||||
}
|
||||
|
||||
export function updateCronRunsFilter(
|
||||
state: CronState,
|
||||
patch: Partial<
|
||||
Pick<
|
||||
CronState,
|
||||
| "cronRunsScope"
|
||||
| "cronRunsStatuses"
|
||||
| "cronRunsDeliveryStatuses"
|
||||
| "cronRunsStatusFilter"
|
||||
| "cronRunsQuery"
|
||||
| "cronRunsSortDir"
|
||||
>
|
||||
>,
|
||||
) {
|
||||
if (patch.cronRunsScope) {
|
||||
state.cronRunsScope = patch.cronRunsScope;
|
||||
}
|
||||
if (Array.isArray(patch.cronRunsStatuses)) {
|
||||
state.cronRunsStatuses = patch.cronRunsStatuses;
|
||||
state.cronRunsStatusFilter =
|
||||
patch.cronRunsStatuses.length === 1 ? patch.cronRunsStatuses[0] : "all";
|
||||
}
|
||||
if (Array.isArray(patch.cronRunsDeliveryStatuses)) {
|
||||
state.cronRunsDeliveryStatuses = patch.cronRunsDeliveryStatuses;
|
||||
}
|
||||
if (patch.cronRunsStatusFilter) {
|
||||
state.cronRunsStatusFilter = patch.cronRunsStatusFilter;
|
||||
state.cronRunsStatuses =
|
||||
patch.cronRunsStatusFilter === "all" ? [] : [patch.cronRunsStatusFilter];
|
||||
}
|
||||
if (typeof patch.cronRunsQuery === "string") {
|
||||
state.cronRunsQuery = patch.cronRunsQuery;
|
||||
}
|
||||
if (patch.cronRunsSortDir) {
|
||||
state.cronRunsSortDir = patch.cronRunsSortDir;
|
||||
}
|
||||
}
|
||||
|
||||
export function startCronEdit(state: CronState, job: CronJob) {
|
||||
state.cronEditingJobId = job.id;
|
||||
state.cronRunsJobId = job.id;
|
||||
state.cronForm = jobToForm(job, state.cronForm);
|
||||
state.cronFieldErrors = validateCronForm(state.cronForm);
|
||||
}
|
||||
|
||||
function buildCloneName(name: string, existingNames: Set<string>) {
|
||||
const base = name.trim() || "Job";
|
||||
const first = `${base} copy`;
|
||||
if (!existingNames.has(first.toLowerCase())) {
|
||||
return first;
|
||||
}
|
||||
let index = 2;
|
||||
while (index < 1000) {
|
||||
const next = `${base} copy ${index}`;
|
||||
if (!existingNames.has(next.toLowerCase())) {
|
||||
return next;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
return `${base} copy ${Date.now()}`;
|
||||
}
|
||||
|
||||
export function startCronClone(state: CronState, job: CronJob) {
|
||||
clearCronEditState(state);
|
||||
state.cronRunsJobId = job.id;
|
||||
const existingNames = new Set(state.cronJobs.map((entry) => entry.name.trim().toLowerCase()));
|
||||
const cloned = jobToForm(job, state.cronForm);
|
||||
cloned.name = buildCloneName(job.name, existingNames);
|
||||
state.cronForm = cloned;
|
||||
state.cronFieldErrors = validateCronForm(state.cronForm);
|
||||
}
|
||||
|
||||
export function cancelCronEdit(state: CronState) {
|
||||
clearCronEditState(state);
|
||||
resetCronFormToDefaults(state);
|
||||
}
|
||||
60
openclaw/ui/src/ui/controllers/debug.ts
Normal file
60
openclaw/ui/src/ui/controllers/debug.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { GatewayBrowserClient } from "../gateway.ts";
|
||||
import type { HealthSnapshot, StatusSummary } from "../types.ts";
|
||||
|
||||
export type DebugState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
connected: boolean;
|
||||
debugLoading: boolean;
|
||||
debugStatus: StatusSummary | null;
|
||||
debugHealth: HealthSnapshot | null;
|
||||
debugModels: unknown[];
|
||||
debugHeartbeat: unknown;
|
||||
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;
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
159
openclaw/ui/src/ui/controllers/devices.ts
Normal file
159
openclaw/ui/src/ui/controllers/devices.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { clearDeviceAuthToken, storeDeviceAuthToken } from "../device-auth.ts";
|
||||
import { loadOrCreateDeviceIdentity } from "../device-identity.ts";
|
||||
import type { GatewayBrowserClient } from "../gateway.ts";
|
||||
|
||||
export type DeviceTokenSummary = {
|
||||
role: string;
|
||||
scopes?: string[];
|
||||
createdAtMs?: number;
|
||||
rotatedAtMs?: number;
|
||||
revokedAtMs?: number;
|
||||
lastUsedAtMs?: number;
|
||||
};
|
||||
|
||||
export type PendingDevice = {
|
||||
requestId: string;
|
||||
deviceId: string;
|
||||
displayName?: string;
|
||||
role?: string;
|
||||
remoteIp?: string;
|
||||
isRepair?: boolean;
|
||||
ts?: number;
|
||||
};
|
||||
|
||||
export type PairedDevice = {
|
||||
deviceId: string;
|
||||
displayName?: string;
|
||||
roles?: string[];
|
||||
scopes?: string[];
|
||||
remoteIp?: string;
|
||||
tokens?: DeviceTokenSummary[];
|
||||
createdAtMs?: number;
|
||||
approvedAtMs?: number;
|
||||
};
|
||||
|
||||
export type DevicePairingList = {
|
||||
pending: PendingDevice[];
|
||||
paired: PairedDevice[];
|
||||
};
|
||||
|
||||
export type DevicesState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
connected: boolean;
|
||||
devicesLoading: boolean;
|
||||
devicesError: string | null;
|
||||
devicesList: DevicePairingList | null;
|
||||
};
|
||||
|
||||
export async function loadDevices(state: DevicesState, opts?: { quiet?: boolean }) {
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
if (state.devicesLoading) {
|
||||
return;
|
||||
}
|
||||
state.devicesLoading = true;
|
||||
if (!opts?.quiet) {
|
||||
state.devicesError = null;
|
||||
}
|
||||
try {
|
||||
const res = await state.client.request<{
|
||||
pending?: Array<PendingDevice>;
|
||||
paired?: Array<PendingDevice>;
|
||||
}>("device.pair.list", {});
|
||||
state.devicesList = {
|
||||
pending: Array.isArray(res?.pending) ? res.pending : [],
|
||||
paired: Array.isArray(res?.paired) ? res.paired : [],
|
||||
};
|
||||
} catch (err) {
|
||||
if (!opts?.quiet) {
|
||||
state.devicesError = String(err);
|
||||
}
|
||||
} finally {
|
||||
state.devicesLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function approveDevicePairing(state: DevicesState, requestId: string) {
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await state.client.request("device.pair.approve", { requestId });
|
||||
await loadDevices(state);
|
||||
} catch (err) {
|
||||
state.devicesError = String(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function rejectDevicePairing(state: DevicesState, requestId: string) {
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
const confirmed = window.confirm("Reject this device pairing request?");
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await state.client.request("device.pair.reject", { requestId });
|
||||
await loadDevices(state);
|
||||
} catch (err) {
|
||||
state.devicesError = String(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function rotateDeviceToken(
|
||||
state: DevicesState,
|
||||
params: { deviceId: string; role: string; scopes?: string[] },
|
||||
) {
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await state.client.request<{
|
||||
token: string;
|
||||
role?: string;
|
||||
deviceId?: string;
|
||||
scopes?: Array<string>;
|
||||
}>("device.token.rotate", params);
|
||||
if (res?.token) {
|
||||
const identity = await loadOrCreateDeviceIdentity();
|
||||
const role = res.role ?? params.role;
|
||||
if (res.deviceId === identity.deviceId || params.deviceId === identity.deviceId) {
|
||||
storeDeviceAuthToken({
|
||||
deviceId: identity.deviceId,
|
||||
role,
|
||||
token: res.token,
|
||||
scopes: res.scopes ?? params.scopes ?? [],
|
||||
});
|
||||
}
|
||||
window.prompt("New device token (copy and store securely):", res.token);
|
||||
}
|
||||
await loadDevices(state);
|
||||
} catch (err) {
|
||||
state.devicesError = String(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function revokeDeviceToken(
|
||||
state: DevicesState,
|
||||
params: { deviceId: string; role: string },
|
||||
) {
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
const confirmed = window.confirm(`Revoke token for ${params.deviceId} (${params.role})?`);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await state.client.request("device.token.revoke", params);
|
||||
const identity = await loadOrCreateDeviceIdentity();
|
||||
if (params.deviceId === identity.deviceId) {
|
||||
clearDeviceAuthToken({ deviceId: identity.deviceId, role: params.role });
|
||||
}
|
||||
await loadDevices(state);
|
||||
} catch (err) {
|
||||
state.devicesError = String(err);
|
||||
}
|
||||
}
|
||||
100
openclaw/ui/src/ui/controllers/exec-approval.ts
Normal file
100
openclaw/ui/src/ui/controllers/exec-approval.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
export type ExecApprovalRequestPayload = {
|
||||
command: string;
|
||||
cwd?: string | null;
|
||||
host?: string | null;
|
||||
security?: string | null;
|
||||
ask?: string | null;
|
||||
agentId?: string | null;
|
||||
resolvedPath?: string | null;
|
||||
sessionKey?: string | null;
|
||||
};
|
||||
|
||||
export type ExecApprovalRequest = {
|
||||
id: string;
|
||||
request: ExecApprovalRequestPayload;
|
||||
createdAtMs: number;
|
||||
expiresAtMs: number;
|
||||
};
|
||||
|
||||
export type ExecApprovalResolved = {
|
||||
id: string;
|
||||
decision?: string | null;
|
||||
resolvedBy?: string | null;
|
||||
ts?: number | null;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
export function parseExecApprovalRequested(payload: unknown): ExecApprovalRequest | null {
|
||||
if (!isRecord(payload)) {
|
||||
return null;
|
||||
}
|
||||
const id = typeof payload.id === "string" ? payload.id.trim() : "";
|
||||
const request = payload.request;
|
||||
if (!id || !isRecord(request)) {
|
||||
return null;
|
||||
}
|
||||
const command = typeof request.command === "string" ? request.command.trim() : "";
|
||||
if (!command) {
|
||||
return null;
|
||||
}
|
||||
const createdAtMs = typeof payload.createdAtMs === "number" ? payload.createdAtMs : 0;
|
||||
const expiresAtMs = typeof payload.expiresAtMs === "number" ? payload.expiresAtMs : 0;
|
||||
if (!createdAtMs || !expiresAtMs) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id,
|
||||
request: {
|
||||
command,
|
||||
cwd: typeof request.cwd === "string" ? request.cwd : null,
|
||||
host: typeof request.host === "string" ? request.host : null,
|
||||
security: typeof request.security === "string" ? request.security : null,
|
||||
ask: typeof request.ask === "string" ? request.ask : null,
|
||||
agentId: typeof request.agentId === "string" ? request.agentId : null,
|
||||
resolvedPath: typeof request.resolvedPath === "string" ? request.resolvedPath : null,
|
||||
sessionKey: typeof request.sessionKey === "string" ? request.sessionKey : null,
|
||||
},
|
||||
createdAtMs,
|
||||
expiresAtMs,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseExecApprovalResolved(payload: unknown): ExecApprovalResolved | null {
|
||||
if (!isRecord(payload)) {
|
||||
return null;
|
||||
}
|
||||
const id = typeof payload.id === "string" ? payload.id.trim() : "";
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id,
|
||||
decision: typeof payload.decision === "string" ? payload.decision : null,
|
||||
resolvedBy: typeof payload.resolvedBy === "string" ? payload.resolvedBy : null,
|
||||
ts: typeof payload.ts === "number" ? payload.ts : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function pruneExecApprovalQueue(queue: ExecApprovalRequest[]): ExecApprovalRequest[] {
|
||||
const now = Date.now();
|
||||
return queue.filter((entry) => entry.expiresAtMs > now);
|
||||
}
|
||||
|
||||
export function addExecApproval(
|
||||
queue: ExecApprovalRequest[],
|
||||
entry: ExecApprovalRequest,
|
||||
): ExecApprovalRequest[] {
|
||||
const next = pruneExecApprovalQueue(queue).filter((item) => item.id !== entry.id);
|
||||
next.push(entry);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function removeExecApproval(
|
||||
queue: ExecApprovalRequest[],
|
||||
id: string,
|
||||
): ExecApprovalRequest[] {
|
||||
return pruneExecApprovalQueue(queue).filter((entry) => entry.id !== id);
|
||||
}
|
||||
170
openclaw/ui/src/ui/controllers/exec-approvals.ts
Normal file
170
openclaw/ui/src/ui/controllers/exec-approvals.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import type { GatewayBrowserClient } from "../gateway.ts";
|
||||
import { cloneConfigObject, removePathValue, setPathValue } from "./config/form-utils.ts";
|
||||
|
||||
export type ExecApprovalsDefaults = {
|
||||
security?: string;
|
||||
ask?: string;
|
||||
askFallback?: string;
|
||||
autoAllowSkills?: boolean;
|
||||
};
|
||||
|
||||
export type ExecApprovalsAllowlistEntry = {
|
||||
id?: string;
|
||||
pattern: string;
|
||||
lastUsedAt?: number;
|
||||
lastUsedCommand?: string;
|
||||
lastResolvedPath?: string;
|
||||
};
|
||||
|
||||
export type ExecApprovalsAgent = ExecApprovalsDefaults & {
|
||||
allowlist?: ExecApprovalsAllowlistEntry[];
|
||||
};
|
||||
|
||||
export type ExecApprovalsFile = {
|
||||
version?: number;
|
||||
socket?: { path?: string };
|
||||
defaults?: ExecApprovalsDefaults;
|
||||
agents?: Record<string, ExecApprovalsAgent>;
|
||||
};
|
||||
|
||||
export type ExecApprovalsSnapshot = {
|
||||
path: string;
|
||||
exists: boolean;
|
||||
hash: string;
|
||||
file: ExecApprovalsFile;
|
||||
};
|
||||
|
||||
export type ExecApprovalsTarget = { kind: "gateway" } | { kind: "node"; nodeId: string };
|
||||
|
||||
export type ExecApprovalsState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
connected: boolean;
|
||||
execApprovalsLoading: boolean;
|
||||
execApprovalsSaving: boolean;
|
||||
execApprovalsDirty: boolean;
|
||||
execApprovalsSnapshot: ExecApprovalsSnapshot | null;
|
||||
execApprovalsForm: ExecApprovalsFile | null;
|
||||
execApprovalsSelectedAgent: string | null;
|
||||
lastError: string | null;
|
||||
};
|
||||
|
||||
function resolveExecApprovalsRpc(target?: ExecApprovalsTarget | null): {
|
||||
method: string;
|
||||
params: Record<string, unknown>;
|
||||
} | null {
|
||||
if (!target || target.kind === "gateway") {
|
||||
return { method: "exec.approvals.get", params: {} };
|
||||
}
|
||||
const nodeId = target.nodeId.trim();
|
||||
if (!nodeId) {
|
||||
return null;
|
||||
}
|
||||
return { method: "exec.approvals.node.get", params: { nodeId } };
|
||||
}
|
||||
|
||||
function resolveExecApprovalsSaveRpc(
|
||||
target: ExecApprovalsTarget | null | undefined,
|
||||
params: { file: ExecApprovalsFile; baseHash: string },
|
||||
): { method: string; params: Record<string, unknown> } | null {
|
||||
if (!target || target.kind === "gateway") {
|
||||
return { method: "exec.approvals.set", params };
|
||||
}
|
||||
const nodeId = target.nodeId.trim();
|
||||
if (!nodeId) {
|
||||
return null;
|
||||
}
|
||||
return { method: "exec.approvals.node.set", params: { ...params, nodeId } };
|
||||
}
|
||||
|
||||
export async function loadExecApprovals(
|
||||
state: ExecApprovalsState,
|
||||
target?: ExecApprovalsTarget | null,
|
||||
) {
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
if (state.execApprovalsLoading) {
|
||||
return;
|
||||
}
|
||||
state.execApprovalsLoading = true;
|
||||
state.lastError = null;
|
||||
try {
|
||||
const rpc = resolveExecApprovalsRpc(target);
|
||||
if (!rpc) {
|
||||
state.lastError = "Select a node before loading exec approvals.";
|
||||
return;
|
||||
}
|
||||
const res = await state.client.request<ExecApprovalsSnapshot>(rpc.method, rpc.params);
|
||||
applyExecApprovalsSnapshot(state, res);
|
||||
} catch (err) {
|
||||
state.lastError = String(err);
|
||||
} finally {
|
||||
state.execApprovalsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function applyExecApprovalsSnapshot(
|
||||
state: ExecApprovalsState,
|
||||
snapshot: ExecApprovalsSnapshot,
|
||||
) {
|
||||
state.execApprovalsSnapshot = snapshot;
|
||||
if (!state.execApprovalsDirty) {
|
||||
state.execApprovalsForm = cloneConfigObject(snapshot.file ?? {});
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveExecApprovals(
|
||||
state: ExecApprovalsState,
|
||||
target?: ExecApprovalsTarget | null,
|
||||
) {
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
state.execApprovalsSaving = true;
|
||||
state.lastError = null;
|
||||
try {
|
||||
const baseHash = state.execApprovalsSnapshot?.hash;
|
||||
if (!baseHash) {
|
||||
state.lastError = "Exec approvals hash missing; reload and retry.";
|
||||
return;
|
||||
}
|
||||
const file = state.execApprovalsForm ?? state.execApprovalsSnapshot?.file ?? {};
|
||||
const rpc = resolveExecApprovalsSaveRpc(target, { file, baseHash });
|
||||
if (!rpc) {
|
||||
state.lastError = "Select a node before saving exec approvals.";
|
||||
return;
|
||||
}
|
||||
await state.client.request(rpc.method, rpc.params);
|
||||
state.execApprovalsDirty = false;
|
||||
await loadExecApprovals(state, target);
|
||||
} catch (err) {
|
||||
state.lastError = String(err);
|
||||
} finally {
|
||||
state.execApprovalsSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function updateExecApprovalsFormValue(
|
||||
state: ExecApprovalsState,
|
||||
path: Array<string | number>,
|
||||
value: unknown,
|
||||
) {
|
||||
const base = cloneConfigObject(
|
||||
state.execApprovalsForm ?? state.execApprovalsSnapshot?.file ?? {},
|
||||
);
|
||||
setPathValue(base, path, value);
|
||||
state.execApprovalsForm = base;
|
||||
state.execApprovalsDirty = true;
|
||||
}
|
||||
|
||||
export function removeExecApprovalsFormValue(
|
||||
state: ExecApprovalsState,
|
||||
path: Array<string | number>,
|
||||
) {
|
||||
const base = cloneConfigObject(
|
||||
state.execApprovalsForm ?? state.execApprovalsSnapshot?.file ?? {},
|
||||
);
|
||||
removePathValue(base, path);
|
||||
state.execApprovalsForm = base;
|
||||
state.execApprovalsDirty = true;
|
||||
}
|
||||
147
openclaw/ui/src/ui/controllers/logs.ts
Normal file
147
openclaw/ui/src/ui/controllers/logs.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import type { GatewayBrowserClient } from "../gateway.ts";
|
||||
import type { LogEntry, LogLevel } from "../types.ts";
|
||||
|
||||
export type LogsState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
connected: boolean;
|
||||
logsLoading: boolean;
|
||||
logsError: string | null;
|
||||
logsCursor: number | null;
|
||||
logsFile: string | null;
|
||||
logsEntries: LogEntry[];
|
||||
logsTruncated: boolean;
|
||||
logsLastFetchAt: number | null;
|
||||
logsLimit: number;
|
||||
logsMaxBytes: number;
|
||||
};
|
||||
|
||||
const LOG_BUFFER_LIMIT = 2000;
|
||||
const LEVELS = new Set<LogLevel>(["trace", "debug", "info", "warn", "error", "fatal"]);
|
||||
|
||||
function parseMaybeJsonString(value: unknown) {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as unknown;
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
return null;
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeLevel(value: unknown): LogLevel | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const lowered = value.toLowerCase() as LogLevel;
|
||||
return LEVELS.has(lowered) ? lowered : null;
|
||||
}
|
||||
|
||||
export function parseLogLine(line: string): LogEntry {
|
||||
if (!line.trim()) {
|
||||
return { raw: line, message: line };
|
||||
}
|
||||
try {
|
||||
const obj = JSON.parse(line) as Record<string, unknown>;
|
||||
const meta =
|
||||
obj && typeof obj._meta === "object" && obj._meta !== null
|
||||
? (obj._meta as Record<string, unknown>)
|
||||
: null;
|
||||
const time =
|
||||
typeof obj.time === "string" ? obj.time : typeof meta?.date === "string" ? meta?.date : null;
|
||||
const level = normalizeLevel(meta?.logLevelName ?? meta?.level);
|
||||
|
||||
const contextCandidate =
|
||||
typeof obj["0"] === "string" ? obj["0"] : typeof meta?.name === "string" ? meta?.name : null;
|
||||
const contextObj = parseMaybeJsonString(contextCandidate);
|
||||
let subsystem: string | null = null;
|
||||
if (contextObj) {
|
||||
if (typeof contextObj.subsystem === "string") {
|
||||
subsystem = contextObj.subsystem;
|
||||
} else if (typeof contextObj.module === "string") {
|
||||
subsystem = contextObj.module;
|
||||
}
|
||||
}
|
||||
if (!subsystem && contextCandidate && contextCandidate.length < 120) {
|
||||
subsystem = contextCandidate;
|
||||
}
|
||||
|
||||
let message: string | null = null;
|
||||
if (typeof obj["1"] === "string") {
|
||||
message = obj["1"];
|
||||
} else if (!contextObj && typeof obj["0"] === "string") {
|
||||
message = obj["0"];
|
||||
} else if (typeof obj.message === "string") {
|
||||
message = obj.message;
|
||||
}
|
||||
|
||||
return {
|
||||
raw: line,
|
||||
time,
|
||||
level,
|
||||
subsystem,
|
||||
message: message ?? line,
|
||||
meta: meta ?? undefined,
|
||||
};
|
||||
} catch {
|
||||
return { raw: line, message: line };
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadLogs(state: LogsState, opts?: { reset?: boolean; quiet?: boolean }) {
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
if (state.logsLoading && !opts?.quiet) {
|
||||
return;
|
||||
}
|
||||
if (!opts?.quiet) {
|
||||
state.logsLoading = true;
|
||||
}
|
||||
state.logsError = null;
|
||||
try {
|
||||
const res = await state.client.request("logs.tail", {
|
||||
cursor: opts?.reset ? undefined : (state.logsCursor ?? undefined),
|
||||
limit: state.logsLimit,
|
||||
maxBytes: state.logsMaxBytes,
|
||||
});
|
||||
const payload = res as {
|
||||
file?: string;
|
||||
cursor?: number;
|
||||
size?: number;
|
||||
lines?: unknown;
|
||||
truncated?: boolean;
|
||||
reset?: boolean;
|
||||
};
|
||||
const lines = Array.isArray(payload.lines)
|
||||
? payload.lines.filter((line) => typeof line === "string")
|
||||
: [];
|
||||
const entries = lines.map(parseLogLine);
|
||||
const shouldReset = Boolean(opts?.reset || payload.reset || state.logsCursor == null);
|
||||
state.logsEntries = shouldReset
|
||||
? entries
|
||||
: [...state.logsEntries, ...entries].slice(-LOG_BUFFER_LIMIT);
|
||||
if (typeof payload.cursor === "number") {
|
||||
state.logsCursor = payload.cursor;
|
||||
}
|
||||
if (typeof payload.file === "string") {
|
||||
state.logsFile = payload.file;
|
||||
}
|
||||
state.logsTruncated = Boolean(payload.truncated);
|
||||
state.logsLastFetchAt = Date.now();
|
||||
} catch (err) {
|
||||
state.logsError = String(err);
|
||||
} finally {
|
||||
if (!opts?.quiet) {
|
||||
state.logsLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
32
openclaw/ui/src/ui/controllers/nodes.ts
Normal file
32
openclaw/ui/src/ui/controllers/nodes.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { GatewayBrowserClient } from "../gateway.ts";
|
||||
|
||||
export type NodesState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
connected: boolean;
|
||||
nodesLoading: boolean;
|
||||
nodes: Array<Record<string, unknown>>;
|
||||
lastError: string | null;
|
||||
};
|
||||
|
||||
export async function loadNodes(state: NodesState, opts?: { quiet?: boolean }) {
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
if (state.nodesLoading) {
|
||||
return;
|
||||
}
|
||||
state.nodesLoading = true;
|
||||
if (!opts?.quiet) {
|
||||
state.lastError = null;
|
||||
}
|
||||
try {
|
||||
const res = await state.client.request<{ nodes?: Record<string, unknown> }>("node.list", {});
|
||||
state.nodes = Array.isArray(res.nodes) ? res.nodes : [];
|
||||
} catch (err) {
|
||||
if (!opts?.quiet) {
|
||||
state.lastError = String(err);
|
||||
}
|
||||
} finally {
|
||||
state.nodesLoading = false;
|
||||
}
|
||||
}
|
||||
37
openclaw/ui/src/ui/controllers/presence.ts
Normal file
37
openclaw/ui/src/ui/controllers/presence.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { GatewayBrowserClient } from "../gateway.ts";
|
||||
import type { PresenceEntry } from "../types.ts";
|
||||
|
||||
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", {});
|
||||
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;
|
||||
}
|
||||
}
|
||||
104
openclaw/ui/src/ui/controllers/sessions.test.ts
Normal file
104
openclaw/ui/src/ui/controllers/sessions.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { deleteSession, deleteSessionAndRefresh, type SessionsState } from "./sessions.ts";
|
||||
|
||||
type RequestFn = (method: string, params?: unknown) => Promise<unknown>;
|
||||
|
||||
function createState(request: RequestFn, overrides: Partial<SessionsState> = {}): SessionsState {
|
||||
return {
|
||||
client: { request } as unknown as SessionsState["client"],
|
||||
connected: true,
|
||||
sessionsLoading: false,
|
||||
sessionsResult: null,
|
||||
sessionsError: null,
|
||||
sessionsFilterActive: "0",
|
||||
sessionsFilterLimit: "0",
|
||||
sessionsIncludeGlobal: true,
|
||||
sessionsIncludeUnknown: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("deleteSessionAndRefresh", () => {
|
||||
it("refreshes sessions after a successful delete", async () => {
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "sessions.delete") {
|
||||
return { ok: true };
|
||||
}
|
||||
if (method === "sessions.list") {
|
||||
return undefined;
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
const state = createState(request);
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
|
||||
const deleted = await deleteSessionAndRefresh(state, "agent:main:test");
|
||||
|
||||
expect(deleted).toBe(true);
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
expect(request).toHaveBeenNthCalledWith(1, "sessions.delete", {
|
||||
key: "agent:main:test",
|
||||
deleteTranscript: true,
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(2, "sessions.list", {
|
||||
includeGlobal: true,
|
||||
includeUnknown: true,
|
||||
});
|
||||
expect(state.sessionsError).toBeNull();
|
||||
expect(state.sessionsLoading).toBe(false);
|
||||
});
|
||||
|
||||
it("does not refresh sessions when user cancels delete", async () => {
|
||||
const request = vi.fn(async () => undefined);
|
||||
const state = createState(request, { sessionsError: "existing error" });
|
||||
vi.spyOn(window, "confirm").mockReturnValue(false);
|
||||
|
||||
const deleted = await deleteSessionAndRefresh(state, "agent:main:test");
|
||||
|
||||
expect(deleted).toBe(false);
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
expect(state.sessionsError).toBe("existing error");
|
||||
expect(state.sessionsLoading).toBe(false);
|
||||
});
|
||||
|
||||
it("does not refresh sessions when delete fails and preserves the delete error", async () => {
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "sessions.delete") {
|
||||
throw new Error("delete boom");
|
||||
}
|
||||
if (method === "sessions.list") {
|
||||
return undefined;
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
const state = createState(request);
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
|
||||
const deleted = await deleteSessionAndRefresh(state, "agent:main:test");
|
||||
|
||||
expect(deleted).toBe(false);
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
expect(request).toHaveBeenCalledWith("sessions.delete", {
|
||||
key: "agent:main:test",
|
||||
deleteTranscript: true,
|
||||
});
|
||||
expect(state.sessionsError).toContain("delete boom");
|
||||
expect(state.sessionsLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteSession", () => {
|
||||
it("returns false when already loading", async () => {
|
||||
const request = vi.fn(async () => undefined);
|
||||
const state = createState(request, { sessionsLoading: true });
|
||||
|
||||
const deleted = await deleteSession(state, "agent:main:test");
|
||||
|
||||
expect(deleted).toBe(false);
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
127
openclaw/ui/src/ui/controllers/sessions.ts
Normal file
127
openclaw/ui/src/ui/controllers/sessions.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { toNumber } from "../format.ts";
|
||||
import type { GatewayBrowserClient } from "../gateway.ts";
|
||||
import type { SessionsListResult } from "../types.ts";
|
||||
|
||||
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,
|
||||
overrides?: {
|
||||
activeMinutes?: number;
|
||||
limit?: number;
|
||||
includeGlobal?: boolean;
|
||||
includeUnknown?: boolean;
|
||||
},
|
||||
) {
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
if (state.sessionsLoading) {
|
||||
return;
|
||||
}
|
||||
state.sessionsLoading = true;
|
||||
state.sessionsError = null;
|
||||
try {
|
||||
const includeGlobal = overrides?.includeGlobal ?? state.sessionsIncludeGlobal;
|
||||
const includeUnknown = overrides?.includeUnknown ?? state.sessionsIncludeUnknown;
|
||||
const activeMinutes = overrides?.activeMinutes ?? toNumber(state.sessionsFilterActive, 0);
|
||||
const limit = overrides?.limit ?? toNumber(state.sessionsFilterLimit, 0);
|
||||
const params: Record<string, unknown> = {
|
||||
includeGlobal,
|
||||
includeUnknown,
|
||||
};
|
||||
if (activeMinutes > 0) {
|
||||
params.activeMinutes = activeMinutes;
|
||||
}
|
||||
if (limit > 0) {
|
||||
params.limit = limit;
|
||||
}
|
||||
const res = await state.client.request<SessionsListResult | undefined>("sessions.list", params);
|
||||
if (res) {
|
||||
state.sessionsResult = res;
|
||||
}
|
||||
} catch (err) {
|
||||
state.sessionsError = String(err);
|
||||
} finally {
|
||||
state.sessionsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function patchSession(
|
||||
state: SessionsState,
|
||||
key: string,
|
||||
patch: {
|
||||
label?: string | null;
|
||||
thinkingLevel?: string | null;
|
||||
verboseLevel?: string | null;
|
||||
reasoningLevel?: string | null;
|
||||
},
|
||||
) {
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
const params: Record<string, unknown> = { key };
|
||||
if ("label" in patch) {
|
||||
params.label = patch.label;
|
||||
}
|
||||
if ("thinkingLevel" in patch) {
|
||||
params.thinkingLevel = patch.thinkingLevel;
|
||||
}
|
||||
if ("verboseLevel" in patch) {
|
||||
params.verboseLevel = patch.verboseLevel;
|
||||
}
|
||||
if ("reasoningLevel" in patch) {
|
||||
params.reasoningLevel = patch.reasoningLevel;
|
||||
}
|
||||
try {
|
||||
await state.client.request("sessions.patch", params);
|
||||
await loadSessions(state);
|
||||
} catch (err) {
|
||||
state.sessionsError = String(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteSession(state: SessionsState, key: string): Promise<boolean> {
|
||||
if (!state.client || !state.connected) {
|
||||
return false;
|
||||
}
|
||||
if (state.sessionsLoading) {
|
||||
return false;
|
||||
}
|
||||
const confirmed = window.confirm(
|
||||
`Delete session "${key}"?\n\nDeletes the session entry and archives its transcript.`,
|
||||
);
|
||||
if (!confirmed) {
|
||||
return false;
|
||||
}
|
||||
state.sessionsLoading = true;
|
||||
state.sessionsError = null;
|
||||
try {
|
||||
await state.client.request("sessions.delete", { key, deleteTranscript: true });
|
||||
return true;
|
||||
} catch (err) {
|
||||
state.sessionsError = String(err);
|
||||
return false;
|
||||
} finally {
|
||||
state.sessionsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteSessionAndRefresh(state: SessionsState, key: string): Promise<boolean> {
|
||||
const deleted = await deleteSession(state, key);
|
||||
if (!deleted) {
|
||||
return false;
|
||||
}
|
||||
await loadSessions(state);
|
||||
return true;
|
||||
}
|
||||
157
openclaw/ui/src/ui/controllers/skills.ts
Normal file
157
openclaw/ui/src/ui/controllers/skills.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import type { GatewayBrowserClient } from "../gateway.ts";
|
||||
import type { SkillStatusReport } from "../types.ts";
|
||||
|
||||
export type SkillsState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
connected: boolean;
|
||||
skillsLoading: boolean;
|
||||
skillsReport: SkillStatusReport | null;
|
||||
skillsError: string | null;
|
||||
skillsBusyKey: string | null;
|
||||
skillEdits: Record<string, string>;
|
||||
skillMessages: SkillMessageMap;
|
||||
};
|
||||
|
||||
export type SkillMessage = {
|
||||
kind: "success" | "error";
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type SkillMessageMap = Record<string, SkillMessage>;
|
||||
|
||||
type LoadSkillsOptions = {
|
||||
clearMessages?: boolean;
|
||||
};
|
||||
|
||||
function setSkillMessage(state: SkillsState, key: string, message?: SkillMessage) {
|
||||
if (!key.trim()) {
|
||||
return;
|
||||
}
|
||||
const next = { ...state.skillMessages };
|
||||
if (message) {
|
||||
next[key] = message;
|
||||
} else {
|
||||
delete next[key];
|
||||
}
|
||||
state.skillMessages = next;
|
||||
}
|
||||
|
||||
function getErrorMessage(err: unknown) {
|
||||
if (err instanceof Error) {
|
||||
return err.message;
|
||||
}
|
||||
return String(err);
|
||||
}
|
||||
|
||||
export async function loadSkills(state: SkillsState, options?: LoadSkillsOptions) {
|
||||
if (options?.clearMessages && Object.keys(state.skillMessages).length > 0) {
|
||||
state.skillMessages = {};
|
||||
}
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
if (state.skillsLoading) {
|
||||
return;
|
||||
}
|
||||
state.skillsLoading = true;
|
||||
state.skillsError = null;
|
||||
try {
|
||||
const res = await state.client.request<SkillStatusReport | undefined>("skills.status", {});
|
||||
if (res) {
|
||||
state.skillsReport = res;
|
||||
}
|
||||
} catch (err) {
|
||||
state.skillsError = getErrorMessage(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);
|
||||
setSkillMessage(state, skillKey, {
|
||||
kind: "success",
|
||||
message: enabled ? "Skill enabled" : "Skill disabled",
|
||||
});
|
||||
} catch (err) {
|
||||
const message = getErrorMessage(err);
|
||||
state.skillsError = message;
|
||||
setSkillMessage(state, skillKey, {
|
||||
kind: "error",
|
||||
message,
|
||||
});
|
||||
} 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);
|
||||
setSkillMessage(state, skillKey, {
|
||||
kind: "success",
|
||||
message: "API key saved",
|
||||
});
|
||||
} catch (err) {
|
||||
const message = getErrorMessage(err);
|
||||
state.skillsError = message;
|
||||
setSkillMessage(state, skillKey, {
|
||||
kind: "error",
|
||||
message,
|
||||
});
|
||||
} finally {
|
||||
state.skillsBusyKey = null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function installSkill(
|
||||
state: SkillsState,
|
||||
skillKey: string,
|
||||
name: string,
|
||||
installId: string,
|
||||
) {
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
state.skillsBusyKey = skillKey;
|
||||
state.skillsError = null;
|
||||
try {
|
||||
const result = await state.client.request<{ message?: string }>("skills.install", {
|
||||
name,
|
||||
installId,
|
||||
timeoutMs: 120000,
|
||||
});
|
||||
await loadSkills(state);
|
||||
setSkillMessage(state, skillKey, {
|
||||
kind: "success",
|
||||
message: result?.message ?? "Installed",
|
||||
});
|
||||
} catch (err) {
|
||||
const message = getErrorMessage(err);
|
||||
state.skillsError = message;
|
||||
setSkillMessage(state, skillKey, {
|
||||
kind: "error",
|
||||
message,
|
||||
});
|
||||
} finally {
|
||||
state.skillsBusyKey = null;
|
||||
}
|
||||
}
|
||||
190
openclaw/ui/src/ui/controllers/usage.node.test.ts
Normal file
190
openclaw/ui/src/ui/controllers/usage.node.test.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { __test, loadUsage, type UsageState } from "./usage.ts";
|
||||
|
||||
type RequestFn = (method: string, params?: unknown) => Promise<unknown>;
|
||||
|
||||
function createState(request: RequestFn, overrides: Partial<UsageState> = {}): UsageState {
|
||||
return {
|
||||
client: { request } as unknown as UsageState["client"],
|
||||
connected: true,
|
||||
usageLoading: false,
|
||||
usageResult: null,
|
||||
usageCostSummary: null,
|
||||
usageError: null,
|
||||
usageStartDate: "2026-02-16",
|
||||
usageEndDate: "2026-02-16",
|
||||
usageSelectedSessions: [],
|
||||
usageSelectedDays: [],
|
||||
usageTimeSeries: null,
|
||||
usageTimeSeriesLoading: false,
|
||||
usageTimeSeriesCursorStart: null,
|
||||
usageTimeSeriesCursorEnd: null,
|
||||
usageSessionLogs: null,
|
||||
usageSessionLogsLoading: false,
|
||||
usageTimeZone: "local",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("usage controller date interpretation params", () => {
|
||||
beforeEach(() => {
|
||||
__test.resetLegacyUsageDateParamsCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("formats UTC offsets for whole and half-hour timezones", () => {
|
||||
expect(__test.formatUtcOffset(240)).toBe("UTC-4");
|
||||
expect(__test.formatUtcOffset(-330)).toBe("UTC+5:30");
|
||||
expect(__test.formatUtcOffset(0)).toBe("UTC+0");
|
||||
});
|
||||
|
||||
it("sends specific mode with browser offset when usage timezone is local", async () => {
|
||||
const request = vi.fn(async () => ({}));
|
||||
const state = createState(request, { usageTimeZone: "local" });
|
||||
vi.spyOn(Date.prototype, "getTimezoneOffset").mockReturnValue(-330);
|
||||
|
||||
await loadUsage(state);
|
||||
|
||||
expect(request).toHaveBeenNthCalledWith(1, "sessions.usage", {
|
||||
startDate: "2026-02-16",
|
||||
endDate: "2026-02-16",
|
||||
mode: "specific",
|
||||
utcOffset: "UTC+5:30",
|
||||
limit: 1000,
|
||||
includeContextWeight: true,
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(2, "usage.cost", {
|
||||
startDate: "2026-02-16",
|
||||
endDate: "2026-02-16",
|
||||
mode: "specific",
|
||||
utcOffset: "UTC+5:30",
|
||||
});
|
||||
});
|
||||
|
||||
it("sends utc mode without offset when usage timezone is utc", async () => {
|
||||
const request = vi.fn(async () => ({}));
|
||||
const state = createState(request, { usageTimeZone: "utc" });
|
||||
|
||||
await loadUsage(state);
|
||||
|
||||
expect(request).toHaveBeenNthCalledWith(1, "sessions.usage", {
|
||||
startDate: "2026-02-16",
|
||||
endDate: "2026-02-16",
|
||||
mode: "utc",
|
||||
limit: 1000,
|
||||
includeContextWeight: true,
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(2, "usage.cost", {
|
||||
startDate: "2026-02-16",
|
||||
endDate: "2026-02-16",
|
||||
mode: "utc",
|
||||
});
|
||||
});
|
||||
|
||||
it("captures useful error strings in loadUsage", async () => {
|
||||
const request = vi.fn(async () => {
|
||||
throw new Error("request failed");
|
||||
});
|
||||
const state = createState(request);
|
||||
|
||||
await loadUsage(state);
|
||||
|
||||
expect(state.usageError).toBe("request failed");
|
||||
});
|
||||
|
||||
it("serializes non-Error objects without object-to-string coercion", () => {
|
||||
expect(__test.toErrorMessage({ reason: "nope" })).toBe('{"reason":"nope"}');
|
||||
});
|
||||
|
||||
it("falls back and remembers compatibility when sessions.usage rejects mode/utcOffset", async () => {
|
||||
const storage = createStorageMock();
|
||||
vi.stubGlobal("localStorage", storage as unknown as Storage);
|
||||
vi.spyOn(Date.prototype, "getTimezoneOffset").mockReturnValue(-330);
|
||||
|
||||
const request = vi.fn(async (method: string, params?: unknown) => {
|
||||
if (method === "sessions.usage") {
|
||||
const record = (params ?? {}) as Record<string, unknown>;
|
||||
if ("mode" in record || "utcOffset" in record) {
|
||||
throw new Error(
|
||||
"invalid sessions.usage params: at root: unexpected property 'mode'; at root: unexpected property 'utcOffset'",
|
||||
);
|
||||
}
|
||||
return { sessions: [] };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const state = createState(request, {
|
||||
usageTimeZone: "local",
|
||||
settings: { gatewayUrl: "ws://127.0.0.1:18789" },
|
||||
});
|
||||
|
||||
await loadUsage(state);
|
||||
|
||||
expect(request).toHaveBeenNthCalledWith(1, "sessions.usage", {
|
||||
startDate: "2026-02-16",
|
||||
endDate: "2026-02-16",
|
||||
mode: "specific",
|
||||
utcOffset: "UTC+5:30",
|
||||
limit: 1000,
|
||||
includeContextWeight: true,
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(2, "usage.cost", {
|
||||
startDate: "2026-02-16",
|
||||
endDate: "2026-02-16",
|
||||
mode: "specific",
|
||||
utcOffset: "UTC+5:30",
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(3, "sessions.usage", {
|
||||
startDate: "2026-02-16",
|
||||
endDate: "2026-02-16",
|
||||
limit: 1000,
|
||||
includeContextWeight: true,
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(4, "usage.cost", {
|
||||
startDate: "2026-02-16",
|
||||
endDate: "2026-02-16",
|
||||
});
|
||||
|
||||
// Subsequent loads for the same gateway should skip mode/utcOffset immediately.
|
||||
await loadUsage(state);
|
||||
|
||||
expect(request).toHaveBeenNthCalledWith(5, "sessions.usage", {
|
||||
startDate: "2026-02-16",
|
||||
endDate: "2026-02-16",
|
||||
limit: 1000,
|
||||
includeContextWeight: true,
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(6, "usage.cost", {
|
||||
startDate: "2026-02-16",
|
||||
endDate: "2026-02-16",
|
||||
});
|
||||
|
||||
// Persisted flag should survive cache resets (simulating app reload).
|
||||
__test.resetLegacyUsageDateParamsCache();
|
||||
expect(__test.shouldSendLegacyDateInterpretation(state)).toBe(false);
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
|
||||
function createStorageMock() {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
getItem(key: string) {
|
||||
return store.get(key) ?? null;
|
||||
},
|
||||
setItem(key: string, value: string) {
|
||||
store.set(key, String(value));
|
||||
},
|
||||
removeItem(key: string) {
|
||||
store.delete(key);
|
||||
},
|
||||
clear() {
|
||||
store.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
315
openclaw/ui/src/ui/controllers/usage.ts
Normal file
315
openclaw/ui/src/ui/controllers/usage.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
import type { GatewayBrowserClient } from "../gateway.ts";
|
||||
import type { SessionsUsageResult, CostUsageSummary, SessionUsageTimeSeries } from "../types.ts";
|
||||
import type { SessionLogEntry } from "../views/usage.ts";
|
||||
|
||||
export type UsageState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
connected: boolean;
|
||||
usageLoading: boolean;
|
||||
usageResult: SessionsUsageResult | null;
|
||||
usageCostSummary: CostUsageSummary | null;
|
||||
usageError: string | null;
|
||||
usageStartDate: string;
|
||||
usageEndDate: string;
|
||||
usageSelectedSessions: string[];
|
||||
usageSelectedDays: string[];
|
||||
usageTimeSeries: SessionUsageTimeSeries | null;
|
||||
usageTimeSeriesLoading: boolean;
|
||||
usageTimeSeriesCursorStart: number | null;
|
||||
usageTimeSeriesCursorEnd: number | null;
|
||||
usageSessionLogs: SessionLogEntry[] | null;
|
||||
usageSessionLogsLoading: boolean;
|
||||
usageTimeZone: "local" | "utc";
|
||||
settings?: { gatewayUrl?: string };
|
||||
};
|
||||
|
||||
type DateInterpretationMode = "utc" | "gateway" | "specific";
|
||||
|
||||
type UsageDateInterpretationParams = {
|
||||
mode: DateInterpretationMode;
|
||||
utcOffset?: string;
|
||||
};
|
||||
|
||||
const LEGACY_USAGE_DATE_PARAMS_STORAGE_KEY = "openclaw.control.usage.date-params.v1";
|
||||
const LEGACY_USAGE_DATE_PARAMS_DEFAULT_GATEWAY_KEY = "__default__";
|
||||
const LEGACY_USAGE_DATE_PARAMS_MODE_RE = /unexpected property ['"]mode['"]/i;
|
||||
const LEGACY_USAGE_DATE_PARAMS_OFFSET_RE = /unexpected property ['"]utcoffset['"]/i;
|
||||
const LEGACY_USAGE_DATE_PARAMS_INVALID_RE = /invalid sessions\.usage params/i;
|
||||
|
||||
let legacyUsageDateParamsCache: Set<string> | null = null;
|
||||
|
||||
function getLocalStorage(): Storage | null {
|
||||
// Support browser runtime and node tests (when localStorage is stubbed globally).
|
||||
if (typeof window !== "undefined" && window.localStorage) {
|
||||
return window.localStorage;
|
||||
}
|
||||
if (typeof localStorage !== "undefined") {
|
||||
return localStorage;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function loadLegacyUsageDateParamsCache(): Set<string> {
|
||||
const storage = getLocalStorage();
|
||||
if (!storage) {
|
||||
return new Set<string>();
|
||||
}
|
||||
try {
|
||||
const raw = storage.getItem(LEGACY_USAGE_DATE_PARAMS_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return new Set<string>();
|
||||
}
|
||||
const parsed = JSON.parse(raw) as { unsupportedGatewayKeys?: unknown } | null;
|
||||
if (!parsed || !Array.isArray(parsed.unsupportedGatewayKeys)) {
|
||||
return new Set<string>();
|
||||
}
|
||||
return new Set(
|
||||
parsed.unsupportedGatewayKeys
|
||||
.filter((entry): entry is string => typeof entry === "string")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
} catch {
|
||||
return new Set<string>();
|
||||
}
|
||||
}
|
||||
|
||||
function persistLegacyUsageDateParamsCache(cache: Set<string>) {
|
||||
const storage = getLocalStorage();
|
||||
if (!storage) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
storage.setItem(
|
||||
LEGACY_USAGE_DATE_PARAMS_STORAGE_KEY,
|
||||
JSON.stringify({ unsupportedGatewayKeys: Array.from(cache) }),
|
||||
);
|
||||
} catch {
|
||||
// ignore quota/private-mode failures
|
||||
}
|
||||
}
|
||||
|
||||
function getLegacyUsageDateParamsCache(): Set<string> {
|
||||
if (!legacyUsageDateParamsCache) {
|
||||
legacyUsageDateParamsCache = loadLegacyUsageDateParamsCache();
|
||||
}
|
||||
return legacyUsageDateParamsCache;
|
||||
}
|
||||
|
||||
function normalizeGatewayCompatibilityKey(gatewayUrl?: string): string {
|
||||
const trimmed = gatewayUrl?.trim();
|
||||
if (!trimmed) {
|
||||
return LEGACY_USAGE_DATE_PARAMS_DEFAULT_GATEWAY_KEY;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
const pathname = parsed.pathname === "/" ? "" : parsed.pathname;
|
||||
return `${parsed.protocol}//${parsed.host}${pathname}`.toLowerCase();
|
||||
} catch {
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
function resolveGatewayCompatibilityKey(state: UsageState): string {
|
||||
return normalizeGatewayCompatibilityKey(state.settings?.gatewayUrl);
|
||||
}
|
||||
|
||||
function shouldSendLegacyDateInterpretation(state: UsageState): boolean {
|
||||
return !getLegacyUsageDateParamsCache().has(resolveGatewayCompatibilityKey(state));
|
||||
}
|
||||
|
||||
function rememberLegacyDateInterpretation(state: UsageState) {
|
||||
const cache = getLegacyUsageDateParamsCache();
|
||||
cache.add(resolveGatewayCompatibilityKey(state));
|
||||
persistLegacyUsageDateParamsCache(cache);
|
||||
}
|
||||
|
||||
function isLegacyDateInterpretationUnsupportedError(err: unknown): boolean {
|
||||
const message = toErrorMessage(err);
|
||||
return (
|
||||
LEGACY_USAGE_DATE_PARAMS_INVALID_RE.test(message) &&
|
||||
(LEGACY_USAGE_DATE_PARAMS_MODE_RE.test(message) ||
|
||||
LEGACY_USAGE_DATE_PARAMS_OFFSET_RE.test(message))
|
||||
);
|
||||
}
|
||||
|
||||
const formatUtcOffset = (timezoneOffsetMinutes: number): string => {
|
||||
// `Date#getTimezoneOffset()` is minutes to add to local time to reach UTC.
|
||||
// Convert to UTC±H[:MM] where positive means east of UTC.
|
||||
const offsetFromUtcMinutes = -timezoneOffsetMinutes;
|
||||
const sign = offsetFromUtcMinutes >= 0 ? "+" : "-";
|
||||
const absMinutes = Math.abs(offsetFromUtcMinutes);
|
||||
const hours = Math.floor(absMinutes / 60);
|
||||
const minutes = absMinutes % 60;
|
||||
return minutes === 0
|
||||
? `UTC${sign}${hours}`
|
||||
: `UTC${sign}${hours}:${minutes.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const buildDateInterpretationParams = (
|
||||
timeZone: "local" | "utc",
|
||||
includeDateInterpretation: boolean,
|
||||
): UsageDateInterpretationParams | undefined => {
|
||||
if (!includeDateInterpretation) {
|
||||
return undefined;
|
||||
}
|
||||
if (timeZone === "utc") {
|
||||
return { mode: "utc" };
|
||||
}
|
||||
return {
|
||||
mode: "specific",
|
||||
utcOffset: formatUtcOffset(new Date().getTimezoneOffset()),
|
||||
};
|
||||
};
|
||||
|
||||
function toErrorMessage(err: unknown): string {
|
||||
if (typeof err === "string") {
|
||||
return err;
|
||||
}
|
||||
if (err instanceof Error && typeof err.message === "string" && err.message.trim()) {
|
||||
return err.message;
|
||||
}
|
||||
if (err && typeof err === "object") {
|
||||
try {
|
||||
const serialized = JSON.stringify(err);
|
||||
if (serialized) {
|
||||
return serialized;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return "request failed";
|
||||
}
|
||||
|
||||
export async function loadUsage(
|
||||
state: UsageState,
|
||||
overrides?: {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
},
|
||||
) {
|
||||
// Capture client for TS18047 work around on it being possibly null
|
||||
const client = state.client;
|
||||
if (!client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
if (state.usageLoading) {
|
||||
return;
|
||||
}
|
||||
state.usageLoading = true;
|
||||
state.usageError = null;
|
||||
try {
|
||||
const startDate = overrides?.startDate ?? state.usageStartDate;
|
||||
const endDate = overrides?.endDate ?? state.usageEndDate;
|
||||
const runUsageRequests = async (includeDateInterpretation: boolean) => {
|
||||
const dateInterpretation = buildDateInterpretationParams(
|
||||
state.usageTimeZone,
|
||||
includeDateInterpretation,
|
||||
);
|
||||
return await Promise.all([
|
||||
client.request("sessions.usage", {
|
||||
startDate,
|
||||
endDate,
|
||||
...dateInterpretation,
|
||||
limit: 1000, // Cap at 1000 sessions
|
||||
includeContextWeight: true,
|
||||
}),
|
||||
client.request("usage.cost", {
|
||||
startDate,
|
||||
endDate,
|
||||
...dateInterpretation,
|
||||
}),
|
||||
]);
|
||||
};
|
||||
|
||||
const applyUsageResults = (sessionsRes: unknown, costRes: unknown) => {
|
||||
if (sessionsRes) {
|
||||
state.usageResult = sessionsRes as SessionsUsageResult;
|
||||
}
|
||||
if (costRes) {
|
||||
state.usageCostSummary = costRes as CostUsageSummary;
|
||||
}
|
||||
};
|
||||
|
||||
const includeDateInterpretation = shouldSendLegacyDateInterpretation(state);
|
||||
try {
|
||||
const [sessionsRes, costRes] = await runUsageRequests(includeDateInterpretation);
|
||||
applyUsageResults(sessionsRes, costRes);
|
||||
} catch (err) {
|
||||
if (includeDateInterpretation && isLegacyDateInterpretationUnsupportedError(err)) {
|
||||
// Older gateways reject `mode`/`utcOffset` in `sessions.usage`.
|
||||
// Remember this per gateway and retry once without those fields.
|
||||
rememberLegacyDateInterpretation(state);
|
||||
const [sessionsRes, costRes] = await runUsageRequests(false);
|
||||
applyUsageResults(sessionsRes, costRes);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
state.usageError = toErrorMessage(err);
|
||||
} finally {
|
||||
state.usageLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
export const __test = {
|
||||
formatUtcOffset,
|
||||
buildDateInterpretationParams,
|
||||
toErrorMessage,
|
||||
isLegacyDateInterpretationUnsupportedError,
|
||||
normalizeGatewayCompatibilityKey,
|
||||
shouldSendLegacyDateInterpretation,
|
||||
rememberLegacyDateInterpretation,
|
||||
resetLegacyUsageDateParamsCache: () => {
|
||||
legacyUsageDateParamsCache = null;
|
||||
},
|
||||
};
|
||||
|
||||
export async function loadSessionTimeSeries(state: UsageState, sessionKey: string) {
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
if (state.usageTimeSeriesLoading) {
|
||||
return;
|
||||
}
|
||||
state.usageTimeSeriesLoading = true;
|
||||
state.usageTimeSeries = null;
|
||||
try {
|
||||
const res = await state.client.request("sessions.usage.timeseries", { key: sessionKey });
|
||||
if (res) {
|
||||
state.usageTimeSeries = res as SessionUsageTimeSeries;
|
||||
}
|
||||
} catch {
|
||||
// Silently fail - time series is optional
|
||||
state.usageTimeSeries = null;
|
||||
} finally {
|
||||
state.usageTimeSeriesLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadSessionLogs(state: UsageState, sessionKey: string) {
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
if (state.usageSessionLogsLoading) {
|
||||
return;
|
||||
}
|
||||
state.usageSessionLogsLoading = true;
|
||||
state.usageSessionLogs = null;
|
||||
try {
|
||||
const res = await state.client.request("sessions.usage.logs", {
|
||||
key: sessionKey,
|
||||
limit: 1000,
|
||||
});
|
||||
if (res && Array.isArray((res as { logs: SessionLogEntry[] }).logs)) {
|
||||
state.usageSessionLogs = (res as { logs: SessionLogEntry[] }).logs;
|
||||
}
|
||||
} catch {
|
||||
// Silently fail - logs are optional
|
||||
state.usageSessionLogs = null;
|
||||
} finally {
|
||||
state.usageSessionLogsLoading = false;
|
||||
}
|
||||
}
|
||||
39
openclaw/ui/src/ui/data/moonshot-kimi-k2.ts
Normal file
39
openclaw/ui/src/ui/data/moonshot-kimi-k2.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
export const MOONSHOT_KIMI_K2_DEFAULT_ID = "kimi-k2-0905-preview";
|
||||
export const MOONSHOT_KIMI_K2_CONTEXT_WINDOW = 256000;
|
||||
export const MOONSHOT_KIMI_K2_MAX_TOKENS = 8192;
|
||||
export const MOONSHOT_KIMI_K2_INPUT = ["text"] as const;
|
||||
export const MOONSHOT_KIMI_K2_COST = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
} as const;
|
||||
|
||||
export const MOONSHOT_KIMI_K2_MODELS = [
|
||||
{
|
||||
id: "kimi-k2-0905-preview",
|
||||
name: "Kimi K2 0905 Preview",
|
||||
alias: "Kimi K2",
|
||||
reasoning: false,
|
||||
},
|
||||
{
|
||||
id: "kimi-k2-turbo-preview",
|
||||
name: "Kimi K2 Turbo",
|
||||
alias: "Kimi K2 Turbo",
|
||||
reasoning: false,
|
||||
},
|
||||
{
|
||||
id: "kimi-k2-thinking",
|
||||
name: "Kimi K2 Thinking",
|
||||
alias: "Kimi K2 Thinking",
|
||||
reasoning: true,
|
||||
},
|
||||
{
|
||||
id: "kimi-k2-thinking-turbo",
|
||||
name: "Kimi K2 Thinking Turbo",
|
||||
alias: "Kimi K2 Thinking Turbo",
|
||||
reasoning: true,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type MoonshotKimiK2Model = (typeof MOONSHOT_KIMI_K2_MODELS)[number];
|
||||
95
openclaw/ui/src/ui/device-auth.ts
Normal file
95
openclaw/ui/src/ui/device-auth.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import {
|
||||
type DeviceAuthEntry,
|
||||
type DeviceAuthStore,
|
||||
normalizeDeviceAuthRole,
|
||||
normalizeDeviceAuthScopes,
|
||||
} from "../../../src/shared/device-auth.js";
|
||||
|
||||
const STORAGE_KEY = "openclaw.device.auth.v1";
|
||||
|
||||
function readStore(): DeviceAuthStore | null {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const parsed = JSON.parse(raw) as DeviceAuthStore;
|
||||
if (!parsed || parsed.version !== 1) {
|
||||
return null;
|
||||
}
|
||||
if (!parsed.deviceId || typeof parsed.deviceId !== "string") {
|
||||
return null;
|
||||
}
|
||||
if (!parsed.tokens || typeof parsed.tokens !== "object") {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeStore(store: DeviceAuthStore) {
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(store));
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
export function loadDeviceAuthToken(params: {
|
||||
deviceId: string;
|
||||
role: string;
|
||||
}): DeviceAuthEntry | null {
|
||||
const store = readStore();
|
||||
if (!store || store.deviceId !== params.deviceId) {
|
||||
return null;
|
||||
}
|
||||
const role = normalizeDeviceAuthRole(params.role);
|
||||
const entry = store.tokens[role];
|
||||
if (!entry || typeof entry.token !== "string") {
|
||||
return null;
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function storeDeviceAuthToken(params: {
|
||||
deviceId: string;
|
||||
role: string;
|
||||
token: string;
|
||||
scopes?: string[];
|
||||
}): DeviceAuthEntry {
|
||||
const role = normalizeDeviceAuthRole(params.role);
|
||||
const next: DeviceAuthStore = {
|
||||
version: 1,
|
||||
deviceId: params.deviceId,
|
||||
tokens: {},
|
||||
};
|
||||
const existing = readStore();
|
||||
if (existing && existing.deviceId === params.deviceId) {
|
||||
next.tokens = { ...existing.tokens };
|
||||
}
|
||||
const entry: DeviceAuthEntry = {
|
||||
token: params.token,
|
||||
role,
|
||||
scopes: normalizeDeviceAuthScopes(params.scopes),
|
||||
updatedAtMs: Date.now(),
|
||||
};
|
||||
next.tokens[role] = entry;
|
||||
writeStore(next);
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function clearDeviceAuthToken(params: { deviceId: string; role: string }) {
|
||||
const store = readStore();
|
||||
if (!store || store.deviceId !== params.deviceId) {
|
||||
return;
|
||||
}
|
||||
const role = normalizeDeviceAuthRole(params.role);
|
||||
if (!store.tokens[role]) {
|
||||
return;
|
||||
}
|
||||
const next = { ...store, tokens: { ...store.tokens } };
|
||||
delete next.tokens[role];
|
||||
writeStore(next);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user