mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
chore: Manually fix lint issues in ui.
This commit is contained in:
@@ -36,15 +36,23 @@ export async function handleChannelConfigReload(host: OpenClawApp) {
|
||||
}
|
||||
|
||||
function parseValidationErrors(details: unknown): Record<string, string> {
|
||||
if (!Array.isArray(details)) {return {};}
|
||||
if (!Array.isArray(details)) {
|
||||
return {};
|
||||
}
|
||||
const errors: Record<string, string> = {};
|
||||
for (const entry of details) {
|
||||
if (typeof entry !== "string") {continue;}
|
||||
if (typeof entry !== "string") {
|
||||
continue;
|
||||
}
|
||||
const [rawField, ...rest] = entry.split(":");
|
||||
if (!rawField || rest.length === 0) {continue;}
|
||||
if (!rawField || rest.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const field = rawField.trim();
|
||||
const message = rest.join(":").trim();
|
||||
if (field && message) {errors[field] = message;}
|
||||
if (field && message) {
|
||||
errors[field] = message;
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
@@ -78,7 +86,9 @@ export function handleNostrProfileFieldChange(
|
||||
value: string,
|
||||
) {
|
||||
const state = host.nostrProfileFormState;
|
||||
if (!state) {return;}
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
host.nostrProfileFormState = {
|
||||
...state,
|
||||
values: {
|
||||
@@ -94,7 +104,9 @@ export function handleNostrProfileFieldChange(
|
||||
|
||||
export function handleNostrProfileToggleAdvanced(host: OpenClawApp) {
|
||||
const state = host.nostrProfileFormState;
|
||||
if (!state) {return;}
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
host.nostrProfileFormState = {
|
||||
...state,
|
||||
showAdvanced: !state.showAdvanced,
|
||||
@@ -103,7 +115,9 @@ export function handleNostrProfileToggleAdvanced(host: OpenClawApp) {
|
||||
|
||||
export async function handleNostrProfileSave(host: OpenClawApp) {
|
||||
const state = host.nostrProfileFormState;
|
||||
if (!state || state.saving) {return;}
|
||||
if (!state || state.saving) {
|
||||
return;
|
||||
}
|
||||
const accountId = resolveNostrAccountId(host);
|
||||
|
||||
host.nostrProfileFormState = {
|
||||
@@ -172,7 +186,9 @@ export async function handleNostrProfileSave(host: OpenClawApp) {
|
||||
|
||||
export async function handleNostrProfileImport(host: OpenClawApp) {
|
||||
const state = host.nostrProfileFormState;
|
||||
if (!state || state.importing) {return;}
|
||||
if (!state || state.importing) {
|
||||
return;
|
||||
}
|
||||
const accountId = resolveNostrAccountId(host);
|
||||
|
||||
host.nostrProfileFormState = {
|
||||
|
||||
@@ -32,9 +32,13 @@ export function isChatBusy(host: ChatHost) {
|
||||
|
||||
export function isChatStopCommand(text: string) {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {return false;}
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
const normalized = trimmed.toLowerCase();
|
||||
if (normalized === "/stop") {return true;}
|
||||
if (normalized === "/stop") {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
normalized === "stop" ||
|
||||
normalized === "esc" ||
|
||||
@@ -46,14 +50,20 @@ export function isChatStopCommand(text: string) {
|
||||
|
||||
function isChatResetCommand(text: string) {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {return false;}
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
const normalized = trimmed.toLowerCase();
|
||||
if (normalized === "/new" || normalized === "/reset") {return true;}
|
||||
if (normalized === "/new" || normalized === "/reset") {
|
||||
return true;
|
||||
}
|
||||
return normalized.startsWith("/new ") || normalized.startsWith("/reset ");
|
||||
}
|
||||
|
||||
export async function handleAbortChat(host: ChatHost) {
|
||||
if (!host.connected) {return;}
|
||||
if (!host.connected) {
|
||||
return;
|
||||
}
|
||||
host.chatMessage = "";
|
||||
await abortChatRun(host as unknown as OpenClawApp);
|
||||
}
|
||||
@@ -66,7 +76,9 @@ function enqueueChatMessage(
|
||||
) {
|
||||
const trimmed = text.trim();
|
||||
const hasAttachments = Boolean(attachments && attachments.length > 0);
|
||||
if (!trimmed && !hasAttachments) {return;}
|
||||
if (!trimmed && !hasAttachments) {
|
||||
return;
|
||||
}
|
||||
host.chatQueue = [
|
||||
...host.chatQueue,
|
||||
{
|
||||
@@ -123,9 +135,13 @@ async function sendChatMessageNow(
|
||||
}
|
||||
|
||||
async function flushChatQueue(host: ChatHost) {
|
||||
if (!host.connected || isChatBusy(host)) {return;}
|
||||
if (!host.connected || isChatBusy(host)) {
|
||||
return;
|
||||
}
|
||||
const [next, ...rest] = host.chatQueue;
|
||||
if (!next) {return;}
|
||||
if (!next) {
|
||||
return;
|
||||
}
|
||||
host.chatQueue = rest;
|
||||
const ok = await sendChatMessageNow(host, next.text, {
|
||||
attachments: next.attachments,
|
||||
@@ -145,7 +161,9 @@ export async function handleSendChat(
|
||||
messageOverride?: string,
|
||||
opts?: { restoreDraft?: boolean },
|
||||
) {
|
||||
if (!host.connected) {return;}
|
||||
if (!host.connected) {
|
||||
return;
|
||||
}
|
||||
const previousDraft = host.chatMessage;
|
||||
const message = (messageOverride ?? host.chatMessage).trim();
|
||||
const attachments = host.chatAttachments ?? [];
|
||||
@@ -153,7 +171,9 @@ export async function handleSendChat(
|
||||
const hasAttachments = attachmentsToSend.length > 0;
|
||||
|
||||
// Allow sending with just attachments (no message text required)
|
||||
if (!message && !hasAttachments) {return;}
|
||||
if (!message && !hasAttachments) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isChatStopCommand(message)) {
|
||||
await handleAbortChat(host);
|
||||
@@ -201,7 +221,9 @@ type SessionDefaultsSnapshot = {
|
||||
|
||||
function resolveAgentIdForSession(host: ChatHost): string | null {
|
||||
const parsed = parseAgentSessionKey(host.sessionKey);
|
||||
if (parsed?.agentId) {return parsed.agentId;}
|
||||
if (parsed?.agentId) {
|
||||
return parsed.agentId;
|
||||
}
|
||||
const snapshot = host.hello?.snapshot as
|
||||
| { sessionDefaults?: SessionDefaultsSnapshot }
|
||||
| undefined;
|
||||
|
||||
@@ -64,8 +64,12 @@ function normalizeSessionKeyForDefaults(
|
||||
): string {
|
||||
const raw = (value ?? "").trim();
|
||||
const mainSessionKey = defaults.mainSessionKey?.trim();
|
||||
if (!mainSessionKey) {return raw;}
|
||||
if (!raw) {return mainSessionKey;}
|
||||
if (!mainSessionKey) {
|
||||
return raw;
|
||||
}
|
||||
if (!raw) {
|
||||
return mainSessionKey;
|
||||
}
|
||||
const mainKey = defaults.mainKey?.trim() || "main";
|
||||
const defaultAgentId = defaults.defaultAgentId?.trim();
|
||||
const isAlias =
|
||||
@@ -77,7 +81,9 @@ function normalizeSessionKeyForDefaults(
|
||||
}
|
||||
|
||||
function applySessionDefaults(host: GatewayHost, defaults?: SessionDefaultsSnapshot) {
|
||||
if (!defaults?.mainSessionKey) {return;}
|
||||
if (!defaults?.mainSessionKey) {
|
||||
return;
|
||||
}
|
||||
const resolvedSessionKey = normalizeSessionKeyForDefaults(host.sessionKey, defaults);
|
||||
const resolvedSettingsSessionKey = normalizeSessionKeyForDefaults(
|
||||
host.settings.sessionKey,
|
||||
@@ -168,7 +174,9 @@ function handleGatewayEventUnsafe(host: GatewayHost, evt: GatewayEventFrame) {
|
||||
}
|
||||
|
||||
if (evt.event === "agent") {
|
||||
if (host.onboarding) {return;}
|
||||
if (host.onboarding) {
|
||||
return;
|
||||
}
|
||||
handleAgentEvent(
|
||||
host as unknown as Parameters<typeof handleAgentEvent>[0],
|
||||
evt.payload as AgentEventPayload | undefined,
|
||||
@@ -198,7 +206,9 @@ function handleGatewayEventUnsafe(host: GatewayHost, evt: GatewayEventFrame) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (state === "final") {void loadChatHistory(host as unknown as OpenClawApp);}
|
||||
if (state === "final") {
|
||||
void loadChatHistory(host as unknown as OpenClawApp);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -75,10 +75,7 @@ export function handleUpdated(host: LifecycleHost, changed: Map<PropertyKey, unk
|
||||
) {
|
||||
const forcedByTab = changed.has("tab");
|
||||
const forcedByLoad =
|
||||
changed.has("chatLoading") &&
|
||||
changed.get("chatLoading") === true &&
|
||||
!
|
||||
host.chatLoading;
|
||||
changed.has("chatLoading") && changed.get("chatLoading") === true && !host.chatLoading;
|
||||
scheduleChatScroll(
|
||||
host as unknown as Parameters<typeof scheduleChatScroll>[0],
|
||||
forcedByTab || forcedByLoad || !host.chatHasAutoScrolled,
|
||||
|
||||
@@ -11,7 +11,9 @@ type PollingHost = {
|
||||
};
|
||||
|
||||
export function startNodesPolling(host: PollingHost) {
|
||||
if (host.nodesPollInterval != null) {return;}
|
||||
if (host.nodesPollInterval != null) {
|
||||
return;
|
||||
}
|
||||
host.nodesPollInterval = window.setInterval(
|
||||
() => void loadNodes(host as unknown as OpenClawApp, { quiet: true }),
|
||||
5000,
|
||||
@@ -19,35 +21,49 @@ export function startNodesPolling(host: PollingHost) {
|
||||
}
|
||||
|
||||
export function stopNodesPolling(host: PollingHost) {
|
||||
if (host.nodesPollInterval == null) {return;}
|
||||
if (host.nodesPollInterval == null) {
|
||||
return;
|
||||
}
|
||||
clearInterval(host.nodesPollInterval);
|
||||
host.nodesPollInterval = null;
|
||||
}
|
||||
|
||||
export function startLogsPolling(host: PollingHost) {
|
||||
if (host.logsPollInterval != null) {return;}
|
||||
if (host.logsPollInterval != null) {
|
||||
return;
|
||||
}
|
||||
host.logsPollInterval = window.setInterval(() => {
|
||||
if (host.tab !== "logs") {return;}
|
||||
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;}
|
||||
if (host.logsPollInterval == null) {
|
||||
return;
|
||||
}
|
||||
clearInterval(host.logsPollInterval);
|
||||
host.logsPollInterval = null;
|
||||
}
|
||||
|
||||
export function startDebugPolling(host: PollingHost) {
|
||||
if (host.debugPollInterval != null) {return;}
|
||||
if (host.debugPollInterval != null) {
|
||||
return;
|
||||
}
|
||||
host.debugPollInterval = window.setInterval(() => {
|
||||
if (host.tab !== "debug") {return;}
|
||||
if (host.tab !== "debug") {
|
||||
return;
|
||||
}
|
||||
void loadDebug(host as unknown as OpenClawApp);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
export function stopDebugPolling(host: PollingHost) {
|
||||
if (host.debugPollInterval == null) {return;}
|
||||
if (host.debugPollInterval == null) {
|
||||
return;
|
||||
}
|
||||
clearInterval(host.debugPollInterval);
|
||||
host.debugPollInterval = null;
|
||||
}
|
||||
|
||||
@@ -134,7 +134,9 @@ export function renderChatControls(state: AppViewState) {
|
||||
class="btn btn--sm btn--icon ${showThinking ? "active" : ""}"
|
||||
?disabled=${disableThinkingToggle}
|
||||
@click=${() => {
|
||||
if (disableThinkingToggle) {return;}
|
||||
if (disableThinkingToggle) {
|
||||
return;
|
||||
}
|
||||
state.applySettings({
|
||||
...state.settings,
|
||||
chatShowThinking: !state.settings.chatShowThinking,
|
||||
@@ -153,7 +155,9 @@ export function renderChatControls(state: AppViewState) {
|
||||
class="btn btn--sm btn--icon ${focusActive ? "active" : ""}"
|
||||
?disabled=${disableFocusToggle}
|
||||
@click=${() => {
|
||||
if (disableFocusToggle) {return;}
|
||||
if (disableFocusToggle) {
|
||||
return;
|
||||
}
|
||||
state.applySettings({
|
||||
...state.settings,
|
||||
chatFocusMode: !state.settings.chatFocusMode,
|
||||
@@ -183,18 +187,28 @@ function resolveMainSessionKey(
|
||||
): string | null {
|
||||
const snapshot = hello?.snapshot as { sessionDefaults?: SessionDefaultsSnapshot } | undefined;
|
||||
const mainSessionKey = snapshot?.sessionDefaults?.mainSessionKey?.trim();
|
||||
if (mainSessionKey) {return mainSessionKey;}
|
||||
if (mainSessionKey) {
|
||||
return mainSessionKey;
|
||||
}
|
||||
const mainKey = snapshot?.sessionDefaults?.mainKey?.trim();
|
||||
if (mainKey) {return mainKey;}
|
||||
if (sessions?.sessions?.some((row) => row.key === "main")) {return "main";}
|
||||
if (mainKey) {
|
||||
return mainKey;
|
||||
}
|
||||
if (sessions?.sessions?.some((row) => row.key === "main")) {
|
||||
return "main";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveSessionDisplayName(key: string, row?: SessionsListResult["sessions"][number]) {
|
||||
const label = row?.label?.trim();
|
||||
if (label) {return `${label} (${key})`;}
|
||||
if (label) {
|
||||
return `${label} (${key})`;
|
||||
}
|
||||
const displayName = row?.displayName?.trim();
|
||||
if (displayName) {return displayName;}
|
||||
if (displayName) {
|
||||
return displayName;
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,24 +1,5 @@
|
||||
import { html, nothing } from "lit";
|
||||
import type { AppViewState } from "./app-view-state";
|
||||
import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway";
|
||||
import type { UiSettings } from "./storage";
|
||||
import type { ThemeMode } from "./theme";
|
||||
import type { ThemeTransitionContext } from "./theme-transition";
|
||||
import type {
|
||||
ConfigSnapshot,
|
||||
CronJob,
|
||||
CronRunLogEntry,
|
||||
CronStatus,
|
||||
HealthSnapshot,
|
||||
LogEntry,
|
||||
LogLevel,
|
||||
PresenceEntry,
|
||||
ChannelsStatusSnapshot,
|
||||
SessionsListResult,
|
||||
SkillStatusReport,
|
||||
StatusSummary,
|
||||
} from "./types";
|
||||
import type { ChatQueueItem, CronFormState } from "./ui-types";
|
||||
import { parseAgentSessionKey } from "../../../src/routing/session-key.js";
|
||||
import { refreshChatAvatar } from "./app-chat";
|
||||
import { renderChatControls, renderTab, renderThemeToggle } from "./app-render.helpers";
|
||||
@@ -63,17 +44,9 @@ import {
|
||||
saveSkillApiKey,
|
||||
updateSkillEdit,
|
||||
updateSkillEnabled,
|
||||
type SkillMessage,
|
||||
} from "./controllers/skills";
|
||||
import { icons } from "./icons";
|
||||
import {
|
||||
TAB_GROUPS,
|
||||
iconForTab,
|
||||
pathForTab,
|
||||
subtitleForTab,
|
||||
titleForTab,
|
||||
type Tab,
|
||||
} from "./navigation";
|
||||
import { TAB_GROUPS, subtitleForTab, titleForTab } from "./navigation";
|
||||
import { renderChannels } from "./views/channels";
|
||||
import { renderChat } from "./views/chat";
|
||||
import { renderConfig } from "./views/config";
|
||||
@@ -98,8 +71,12 @@ function resolveAssistantAvatarUrl(state: AppViewState): string | undefined {
|
||||
const agent = list.find((entry) => entry.id === agentId);
|
||||
const identity = agent?.identity;
|
||||
const candidate = identity?.avatarUrl ?? identity?.avatar;
|
||||
if (!candidate) {return undefined;}
|
||||
if (AVATAR_DATA_RE.test(candidate) || AVATAR_HTTP_RE.test(candidate)) {return candidate;}
|
||||
if (!candidate) {
|
||||
return undefined;
|
||||
}
|
||||
if (AVATAR_DATA_RE.test(candidate) || AVATAR_HTTP_RE.test(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
return identity?.avatarUrl;
|
||||
}
|
||||
|
||||
@@ -486,7 +463,9 @@ export function renderApp(state: AppViewState) {
|
||||
return Promise.all([loadChatHistory(state), refreshChatAvatar(state)]);
|
||||
},
|
||||
onToggleFocusMode: () => {
|
||||
if (state.onboarding) {return;}
|
||||
if (state.onboarding) {
|
||||
return;
|
||||
}
|
||||
state.applySettings({
|
||||
...state.settings,
|
||||
chatFocusMode: !state.settings.chatFocusMode,
|
||||
|
||||
@@ -12,7 +12,9 @@ type ScrollHost = {
|
||||
};
|
||||
|
||||
export function scheduleChatScroll(host: ScrollHost, force = false) {
|
||||
if (host.chatScrollFrame) {cancelAnimationFrame(host.chatScrollFrame);}
|
||||
if (host.chatScrollFrame) {
|
||||
cancelAnimationFrame(host.chatScrollFrame);
|
||||
}
|
||||
if (host.chatScrollTimeout != null) {
|
||||
clearTimeout(host.chatScrollTimeout);
|
||||
host.chatScrollTimeout = null;
|
||||
@@ -25,7 +27,9 @@ export function scheduleChatScroll(host: ScrollHost, force = false) {
|
||||
overflowY === "auto" ||
|
||||
overflowY === "scroll" ||
|
||||
container.scrollHeight - container.clientHeight > 1;
|
||||
if (canScroll) {return container;}
|
||||
if (canScroll) {
|
||||
return container;
|
||||
}
|
||||
}
|
||||
return (document.scrollingElement ?? document.documentElement) as HTMLElement | null;
|
||||
};
|
||||
@@ -34,22 +38,32 @@ export function scheduleChatScroll(host: ScrollHost, force = false) {
|
||||
host.chatScrollFrame = requestAnimationFrame(() => {
|
||||
host.chatScrollFrame = null;
|
||||
const target = pickScrollTarget();
|
||||
if (!target) {return;}
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
const distanceFromBottom = target.scrollHeight - target.scrollTop - target.clientHeight;
|
||||
const shouldStick = force || host.chatUserNearBottom || distanceFromBottom < 200;
|
||||
if (!shouldStick) {return;}
|
||||
if (force) {host.chatHasAutoScrolled = true;}
|
||||
if (!shouldStick) {
|
||||
return;
|
||||
}
|
||||
if (force) {
|
||||
host.chatHasAutoScrolled = true;
|
||||
}
|
||||
target.scrollTop = target.scrollHeight;
|
||||
host.chatUserNearBottom = true;
|
||||
const retryDelay = force ? 150 : 120;
|
||||
host.chatScrollTimeout = window.setTimeout(() => {
|
||||
host.chatScrollTimeout = null;
|
||||
const latest = pickScrollTarget();
|
||||
if (!latest) {return;}
|
||||
if (!latest) {
|
||||
return;
|
||||
}
|
||||
const latestDistanceFromBottom =
|
||||
latest.scrollHeight - latest.scrollTop - latest.clientHeight;
|
||||
const shouldStickRetry = force || host.chatUserNearBottom || latestDistanceFromBottom < 200;
|
||||
if (!shouldStickRetry) {return;}
|
||||
if (!shouldStickRetry) {
|
||||
return;
|
||||
}
|
||||
latest.scrollTop = latest.scrollHeight;
|
||||
host.chatUserNearBottom = true;
|
||||
}, retryDelay);
|
||||
@@ -58,16 +72,22 @@ export function scheduleChatScroll(host: ScrollHost, force = false) {
|
||||
}
|
||||
|
||||
export function scheduleLogsScroll(host: ScrollHost, force = false) {
|
||||
if (host.logsScrollFrame) {cancelAnimationFrame(host.logsScrollFrame);}
|
||||
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;}
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
const distanceFromBottom =
|
||||
container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||
const shouldStick = force || distanceFromBottom < 80;
|
||||
if (!shouldStick) {return;}
|
||||
if (!shouldStick) {
|
||||
return;
|
||||
}
|
||||
container.scrollTop = container.scrollHeight;
|
||||
});
|
||||
});
|
||||
@@ -75,14 +95,18 @@ export function scheduleLogsScroll(host: ScrollHost, force = false) {
|
||||
|
||||
export function handleChatScroll(host: ScrollHost, event: Event) {
|
||||
const container = event.currentTarget as HTMLElement | null;
|
||||
if (!container) {return;}
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||
host.chatUserNearBottom = distanceFromBottom < 200;
|
||||
}
|
||||
|
||||
export function handleLogsScroll(host: ScrollHost, event: Event) {
|
||||
const container = event.currentTarget as HTMLElement | null;
|
||||
if (!container) {return;}
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||
host.logsAtBottom = distanceFromBottom < 80;
|
||||
}
|
||||
@@ -93,7 +117,9 @@ export function resetChatScroll(host: ScrollHost) {
|
||||
}
|
||||
|
||||
export function exportLogs(lines: string[], label: string) {
|
||||
if (lines.length === 0) {return;}
|
||||
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");
|
||||
@@ -105,9 +131,13 @@ export function exportLogs(lines: string[], label: string) {
|
||||
}
|
||||
|
||||
export function observeTopbar(host: ScrollHost) {
|
||||
if (typeof ResizeObserver === "undefined") {return;}
|
||||
if (typeof ResizeObserver === "undefined") {
|
||||
return;
|
||||
}
|
||||
const topbar = host.querySelector(".topbar");
|
||||
if (!topbar) {return;}
|
||||
if (!topbar) {
|
||||
return;
|
||||
}
|
||||
const update = () => {
|
||||
const { height } = topbar.getBoundingClientRect();
|
||||
host.style.setProperty("--topbar-height", `${height}px`);
|
||||
|
||||
@@ -64,13 +64,19 @@ export function applySettings(host: SettingsHost, next: UiSettings) {
|
||||
|
||||
export function setLastActiveSessionKey(host: SettingsHost, next: string) {
|
||||
const trimmed = next.trim();
|
||||
if (!trimmed) {return;}
|
||||
if (host.settings.lastActiveSessionKey === trimmed) {return;}
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
if (host.settings.lastActiveSessionKey === trimmed) {
|
||||
return;
|
||||
}
|
||||
applySettings(host, { ...host.settings, lastActiveSessionKey: trimmed });
|
||||
}
|
||||
|
||||
export function applySettingsFromUrl(host: SettingsHost) {
|
||||
if (!window.location.search) {return;}
|
||||
if (!window.location.search) {
|
||||
return;
|
||||
}
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const tokenRaw = params.get("token");
|
||||
const passwordRaw = params.get("password");
|
||||
@@ -117,20 +123,31 @@ export function applySettingsFromUrl(host: SettingsHost) {
|
||||
shouldCleanUrl = true;
|
||||
}
|
||||
|
||||
if (!shouldCleanUrl) {return;}
|
||||
if (!shouldCleanUrl) {
|
||||
return;
|
||||
}
|
||||
const url = new URL(window.location.href);
|
||||
url.search = params.toString();
|
||||
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]);}
|
||||
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);
|
||||
}
|
||||
@@ -150,12 +167,24 @@ export function setTheme(host: SettingsHost, next: ThemeMode, context?: ThemeTra
|
||||
}
|
||||
|
||||
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 === "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 === "nodes") {
|
||||
await loadNodes(host as unknown as OpenClawApp);
|
||||
await loadDevices(host as unknown as OpenClawApp);
|
||||
@@ -185,7 +214,9 @@ export async function refreshActiveTab(host: SettingsHost) {
|
||||
}
|
||||
|
||||
export function inferBasePath() {
|
||||
if (typeof window === "undefined") {return "";}
|
||||
if (typeof window === "undefined") {
|
||||
return "";
|
||||
}
|
||||
const configured = window.__OPENCLAW_CONTROL_UI_BASE_PATH__;
|
||||
if (typeof configured === "string" && configured.trim()) {
|
||||
return normalizeBasePath(configured);
|
||||
@@ -200,17 +231,23 @@ export function syncThemeWithSettings(host: SettingsHost) {
|
||||
|
||||
export function applyResolvedTheme(host: SettingsHost, resolved: ResolvedTheme) {
|
||||
host.themeResolved = resolved;
|
||||
if (typeof document === "undefined") {return;}
|
||||
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;}
|
||||
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;}
|
||||
if (host.theme !== "system") {
|
||||
return;
|
||||
}
|
||||
applyResolvedTheme(host, event.matches ? "dark" : "light");
|
||||
};
|
||||
if (typeof host.themeMedia.addEventListener === "function") {
|
||||
@@ -224,7 +261,9 @@ export function attachThemeListener(host: SettingsHost) {
|
||||
}
|
||||
|
||||
export function detachThemeListener(host: SettingsHost) {
|
||||
if (!host.themeMedia || !host.themeMediaHandler) {return;}
|
||||
if (!host.themeMedia || !host.themeMediaHandler) {
|
||||
return;
|
||||
}
|
||||
if (typeof host.themeMedia.removeEventListener === "function") {
|
||||
host.themeMedia.removeEventListener("change", host.themeMediaHandler);
|
||||
return;
|
||||
@@ -238,16 +277,22 @@ export function detachThemeListener(host: SettingsHost) {
|
||||
}
|
||||
|
||||
export function syncTabWithLocation(host: SettingsHost, replace: boolean) {
|
||||
if (typeof window === "undefined") {return;}
|
||||
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;}
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
const resolved = tabFromPath(window.location.pathname, host.basePath);
|
||||
if (!resolved) {return;}
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(window.location.href);
|
||||
const session = url.searchParams.get("session")?.trim();
|
||||
@@ -264,18 +309,31 @@ export function onPopState(host: SettingsHost) {
|
||||
}
|
||||
|
||||
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);}
|
||||
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;}
|
||||
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);
|
||||
@@ -298,11 +356,16 @@ export function syncUrlWithTab(host: SettingsHost, tab: Tab, replace: boolean) {
|
||||
}
|
||||
|
||||
export function syncUrlWithSessionKey(host: SettingsHost, sessionKey: string, replace: boolean) {
|
||||
if (typeof window === "undefined") {return;}
|
||||
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());}
|
||||
if (replace) {
|
||||
window.history.replaceState({}, "", url.toString());
|
||||
} else {
|
||||
window.history.pushState({}, "", url.toString());
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadOverview(host: SettingsHost) {
|
||||
|
||||
@@ -35,25 +35,39 @@ type ToolStreamHost = {
|
||||
};
|
||||
|
||||
function extractToolOutputText(value: unknown): string | null {
|
||||
if (!value || typeof value !== "object") {return null;}
|
||||
if (!value || typeof value !== "object") {
|
||||
return null;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if (typeof record.text === "string") {return record.text;}
|
||||
if (typeof record.text === "string") {
|
||||
return record.text;
|
||||
}
|
||||
const content = record.content;
|
||||
if (!Array.isArray(content)) {return null;}
|
||||
if (!Array.isArray(content)) {
|
||||
return null;
|
||||
}
|
||||
const parts = content
|
||||
.map((item) => {
|
||||
if (!item || typeof item !== "object") {return null;}
|
||||
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;}
|
||||
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;}
|
||||
if (parts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
function formatToolOutput(value: unknown): string | null {
|
||||
if (value === null || value === undefined) {return null;}
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
@@ -67,11 +81,14 @@ function formatToolOutput(value: unknown): string | null {
|
||||
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;}
|
||||
if (!truncated.truncated) {
|
||||
return truncated.text;
|
||||
}
|
||||
return `${truncated.text}\n\n… truncated (${truncated.total} chars, showing first ${truncated.text.length}).`;
|
||||
}
|
||||
|
||||
@@ -99,10 +116,14 @@ function buildToolStreamMessage(entry: ToolStreamEntry): Record<string, unknown>
|
||||
}
|
||||
|
||||
function trimToolStream(host: ToolStreamHost) {
|
||||
if (host.toolStreamOrder.length <= TOOL_STREAM_LIMIT) {return;}
|
||||
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);}
|
||||
for (const id of removed) {
|
||||
host.toolStreamById.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
function syncToolStreamMessages(host: ToolStreamHost) {
|
||||
@@ -124,7 +145,9 @@ export function scheduleToolStreamSync(host: ToolStreamHost, force = false) {
|
||||
flushToolStreamSync(host);
|
||||
return;
|
||||
}
|
||||
if (host.toolStreamSyncTimer != null) {return;}
|
||||
if (host.toolStreamSyncTimer != null) {
|
||||
return;
|
||||
}
|
||||
host.toolStreamSyncTimer = window.setTimeout(
|
||||
() => flushToolStreamSync(host),
|
||||
TOOL_STREAM_THROTTLE_MS,
|
||||
@@ -182,7 +205,9 @@ export function handleCompactionEvent(host: CompactionHost, payload: AgentEventP
|
||||
}
|
||||
|
||||
export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPayload) {
|
||||
if (!payload) {return;}
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle compaction events
|
||||
if (payload.stream === "compaction") {
|
||||
@@ -190,17 +215,29 @@ export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPaylo
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.stream !== "tool") {return;}
|
||||
if (payload.stream !== "tool") {
|
||||
return;
|
||||
}
|
||||
const sessionKey = typeof payload.sessionKey === "string" ? payload.sessionKey : undefined;
|
||||
if (sessionKey && sessionKey !== host.sessionKey) {return;}
|
||||
if (sessionKey && sessionKey !== host.sessionKey) {
|
||||
return;
|
||||
}
|
||||
// Fallback: only accept session-less events for the active run.
|
||||
if (!sessionKey && host.chatRunId && payload.runId !== host.chatRunId) {return;}
|
||||
if (host.chatRunId && payload.runId !== host.chatRunId) {return;}
|
||||
if (!host.chatRunId) {return;}
|
||||
if (!sessionKey && host.chatRunId && payload.runId !== host.chatRunId) {
|
||||
return;
|
||||
}
|
||||
if (host.chatRunId && payload.runId !== host.chatRunId) {
|
||||
return;
|
||||
}
|
||||
if (!host.chatRunId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = payload.data ?? {};
|
||||
const toolCallId = typeof data.toolCallId === "string" ? data.toolCallId : "";
|
||||
if (!toolCallId) {return;}
|
||||
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;
|
||||
@@ -229,8 +266,12 @@ export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPaylo
|
||||
host.toolStreamOrder.push(toolCallId);
|
||||
} else {
|
||||
entry.name = name;
|
||||
if (args !== undefined) {entry.args = args;}
|
||||
if (output !== undefined) {entry.output = output;}
|
||||
if (args !== undefined) {
|
||||
entry.args = args;
|
||||
}
|
||||
if (output !== undefined) {
|
||||
entry.output = output;
|
||||
}
|
||||
entry.updatedAt = now;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LitElement, html, nothing } from "lit";
|
||||
import { LitElement } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import type { EventLogEntry } from "./app-events";
|
||||
import type { DevicePairingList } from "./controllers/devices";
|
||||
@@ -84,10 +84,14 @@ declare global {
|
||||
const injectedAssistantIdentity = resolveInjectedAssistantIdentity();
|
||||
|
||||
function resolveOnboardingMode(): boolean {
|
||||
if (!window.location.search) {return false;}
|
||||
if (!window.location.search) {
|
||||
return false;
|
||||
}
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const raw = params.get("onboarding");
|
||||
if (!raw) {return false;}
|
||||
if (!raw) {
|
||||
return false;
|
||||
}
|
||||
const normalized = raw.trim().toLowerCase();
|
||||
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
|
||||
}
|
||||
@@ -406,7 +410,9 @@ export class OpenClawApp extends LitElement {
|
||||
|
||||
async handleExecApprovalDecision(decision: "allow-once" | "allow-always" | "deny") {
|
||||
const active = this.execApprovalQueue[0];
|
||||
if (!active || !this.client || this.execApprovalBusy) {return;}
|
||||
if (!active || !this.client || this.execApprovalBusy) {
|
||||
return;
|
||||
}
|
||||
this.execApprovalBusy = true;
|
||||
this.execApprovalError = null;
|
||||
try {
|
||||
@@ -424,7 +430,9 @@ export class OpenClawApp extends LitElement {
|
||||
|
||||
handleGatewayUrlConfirm() {
|
||||
const nextGatewayUrl = this.pendingGatewayUrl;
|
||||
if (!nextGatewayUrl) {return;}
|
||||
if (!nextGatewayUrl) {
|
||||
return;
|
||||
}
|
||||
this.pendingGatewayUrl = null;
|
||||
applySettingsInternal(this as unknown as Parameters<typeof applySettingsInternal>[0], {
|
||||
...this.settings,
|
||||
@@ -455,7 +463,9 @@ export class OpenClawApp extends LitElement {
|
||||
window.clearTimeout(this.sidebarCloseTimer);
|
||||
}
|
||||
this.sidebarCloseTimer = window.setTimeout(() => {
|
||||
if (this.sidebarOpen) {return;}
|
||||
if (this.sidebarOpen) {
|
||||
return;
|
||||
}
|
||||
this.sidebarContent = null;
|
||||
this.sidebarError = null;
|
||||
this.sidebarCloseTimer = null;
|
||||
|
||||
@@ -18,10 +18,16 @@ declare global {
|
||||
}
|
||||
|
||||
function coerceIdentityValue(value: string | undefined, maxLength: number): string | undefined {
|
||||
if (typeof value !== "string") {return undefined;}
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {return undefined;}
|
||||
if (trimmed.length <= maxLength) {return trimmed;}
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
if (trimmed.length <= maxLength) {
|
||||
return trimmed;
|
||||
}
|
||||
return trimmed.slice(0, maxLength);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { OpenClawApp } from "./app";
|
||||
|
||||
// oxlint-disable-next-line typescript/unbound-method
|
||||
const originalConnect = OpenClawApp.prototype.connect;
|
||||
|
||||
function mountApp(pathname: string) {
|
||||
|
||||
@@ -13,7 +13,9 @@ type CopyButtonOptions = {
|
||||
};
|
||||
|
||||
async function copyTextToClipboard(text: string): Promise<boolean> {
|
||||
if (!text) {return false;}
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
@@ -38,16 +40,19 @@ function createCopyButton(options: CopyButtonOptions): TemplateResult {
|
||||
aria-label=${idleLabel}
|
||||
@click=${async (e: Event) => {
|
||||
const btn = e.currentTarget as HTMLButtonElement | null;
|
||||
const iconContainer = btn?.querySelector(".chat-copy-btn__icon") as HTMLElement | null;
|
||||
|
||||
if (!btn || btn.dataset.copying === "1") {return;}
|
||||
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;}
|
||||
if (!btn.isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
delete btn.dataset.copying;
|
||||
btn.removeAttribute("aria-busy");
|
||||
@@ -58,7 +63,9 @@ function createCopyButton(options: CopyButtonOptions): TemplateResult {
|
||||
setButtonLabel(btn, ERROR_LABEL);
|
||||
|
||||
window.setTimeout(() => {
|
||||
if (!btn.isConnected) {return;}
|
||||
if (!btn.isConnected) {
|
||||
return;
|
||||
}
|
||||
delete btn.dataset.error;
|
||||
setButtonLabel(btn, idleLabel);
|
||||
}, ERROR_FOR_MS);
|
||||
@@ -69,7 +76,9 @@ function createCopyButton(options: CopyButtonOptions): TemplateResult {
|
||||
setButtonLabel(btn, COPIED_LABEL);
|
||||
|
||||
window.setTimeout(() => {
|
||||
if (!btn.isConnected) {return;}
|
||||
if (!btn.isConnected) {
|
||||
return;
|
||||
}
|
||||
delete btn.dataset.copied;
|
||||
setButtonLabel(btn, idleLabel);
|
||||
}, COPIED_FOR_MS);
|
||||
|
||||
@@ -24,7 +24,9 @@ function extractImages(message: unknown): ImageBlock[] {
|
||||
|
||||
if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
if (typeof block !== "object" || block === null) {continue;}
|
||||
if (typeof block !== "object" || block === null) {
|
||||
continue;
|
||||
}
|
||||
const b = block as Record<string, unknown>;
|
||||
|
||||
if (b.type === "image") {
|
||||
@@ -188,12 +190,14 @@ function renderAvatar(role: string, assistant?: Pick<AssistantIdentity, "name" |
|
||||
|
||||
function isAvatarUrl(value: string): boolean {
|
||||
return (
|
||||
/^https?:\/\//i.test(value) || /^data:image\//i.test(value) || value.startsWith('/') // Relative paths from avatar endpoint
|
||||
/^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;}
|
||||
if (images.length === 0) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="chat-message-images">
|
||||
@@ -251,7 +255,9 @@ function renderGroupedMessage(
|
||||
return html`${toolCards.map((card) => renderToolCardSidebar(card, onOpenSidebar))}`;
|
||||
}
|
||||
|
||||
if (!markdown && !hasToolCards && !hasImages) {return nothing;}
|
||||
if (!markdown && !hasToolCards && !hasImages) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="${bubbleClasses}">
|
||||
|
||||
@@ -20,16 +20,24 @@ const textCache = new WeakMap<object, string | null>();
|
||||
const thinkingCache = new WeakMap<object, string | null>();
|
||||
|
||||
function looksLikeEnvelopeHeader(header: string): boolean {
|
||||
if (/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z\b/.test(header)) {return true;}
|
||||
if (/\d{4}-\d{2}-\d{2} \d{2}:\d{2}\b/.test(header)) {return true;}
|
||||
if (/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z\b/.test(header)) {
|
||||
return true;
|
||||
}
|
||||
if (/\d{4}-\d{2}-\d{2} \d{2}:\d{2}\b/.test(header)) {
|
||||
return true;
|
||||
}
|
||||
return ENVELOPE_CHANNELS.some((label) => header.startsWith(`${label} `));
|
||||
}
|
||||
|
||||
export function stripEnvelope(text: string): string {
|
||||
const match = text.match(ENVELOPE_PREFIX);
|
||||
if (!match) {return text;}
|
||||
if (!match) {
|
||||
return text;
|
||||
}
|
||||
const header = match[1] ?? "";
|
||||
if (!looksLikeEnvelopeHeader(header)) {return text;}
|
||||
if (!looksLikeEnvelopeHeader(header)) {
|
||||
return text;
|
||||
}
|
||||
return text.slice(match[0].length);
|
||||
}
|
||||
|
||||
@@ -45,7 +53,9 @@ export function extractText(message: unknown): string | null {
|
||||
const parts = content
|
||||
.map((p) => {
|
||||
const item = p as Record<string, unknown>;
|
||||
if (item.type === "text" && typeof item.text === "string") {return item.text;}
|
||||
if (item.type === "text" && typeof item.text === "string") {
|
||||
return item.text;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((v): v is string => typeof v === "string");
|
||||
@@ -63,9 +73,13 @@ export function extractText(message: unknown): string | null {
|
||||
}
|
||||
|
||||
export function extractTextCached(message: unknown): string | null {
|
||||
if (!message || typeof message !== "object") {return extractText(message);}
|
||||
if (!message || typeof message !== "object") {
|
||||
return extractText(message);
|
||||
}
|
||||
const obj = message;
|
||||
if (textCache.has(obj)) {return textCache.get(obj) ?? null;}
|
||||
if (textCache.has(obj)) {
|
||||
return textCache.get(obj) ?? null;
|
||||
}
|
||||
const value = extractText(message);
|
||||
textCache.set(obj, value);
|
||||
return value;
|
||||
@@ -80,15 +94,21 @@ export function extractThinking(message: unknown): string | null {
|
||||
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 (cleaned) {
|
||||
parts.push(cleaned);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (parts.length > 0) {return parts.join("\n");}
|
||||
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;}
|
||||
if (!rawText) {
|
||||
return null;
|
||||
}
|
||||
const matches = [
|
||||
...rawText.matchAll(/<\s*think(?:ing)?\s*>([\s\S]*?)<\s*\/\s*think(?:ing)?\s*>/gi),
|
||||
];
|
||||
@@ -97,9 +117,13 @@ export function extractThinking(message: unknown): string | null {
|
||||
}
|
||||
|
||||
export function extractThinkingCached(message: unknown): string | null {
|
||||
if (!message || typeof message !== "object") {return extractThinking(message);}
|
||||
if (!message || typeof message !== "object") {
|
||||
return extractThinking(message);
|
||||
}
|
||||
const obj = message;
|
||||
if (thinkingCache.has(obj)) {return thinkingCache.get(obj) ?? null;}
|
||||
if (thinkingCache.has(obj)) {
|
||||
return thinkingCache.get(obj) ?? null;
|
||||
}
|
||||
const value = extractThinking(message);
|
||||
thinkingCache.set(obj, value);
|
||||
return value;
|
||||
@@ -108,24 +132,34 @@ export function extractThinkingCached(message: unknown): string | null {
|
||||
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 (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;}
|
||||
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 (parts.length > 0) {
|
||||
return parts.join("\n");
|
||||
}
|
||||
}
|
||||
if (typeof m.text === "string") {
|
||||
return m.text;
|
||||
}
|
||||
if (typeof m.text === "string") {return m.text;}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function formatReasoningMarkdown(text: string): string {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {return "";}
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
const lines = trimmed
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
|
||||
@@ -21,13 +21,11 @@ export function normalizeMessage(message: unknown): NormalizedMessage {
|
||||
Array.isArray(contentItems) &&
|
||||
contentItems.some((item) => {
|
||||
const x = item as Record<string, unknown>;
|
||||
const t = String(x.type ?? "").toLowerCase();
|
||||
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";
|
||||
const hasToolName = typeof m.toolName === "string" || typeof m.tool_name === "string";
|
||||
|
||||
if (hasToolId || hasToolContent || hasToolName) {
|
||||
role = "toolResult";
|
||||
@@ -61,9 +59,15 @@ export function normalizeMessage(message: unknown): NormalizedMessage {
|
||||
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";}
|
||||
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" ||
|
||||
|
||||
@@ -13,7 +13,7 @@ export function extractToolCards(message: unknown): ToolCard[] {
|
||||
const cards: ToolCard[] = [];
|
||||
|
||||
for (const item of content) {
|
||||
const kind = String(item.type ?? "").toLowerCase();
|
||||
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);
|
||||
@@ -27,8 +27,10 @@ export function extractToolCards(message: unknown): ToolCard[] {
|
||||
}
|
||||
|
||||
for (const item of content) {
|
||||
const kind = String(item.type ?? "").toLowerCase();
|
||||
if (kind !== "toolresult" && kind !== "tool_result") {continue;}
|
||||
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 });
|
||||
@@ -79,7 +81,9 @@ export function renderToolCardSidebar(card: ToolCard, onOpenSidebar?: (content:
|
||||
@keydown=${
|
||||
canClick
|
||||
? (e: KeyboardEvent) => {
|
||||
if (e.key !== "Enter" && e.key !== " ") {return;}
|
||||
if (e.key !== "Enter" && e.key !== " ") {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
handleClick?.();
|
||||
}
|
||||
@@ -117,15 +121,23 @@ export function renderToolCardSidebar(card: ToolCard, onOpenSidebar?: (content:
|
||||
}
|
||||
|
||||
function normalizeContent(content: unknown): Array<Record<string, unknown>> {
|
||||
if (!Array.isArray(content)) {return [];}
|
||||
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;}
|
||||
if (typeof value !== "string") {
|
||||
return value;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {return value;}
|
||||
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) {return value;}
|
||||
if (!trimmed) {
|
||||
return value;
|
||||
}
|
||||
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
@@ -134,7 +146,11 @@ function coerceArgs(value: unknown): unknown {
|
||||
}
|
||||
|
||||
function extractToolText(item: Record<string, unknown>): string | undefined {
|
||||
if (typeof item.text === "string") {return item.text;}
|
||||
if (typeof item.content === "string") {return item.content;}
|
||||
if (typeof item.text === "string") {
|
||||
return item.text;
|
||||
}
|
||||
if (typeof item.content === "string") {
|
||||
return item.content;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -74,10 +74,14 @@ export class ResizableDivider extends LitElement {
|
||||
};
|
||||
|
||||
private handleMouseMove = (e: MouseEvent) => {
|
||||
if (!this.isDragging) {return;}
|
||||
if (!this.isDragging) {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = this.parentElement;
|
||||
if (!container) {return;}
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const containerWidth = container.getBoundingClientRect().width;
|
||||
const deltaX = e.clientX - this.startX;
|
||||
|
||||
@@ -53,7 +53,9 @@ describe("config form renderer", () => {
|
||||
|
||||
const tokenInput = container.querySelector("input[type='password']");
|
||||
expect(tokenInput).not.toBeNull();
|
||||
if (!tokenInput) {return;}
|
||||
if (!tokenInput) {
|
||||
return;
|
||||
}
|
||||
tokenInput.value = "abc123";
|
||||
tokenInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
expect(onPatch).toHaveBeenCalledWith(["gateway", "auth", "token"], "abc123");
|
||||
@@ -67,7 +69,9 @@ describe("config form renderer", () => {
|
||||
|
||||
const checkbox = container.querySelector("input[type='checkbox']");
|
||||
expect(checkbox).not.toBeNull();
|
||||
if (!checkbox) {return;}
|
||||
if (!checkbox) {
|
||||
return;
|
||||
}
|
||||
checkbox.checked = true;
|
||||
checkbox.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
expect(onPatch).toHaveBeenCalledWith(["enabled"], true);
|
||||
@@ -93,9 +97,7 @@ describe("config form renderer", () => {
|
||||
addButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(onPatch).toHaveBeenCalledWith(["allowFrom"], ["+1", ""]);
|
||||
|
||||
const removeButton = container.querySelector(
|
||||
".cfg-array__item-remove",
|
||||
);
|
||||
const removeButton = container.querySelector(".cfg-array__item-remove");
|
||||
expect(removeButton).not.toBeUndefined();
|
||||
removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(onPatch).toHaveBeenCalledWith(["allowFrom"], []);
|
||||
@@ -150,9 +152,7 @@ describe("config form renderer", () => {
|
||||
container,
|
||||
);
|
||||
|
||||
const removeButton = container.querySelector(
|
||||
".cfg-map__item-remove",
|
||||
);
|
||||
const removeButton = container.querySelector(".cfg-map__item-remove");
|
||||
expect(removeButton).not.toBeUndefined();
|
||||
removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(onPatch).toHaveBeenCalledWith(["slack"], {});
|
||||
|
||||
@@ -10,13 +10,19 @@ export type AgentsState = {
|
||||
};
|
||||
|
||||
export async function loadAgents(state: AgentsState) {
|
||||
if (!state.client || !state.connected) {return;}
|
||||
if (state.agentsLoading) {return;}
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
if (state.agentsLoading) {
|
||||
return;
|
||||
}
|
||||
state.agentsLoading = true;
|
||||
state.agentsError = null;
|
||||
try {
|
||||
const res = (await state.client.request("agents.list", {}));
|
||||
if (res) {state.agentsList = res;}
|
||||
const res = await state.client.request("agents.list", {});
|
||||
if (res) {
|
||||
state.agentsList = res;
|
||||
}
|
||||
} catch (err) {
|
||||
state.agentsError = String(err);
|
||||
} finally {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { GatewayBrowserClient } from "../gateway";
|
||||
import { normalizeAssistantIdentity, type AssistantIdentity } from "../assistant-identity";
|
||||
import { normalizeAssistantIdentity } from "../assistant-identity";
|
||||
|
||||
export type AssistantIdentityState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
@@ -14,12 +14,16 @@ export async function loadAssistantIdentity(
|
||||
state: AssistantIdentityState,
|
||||
opts?: { sessionKey?: string },
|
||||
) {
|
||||
if (!state.client || !state.connected) {return;}
|
||||
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 res = await state.client.request("agent.identity.get", params);
|
||||
if (!res) {
|
||||
return;
|
||||
}
|
||||
const normalized = normalizeAssistantIdentity(res);
|
||||
state.assistantName = normalized.name;
|
||||
state.assistantAvatar = normalized.avatar;
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import type { ChannelsStatusSnapshot } from "../types";
|
||||
import type { ChannelsState } from "./channels.types";
|
||||
|
||||
export type { ChannelsState };
|
||||
|
||||
export async function loadChannels(state: ChannelsState, probe: boolean) {
|
||||
if (!state.client || !state.connected) {return;}
|
||||
if (state.channelsLoading) {return;}
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
if (state.channelsLoading) {
|
||||
return;
|
||||
}
|
||||
state.channelsLoading = true;
|
||||
state.channelsError = null;
|
||||
try {
|
||||
const res = (await state.client.request("channels.status", {
|
||||
const res = await state.client.request("channels.status", {
|
||||
probe,
|
||||
timeoutMs: 8000,
|
||||
}));
|
||||
});
|
||||
state.channelsSnapshot = res;
|
||||
state.channelsLastSuccess = Date.now();
|
||||
} catch (err) {
|
||||
@@ -23,13 +26,15 @@ export async function loadChannels(state: ChannelsState, probe: boolean) {
|
||||
}
|
||||
|
||||
export async function startWhatsAppLogin(state: ChannelsState, force: boolean) {
|
||||
if (!state.client || !state.connected || state.whatsappBusy) {return;}
|
||||
if (!state.client || !state.connected || state.whatsappBusy) {
|
||||
return;
|
||||
}
|
||||
state.whatsappBusy = true;
|
||||
try {
|
||||
const res = (await state.client.request("web.login.start", {
|
||||
const res = await state.client.request("web.login.start", {
|
||||
force,
|
||||
timeoutMs: 30000,
|
||||
}));
|
||||
});
|
||||
state.whatsappLoginMessage = res.message ?? null;
|
||||
state.whatsappLoginQrDataUrl = res.qrDataUrl ?? null;
|
||||
state.whatsappLoginConnected = null;
|
||||
@@ -43,15 +48,19 @@ export async function startWhatsAppLogin(state: ChannelsState, force: boolean) {
|
||||
}
|
||||
|
||||
export async function waitWhatsAppLogin(state: ChannelsState) {
|
||||
if (!state.client || !state.connected || state.whatsappBusy) {return;}
|
||||
if (!state.client || !state.connected || state.whatsappBusy) {
|
||||
return;
|
||||
}
|
||||
state.whatsappBusy = true;
|
||||
try {
|
||||
const res = (await state.client.request("web.login.wait", {
|
||||
const res = await state.client.request("web.login.wait", {
|
||||
timeoutMs: 120000,
|
||||
}));
|
||||
});
|
||||
state.whatsappLoginMessage = res.message ?? null;
|
||||
state.whatsappLoginConnected = res.connected ?? null;
|
||||
if (res.connected) {state.whatsappLoginQrDataUrl = null;}
|
||||
if (res.connected) {
|
||||
state.whatsappLoginQrDataUrl = null;
|
||||
}
|
||||
} catch (err) {
|
||||
state.whatsappLoginMessage = String(err);
|
||||
state.whatsappLoginConnected = null;
|
||||
@@ -61,7 +70,9 @@ export async function waitWhatsAppLogin(state: ChannelsState) {
|
||||
}
|
||||
|
||||
export async function logoutWhatsApp(state: ChannelsState) {
|
||||
if (!state.client || !state.connected || state.whatsappBusy) {return;}
|
||||
if (!state.client || !state.connected || state.whatsappBusy) {
|
||||
return;
|
||||
}
|
||||
state.whatsappBusy = true;
|
||||
try {
|
||||
await state.client.request("channels.logout", { channel: "whatsapp" });
|
||||
|
||||
@@ -28,14 +28,16 @@ export type ChatEventPayload = {
|
||||
};
|
||||
|
||||
export async function loadChatHistory(state: ChatState) {
|
||||
if (!state.client || !state.connected) {return;}
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
state.chatLoading = true;
|
||||
state.lastError = null;
|
||||
try {
|
||||
const res = (await state.client.request("chat.history", {
|
||||
const res = await state.client.request("chat.history", {
|
||||
sessionKey: state.sessionKey,
|
||||
limit: 200,
|
||||
}));
|
||||
});
|
||||
state.chatMessages = Array.isArray(res.messages) ? res.messages : [];
|
||||
state.chatThinkingLevel = res.thinkingLevel ?? null;
|
||||
} catch (err) {
|
||||
@@ -47,7 +49,9 @@ export async function loadChatHistory(state: ChatState) {
|
||||
|
||||
function dataUrlToBase64(dataUrl: string): { content: string; mimeType: string } | null {
|
||||
const match = /^data:([^;]+);base64,(.+)$/.exec(dataUrl);
|
||||
if (!match) {return null;}
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
return { mimeType: match[1], content: match[2] };
|
||||
}
|
||||
|
||||
@@ -56,10 +60,14 @@ export async function sendChatMessage(
|
||||
message: string,
|
||||
attachments?: ChatAttachment[],
|
||||
): Promise<string | null> {
|
||||
if (!state.client || !state.connected) {return null;}
|
||||
if (!state.client || !state.connected) {
|
||||
return null;
|
||||
}
|
||||
const msg = message.trim();
|
||||
const hasAttachments = attachments && attachments.length > 0;
|
||||
if (!msg && !hasAttachments) {return null;}
|
||||
if (!msg && !hasAttachments) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
@@ -99,7 +107,9 @@ export async function sendChatMessage(
|
||||
? attachments
|
||||
.map((att) => {
|
||||
const parsed = dataUrlToBase64(att.dataUrl);
|
||||
if (!parsed) {return null;}
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: "image",
|
||||
mimeType: parsed.mimeType,
|
||||
@@ -139,7 +149,9 @@ export async function sendChatMessage(
|
||||
}
|
||||
|
||||
export async function abortChatRun(state: ChatState): Promise<boolean> {
|
||||
if (!state.client || !state.connected) {return false;}
|
||||
if (!state.client || !state.connected) {
|
||||
return false;
|
||||
}
|
||||
const runId = state.chatRunId;
|
||||
try {
|
||||
await state.client.request(
|
||||
@@ -154,13 +166,19 @@ export async function abortChatRun(state: ChatState): Promise<boolean> {
|
||||
}
|
||||
|
||||
export function handleChatEvent(state: ChatState, payload?: ChatEventPayload) {
|
||||
if (!payload) {return null;}
|
||||
if (payload.sessionKey !== state.sessionKey) {return null;}
|
||||
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") {return "final";}
|
||||
if (payload.state === "final") {
|
||||
return "final";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -35,11 +35,13 @@ export type ConfigState = {
|
||||
};
|
||||
|
||||
export async function loadConfig(state: ConfigState) {
|
||||
if (!state.client || !state.connected) {return;}
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
state.configLoading = true;
|
||||
state.lastError = null;
|
||||
try {
|
||||
const res = (await state.client.request("config.get", {}));
|
||||
const res = await state.client.request("config.get", {});
|
||||
applyConfigSnapshot(state, res);
|
||||
} catch (err) {
|
||||
state.lastError = String(err);
|
||||
@@ -49,11 +51,15 @@ export async function loadConfig(state: ConfigState) {
|
||||
}
|
||||
|
||||
export async function loadConfigSchema(state: ConfigState) {
|
||||
if (!state.client || !state.connected) {return;}
|
||||
if (state.configSchemaLoading) {return;}
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
if (state.configSchemaLoading) {
|
||||
return;
|
||||
}
|
||||
state.configSchemaLoading = true;
|
||||
try {
|
||||
const res = (await state.client.request("config.schema", {}));
|
||||
const res = await state.client.request("config.schema", {});
|
||||
applyConfigSchema(state, res);
|
||||
} catch (err) {
|
||||
state.lastError = String(err);
|
||||
@@ -94,7 +100,9 @@ export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot
|
||||
}
|
||||
|
||||
export async function saveConfig(state: ConfigState) {
|
||||
if (!state.client || !state.connected) {return;}
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
state.configSaving = true;
|
||||
state.lastError = null;
|
||||
try {
|
||||
@@ -118,7 +126,9 @@ export async function saveConfig(state: ConfigState) {
|
||||
}
|
||||
|
||||
export async function applyConfig(state: ConfigState) {
|
||||
if (!state.client || !state.connected) {return;}
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
state.configApplying = true;
|
||||
state.lastError = null;
|
||||
try {
|
||||
@@ -146,7 +156,9 @@ export async function applyConfig(state: ConfigState) {
|
||||
}
|
||||
|
||||
export async function runUpdate(state: ConfigState) {
|
||||
if (!state.client || !state.connected) {return;}
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
state.updateRunning = true;
|
||||
state.lastError = null;
|
||||
try {
|
||||
|
||||
@@ -14,19 +14,25 @@ export function setPathValue(
|
||||
path: Array<string | number>,
|
||||
value: unknown,
|
||||
) {
|
||||
if (path.length === 0) {return;}
|
||||
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 (!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;}
|
||||
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>);
|
||||
@@ -36,7 +42,9 @@ export function setPathValue(
|
||||
}
|
||||
const lastKey = path[path.length - 1];
|
||||
if (typeof lastKey === "number") {
|
||||
if (Array.isArray(current)) {current[lastKey] = value;}
|
||||
if (Array.isArray(current)) {
|
||||
current[lastKey] = value;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (typeof current === "object" && current != null) {
|
||||
@@ -48,22 +56,32 @@ export function removePathValue(
|
||||
obj: Record<string, unknown> | unknown[],
|
||||
path: Array<string | number>,
|
||||
) {
|
||||
if (path.length === 0) {return;}
|
||||
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;}
|
||||
if (!Array.isArray(current)) {
|
||||
return;
|
||||
}
|
||||
current = current[key] as Record<string, unknown> | unknown[];
|
||||
} else {
|
||||
if (typeof current !== "object" || current == null) {return;}
|
||||
if (typeof current !== "object" || current == null) {
|
||||
return;
|
||||
}
|
||||
current = (current as Record<string, unknown>)[key] as Record<string, unknown> | unknown[];
|
||||
}
|
||||
if (current == null) {return;}
|
||||
if (current == null) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const lastKey = path[path.length - 1];
|
||||
if (typeof lastKey === "number") {
|
||||
if (Array.isArray(current)) {current.splice(lastKey, 1);}
|
||||
if (Array.isArray(current)) {
|
||||
current.splice(lastKey, 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (typeof current === "object" && current != null) {
|
||||
|
||||
@@ -17,9 +17,11 @@ export type CronState = {
|
||||
};
|
||||
|
||||
export async function loadCronStatus(state: CronState) {
|
||||
if (!state.client || !state.connected) {return;}
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = (await state.client.request("cron.status", {}));
|
||||
const res = await state.client.request("cron.status", {});
|
||||
state.cronStatus = res;
|
||||
} catch (err) {
|
||||
state.cronError = String(err);
|
||||
@@ -27,14 +29,18 @@ export async function loadCronStatus(state: CronState) {
|
||||
}
|
||||
|
||||
export async function loadCronJobs(state: CronState) {
|
||||
if (!state.client || !state.connected) {return;}
|
||||
if (state.cronLoading) {return;}
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
if (state.cronLoading) {
|
||||
return;
|
||||
}
|
||||
state.cronLoading = true;
|
||||
state.cronError = null;
|
||||
try {
|
||||
const res = (await state.client.request("cron.list", {
|
||||
const res = await state.client.request("cron.list", {
|
||||
includeDisabled: true,
|
||||
}));
|
||||
});
|
||||
state.cronJobs = Array.isArray(res.jobs) ? res.jobs : [];
|
||||
} catch (err) {
|
||||
state.cronError = String(err);
|
||||
@@ -46,29 +52,39 @@ export async function loadCronJobs(state: CronState) {
|
||||
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.");}
|
||||
if (!Number.isFinite(ms)) {
|
||||
throw new Error("Invalid run time.");
|
||||
}
|
||||
return { kind: "at" as const, atMs: ms };
|
||||
}
|
||||
if (form.scheduleKind === "every") {
|
||||
const amount = toNumber(form.everyAmount, 0);
|
||||
if (amount <= 0) {throw new Error("Invalid interval amount.");}
|
||||
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 (!expr) {
|
||||
throw new Error("Cron expression required.");
|
||||
}
|
||||
return { kind: "cron" as const, expr, tz: form.cronTz.trim() || undefined };
|
||||
}
|
||||
|
||||
export function buildCronPayload(form: CronFormState) {
|
||||
if (form.payloadKind === "systemEvent") {
|
||||
const text = form.payloadText.trim();
|
||||
if (!text) {throw new Error("System event text required.");}
|
||||
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.");}
|
||||
if (!message) {
|
||||
throw new Error("Agent message required.");
|
||||
}
|
||||
const payload: {
|
||||
kind: "agentTurn";
|
||||
message: string;
|
||||
@@ -77,16 +93,26 @@ export function buildCronPayload(form: CronFormState) {
|
||||
to?: string;
|
||||
timeoutSeconds?: number;
|
||||
} = { kind: "agentTurn", message };
|
||||
if (form.deliver) {payload.deliver = true;}
|
||||
if (form.channel) {payload.channel = form.channel;}
|
||||
if (form.to.trim()) {payload.to = form.to.trim();}
|
||||
if (form.deliver) {
|
||||
payload.deliver = true;
|
||||
}
|
||||
if (form.channel) {
|
||||
payload.channel = form.channel;
|
||||
}
|
||||
if (form.to.trim()) {
|
||||
payload.to = form.to.trim();
|
||||
}
|
||||
const timeoutSeconds = toNumber(form.timeoutSeconds, 0);
|
||||
if (timeoutSeconds > 0) {payload.timeoutSeconds = timeoutSeconds;}
|
||||
if (timeoutSeconds > 0) {
|
||||
payload.timeoutSeconds = timeoutSeconds;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function addCronJob(state: CronState) {
|
||||
if (!state.client || !state.connected || state.cronBusy) {return;}
|
||||
if (!state.client || !state.connected || state.cronBusy) {
|
||||
return;
|
||||
}
|
||||
state.cronBusy = true;
|
||||
state.cronError = null;
|
||||
try {
|
||||
@@ -107,7 +133,9 @@ export async function addCronJob(state: CronState) {
|
||||
? { postToMainPrefix: state.cronForm.postToMainPrefix.trim() }
|
||||
: undefined,
|
||||
};
|
||||
if (!job.name) {throw new Error("Name required.");}
|
||||
if (!job.name) {
|
||||
throw new Error("Name required.");
|
||||
}
|
||||
await state.client.request("cron.add", job);
|
||||
state.cronForm = {
|
||||
...state.cronForm,
|
||||
@@ -125,7 +153,9 @@ export async function addCronJob(state: CronState) {
|
||||
}
|
||||
|
||||
export async function toggleCronJob(state: CronState, job: CronJob, enabled: boolean) {
|
||||
if (!state.client || !state.connected || state.cronBusy) {return;}
|
||||
if (!state.client || !state.connected || state.cronBusy) {
|
||||
return;
|
||||
}
|
||||
state.cronBusy = true;
|
||||
state.cronError = null;
|
||||
try {
|
||||
@@ -140,7 +170,9 @@ export async function toggleCronJob(state: CronState, job: CronJob, enabled: boo
|
||||
}
|
||||
|
||||
export async function runCronJob(state: CronState, job: CronJob) {
|
||||
if (!state.client || !state.connected || state.cronBusy) {return;}
|
||||
if (!state.client || !state.connected || state.cronBusy) {
|
||||
return;
|
||||
}
|
||||
state.cronBusy = true;
|
||||
state.cronError = null;
|
||||
try {
|
||||
@@ -154,7 +186,9 @@ export async function runCronJob(state: CronState, job: CronJob) {
|
||||
}
|
||||
|
||||
export async function removeCronJob(state: CronState, job: CronJob) {
|
||||
if (!state.client || !state.connected || state.cronBusy) {return;}
|
||||
if (!state.client || !state.connected || state.cronBusy) {
|
||||
return;
|
||||
}
|
||||
state.cronBusy = true;
|
||||
state.cronError = null;
|
||||
try {
|
||||
@@ -173,12 +207,14 @@ export async function removeCronJob(state: CronState, job: CronJob) {
|
||||
}
|
||||
|
||||
export async function loadCronRuns(state: CronState, jobId: string) {
|
||||
if (!state.client || !state.connected) {return;}
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = (await state.client.request("cron.runs", {
|
||||
const res = await state.client.request("cron.runs", {
|
||||
id: jobId,
|
||||
limit: 50,
|
||||
}));
|
||||
});
|
||||
state.cronRunsJobId = jobId;
|
||||
state.cronRuns = Array.isArray(res.entries) ? res.entries : [];
|
||||
} catch (err) {
|
||||
|
||||
@@ -16,8 +16,12 @@ export type DebugState = {
|
||||
};
|
||||
|
||||
export async function loadDebug(state: DebugState) {
|
||||
if (!state.client || !state.connected) {return;}
|
||||
if (state.debugLoading) {return;}
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
if (state.debugLoading) {
|
||||
return;
|
||||
}
|
||||
state.debugLoading = true;
|
||||
try {
|
||||
const [status, health, models, heartbeat] = await Promise.all([
|
||||
@@ -39,7 +43,9 @@ export async function loadDebug(state: DebugState) {
|
||||
}
|
||||
|
||||
export async function callDebugMethod(state: DebugState) {
|
||||
if (!state.client || !state.connected) {return;}
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
state.debugCallError = null;
|
||||
state.debugCallResult = null;
|
||||
try {
|
||||
|
||||
@@ -46,25 +46,35 @@ export type DevicesState = {
|
||||
};
|
||||
|
||||
export async function loadDevices(state: DevicesState, opts?: { quiet?: boolean }) {
|
||||
if (!state.client || !state.connected) {return;}
|
||||
if (state.devicesLoading) {return;}
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
if (state.devicesLoading) {
|
||||
return;
|
||||
}
|
||||
state.devicesLoading = true;
|
||||
if (!opts?.quiet) {state.devicesError = null;}
|
||||
if (!opts?.quiet) {
|
||||
state.devicesError = null;
|
||||
}
|
||||
try {
|
||||
const res = (await state.client.request("device.pair.list", {}));
|
||||
const res = await state.client.request("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);}
|
||||
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;}
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await state.client.request("device.pair.approve", { requestId });
|
||||
await loadDevices(state);
|
||||
@@ -74,9 +84,13 @@ export async function approveDevicePairing(state: DevicesState, requestId: strin
|
||||
}
|
||||
|
||||
export async function rejectDevicePairing(state: DevicesState, requestId: string) {
|
||||
if (!state.client || !state.connected) {return;}
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
const confirmed = window.confirm("Reject this device pairing request?");
|
||||
if (!confirmed) {return;}
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await state.client.request("device.pair.reject", { requestId });
|
||||
await loadDevices(state);
|
||||
@@ -89,9 +103,11 @@ export async function rotateDeviceToken(
|
||||
state: DevicesState,
|
||||
params: { deviceId: string; role: string; scopes?: string[] },
|
||||
) {
|
||||
if (!state.client || !state.connected) {return;}
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = (await state.client.request("device.token.rotate", params));
|
||||
const res = await state.client.request("device.token.rotate", params);
|
||||
if (res?.token) {
|
||||
const identity = await loadOrCreateDeviceIdentity();
|
||||
const role = res.role ?? params.role;
|
||||
@@ -115,9 +131,13 @@ export async function revokeDeviceToken(
|
||||
state: DevicesState,
|
||||
params: { deviceId: string; role: string },
|
||||
) {
|
||||
if (!state.client || !state.connected) {return;}
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
const confirmed = window.confirm(`Revoke token for ${params.deviceId} (${params.role})?`);
|
||||
if (!confirmed) {return;}
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await state.client.request("device.token.revoke", params);
|
||||
const identity = await loadOrCreateDeviceIdentity();
|
||||
|
||||
@@ -28,15 +28,23 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
}
|
||||
|
||||
export function parseExecApprovalRequested(payload: unknown): ExecApprovalRequest | null {
|
||||
if (!isRecord(payload)) {return 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;}
|
||||
if (!id || !isRecord(request)) {
|
||||
return null;
|
||||
}
|
||||
const command = typeof request.command === "string" ? request.command.trim() : "";
|
||||
if (!command) {return null;}
|
||||
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;}
|
||||
if (!createdAtMs || !expiresAtMs) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id,
|
||||
request: {
|
||||
@@ -55,9 +63,13 @@ export function parseExecApprovalRequested(payload: unknown): ExecApprovalReques
|
||||
}
|
||||
|
||||
export function parseExecApprovalResolved(payload: unknown): ExecApprovalResolved | null {
|
||||
if (!isRecord(payload)) {return null;}
|
||||
if (!isRecord(payload)) {
|
||||
return null;
|
||||
}
|
||||
const id = typeof payload.id === "string" ? payload.id.trim() : "";
|
||||
if (!id) {return null;}
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id,
|
||||
decision: typeof payload.decision === "string" ? payload.decision : null,
|
||||
|
||||
@@ -56,7 +56,9 @@ function resolveExecApprovalsRpc(target?: ExecApprovalsTarget | null): {
|
||||
return { method: "exec.approvals.get", params: {} };
|
||||
}
|
||||
const nodeId = target.nodeId.trim();
|
||||
if (!nodeId) {return null;}
|
||||
if (!nodeId) {
|
||||
return null;
|
||||
}
|
||||
return { method: "exec.approvals.node.get", params: { nodeId } };
|
||||
}
|
||||
|
||||
@@ -68,7 +70,9 @@ function resolveExecApprovalsSaveRpc(
|
||||
return { method: "exec.approvals.set", params };
|
||||
}
|
||||
const nodeId = target.nodeId.trim();
|
||||
if (!nodeId) {return null;}
|
||||
if (!nodeId) {
|
||||
return null;
|
||||
}
|
||||
return { method: "exec.approvals.node.set", params: { ...params, nodeId } };
|
||||
}
|
||||
|
||||
@@ -76,8 +80,12 @@ export async function loadExecApprovals(
|
||||
state: ExecApprovalsState,
|
||||
target?: ExecApprovalsTarget | null,
|
||||
) {
|
||||
if (!state.client || !state.connected) {return;}
|
||||
if (state.execApprovalsLoading) {return;}
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
if (state.execApprovalsLoading) {
|
||||
return;
|
||||
}
|
||||
state.execApprovalsLoading = true;
|
||||
state.lastError = null;
|
||||
try {
|
||||
@@ -86,7 +94,7 @@ export async function loadExecApprovals(
|
||||
state.lastError = "Select a node before loading exec approvals.";
|
||||
return;
|
||||
}
|
||||
const res = (await state.client.request(rpc.method, rpc.params));
|
||||
const res = await state.client.request(rpc.method, rpc.params);
|
||||
applyExecApprovalsSnapshot(state, res);
|
||||
} catch (err) {
|
||||
state.lastError = String(err);
|
||||
@@ -109,7 +117,9 @@ export async function saveExecApprovals(
|
||||
state: ExecApprovalsState,
|
||||
target?: ExecApprovalsTarget | null,
|
||||
) {
|
||||
if (!state.client || !state.connected) {return;}
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
state.execApprovalsSaving = true;
|
||||
state.lastError = null;
|
||||
try {
|
||||
|
||||
@@ -19,12 +19,18 @@ 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;}
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) {return null;}
|
||||
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as unknown;
|
||||
if (!parsed || typeof parsed !== "object") {return null;}
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
return null;
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
@@ -32,13 +38,17 @@ function parseMaybeJsonString(value: unknown) {
|
||||
}
|
||||
|
||||
function normalizeLevel(value: unknown): LogLevel | null {
|
||||
if (typeof value !== "string") {return 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 };}
|
||||
if (!line.trim()) {
|
||||
return { raw: line, message: line };
|
||||
}
|
||||
try {
|
||||
const obj = JSON.parse(line) as Record<string, unknown>;
|
||||
const meta =
|
||||
@@ -50,25 +60,28 @@ export function parseLogLine(line: string): LogEntry {
|
||||
const level = normalizeLevel(meta?.logLevelName ?? meta?.level);
|
||||
|
||||
const contextCandidate =
|
||||
typeof obj["0"] === "string"
|
||||
? (obj["0"])
|
||||
: typeof meta?.name === "string"
|
||||
? (meta?.name)
|
||||
: null;
|
||||
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 (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;}
|
||||
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,
|
||||
@@ -84,9 +97,15 @@ export function parseLogLine(line: string): LogEntry {
|
||||
}
|
||||
|
||||
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;}
|
||||
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", {
|
||||
@@ -103,20 +122,26 @@ export async function loadLogs(state: LogsState, opts?: { reset?: boolean; quiet
|
||||
reset?: boolean;
|
||||
};
|
||||
const lines = Array.isArray(payload.lines)
|
||||
? (payload.lines.filter((line) => typeof line === "string"))
|
||||
? 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;}
|
||||
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;}
|
||||
if (!opts?.quiet) {
|
||||
state.logsLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,15 +9,23 @@ export type NodesState = {
|
||||
};
|
||||
|
||||
export async function loadNodes(state: NodesState, opts?: { quiet?: boolean }) {
|
||||
if (!state.client || !state.connected) {return;}
|
||||
if (state.nodesLoading) {return;}
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
if (state.nodesLoading) {
|
||||
return;
|
||||
}
|
||||
state.nodesLoading = true;
|
||||
if (!opts?.quiet) {state.lastError = null;}
|
||||
if (!opts?.quiet) {
|
||||
state.lastError = null;
|
||||
}
|
||||
try {
|
||||
const res = (await state.client.request("node.list", {}));
|
||||
const res = await state.client.request("node.list", {});
|
||||
state.nodes = Array.isArray(res.nodes) ? res.nodes : [];
|
||||
} catch (err) {
|
||||
if (!opts?.quiet) {state.lastError = String(err);}
|
||||
if (!opts?.quiet) {
|
||||
state.lastError = String(err);
|
||||
}
|
||||
} finally {
|
||||
state.nodesLoading = false;
|
||||
}
|
||||
|
||||
@@ -11,13 +11,17 @@ export type PresenceState = {
|
||||
};
|
||||
|
||||
export async function loadPresence(state: PresenceState) {
|
||||
if (!state.client || !state.connected) {return;}
|
||||
if (state.presenceLoading) {return;}
|
||||
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", {}));
|
||||
const res = await state.client.request("system-presence", {});
|
||||
if (Array.isArray(res)) {
|
||||
state.presenceEntries = res;
|
||||
state.presenceStatus = res.length === 0 ? "No instances yet." : null;
|
||||
|
||||
@@ -23,8 +23,12 @@ export async function loadSessions(
|
||||
includeUnknown?: boolean;
|
||||
},
|
||||
) {
|
||||
if (!state.client || !state.connected) {return;}
|
||||
if (state.sessionsLoading) {return;}
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
if (state.sessionsLoading) {
|
||||
return;
|
||||
}
|
||||
state.sessionsLoading = true;
|
||||
state.sessionsError = null;
|
||||
try {
|
||||
@@ -36,10 +40,16 @@ export async function loadSessions(
|
||||
includeGlobal,
|
||||
includeUnknown,
|
||||
};
|
||||
if (activeMinutes > 0) {params.activeMinutes = activeMinutes;}
|
||||
if (limit > 0) {params.limit = limit;}
|
||||
const res = (await state.client.request("sessions.list", params));
|
||||
if (res) {state.sessionsResult = res;}
|
||||
if (activeMinutes > 0) {
|
||||
params.activeMinutes = activeMinutes;
|
||||
}
|
||||
if (limit > 0) {
|
||||
params.limit = limit;
|
||||
}
|
||||
const res = await state.client.request("sessions.list", params);
|
||||
if (res) {
|
||||
state.sessionsResult = res;
|
||||
}
|
||||
} catch (err) {
|
||||
state.sessionsError = String(err);
|
||||
} finally {
|
||||
@@ -57,12 +67,22 @@ export async function patchSession(
|
||||
reasoningLevel?: string | null;
|
||||
},
|
||||
) {
|
||||
if (!state.client || !state.connected) {return;}
|
||||
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;}
|
||||
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);
|
||||
@@ -72,12 +92,18 @@ export async function patchSession(
|
||||
}
|
||||
|
||||
export async function deleteSession(state: SessionsState, key: string) {
|
||||
if (!state.client || !state.connected) {return;}
|
||||
if (state.sessionsLoading) {return;}
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
if (state.sessionsLoading) {
|
||||
return;
|
||||
}
|
||||
const confirmed = window.confirm(
|
||||
`Delete session "${key}"?\n\nDeletes the session entry and archives its transcript.`,
|
||||
);
|
||||
if (!confirmed) {return;}
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
state.sessionsLoading = true;
|
||||
state.sessionsError = null;
|
||||
try {
|
||||
|
||||
@@ -24,15 +24,22 @@ type LoadSkillsOptions = {
|
||||
};
|
||||
|
||||
function setSkillMessage(state: SkillsState, key: string, message?: SkillMessage) {
|
||||
if (!key.trim()) {return;}
|
||||
if (!key.trim()) {
|
||||
return;
|
||||
}
|
||||
const next = { ...state.skillMessages };
|
||||
if (message) {next[key] = message;}
|
||||
else {delete next[key];}
|
||||
if (message) {
|
||||
next[key] = message;
|
||||
} else {
|
||||
delete next[key];
|
||||
}
|
||||
state.skillMessages = next;
|
||||
}
|
||||
|
||||
function getErrorMessage(err: unknown) {
|
||||
if (err instanceof Error) {return err.message;}
|
||||
if (err instanceof Error) {
|
||||
return err.message;
|
||||
}
|
||||
return String(err);
|
||||
}
|
||||
|
||||
@@ -40,13 +47,19 @@ 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;}
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
if (state.skillsLoading) {
|
||||
return;
|
||||
}
|
||||
state.skillsLoading = true;
|
||||
state.skillsError = null;
|
||||
try {
|
||||
const res = (await state.client.request("skills.status", {}));
|
||||
if (res) {state.skillsReport = res;}
|
||||
const res = await state.client.request("skills.status", {});
|
||||
if (res) {
|
||||
state.skillsReport = res;
|
||||
}
|
||||
} catch (err) {
|
||||
state.skillsError = getErrorMessage(err);
|
||||
} finally {
|
||||
@@ -59,7 +72,9 @@ export function updateSkillEdit(state: SkillsState, skillKey: string, value: str
|
||||
}
|
||||
|
||||
export async function updateSkillEnabled(state: SkillsState, skillKey: string, enabled: boolean) {
|
||||
if (!state.client || !state.connected) {return;}
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
state.skillsBusyKey = skillKey;
|
||||
state.skillsError = null;
|
||||
try {
|
||||
@@ -82,7 +97,9 @@ export async function updateSkillEnabled(state: SkillsState, skillKey: string, e
|
||||
}
|
||||
|
||||
export async function saveSkillApiKey(state: SkillsState, skillKey: string) {
|
||||
if (!state.client || !state.connected) {return;}
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
state.skillsBusyKey = skillKey;
|
||||
state.skillsError = null;
|
||||
try {
|
||||
@@ -111,15 +128,17 @@ export async function installSkill(
|
||||
name: string,
|
||||
installId: string,
|
||||
) {
|
||||
if (!state.client || !state.connected) {return;}
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
state.skillsBusyKey = skillKey;
|
||||
state.skillsError = null;
|
||||
try {
|
||||
const result = (await state.client.request("skills.install", {
|
||||
const result = await state.client.request("skills.install", {
|
||||
name,
|
||||
installId,
|
||||
timeoutMs: 120000,
|
||||
}));
|
||||
});
|
||||
await loadSkills(state);
|
||||
setSkillMessage(state, skillKey, {
|
||||
kind: "success",
|
||||
|
||||
@@ -18,11 +18,15 @@ function normalizeRole(role: string): string {
|
||||
}
|
||||
|
||||
function normalizeScopes(scopes: string[] | undefined): string[] {
|
||||
if (!Array.isArray(scopes)) {return [];}
|
||||
if (!Array.isArray(scopes)) {
|
||||
return [];
|
||||
}
|
||||
const out = new Set<string>();
|
||||
for (const scope of scopes) {
|
||||
const trimmed = scope.trim();
|
||||
if (trimmed) {out.add(trimmed);}
|
||||
if (trimmed) {
|
||||
out.add(trimmed);
|
||||
}
|
||||
}
|
||||
return [...out].toSorted();
|
||||
}
|
||||
@@ -30,11 +34,19 @@ function normalizeScopes(scopes: string[] | undefined): string[] {
|
||||
function readStore(): DeviceAuthStore | null {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) {return null;}
|
||||
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;}
|
||||
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;
|
||||
@@ -54,10 +66,14 @@ export function loadDeviceAuthToken(params: {
|
||||
role: string;
|
||||
}): DeviceAuthEntry | null {
|
||||
const store = readStore();
|
||||
if (!store || store.deviceId !== params.deviceId) {return null;}
|
||||
if (!store || store.deviceId !== params.deviceId) {
|
||||
return null;
|
||||
}
|
||||
const role = normalizeRole(params.role);
|
||||
const entry = store.tokens[role];
|
||||
if (!entry || typeof entry.token !== "string") {return null;}
|
||||
if (!entry || typeof entry.token !== "string") {
|
||||
return null;
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
@@ -90,9 +106,13 @@ export function storeDeviceAuthToken(params: {
|
||||
|
||||
export function clearDeviceAuthToken(params: { deviceId: string; role: string }) {
|
||||
const store = readStore();
|
||||
if (!store || store.deviceId !== params.deviceId) {return;}
|
||||
if (!store || store.deviceId !== params.deviceId) {
|
||||
return;
|
||||
}
|
||||
const role = normalizeRole(params.role);
|
||||
if (!store.tokens[role]) {return;}
|
||||
if (!store.tokens[role]) {
|
||||
return;
|
||||
}
|
||||
const next = { ...store, tokens: { ...store.tokens } };
|
||||
delete next.tokens[role];
|
||||
writeStore(next);
|
||||
|
||||
@@ -18,7 +18,9 @@ const STORAGE_KEY = "openclaw-device-identity-v1";
|
||||
|
||||
function base64UrlEncode(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
for (const byte of bytes) {binary += String.fromCharCode(byte);}
|
||||
for (const byte of bytes) {
|
||||
binary += String.fromCharCode(byte);
|
||||
}
|
||||
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/g, "");
|
||||
}
|
||||
|
||||
@@ -27,7 +29,9 @@ function base64UrlDecode(input: string): Uint8Array {
|
||||
const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4);
|
||||
const binary = atob(padded);
|
||||
const out = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i += 1) {out[i] = binary.charCodeAt(i);}
|
||||
for (let i = 0; i < binary.length; i += 1) {
|
||||
out[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { OpenClawApp } from "./app";
|
||||
|
||||
// oxlint-disable-next-line typescript/unbound-method
|
||||
const originalConnect = OpenClawApp.prototype.connect;
|
||||
|
||||
function mountApp(pathname: string) {
|
||||
|
||||
@@ -1,44 +1,70 @@
|
||||
import { stripReasoningTagsFromText } from "../../../src/shared/text/reasoning-tags.js";
|
||||
|
||||
export function formatMs(ms?: number | null): string {
|
||||
if (!ms && ms !== 0) {return "n/a";}
|
||||
if (!ms && ms !== 0) {
|
||||
return "n/a";
|
||||
}
|
||||
return new Date(ms).toLocaleString();
|
||||
}
|
||||
|
||||
export function formatAgo(ms?: number | null): string {
|
||||
if (!ms && ms !== 0) {return "n/a";}
|
||||
if (!ms && ms !== 0) {
|
||||
return "n/a";
|
||||
}
|
||||
const diff = Date.now() - ms;
|
||||
if (diff < 0) {return "just now";}
|
||||
if (diff < 0) {
|
||||
return "just now";
|
||||
}
|
||||
const sec = Math.round(diff / 1000);
|
||||
if (sec < 60) {return `${sec}s ago`;}
|
||||
if (sec < 60) {
|
||||
return `${sec}s ago`;
|
||||
}
|
||||
const min = Math.round(sec / 60);
|
||||
if (min < 60) {return `${min}m ago`;}
|
||||
if (min < 60) {
|
||||
return `${min}m ago`;
|
||||
}
|
||||
const hr = Math.round(min / 60);
|
||||
if (hr < 48) {return `${hr}h ago`;}
|
||||
if (hr < 48) {
|
||||
return `${hr}h ago`;
|
||||
}
|
||||
const day = Math.round(hr / 24);
|
||||
return `${day}d ago`;
|
||||
}
|
||||
|
||||
export function formatDurationMs(ms?: number | null): string {
|
||||
if (!ms && ms !== 0) {return "n/a";}
|
||||
if (ms < 1000) {return `${ms}ms`;}
|
||||
if (!ms && ms !== 0) {
|
||||
return "n/a";
|
||||
}
|
||||
if (ms < 1000) {
|
||||
return `${ms}ms`;
|
||||
}
|
||||
const sec = Math.round(ms / 1000);
|
||||
if (sec < 60) {return `${sec}s`;}
|
||||
if (sec < 60) {
|
||||
return `${sec}s`;
|
||||
}
|
||||
const min = Math.round(sec / 60);
|
||||
if (min < 60) {return `${min}m`;}
|
||||
if (min < 60) {
|
||||
return `${min}m`;
|
||||
}
|
||||
const hr = Math.round(min / 60);
|
||||
if (hr < 48) {return `${hr}h`;}
|
||||
if (hr < 48) {
|
||||
return `${hr}h`;
|
||||
}
|
||||
const day = Math.round(hr / 24);
|
||||
return `${day}d`;
|
||||
}
|
||||
|
||||
export function formatList(values?: Array<string | null | undefined>): string {
|
||||
if (!values || values.length === 0) {return "none";}
|
||||
if (!values || values.length === 0) {
|
||||
return "none";
|
||||
}
|
||||
return values.filter((v): v is string => Boolean(v && v.trim())).join(", ");
|
||||
}
|
||||
|
||||
export function clampText(value: string, max = 120): string {
|
||||
if (value.length <= max) {return value;}
|
||||
if (value.length <= max) {
|
||||
return value;
|
||||
}
|
||||
return `${value.slice(0, Math.max(0, max - 1))}…`;
|
||||
}
|
||||
|
||||
|
||||
@@ -91,36 +91,44 @@ export class GatewayBrowserClient {
|
||||
}
|
||||
|
||||
private connect() {
|
||||
if (this.closed) {return;}
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
this.ws = new WebSocket(this.opts.url);
|
||||
this.ws.onopen = () => this.queueConnect();
|
||||
this.ws.onmessage = (ev) => this.handleMessage(String(ev.data ?? ""));
|
||||
this.ws.onclose = (ev) => {
|
||||
this.ws.addEventListener("open", () => this.queueConnect());
|
||||
this.ws.addEventListener("message", (ev) => this.handleMessage(String(ev.data ?? "")));
|
||||
this.ws.addEventListener("close", (ev) => {
|
||||
const reason = String(ev.reason ?? "");
|
||||
this.ws = null;
|
||||
this.flushPending(new Error(`gateway closed (${ev.code}): ${reason}`));
|
||||
this.opts.onClose?.({ code: ev.code, reason });
|
||||
this.scheduleReconnect();
|
||||
};
|
||||
this.ws.onerror = () => {
|
||||
});
|
||||
this.ws.addEventListener("error", () => {
|
||||
// ignored; close handler will fire
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private scheduleReconnect() {
|
||||
if (this.closed) {return;}
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
const delay = this.backoffMs;
|
||||
this.backoffMs = Math.min(this.backoffMs * 1.7, 15_000);
|
||||
window.setTimeout(() => this.connect(), delay);
|
||||
}
|
||||
|
||||
private flushPending(err: Error) {
|
||||
for (const [, p] of this.pending) {p.reject(err);}
|
||||
for (const [, p] of this.pending) {
|
||||
p.reject(err);
|
||||
}
|
||||
this.pending.clear();
|
||||
}
|
||||
|
||||
private async sendConnect() {
|
||||
if (this.connectSent) {return;}
|
||||
if (this.connectSent) {
|
||||
return;
|
||||
}
|
||||
this.connectSent = true;
|
||||
if (this.connectTimer !== null) {
|
||||
window.clearTimeout(this.connectTimer);
|
||||
@@ -265,10 +273,15 @@ export class GatewayBrowserClient {
|
||||
if (frame.type === "res") {
|
||||
const res = parsed as GatewayResponseFrame;
|
||||
const pending = this.pending.get(res.id);
|
||||
if (!pending) {return;}
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
this.pending.delete(res.id);
|
||||
if (res.ok) {pending.resolve(res.payload);}
|
||||
else {pending.reject(new Error(res.error?.message ?? "request failed"));}
|
||||
if (res.ok) {
|
||||
pending.resolve(res.payload);
|
||||
} else {
|
||||
pending.reject(new Error(res.error?.message ?? "request failed"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -289,7 +302,9 @@ export class GatewayBrowserClient {
|
||||
private queueConnect() {
|
||||
this.connectNonce = null;
|
||||
this.connectSent = false;
|
||||
if (this.connectTimer !== null) {window.clearTimeout(this.connectTimer);}
|
||||
if (this.connectTimer !== null) {
|
||||
window.clearTimeout(this.connectTimer);
|
||||
}
|
||||
this.connectTimer = window.setTimeout(() => {
|
||||
void this.sendConnect();
|
||||
}, 750);
|
||||
|
||||
@@ -243,6 +243,8 @@ export function renderEmojiIcon(
|
||||
}
|
||||
|
||||
export function setEmojiIcon(target: HTMLElement | null, icon: string): void {
|
||||
if (!target) {return;}
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
target.textContent = icon;
|
||||
}
|
||||
|
||||
@@ -47,7 +47,9 @@ const markdownCache = new Map<string, string>();
|
||||
|
||||
function getCachedMarkdown(key: string): string | null {
|
||||
const cached = markdownCache.get(key);
|
||||
if (cached === undefined) {return null;}
|
||||
if (cached === undefined) {
|
||||
return null;
|
||||
}
|
||||
markdownCache.delete(key);
|
||||
markdownCache.set(key, cached);
|
||||
return cached;
|
||||
@@ -55,19 +57,29 @@ function getCachedMarkdown(key: string): string | null {
|
||||
|
||||
function setCachedMarkdown(key: string, value: string) {
|
||||
markdownCache.set(key, value);
|
||||
if (markdownCache.size <= MARKDOWN_CACHE_LIMIT) {return;}
|
||||
if (markdownCache.size <= MARKDOWN_CACHE_LIMIT) {
|
||||
return;
|
||||
}
|
||||
const oldest = markdownCache.keys().next().value;
|
||||
if (oldest) {markdownCache.delete(oldest);}
|
||||
if (oldest) {
|
||||
markdownCache.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
function installHooks() {
|
||||
if (hooksInstalled) {return;}
|
||||
if (hooksInstalled) {
|
||||
return;
|
||||
}
|
||||
hooksInstalled = true;
|
||||
|
||||
DOMPurify.addHook("afterSanitizeAttributes", (node) => {
|
||||
if (!(node instanceof HTMLAnchorElement)) {return;}
|
||||
if (!(node instanceof HTMLAnchorElement)) {
|
||||
return;
|
||||
}
|
||||
const href = node.getAttribute("href");
|
||||
if (!href) {return;}
|
||||
if (!href) {
|
||||
return;
|
||||
}
|
||||
node.setAttribute("rel", "noreferrer noopener");
|
||||
node.setAttribute("target", "_blank");
|
||||
});
|
||||
@@ -75,11 +87,15 @@ function installHooks() {
|
||||
|
||||
export function toSanitizedMarkdownHtml(markdown: string): string {
|
||||
const input = markdown.trim();
|
||||
if (!input) {return "";}
|
||||
if (!input) {
|
||||
return "";
|
||||
}
|
||||
installHooks();
|
||||
if (input.length <= MARKDOWN_CACHE_MAX_CHARS) {
|
||||
const cached = getCachedMarkdown(input);
|
||||
if (cached !== null) {return cached;}
|
||||
if (cached !== null) {
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
const truncated = truncateText(input, MARKDOWN_CHAR_LIMIT);
|
||||
const suffix = truncated.truncated
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { OpenClawApp } from "./app";
|
||||
import "../styles.css";
|
||||
|
||||
// oxlint-disable-next-line typescript/unbound-method
|
||||
const originalConnect = OpenClawApp.prototype.connect;
|
||||
|
||||
function mountApp(pathname: string) {
|
||||
@@ -117,7 +118,9 @@ describe("control UI routing", () => {
|
||||
|
||||
const initialContainer = app.querySelector(".chat-thread");
|
||||
expect(initialContainer).not.toBeNull();
|
||||
if (!initialContainer) {return;}
|
||||
if (!initialContainer) {
|
||||
return;
|
||||
}
|
||||
initialContainer.style.maxHeight = "180px";
|
||||
initialContainer.style.overflow = "auto";
|
||||
|
||||
@@ -134,11 +137,15 @@ describe("control UI routing", () => {
|
||||
|
||||
const container = app.querySelector(".chat-thread");
|
||||
expect(container).not.toBeNull();
|
||||
if (!container) {return;}
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
const maxScroll = container.scrollHeight - container.clientHeight;
|
||||
expect(maxScroll).toBeGreaterThan(0);
|
||||
for (let i = 0; i < 10; i++) {
|
||||
if (container.scrollTop === maxScroll) {break;}
|
||||
if (container.scrollTop === maxScroll) {
|
||||
break;
|
||||
}
|
||||
await nextFrame();
|
||||
}
|
||||
expect(container.scrollTop).toBe(maxScroll);
|
||||
|
||||
@@ -40,18 +40,30 @@ const TAB_PATHS: Record<Tab, string> = {
|
||||
const PATH_TO_TAB = new Map(Object.entries(TAB_PATHS).map(([tab, path]) => [path, tab as Tab]));
|
||||
|
||||
export function normalizeBasePath(basePath: string): string {
|
||||
if (!basePath) {return "";}
|
||||
if (!basePath) {
|
||||
return "";
|
||||
}
|
||||
let base = basePath.trim();
|
||||
if (!base.startsWith("/")) {base = `/${base}`;}
|
||||
if (base === "/") {return "";}
|
||||
if (base.endsWith("/")) {base = base.slice(0, -1);}
|
||||
if (!base.startsWith("/")) {
|
||||
base = `/${base}`;
|
||||
}
|
||||
if (base === "/") {
|
||||
return "";
|
||||
}
|
||||
if (base.endsWith("/")) {
|
||||
base = base.slice(0, -1);
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
export function normalizePath(path: string): string {
|
||||
if (!path) {return "/";}
|
||||
if (!path) {
|
||||
return "/";
|
||||
}
|
||||
let normalized = path.trim();
|
||||
if (!normalized.startsWith("/")) {normalized = `/${normalized}`;}
|
||||
if (!normalized.startsWith("/")) {
|
||||
normalized = `/${normalized}`;
|
||||
}
|
||||
if (normalized.length > 1 && normalized.endsWith("/")) {
|
||||
normalized = normalized.slice(0, -1);
|
||||
}
|
||||
@@ -75,8 +87,12 @@ export function tabFromPath(pathname: string, basePath = ""): Tab | null {
|
||||
}
|
||||
}
|
||||
let normalized = normalizePath(path).toLowerCase();
|
||||
if (normalized.endsWith("/index.html")) {normalized = "/";}
|
||||
if (normalized === "/") {return "chat";}
|
||||
if (normalized.endsWith("/index.html")) {
|
||||
normalized = "/";
|
||||
}
|
||||
if (normalized === "/") {
|
||||
return "chat";
|
||||
}
|
||||
return PATH_TO_TAB.get(normalized) ?? null;
|
||||
}
|
||||
|
||||
@@ -85,9 +101,13 @@ export function inferBasePathFromPathname(pathname: string): string {
|
||||
if (normalized.endsWith("/index.html")) {
|
||||
normalized = normalizePath(normalized.slice(0, -"/index.html".length));
|
||||
}
|
||||
if (normalized === "/") {return "";}
|
||||
if (normalized === "/") {
|
||||
return "";
|
||||
}
|
||||
const segments = normalized.split("/").filter(Boolean);
|
||||
if (segments.length === 0) {return "";}
|
||||
if (segments.length === 0) {
|
||||
return "";
|
||||
}
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const candidate = `/${segments.slice(i).join("/")}`.toLowerCase();
|
||||
if (PATH_TO_TAB.has(candidate)) {
|
||||
|
||||
@@ -15,22 +15,29 @@ export function formatPresenceAge(entry: PresenceEntry): string {
|
||||
}
|
||||
|
||||
export function formatNextRun(ms?: number | null) {
|
||||
if (!ms) {return "n/a";}
|
||||
if (!ms) {
|
||||
return "n/a";
|
||||
}
|
||||
return `${formatMs(ms)} (${formatAgo(ms)})`;
|
||||
}
|
||||
|
||||
export function formatSessionTokens(row: GatewaySessionRow) {
|
||||
if (row.totalTokens == null) {return "n/a";}
|
||||
if (row.totalTokens == null) {
|
||||
return "n/a";
|
||||
}
|
||||
const total = row.totalTokens ?? 0;
|
||||
const ctx = row.contextTokens ?? 0;
|
||||
return ctx ? `${total} / ${ctx}` : String(total);
|
||||
}
|
||||
|
||||
export function formatEventPayload(payload: unknown): string {
|
||||
if (payload == null) {return "";}
|
||||
if (payload == null) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(payload, null, 2);
|
||||
} catch {
|
||||
// oxlint-disable typescript/no-base-to-string
|
||||
return String(payload);
|
||||
}
|
||||
}
|
||||
@@ -45,13 +52,19 @@ export function formatCronState(job: CronJob) {
|
||||
|
||||
export function formatCronSchedule(job: CronJob) {
|
||||
const s = job.schedule;
|
||||
if (s.kind === "at") {return `At ${formatMs(s.atMs)}`;}
|
||||
if (s.kind === "every") {return `Every ${formatDurationMs(s.everyMs)}`;}
|
||||
if (s.kind === "at") {
|
||||
return `At ${formatMs(s.atMs)}`;
|
||||
}
|
||||
if (s.kind === "every") {
|
||||
return `Every ${formatDurationMs(s.everyMs)}`;
|
||||
}
|
||||
return `Cron ${s.expr}${s.tz ? ` (${s.tz})` : ""}`;
|
||||
}
|
||||
|
||||
export function formatCronPayload(job: CronJob) {
|
||||
const p = job.payload;
|
||||
if (p.kind === "systemEvent") {return `System: ${p.text}`;}
|
||||
if (p.kind === "systemEvent") {
|
||||
return `System: ${p.text}`;
|
||||
}
|
||||
return `Agent: ${p.message}`;
|
||||
}
|
||||
|
||||
@@ -36,7 +36,9 @@ export function loadSettings(): UiSettings {
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY);
|
||||
if (!raw) {return defaults;}
|
||||
if (!raw) {
|
||||
return defaults;
|
||||
}
|
||||
const parsed = JSON.parse(raw) as Partial<UiSettings>;
|
||||
return {
|
||||
gatewayUrl:
|
||||
|
||||
@@ -18,9 +18,15 @@ type DocumentWithViewTransition = Document & {
|
||||
};
|
||||
|
||||
const clamp01 = (value: number) => {
|
||||
if (Number.isNaN(value)) {return 0.5;}
|
||||
if (value <= 0) {return 0;}
|
||||
if (value >= 1) {return 1;}
|
||||
if (Number.isNaN(value)) {
|
||||
return 0.5;
|
||||
}
|
||||
if (value <= 0) {
|
||||
return 0;
|
||||
}
|
||||
if (value >= 1) {
|
||||
return 1;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
@@ -43,7 +49,9 @@ export const startThemeTransition = ({
|
||||
context,
|
||||
currentTheme,
|
||||
}: ThemeTransitionOptions) => {
|
||||
if (currentTheme === nextTheme) {return;}
|
||||
if (currentTheme === nextTheme) {
|
||||
return;
|
||||
}
|
||||
|
||||
const documentReference = globalThis.document ?? null;
|
||||
if (!documentReference) {
|
||||
|
||||
@@ -9,6 +9,8 @@ export function getSystemTheme(): ResolvedTheme {
|
||||
}
|
||||
|
||||
export function resolveTheme(mode: ThemeMode): ResolvedTheme {
|
||||
if (mode === "system") {return getSystemTheme();}
|
||||
if (mode === "system") {
|
||||
return getSystemTheme();
|
||||
}
|
||||
return mode;
|
||||
}
|
||||
|
||||
@@ -39,7 +39,9 @@ function normalizeToolName(name?: string): string {
|
||||
|
||||
function defaultTitle(name: string): string {
|
||||
const cleaned = name.replace(/_/g, " ").trim();
|
||||
if (!cleaned) {return "Tool";}
|
||||
if (!cleaned) {
|
||||
return "Tool";
|
||||
}
|
||||
return cleaned
|
||||
.split(/\s+/)
|
||||
.map((part) =>
|
||||
@@ -52,17 +54,25 @@ function defaultTitle(name: string): string {
|
||||
|
||||
function normalizeVerb(value?: string): string | undefined {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) {return undefined;}
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
return trimmed.replace(/_/g, " ");
|
||||
}
|
||||
|
||||
function coerceDisplayValue(value: unknown): string | undefined {
|
||||
if (value === null || value === undefined) {return undefined;}
|
||||
if (value === null || value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {return undefined;}
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
const firstLine = trimmed.split(/\r?\n/)[0]?.trim() ?? "";
|
||||
if (!firstLine) {return undefined;}
|
||||
if (!firstLine) {
|
||||
return undefined;
|
||||
}
|
||||
return firstLine.length > 160 ? `${firstLine.slice(0, 157)}…` : firstLine;
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
@@ -72,7 +82,9 @@ function coerceDisplayValue(value: unknown): string | undefined {
|
||||
const values = value
|
||||
.map((item) => coerceDisplayValue(item))
|
||||
.filter((item): item is string => Boolean(item));
|
||||
if (values.length === 0) {return undefined;}
|
||||
if (values.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const preview = values.slice(0, 3).join(", ");
|
||||
return values.length > 3 ? `${preview}…` : preview;
|
||||
}
|
||||
@@ -80,11 +92,17 @@ function coerceDisplayValue(value: unknown): string | undefined {
|
||||
}
|
||||
|
||||
function lookupValueByPath(args: unknown, path: string): unknown {
|
||||
if (!args || typeof args !== "object") {return undefined;}
|
||||
if (!args || typeof args !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
let current: unknown = args;
|
||||
for (const segment of path.split(".")) {
|
||||
if (!segment) {return undefined;}
|
||||
if (!current || typeof current !== "object") {return undefined;}
|
||||
if (!segment) {
|
||||
return undefined;
|
||||
}
|
||||
if (!current || typeof current !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const record = current as Record<string, unknown>;
|
||||
current = record[segment];
|
||||
}
|
||||
@@ -95,16 +113,22 @@ function resolveDetailFromKeys(args: unknown, keys: string[]): string | undefine
|
||||
for (const key of keys) {
|
||||
const value = lookupValueByPath(args, key);
|
||||
const display = coerceDisplayValue(value);
|
||||
if (display) {return display;}
|
||||
if (display) {
|
||||
return display;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveReadDetail(args: unknown): string | undefined {
|
||||
if (!args || typeof args !== "object") {return undefined;}
|
||||
if (!args || typeof args !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const record = args as Record<string, unknown>;
|
||||
const path = typeof record.path === "string" ? record.path : undefined;
|
||||
if (!path) {return undefined;}
|
||||
if (!path) {
|
||||
return undefined;
|
||||
}
|
||||
const offset = typeof record.offset === "number" ? record.offset : undefined;
|
||||
const limit = typeof record.limit === "number" ? record.limit : undefined;
|
||||
if (offset !== undefined && limit !== undefined) {
|
||||
@@ -114,7 +138,9 @@ function resolveReadDetail(args: unknown): string | undefined {
|
||||
}
|
||||
|
||||
function resolveWriteDetail(args: unknown): string | undefined {
|
||||
if (!args || typeof args !== "object") {return undefined;}
|
||||
if (!args || typeof args !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const record = args as Record<string, unknown>;
|
||||
const path = typeof record.path === "string" ? record.path : undefined;
|
||||
return path;
|
||||
@@ -124,7 +150,9 @@ function resolveActionSpec(
|
||||
spec: ToolDisplaySpec | undefined,
|
||||
action: string | undefined,
|
||||
): ToolDisplayActionSpec | undefined {
|
||||
if (!spec || !action) {return undefined;}
|
||||
if (!spec || !action) {
|
||||
return undefined;
|
||||
}
|
||||
return spec.actions?.[action] ?? undefined;
|
||||
}
|
||||
|
||||
@@ -148,7 +176,9 @@ export function resolveToolDisplay(params: {
|
||||
const verb = normalizeVerb(actionSpec?.label ?? action);
|
||||
|
||||
let detail: string | undefined;
|
||||
if (key === "read") {detail = resolveReadDetail(params.args);}
|
||||
if (key === "read") {
|
||||
detail = resolveReadDetail(params.args);
|
||||
}
|
||||
if (!detail && (key === "write" || key === "edit" || key === "attach")) {
|
||||
detail = resolveWriteDetail(params.args);
|
||||
}
|
||||
@@ -178,9 +208,15 @@ export function resolveToolDisplay(params: {
|
||||
|
||||
export function formatToolDetail(display: ToolDisplay): string | undefined {
|
||||
const parts: string[] = [];
|
||||
if (display.verb) {parts.push(display.verb);}
|
||||
if (display.detail) {parts.push(display.detail);}
|
||||
if (parts.length === 0) {return undefined;}
|
||||
if (display.verb) {
|
||||
parts.push(display.verb);
|
||||
}
|
||||
if (display.detail) {
|
||||
parts.push(display.detail);
|
||||
}
|
||||
if (parts.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
@@ -190,6 +226,8 @@ export function formatToolSummary(display: ToolDisplay): string {
|
||||
}
|
||||
|
||||
function shortenHomeInString(input: string): string {
|
||||
if (!input) {return input;}
|
||||
if (!input) {
|
||||
return input;
|
||||
}
|
||||
return input.replace(/\/Users\/[^/]+/g, "~").replace(/\/home\/[^/]+/g, "~");
|
||||
}
|
||||
|
||||
@@ -16,7 +16,9 @@ describe("generateUUID", () => {
|
||||
it("falls back to crypto.getRandomValues", () => {
|
||||
const id = generateUUID({
|
||||
getRandomValues: (bytes) => {
|
||||
for (let i = 0; i < bytes.length; i++) {bytes[i] = i;}
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
bytes[i] = i;
|
||||
}
|
||||
return bytes;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -23,7 +23,9 @@ function uuidFromBytes(bytes: Uint8Array): string {
|
||||
function weakRandomBytes(): Uint8Array {
|
||||
const bytes = new Uint8Array(16);
|
||||
const now = Date.now();
|
||||
for (let i = 0; i < bytes.length; i++) {bytes[i] = Math.floor(Math.random() * 256);}
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
bytes[i] = Math.floor(Math.random() * 256);
|
||||
}
|
||||
bytes[0] ^= now & 0xff;
|
||||
bytes[1] ^= (now >>> 8) & 0xff;
|
||||
bytes[2] ^= (now >>> 16) & 0xff;
|
||||
@@ -32,13 +34,17 @@ function weakRandomBytes(): Uint8Array {
|
||||
}
|
||||
|
||||
function warnWeakCryptoOnce() {
|
||||
if (warnedWeakCrypto) {return;}
|
||||
if (warnedWeakCrypto) {
|
||||
return;
|
||||
}
|
||||
warnedWeakCrypto = true;
|
||||
console.warn("[uuid] crypto API missing; falling back to weak randomness");
|
||||
}
|
||||
|
||||
export function generateUUID(cryptoLike: CryptoLike | null = globalThis.crypto): string {
|
||||
if (cryptoLike && typeof cryptoLike.randomUUID === "function") {return cryptoLike.randomUUID();}
|
||||
if (cryptoLike && typeof cryptoLike.randomUUID === "function") {
|
||||
return cryptoLike.randomUUID();
|
||||
}
|
||||
|
||||
if (cryptoLike && typeof cryptoLike.getRandomValues === "function") {
|
||||
const bytes = new Uint8Array(16);
|
||||
|
||||
@@ -18,7 +18,9 @@ function resolveSchemaNode(
|
||||
): JsonSchema | null {
|
||||
let current = schema;
|
||||
for (const key of path) {
|
||||
if (!current) {return null;}
|
||||
if (!current) {
|
||||
return null;
|
||||
}
|
||||
const type = schemaType(current);
|
||||
if (type === "object") {
|
||||
const properties = current.properties ?? {};
|
||||
@@ -34,7 +36,9 @@ function resolveSchemaNode(
|
||||
return null;
|
||||
}
|
||||
if (type === "array") {
|
||||
if (typeof key !== "number") {return null;}
|
||||
if (typeof key !== "number") {
|
||||
return null;
|
||||
}
|
||||
const items = Array.isArray(current.items) ? current.items[0] : current.items;
|
||||
current = items ?? null;
|
||||
continue;
|
||||
|
||||
@@ -140,7 +140,9 @@ export function renderNostrProfileForm(params: {
|
||||
|
||||
const renderPicturePreview = () => {
|
||||
const picture = state.values.picture;
|
||||
if (!picture) {return nothing;}
|
||||
if (!picture) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div style="margin-bottom: 12px;">
|
||||
|
||||
@@ -13,8 +13,12 @@ import {
|
||||
* Truncate a pubkey for display (shows first and last 8 chars)
|
||||
*/
|
||||
function truncatePubkey(pubkey: string | null | undefined): string {
|
||||
if (!pubkey) {return "n/a";}
|
||||
if (pubkey.length <= 20) {return pubkey;}
|
||||
if (!pubkey) {
|
||||
return "n/a";
|
||||
}
|
||||
if (pubkey.length <= 20) {
|
||||
return pubkey;
|
||||
}
|
||||
return `${pubkey.slice(0, 8)}...${pubkey.slice(-8)}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,11 +3,17 @@ import type { ChannelAccountSnapshot } from "../types";
|
||||
import type { ChannelKey, ChannelsProps } from "./channels.types";
|
||||
|
||||
export function formatDuration(ms?: number | null) {
|
||||
if (!ms && ms !== 0) {return "n/a";}
|
||||
if (!ms && ms !== 0) {
|
||||
return "n/a";
|
||||
}
|
||||
const sec = Math.round(ms / 1000);
|
||||
if (sec < 60) {return `${sec}s`;}
|
||||
if (sec < 60) {
|
||||
return `${sec}s`;
|
||||
}
|
||||
const min = Math.round(sec / 60);
|
||||
if (min < 60) {return `${min}m`;}
|
||||
if (min < 60) {
|
||||
return `${min}m`;
|
||||
}
|
||||
const hr = Math.round(min / 60);
|
||||
return `${hr}h`;
|
||||
}
|
||||
@@ -15,7 +21,9 @@ export function formatDuration(ms?: number | null) {
|
||||
export function channelEnabled(key: ChannelKey, props: ChannelsProps) {
|
||||
const snapshot = props.snapshot;
|
||||
const channels = snapshot?.channels as Record<string, unknown> | null;
|
||||
if (!snapshot || !channels) {return false;}
|
||||
if (!snapshot || !channels) {
|
||||
return false;
|
||||
}
|
||||
const channelStatus = channels[key] as Record<string, unknown> | undefined;
|
||||
const configured = typeof channelStatus?.configured === "boolean" && channelStatus.configured;
|
||||
const running = typeof channelStatus?.running === "boolean" && channelStatus.running;
|
||||
@@ -39,6 +47,8 @@ export function renderChannelAccountCount(
|
||||
channelAccounts?: Record<string, ChannelAccountSnapshot[]> | null,
|
||||
) {
|
||||
const count = getChannelAccountCount(key, channelAccounts);
|
||||
if (count < 2) {return nothing;}
|
||||
if (count < 2) {
|
||||
return nothing;
|
||||
}
|
||||
return html`<div class="account-count">Accounts (${count})</div>`;
|
||||
}
|
||||
|
||||
@@ -44,7 +44,9 @@ export function renderChannels(props: ChannelsProps) {
|
||||
order: index,
|
||||
}))
|
||||
.toSorted((a, b) => {
|
||||
if (a.enabled !== b.enabled) {return a.enabled ? -1 : 1;}
|
||||
if (a.enabled !== b.enabled) {
|
||||
return a.enabled ? -1 : 1;
|
||||
}
|
||||
return a.order - b.order;
|
||||
});
|
||||
|
||||
@@ -236,7 +238,9 @@ function renderGenericChannelCard(
|
||||
function resolveChannelMetaMap(
|
||||
snapshot: ChannelsStatusSnapshot | null,
|
||||
): Record<string, ChannelUiMetaEntry> {
|
||||
if (!snapshot?.channelMeta?.length) {return {};}
|
||||
if (!snapshot?.channelMeta?.length) {
|
||||
return {};
|
||||
}
|
||||
return Object.fromEntries(snapshot.channelMeta.map((entry) => [entry.id, entry]));
|
||||
}
|
||||
|
||||
@@ -248,22 +252,34 @@ function resolveChannelLabel(snapshot: ChannelsStatusSnapshot | null, key: strin
|
||||
const RECENT_ACTIVITY_THRESHOLD_MS = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
function hasRecentActivity(account: ChannelAccountSnapshot): boolean {
|
||||
if (!account.lastInboundAt) {return false;}
|
||||
if (!account.lastInboundAt) {
|
||||
return false;
|
||||
}
|
||||
return Date.now() - account.lastInboundAt < RECENT_ACTIVITY_THRESHOLD_MS;
|
||||
}
|
||||
|
||||
function deriveRunningStatus(account: ChannelAccountSnapshot): "Yes" | "No" | "Active" {
|
||||
if (account.running) {return "Yes";}
|
||||
if (account.running) {
|
||||
return "Yes";
|
||||
}
|
||||
// If we have recent inbound activity, the channel is effectively running
|
||||
if (hasRecentActivity(account)) {return "Active";}
|
||||
if (hasRecentActivity(account)) {
|
||||
return "Active";
|
||||
}
|
||||
return "No";
|
||||
}
|
||||
|
||||
function deriveConnectedStatus(account: ChannelAccountSnapshot): "Yes" | "No" | "Active" | "n/a" {
|
||||
if (account.connected === true) {return "Yes";}
|
||||
if (account.connected === false) {return "No";}
|
||||
if (account.connected === true) {
|
||||
return "Yes";
|
||||
}
|
||||
if (account.connected === false) {
|
||||
return "No";
|
||||
}
|
||||
// If connected is null/undefined but we have recent activity, show as active
|
||||
if (hasRecentActivity(account)) {return "Active";}
|
||||
if (hasRecentActivity(account)) {
|
||||
return "Active";
|
||||
}
|
||||
return "n/a";
|
||||
}
|
||||
|
||||
|
||||
@@ -75,7 +75,9 @@ function adjustTextareaHeight(el: HTMLTextAreaElement) {
|
||||
}
|
||||
|
||||
function renderCompactionIndicator(status: CompactionIndicatorStatus | null | undefined) {
|
||||
if (!status) {return nothing;}
|
||||
if (!status) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
// Show "compacting..." while active
|
||||
if (status.active) {
|
||||
@@ -107,7 +109,9 @@ function generateAttachmentId(): string {
|
||||
|
||||
function handlePaste(e: ClipboardEvent, props: ChatProps) {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items || !props.onAttachmentsChange) {return;}
|
||||
if (!items || !props.onAttachmentsChange) {
|
||||
return;
|
||||
}
|
||||
|
||||
const imageItems: DataTransferItem[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
@@ -117,16 +121,20 @@ function handlePaste(e: ClipboardEvent, props: ChatProps) {
|
||||
}
|
||||
}
|
||||
|
||||
if (imageItems.length === 0) {return;}
|
||||
if (imageItems.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
for (const item of imageItems) {
|
||||
const file = item.getAsFile();
|
||||
if (!file) {continue;}
|
||||
if (!file) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
reader.addEventListener("load", () => {
|
||||
const dataUrl = reader.result as string;
|
||||
const newAttachment: ChatAttachment = {
|
||||
id: generateAttachmentId(),
|
||||
@@ -135,14 +143,16 @@ function handlePaste(e: ClipboardEvent, props: ChatProps) {
|
||||
};
|
||||
const current = props.attachments ?? [];
|
||||
props.onAttachmentsChange?.([...current, newAttachment]);
|
||||
};
|
||||
});
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}
|
||||
|
||||
function renderAttachmentPreview(props: ChatProps) {
|
||||
const attachments = props.attachments ?? [];
|
||||
if (attachments.length === 0) {return nothing;}
|
||||
if (attachments.length === 0) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="chat-attachments">
|
||||
@@ -286,7 +296,9 @@ export function renderChat(props: ChatProps) {
|
||||
error: props.sidebarError ?? null,
|
||||
onClose: props.onCloseSidebar!,
|
||||
onViewRawText: () => {
|
||||
if (!props.sidebarContent || !props.onOpenSidebar) {return;}
|
||||
if (!props.sidebarContent || !props.onOpenSidebar) {
|
||||
return;
|
||||
}
|
||||
props.onOpenSidebar(`\`\`\`\n${props.sidebarContent}\n\`\`\``);
|
||||
},
|
||||
})}
|
||||
@@ -338,12 +350,22 @@ export function renderChat(props: ChatProps) {
|
||||
.value=${props.draft}
|
||||
?disabled=${!props.connected}
|
||||
@keydown=${(e: KeyboardEvent) => {
|
||||
if (e.key !== "Enter") {return;}
|
||||
if (e.isComposing || e.keyCode === 229) {return;}
|
||||
if (e.shiftKey) {return;} // Allow Shift+Enter for line breaks
|
||||
if (!props.connected) {return;}
|
||||
if (e.key !== "Enter") {
|
||||
return;
|
||||
}
|
||||
if (e.isComposing || e.keyCode === 229) {
|
||||
return;
|
||||
}
|
||||
if (e.shiftKey) {
|
||||
return;
|
||||
} // Allow Shift+Enter for line breaks
|
||||
if (!props.connected) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
if (canCompose) {props.onSend();}
|
||||
if (canCompose) {
|
||||
props.onSend();
|
||||
}
|
||||
}}
|
||||
@input=${(e: Event) => {
|
||||
const target = e.target as HTMLTextAreaElement;
|
||||
@@ -397,7 +419,9 @@ function groupMessages(items: ChatItem[]): Array<ChatItem | MessageGroup> {
|
||||
const timestamp = normalized.timestamp || Date.now();
|
||||
|
||||
if (!currentGroup || currentGroup.role !== role) {
|
||||
if (currentGroup) {result.push(currentGroup);}
|
||||
if (currentGroup) {
|
||||
result.push(currentGroup);
|
||||
}
|
||||
currentGroup = {
|
||||
kind: "group",
|
||||
key: `group:${role}:${item.key}`,
|
||||
@@ -411,7 +435,9 @@ function groupMessages(items: ChatItem[]): Array<ChatItem | MessageGroup> {
|
||||
}
|
||||
}
|
||||
|
||||
if (currentGroup) {result.push(currentGroup);}
|
||||
if (currentGroup) {
|
||||
result.push(currentGroup);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -475,13 +501,21 @@ function buildChatItems(props: ChatProps): Array<ChatItem | MessageGroup> {
|
||||
function messageKey(message: unknown, index: number): string {
|
||||
const m = message as Record<string, unknown>;
|
||||
const toolCallId = typeof m.toolCallId === "string" ? m.toolCallId : "";
|
||||
if (toolCallId) {return `tool:${toolCallId}`;}
|
||||
if (toolCallId) {
|
||||
return `tool:${toolCallId}`;
|
||||
}
|
||||
const id = typeof m.id === "string" ? m.id : "";
|
||||
if (id) {return `msg:${id}`;}
|
||||
if (id) {
|
||||
return `msg:${id}`;
|
||||
}
|
||||
const messageId = typeof m.messageId === "string" ? m.messageId : "";
|
||||
if (messageId) {return `msg:${messageId}`;}
|
||||
if (messageId) {
|
||||
return `msg:${messageId}`;
|
||||
}
|
||||
const timestamp = typeof m.timestamp === "number" ? m.timestamp : null;
|
||||
const role = typeof m.role === "string" ? m.role : "unknown";
|
||||
if (timestamp != null) {return `msg:${role}:${timestamp}:${index}`;}
|
||||
if (timestamp != null) {
|
||||
return `msg:${role}:${timestamp}:${index}`;
|
||||
}
|
||||
return `msg:${role}:${index}`;
|
||||
}
|
||||
|
||||
@@ -41,7 +41,9 @@ function normalizeSchemaNode(
|
||||
|
||||
if (schema.anyOf || schema.oneOf || schema.allOf) {
|
||||
const union = normalizeUnion(schema, path);
|
||||
if (union) {return union;}
|
||||
if (union) {
|
||||
return union;
|
||||
}
|
||||
return { schema, unsupportedPaths: [pathLabel] };
|
||||
}
|
||||
|
||||
@@ -54,8 +56,12 @@ function normalizeSchemaNode(
|
||||
if (normalized.enum) {
|
||||
const { enumValues, nullable: enumNullable } = normalizeEnum(normalized.enum);
|
||||
normalized.enum = enumValues;
|
||||
if (enumNullable) {normalized.nullable = true;}
|
||||
if (enumValues.length === 0) {unsupported.add(pathLabel);}
|
||||
if (enumNullable) {
|
||||
normalized.nullable = true;
|
||||
}
|
||||
if (enumValues.length === 0) {
|
||||
unsupported.add(pathLabel);
|
||||
}
|
||||
}
|
||||
|
||||
if (type === "object") {
|
||||
@@ -63,8 +69,12 @@ function normalizeSchemaNode(
|
||||
const normalizedProps: Record<string, JsonSchema> = {};
|
||||
for (const [key, value] of Object.entries(properties)) {
|
||||
const res = normalizeSchemaNode(value, [...path, key]);
|
||||
if (res.schema) {normalizedProps[key] = res.schema;}
|
||||
for (const entry of res.unsupportedPaths) {unsupported.add(entry);}
|
||||
if (res.schema) {
|
||||
normalizedProps[key] = res.schema;
|
||||
}
|
||||
for (const entry of res.unsupportedPaths) {
|
||||
unsupported.add(entry);
|
||||
}
|
||||
}
|
||||
normalized.properties = normalizedProps;
|
||||
|
||||
@@ -75,8 +85,10 @@ function normalizeSchemaNode(
|
||||
} else if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
|
||||
if (!isAnySchema(schema.additionalProperties)) {
|
||||
const res = normalizeSchemaNode(schema.additionalProperties, [...path, "*"]);
|
||||
normalized.additionalProperties = res.schema ?? (schema.additionalProperties);
|
||||
if (res.unsupportedPaths.length > 0) {unsupported.add(pathLabel);}
|
||||
normalized.additionalProperties = res.schema ?? schema.additionalProperties;
|
||||
if (res.unsupportedPaths.length > 0) {
|
||||
unsupported.add(pathLabel);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (type === "array") {
|
||||
@@ -86,7 +98,9 @@ function normalizeSchemaNode(
|
||||
} else {
|
||||
const res = normalizeSchemaNode(itemsSchema, [...path, "*"]);
|
||||
normalized.items = res.schema ?? itemsSchema;
|
||||
if (res.unsupportedPaths.length > 0) {unsupported.add(pathLabel);}
|
||||
if (res.unsupportedPaths.length > 0) {
|
||||
unsupported.add(pathLabel);
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
type !== "string" &&
|
||||
@@ -108,20 +122,28 @@ function normalizeUnion(
|
||||
schema: JsonSchema,
|
||||
path: Array<string | number>,
|
||||
): ConfigSchemaAnalysis | null {
|
||||
if (schema.allOf) {return null;}
|
||||
if (schema.allOf) {
|
||||
return null;
|
||||
}
|
||||
const union = schema.anyOf ?? schema.oneOf;
|
||||
if (!union) {return null;}
|
||||
if (!union) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const literals: unknown[] = [];
|
||||
const remaining: JsonSchema[] = [];
|
||||
let nullable = false;
|
||||
|
||||
for (const entry of union) {
|
||||
if (!entry || typeof entry !== "object") {return null;}
|
||||
if (!entry || typeof entry !== "object") {
|
||||
return null;
|
||||
}
|
||||
if (Array.isArray(entry.enum)) {
|
||||
const { enumValues, nullable: enumNullable } = normalizeEnum(entry.enum);
|
||||
literals.push(...enumValues);
|
||||
if (enumNullable) {nullable = true;}
|
||||
if (enumNullable) {
|
||||
nullable = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if ("const" in entry) {
|
||||
|
||||
@@ -18,7 +18,9 @@ function isAnySchema(schema: JsonSchema): boolean {
|
||||
}
|
||||
|
||||
function jsonValue(value: unknown): string {
|
||||
if (value === undefined) {return "";}
|
||||
if (value === undefined) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value, null, 2) ?? "";
|
||||
} catch {
|
||||
@@ -131,8 +133,12 @@ export function renderNode(params: {
|
||||
|
||||
// Check if it's a set of literal values (enum-like)
|
||||
const extractLiteral = (v: JsonSchema): unknown | undefined => {
|
||||
if (v.const !== undefined) {return v.const;}
|
||||
if (v.enum && v.enum.length === 1) {return v.enum[0];}
|
||||
if (v.const !== undefined) {
|
||||
return v.const;
|
||||
}
|
||||
if (v.enum && v.enum.length === 1) {
|
||||
return v.enum[0];
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const literals = nonNull.map(extractLiteral);
|
||||
@@ -147,14 +153,20 @@ export function renderNode(params: {
|
||||
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing}
|
||||
<div class="cfg-segmented">
|
||||
${literals.map(
|
||||
(lit, idx) => html`
|
||||
(lit) => html`
|
||||
<button
|
||||
type="button"
|
||||
class="cfg-segmented__btn ${lit === resolvedValue || String(lit) === String(resolvedValue) ? "active" : ""}"
|
||||
class="cfg-segmented__btn ${
|
||||
// oxlint-disable typescript/no-base-to-string
|
||||
lit === resolvedValue || String(lit) === String(resolvedValue) ? "active" : ""
|
||||
}"
|
||||
?disabled=${disabled}
|
||||
@click=${() => onPatch(path, lit)}
|
||||
>
|
||||
${String(lit)}
|
||||
${
|
||||
// oxlint-disable typescript/no-base-to-string
|
||||
String(lit)
|
||||
}
|
||||
</button>
|
||||
`,
|
||||
)}
|
||||
@@ -298,7 +310,12 @@ function renderTextInput(params: {
|
||||
const isSensitive = hint?.sensitive ?? isSensitivePath(path);
|
||||
const placeholder =
|
||||
hint?.placeholder ??
|
||||
(isSensitive ? "••••" : schema.default !== undefined ? `Default: ${schema.default}` : "");
|
||||
// oxlint-disable typescript/no-base-to-string
|
||||
(isSensitive
|
||||
? "••••"
|
||||
: schema.default !== undefined
|
||||
? `Default: ${String(schema.default)}`
|
||||
: "");
|
||||
const displayValue = value ?? "";
|
||||
|
||||
return html`
|
||||
@@ -326,7 +343,9 @@ function renderTextInput(params: {
|
||||
onPatch(path, raw);
|
||||
}}
|
||||
@change=${(e: Event) => {
|
||||
if (inputType === "number") {return;}
|
||||
if (inputType === "number") {
|
||||
return;
|
||||
}
|
||||
const raw = (e.target as HTMLInputElement).value;
|
||||
onPatch(path, raw.trim());
|
||||
}}
|
||||
@@ -455,7 +474,6 @@ function renderObject(params: {
|
||||
onPatch: (path: Array<string | number>, value: unknown) => void;
|
||||
}): TemplateResult {
|
||||
const { schema, value, path, hints, unsupported, disabled, onPatch } = params;
|
||||
const showLabel = params.showLabel ?? true;
|
||||
const hint = hintForPath(path, hints);
|
||||
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
|
||||
const help = hint?.help ?? schema.description;
|
||||
@@ -472,7 +490,9 @@ function renderObject(params: {
|
||||
const sorted = entries.toSorted((a, b) => {
|
||||
const orderA = hintForPath([...path, a[0]], hints)?.order ?? 0;
|
||||
const orderB = hintForPath([...path, b[0]], hints)?.order ?? 0;
|
||||
if (orderA !== orderB) {return orderA - orderB;}
|
||||
if (orderA !== orderB) {
|
||||
return orderA - orderB;
|
||||
}
|
||||
return a[0].localeCompare(b[0]);
|
||||
});
|
||||
|
||||
@@ -708,9 +728,13 @@ function renderMapField(params: {
|
||||
?disabled=${disabled}
|
||||
@change=${(e: Event) => {
|
||||
const nextKey = (e.target as HTMLInputElement).value.trim();
|
||||
if (!nextKey || nextKey === key) {return;}
|
||||
if (!nextKey || nextKey === key) {
|
||||
return;
|
||||
}
|
||||
const next = { ...value };
|
||||
if (nextKey in next) {return;}
|
||||
if (nextKey in next) {
|
||||
return;
|
||||
}
|
||||
next[nextKey] = next[key];
|
||||
delete next[key];
|
||||
onPatch(path, next);
|
||||
|
||||
@@ -279,49 +279,73 @@ function getSectionIcon(key: string) {
|
||||
}
|
||||
|
||||
function matchesSearch(key: string, schema: JsonSchema, query: string): boolean {
|
||||
if (!query) {return true;}
|
||||
if (!query) {
|
||||
return true;
|
||||
}
|
||||
const q = query.toLowerCase();
|
||||
const meta = SECTION_META[key];
|
||||
|
||||
// Check key name
|
||||
if (key.toLowerCase().includes(q)) {return true;}
|
||||
if (key.toLowerCase().includes(q)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check label and description
|
||||
if (meta) {
|
||||
if (meta.label.toLowerCase().includes(q)) {return true;}
|
||||
if (meta.description.toLowerCase().includes(q)) {return true;}
|
||||
if (meta.label.toLowerCase().includes(q)) {
|
||||
return true;
|
||||
}
|
||||
if (meta.description.toLowerCase().includes(q)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return schemaMatches(schema, q);
|
||||
}
|
||||
|
||||
function schemaMatches(schema: JsonSchema, query: string): boolean {
|
||||
if (schema.title?.toLowerCase().includes(query)) {return true;}
|
||||
if (schema.description?.toLowerCase().includes(query)) {return true;}
|
||||
if (schema.enum?.some((value) => String(value).toLowerCase().includes(query))) {return true;}
|
||||
if (schema.title?.toLowerCase().includes(query)) {
|
||||
return true;
|
||||
}
|
||||
if (schema.description?.toLowerCase().includes(query)) {
|
||||
return true;
|
||||
}
|
||||
if (schema.enum?.some((value) => String(value).toLowerCase().includes(query))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (schema.properties) {
|
||||
for (const [propKey, propSchema] of Object.entries(schema.properties)) {
|
||||
if (propKey.toLowerCase().includes(query)) {return true;}
|
||||
if (schemaMatches(propSchema, query)) {return true;}
|
||||
if (propKey.toLowerCase().includes(query)) {
|
||||
return true;
|
||||
}
|
||||
if (schemaMatches(propSchema, query)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (schema.items) {
|
||||
const items = Array.isArray(schema.items) ? schema.items : [schema.items];
|
||||
for (const item of items) {
|
||||
if (item && schemaMatches(item, query)) {return true;}
|
||||
if (item && schemaMatches(item, query)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
|
||||
if (schemaMatches(schema.additionalProperties, query)) {return true;}
|
||||
if (schemaMatches(schema.additionalProperties, query)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const unions = schema.anyOf ?? schema.oneOf ?? schema.allOf;
|
||||
if (unions) {
|
||||
for (const entry of unions) {
|
||||
if (entry && schemaMatches(entry, query)) {return true;}
|
||||
if (entry && schemaMatches(entry, query)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,13 +374,19 @@ export function renderConfigForm(props: ConfigFormProps) {
|
||||
const entries = Object.entries(properties).toSorted((a, b) => {
|
||||
const orderA = hintForPath([a[0]], props.uiHints)?.order ?? 50;
|
||||
const orderB = hintForPath([b[0]], props.uiHints)?.order ?? 50;
|
||||
if (orderA !== orderB) {return orderA - orderB;}
|
||||
if (orderA !== orderB) {
|
||||
return orderA - orderB;
|
||||
}
|
||||
return a[0].localeCompare(b[0]);
|
||||
});
|
||||
|
||||
const filteredEntries = entries.filter(([key, node]) => {
|
||||
if (activeSection && key !== activeSection) {return false;}
|
||||
if (searchQuery && !matchesSearch(key, node, searchQuery)) {return false;}
|
||||
if (activeSection && key !== activeSection) {
|
||||
return false;
|
||||
}
|
||||
if (searchQuery && !matchesSearch(key, node, searchQuery)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -398,7 +428,7 @@ export function renderConfigForm(props: ConfigFormProps) {
|
||||
const hint = hintForPath([sectionKey, subsectionKey], props.uiHints);
|
||||
const label = hint?.label ?? node.title ?? humanize(subsectionKey);
|
||||
const description = hint?.help ?? node.description ?? "";
|
||||
const sectionValue = (value)[sectionKey];
|
||||
const sectionValue = value[sectionKey];
|
||||
const scopedValue =
|
||||
sectionValue && typeof sectionValue === "object"
|
||||
? (sectionValue as Record<string, unknown>)[subsectionKey]
|
||||
@@ -454,7 +484,7 @@ export function renderConfigForm(props: ConfigFormProps) {
|
||||
<div class="config-section-card__content">
|
||||
${renderNode({
|
||||
schema: node,
|
||||
value: (value)[key],
|
||||
value: value[key],
|
||||
path: [key],
|
||||
hints: props.uiHints,
|
||||
unsupported,
|
||||
|
||||
@@ -17,7 +17,9 @@ export type JsonSchema = {
|
||||
};
|
||||
|
||||
export function schemaType(schema: JsonSchema): string | undefined {
|
||||
if (!schema) {return undefined;}
|
||||
if (!schema) {
|
||||
return undefined;
|
||||
}
|
||||
if (Array.isArray(schema.type)) {
|
||||
const filtered = schema.type.filter((t) => t !== "null");
|
||||
return filtered[0] ?? schema.type[0];
|
||||
@@ -26,8 +28,12 @@ export function schemaType(schema: JsonSchema): string | undefined {
|
||||
}
|
||||
|
||||
export function defaultValue(schema?: JsonSchema): unknown {
|
||||
if (!schema) {return "";}
|
||||
if (schema.default !== undefined) {return schema.default;}
|
||||
if (!schema) {
|
||||
return "";
|
||||
}
|
||||
if (schema.default !== undefined) {
|
||||
return schema.default;
|
||||
}
|
||||
const type = schemaType(schema);
|
||||
switch (type) {
|
||||
case "object":
|
||||
@@ -53,12 +59,18 @@ export function pathKey(path: Array<string | number>): string {
|
||||
export function hintForPath(path: Array<string | number>, hints: ConfigUiHints) {
|
||||
const key = pathKey(path);
|
||||
const direct = hints[key];
|
||||
if (direct) {return direct;}
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
const segments = key.split(".");
|
||||
for (const [hintKey, hint] of Object.entries(hints)) {
|
||||
if (!hintKey.includes("*")) {continue;}
|
||||
if (!hintKey.includes("*")) {
|
||||
continue;
|
||||
}
|
||||
const hintSegments = hintKey.split(".");
|
||||
if (hintSegments.length !== segments.length) {continue;}
|
||||
if (hintSegments.length !== segments.length) {
|
||||
continue;
|
||||
}
|
||||
let match = true;
|
||||
for (let i = 0; i < segments.length; i += 1) {
|
||||
if (hintSegments[i] !== "*" && hintSegments[i] !== segments[i]) {
|
||||
@@ -66,7 +78,9 @@ export function hintForPath(path: Array<string | number>, hints: ConfigUiHints)
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (match) {return hint;}
|
||||
if (match) {
|
||||
return hint;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -191,7 +191,9 @@ describe("config view", () => {
|
||||
|
||||
const input = container.querySelector(".config-search__input");
|
||||
expect(input).not.toBeNull();
|
||||
if (!input) {return;}
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
input.value = "gateway";
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
expect(onSearchChange).toHaveBeenCalledWith("gateway");
|
||||
|
||||
@@ -299,7 +299,9 @@ function resolveSectionMeta(
|
||||
description?: string;
|
||||
} {
|
||||
const meta = SECTION_META[key];
|
||||
if (meta) {return meta;}
|
||||
if (meta) {
|
||||
return meta;
|
||||
}
|
||||
return {
|
||||
label: schema?.title ?? humanize(key),
|
||||
description: schema?.description ?? "",
|
||||
@@ -312,7 +314,9 @@ function resolveSubsections(params: {
|
||||
uiHints: ConfigUiHints;
|
||||
}): SubsectionEntry[] {
|
||||
const { key, schema, uiHints } = params;
|
||||
if (!schema || schemaType(schema) !== "object" || !schema.properties) {return [];}
|
||||
if (!schema || schemaType(schema) !== "object" || !schema.properties) {
|
||||
return [];
|
||||
}
|
||||
const entries = Object.entries(schema.properties).map(([subKey, node]) => {
|
||||
const hint = hintForPath([key, subKey], uiHints);
|
||||
const label = hint?.label ?? node.title ?? humanize(subKey);
|
||||
@@ -328,11 +332,15 @@ function computeDiff(
|
||||
original: Record<string, unknown> | null,
|
||||
current: Record<string, unknown> | null,
|
||||
): Array<{ path: string; from: unknown; to: unknown }> {
|
||||
if (!original || !current) {return [];}
|
||||
if (!original || !current) {
|
||||
return [];
|
||||
}
|
||||
const changes: Array<{ path: string; from: unknown; to: unknown }> = [];
|
||||
|
||||
function compare(orig: unknown, curr: unknown, path: string) {
|
||||
if (orig === curr) {return;}
|
||||
if (orig === curr) {
|
||||
return;
|
||||
}
|
||||
if (typeof orig !== typeof curr) {
|
||||
changes.push({ path, from: orig, to: curr });
|
||||
return;
|
||||
@@ -369,7 +377,9 @@ function truncateValue(value: unknown, maxLen = 40): string {
|
||||
} catch {
|
||||
str = String(value);
|
||||
}
|
||||
if (str.length <= maxLen) {return str;}
|
||||
if (str.length <= maxLen) {
|
||||
return str;
|
||||
}
|
||||
return str.slice(0, maxLen - 3) + "...";
|
||||
}
|
||||
|
||||
@@ -392,7 +402,7 @@ export function renderConfig(props: ConfigProps) {
|
||||
|
||||
const activeSectionSchema =
|
||||
props.activeSection && analysis.schema && schemaType(analysis.schema) === "object"
|
||||
? (analysis.schema.properties?.[props.activeSection])
|
||||
? analysis.schema.properties?.[props.activeSection]
|
||||
: undefined;
|
||||
const activeSectionMeta = props.activeSection
|
||||
? resolveSectionMeta(props.activeSection, activeSectionSchema)
|
||||
|
||||
@@ -38,16 +38,22 @@ function buildChannelOptions(props: CronProps): string[] {
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
return options.filter((value) => {
|
||||
if (seen.has(value)) {return false;}
|
||||
if (seen.has(value)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(value);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function resolveChannelLabel(props: CronProps, channel: string): string {
|
||||
if (channel === "last") {return "last";}
|
||||
if (channel === "last") {
|
||||
return "last";
|
||||
}
|
||||
const meta = props.channelMeta?.find((entry) => entry.id === channel);
|
||||
if (meta?.label) {return meta.label;}
|
||||
if (meta?.label) {
|
||||
return meta.label;
|
||||
}
|
||||
return props.channelLabels?.[channel] ?? channel;
|
||||
}
|
||||
|
||||
@@ -212,8 +218,7 @@ export function renderCron(props: CronProps) {
|
||||
.value=${props.form.channel || "last"}
|
||||
@change=${(e: Event) =>
|
||||
props.onFormChange({
|
||||
channel: (e.target as HTMLSelectElement)
|
||||
.value,
|
||||
channel: (e.target as HTMLSelectElement).value,
|
||||
})}
|
||||
>
|
||||
${channelOptions.map(
|
||||
|
||||
@@ -4,21 +4,29 @@ import type { AppViewState } from "../app-view-state";
|
||||
function formatRemaining(ms: number): string {
|
||||
const remaining = Math.max(0, ms);
|
||||
const totalSeconds = Math.floor(remaining / 1000);
|
||||
if (totalSeconds < 60) {return `${totalSeconds}s`;}
|
||||
if (totalSeconds < 60) {
|
||||
return `${totalSeconds}s`;
|
||||
}
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
if (minutes < 60) {return `${minutes}m`;}
|
||||
if (minutes < 60) {
|
||||
return `${minutes}m`;
|
||||
}
|
||||
const hours = Math.floor(minutes / 60);
|
||||
return `${hours}h`;
|
||||
}
|
||||
|
||||
function renderMetaRow(label: string, value?: string | null) {
|
||||
if (!value) {return nothing;}
|
||||
if (!value) {
|
||||
return nothing;
|
||||
}
|
||||
return html`<div class="exec-approval-meta-row"><span>${label}</span><span>${value}</span></div>`;
|
||||
}
|
||||
|
||||
export function renderExecApprovalPrompt(state: AppViewState) {
|
||||
const active = state.execApprovalQueue[0];
|
||||
if (!active) {return nothing;}
|
||||
if (!active) {
|
||||
return nothing;
|
||||
}
|
||||
const request = active.request;
|
||||
const remainingMs = active.expiresAtMs - Date.now();
|
||||
const remaining = remainingMs > 0 ? `expires in ${formatRemaining(remainingMs)}` : "expired";
|
||||
|
||||
@@ -3,7 +3,9 @@ import type { AppViewState } from "../app-view-state";
|
||||
|
||||
export function renderGatewayUrlConfirmation(state: AppViewState) {
|
||||
const { pendingGatewayUrl } = state;
|
||||
if (!pendingGatewayUrl) {return nothing;}
|
||||
if (!pendingGatewayUrl) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="exec-approval-overlay" role="dialog" aria-modal="true" aria-live="polite">
|
||||
|
||||
@@ -21,14 +21,20 @@ export type LogsProps = {
|
||||
};
|
||||
|
||||
function formatTime(value?: string | null) {
|
||||
if (!value) {return "";}
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {return value;}
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
return date.toLocaleTimeString();
|
||||
}
|
||||
|
||||
function matchesFilter(entry: LogEntry, needle: string) {
|
||||
if (!needle) {return true;}
|
||||
if (!needle) {
|
||||
return true;
|
||||
}
|
||||
const haystack = [entry.message, entry.subsystem, entry.raw]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
@@ -40,7 +46,9 @@ export function renderLogs(props: LogsProps) {
|
||||
const needle = props.filterText.trim().toLowerCase();
|
||||
const levelFiltered = LEVELS.some((level) => !props.levelFilters[level]);
|
||||
const filtered = props.entries.filter((entry) => {
|
||||
if (entry.level && !props.levelFilters[entry.level]) {return false;}
|
||||
if (entry.level && !props.levelFilters[entry.level]) {
|
||||
return false;
|
||||
}
|
||||
return matchesFilter(entry, needle);
|
||||
});
|
||||
const exportLabel = needle || levelFiltered ? "filtered" : "visible";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { html, nothing } from "lit";
|
||||
import { html } from "lit";
|
||||
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
||||
import { icons } from "../icons";
|
||||
import { toSanitizedMarkdownHtml } from "../markdown";
|
||||
|
||||
@@ -328,12 +328,16 @@ function resolveBindingsState(props: NodesProps): BindingState {
|
||||
}
|
||||
|
||||
function normalizeSecurity(value?: string): ExecSecurity {
|
||||
if (value === "allowlist" || value === "full" || value === "deny") {return value;}
|
||||
if (value === "allowlist" || value === "full" || value === "deny") {
|
||||
return value;
|
||||
}
|
||||
return "deny";
|
||||
}
|
||||
|
||||
function normalizeAsk(value?: string): ExecAsk {
|
||||
if (value === "always" || value === "off" || value === "on-miss") {return value;}
|
||||
if (value === "always" || value === "off" || value === "on-miss") {
|
||||
return value;
|
||||
}
|
||||
return "on-miss";
|
||||
}
|
||||
|
||||
@@ -354,10 +358,14 @@ function resolveConfigAgents(config: Record<string, unknown> | null): ExecApprov
|
||||
const list = Array.isArray(agentsNode.list) ? agentsNode.list : [];
|
||||
const agents: ExecApprovalsAgentOption[] = [];
|
||||
list.forEach((entry) => {
|
||||
if (!entry || typeof entry !== "object") {return;}
|
||||
if (!entry || typeof entry !== "object") {
|
||||
return;
|
||||
}
|
||||
const record = entry as Record<string, unknown>;
|
||||
const id = typeof record.id === "string" ? record.id.trim() : "";
|
||||
if (!id) {return;}
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
const name = typeof record.name === "string" ? record.name.trim() : undefined;
|
||||
const isDefault = record.default === true;
|
||||
agents.push({ id, name: name || undefined, isDefault });
|
||||
@@ -374,7 +382,9 @@ function resolveExecApprovalsAgents(
|
||||
const merged = new Map<string, ExecApprovalsAgentOption>();
|
||||
configAgents.forEach((agent) => merged.set(agent.id, agent));
|
||||
approvalsAgents.forEach((id) => {
|
||||
if (merged.has(id)) {return;}
|
||||
if (merged.has(id)) {
|
||||
return;
|
||||
}
|
||||
merged.set(id, { id });
|
||||
});
|
||||
const agents = Array.from(merged.values());
|
||||
@@ -382,8 +392,12 @@ function resolveExecApprovalsAgents(
|
||||
agents.push({ id: "main", isDefault: true });
|
||||
}
|
||||
agents.sort((a, b) => {
|
||||
if (a.isDefault && !b.isDefault) {return -1;}
|
||||
if (!a.isDefault && b.isDefault) {return 1;}
|
||||
if (a.isDefault && !b.isDefault) {
|
||||
return -1;
|
||||
}
|
||||
if (!a.isDefault && b.isDefault) {
|
||||
return 1;
|
||||
}
|
||||
const aLabel = a.name?.trim() ? a.name : a.id;
|
||||
const bLabel = b.name?.trim() ? b.name : b.id;
|
||||
return aLabel.localeCompare(bLabel);
|
||||
@@ -395,8 +409,12 @@ function resolveExecApprovalsScope(
|
||||
selected: string | null,
|
||||
agents: ExecApprovalsAgentOption[],
|
||||
): string {
|
||||
if (selected === EXEC_APPROVALS_DEFAULT_SCOPE) {return EXEC_APPROVALS_DEFAULT_SCOPE;}
|
||||
if (selected && agents.some((agent) => agent.id === selected)) {return selected;}
|
||||
if (selected === EXEC_APPROVALS_DEFAULT_SCOPE) {
|
||||
return EXEC_APPROVALS_DEFAULT_SCOPE;
|
||||
}
|
||||
if (selected && agents.some((agent) => agent.id === selected)) {
|
||||
return selected;
|
||||
}
|
||||
return EXEC_APPROVALS_DEFAULT_SCOPE;
|
||||
}
|
||||
|
||||
@@ -1010,9 +1028,13 @@ function resolveExecNodes(nodes: Array<Record<string, unknown>>): BindingNode[]
|
||||
for (const node of nodes) {
|
||||
const commands = Array.isArray(node.commands) ? node.commands : [];
|
||||
const supports = commands.some((cmd) => String(cmd) === "system.run");
|
||||
if (!supports) {continue;}
|
||||
if (!supports) {
|
||||
continue;
|
||||
}
|
||||
const nodeId = typeof node.nodeId === "string" ? node.nodeId.trim() : "";
|
||||
if (!nodeId) {continue;}
|
||||
if (!nodeId) {
|
||||
continue;
|
||||
}
|
||||
const displayName =
|
||||
typeof node.displayName === "string" && node.displayName.trim()
|
||||
? node.displayName.trim()
|
||||
@@ -1036,9 +1058,13 @@ function resolveExecApprovalsNodes(
|
||||
(cmd) =>
|
||||
String(cmd) === "system.execApprovals.get" || String(cmd) === "system.execApprovals.set",
|
||||
);
|
||||
if (!supports) {continue;}
|
||||
if (!supports) {
|
||||
continue;
|
||||
}
|
||||
const nodeId = typeof node.nodeId === "string" ? node.nodeId.trim() : "";
|
||||
if (!nodeId) {continue;}
|
||||
if (!nodeId) {
|
||||
continue;
|
||||
}
|
||||
const displayName =
|
||||
typeof node.displayName === "string" && node.displayName.trim()
|
||||
? node.displayName.trim()
|
||||
@@ -1079,10 +1105,14 @@ function resolveAgentBindings(config: Record<string, unknown> | null): {
|
||||
|
||||
const agents: BindingAgent[] = [];
|
||||
list.forEach((entry, index) => {
|
||||
if (!entry || typeof entry !== "object") {return;}
|
||||
if (!entry || typeof entry !== "object") {
|
||||
return;
|
||||
}
|
||||
const record = entry as Record<string, unknown>;
|
||||
const id = typeof record.id === "string" ? record.id.trim() : "";
|
||||
if (!id) {return;}
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
const name = typeof record.name === "string" ? record.name.trim() : undefined;
|
||||
const isDefault = record.default === true;
|
||||
const toolsEntry = (record.tools ?? {}) as Record<string, unknown>;
|
||||
|
||||
@@ -29,10 +29,14 @@ export function renderOverview(props: OverviewProps) {
|
||||
const uptime = snapshot?.uptimeMs ? formatDurationMs(snapshot.uptimeMs) : "n/a";
|
||||
const tick = snapshot?.policy?.tickIntervalMs ? `${snapshot.policy.tickIntervalMs}ms` : "n/a";
|
||||
const authHint = (() => {
|
||||
if (props.connected || !props.lastError) {return null;}
|
||||
if (props.connected || !props.lastError) {
|
||||
return null;
|
||||
}
|
||||
const lower = props.lastError.toLowerCase();
|
||||
const authFailed = lower.includes("unauthorized") || lower.includes("connect failed");
|
||||
if (!authFailed) {return null;}
|
||||
if (!authFailed) {
|
||||
return null;
|
||||
}
|
||||
const hasToken = Boolean(props.settings.token.trim());
|
||||
const hasPassword = Boolean(props.password.trim());
|
||||
if (!hasToken && !hasPassword) {
|
||||
@@ -74,9 +78,13 @@ export function renderOverview(props: OverviewProps) {
|
||||
`;
|
||||
})();
|
||||
const insecureContextHint = (() => {
|
||||
if (props.connected || !props.lastError) {return null;}
|
||||
if (props.connected || !props.lastError) {
|
||||
return null;
|
||||
}
|
||||
const isSecureContext = typeof window !== "undefined" ? window.isSecureContext : true;
|
||||
if (isSecureContext) {return null;}
|
||||
if (isSecureContext) {
|
||||
return null;
|
||||
}
|
||||
const lower = props.lastError.toLowerCase();
|
||||
if (!lower.includes("secure context") && !lower.includes("device identity required")) {
|
||||
return null;
|
||||
|
||||
@@ -42,9 +42,13 @@ const VERBOSE_LEVELS = [
|
||||
const REASONING_LEVELS = ["", "off", "on", "stream"] as const;
|
||||
|
||||
function normalizeProviderId(provider?: string | null): string {
|
||||
if (!provider) {return "";}
|
||||
if (!provider) {
|
||||
return "";
|
||||
}
|
||||
const normalized = provider.trim().toLowerCase();
|
||||
if (normalized === "z.ai" || normalized === "z-ai") {return "zai";}
|
||||
if (normalized === "z.ai" || normalized === "z-ai") {
|
||||
return "zai";
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
@@ -57,15 +61,25 @@ function resolveThinkLevelOptions(provider?: string | null): readonly string[] {
|
||||
}
|
||||
|
||||
function resolveThinkLevelDisplay(value: string, isBinary: boolean): string {
|
||||
if (!isBinary) {return value;}
|
||||
if (!value || value === "off") {return value;}
|
||||
if (!isBinary) {
|
||||
return value;
|
||||
}
|
||||
if (!value || value === "off") {
|
||||
return value;
|
||||
}
|
||||
return "on";
|
||||
}
|
||||
|
||||
function resolveThinkLevelPatchValue(value: string, isBinary: boolean): string | null {
|
||||
if (!value) {return null;}
|
||||
if (!isBinary) {return value;}
|
||||
if (value === "on") {return "low";}
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
if (!isBinary) {
|
||||
return value;
|
||||
}
|
||||
if (value === "on") {
|
||||
return "low";
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
@@ -85,8 +85,12 @@ function renderSkill(skill: SkillStatusEntry, props: SkillsProps) {
|
||||
...skill.missing.os.map((o) => `os:${o}`),
|
||||
];
|
||||
const reasons: string[] = [];
|
||||
if (skill.disabled) {reasons.push("disabled");}
|
||||
if (skill.blockedByAllowlist) {reasons.push("blocked by allowlist");}
|
||||
if (skill.disabled) {
|
||||
reasons.push("disabled");
|
||||
}
|
||||
if (skill.blockedByAllowlist) {
|
||||
reasons.push("blocked by allowlist");
|
||||
}
|
||||
return html`
|
||||
<div class="list-item">
|
||||
<div class="list-main">
|
||||
|
||||
@@ -6,13 +6,19 @@ const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function normalizeBase(input: string): string {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {return "/";}
|
||||
if (trimmed === "./") {return "./";}
|
||||
if (trimmed.endsWith("/")) {return trimmed;}
|
||||
if (!trimmed) {
|
||||
return "/";
|
||||
}
|
||||
if (trimmed === "./") {
|
||||
return "./";
|
||||
}
|
||||
if (trimmed.endsWith("/")) {
|
||||
return trimmed;
|
||||
}
|
||||
return `${trimmed}/`;
|
||||
}
|
||||
|
||||
export default defineConfig(({ command }) => {
|
||||
export default defineConfig(() => {
|
||||
const envBase = process.env.OPENCLAW_CONTROL_UI_BASE_PATH?.trim();
|
||||
const base = envBase ? normalizeBase(envBase) : "./";
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user