mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-14 18:51:04 +00:00
UI: consolidate config/tab/skills flows
This commit is contained in:
@@ -122,7 +122,7 @@ import {
|
||||
} from "./views/agents-utils.ts";
|
||||
import { renderChat } from "./views/chat.ts";
|
||||
import { renderCommandPalette } from "./views/command-palette.ts";
|
||||
import { renderConfig } from "./views/config.ts";
|
||||
import { renderConfig, type ConfigProps } from "./views/config.ts";
|
||||
import { renderDreaming } from "./views/dreaming.ts";
|
||||
import { renderExecApprovalPrompt } from "./views/exec-approval.ts";
|
||||
import { renderGatewayUrlConfirmation } from "./views/gateway-url-confirmation.ts";
|
||||
@@ -320,11 +320,61 @@ const AI_AGENTS_SECTION_KEYS = [
|
||||
"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];
|
||||
type ConfigSectionSelection = {
|
||||
activeSection: string | null;
|
||||
activeSubsection: string | null;
|
||||
};
|
||||
|
||||
type ConfigTabOverrides = Pick<
|
||||
ConfigProps,
|
||||
| "formMode"
|
||||
| "searchQuery"
|
||||
| "activeSection"
|
||||
| "activeSubsection"
|
||||
| "onFormModeChange"
|
||||
| "onSearchChange"
|
||||
| "onSectionChange"
|
||||
| "onSubsectionChange"
|
||||
> &
|
||||
Partial<
|
||||
Pick<
|
||||
ConfigProps,
|
||||
| "showModeToggle"
|
||||
| "navRootLabel"
|
||||
| "includeSections"
|
||||
| "excludeSections"
|
||||
| "includeVirtualSections"
|
||||
>
|
||||
>;
|
||||
|
||||
const SCOPED_CONFIG_SECTION_KEYS = new Set<string>([
|
||||
...COMMUNICATION_SECTION_KEYS,
|
||||
...APPEARANCE_SECTION_KEYS,
|
||||
...AUTOMATION_SECTION_KEYS,
|
||||
...INFRASTRUCTURE_SECTION_KEYS,
|
||||
...AI_AGENTS_SECTION_KEYS,
|
||||
]);
|
||||
|
||||
function normalizeMainConfigSelection(
|
||||
activeSection: string | null,
|
||||
activeSubsection: string | null,
|
||||
): ConfigSectionSelection {
|
||||
if (activeSection && SCOPED_CONFIG_SECTION_KEYS.has(activeSection)) {
|
||||
return { activeSection: null, activeSubsection: null };
|
||||
}
|
||||
return { activeSection, activeSubsection };
|
||||
}
|
||||
|
||||
function normalizeScopedConfigSelection(
|
||||
activeSection: string | null,
|
||||
activeSubsection: string | null,
|
||||
includedSections: readonly string[],
|
||||
): ConfigSectionSelection {
|
||||
if (activeSection && !includedSections.includes(activeSection)) {
|
||||
return { activeSection: null, activeSubsection: null };
|
||||
}
|
||||
return { activeSection, activeSubsection };
|
||||
}
|
||||
|
||||
function resolveAssistantAvatarUrl(state: AppViewState): string | undefined {
|
||||
const list = state.agentsList?.agents ?? [];
|
||||
@@ -459,6 +509,204 @@ export function renderApp(state: AppViewState) {
|
||||
state.cronForm.deliveryMode === "webhook"
|
||||
? rawDeliveryToSuggestions.filter((value) => isHttpUrl(value))
|
||||
: rawDeliveryToSuggestions;
|
||||
const commonConfigProps = {
|
||||
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,
|
||||
formValue: state.configForm,
|
||||
originalValue: state.configFormOriginal,
|
||||
onRawChange: (next: string) => {
|
||||
state.configRaw = next;
|
||||
},
|
||||
onRequestUpdate: requestHostUpdate,
|
||||
onFormPatch: (path: Array<string | number>, value: unknown) =>
|
||||
updateConfigFormValue(state, path, value),
|
||||
onReload: () => loadConfig(state),
|
||||
onSave: () => saveConfig(state),
|
||||
onApply: () => applyConfig(state),
|
||||
onUpdate: () => runUpdate(state),
|
||||
onOpenFile: () => openConfigFile(state),
|
||||
version: state.hello?.server?.version ?? "",
|
||||
theme: state.theme,
|
||||
themeMode: state.themeMode,
|
||||
setTheme: (theme, context) => state.setTheme(theme, context),
|
||||
setThemeMode: (mode, context) => state.setThemeMode(mode, context),
|
||||
borderRadius: state.settings.borderRadius,
|
||||
setBorderRadius: (value) => state.setBorderRadius(value),
|
||||
gatewayUrl: state.settings.gatewayUrl,
|
||||
assistantName: state.assistantName,
|
||||
configPath: state.configSnapshot?.path ?? null,
|
||||
rawAvailable: typeof state.configSnapshot?.raw === "string",
|
||||
} satisfies Omit<
|
||||
ConfigProps,
|
||||
| "formMode"
|
||||
| "searchQuery"
|
||||
| "activeSection"
|
||||
| "activeSubsection"
|
||||
| "onFormModeChange"
|
||||
| "onSearchChange"
|
||||
| "onSectionChange"
|
||||
| "onSubsectionChange"
|
||||
| "showModeToggle"
|
||||
| "navRootLabel"
|
||||
| "includeSections"
|
||||
| "excludeSections"
|
||||
| "includeVirtualSections"
|
||||
>;
|
||||
const renderConfigTab = (overrides: ConfigTabOverrides) =>
|
||||
renderConfig({
|
||||
...commonConfigProps,
|
||||
includeVirtualSections: false,
|
||||
...overrides,
|
||||
});
|
||||
const configSelection = normalizeMainConfigSelection(
|
||||
state.configActiveSection,
|
||||
state.configActiveSubsection,
|
||||
);
|
||||
const communicationsSelection = normalizeScopedConfigSelection(
|
||||
state.communicationsActiveSection,
|
||||
state.communicationsActiveSubsection,
|
||||
COMMUNICATION_SECTION_KEYS,
|
||||
);
|
||||
const appearanceSelection = normalizeScopedConfigSelection(
|
||||
state.appearanceActiveSection,
|
||||
state.appearanceActiveSubsection,
|
||||
APPEARANCE_SECTION_KEYS,
|
||||
);
|
||||
const automationSelection = normalizeScopedConfigSelection(
|
||||
state.automationActiveSection,
|
||||
state.automationActiveSubsection,
|
||||
AUTOMATION_SECTION_KEYS,
|
||||
);
|
||||
const infrastructureSelection = normalizeScopedConfigSelection(
|
||||
state.infrastructureActiveSection,
|
||||
state.infrastructureActiveSubsection,
|
||||
INFRASTRUCTURE_SECTION_KEYS,
|
||||
);
|
||||
const aiAgentsSelection = normalizeScopedConfigSelection(
|
||||
state.aiAgentsActiveSection,
|
||||
state.aiAgentsActiveSubsection,
|
||||
AI_AGENTS_SECTION_KEYS,
|
||||
);
|
||||
const renderConfigTabForActiveTab = () => {
|
||||
switch (state.tab) {
|
||||
case "config":
|
||||
return renderConfigTab({
|
||||
formMode: state.configFormMode,
|
||||
searchQuery: state.configSearchQuery,
|
||||
activeSection: configSelection.activeSection,
|
||||
activeSubsection: configSelection.activeSubsection,
|
||||
onFormModeChange: (mode) => (state.configFormMode = mode),
|
||||
onSearchChange: (query) => (state.configSearchQuery = query),
|
||||
onSectionChange: (section) => {
|
||||
state.configActiveSection = section;
|
||||
state.configActiveSubsection = null;
|
||||
},
|
||||
onSubsectionChange: (section) => (state.configActiveSubsection = section),
|
||||
showModeToggle: true,
|
||||
excludeSections: [
|
||||
...COMMUNICATION_SECTION_KEYS,
|
||||
...AUTOMATION_SECTION_KEYS,
|
||||
...INFRASTRUCTURE_SECTION_KEYS,
|
||||
...AI_AGENTS_SECTION_KEYS,
|
||||
"ui",
|
||||
"wizard",
|
||||
],
|
||||
});
|
||||
case "communications":
|
||||
return renderConfigTab({
|
||||
formMode: state.communicationsFormMode,
|
||||
searchQuery: state.communicationsSearchQuery,
|
||||
activeSection: communicationsSelection.activeSection,
|
||||
activeSubsection: communicationsSelection.activeSubsection,
|
||||
onFormModeChange: (mode) => (state.communicationsFormMode = mode),
|
||||
onSearchChange: (query) => (state.communicationsSearchQuery = query),
|
||||
onSectionChange: (section) => {
|
||||
state.communicationsActiveSection = section;
|
||||
state.communicationsActiveSubsection = null;
|
||||
},
|
||||
onSubsectionChange: (section) => (state.communicationsActiveSubsection = section),
|
||||
navRootLabel: "Communication",
|
||||
includeSections: [...COMMUNICATION_SECTION_KEYS],
|
||||
});
|
||||
case "appearance":
|
||||
return renderConfigTab({
|
||||
formMode: state.appearanceFormMode,
|
||||
searchQuery: state.appearanceSearchQuery,
|
||||
activeSection: appearanceSelection.activeSection,
|
||||
activeSubsection: appearanceSelection.activeSubsection,
|
||||
onFormModeChange: (mode) => (state.appearanceFormMode = mode),
|
||||
onSearchChange: (query) => (state.appearanceSearchQuery = query),
|
||||
onSectionChange: (section) => {
|
||||
state.appearanceActiveSection = section;
|
||||
state.appearanceActiveSubsection = null;
|
||||
},
|
||||
onSubsectionChange: (section) => (state.appearanceActiveSubsection = section),
|
||||
navRootLabel: t("tabs.appearance"),
|
||||
includeSections: [...APPEARANCE_SECTION_KEYS],
|
||||
includeVirtualSections: true,
|
||||
});
|
||||
case "automation":
|
||||
return renderConfigTab({
|
||||
formMode: state.automationFormMode,
|
||||
searchQuery: state.automationSearchQuery,
|
||||
activeSection: automationSelection.activeSection,
|
||||
activeSubsection: automationSelection.activeSubsection,
|
||||
onFormModeChange: (mode) => (state.automationFormMode = mode),
|
||||
onSearchChange: (query) => (state.automationSearchQuery = query),
|
||||
onSectionChange: (section) => {
|
||||
state.automationActiveSection = section;
|
||||
state.automationActiveSubsection = null;
|
||||
},
|
||||
onSubsectionChange: (section) => (state.automationActiveSubsection = section),
|
||||
navRootLabel: "Automation",
|
||||
includeSections: [...AUTOMATION_SECTION_KEYS],
|
||||
});
|
||||
case "infrastructure":
|
||||
return renderConfigTab({
|
||||
formMode: state.infrastructureFormMode,
|
||||
searchQuery: state.infrastructureSearchQuery,
|
||||
activeSection: infrastructureSelection.activeSection,
|
||||
activeSubsection: infrastructureSelection.activeSubsection,
|
||||
onFormModeChange: (mode) => (state.infrastructureFormMode = mode),
|
||||
onSearchChange: (query) => (state.infrastructureSearchQuery = query),
|
||||
onSectionChange: (section) => {
|
||||
state.infrastructureActiveSection = section;
|
||||
state.infrastructureActiveSubsection = null;
|
||||
},
|
||||
onSubsectionChange: (section) => (state.infrastructureActiveSubsection = section),
|
||||
navRootLabel: "Infrastructure",
|
||||
includeSections: [...INFRASTRUCTURE_SECTION_KEYS],
|
||||
});
|
||||
case "aiAgents":
|
||||
return renderConfigTab({
|
||||
formMode: state.aiAgentsFormMode,
|
||||
searchQuery: state.aiAgentsSearchQuery,
|
||||
activeSection: aiAgentsSelection.activeSection,
|
||||
activeSubsection: aiAgentsSelection.activeSubsection,
|
||||
onFormModeChange: (mode) => (state.aiAgentsFormMode = mode),
|
||||
onSearchChange: (query) => (state.aiAgentsSearchQuery = query),
|
||||
onSectionChange: (section) => {
|
||||
state.aiAgentsActiveSection = section;
|
||||
state.aiAgentsActiveSubsection = null;
|
||||
},
|
||||
onSubsectionChange: (section) => (state.aiAgentsActiveSubsection = section),
|
||||
navRootLabel: "AI & Agents",
|
||||
includeSections: [...AI_AGENTS_SECTION_KEYS],
|
||||
});
|
||||
default:
|
||||
return nothing;
|
||||
}
|
||||
};
|
||||
|
||||
return html`
|
||||
${renderCommandPalette({
|
||||
@@ -1659,419 +1907,7 @@ export function renderApp(state: AppViewState) {
|
||||
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,
|
||||
onRawChange: (next) => {
|
||||
state.configRaw = next;
|
||||
},
|
||||
onRequestUpdate: requestHostUpdate,
|
||||
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),
|
||||
onOpenFile: () => openConfigFile(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),
|
||||
borderRadius: state.settings.borderRadius,
|
||||
setBorderRadius: (v) => state.setBorderRadius(v),
|
||||
gatewayUrl: state.settings.gatewayUrl,
|
||||
assistantName: state.assistantName,
|
||||
configPath: state.configSnapshot?.path ?? null,
|
||||
rawAvailable: typeof state.configSnapshot?.raw === "string",
|
||||
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,
|
||||
onRawChange: (next) => {
|
||||
state.configRaw = next;
|
||||
},
|
||||
onRequestUpdate: requestHostUpdate,
|
||||
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),
|
||||
onOpenFile: () => openConfigFile(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),
|
||||
borderRadius: state.settings.borderRadius,
|
||||
setBorderRadius: (v) => state.setBorderRadius(v),
|
||||
gatewayUrl: state.settings.gatewayUrl,
|
||||
assistantName: state.assistantName,
|
||||
configPath: state.configSnapshot?.path ?? null,
|
||||
rawAvailable: typeof state.configSnapshot?.raw === "string",
|
||||
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,
|
||||
onRawChange: (next) => {
|
||||
state.configRaw = next;
|
||||
},
|
||||
onRequestUpdate: requestHostUpdate,
|
||||
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),
|
||||
onOpenFile: () => openConfigFile(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),
|
||||
borderRadius: state.settings.borderRadius,
|
||||
setBorderRadius: (v) => state.setBorderRadius(v),
|
||||
gatewayUrl: state.settings.gatewayUrl,
|
||||
assistantName: state.assistantName,
|
||||
configPath: state.configSnapshot?.path ?? null,
|
||||
rawAvailable: typeof state.configSnapshot?.raw === "string",
|
||||
navRootLabel: t("tabs.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,
|
||||
onRawChange: (next) => {
|
||||
state.configRaw = next;
|
||||
},
|
||||
onRequestUpdate: requestHostUpdate,
|
||||
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),
|
||||
onOpenFile: () => openConfigFile(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),
|
||||
borderRadius: state.settings.borderRadius,
|
||||
setBorderRadius: (v) => state.setBorderRadius(v),
|
||||
gatewayUrl: state.settings.gatewayUrl,
|
||||
assistantName: state.assistantName,
|
||||
configPath: state.configSnapshot?.path ?? null,
|
||||
rawAvailable: typeof state.configSnapshot?.raw === "string",
|
||||
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,
|
||||
onRawChange: (next) => {
|
||||
state.configRaw = next;
|
||||
},
|
||||
onRequestUpdate: requestHostUpdate,
|
||||
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),
|
||||
onOpenFile: () => openConfigFile(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),
|
||||
borderRadius: state.settings.borderRadius,
|
||||
setBorderRadius: (v) => state.setBorderRadius(v),
|
||||
gatewayUrl: state.settings.gatewayUrl,
|
||||
assistantName: state.assistantName,
|
||||
configPath: state.configSnapshot?.path ?? null,
|
||||
rawAvailable: typeof state.configSnapshot?.raw === "string",
|
||||
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,
|
||||
onRawChange: (next) => {
|
||||
state.configRaw = next;
|
||||
},
|
||||
onRequestUpdate: requestHostUpdate,
|
||||
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),
|
||||
onOpenFile: () => openConfigFile(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),
|
||||
borderRadius: state.settings.borderRadius,
|
||||
setBorderRadius: (v) => state.setBorderRadius(v),
|
||||
gatewayUrl: state.settings.gatewayUrl,
|
||||
assistantName: state.assistantName,
|
||||
configPath: state.configSnapshot?.path ?? null,
|
||||
rawAvailable: typeof state.configSnapshot?.raw === "string",
|
||||
navRootLabel: "AI & Agents",
|
||||
includeSections: [...AI_AGENTS_SECTION_KEYS],
|
||||
includeVirtualSections: false,
|
||||
})
|
||||
: nothing}
|
||||
${renderConfigTabForActiveTab()}
|
||||
${state.tab === "debug"
|
||||
? lazyRender(lazyDebug, (m) =>
|
||||
m.renderDebug({
|
||||
|
||||
@@ -237,91 +237,96 @@ export function setThemeMode(
|
||||
}
|
||||
|
||||
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 === "usage") {
|
||||
await loadUsage(host as unknown as OpenClawApp);
|
||||
}
|
||||
if (host.tab === "sessions") {
|
||||
await loadSessions(host as unknown as OpenClawApp);
|
||||
}
|
||||
if (host.tab === "cron") {
|
||||
await loadCron(host);
|
||||
}
|
||||
if (host.tab === "skills") {
|
||||
await loadSkills(host as unknown as OpenClawApp);
|
||||
}
|
||||
if (host.tab === "agents") {
|
||||
await loadAgents(host as unknown as OpenClawApp);
|
||||
await loadConfig(host as unknown as OpenClawApp);
|
||||
const agentIds = host.agentsList?.agents?.map((entry) => entry.id) ?? [];
|
||||
if (agentIds.length > 0) {
|
||||
void loadAgentIdentities(host as unknown as OpenClawApp, agentIds);
|
||||
}
|
||||
const agentId =
|
||||
host.agentsSelectedId ?? host.agentsList?.defaultId ?? host.agentsList?.agents?.[0]?.id;
|
||||
if (agentId) {
|
||||
void loadAgentIdentity(host as unknown as OpenClawApp, agentId);
|
||||
if (host.agentsPanel === "files") {
|
||||
void loadAgentFiles(host as unknown as OpenClawApp, agentId);
|
||||
const app = host as unknown as OpenClawApp;
|
||||
switch (host.tab) {
|
||||
case "overview":
|
||||
await loadOverview(host);
|
||||
return;
|
||||
case "channels":
|
||||
await loadChannelsTab(host);
|
||||
return;
|
||||
case "instances":
|
||||
await loadPresence(app);
|
||||
return;
|
||||
case "usage":
|
||||
await loadUsage(app);
|
||||
return;
|
||||
case "sessions":
|
||||
await loadSessions(app);
|
||||
return;
|
||||
case "cron":
|
||||
await loadCron(host);
|
||||
return;
|
||||
case "skills":
|
||||
await loadSkills(app);
|
||||
return;
|
||||
case "agents": {
|
||||
await loadAgents(app);
|
||||
await loadConfig(app);
|
||||
const agentIds = host.agentsList?.agents?.map((entry) => entry.id) ?? [];
|
||||
if (agentIds.length > 0) {
|
||||
void loadAgentIdentities(app, agentIds);
|
||||
}
|
||||
if (host.agentsPanel === "skills") {
|
||||
void loadAgentSkills(host as unknown as OpenClawApp, agentId);
|
||||
const agentId =
|
||||
host.agentsSelectedId ?? host.agentsList?.defaultId ?? host.agentsList?.agents?.[0]?.id;
|
||||
if (!agentId) {
|
||||
return;
|
||||
}
|
||||
if (host.agentsPanel === "channels") {
|
||||
void loadChannels(host as unknown as OpenClawApp, false);
|
||||
}
|
||||
if (host.agentsPanel === "cron") {
|
||||
void loadCron(host);
|
||||
void loadAgentIdentity(app, agentId);
|
||||
switch (host.agentsPanel) {
|
||||
case "files":
|
||||
void loadAgentFiles(app, agentId);
|
||||
return;
|
||||
case "skills":
|
||||
void loadAgentSkills(app, agentId);
|
||||
return;
|
||||
case "channels":
|
||||
void loadChannels(app, false);
|
||||
return;
|
||||
case "cron":
|
||||
void loadCron(host);
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (host.tab === "nodes") {
|
||||
await loadNodes(host as unknown as OpenClawApp);
|
||||
await loadDevices(host as unknown as OpenClawApp);
|
||||
await loadConfig(host as unknown as OpenClawApp);
|
||||
await loadExecApprovals(host as unknown as OpenClawApp);
|
||||
}
|
||||
if (host.tab === "dreams") {
|
||||
await loadConfig(host as unknown as OpenClawApp);
|
||||
await Promise.all([
|
||||
loadDreamingStatus(host as unknown as OpenClawApp),
|
||||
loadDreamDiary(host as unknown as OpenClawApp),
|
||||
]);
|
||||
}
|
||||
if (host.tab === "chat") {
|
||||
await refreshChat(host as unknown as Parameters<typeof refreshChat>[0]);
|
||||
scheduleChatScroll(
|
||||
host as unknown as Parameters<typeof scheduleChatScroll>[0],
|
||||
!host.chatHasAutoScrolled,
|
||||
);
|
||||
}
|
||||
if (
|
||||
host.tab === "config" ||
|
||||
host.tab === "communications" ||
|
||||
host.tab === "appearance" ||
|
||||
host.tab === "automation" ||
|
||||
host.tab === "infrastructure" ||
|
||||
host.tab === "aiAgents"
|
||||
) {
|
||||
await loadConfigSchema(host as unknown as OpenClawApp);
|
||||
await loadConfig(host as unknown as OpenClawApp);
|
||||
}
|
||||
if (host.tab === "debug") {
|
||||
await loadDebug(host as unknown as OpenClawApp);
|
||||
host.eventLog = host.eventLogBuffer;
|
||||
}
|
||||
if (host.tab === "logs") {
|
||||
host.logsAtBottom = true;
|
||||
await loadLogs(host as unknown as OpenClawApp, { reset: true });
|
||||
scheduleLogsScroll(host as unknown as Parameters<typeof scheduleLogsScroll>[0], true);
|
||||
case "nodes":
|
||||
await loadNodes(app);
|
||||
await loadDevices(app);
|
||||
await loadConfig(app);
|
||||
await loadExecApprovals(app);
|
||||
return;
|
||||
case "dreams":
|
||||
await loadConfig(app);
|
||||
await Promise.all([loadDreamingStatus(app), loadDreamDiary(app)]);
|
||||
return;
|
||||
case "chat":
|
||||
await refreshChat(host as unknown as Parameters<typeof refreshChat>[0]);
|
||||
scheduleChatScroll(
|
||||
host as unknown as Parameters<typeof scheduleChatScroll>[0],
|
||||
!host.chatHasAutoScrolled,
|
||||
);
|
||||
return;
|
||||
case "config":
|
||||
case "communications":
|
||||
case "appearance":
|
||||
case "automation":
|
||||
case "infrastructure":
|
||||
case "aiAgents":
|
||||
await loadConfigSchema(app);
|
||||
await loadConfig(app);
|
||||
return;
|
||||
case "debug":
|
||||
await loadDebug(app);
|
||||
host.eventLog = host.eventLogBuffer;
|
||||
return;
|
||||
case "logs":
|
||||
host.logsAtBottom = true;
|
||||
await loadLogs(app, { reset: true });
|
||||
scheduleLogsScroll(host as unknown as Parameters<typeof scheduleLogsScroll>[0], true);
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -134,11 +134,19 @@ function serializeFormForSubmit(state: ConfigState): string {
|
||||
return serializeConfigForm(form);
|
||||
}
|
||||
|
||||
export async function saveConfig(state: ConfigState) {
|
||||
type ConfigSubmitMethod = "config.set" | "config.apply";
|
||||
type ConfigSubmitBusyKey = "configSaving" | "configApplying";
|
||||
|
||||
async function submitConfigChange(
|
||||
state: ConfigState,
|
||||
method: ConfigSubmitMethod,
|
||||
busyKey: ConfigSubmitBusyKey,
|
||||
extraParams: Record<string, unknown> = {},
|
||||
) {
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
state.configSaving = true;
|
||||
state[busyKey] = true;
|
||||
state.lastError = null;
|
||||
try {
|
||||
const raw = serializeFormForSubmit(state);
|
||||
@@ -147,41 +155,24 @@ export async function saveConfig(state: ConfigState) {
|
||||
state.lastError = "Config hash missing; reload and retry.";
|
||||
return;
|
||||
}
|
||||
await state.client.request("config.set", { raw, baseHash });
|
||||
await state.client.request(method, { raw, baseHash, ...extraParams });
|
||||
state.configFormDirty = false;
|
||||
await loadConfig(state);
|
||||
} catch (err) {
|
||||
state.lastError = String(err);
|
||||
} finally {
|
||||
state.configSaving = false;
|
||||
state[busyKey] = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveConfig(state: ConfigState) {
|
||||
await submitConfigChange(state, "config.set", "configSaving");
|
||||
}
|
||||
|
||||
export async function applyConfig(state: ConfigState) {
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
state.configApplying = true;
|
||||
state.lastError = null;
|
||||
try {
|
||||
const raw = serializeFormForSubmit(state);
|
||||
const baseHash = state.configSnapshot?.hash;
|
||||
if (!baseHash) {
|
||||
state.lastError = "Config hash missing; reload and retry.";
|
||||
return;
|
||||
}
|
||||
await state.client.request("config.apply", {
|
||||
raw,
|
||||
baseHash,
|
||||
sessionKey: state.applySessionKey,
|
||||
});
|
||||
state.configFormDirty = false;
|
||||
await loadConfig(state);
|
||||
} catch (err) {
|
||||
state.lastError = String(err);
|
||||
} finally {
|
||||
state.configApplying = false;
|
||||
}
|
||||
await submitConfigChange(state, "config.apply", "configApplying", {
|
||||
sessionKey: state.applySessionKey,
|
||||
});
|
||||
}
|
||||
|
||||
export async function runUpdate(state: ConfigState) {
|
||||
@@ -209,13 +200,9 @@ export async function runUpdate(state: ConfigState) {
|
||||
}
|
||||
}
|
||||
|
||||
export function updateConfigFormValue(
|
||||
state: ConfigState,
|
||||
path: Array<string | number>,
|
||||
value: unknown,
|
||||
) {
|
||||
function mutateConfigForm(state: ConfigState, mutate: (draft: Record<string, unknown>) => void) {
|
||||
const base = cloneConfigObject(state.configForm ?? state.configSnapshot?.config ?? {});
|
||||
setPathValue(base, path, value);
|
||||
mutate(base);
|
||||
state.configForm = base;
|
||||
state.configFormDirty = true;
|
||||
if (state.configFormMode === "form") {
|
||||
@@ -223,14 +210,16 @@ export function updateConfigFormValue(
|
||||
}
|
||||
}
|
||||
|
||||
export function updateConfigFormValue(
|
||||
state: ConfigState,
|
||||
path: Array<string | number>,
|
||||
value: unknown,
|
||||
) {
|
||||
mutateConfigForm(state, (draft) => setPathValue(draft, path, value));
|
||||
}
|
||||
|
||||
export function removeConfigFormValue(state: ConfigState, path: Array<string | number>) {
|
||||
const base = cloneConfigObject(state.configForm ?? state.configSnapshot?.config ?? {});
|
||||
removePathValue(base, path);
|
||||
state.configForm = base;
|
||||
state.configFormDirty = true;
|
||||
if (state.configFormMode === "form") {
|
||||
state.configRaw = serializeConfigForm(base);
|
||||
}
|
||||
mutateConfigForm(state, (draft) => removePathValue(draft, path));
|
||||
}
|
||||
|
||||
export function findAgentConfigEntryIndex(
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { searchClawHub, setClawHubSearchQuery, type SkillsState } from "./skills.ts";
|
||||
import {
|
||||
installSkill,
|
||||
saveSkillApiKey,
|
||||
searchClawHub,
|
||||
setClawHubSearchQuery,
|
||||
updateSkillEnabled,
|
||||
type SkillsState,
|
||||
} from "./skills.ts";
|
||||
|
||||
function createState(): { state: SkillsState; request: ReturnType<typeof vi.fn> } {
|
||||
const request = vi.fn();
|
||||
@@ -107,3 +114,86 @@ describe("searchClawHub", () => {
|
||||
expect(state.clawhubSearchLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("skill mutations", () => {
|
||||
it("updates skill enablement and records a success message", async () => {
|
||||
const { state, request } = createState();
|
||||
request.mockImplementation(async (method: string) => {
|
||||
if (method === "skills.status") {
|
||||
return {};
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
await updateSkillEnabled(state, "github", true);
|
||||
|
||||
expect(request).toHaveBeenCalledWith("skills.update", { skillKey: "github", enabled: true });
|
||||
expect(state.skillMessages.github).toEqual({ kind: "success", message: "Skill enabled" });
|
||||
expect(state.skillsBusyKey).toBeNull();
|
||||
expect(state.skillsError).toBeNull();
|
||||
});
|
||||
|
||||
it("saves API keys and reports success", async () => {
|
||||
const { state, request } = createState();
|
||||
state.skillEdits.github = "sk-test";
|
||||
request.mockImplementation(async (method: string) => {
|
||||
if (method === "skills.status") {
|
||||
return {};
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
await saveSkillApiKey(state, "github");
|
||||
|
||||
expect(request).toHaveBeenCalledWith("skills.update", {
|
||||
skillKey: "github",
|
||||
apiKey: "sk-test",
|
||||
});
|
||||
expect(state.skillMessages.github).toEqual({
|
||||
kind: "success",
|
||||
message: "API key saved — stored in openclaw.json (skills.entries.github)",
|
||||
});
|
||||
expect(state.skillsBusyKey).toBeNull();
|
||||
});
|
||||
|
||||
it("installs skills and uses server success messages", async () => {
|
||||
const { state, request } = createState();
|
||||
request.mockImplementation(async (method: string) => {
|
||||
if (method === "skills.install") {
|
||||
return { message: "Installed from registry" };
|
||||
}
|
||||
if (method === "skills.status") {
|
||||
return {};
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
await installSkill(state, "github", "GitHub", "install-123", true);
|
||||
|
||||
expect(request).toHaveBeenCalledWith("skills.install", {
|
||||
name: "GitHub",
|
||||
installId: "install-123",
|
||||
dangerouslyForceUnsafeInstall: true,
|
||||
timeoutMs: 120000,
|
||||
});
|
||||
expect(state.skillMessages.github).toEqual({
|
||||
kind: "success",
|
||||
message: "Installed from registry",
|
||||
});
|
||||
expect(state.skillsBusyKey).toBeNull();
|
||||
});
|
||||
|
||||
it("records errors from failed mutations", async () => {
|
||||
const { state, request } = createState();
|
||||
request.mockRejectedValue(new Error("skills update failed"));
|
||||
|
||||
await updateSkillEnabled(state, "github", false);
|
||||
|
||||
expect(state.skillsError).toBe("skills update failed");
|
||||
expect(state.skillMessages.github).toEqual({
|
||||
kind: "error",
|
||||
message: "skills update failed",
|
||||
});
|
||||
expect(state.skillsBusyKey).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -123,19 +123,21 @@ export function updateSkillEdit(state: SkillsState, skillKey: string, value: str
|
||||
state.skillEdits = { ...state.skillEdits, [skillKey]: value };
|
||||
}
|
||||
|
||||
export async function updateSkillEnabled(state: SkillsState, skillKey: string, enabled: boolean) {
|
||||
if (!state.client || !state.connected) {
|
||||
async function runSkillMutation(
|
||||
state: SkillsState,
|
||||
skillKey: string,
|
||||
run: (client: GatewayBrowserClient) => Promise<SkillMessage>,
|
||||
) {
|
||||
const client = state.client;
|
||||
if (!client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
state.skillsBusyKey = skillKey;
|
||||
state.skillsError = null;
|
||||
try {
|
||||
await state.client.request("skills.update", { skillKey, enabled });
|
||||
const message = await run(client);
|
||||
await loadSkills(state);
|
||||
setSkillMessage(state, skillKey, {
|
||||
kind: "success",
|
||||
message: enabled ? "Skill enabled" : "Skill disabled",
|
||||
});
|
||||
setSkillMessage(state, skillKey, message);
|
||||
} catch (err) {
|
||||
const message = getErrorMessage(err);
|
||||
state.skillsError = message;
|
||||
@@ -148,30 +150,25 @@ export async function updateSkillEnabled(state: SkillsState, skillKey: string, e
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateSkillEnabled(state: SkillsState, skillKey: string, enabled: boolean) {
|
||||
await runSkillMutation(state, skillKey, async (client) => {
|
||||
await client.request("skills.update", { skillKey, enabled });
|
||||
return {
|
||||
kind: "success",
|
||||
message: enabled ? "Skill enabled" : "Skill disabled",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveSkillApiKey(state: SkillsState, skillKey: string) {
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
state.skillsBusyKey = skillKey;
|
||||
state.skillsError = null;
|
||||
try {
|
||||
await runSkillMutation(state, skillKey, async (client) => {
|
||||
const apiKey = state.skillEdits[skillKey] ?? "";
|
||||
await state.client.request("skills.update", { skillKey, apiKey });
|
||||
await loadSkills(state);
|
||||
setSkillMessage(state, skillKey, {
|
||||
await client.request("skills.update", { skillKey, apiKey });
|
||||
return {
|
||||
kind: "success",
|
||||
message: `API key saved — stored in openclaw.json (skills.entries.${skillKey})`,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = getErrorMessage(err);
|
||||
state.skillsError = message;
|
||||
setSkillMessage(state, skillKey, {
|
||||
kind: "error",
|
||||
message,
|
||||
});
|
||||
} finally {
|
||||
state.skillsBusyKey = null;
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function installSkill(
|
||||
@@ -181,33 +178,18 @@ export async function installSkill(
|
||||
installId: string,
|
||||
dangerouslyForceUnsafeInstall = false,
|
||||
) {
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
state.skillsBusyKey = skillKey;
|
||||
state.skillsError = null;
|
||||
try {
|
||||
const result = await state.client.request<{ message?: string }>("skills.install", {
|
||||
await runSkillMutation(state, skillKey, async (client) => {
|
||||
const result = await client.request<{ message?: string }>("skills.install", {
|
||||
name,
|
||||
installId,
|
||||
dangerouslyForceUnsafeInstall,
|
||||
timeoutMs: 120000,
|
||||
});
|
||||
await loadSkills(state);
|
||||
setSkillMessage(state, skillKey, {
|
||||
return {
|
||||
kind: "success",
|
||||
message: result?.message ?? "Installed",
|
||||
});
|
||||
} catch (err) {
|
||||
const message = getErrorMessage(err);
|
||||
state.skillsError = message;
|
||||
setSkillMessage(state, skillKey, {
|
||||
kind: "error",
|
||||
message,
|
||||
});
|
||||
} finally {
|
||||
state.skillsBusyKey = null;
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function searchClawHub(state: SkillsState, query: string) {
|
||||
|
||||
Reference in New Issue
Block a user