mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
fix(ui): restore curated settings defaults
This commit is contained in:
@@ -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 }>;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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", ""],
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user