- Added support for loading and displaying a tools catalog in the agent management interface. - Enhanced the AppViewState to include loading, error, and result states for the tools catalog. - Implemented loadToolsCatalog function to fetch tools based on the selected agent. - Updated UI components to reflect tools catalog loading states and errors. - Refactored agent tools rendering logic to utilize the new tools catalog data structure.
1893 lines
79 KiB
TypeScript
1893 lines
79 KiB
TypeScript
import { html, nothing } from "lit";
|
|
import {
|
|
buildAgentMainSessionKey,
|
|
parseAgentSessionKey,
|
|
} from "../../../src/routing/session-key.js";
|
|
import { t } from "../i18n/index.ts";
|
|
import { refreshChatAvatar } from "./app-chat.ts";
|
|
import { renderUsageTab } from "./app-render-usage-tab.ts";
|
|
import {
|
|
renderChatControls,
|
|
renderChatSessionSelect,
|
|
renderTab,
|
|
renderTopbarThemeModeToggle,
|
|
} from "./app-render.helpers.ts";
|
|
import type { AppViewState } from "./app-view-state.ts";
|
|
import { loadAgentFileContent, loadAgentFiles, saveAgentFile } from "./controllers/agent-files.ts";
|
|
import { loadAgentIdentities, loadAgentIdentity } from "./controllers/agent-identity.ts";
|
|
import { loadAgentSkills } from "./controllers/agent-skills.ts";
|
|
import { loadAgents, loadToolsCatalog } from "./controllers/agents.ts";
|
|
import { loadChannels } from "./controllers/channels.ts";
|
|
import { loadChatHistory } from "./controllers/chat.ts";
|
|
import {
|
|
applyConfig,
|
|
loadConfig,
|
|
runUpdate,
|
|
saveConfig,
|
|
updateConfigFormValue,
|
|
removeConfigFormValue,
|
|
} from "./controllers/config.ts";
|
|
import {
|
|
loadCronRuns,
|
|
loadMoreCronJobs,
|
|
loadMoreCronRuns,
|
|
reloadCronJobs,
|
|
toggleCronJob,
|
|
runCronJob,
|
|
removeCronJob,
|
|
addCronJob,
|
|
startCronEdit,
|
|
startCronClone,
|
|
cancelCronEdit,
|
|
validateCronForm,
|
|
hasCronFormErrors,
|
|
normalizeCronFormState,
|
|
getVisibleCronJobs,
|
|
updateCronJobsFilter,
|
|
updateCronRunsFilter,
|
|
} from "./controllers/cron.ts";
|
|
import { loadDebug, callDebugMethod } from "./controllers/debug.ts";
|
|
import {
|
|
approveDevicePairing,
|
|
loadDevices,
|
|
rejectDevicePairing,
|
|
revokeDeviceToken,
|
|
rotateDeviceToken,
|
|
} from "./controllers/devices.ts";
|
|
import {
|
|
loadExecApprovals,
|
|
removeExecApprovalsFormValue,
|
|
saveExecApprovals,
|
|
updateExecApprovalsFormValue,
|
|
} from "./controllers/exec-approvals.ts";
|
|
import { loadLogs } from "./controllers/logs.ts";
|
|
import { loadNodes } from "./controllers/nodes.ts";
|
|
import { loadPresence } from "./controllers/presence.ts";
|
|
import { deleteSessionAndRefresh, loadSessions, patchSession } from "./controllers/sessions.ts";
|
|
import {
|
|
installSkill,
|
|
loadSkills,
|
|
saveSkillApiKey,
|
|
updateSkillEdit,
|
|
updateSkillEnabled,
|
|
} from "./controllers/skills.ts";
|
|
import "./components/dashboard-header.ts";
|
|
import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "./external-link.ts";
|
|
import { icons } from "./icons.ts";
|
|
import { normalizeBasePath, TAB_GROUPS, subtitleForTab, titleForTab } from "./navigation.ts";
|
|
import { resolveConfiguredCronModelSuggestions, sortLocaleStrings } from "./views/agents-utils.ts";
|
|
import { renderAgents } from "./views/agents.ts";
|
|
import { renderChannels } from "./views/channels.ts";
|
|
import { renderChat } from "./views/chat.ts";
|
|
import { renderCommandPalette } from "./views/command-palette.ts";
|
|
import { renderConfig } from "./views/config.ts";
|
|
import { renderCron } from "./views/cron.ts";
|
|
import { renderDebug } from "./views/debug.ts";
|
|
import { renderExecApprovalPrompt } from "./views/exec-approval.ts";
|
|
import { renderGatewayUrlConfirmation } from "./views/gateway-url-confirmation.ts";
|
|
import { renderInstances } from "./views/instances.ts";
|
|
import { renderLoginGate } from "./views/login-gate.ts";
|
|
import { renderLogs } from "./views/logs.ts";
|
|
import { renderNodes } from "./views/nodes.ts";
|
|
import { renderOverview } from "./views/overview.ts";
|
|
import { renderSessions } from "./views/sessions.ts";
|
|
import { renderSkills } from "./views/skills.ts";
|
|
|
|
const UPDATE_BANNER_DISMISS_KEY = "openclaw:control-ui:update-banner-dismissed:v1";
|
|
const CRON_THINKING_SUGGESTIONS = ["off", "minimal", "low", "medium", "high"];
|
|
const CRON_TIMEZONE_SUGGESTIONS = [
|
|
"UTC",
|
|
"America/Los_Angeles",
|
|
"America/Denver",
|
|
"America/Chicago",
|
|
"America/New_York",
|
|
"Europe/London",
|
|
"Europe/Berlin",
|
|
"Asia/Tokyo",
|
|
];
|
|
|
|
function isHttpUrl(value: string): boolean {
|
|
return /^https?:\/\//i.test(value.trim());
|
|
}
|
|
|
|
function normalizeSuggestionValue(value: unknown): string {
|
|
return typeof value === "string" ? value.trim() : "";
|
|
}
|
|
|
|
function uniquePreserveOrder(values: string[]): string[] {
|
|
const seen = new Set<string>();
|
|
const output: string[] = [];
|
|
for (const value of values) {
|
|
const normalized = value.trim();
|
|
if (!normalized) {
|
|
continue;
|
|
}
|
|
const key = normalized.toLowerCase();
|
|
if (seen.has(key)) {
|
|
continue;
|
|
}
|
|
seen.add(key);
|
|
output.push(normalized);
|
|
}
|
|
return output;
|
|
}
|
|
|
|
type DismissedUpdateBanner = {
|
|
latestVersion: string;
|
|
channel: string | null;
|
|
dismissedAtMs: number;
|
|
};
|
|
|
|
function loadDismissedUpdateBanner(): DismissedUpdateBanner | null {
|
|
try {
|
|
const raw = localStorage.getItem(UPDATE_BANNER_DISMISS_KEY);
|
|
if (!raw) {
|
|
return null;
|
|
}
|
|
const parsed = JSON.parse(raw) as Partial<DismissedUpdateBanner>;
|
|
if (!parsed || typeof parsed.latestVersion !== "string") {
|
|
return null;
|
|
}
|
|
return {
|
|
latestVersion: parsed.latestVersion,
|
|
channel: typeof parsed.channel === "string" ? parsed.channel : null,
|
|
dismissedAtMs: typeof parsed.dismissedAtMs === "number" ? parsed.dismissedAtMs : Date.now(),
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function isUpdateBannerDismissed(updateAvailable: unknown): boolean {
|
|
const dismissed = loadDismissedUpdateBanner();
|
|
if (!dismissed) {
|
|
return false;
|
|
}
|
|
const info = updateAvailable as { latestVersion?: unknown; channel?: unknown };
|
|
const latestVersion = info && typeof info.latestVersion === "string" ? info.latestVersion : null;
|
|
const channel = info && typeof info.channel === "string" ? info.channel : null;
|
|
return Boolean(
|
|
latestVersion && dismissed.latestVersion === latestVersion && dismissed.channel === channel,
|
|
);
|
|
}
|
|
|
|
function dismissUpdateBanner(updateAvailable: unknown) {
|
|
const info = updateAvailable as { latestVersion?: unknown; channel?: unknown };
|
|
const latestVersion = info && typeof info.latestVersion === "string" ? info.latestVersion : null;
|
|
if (!latestVersion) {
|
|
return;
|
|
}
|
|
const channel = info && typeof info.channel === "string" ? info.channel : null;
|
|
const payload: DismissedUpdateBanner = {
|
|
latestVersion,
|
|
channel,
|
|
dismissedAtMs: Date.now(),
|
|
};
|
|
try {
|
|
localStorage.setItem(UPDATE_BANNER_DISMISS_KEY, JSON.stringify(payload));
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
const AVATAR_DATA_RE = /^data:/i;
|
|
const AVATAR_HTTP_RE = /^https?:\/\//i;
|
|
const COMMUNICATION_SECTION_KEYS = ["channels", "messages", "broadcast", "talk", "audio"] as const;
|
|
const APPEARANCE_SECTION_KEYS = ["__appearance__", "ui", "wizard"] as const;
|
|
const AUTOMATION_SECTION_KEYS = [
|
|
"commands",
|
|
"hooks",
|
|
"bindings",
|
|
"cron",
|
|
"approvals",
|
|
"plugins",
|
|
] as const;
|
|
const INFRASTRUCTURE_SECTION_KEYS = [
|
|
"gateway",
|
|
"web",
|
|
"browser",
|
|
"nodeHost",
|
|
"canvasHost",
|
|
"discovery",
|
|
"media",
|
|
] as const;
|
|
const AI_AGENTS_SECTION_KEYS = [
|
|
"agents",
|
|
"models",
|
|
"skills",
|
|
"tools",
|
|
"memory",
|
|
"session",
|
|
] as const;
|
|
type CommunicationSectionKey = (typeof COMMUNICATION_SECTION_KEYS)[number];
|
|
type AppearanceSectionKey = (typeof APPEARANCE_SECTION_KEYS)[number];
|
|
type AutomationSectionKey = (typeof AUTOMATION_SECTION_KEYS)[number];
|
|
type InfrastructureSectionKey = (typeof INFRASTRUCTURE_SECTION_KEYS)[number];
|
|
type AiAgentsSectionKey = (typeof AI_AGENTS_SECTION_KEYS)[number];
|
|
|
|
const NAV_WIDTH_MIN = 200;
|
|
const NAV_WIDTH_MAX = 400;
|
|
|
|
function handleNavResizeStart(e: MouseEvent, state: AppViewState) {
|
|
e.preventDefault();
|
|
const startX = e.clientX;
|
|
const startWidth = state.settings.navWidth;
|
|
|
|
const onMove = (ev: MouseEvent) => {
|
|
const delta = ev.clientX - startX;
|
|
const next = Math.round(Math.min(NAV_WIDTH_MAX, Math.max(NAV_WIDTH_MIN, startWidth + delta)));
|
|
state.applySettings({ ...state.settings, navWidth: next });
|
|
};
|
|
|
|
const onUp = () => {
|
|
document.removeEventListener("mousemove", onMove);
|
|
document.removeEventListener("mouseup", onUp);
|
|
document.body.style.cursor = "";
|
|
document.body.style.userSelect = "";
|
|
};
|
|
|
|
document.body.style.cursor = "col-resize";
|
|
document.body.style.userSelect = "none";
|
|
document.addEventListener("mousemove", onMove);
|
|
document.addEventListener("mouseup", onUp);
|
|
}
|
|
|
|
function resolveAssistantAvatarUrl(state: AppViewState): string | undefined {
|
|
const list = state.agentsList?.agents ?? [];
|
|
const parsed = parseAgentSessionKey(state.sessionKey);
|
|
const agentId = parsed?.agentId ?? state.agentsList?.defaultId ?? "main";
|
|
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;
|
|
}
|
|
return identity?.avatarUrl;
|
|
}
|
|
|
|
export function renderApp(state: AppViewState) {
|
|
// Gate: require successful gateway connection before showing the dashboard.
|
|
// The gateway URL confirmation overlay is always rendered so URL-param flows still work.
|
|
if (!state.connected) {
|
|
return html`
|
|
${renderLoginGate(state)}
|
|
${renderGatewayUrlConfirmation(state)}
|
|
`;
|
|
}
|
|
|
|
const presenceCount = state.presenceEntries.length;
|
|
const sessionsCount = state.sessionsResult?.count ?? null;
|
|
const cronNext = state.cronStatus?.nextWakeAtMs ?? null;
|
|
const chatDisabledReason = state.connected ? null : t("chat.disconnected");
|
|
const isChat = state.tab === "chat";
|
|
const chatFocus = isChat && (state.settings.chatFocusMode || state.onboarding);
|
|
const showThinking = state.onboarding ? false : state.settings.chatShowThinking;
|
|
const assistantAvatarUrl = resolveAssistantAvatarUrl(state);
|
|
const chatAvatarUrl = state.chatAvatarUrl ?? assistantAvatarUrl ?? null;
|
|
const configValue =
|
|
state.configForm ?? (state.configSnapshot?.config as Record<string, unknown> | null);
|
|
const basePath = normalizeBasePath(state.basePath ?? "");
|
|
const resolvedAgentId =
|
|
state.agentsSelectedId ??
|
|
state.agentsList?.defaultId ??
|
|
state.agentsList?.agents?.[0]?.id ??
|
|
null;
|
|
const cronAgentSuggestions = sortLocaleStrings(
|
|
new Set(
|
|
[
|
|
...(state.agentsList?.agents?.map((entry) => entry.id.trim()) ?? []),
|
|
...state.cronJobs
|
|
.map((job) => (typeof job.agentId === "string" ? job.agentId.trim() : ""))
|
|
.filter(Boolean),
|
|
].filter(Boolean),
|
|
),
|
|
);
|
|
const cronModelSuggestions = sortLocaleStrings(
|
|
new Set(
|
|
[
|
|
...state.cronModelSuggestions,
|
|
...resolveConfiguredCronModelSuggestions(configValue),
|
|
...state.cronJobs
|
|
.map((job) => {
|
|
if (job.payload.kind !== "agentTurn" || typeof job.payload.model !== "string") {
|
|
return "";
|
|
}
|
|
return job.payload.model.trim();
|
|
})
|
|
.filter(Boolean),
|
|
].filter(Boolean),
|
|
),
|
|
);
|
|
const visibleCronJobs = getVisibleCronJobs(state);
|
|
const selectedDeliveryChannel =
|
|
state.cronForm.deliveryChannel && state.cronForm.deliveryChannel.trim()
|
|
? state.cronForm.deliveryChannel.trim()
|
|
: "last";
|
|
const jobToSuggestions = state.cronJobs
|
|
.map((job) => normalizeSuggestionValue(job.delivery?.to))
|
|
.filter(Boolean);
|
|
const accountToSuggestions = (
|
|
selectedDeliveryChannel === "last"
|
|
? Object.values(state.channelsSnapshot?.channelAccounts ?? {}).flat()
|
|
: (state.channelsSnapshot?.channelAccounts?.[selectedDeliveryChannel] ?? [])
|
|
)
|
|
.flatMap((account) => [
|
|
normalizeSuggestionValue(account.accountId),
|
|
normalizeSuggestionValue(account.name),
|
|
])
|
|
.filter(Boolean);
|
|
const rawDeliveryToSuggestions = uniquePreserveOrder([
|
|
...jobToSuggestions,
|
|
...accountToSuggestions,
|
|
]);
|
|
const accountSuggestions = uniquePreserveOrder(accountToSuggestions);
|
|
const deliveryToSuggestions =
|
|
state.cronForm.deliveryMode === "webhook"
|
|
? rawDeliveryToSuggestions.filter((value) => isHttpUrl(value))
|
|
: rawDeliveryToSuggestions;
|
|
|
|
return html`
|
|
${renderCommandPalette({
|
|
open: state.paletteOpen,
|
|
query: state.paletteQuery,
|
|
activeIndex: state.paletteActiveIndex,
|
|
onToggle: () => {
|
|
state.paletteOpen = !state.paletteOpen;
|
|
},
|
|
onQueryChange: (q) => {
|
|
state.paletteQuery = q;
|
|
},
|
|
onActiveIndexChange: (i) => {
|
|
state.paletteActiveIndex = i;
|
|
},
|
|
onNavigate: (tab) => {
|
|
state.setTab(tab as import("./navigation.ts").Tab);
|
|
},
|
|
onSlashCommand: (cmd) => {
|
|
state.setTab("chat" as import("./navigation.ts").Tab);
|
|
state.chatMessage = cmd.endsWith(" ") ? cmd : `${cmd} `;
|
|
},
|
|
})}
|
|
<div
|
|
class="shell ${isChat ? "shell--chat" : ""} ${chatFocus ? "shell--chat-focus" : ""} ${state.settings.navCollapsed ? "shell--nav-collapsed" : ""} ${state.onboarding ? "shell--onboarding" : ""}"
|
|
style="--shell-nav-width: ${state.settings.navWidth}px"
|
|
>
|
|
<header class="topbar">
|
|
<dashboard-header .tab=${state.tab}></dashboard-header>
|
|
<button
|
|
class="topbar-search"
|
|
@click=${() => {
|
|
state.paletteOpen = !state.paletteOpen;
|
|
}}
|
|
title="Search or jump to… (⌘K)"
|
|
aria-label="Open command palette"
|
|
>
|
|
<span class="topbar-search__label">${t("common.search")}</span>
|
|
<kbd class="topbar-search__kbd">⌘K</kbd>
|
|
</button>
|
|
<div class="topbar-status">
|
|
${renderTopbarThemeModeToggle(state)}
|
|
</div>
|
|
</header>
|
|
<div class="shell-nav">
|
|
<aside class="sidebar ${state.settings.navCollapsed ? "sidebar--collapsed" : ""}">
|
|
<div class="sidebar-header">
|
|
${
|
|
state.settings.navCollapsed
|
|
? nothing
|
|
: html`
|
|
<div class="sidebar-brand">
|
|
<img class="sidebar-brand__logo" src="${basePath ? `${basePath}/favicon.svg` : "/favicon.svg"}" alt="OpenClaw" />
|
|
<span class="sidebar-brand__title">OpenClaw</span>
|
|
</div>
|
|
`
|
|
}
|
|
<button
|
|
class="sidebar-collapse-btn"
|
|
@click=${() =>
|
|
state.applySettings({
|
|
...state.settings,
|
|
navCollapsed: !state.settings.navCollapsed,
|
|
})}
|
|
title="${state.settings.navCollapsed ? t("nav.expand") : t("nav.collapse")}"
|
|
aria-label="${state.settings.navCollapsed ? t("nav.expand") : t("nav.collapse")}"
|
|
>
|
|
${state.settings.navCollapsed ? icons.panelLeftOpen : icons.panelLeftClose}
|
|
</button>
|
|
</div>
|
|
|
|
|
|
<nav class="sidebar-nav">
|
|
${TAB_GROUPS.map((group) => {
|
|
const isGroupCollapsed = state.settings.navGroupsCollapsed[group.label] ?? false;
|
|
const hasActiveTab = group.tabs.some((tab) => tab === state.tab);
|
|
const showItems = hasActiveTab || !isGroupCollapsed;
|
|
|
|
return html`
|
|
<div class="nav-group ${!showItems ? "nav-group--collapsed" : ""}">
|
|
${
|
|
!state.settings.navCollapsed
|
|
? html`
|
|
<button
|
|
class="nav-group__label"
|
|
@click=${() => {
|
|
const next = { ...state.settings.navGroupsCollapsed };
|
|
next[group.label] = !isGroupCollapsed;
|
|
state.applySettings({
|
|
...state.settings,
|
|
navGroupsCollapsed: next,
|
|
});
|
|
}}
|
|
aria-expanded=${showItems}
|
|
>
|
|
<span class="nav-group__label-text">${t(`nav.${group.label}`)}</span>
|
|
<span class="nav-group__chevron">${showItems ? icons.chevronDown : icons.chevronRight}</span>
|
|
</button>
|
|
`
|
|
: nothing
|
|
}
|
|
<div class="nav-group__items">
|
|
${group.tabs.map((tab) => renderTab(state, tab))}
|
|
</div>
|
|
</div>
|
|
`;
|
|
})}
|
|
</nav>
|
|
|
|
<div class="sidebar-footer">
|
|
<div class="sidebar-footer__docs-block">
|
|
<a
|
|
class="nav-item nav-item--external"
|
|
href="https://docs.openclaw.ai"
|
|
target=${EXTERNAL_LINK_TARGET}
|
|
rel=${buildExternalLinkRel()}
|
|
title="${t("common.docs")} (opens in new tab)"
|
|
>
|
|
<span class="nav-item__icon" aria-hidden="true">${icons.book}</span>
|
|
${
|
|
!state.settings.navCollapsed
|
|
? html`
|
|
<span class="nav-item__text">${t("common.docs")}</span>
|
|
<span class="nav-item__external-icon">${icons.externalLink}</span>
|
|
`
|
|
: nothing
|
|
}
|
|
</a>
|
|
${(() => {
|
|
const version = state.hello?.server?.version ?? "";
|
|
return version
|
|
? html`
|
|
<div class="sidebar-version" title=${`v${version}`}>
|
|
${
|
|
!state.settings.navCollapsed
|
|
? html`<span class="sidebar-version__text">v${version}</span>`
|
|
: html`
|
|
<span class="sidebar-version__dot"></span>
|
|
`
|
|
}
|
|
</div>
|
|
`
|
|
: nothing;
|
|
})()}
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
${
|
|
!state.settings.navCollapsed && !chatFocus
|
|
? html`
|
|
<div
|
|
class="sidebar-resizer"
|
|
role="separator"
|
|
aria-orientation="vertical"
|
|
aria-label="${t("nav.resize")}"
|
|
title="${t("nav.resize")}"
|
|
@mousedown=${(ev: MouseEvent) => handleNavResizeStart(ev, state)}
|
|
></div>
|
|
`
|
|
: nothing
|
|
}
|
|
</div>
|
|
<main class="content ${isChat ? "content--chat" : ""}">
|
|
${
|
|
state.updateAvailable &&
|
|
state.updateAvailable.latestVersion !== state.updateAvailable.currentVersion &&
|
|
!isUpdateBannerDismissed(state.updateAvailable)
|
|
? html`<div class="update-banner callout danger" role="alert">
|
|
<strong>Update available:</strong> v${state.updateAvailable.latestVersion}
|
|
(running v${state.updateAvailable.currentVersion}).
|
|
<button
|
|
class="btn btn--sm update-banner__btn"
|
|
?disabled=${state.updateRunning || !state.connected}
|
|
@click=${() => runUpdate(state)}
|
|
>${state.updateRunning ? "Updating…" : "Update now"}</button>
|
|
<button
|
|
class="update-banner__close"
|
|
type="button"
|
|
title="Dismiss"
|
|
aria-label="Dismiss update banner"
|
|
@click=${() => {
|
|
dismissUpdateBanner(state.updateAvailable);
|
|
state.updateAvailable = null;
|
|
}}
|
|
>
|
|
${icons.x}
|
|
</button>
|
|
</div>`
|
|
: nothing
|
|
}
|
|
${
|
|
state.tab === "config"
|
|
? nothing
|
|
: html`<section class="content-header">
|
|
<div>
|
|
${
|
|
isChat
|
|
? renderChatSessionSelect(state)
|
|
: html`<div class="page-title">${titleForTab(state.tab)}</div>`
|
|
}
|
|
${isChat ? nothing : html`<div class="page-sub">${subtitleForTab(state.tab)}</div>`}
|
|
</div>
|
|
<div class="page-meta">
|
|
${state.lastError ? html`<div class="pill danger">${state.lastError}</div>` : nothing}
|
|
${isChat ? renderChatControls(state) : nothing}
|
|
</div>
|
|
</section>`
|
|
}
|
|
|
|
${
|
|
state.tab === "overview"
|
|
? renderOverview({
|
|
connected: state.connected,
|
|
hello: state.hello,
|
|
settings: state.settings,
|
|
password: state.password,
|
|
lastError: state.lastError,
|
|
presenceCount,
|
|
sessionsCount,
|
|
cronEnabled: state.cronStatus?.enabled ?? null,
|
|
cronNext,
|
|
lastChannelsRefresh: state.channelsLastSuccess,
|
|
usageResult: state.usageResult,
|
|
sessionsResult: state.sessionsResult,
|
|
skillsReport: state.skillsReport,
|
|
cronJobs: state.cronJobs,
|
|
cronStatus: state.cronStatus,
|
|
attentionItems: state.attentionItems,
|
|
eventLog: state.eventLog,
|
|
overviewLogLines: state.overviewLogLines,
|
|
streamMode: state.streamMode,
|
|
showGatewayToken: state.overviewShowGatewayToken,
|
|
showGatewayPassword: state.overviewShowGatewayPassword,
|
|
onSettingsChange: (next) => state.applySettings(next),
|
|
onPasswordChange: (next) => (state.password = next),
|
|
onSessionKeyChange: (next) => {
|
|
state.sessionKey = next;
|
|
state.chatMessage = "";
|
|
state.resetToolStream();
|
|
state.applySettings({
|
|
...state.settings,
|
|
sessionKey: next,
|
|
lastActiveSessionKey: next,
|
|
});
|
|
void state.loadAssistantIdentity();
|
|
},
|
|
onToggleGatewayTokenVisibility: () => {
|
|
state.overviewShowGatewayToken = !state.overviewShowGatewayToken;
|
|
},
|
|
onToggleGatewayPasswordVisibility: () => {
|
|
state.overviewShowGatewayPassword = !state.overviewShowGatewayPassword;
|
|
},
|
|
onConnect: () => state.connect(),
|
|
onRefresh: () => state.loadOverview(),
|
|
onNavigate: (tab) => state.setTab(tab as import("./navigation.ts").Tab),
|
|
onRefreshLogs: () => state.loadOverview(),
|
|
onToggleStreamMode: () => {
|
|
state.streamMode = !state.streamMode;
|
|
try {
|
|
localStorage.setItem("openclaw:stream-mode", String(state.streamMode));
|
|
} catch {
|
|
/* */
|
|
}
|
|
},
|
|
})
|
|
: nothing
|
|
}
|
|
|
|
${
|
|
state.tab === "channels"
|
|
? renderChannels({
|
|
connected: state.connected,
|
|
loading: state.channelsLoading,
|
|
snapshot: state.channelsSnapshot,
|
|
lastError: state.channelsError,
|
|
lastSuccessAt: state.channelsLastSuccess,
|
|
whatsappMessage: state.whatsappLoginMessage,
|
|
whatsappQrDataUrl: state.whatsappLoginQrDataUrl,
|
|
whatsappConnected: state.whatsappLoginConnected,
|
|
whatsappBusy: state.whatsappBusy,
|
|
configSchema: state.configSchema,
|
|
configSchemaLoading: state.configSchemaLoading,
|
|
configForm: state.configForm,
|
|
configUiHints: state.configUiHints,
|
|
configSaving: state.configSaving,
|
|
configFormDirty: state.configFormDirty,
|
|
nostrProfileFormState: state.nostrProfileFormState,
|
|
nostrProfileAccountId: state.nostrProfileAccountId,
|
|
onRefresh: (probe) => loadChannels(state, probe),
|
|
onWhatsAppStart: (force) => state.handleWhatsAppStart(force),
|
|
onWhatsAppWait: () => state.handleWhatsAppWait(),
|
|
onWhatsAppLogout: () => state.handleWhatsAppLogout(),
|
|
onConfigPatch: (path, value) => updateConfigFormValue(state, path, value),
|
|
onConfigSave: () => state.handleChannelConfigSave(),
|
|
onConfigReload: () => state.handleChannelConfigReload(),
|
|
onNostrProfileEdit: (accountId, profile) =>
|
|
state.handleNostrProfileEdit(accountId, profile),
|
|
onNostrProfileCancel: () => state.handleNostrProfileCancel(),
|
|
onNostrProfileFieldChange: (field, value) =>
|
|
state.handleNostrProfileFieldChange(field, value),
|
|
onNostrProfileSave: () => state.handleNostrProfileSave(),
|
|
onNostrProfileImport: () => state.handleNostrProfileImport(),
|
|
onNostrProfileToggleAdvanced: () => state.handleNostrProfileToggleAdvanced(),
|
|
})
|
|
: nothing
|
|
}
|
|
|
|
${
|
|
state.tab === "instances"
|
|
? renderInstances({
|
|
loading: state.presenceLoading,
|
|
entries: state.presenceEntries,
|
|
lastError: state.presenceError,
|
|
statusMessage: state.presenceStatus,
|
|
streamMode: state.streamMode,
|
|
onRefresh: () => loadPresence(state),
|
|
})
|
|
: nothing
|
|
}
|
|
|
|
${
|
|
state.tab === "sessions"
|
|
? renderSessions({
|
|
loading: state.sessionsLoading,
|
|
result: state.sessionsResult,
|
|
error: state.sessionsError,
|
|
activeMinutes: state.sessionsFilterActive,
|
|
limit: state.sessionsFilterLimit,
|
|
includeGlobal: state.sessionsIncludeGlobal,
|
|
includeUnknown: state.sessionsIncludeUnknown,
|
|
basePath: state.basePath,
|
|
searchQuery: state.sessionsSearchQuery,
|
|
sortColumn: state.sessionsSortColumn,
|
|
sortDir: state.sessionsSortDir,
|
|
page: state.sessionsPage,
|
|
pageSize: state.sessionsPageSize,
|
|
actionsOpenKey: state.sessionsActionsOpenKey,
|
|
onFiltersChange: (next) => {
|
|
state.sessionsFilterActive = next.activeMinutes;
|
|
state.sessionsFilterLimit = next.limit;
|
|
state.sessionsIncludeGlobal = next.includeGlobal;
|
|
state.sessionsIncludeUnknown = next.includeUnknown;
|
|
},
|
|
onSearchChange: (q) => {
|
|
state.sessionsSearchQuery = q;
|
|
state.sessionsPage = 0;
|
|
},
|
|
onSortChange: (col, dir) => {
|
|
state.sessionsSortColumn = col;
|
|
state.sessionsSortDir = dir;
|
|
state.sessionsPage = 0;
|
|
},
|
|
onPageChange: (p) => {
|
|
state.sessionsPage = p;
|
|
},
|
|
onPageSizeChange: (s) => {
|
|
state.sessionsPageSize = s;
|
|
state.sessionsPage = 0;
|
|
},
|
|
onActionsOpenChange: (key) => {
|
|
state.sessionsActionsOpenKey = key;
|
|
},
|
|
onRefresh: () => loadSessions(state),
|
|
onPatch: (key, patch) => patchSession(state, key, patch),
|
|
onDelete: (key) => deleteSessionAndRefresh(state, key),
|
|
})
|
|
: nothing
|
|
}
|
|
|
|
${renderUsageTab(state)}
|
|
|
|
${
|
|
state.tab === "cron"
|
|
? renderCron({
|
|
basePath: state.basePath,
|
|
loading: state.cronLoading,
|
|
status: state.cronStatus,
|
|
jobs: visibleCronJobs,
|
|
jobsLoadingMore: state.cronJobsLoadingMore,
|
|
jobsTotal: state.cronJobsTotal,
|
|
jobsHasMore: state.cronJobsHasMore,
|
|
jobsQuery: state.cronJobsQuery,
|
|
jobsEnabledFilter: state.cronJobsEnabledFilter,
|
|
jobsScheduleKindFilter: state.cronJobsScheduleKindFilter,
|
|
jobsLastStatusFilter: state.cronJobsLastStatusFilter,
|
|
jobsSortBy: state.cronJobsSortBy,
|
|
jobsSortDir: state.cronJobsSortDir,
|
|
error: state.cronError,
|
|
busy: state.cronBusy,
|
|
form: state.cronForm,
|
|
channels: state.channelsSnapshot?.channelMeta?.length
|
|
? state.channelsSnapshot.channelMeta.map((entry) => entry.id)
|
|
: (state.channelsSnapshot?.channelOrder ?? []),
|
|
channelLabels: state.channelsSnapshot?.channelLabels ?? {},
|
|
channelMeta: state.channelsSnapshot?.channelMeta ?? [],
|
|
runsJobId: state.cronRunsJobId,
|
|
runs: state.cronRuns,
|
|
runsTotal: state.cronRunsTotal,
|
|
runsHasMore: state.cronRunsHasMore,
|
|
runsLoadingMore: state.cronRunsLoadingMore,
|
|
runsScope: state.cronRunsScope,
|
|
runsStatuses: state.cronRunsStatuses,
|
|
runsDeliveryStatuses: state.cronRunsDeliveryStatuses,
|
|
runsStatusFilter: state.cronRunsStatusFilter,
|
|
runsQuery: state.cronRunsQuery,
|
|
runsSortDir: state.cronRunsSortDir,
|
|
fieldErrors: state.cronFieldErrors,
|
|
canSubmit: !hasCronFormErrors(state.cronFieldErrors),
|
|
agentSuggestions: cronAgentSuggestions,
|
|
modelSuggestions: cronModelSuggestions,
|
|
thinkingSuggestions: CRON_THINKING_SUGGESTIONS,
|
|
timezoneSuggestions: CRON_TIMEZONE_SUGGESTIONS,
|
|
deliveryToSuggestions,
|
|
accountSuggestions,
|
|
onFormChange: (patch) => {
|
|
state.cronForm = normalizeCronFormState({ ...state.cronForm, ...patch });
|
|
state.cronFieldErrors = validateCronForm(state.cronForm);
|
|
},
|
|
onRefresh: () => state.loadCron(),
|
|
onAdd: () => addCronJob(state),
|
|
onEdit: (job) => startCronEdit(state, job),
|
|
onClone: (job) => startCronClone(state, job),
|
|
onCancelEdit: () => cancelCronEdit(state),
|
|
onToggle: (job, enabled) => toggleCronJob(state, job, enabled),
|
|
onRun: (job, mode) => runCronJob(state, job, mode ?? "force"),
|
|
onRemove: (job) => removeCronJob(state, job),
|
|
onLoadRuns: async (jobId) => {
|
|
updateCronRunsFilter(state, { cronRunsScope: "job" });
|
|
await loadCronRuns(state, jobId);
|
|
},
|
|
onLoadMoreJobs: () => loadMoreCronJobs(state),
|
|
onJobsFiltersChange: async (patch) => {
|
|
updateCronJobsFilter(state, patch);
|
|
const shouldReload =
|
|
typeof patch.cronJobsQuery === "string" ||
|
|
Boolean(patch.cronJobsEnabledFilter) ||
|
|
Boolean(patch.cronJobsSortBy) ||
|
|
Boolean(patch.cronJobsSortDir);
|
|
if (shouldReload) {
|
|
await reloadCronJobs(state);
|
|
}
|
|
},
|
|
onJobsFiltersReset: async () => {
|
|
updateCronJobsFilter(state, {
|
|
cronJobsQuery: "",
|
|
cronJobsEnabledFilter: "all",
|
|
cronJobsScheduleKindFilter: "all",
|
|
cronJobsLastStatusFilter: "all",
|
|
cronJobsSortBy: "nextRunAtMs",
|
|
cronJobsSortDir: "asc",
|
|
});
|
|
await reloadCronJobs(state);
|
|
},
|
|
onLoadMoreRuns: () => loadMoreCronRuns(state),
|
|
onRunsFiltersChange: async (patch) => {
|
|
updateCronRunsFilter(state, patch);
|
|
if (state.cronRunsScope === "all") {
|
|
await loadCronRuns(state, null);
|
|
return;
|
|
}
|
|
await loadCronRuns(state, state.cronRunsJobId);
|
|
},
|
|
})
|
|
: nothing
|
|
}
|
|
|
|
${
|
|
state.tab === "agents"
|
|
? renderAgents({
|
|
basePath: state.basePath ?? "",
|
|
loading: state.agentsLoading,
|
|
error: state.agentsError,
|
|
agentsList: state.agentsList,
|
|
selectedAgentId: resolvedAgentId,
|
|
activePanel: state.agentsPanel,
|
|
config: {
|
|
form: configValue,
|
|
loading: state.configLoading,
|
|
saving: state.configSaving,
|
|
dirty: state.configFormDirty,
|
|
},
|
|
channels: {
|
|
snapshot: state.channelsSnapshot,
|
|
loading: state.channelsLoading,
|
|
error: state.channelsError,
|
|
lastSuccess: state.channelsLastSuccess,
|
|
},
|
|
cron: {
|
|
status: state.cronStatus,
|
|
jobs: state.cronJobs,
|
|
loading: state.cronLoading,
|
|
error: state.cronError,
|
|
},
|
|
agentFiles: {
|
|
list: state.agentFilesList,
|
|
loading: state.agentFilesLoading,
|
|
error: state.agentFilesError,
|
|
active: state.agentFileActive,
|
|
contents: state.agentFileContents,
|
|
drafts: state.agentFileDrafts,
|
|
saving: state.agentFileSaving,
|
|
},
|
|
agentIdentityLoading: state.agentIdentityLoading,
|
|
agentIdentityError: state.agentIdentityError,
|
|
agentIdentityById: state.agentIdentityById,
|
|
agentSkills: {
|
|
report: state.agentSkillsReport,
|
|
loading: state.agentSkillsLoading,
|
|
error: state.agentSkillsError,
|
|
agentId: state.agentSkillsAgentId,
|
|
filter: state.skillsFilter,
|
|
},
|
|
toolsCatalog: {
|
|
loading: state.toolsCatalogLoading,
|
|
error: state.toolsCatalogError,
|
|
result: state.toolsCatalogResult,
|
|
},
|
|
onRefresh: async () => {
|
|
await loadAgents(state);
|
|
const agentIds = state.agentsList?.agents?.map((entry) => entry.id) ?? [];
|
|
if (agentIds.length > 0) {
|
|
void loadAgentIdentities(state, agentIds);
|
|
}
|
|
const refreshedAgentId =
|
|
state.agentsSelectedId ??
|
|
state.agentsList?.defaultId ??
|
|
state.agentsList?.agents?.[0]?.id ??
|
|
null;
|
|
if (state.agentsPanel === "tools" && refreshedAgentId) {
|
|
void loadToolsCatalog(state, refreshedAgentId);
|
|
}
|
|
},
|
|
onSelectAgent: (agentId) => {
|
|
if (state.agentsSelectedId === agentId) {
|
|
return;
|
|
}
|
|
state.agentsSelectedId = agentId;
|
|
state.agentFilesList = null;
|
|
state.agentFilesError = null;
|
|
state.agentFilesLoading = false;
|
|
state.agentFileActive = null;
|
|
state.agentFileContents = {};
|
|
state.agentFileDrafts = {};
|
|
state.agentSkillsReport = null;
|
|
state.agentSkillsError = null;
|
|
state.agentSkillsAgentId = null;
|
|
state.toolsCatalogResult = null;
|
|
state.toolsCatalogError = null;
|
|
state.toolsCatalogLoading = false;
|
|
void loadAgentIdentity(state, agentId);
|
|
if (state.agentsPanel === "files") {
|
|
void loadAgentFiles(state, agentId);
|
|
}
|
|
if (state.agentsPanel === "tools") {
|
|
void loadToolsCatalog(state, agentId);
|
|
}
|
|
if (state.agentsPanel === "skills") {
|
|
void loadAgentSkills(state, agentId);
|
|
}
|
|
},
|
|
onSelectPanel: (panel) => {
|
|
state.agentsPanel = panel;
|
|
if (panel === "files" && resolvedAgentId) {
|
|
if (state.agentFilesList?.agentId !== resolvedAgentId) {
|
|
state.agentFilesList = null;
|
|
state.agentFilesError = null;
|
|
state.agentFileActive = null;
|
|
state.agentFileContents = {};
|
|
state.agentFileDrafts = {};
|
|
void loadAgentFiles(state, resolvedAgentId);
|
|
}
|
|
}
|
|
if (panel === "skills") {
|
|
if (resolvedAgentId) {
|
|
void loadAgentSkills(state, resolvedAgentId);
|
|
}
|
|
}
|
|
if (panel === "tools" && resolvedAgentId) {
|
|
if (
|
|
state.toolsCatalogResult?.agentId !== resolvedAgentId ||
|
|
state.toolsCatalogError
|
|
) {
|
|
void loadToolsCatalog(state, resolvedAgentId);
|
|
}
|
|
}
|
|
if (panel === "channels") {
|
|
void loadChannels(state, false);
|
|
}
|
|
if (panel === "cron") {
|
|
void state.loadCron();
|
|
}
|
|
},
|
|
onLoadFiles: (agentId) => loadAgentFiles(state, agentId),
|
|
onSelectFile: (name) => {
|
|
state.agentFileActive = name;
|
|
if (!resolvedAgentId) {
|
|
return;
|
|
}
|
|
void loadAgentFileContent(state, resolvedAgentId, name);
|
|
},
|
|
onFileDraftChange: (name, content) => {
|
|
state.agentFileDrafts = { ...state.agentFileDrafts, [name]: content };
|
|
},
|
|
onFileReset: (name) => {
|
|
const base = state.agentFileContents[name] ?? "";
|
|
state.agentFileDrafts = { ...state.agentFileDrafts, [name]: base };
|
|
},
|
|
onFileSave: (name) => {
|
|
if (!resolvedAgentId) {
|
|
return;
|
|
}
|
|
const content =
|
|
state.agentFileDrafts[name] ?? state.agentFileContents[name] ?? "";
|
|
void saveAgentFile(state, resolvedAgentId, name, content);
|
|
},
|
|
onToolsProfileChange: (agentId, profile, clearAllow) => {
|
|
if (!configValue) {
|
|
return;
|
|
}
|
|
const list = (configValue as { agents?: { list?: unknown[] } }).agents?.list;
|
|
if (!Array.isArray(list)) {
|
|
return;
|
|
}
|
|
const index = list.findIndex(
|
|
(entry) =>
|
|
entry &&
|
|
typeof entry === "object" &&
|
|
"id" in entry &&
|
|
(entry as { id?: string }).id === agentId,
|
|
);
|
|
if (index < 0) {
|
|
return;
|
|
}
|
|
const basePath = ["agents", "list", index, "tools"];
|
|
if (profile) {
|
|
updateConfigFormValue(state, [...basePath, "profile"], profile);
|
|
} else {
|
|
removeConfigFormValue(state, [...basePath, "profile"]);
|
|
}
|
|
if (clearAllow) {
|
|
removeConfigFormValue(state, [...basePath, "allow"]);
|
|
}
|
|
},
|
|
onToolsOverridesChange: (agentId, alsoAllow, deny) => {
|
|
if (!configValue) {
|
|
return;
|
|
}
|
|
const list = (configValue as { agents?: { list?: unknown[] } }).agents?.list;
|
|
if (!Array.isArray(list)) {
|
|
return;
|
|
}
|
|
const index = list.findIndex(
|
|
(entry) =>
|
|
entry &&
|
|
typeof entry === "object" &&
|
|
"id" in entry &&
|
|
(entry as { id?: string }).id === agentId,
|
|
);
|
|
if (index < 0) {
|
|
return;
|
|
}
|
|
const basePath = ["agents", "list", index, "tools"];
|
|
if (alsoAllow.length > 0) {
|
|
updateConfigFormValue(state, [...basePath, "alsoAllow"], alsoAllow);
|
|
} else {
|
|
removeConfigFormValue(state, [...basePath, "alsoAllow"]);
|
|
}
|
|
if (deny.length > 0) {
|
|
updateConfigFormValue(state, [...basePath, "deny"], deny);
|
|
} else {
|
|
removeConfigFormValue(state, [...basePath, "deny"]);
|
|
}
|
|
},
|
|
onConfigReload: () => loadConfig(state),
|
|
onConfigSave: () => saveConfig(state),
|
|
onChannelsRefresh: () => loadChannels(state, false),
|
|
onCronRefresh: () => state.loadCron(),
|
|
onCronRunNow: (jobId) => {
|
|
const job = state.cronJobs.find((entry) => entry.id === jobId);
|
|
if (!job) {
|
|
return;
|
|
}
|
|
void runCronJob(state, job, "force");
|
|
},
|
|
onSkillsFilterChange: (next) => (state.skillsFilter = next),
|
|
onSkillsRefresh: () => {
|
|
if (resolvedAgentId) {
|
|
void loadAgentSkills(state, resolvedAgentId);
|
|
}
|
|
},
|
|
onAgentSkillToggle: (agentId, skillName, enabled) => {
|
|
if (!configValue) {
|
|
return;
|
|
}
|
|
const list = (configValue as { agents?: { list?: unknown[] } }).agents?.list;
|
|
if (!Array.isArray(list)) {
|
|
return;
|
|
}
|
|
const index = list.findIndex(
|
|
(entry) =>
|
|
entry &&
|
|
typeof entry === "object" &&
|
|
"id" in entry &&
|
|
(entry as { id?: string }).id === agentId,
|
|
);
|
|
if (index < 0) {
|
|
return;
|
|
}
|
|
const entry = list[index] as { skills?: unknown };
|
|
const normalizedSkill = skillName.trim();
|
|
if (!normalizedSkill) {
|
|
return;
|
|
}
|
|
const allSkills =
|
|
state.agentSkillsReport?.skills?.map((skill) => skill.name).filter(Boolean) ??
|
|
[];
|
|
const existing = Array.isArray(entry.skills)
|
|
? entry.skills.map((name) => String(name).trim()).filter(Boolean)
|
|
: undefined;
|
|
const base = existing ?? allSkills;
|
|
const next = new Set(base);
|
|
if (enabled) {
|
|
next.add(normalizedSkill);
|
|
} else {
|
|
next.delete(normalizedSkill);
|
|
}
|
|
updateConfigFormValue(state, ["agents", "list", index, "skills"], [...next]);
|
|
},
|
|
onAgentSkillsClear: (agentId) => {
|
|
if (!configValue) {
|
|
return;
|
|
}
|
|
const list = (configValue as { agents?: { list?: unknown[] } }).agents?.list;
|
|
if (!Array.isArray(list)) {
|
|
return;
|
|
}
|
|
const index = list.findIndex(
|
|
(entry) =>
|
|
entry &&
|
|
typeof entry === "object" &&
|
|
"id" in entry &&
|
|
(entry as { id?: string }).id === agentId,
|
|
);
|
|
if (index < 0) {
|
|
return;
|
|
}
|
|
removeConfigFormValue(state, ["agents", "list", index, "skills"]);
|
|
},
|
|
onAgentSkillsDisableAll: (agentId) => {
|
|
if (!configValue) {
|
|
return;
|
|
}
|
|
const list = (configValue as { agents?: { list?: unknown[] } }).agents?.list;
|
|
if (!Array.isArray(list)) {
|
|
return;
|
|
}
|
|
const index = list.findIndex(
|
|
(entry) =>
|
|
entry &&
|
|
typeof entry === "object" &&
|
|
"id" in entry &&
|
|
(entry as { id?: string }).id === agentId,
|
|
);
|
|
if (index < 0) {
|
|
return;
|
|
}
|
|
updateConfigFormValue(state, ["agents", "list", index, "skills"], []);
|
|
},
|
|
onModelChange: (agentId, modelId) => {
|
|
if (!configValue) {
|
|
return;
|
|
}
|
|
const list = (configValue as { agents?: { list?: unknown[] } }).agents?.list;
|
|
if (!Array.isArray(list)) {
|
|
return;
|
|
}
|
|
const index = list.findIndex(
|
|
(entry) =>
|
|
entry &&
|
|
typeof entry === "object" &&
|
|
"id" in entry &&
|
|
(entry as { id?: string }).id === agentId,
|
|
);
|
|
if (index < 0) {
|
|
return;
|
|
}
|
|
const basePath = ["agents", "list", index, "model"];
|
|
if (!modelId) {
|
|
removeConfigFormValue(state, basePath);
|
|
return;
|
|
}
|
|
const entry = list[index] as { model?: unknown };
|
|
const existing = entry?.model;
|
|
if (existing && typeof existing === "object" && !Array.isArray(existing)) {
|
|
const fallbacks = (existing as { fallbacks?: unknown }).fallbacks;
|
|
const next = {
|
|
primary: modelId,
|
|
...(Array.isArray(fallbacks) ? { fallbacks } : {}),
|
|
};
|
|
updateConfigFormValue(state, basePath, next);
|
|
} else {
|
|
updateConfigFormValue(state, basePath, modelId);
|
|
}
|
|
},
|
|
onModelFallbacksChange: (agentId, fallbacks) => {
|
|
if (!configValue) {
|
|
return;
|
|
}
|
|
const list = (configValue as { agents?: { list?: unknown[] } }).agents?.list;
|
|
if (!Array.isArray(list)) {
|
|
return;
|
|
}
|
|
const index = list.findIndex(
|
|
(entry) =>
|
|
entry &&
|
|
typeof entry === "object" &&
|
|
"id" in entry &&
|
|
(entry as { id?: string }).id === agentId,
|
|
);
|
|
if (index < 0) {
|
|
return;
|
|
}
|
|
const basePath = ["agents", "list", index, "model"];
|
|
const entry = list[index] as { model?: unknown };
|
|
const normalized = fallbacks.map((name) => name.trim()).filter(Boolean);
|
|
const existing = entry.model;
|
|
const resolvePrimary = () => {
|
|
if (typeof existing === "string") {
|
|
return existing.trim() || null;
|
|
}
|
|
if (existing && typeof existing === "object" && !Array.isArray(existing)) {
|
|
const primary = (existing as { primary?: unknown }).primary;
|
|
if (typeof primary === "string") {
|
|
const trimmed = primary.trim();
|
|
return trimmed || null;
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
const primary = resolvePrimary();
|
|
if (normalized.length === 0) {
|
|
if (primary) {
|
|
updateConfigFormValue(state, basePath, primary);
|
|
} else {
|
|
removeConfigFormValue(state, basePath);
|
|
}
|
|
return;
|
|
}
|
|
const next = primary
|
|
? { primary, fallbacks: normalized }
|
|
: { fallbacks: normalized };
|
|
updateConfigFormValue(state, basePath, next);
|
|
},
|
|
onSetDefault: (agentId) => {
|
|
if (!configValue) {
|
|
return;
|
|
}
|
|
updateConfigFormValue(state, ["agents", "defaultId"], agentId);
|
|
},
|
|
})
|
|
: nothing
|
|
}
|
|
|
|
${
|
|
state.tab === "skills"
|
|
? renderSkills({
|
|
loading: state.skillsLoading,
|
|
report: state.skillsReport,
|
|
error: state.skillsError,
|
|
filter: state.skillsFilter,
|
|
edits: state.skillEdits,
|
|
messages: state.skillMessages,
|
|
busyKey: state.skillsBusyKey,
|
|
onFilterChange: (next) => (state.skillsFilter = next),
|
|
onRefresh: () => loadSkills(state, { clearMessages: true }),
|
|
onToggle: (key, enabled) => updateSkillEnabled(state, key, enabled),
|
|
onEdit: (key, value) => updateSkillEdit(state, key, value),
|
|
onSaveKey: (key) => saveSkillApiKey(state, key),
|
|
onInstall: (skillKey, name, installId) =>
|
|
installSkill(state, skillKey, name, installId),
|
|
})
|
|
: nothing
|
|
}
|
|
|
|
${
|
|
state.tab === "nodes"
|
|
? renderNodes({
|
|
loading: state.nodesLoading,
|
|
nodes: state.nodes,
|
|
devicesLoading: state.devicesLoading,
|
|
devicesError: state.devicesError,
|
|
devicesList: state.devicesList,
|
|
configForm:
|
|
state.configForm ??
|
|
(state.configSnapshot?.config as Record<string, unknown> | null),
|
|
configLoading: state.configLoading,
|
|
configSaving: state.configSaving,
|
|
configDirty: state.configFormDirty,
|
|
configFormMode: state.configFormMode,
|
|
execApprovalsLoading: state.execApprovalsLoading,
|
|
execApprovalsSaving: state.execApprovalsSaving,
|
|
execApprovalsDirty: state.execApprovalsDirty,
|
|
execApprovalsSnapshot: state.execApprovalsSnapshot,
|
|
execApprovalsForm: state.execApprovalsForm,
|
|
execApprovalsSelectedAgent: state.execApprovalsSelectedAgent,
|
|
execApprovalsTarget: state.execApprovalsTarget,
|
|
execApprovalsTargetNodeId: state.execApprovalsTargetNodeId,
|
|
onRefresh: () => loadNodes(state),
|
|
onDevicesRefresh: () => loadDevices(state),
|
|
onDeviceApprove: (requestId) => approveDevicePairing(state, requestId),
|
|
onDeviceReject: (requestId) => rejectDevicePairing(state, requestId),
|
|
onDeviceRotate: (deviceId, role, scopes) =>
|
|
rotateDeviceToken(state, { deviceId, role, scopes }),
|
|
onDeviceRevoke: (deviceId, role) => revokeDeviceToken(state, { deviceId, role }),
|
|
onLoadConfig: () => loadConfig(state),
|
|
onLoadExecApprovals: () => {
|
|
const target =
|
|
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
|
|
? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId }
|
|
: { kind: "gateway" as const };
|
|
return loadExecApprovals(state, target);
|
|
},
|
|
onBindDefault: (nodeId) => {
|
|
if (nodeId) {
|
|
updateConfigFormValue(state, ["tools", "exec", "node"], nodeId);
|
|
} else {
|
|
removeConfigFormValue(state, ["tools", "exec", "node"]);
|
|
}
|
|
},
|
|
onBindAgent: (agentIndex, nodeId) => {
|
|
const basePath = ["agents", "list", agentIndex, "tools", "exec", "node"];
|
|
if (nodeId) {
|
|
updateConfigFormValue(state, basePath, nodeId);
|
|
} else {
|
|
removeConfigFormValue(state, basePath);
|
|
}
|
|
},
|
|
onSaveBindings: () => saveConfig(state),
|
|
onExecApprovalsTargetChange: (kind, nodeId) => {
|
|
state.execApprovalsTarget = kind;
|
|
state.execApprovalsTargetNodeId = nodeId;
|
|
state.execApprovalsSnapshot = null;
|
|
state.execApprovalsForm = null;
|
|
state.execApprovalsDirty = false;
|
|
state.execApprovalsSelectedAgent = null;
|
|
},
|
|
onExecApprovalsSelectAgent: (agentId) => {
|
|
state.execApprovalsSelectedAgent = agentId;
|
|
},
|
|
onExecApprovalsPatch: (path, value) =>
|
|
updateExecApprovalsFormValue(state, path, value),
|
|
onExecApprovalsRemove: (path) => removeExecApprovalsFormValue(state, path),
|
|
onSaveExecApprovals: () => {
|
|
const target =
|
|
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
|
|
? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId }
|
|
: { kind: "gateway" as const };
|
|
return saveExecApprovals(state, target);
|
|
},
|
|
})
|
|
: nothing
|
|
}
|
|
|
|
${
|
|
state.tab === "chat"
|
|
? renderChat({
|
|
sessionKey: state.sessionKey,
|
|
onSessionKeyChange: (next) => {
|
|
state.sessionKey = next;
|
|
state.chatMessage = "";
|
|
state.chatAttachments = [];
|
|
state.chatStream = null;
|
|
state.chatStreamStartedAt = null;
|
|
state.chatRunId = null;
|
|
state.chatQueue = [];
|
|
state.resetToolStream();
|
|
state.resetChatScroll();
|
|
state.applySettings({
|
|
...state.settings,
|
|
sessionKey: next,
|
|
lastActiveSessionKey: next,
|
|
});
|
|
void state.loadAssistantIdentity();
|
|
void loadChatHistory(state);
|
|
void refreshChatAvatar(state);
|
|
},
|
|
thinkingLevel: state.chatThinkingLevel,
|
|
showThinking,
|
|
loading: state.chatLoading,
|
|
sending: state.chatSending,
|
|
compactionStatus: state.compactionStatus,
|
|
fallbackStatus: state.fallbackStatus,
|
|
assistantAvatarUrl: chatAvatarUrl,
|
|
messages: state.chatMessages,
|
|
toolMessages: state.chatToolMessages,
|
|
stream: state.chatStream,
|
|
streamStartedAt: state.chatStreamStartedAt,
|
|
draft: state.chatMessage,
|
|
queue: state.chatQueue,
|
|
connected: state.connected,
|
|
canSend: state.connected,
|
|
disabledReason: chatDisabledReason,
|
|
error: state.lastError,
|
|
sessions: state.sessionsResult,
|
|
focusMode: chatFocus,
|
|
onRefresh: () => {
|
|
state.resetToolStream();
|
|
return Promise.all([loadChatHistory(state), refreshChatAvatar(state)]);
|
|
},
|
|
onToggleFocusMode: () => {
|
|
if (state.onboarding) {
|
|
return;
|
|
}
|
|
state.applySettings({
|
|
...state.settings,
|
|
chatFocusMode: !state.settings.chatFocusMode,
|
|
});
|
|
},
|
|
onChatScroll: (event) => state.handleChatScroll(event),
|
|
onDraftChange: (next) => (state.chatMessage = next),
|
|
attachments: state.chatAttachments,
|
|
onAttachmentsChange: (next) => (state.chatAttachments = next),
|
|
onSend: () => state.handleSendChat(),
|
|
canAbort: Boolean(state.chatRunId),
|
|
onAbort: () => void state.handleAbortChat(),
|
|
onQueueRemove: (id) => state.removeQueuedMessage(id),
|
|
onNewSession: () => state.handleSendChat("/new", { restoreDraft: true }),
|
|
onClearHistory: async () => {
|
|
if (!state.client || !state.connected) {
|
|
return;
|
|
}
|
|
try {
|
|
await state.client.request("sessions.reset", { key: state.sessionKey });
|
|
state.chatMessages = [];
|
|
state.chatStream = null;
|
|
state.chatRunId = null;
|
|
await loadChatHistory(state);
|
|
} catch (err) {
|
|
state.lastError = String(err);
|
|
}
|
|
},
|
|
agentsList: state.agentsList,
|
|
currentAgentId: resolvedAgentId ?? "main",
|
|
onAgentChange: (agentId: string) => {
|
|
state.sessionKey = buildAgentMainSessionKey({ agentId });
|
|
state.chatMessages = [];
|
|
state.chatStream = null;
|
|
state.chatRunId = null;
|
|
state.applySettings({
|
|
...state.settings,
|
|
sessionKey: state.sessionKey,
|
|
lastActiveSessionKey: state.sessionKey,
|
|
});
|
|
void loadChatHistory(state);
|
|
void state.loadAssistantIdentity();
|
|
},
|
|
onNavigateToAgent: () => {
|
|
state.agentsSelectedId = resolvedAgentId;
|
|
state.setTab("agents" as import("./navigation.ts").Tab);
|
|
},
|
|
onSessionSelect: (key: string) => {
|
|
state.setSessionKey(key);
|
|
state.chatMessages = [];
|
|
void loadChatHistory(state);
|
|
void state.loadAssistantIdentity();
|
|
},
|
|
showNewMessages: state.chatNewMessagesBelow && !state.chatManualRefreshInFlight,
|
|
onScrollToBottom: () => state.scrollToBottom(),
|
|
// Sidebar props for tool output viewing
|
|
sidebarOpen: state.sidebarOpen,
|
|
sidebarContent: state.sidebarContent,
|
|
sidebarError: state.sidebarError,
|
|
splitRatio: state.splitRatio,
|
|
onOpenSidebar: (content: string) => state.handleOpenSidebar(content),
|
|
onCloseSidebar: () => state.handleCloseSidebar(),
|
|
onSplitRatioChange: (ratio: number) => state.handleSplitRatioChange(ratio),
|
|
assistantName: state.assistantName,
|
|
assistantAvatar: state.assistantAvatar,
|
|
basePath: state.basePath ?? "",
|
|
})
|
|
: nothing
|
|
}
|
|
|
|
${
|
|
state.tab === "config"
|
|
? renderConfig({
|
|
raw: state.configRaw,
|
|
originalRaw: state.configRawOriginal,
|
|
valid: state.configValid,
|
|
issues: state.configIssues,
|
|
loading: state.configLoading,
|
|
saving: state.configSaving,
|
|
applying: state.configApplying,
|
|
updating: state.updateRunning,
|
|
connected: state.connected,
|
|
schema: state.configSchema,
|
|
schemaLoading: state.configSchemaLoading,
|
|
uiHints: state.configUiHints,
|
|
formMode: state.configFormMode,
|
|
showModeToggle: true,
|
|
formValue: state.configForm,
|
|
originalValue: state.configFormOriginal,
|
|
searchQuery: state.configSearchQuery,
|
|
activeSection:
|
|
state.configActiveSection &&
|
|
(COMMUNICATION_SECTION_KEYS.includes(
|
|
state.configActiveSection as CommunicationSectionKey,
|
|
) ||
|
|
APPEARANCE_SECTION_KEYS.includes(
|
|
state.configActiveSection as AppearanceSectionKey,
|
|
) ||
|
|
AUTOMATION_SECTION_KEYS.includes(
|
|
state.configActiveSection as AutomationSectionKey,
|
|
) ||
|
|
INFRASTRUCTURE_SECTION_KEYS.includes(
|
|
state.configActiveSection as InfrastructureSectionKey,
|
|
) ||
|
|
AI_AGENTS_SECTION_KEYS.includes(
|
|
state.configActiveSection as AiAgentsSectionKey,
|
|
))
|
|
? null
|
|
: state.configActiveSection,
|
|
activeSubsection:
|
|
state.configActiveSection &&
|
|
(COMMUNICATION_SECTION_KEYS.includes(
|
|
state.configActiveSection as CommunicationSectionKey,
|
|
) ||
|
|
APPEARANCE_SECTION_KEYS.includes(
|
|
state.configActiveSection as AppearanceSectionKey,
|
|
) ||
|
|
AUTOMATION_SECTION_KEYS.includes(
|
|
state.configActiveSection as AutomationSectionKey,
|
|
) ||
|
|
INFRASTRUCTURE_SECTION_KEYS.includes(
|
|
state.configActiveSection as InfrastructureSectionKey,
|
|
) ||
|
|
AI_AGENTS_SECTION_KEYS.includes(
|
|
state.configActiveSection as AiAgentsSectionKey,
|
|
))
|
|
? null
|
|
: state.configActiveSubsection,
|
|
streamMode: state.streamMode,
|
|
onRawChange: (next) => {
|
|
state.configRaw = next;
|
|
},
|
|
onFormModeChange: (mode) => (state.configFormMode = mode),
|
|
onFormPatch: (path, value) => updateConfigFormValue(state, path, value),
|
|
onSearchChange: (query) => (state.configSearchQuery = query),
|
|
onSectionChange: (section) => {
|
|
state.configActiveSection = section;
|
|
state.configActiveSubsection = null;
|
|
},
|
|
onSubsectionChange: (section) => (state.configActiveSubsection = section),
|
|
onReload: () => loadConfig(state),
|
|
onSave: () => saveConfig(state),
|
|
onApply: () => applyConfig(state),
|
|
onUpdate: () => runUpdate(state),
|
|
version: state.hello?.server?.version ?? "",
|
|
theme: state.theme,
|
|
themeMode: state.themeMode,
|
|
setTheme: (t, ctx) => state.setTheme(t, ctx),
|
|
setThemeMode: (m, ctx) => state.setThemeMode(m, ctx),
|
|
gatewayUrl: state.settings.gatewayUrl,
|
|
assistantName: state.assistantName,
|
|
excludeSections: [
|
|
...COMMUNICATION_SECTION_KEYS,
|
|
...AUTOMATION_SECTION_KEYS,
|
|
...INFRASTRUCTURE_SECTION_KEYS,
|
|
...AI_AGENTS_SECTION_KEYS,
|
|
"ui",
|
|
"wizard",
|
|
],
|
|
includeVirtualSections: false,
|
|
})
|
|
: nothing
|
|
}
|
|
|
|
${
|
|
state.tab === "communications"
|
|
? renderConfig({
|
|
raw: state.configRaw,
|
|
originalRaw: state.configRawOriginal,
|
|
valid: state.configValid,
|
|
issues: state.configIssues,
|
|
loading: state.configLoading,
|
|
saving: state.configSaving,
|
|
applying: state.configApplying,
|
|
updating: state.updateRunning,
|
|
connected: state.connected,
|
|
schema: state.configSchema,
|
|
schemaLoading: state.configSchemaLoading,
|
|
uiHints: state.configUiHints,
|
|
formMode: state.communicationsFormMode,
|
|
formValue: state.configForm,
|
|
originalValue: state.configFormOriginal,
|
|
searchQuery: state.communicationsSearchQuery,
|
|
activeSection:
|
|
state.communicationsActiveSection &&
|
|
!COMMUNICATION_SECTION_KEYS.includes(
|
|
state.communicationsActiveSection as CommunicationSectionKey,
|
|
)
|
|
? null
|
|
: state.communicationsActiveSection,
|
|
activeSubsection:
|
|
state.communicationsActiveSection &&
|
|
!COMMUNICATION_SECTION_KEYS.includes(
|
|
state.communicationsActiveSection as CommunicationSectionKey,
|
|
)
|
|
? null
|
|
: state.communicationsActiveSubsection,
|
|
streamMode: state.streamMode,
|
|
onRawChange: (next) => {
|
|
state.configRaw = next;
|
|
},
|
|
onFormModeChange: (mode) => (state.communicationsFormMode = mode),
|
|
onFormPatch: (path, value) => updateConfigFormValue(state, path, value),
|
|
onSearchChange: (query) => (state.communicationsSearchQuery = query),
|
|
onSectionChange: (section) => {
|
|
state.communicationsActiveSection = section;
|
|
state.communicationsActiveSubsection = null;
|
|
},
|
|
onSubsectionChange: (section) => (state.communicationsActiveSubsection = section),
|
|
onReload: () => loadConfig(state),
|
|
onSave: () => saveConfig(state),
|
|
onApply: () => applyConfig(state),
|
|
onUpdate: () => runUpdate(state),
|
|
version: state.hello?.server?.version ?? "",
|
|
theme: state.theme,
|
|
themeMode: state.themeMode,
|
|
setTheme: (t, ctx) => state.setTheme(t, ctx),
|
|
setThemeMode: (m, ctx) => state.setThemeMode(m, ctx),
|
|
gatewayUrl: state.settings.gatewayUrl,
|
|
assistantName: state.assistantName,
|
|
navRootLabel: "Communication",
|
|
includeSections: [...COMMUNICATION_SECTION_KEYS],
|
|
includeVirtualSections: false,
|
|
})
|
|
: nothing
|
|
}
|
|
|
|
${
|
|
state.tab === "appearance"
|
|
? renderConfig({
|
|
raw: state.configRaw,
|
|
originalRaw: state.configRawOriginal,
|
|
valid: state.configValid,
|
|
issues: state.configIssues,
|
|
loading: state.configLoading,
|
|
saving: state.configSaving,
|
|
applying: state.configApplying,
|
|
updating: state.updateRunning,
|
|
connected: state.connected,
|
|
schema: state.configSchema,
|
|
schemaLoading: state.configSchemaLoading,
|
|
uiHints: state.configUiHints,
|
|
formMode: state.appearanceFormMode,
|
|
formValue: state.configForm,
|
|
originalValue: state.configFormOriginal,
|
|
searchQuery: state.appearanceSearchQuery,
|
|
activeSection:
|
|
state.appearanceActiveSection &&
|
|
!APPEARANCE_SECTION_KEYS.includes(
|
|
state.appearanceActiveSection as AppearanceSectionKey,
|
|
)
|
|
? null
|
|
: state.appearanceActiveSection,
|
|
activeSubsection:
|
|
state.appearanceActiveSection &&
|
|
!APPEARANCE_SECTION_KEYS.includes(
|
|
state.appearanceActiveSection as AppearanceSectionKey,
|
|
)
|
|
? null
|
|
: state.appearanceActiveSubsection,
|
|
streamMode: state.streamMode,
|
|
onRawChange: (next) => {
|
|
state.configRaw = next;
|
|
},
|
|
onFormModeChange: (mode) => (state.appearanceFormMode = mode),
|
|
onFormPatch: (path, value) => updateConfigFormValue(state, path, value),
|
|
onSearchChange: (query) => (state.appearanceSearchQuery = query),
|
|
onSectionChange: (section) => {
|
|
state.appearanceActiveSection = section;
|
|
state.appearanceActiveSubsection = null;
|
|
},
|
|
onSubsectionChange: (section) => (state.appearanceActiveSubsection = section),
|
|
onReload: () => loadConfig(state),
|
|
onSave: () => saveConfig(state),
|
|
onApply: () => applyConfig(state),
|
|
onUpdate: () => runUpdate(state),
|
|
version: state.hello?.server?.version ?? "",
|
|
theme: state.theme,
|
|
themeMode: state.themeMode,
|
|
setTheme: (t, ctx) => state.setTheme(t, ctx),
|
|
setThemeMode: (m, ctx) => state.setThemeMode(m, ctx),
|
|
gatewayUrl: state.settings.gatewayUrl,
|
|
assistantName: state.assistantName,
|
|
navRootLabel: "Appearance",
|
|
includeSections: [...APPEARANCE_SECTION_KEYS],
|
|
includeVirtualSections: true,
|
|
})
|
|
: nothing
|
|
}
|
|
|
|
${
|
|
state.tab === "automation"
|
|
? renderConfig({
|
|
raw: state.configRaw,
|
|
originalRaw: state.configRawOriginal,
|
|
valid: state.configValid,
|
|
issues: state.configIssues,
|
|
loading: state.configLoading,
|
|
saving: state.configSaving,
|
|
applying: state.configApplying,
|
|
updating: state.updateRunning,
|
|
connected: state.connected,
|
|
schema: state.configSchema,
|
|
schemaLoading: state.configSchemaLoading,
|
|
uiHints: state.configUiHints,
|
|
formMode: state.automationFormMode,
|
|
formValue: state.configForm,
|
|
originalValue: state.configFormOriginal,
|
|
searchQuery: state.automationSearchQuery,
|
|
activeSection:
|
|
state.automationActiveSection &&
|
|
!AUTOMATION_SECTION_KEYS.includes(
|
|
state.automationActiveSection as AutomationSectionKey,
|
|
)
|
|
? null
|
|
: state.automationActiveSection,
|
|
activeSubsection:
|
|
state.automationActiveSection &&
|
|
!AUTOMATION_SECTION_KEYS.includes(
|
|
state.automationActiveSection as AutomationSectionKey,
|
|
)
|
|
? null
|
|
: state.automationActiveSubsection,
|
|
streamMode: state.streamMode,
|
|
onRawChange: (next) => {
|
|
state.configRaw = next;
|
|
},
|
|
onFormModeChange: (mode) => (state.automationFormMode = mode),
|
|
onFormPatch: (path, value) => updateConfigFormValue(state, path, value),
|
|
onSearchChange: (query) => (state.automationSearchQuery = query),
|
|
onSectionChange: (section) => {
|
|
state.automationActiveSection = section;
|
|
state.automationActiveSubsection = null;
|
|
},
|
|
onSubsectionChange: (section) => (state.automationActiveSubsection = section),
|
|
onReload: () => loadConfig(state),
|
|
onSave: () => saveConfig(state),
|
|
onApply: () => applyConfig(state),
|
|
onUpdate: () => runUpdate(state),
|
|
version: state.hello?.server?.version ?? "",
|
|
theme: state.theme,
|
|
themeMode: state.themeMode,
|
|
setTheme: (t, ctx) => state.setTheme(t, ctx),
|
|
setThemeMode: (m, ctx) => state.setThemeMode(m, ctx),
|
|
gatewayUrl: state.settings.gatewayUrl,
|
|
assistantName: state.assistantName,
|
|
navRootLabel: "Automation",
|
|
includeSections: [...AUTOMATION_SECTION_KEYS],
|
|
includeVirtualSections: false,
|
|
})
|
|
: nothing
|
|
}
|
|
|
|
${
|
|
state.tab === "infrastructure"
|
|
? renderConfig({
|
|
raw: state.configRaw,
|
|
originalRaw: state.configRawOriginal,
|
|
valid: state.configValid,
|
|
issues: state.configIssues,
|
|
loading: state.configLoading,
|
|
saving: state.configSaving,
|
|
applying: state.configApplying,
|
|
updating: state.updateRunning,
|
|
connected: state.connected,
|
|
schema: state.configSchema,
|
|
schemaLoading: state.configSchemaLoading,
|
|
uiHints: state.configUiHints,
|
|
formMode: state.infrastructureFormMode,
|
|
formValue: state.configForm,
|
|
originalValue: state.configFormOriginal,
|
|
searchQuery: state.infrastructureSearchQuery,
|
|
activeSection:
|
|
state.infrastructureActiveSection &&
|
|
!INFRASTRUCTURE_SECTION_KEYS.includes(
|
|
state.infrastructureActiveSection as InfrastructureSectionKey,
|
|
)
|
|
? null
|
|
: state.infrastructureActiveSection,
|
|
activeSubsection:
|
|
state.infrastructureActiveSection &&
|
|
!INFRASTRUCTURE_SECTION_KEYS.includes(
|
|
state.infrastructureActiveSection as InfrastructureSectionKey,
|
|
)
|
|
? null
|
|
: state.infrastructureActiveSubsection,
|
|
streamMode: state.streamMode,
|
|
onRawChange: (next) => {
|
|
state.configRaw = next;
|
|
},
|
|
onFormModeChange: (mode) => (state.infrastructureFormMode = mode),
|
|
onFormPatch: (path, value) => updateConfigFormValue(state, path, value),
|
|
onSearchChange: (query) => (state.infrastructureSearchQuery = query),
|
|
onSectionChange: (section) => {
|
|
state.infrastructureActiveSection = section;
|
|
state.infrastructureActiveSubsection = null;
|
|
},
|
|
onSubsectionChange: (section) => (state.infrastructureActiveSubsection = section),
|
|
onReload: () => loadConfig(state),
|
|
onSave: () => saveConfig(state),
|
|
onApply: () => applyConfig(state),
|
|
onUpdate: () => runUpdate(state),
|
|
version: state.hello?.server?.version ?? "",
|
|
theme: state.theme,
|
|
themeMode: state.themeMode,
|
|
setTheme: (t, ctx) => state.setTheme(t, ctx),
|
|
setThemeMode: (m, ctx) => state.setThemeMode(m, ctx),
|
|
gatewayUrl: state.settings.gatewayUrl,
|
|
assistantName: state.assistantName,
|
|
navRootLabel: "Infrastructure",
|
|
includeSections: [...INFRASTRUCTURE_SECTION_KEYS],
|
|
includeVirtualSections: false,
|
|
})
|
|
: nothing
|
|
}
|
|
|
|
${
|
|
state.tab === "aiAgents"
|
|
? renderConfig({
|
|
raw: state.configRaw,
|
|
originalRaw: state.configRawOriginal,
|
|
valid: state.configValid,
|
|
issues: state.configIssues,
|
|
loading: state.configLoading,
|
|
saving: state.configSaving,
|
|
applying: state.configApplying,
|
|
updating: state.updateRunning,
|
|
connected: state.connected,
|
|
schema: state.configSchema,
|
|
schemaLoading: state.configSchemaLoading,
|
|
uiHints: state.configUiHints,
|
|
formMode: state.aiAgentsFormMode,
|
|
formValue: state.configForm,
|
|
originalValue: state.configFormOriginal,
|
|
searchQuery: state.aiAgentsSearchQuery,
|
|
activeSection:
|
|
state.aiAgentsActiveSection &&
|
|
!AI_AGENTS_SECTION_KEYS.includes(
|
|
state.aiAgentsActiveSection as AiAgentsSectionKey,
|
|
)
|
|
? null
|
|
: state.aiAgentsActiveSection,
|
|
activeSubsection:
|
|
state.aiAgentsActiveSection &&
|
|
!AI_AGENTS_SECTION_KEYS.includes(
|
|
state.aiAgentsActiveSection as AiAgentsSectionKey,
|
|
)
|
|
? null
|
|
: state.aiAgentsActiveSubsection,
|
|
streamMode: state.streamMode,
|
|
onRawChange: (next) => {
|
|
state.configRaw = next;
|
|
},
|
|
onFormModeChange: (mode) => (state.aiAgentsFormMode = mode),
|
|
onFormPatch: (path, value) => updateConfigFormValue(state, path, value),
|
|
onSearchChange: (query) => (state.aiAgentsSearchQuery = query),
|
|
onSectionChange: (section) => {
|
|
state.aiAgentsActiveSection = section;
|
|
state.aiAgentsActiveSubsection = null;
|
|
},
|
|
onSubsectionChange: (section) => (state.aiAgentsActiveSubsection = section),
|
|
onReload: () => loadConfig(state),
|
|
onSave: () => saveConfig(state),
|
|
onApply: () => applyConfig(state),
|
|
onUpdate: () => runUpdate(state),
|
|
version: state.hello?.server?.version ?? "",
|
|
theme: state.theme,
|
|
themeMode: state.themeMode,
|
|
setTheme: (t, ctx) => state.setTheme(t, ctx),
|
|
setThemeMode: (m, ctx) => state.setThemeMode(m, ctx),
|
|
gatewayUrl: state.settings.gatewayUrl,
|
|
assistantName: state.assistantName,
|
|
navRootLabel: "AI & Agents",
|
|
includeSections: [...AI_AGENTS_SECTION_KEYS],
|
|
includeVirtualSections: false,
|
|
})
|
|
: nothing
|
|
}
|
|
|
|
${
|
|
state.tab === "debug"
|
|
? renderDebug({
|
|
loading: state.debugLoading,
|
|
status: state.debugStatus,
|
|
health: state.debugHealth,
|
|
models: state.debugModels,
|
|
heartbeat: state.debugHeartbeat,
|
|
eventLog: state.eventLog,
|
|
callMethod: state.debugCallMethod,
|
|
callParams: state.debugCallParams,
|
|
callResult: state.debugCallResult,
|
|
callError: state.debugCallError,
|
|
onCallMethodChange: (next) => (state.debugCallMethod = next),
|
|
onCallParamsChange: (next) => (state.debugCallParams = next),
|
|
onRefresh: () => loadDebug(state),
|
|
onCall: () => callDebugMethod(state),
|
|
})
|
|
: nothing
|
|
}
|
|
|
|
${
|
|
state.tab === "logs"
|
|
? renderLogs({
|
|
loading: state.logsLoading,
|
|
error: state.logsError,
|
|
file: state.logsFile,
|
|
entries: state.logsEntries,
|
|
filterText: state.logsFilterText,
|
|
levelFilters: state.logsLevelFilters,
|
|
autoFollow: state.logsAutoFollow,
|
|
truncated: state.logsTruncated,
|
|
onFilterTextChange: (next) => (state.logsFilterText = next),
|
|
onLevelToggle: (level, enabled) => {
|
|
state.logsLevelFilters = { ...state.logsLevelFilters, [level]: enabled };
|
|
},
|
|
onToggleAutoFollow: (next) => (state.logsAutoFollow = next),
|
|
onRefresh: () => loadLogs(state, { reset: true }),
|
|
onExport: (lines, label) => state.exportLogs(lines, label),
|
|
onScroll: (event) => state.handleLogsScroll(event),
|
|
})
|
|
: nothing
|
|
}
|
|
</main>
|
|
${renderExecApprovalPrompt(state)}
|
|
${renderGatewayUrlConfirmation(state)}
|
|
${nothing}
|
|
</div>
|
|
`;
|
|
}
|