From b1c565c35bf85ec6704f39d186d01c5165e2c111 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 11:14:53 +0800 Subject: [PATCH] fix(ui): restore curated settings defaults --- ui/src/components/settings-ui.ts | 35 +++++ ui/src/i18n/locales/en.ts | 2 + ui/src/pages/config/config-page.test.ts | 31 +++- ui/src/pages/config/config-page.ts | 25 +++- ui/src/pages/config/security.test.ts | 69 +++++++++ ui/src/pages/config/security.ts | 45 +++++- ui/src/pages/labs/labs-page.test.ts | 139 +++++++++++++++++- ui/src/pages/labs/labs-page.ts | 57 +++++-- ui/src/pages/labs/labs-registry.ts | 106 +++++++++++-- .../model-providers-page.test.ts | 27 ++++ .../model-providers/model-providers-page.ts | 10 +- ui/src/pages/model-providers/view.test.ts | 60 ++++++++ ui/src/pages/model-providers/view.ts | 91 ++++++++---- 13 files changed, 627 insertions(+), 70 deletions(-) diff --git a/ui/src/components/settings-ui.ts b/ui/src/components/settings-ui.ts index ec74f0f26fbd..26c408499473 100644 --- a/ui/src/components/settings-ui.ts +++ b/ui/src/components/settings-ui.ts @@ -7,6 +7,7 @@ import "@awesome.me/webawesome/dist/components/radio-group/radio-group.js"; import "@awesome.me/webawesome/dist/components/switch/switch.js"; import { html, nothing, type TemplateResult } from "lit"; import { live } from "lit/directives/live.js"; +import { t } from "../i18n/index.ts"; import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "../lib/external-link.ts"; import { icons } from "./icons.ts"; import "./tooltip.ts"; @@ -223,6 +224,40 @@ export function renderSettingsToggleRow(props: { `; } +export function renderSettingsDefaultState(props: { + value: string; + overridden: boolean; + disabled?: boolean; + onReset: () => void; +}): { + description: TemplateResult; + action: TemplateResult | typeof nothing; +} { + return { + description: html`${t( + props.overridden ? "configForm.defaultValue" : "configForm.usingDefault", + { value: props.value }, + )}`, + action: props.overridden + ? html` + + ` + : nothing, + }; +} + export function renderSettingsSegmented(props: { value: T; options: ReadonlyArray<{ value: T; label: unknown; title?: string; testId?: string }>; diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 24fe0e023488..87078023d168 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -1242,6 +1242,8 @@ export const en: TranslationMap = { model: "Model", thinking: "Thinking", fastMode: "Fast mode", + default: "Default", + modelPolicy: "Model policy", thinkingLevels: { off: "Off", low: "Low", diff --git a/ui/src/pages/config/config-page.test.ts b/ui/src/pages/config/config-page.test.ts index 890e679882ad..f0b555c3e650 100644 --- a/ui/src/pages/config/config-page.test.ts +++ b/ui/src/pages/config/config-page.test.ts @@ -11,7 +11,11 @@ import type { import { createStorageMock } from "../../test-helpers/storage.ts"; import * as chatModels from "../chat/models.ts"; import * as realtimeTalk from "../chat/realtime-talk.ts"; -import { ConfigPage, configSelectionFromSearch } from "./config-page.ts"; +import { + ConfigPage, + configSelectionFromSearch, + extractQuickSettingsSecurity, +} from "./config-page.ts"; import { configSectionKeysForPage } from "./config-sections.ts"; import type { ConfigViewState } from "./view.ts"; @@ -92,6 +96,31 @@ describe("configSelectionFromSearch", () => { }); }); +describe("extractQuickSettingsSecurity", () => { + it("preserves provenance for inherited security defaults", () => { + expect(extractQuickSettingsSecurity({})).toMatchObject({ + browserEnabled: true, + browserEnabledOverridden: false, + toolProfile: "full", + toolProfileOverridden: false, + }); + }); + + it("distinguishes explicit values that equal the defaults", () => { + expect( + extractQuickSettingsSecurity({ + browser: { enabled: true }, + tools: { profile: "full" }, + }), + ).toMatchObject({ + browserEnabled: true, + browserEnabledOverridden: true, + toolProfile: "full", + toolProfileOverridden: true, + }); + }); +}); + describe("ConfigPage moved section routes", () => { it.each([ ["channels", "channels", ""], diff --git a/ui/src/pages/config/config-page.ts b/ui/src/pages/config/config-page.ts index 66ec9c600256..a10b7570eacb 100644 --- a/ui/src/pages/config/config-page.ts +++ b/ui/src/pages/config/config-page.ts @@ -152,7 +152,7 @@ function configPageTitle(pageId: ConfigPageId): string { return titleForRoute(pageId); } -function extractQuickSettingsSecurity(config: unknown): SecurityOverview { +export function extractQuickSettingsSecurity(config: unknown): SecurityOverview { const root = asConfigRecord((config as { configForm?: unknown } | null)?.configForm) ?? asConfigRecord(config); @@ -162,7 +162,9 @@ function extractQuickSettingsSecurity(config: unknown): SecurityOverview { execPolicy: "unknown", deviceAuth: false, browserEnabled: true, + browserEnabledOverridden: false, toolProfile: "full", + toolProfileOverridden: false, }; } const gateway = asConfigRecord(root.gateway); @@ -191,7 +193,9 @@ function extractQuickSettingsSecurity(config: unknown): SecurityOverview { execPolicy: typeof security === "string" && security.trim() ? security.trim() : "allowlist", deviceAuth: controlUi?.dangerouslyDisableDeviceAuth !== true, browserEnabled: browser?.enabled !== false, + browserEnabledOverridden: browser !== null && Object.hasOwn(browser, "enabled"), toolProfile: typeof profile === "string" && profile.trim() ? profile.trim() : "full", + toolProfileOverridden: Object.hasOwn(tools, "profile"), }; } @@ -1049,9 +1053,22 @@ export class ConfigPage extends OpenClawLightDomElement { runtimeState.connected && hasOperatorAdminAccess(this.context.gateway.snapshot.hello?.auth ?? null), onPairMobile: () => void this.context.overlays.openDevicePairSetup(), - onBrowserEnabledToggle: (enabled) => - runtimeConfig.patchForm(["browser", "enabled"], enabled), - onToolProfileChange: (profile) => runtimeConfig.patchForm(["tools", "profile"], profile), + onBrowserEnabledToggle: (enabled) => { + if (enabled) { + runtimeConfig.removeFormValue(["browser", "enabled"]); + return; + } + runtimeConfig.patchForm(["browser", "enabled"], false); + }, + onBrowserEnabledReset: () => runtimeConfig.removeFormValue(["browser", "enabled"]), + onToolProfileChange: (profile) => { + if (profile === "full") { + runtimeConfig.removeFormValue(["tools", "profile"]); + return; + } + runtimeConfig.patchForm(["tools", "profile"], profile); + }, + onToolProfileReset: () => runtimeConfig.removeFormValue(["tools", "profile"]), editor: renderConfig({ ...props, embeddedEditor: true }), }); } diff --git a/ui/src/pages/config/security.test.ts b/ui/src/pages/config/security.test.ts index 0efa4373f265..d85a6ffdc816 100644 --- a/ui/src/pages/config/security.test.ts +++ b/ui/src/pages/config/security.test.ts @@ -48,7 +48,9 @@ function createProps(overrides: Partial = {}): SecurityViewPr execPolicy: "allowlist", deviceAuth: true, browserEnabled: true, + browserEnabledOverridden: true, toolProfile: "coding", + toolProfileOverridden: true, }, configBusy: false, canPairDevice: true, @@ -74,7 +76,9 @@ describe("renderSecurity", () => { execPolicy: "allowlist", deviceAuth: true, browserEnabled: false, + browserEnabledOverridden: true, toolProfile: "messaging", + toolProfileOverridden: true, }, onBrowserEnabledToggle, onToolProfileChange, @@ -127,7 +131,9 @@ describe("renderSecurity", () => { execPolicy: "allowlist", deviceAuth: true, browserEnabled: true, + browserEnabledOverridden: false, toolProfile: "full", + toolProfileOverridden: false, }, }), ), @@ -167,4 +173,67 @@ describe("renderSecurity", () => { expect(page).not.toBeNull(); expect(page?.querySelector("[data-testid='security-editor']")).not.toBeNull(); }); + + it("shows inherited defaults without reset actions", () => { + const container = document.createElement("div"); + + render( + renderSecurity( + createProps({ + security: { + gatewayAuth: "token", + execPolicy: "allowlist", + deviceAuth: true, + browserEnabled: true, + browserEnabledOverridden: false, + toolProfile: "full", + toolProfileOverridden: false, + }, + }), + ), + container, + ); + + expect(expectRowByTitle(container, "Browser enabled").textContent).toContain( + "Using default: Enabled", + ); + expect(expectRowByTitle(container, "Tool profile").textContent).toContain( + "Using default: Full", + ); + expect(container.querySelectorAll("button[aria-label='Reset to default']")).toHaveLength(0); + }); + + it("resets explicit browser and tool-profile overrides", () => { + const onBrowserEnabledReset = vi.fn(); + const onToolProfileReset = vi.fn(); + const container = document.createElement("div"); + + render( + renderSecurity( + createProps({ + security: { + gatewayAuth: "token", + execPolicy: "allowlist", + deviceAuth: true, + browserEnabled: true, + browserEnabledOverridden: true, + toolProfile: "full", + toolProfileOverridden: true, + }, + onBrowserEnabledReset, + onToolProfileReset, + }), + ), + container, + ); + + const browserRow = expectRowByTitle(container, "Browser enabled"); + const profileRow = expectRowByTitle(container, "Tool profile"); + expect(browserRow.textContent).toContain("Default: Enabled"); + expect(profileRow.textContent).toContain("Default: Full"); + browserRow.querySelector("button[aria-label='Reset to default']")?.click(); + profileRow.querySelector("button[aria-label='Reset to default']")?.click(); + expect(onBrowserEnabledReset).toHaveBeenCalledOnce(); + expect(onToolProfileReset).toHaveBeenCalledOnce(); + }); }); diff --git a/ui/src/pages/config/security.ts b/ui/src/pages/config/security.ts index fbf25833eb9e..8aaecb493b3e 100644 --- a/ui/src/pages/config/security.ts +++ b/ui/src/pages/config/security.ts @@ -5,6 +5,7 @@ import { html, type TemplateResult } from "lit"; import { icons } from "../../components/icons.ts"; import { renderDocsLink, + renderSettingsDefaultState, renderSettingsRow, renderSettingsSection, renderSettingsSegmented, @@ -22,7 +23,9 @@ export type SecurityOverview = { execPolicy: string; deviceAuth: boolean; browserEnabled: boolean; + browserEnabledOverridden: boolean; toolProfile: string; + toolProfileOverridden: boolean; }; type SecurityViewProps = { @@ -31,14 +34,36 @@ type SecurityViewProps = { canPairDevice: boolean; onPairMobile?: () => void; onBrowserEnabledToggle?: (enabled: boolean) => void; + onBrowserEnabledReset?: () => void; onToolProfileChange?: (profile: string) => void; + onToolProfileReset?: () => void; /** Embedded schema editor; it owns autosave status and the restart banner. */ editor: TemplateResult; }; function renderSecurityOverview(props: SecurityViewProps) { - const { gatewayAuth, execPolicy, deviceAuth, browserEnabled, toolProfile } = props.security; + const { + gatewayAuth, + execPolicy, + deviceAuth, + browserEnabled, + browserEnabledOverridden, + toolProfile, + toolProfileOverridden, + } = props.security; const normalizedToolProfile = toolProfile.trim() || "full"; + const browserDefaultState = renderSettingsDefaultState({ + value: t("common.enabled"), + overridden: browserEnabledOverridden, + disabled: props.configBusy, + onReset: () => props.onBrowserEnabledReset?.(), + }); + const toolProfileDefaultState = renderSettingsDefaultState({ + value: t("agents.toolCatalog.profiles.full"), + overridden: toolProfileOverridden, + disabled: props.configBusy, + onReset: () => props.onToolProfileReset?.(), + }); const profileOptions = PROFILE_OPTIONS.map((profile) => ({ value: profile.id as string, label: t(profile.labelKey), @@ -61,19 +86,25 @@ function renderSecurityOverview(props: SecurityViewProps) { }), renderSettingsToggleRow({ title: t("quickSettings.security.browserEnabled"), + description: browserDefaultState.description, checked: browserEnabled, disabled: props.configBusy, + actions: browserDefaultState.action, onChange: (enabled) => props.onBrowserEnabledToggle?.(enabled), }), renderSettingsRow({ title: t("quickSettings.security.toolProfile"), + description: toolProfileDefaultState.description, stacked: true, - control: renderSettingsSegmented({ - value: normalizedToolProfile, - options: profileOptions, - disabled: props.configBusy, - onChange: (profile) => props.onToolProfileChange?.(profile), - }), + control: html` + ${toolProfileDefaultState.action} + ${renderSettingsSegmented({ + value: normalizedToolProfile, + options: profileOptions, + disabled: props.configBusy, + onChange: (profile) => props.onToolProfileChange?.(profile), + })} + `, }), renderSettingsRow({ title: t("quickSettings.security.deviceAuth"), diff --git a/ui/src/pages/labs/labs-page.test.ts b/ui/src/pages/labs/labs-page.test.ts index a4c60196f73a..f21c8211e059 100644 --- a/ui/src/pages/labs/labs-page.test.ts +++ b/ui/src/pages/labs/labs-page.test.ts @@ -72,6 +72,16 @@ function codeModeToggle(page: LabsPageElement) { return labToggle(page, 0, "Code Mode"); } +function labRow(page: LabsPageElement, title: string) { + const row = [...page.querySelectorAll(".settings-row")].find( + (candidate) => candidate.querySelector(".settings-row__title")?.textContent?.trim() === title, + ); + if (!row) { + throw new Error(`${title} row not rendered`); + } + return row; +} + describe("LabsPage", () => { beforeEach(async () => { await i18n.setLocale("en"); @@ -138,7 +148,7 @@ describe("LabsPage", () => { label: "Code Mode", index: 0, sourceConfig: { tools: { codeMode: { enabled: false } } }, - expectedPatch: { tools: { codeMode: { enabled: "auto" } } }, + expectedPatch: { tools: { codeMode: { enabled: null } } }, note: "labs: update codeMode", }, { @@ -213,7 +223,7 @@ describe("LabsPage", () => { expect(labToggle(all.page, auditIndex, "audit").checked).toBe(true); }); - it("turns a broader audit mode off rather than narrowing it", async () => { + it("restores the default off mode from a broader audit mode", async () => { const auditIndex = LAB_FEATURES.findIndex((feature) => feature.id === "auditMessages"); const { page, runtimeConfig } = await mountPage({ logging: { audit: { messages: "all" } }, @@ -225,7 +235,7 @@ describe("LabsPage", () => { await vi.waitFor(() => expect(runtimeConfig.patch).toHaveBeenCalledOnce()); expect(runtimeConfig.patch).toHaveBeenCalledWith({ - raw: { logging: { audit: { messages: "off" } } }, + raw: { logging: { audit: { messages: null } } }, note: "labs: update auditMessages", }); }); @@ -238,6 +248,58 @@ describe("LabsPage", () => { expect(restartRows).toHaveLength(1); expect(restartRows[0]?.textContent).toContain("Message audit metadata"); }); + + it("shows default provenance and reset actions only for overrides", async () => { + const inherited = await mountPage({}); + expect(labRow(inherited.page, "Code Mode").textContent).toContain("Using default: Enabled"); + expect(labRow(inherited.page, "Swarm").textContent).toContain("Using default: Disabled"); + expect(inherited.page.querySelectorAll("button[aria-label='Reset to default']")).toHaveLength( + 0, + ); + inherited.provider.remove(); + + const overridden = await mountPage({ + tools: { + codeMode: { enabled: "auto" }, + swarm: { enabled: false }, + }, + }); + expect(labRow(overridden.page, "Code Mode").textContent).toContain("Default: Enabled"); + expect(labRow(overridden.page, "Swarm").textContent).toContain("Default: Disabled"); + expect(overridden.page.querySelectorAll("button[aria-label='Reset to default']")).toHaveLength( + 2, + ); + }); + + it("restores an object gate without deleting sibling settings", async () => { + const { page, runtimeConfig } = await mountPage({ + tools: { loopDetection: { enabled: true, warningThreshold: 12 } }, + }); + + labRow(page, "Tool-loop detection") + .querySelector("button[aria-label='Reset to default']") + ?.click(); + + await vi.waitFor(() => expect(runtimeConfig.patch).toHaveBeenCalledOnce()); + expect(runtimeConfig.patch).toHaveBeenCalledWith({ + raw: { tools: { loopDetection: { enabled: null } } }, + note: "labs: update loopDetection", + }); + }); + + it("restores a shorthand gate at its owning parent path", async () => { + const { page, runtimeConfig } = await mountPage({ tools: { codeMode: "auto" } }); + + labRow(page, "Code Mode") + .querySelector("button[aria-label='Reset to default']") + ?.click(); + + await vi.waitFor(() => expect(runtimeConfig.patch).toHaveBeenCalledOnce()); + expect(runtimeConfig.patch).toHaveBeenCalledWith({ + raw: { tools: { codeMode: null } }, + note: "labs: update codeMode", + }); + }); }); describe("LabsPage code mode enablement", () => { @@ -279,6 +341,22 @@ describe("LabsPage code mode enablement", () => { note: "labs: update codeMode", }); }); + + it("restores the inherited auto tier instead of pinning it when re-enabled", async () => { + const { page, runtimeConfig } = await mountPage({ + tools: { codeMode: { enabled: false, timeoutMs: 5000 } }, + }); + const toggle = codeModeToggle(page); + + toggle.checked = true; + toggle.dispatchEvent(new Event("change", { bubbles: true, composed: true })); + + await vi.waitFor(() => expect(runtimeConfig.patch).toHaveBeenCalledOnce()); + expect(runtimeConfig.patch).toHaveBeenCalledWith({ + raw: { tools: { codeMode: { enabled: null } } }, + note: "labs: update codeMode", + }); + }); }); describe("LabsPage tool search enablement", () => { @@ -312,21 +390,52 @@ describe("LabsPage tool search enablement", () => { provider.remove(); }); - it("does not replace an operator's existing mode when already on", async () => { + it("restores a mode-only override at the Tool Search owner boundary", async () => { const { page, runtimeConfig } = await mountPage({ tools: { toolSearch: { mode: "tools" } }, }); const toggle = labToggle(page, toolSearchIndex, "Tool Search"); - // The row reads as on, so the only move available is turning it off — it - // cannot be clicked into overwriting `tools` with `directory`. expect(toggle.checked).toBe(true); + expect(labRow(page, "Tool Search").textContent).toContain("Default: Disabled"); toggle.checked = false; toggle.dispatchEvent(new Event("change", { bubbles: true, composed: true })); await vi.waitFor(() => expect(runtimeConfig.patch).toHaveBeenCalledOnce()); expect(runtimeConfig.patch).toHaveBeenCalledWith({ - raw: { tools: { toolSearch: { enabled: false } } }, + raw: { tools: { toolSearch: null } }, + note: "labs: update toolSearch", + }); + }); + + it("enables an explicit-disabled override with the recommended mode", async () => { + const { page, runtimeConfig } = await mountPage({ + tools: { toolSearch: { enabled: false, mode: "tools" } }, + }); + const toggle = labToggle(page, toolSearchIndex, "Tool Search"); + + toggle.checked = true; + toggle.dispatchEvent(new Event("change", { bubbles: true, composed: true })); + + await vi.waitFor(() => expect(runtimeConfig.patch).toHaveBeenCalledOnce()); + expect(runtimeConfig.patch).toHaveBeenCalledWith({ + raw: { tools: { toolSearch: { enabled: true, mode: "directory" } } }, + note: "labs: update toolSearch", + }); + }); + + it("resets an explicit enabled override as a Tool Search unit", async () => { + const { page, runtimeConfig } = await mountPage({ + tools: { toolSearch: { enabled: true } }, + }); + + labRow(page, "Tool Search") + .querySelector("button[aria-label='Reset to default']") + ?.click(); + + await vi.waitFor(() => expect(runtimeConfig.patch).toHaveBeenCalledOnce()); + expect(runtimeConfig.patch).toHaveBeenCalledWith({ + raw: { tools: { toolSearch: null } }, note: "labs: update toolSearch", }); }); @@ -371,4 +480,20 @@ describe("LabsPage tool loop detection enablement", () => { note: "labs: update loopDetection", }); }); + + it("restores the disabled default instead of pinning false", async () => { + const { page, runtimeConfig } = await mountPage({ + tools: { loopDetection: { enabled: true, warningThreshold: 12 } }, + }); + const toggle = labToggle(page, loopDetectionIndex, "Tool-loop detection"); + + toggle.checked = false; + toggle.dispatchEvent(new Event("change", { bubbles: true, composed: true })); + + await vi.waitFor(() => expect(runtimeConfig.patch).toHaveBeenCalledOnce()); + expect(runtimeConfig.patch).toHaveBeenCalledWith({ + raw: { tools: { loopDetection: { enabled: null } } }, + note: "labs: update loopDetection", + }); + }); }); diff --git a/ui/src/pages/labs/labs-page.ts b/ui/src/pages/labs/labs-page.ts index e92cab811e67..69ffd8894b95 100644 --- a/ui/src/pages/labs/labs-page.ts +++ b/ui/src/pages/labs/labs-page.ts @@ -5,10 +5,11 @@ import { titleForRoute } from "../../app-navigation.ts"; import { applicationContext, type ApplicationContext } from "../../app/context.ts"; import { renderDocsLink, + renderSettingsDefaultState, renderSettingsPage, renderSettingsRow, renderSettingsSection, - renderSettingsToggle, + renderSettingsToggleRow, } from "../../components/settings-ui.ts"; import { renderSettingsWorkspace } from "../../components/settings-workspace.ts"; import { t } from "../../i18n/index.ts"; @@ -17,9 +18,10 @@ import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "../../lib/external-l import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts"; import { SubscriptionsController } from "../../lit/subscriptions-controller.ts"; import { - isLabFeatureEnabled, labFeatureMergePatch, + labFeatureResetPatch, LAB_FEATURES, + resolveLabFeatureState, type LabFeature, } from "./labs-registry.ts"; @@ -44,13 +46,17 @@ class LabsPage extends OpenClawLightDomElement { super.disconnectedCallback(); } + private editableConfig(): Record | null { + const snapshot = this.context?.runtimeConfig.state.configSnapshot; + return resolveEditableSnapshotConfig(snapshot); + } + private featureEnabled(feature: LabFeature): boolean { const pending = this.pendingValues[feature.id]; if (pending !== undefined) { return pending; } - const snapshot = this.context?.runtimeConfig.state.configSnapshot; - return isLabFeatureEnabled(resolveEditableSnapshotConfig(snapshot), feature); + return resolveLabFeatureState(this.editableConfig(), feature).enabled; } private canToggle(): boolean { @@ -69,7 +75,7 @@ class LabsPage extends OpenClawLightDomElement { this.pendingValues = next; } - private async setFeatureEnabled(feature: LabFeature, enabled: boolean) { + private async updateFeature(feature: LabFeature, enabled: boolean, raw: Record) { if (!this.canToggle()) { return; } @@ -79,7 +85,7 @@ class LabsPage extends OpenClawLightDomElement { this.saveError = null; try { const patched = await runtimeConfig.patch({ - raw: labFeatureMergePatch(feature, enabled), + raw, note: `labs: update ${feature.id}`, }); if (!patched) { @@ -99,23 +105,48 @@ class LabsPage extends OpenClawLightDomElement { } } + private setFeatureEnabled(feature: LabFeature, enabled: boolean) { + const config = this.editableConfig(); + const state = resolveLabFeatureState(config, feature); + const resetPatch = + enabled === state.defaultEnabled ? labFeatureResetPatch(config, feature) : null; + void this.updateFeature(feature, enabled, resetPatch ?? labFeatureMergePatch(feature, enabled)); + } + + private resetFeature(feature: LabFeature) { + const config = this.editableConfig(); + const state = resolveLabFeatureState(config, feature); + const resetPatch = labFeatureResetPatch(config, feature); + if (!resetPatch) { + return; + } + void this.updateFeature(feature, state.defaultEnabled, resetPatch); + } + private renderFeature(feature: LabFeature) { const title = feature.title(); + const state = resolveLabFeatureState(this.editableConfig(), feature); + const canToggle = this.canToggle(); + const defaultState = renderSettingsDefaultState({ + value: state.defaultEnabled ? t("common.enabled") : t("common.disabled"), + overridden: state.overridden, + disabled: !canToggle, + onReset: () => this.resetFeature(feature), + }); const description = html` ${feature.description()} ${t("labsPage.documentation")}${feature.restartHint ? html` ${feature.restartHint()}` : nothing} + ${defaultState.description} `; - return renderSettingsRow({ + return renderSettingsToggleRow({ title, description, - control: renderSettingsToggle({ - checked: this.featureEnabled(feature), - disabled: !this.canToggle(), - ariaLabel: title, - onChange: (enabled) => void this.setFeatureEnabled(feature, enabled), - }), + checked: this.featureEnabled(feature), + disabled: !canToggle, + actions: defaultState.action, + onChange: (enabled) => this.setFeatureEnabled(feature, enabled), }); } diff --git a/ui/src/pages/labs/labs-registry.ts b/ui/src/pages/labs/labs-registry.ts index cd3acccb0e30..0e9558e9b550 100644 --- a/ui/src/pages/labs/labs-registry.ts +++ b/ui/src/pages/labs/labs-registry.ts @@ -2,6 +2,7 @@ import { t } from "../../i18n/index.ts"; /** What a lab row writes at its gate. Most gates are booleans; some are modes. */ type LabFeatureValue = boolean | string; +type LabFeatureResetScope = "gate" | "parent"; export type LabFeature = { id: string; @@ -37,9 +38,21 @@ export type LabFeature = { * whatever a bare enable defaults to. */ enableAlso: Readonly> | null; + /** + * Ownership boundary for default provenance and reset. Most rows own only + * their gate; features whose runtime default depends on any parent config + * own and reset that parent as a unit. + */ + resetScope: LabFeatureResetScope; restartHint: (() => string) | null; }; +type LabFeatureState = { + enabled: boolean; + defaultEnabled: boolean; + overridden: boolean; +}; + export const LAB_FEATURES = [ { id: "codeMode", @@ -65,6 +78,7 @@ export const LAB_FEATURES = [ return true; }, enableAlso: null, + resetScope: "gate", restartHint: null, }, { @@ -78,6 +92,7 @@ export const LAB_FEATURES = [ activeValues: [true], readEnabled: null, enableAlso: null, + resetScope: "gate", restartHint: null, }, { @@ -109,6 +124,7 @@ export const LAB_FEATURES = [ // form, which is the surface with the weakest recall. Pin the bounded // directory instead, so enabling from Labs is the variant we recommend. enableAlso: { mode: "directory" }, + resetScope: "parent", restartHint: null, }, { @@ -124,6 +140,7 @@ export const LAB_FEATURES = [ // resolveToolLoopDetectionConfig reads this enabled leaf directly. readEnabled: null, enableAlso: null, + resetScope: "gate", restartHint: null, }, { @@ -137,6 +154,7 @@ export const LAB_FEATURES = [ activeValues: [true], readEnabled: null, enableAlso: null, + resetScope: "gate", restartHint: null, }, { @@ -153,6 +171,7 @@ export const LAB_FEATURES = [ activeValues: ["direct", "all"], readEnabled: null, enableAlso: null, + resetScope: "gate", // startGatewayEventSubscriptions resolves the mode once and bakes it into // the recorder, so this outlives the reload plan's `logging: none` rule. restartHint: () => t("labsPage.restartRequired"), @@ -170,16 +189,8 @@ function recordAtPath(config: Record, path: readonly string[]): return current; } -export function isLabFeatureEnabled( - config: Record | null, - feature: LabFeature, -): boolean { - if (!config) { - return false; - } - const parentPath = feature.configPath.slice(0, -1); +function readEnabledFromParent(feature: LabFeature, parent: unknown): boolean { const key = feature.configPath.at(-1); - const parent = recordAtPath(config, parentPath); if (feature.readEnabled) { return feature.readEnabled(parent); } @@ -194,6 +205,68 @@ export function isLabFeatureEnabled( return feature.activeValues.includes((parent as Record)[key] as LabFeatureValue); } +function labFeatureOverridePath( + config: Record, + feature: LabFeature, +): readonly string[] | null { + const parentPath = feature.configPath.slice(0, -1); + const key = feature.configPath.at(-1); + const parent = recordAtPath(config, parentPath); + if (!key) { + return null; + } + if (feature.resetScope === "parent") { + return parent === undefined ? null : parentPath; + } + // Boolean/string shorthands own the parent node rather than an `enabled` + // child. Resetting the child would replace the shorthand with an empty object. + if ( + key === "enabled" && + parent !== undefined && + (typeof parent !== "object" || parent === null) + ) { + return parentPath; + } + if ( + parent && + typeof parent === "object" && + !Array.isArray(parent) && + Object.hasOwn(parent, key) + ) { + return feature.configPath; + } + return null; +} + +export function resolveLabFeatureState( + config: Record | null, + feature: LabFeature, +): LabFeatureState { + const source = config ?? {}; + const parentPath = feature.configPath.slice(0, -1); + const key = feature.configPath.at(-1); + const parent = recordAtPath(source, parentPath); + const overridePath = labFeatureOverridePath(source, feature); + let defaultParent = parent; + if (overridePath?.length === parentPath.length) { + defaultParent = undefined; + } else if ( + overridePath && + key && + parent && + typeof parent === "object" && + !Array.isArray(parent) + ) { + defaultParent = { ...(parent as Record) }; + delete (defaultParent as Record)[key]; + } + return { + enabled: readEnabledFromParent(feature, parent), + defaultEnabled: readEnabledFromParent(feature, defaultParent), + overridden: overridePath !== null, + }; +} + export function labFeatureMergePatch( feature: LabFeature, enabled: boolean, @@ -210,3 +283,18 @@ export function labFeatureMergePatch( } return patch as Record; } + +export function labFeatureResetPatch( + config: Record | null, + feature: LabFeature, +): Record | null { + const path = labFeatureOverridePath(config ?? {}, feature); + if (!path?.length) { + return null; + } + let patch: unknown = null; + for (const segment of path.toReversed()) { + patch = { [segment]: patch }; + } + return patch as Record; +} diff --git a/ui/src/pages/model-providers/model-providers-page.test.ts b/ui/src/pages/model-providers/model-providers-page.test.ts index ee350cf6a555..be5da1bd7575 100644 --- a/ui/src/pages/model-providers/model-providers-page.test.ts +++ b/ui/src/pages/model-providers/model-providers-page.test.ts @@ -99,6 +99,7 @@ function createHarness(initialScopeId: string) { }, ensureLoaded: vi.fn(async () => undefined), patchForm: vi.fn(), + removeFormValue: vi.fn(), save: vi.fn(async () => true), apply: vi.fn(async () => true), discardDraft: vi.fn(async () => undefined), @@ -190,6 +191,32 @@ describe("ModelProvidersPage agent scope", () => { ); }); + it("removes thinking and fast overrides through the shared config draft", async () => { + const { context, runtimeConfig } = createHarness("main"); + const page = appendPage(context); + await vi.waitFor(() => expect(page.querySelector("#settings-model-behavior")).not.toBeNull()); + + const groups = page.querySelectorAll( + "#settings-model-behavior wa-radio-group", + ); + expect(groups).toHaveLength(2); + groups[0]!.value = ""; + groups[0]!.dispatchEvent(new Event("change", { bubbles: true })); + groups[1]!.value = ""; + groups[1]!.dispatchEvent(new Event("change", { bubbles: true })); + + expect(runtimeConfig.removeFormValue).toHaveBeenNthCalledWith(1, [ + "agents", + "defaults", + "thinkingDefault", + ]); + expect(runtimeConfig.removeFormValue).toHaveBeenNthCalledWith(2, [ + "agents", + "defaults", + "fastModeDefault", + ]); + }); + it("reloads credential status when the agent selector changes", async () => { const { agentSelection, context, notifySelection, request } = createHarness("main"); const page = appendPage(context); diff --git a/ui/src/pages/model-providers/model-providers-page.ts b/ui/src/pages/model-providers/model-providers-page.ts index 66c3e498e37c..2af2262065e8 100644 --- a/ui/src/pages/model-providers/model-providers-page.ts +++ b/ui/src/pages/model-providers/model-providers-page.ts @@ -586,9 +586,11 @@ export class ModelProvidersPage extends OpenClawLightDomElement { {}; const agentsDefaults = asConfigRecord(asConfigRecord(configObject.agents)?.defaults); const thinkingLevel = - typeof agentsDefaults?.thinkingDefault === "string" ? agentsDefaults.thinkingDefault : "off"; + typeof agentsDefaults?.thinkingDefault === "string" + ? agentsDefaults.thinkingDefault + : undefined; const fastValue = agentsDefaults?.fastModeDefault; - const fastMode = fastValue === "auto" || typeof fastValue === "boolean" ? fastValue : false; + const fastMode = fastValue === "auto" || typeof fastValue === "boolean" ? fastValue : undefined; const update = this.context.overlays.snapshot; // The overlay update states replace General's old configUpdating prop, // which config-page derived from this same snapshot (isUpdateBusy); the @@ -696,8 +698,12 @@ export class ModelProvidersPage extends OpenClawLightDomElement { }, onThinkingChange: (level) => runtimeConfig.patchForm(["agents", "defaults", "thinkingDefault"], level), + onThinkingReset: () => + runtimeConfig.removeFormValue(["agents", "defaults", "thinkingDefault"]), onFastModeChange: (mode: FastMode) => runtimeConfig.patchForm(["agents", "defaults", "fastModeDefault"], mode), + onFastModeReset: () => + runtimeConfig.removeFormValue(["agents", "defaults", "fastModeDefault"]), onOpenModelSetup: () => this.context.navigate("model-setup"), }); return html` diff --git a/ui/src/pages/model-providers/view.test.ts b/ui/src/pages/model-providers/view.test.ts index 02718444dccb..5589f1609627 100644 --- a/ui/src/pages/model-providers/view.test.ts +++ b/ui/src/pages/model-providers/view.test.ts @@ -74,7 +74,9 @@ function props(overrides: Partial = {}): ModelProviders onDefaultModelsSave: () => undefined, onDefaultModelsReset: () => undefined, onThinkingChange: () => undefined, + onThinkingReset: () => undefined, onFastModeChange: () => undefined, + onFastModeReset: () => undefined, onOpenModelSetup: () => undefined, ...overrides, }; @@ -147,6 +149,7 @@ describe("renderModelProviders", () => { expect(thinking?.value).toBe("low"); expect(fastMode?.value).toBe("auto"); expect([...fastMode!.querySelectorAll("wa-radio")].map((entry) => text(entry))).toEqual([ + "Default", "Auto", "Fast", "Standard", @@ -158,6 +161,63 @@ describe("renderModelProviders", () => { expect(onFastModeChange).toHaveBeenCalledWith(false); }); + it("shows inherited model policy, restores overrides, and preserves advanced thinking", () => { + const onThinkingReset = vi.fn(); + const onFastModeReset = vi.fn(); + const container = mount( + props({ + thinkingLevel: "adaptive", + fastMode: true, + onThinkingReset, + onFastModeReset, + }), + ); + const behavior = container.querySelector("#settings-model-behavior")!; + const thinkingRow = settingsRow(behavior, "Thinking"); + const fastRow = settingsRow(behavior, "Fast mode"); + + expect(thinkingRow.querySelector("wa-radio-group")?.value).toBe("adaptive"); + expect(text(thinkingRow)).toContain("Adaptive"); + expect(text(thinkingRow)).toContain("Default: Model policy"); + expect(text(fastRow)).toContain("Default: Model policy"); + + thinkingRow.querySelector('button[aria-label="Reset to default"]')?.click(); + fastRow.querySelector('button[aria-label="Reset to default"]')?.click(); + expect(onThinkingReset).toHaveBeenCalledOnce(); + expect(onFastModeReset).toHaveBeenCalledOnce(); + + selectSegment(thinkingRow.querySelector("wa-radio-group")!, ""); + selectSegment(fastRow.querySelector("wa-radio-group")!, ""); + expect(onThinkingReset).toHaveBeenCalledTimes(2); + expect(onFastModeReset).toHaveBeenCalledTimes(2); + + render( + renderModelProviders(props({ thinkingLevel: undefined, fastMode: undefined })), + container, + ); + const inheritedBehavior = container.querySelector("#settings-model-behavior")!; + const inheritedThinking = settingsRow(inheritedBehavior, "Thinking"); + const inheritedFast = settingsRow(inheritedBehavior, "Fast mode"); + expect(inheritedThinking.querySelector("wa-radio-group")?.value).toBe(""); + expect(inheritedFast.querySelector("wa-radio-group")?.value).toBe(""); + expect( + ( + inheritedThinking.querySelector('wa-radio[value=""]') as HTMLElement & { + checked: boolean; + } + ).checked, + ).toBe(true); + expect( + (inheritedFast.querySelector('wa-radio[value=""]') as HTMLElement & { checked: boolean }) + .checked, + ).toBe(true); + expect(text(inheritedThinking)).toContain("Using default: Model policy"); + expect(text(inheritedFast)).toContain("Using default: Model policy"); + expect( + inheritedBehavior.querySelectorAll('button[aria-label="Reset to default"]'), + ).toHaveLength(0); + }); + it("locks model behavior while shared config work is pending", () => { const container = mount(props({ configBusy: true })); const behavior = container.querySelector("#settings-model-behavior"); diff --git a/ui/src/pages/model-providers/view.ts b/ui/src/pages/model-providers/view.ts index 6141598a3ddf..7016a28221c0 100644 --- a/ui/src/pages/model-providers/view.ts +++ b/ui/src/pages/model-providers/view.ts @@ -6,6 +6,7 @@ import { renderProviderBrandIcon } from "../../components/provider-icon.ts"; import { renderProviderUsageDetails } from "../../components/provider-usage.ts"; import { renderSettingsEmpty, + renderSettingsDefaultState, renderSettingsGroup, renderSettingsPage, renderSettingsRow, @@ -15,7 +16,7 @@ import { renderSettingsValue, } from "../../components/settings-ui.ts"; import { t } from "../../i18n/index.ts"; -import { BASE_THINKING_LEVELS } from "../../lib/chat/thinking.ts"; +import { BASE_THINKING_LEVELS, formatThinkingOverrideLabel } from "../../lib/chat/thinking.ts"; import { formatCost, formatTimeMs, formatTokens } from "../../lib/format.ts"; import { MODEL_SETTINGS_TARGET_IDS } from "../config/settings-targets.ts"; import "../../styles/model-providers.css"; @@ -44,7 +45,7 @@ type ModelProvidersViewProps = { configuredModels: ModelPickerEntry[]; defaultModels: DefaultModelSelection; defaultModelsDirty: boolean; - thinkingLevel: string; + thinkingLevel: string | undefined; fastMode: FastMode | undefined; configBusy: boolean; unconfiguredProviders: ProviderOption[]; @@ -80,52 +81,88 @@ type ModelProvidersViewProps = { onUtilityChange: (model: string | null) => void; onDefaultModelsSave: () => void; onDefaultModelsReset: () => void; - onThinkingChange: (level: string) => void; + onThinkingChange: (level: string, element: HTMLElement) => void; + onThinkingReset: () => void; onFastModeChange: (mode: FastMode) => void; + onFastModeReset: () => void; onOpenModelSetup: () => void; }; // The global default intentionally omits "minimal"; the full list stays // available on session-level pickers. const THINKING_LEVELS = BASE_THINKING_LEVELS.filter((level) => level !== "minimal"); +const THINKING_LEVEL_SET = new Set(THINKING_LEVELS); function fastModeOptionValue(value: "auto" | "on" | "off"): FastMode { return value === "auto" ? "auto" : value === "on"; } function renderModelBehavior(props: ModelProvidersViewProps) { - const fastMode = formatFastModeValue(props.fastMode); + const thinkingLevels = + props.thinkingLevel && !THINKING_LEVEL_SET.has(props.thinkingLevel) + ? [...THINKING_LEVELS, props.thinkingLevel] + : THINKING_LEVELS; + const thinkingDefault = renderSettingsDefaultState({ + value: t("quickSettings.model.modelPolicy"), + overridden: props.thinkingLevel !== undefined, + disabled: props.configBusy, + onReset: props.onThinkingReset, + }); + const fastDefault = renderSettingsDefaultState({ + value: t("quickSettings.model.modelPolicy"), + overridden: props.fastMode !== undefined, + disabled: props.configBusy, + onReset: props.onFastModeReset, + }); + const fastMode = props.fastMode === undefined ? "" : formatFastModeValue(props.fastMode); return html`
${renderSettingsSection({ title: t("quickSettings.model.title") }, [ renderSettingsRow({ title: t("quickSettings.model.thinking"), - control: renderSettingsSegmented({ - value: props.thinkingLevel, - options: THINKING_LEVELS.map((level) => ({ - value: level, - label: t(`quickSettings.model.thinkingLevels.${level}`), - })), - disabled: props.configBusy, - onChange: props.onThinkingChange, - }), + description: thinkingDefault.description, + control: html` + ${renderSettingsSegmented({ + value: props.thinkingLevel ?? "", + options: [ + { value: "", label: t("quickSettings.model.default") }, + ...thinkingLevels.map((level) => ({ + value: level, + label: THINKING_LEVEL_SET.has(level) + ? t(`quickSettings.model.thinkingLevels.${level}`) + : formatThinkingOverrideLabel(level), + })), + ], + disabled: props.configBusy, + onChange: (value, element) => + value === "" ? props.onThinkingReset() : props.onThinkingChange(value, element), + })} + ${thinkingDefault.action} + `, }), renderSettingsRow({ title: t("quickSettings.model.fastMode"), - control: renderSettingsSegmented<"auto" | "on" | "off">({ - value: fastMode, - options: [ - { value: "auto", label: t("quickSettings.model.fastModes.auto") }, - { value: "on", label: t("quickSettings.model.fastModes.fast") }, - { value: "off", label: t("quickSettings.model.fastModes.standard") }, - ], - disabled: props.configBusy, - onChange: (value) => { - if (value !== fastMode) { - props.onFastModeChange(fastModeOptionValue(value)); - } - }, - }), + description: fastDefault.description, + control: html` + ${renderSettingsSegmented<"" | "auto" | "on" | "off">({ + value: fastMode, + options: [ + { value: "", label: t("quickSettings.model.default") }, + { value: "auto", label: t("quickSettings.model.fastModes.auto") }, + { value: "on", label: t("quickSettings.model.fastModes.fast") }, + { value: "off", label: t("quickSettings.model.fastModes.standard") }, + ], + disabled: props.configBusy, + onChange: (value) => { + if (value === "") { + props.onFastModeReset(); + } else if (value !== fastMode) { + props.onFastModeChange(fastModeOptionValue(value)); + } + }, + })} + ${fastDefault.action} + `, }), ])}