fix(ui): restore curated settings defaults

This commit is contained in:
Vincent Koc
2026-07-31 11:14:53 +08:00
parent 58d888d573
commit b1c565c35b
13 changed files with 627 additions and 70 deletions
+35
View File
@@ -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`
<button
type="button"
class="btn btn--icon"
title=${t("configForm.resetToDefault")}
aria-label=${t("configForm.resetToDefault")}
?disabled=${props.disabled ?? false}
@click=${(event: Event) => {
event.stopPropagation();
props.onReset();
}}
>
${icons.refresh}
</button>
`
: nothing,
};
}
export function renderSettingsSegmented<T extends string>(props: {
value: T;
options: ReadonlyArray<{ value: T; label: unknown; title?: string; testId?: string }>;
+2
View File
@@ -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",
+30 -1
View File
@@ -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", ""],
+21 -4
View File
@@ -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 }),
});
}
+69
View File
@@ -48,7 +48,9 @@ function createProps(overrides: Partial<SecurityViewProps> = {}): 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<HTMLButtonElement>("button[aria-label='Reset to default']")?.click();
profileRow.querySelector<HTMLButtonElement>("button[aria-label='Reset to default']")?.click();
expect(onBrowserEnabledReset).toHaveBeenCalledOnce();
expect(onToolProfileReset).toHaveBeenCalledOnce();
});
});
+38 -7
View File
@@ -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"),
+132 -7
View File
@@ -72,6 +72,16 @@ function codeModeToggle(page: LabsPageElement) {
return labToggle(page, 0, "Code Mode");
}
function labRow(page: LabsPageElement, title: string) {
const row = [...page.querySelectorAll<HTMLElement>(".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<HTMLButtonElement>("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<HTMLButtonElement>("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<HTMLButtonElement>("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",
});
});
});
+44 -13
View File
@@ -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<string, unknown> | 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<string, unknown>) {
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()}
<a href=${feature.docsUrl} target=${EXTERNAL_LINK_TARGET} rel=${buildExternalLinkRel()}
>${t("labsPage.documentation")}</a
>${feature.restartHint ? html` <span>${feature.restartHint()}</span>` : nothing}
<span>${defaultState.description}</span>
`;
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),
});
}
+97 -9
View File
@@ -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<Record<string, LabFeatureValue>> | 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<string, unknown>, path: readonly string[]):
return current;
}
export function isLabFeatureEnabled(
config: Record<string, unknown> | 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<string, unknown>)[key] as LabFeatureValue);
}
function labFeatureOverridePath(
config: Record<string, unknown>,
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<string, unknown> | 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<string, unknown>) };
delete (defaultParent as Record<string, unknown>)[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<string, unknown>;
}
export function labFeatureResetPatch(
config: Record<string, unknown> | null,
feature: LabFeature,
): Record<string, unknown> | 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<string, unknown>;
}
@@ -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<HTMLElement & { value: string }>(
"#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);
@@ -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`
+60
View File
@@ -74,7 +74,9 @@ function props(overrides: Partial<ModelProvidersViewProps> = {}): 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<SegmentedGroup>("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<HTMLButtonElement>('button[aria-label="Reset to default"]')?.click();
fastRow.querySelector<HTMLButtonElement>('button[aria-label="Reset to default"]')?.click();
expect(onThinkingReset).toHaveBeenCalledOnce();
expect(onFastModeReset).toHaveBeenCalledOnce();
selectSegment(thinkingRow.querySelector<SegmentedGroup>("wa-radio-group")!, "");
selectSegment(fastRow.querySelector<SegmentedGroup>("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<SegmentedGroup>("wa-radio-group")?.value).toBe("");
expect(inheritedFast.querySelector<SegmentedGroup>("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");
+64 -27
View File
@@ -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<string>(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`
<div id=${MODEL_SETTINGS_TARGET_IDS.behavior}>
${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}
`,
}),
])}
</div>