Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ The last model saved in each managed provider form is remembered separately in d

The model button beside the chat composer searches full live cloud catalogs, saved custom connections and the four managed local presets listed above. Selection changes **only the current conversation**, using OpenClaw session overrides. New conversations carry their selected agent's explicit session key, including the main agent, so a model can be selected before the first message in multi-agent configurations. **Use agent default** clears that override without changing agent/global defaults, fallbacks, or other conversations. First use shows the exact model download size, missing embeddings, runtime disk reserve and CPU/GPU mode. Downloads require confirmation; selection changes only after successful preparation. Cancel/failure keeps the previous selection. Already-prepared models switch directly with an inline **Switching...** indicator, without briefly opening a preparation dialog or running setup/inference tests again. Preparation, failures and interrupted-operation recovery still use a persistent dialog.

Both the chat model picker and **Settings → Models → Default model** use the same collapsible provider groups. Only the currently selected model's provider opens by default; headings can be toggled with a click, Enter or Space. Search reveals matching groups, and clearing it restores the previous folds. Folding only changes the list display, never model configuration or downloads. The settings picker still changes the global default, while the chat picker changes only its conversation.
The chat model picker uses collapsible provider groups. Only the currently selected model's provider opens by default; headings can be toggled with a click, Enter or Space. Search reveals matching groups, and clearing it restores the previous folds. Folding only changes the list display, never model configuration or downloads. **Settings → Models → Default model** uses cascading provider menus instead, as described in the Desktop App section below. The settings picker changes the global default, while the chat picker changes only its conversation.

The chat picker shows **Loading models...** on its button for the first catalogue read, then opens the complete list. Later openings retain the previous list and file statuses while refreshing, replacing them together instead of repeatedly shrinking and repositioning the panel. Closing or leaving the button while the first read is pending prevents a late popup.

Expand Down Expand Up @@ -258,6 +258,32 @@ npm install
npm run dev
```

For browser-only UI development, run `npm run dev:renderer` from `desktop` and open
`http://127.0.0.1:5173/?preview=models#/settings/models` to preview **Settings > Models**
with configured Qwen, MiniMax, OpenAI, and Anthropic models. This opt-in preview uses
fake credentials and in-memory configuration; default-model changes and model edits
last until the page reloads. No real model requests or desktop configuration changes
are made. Omit `?preview=models` to use the empty browser preview.

The Settings default-model picker uses cascading menus: hover over a provider to
see up to five curated frequently used models, then hover over **More models** to
see the remaining models. These lists are fixed recommendations, not usage tracking;
providers without curated entries use a stable model-family order. The cascading
picker has no search box or visible submenu titles; menus retain accessible labels.
It shares the Usage sidebar's chevron icon, rotated right for submenu entries,
and uses a matching rounded-stroke outline check for the selected model.
The first-level popup sits 6px from its trigger, and all menu levels use 4px outer
padding. Enabled rows use a theme-aware hover/focus highlight; provider and **More
models** rows retain that highlight while their submenus are open. A selected
model is marked only by its check, not a persistent background. Disabled rows do
not receive the hover highlight.
The first-level title and description align with provider text, and **More models**
uses the same rounded highlight as other menu rows.
Clicking a model closes the entire picker
and uses the existing confirmed default-selection flow. Provider and **More models**
rows also support clicking and Right Arrow to open, Left Arrow or Escape to return,
and Up/Down/Home/End to navigate. Chat's model picker is unchanged.

### Usage snapshots

The sidebar retains the last complete 30-day token total while statistics refresh silently. Model changes, Gateway reconnects, refresh failures and date changes do not clear the displayed summary or replace it with a partial subtotal. Complete snapshots are saved in the desktop user-data directory at `usage/snapshots.json` without a time-based expiry; the sidebar also restores its last display immediately from local storage. Incomplete Gateway results receive a bounded background retry, and newer complete results replace the cache, including legitimate decreases as old usage leaves the reporting window. Explicit dashboard date ranges remain separately cached.
Expand Down
63 changes: 63 additions & 0 deletions desktop/renderer/src/browser-openclaw.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,70 @@
import { describe, expect, it, vi } from "vitest";
import { createBrowserOpenClawMock } from "./browser-openclaw";
import { getModelRef, readConfiguredModels } from "./utils/configured-models";

describe("createBrowserOpenClawMock", () => {
it("keeps configured model preview data opt-in and isolated", async () => {
const api = createBrowserOpenClawMock({ configuredModels: true });
const config = await api.config.read();
const { models, primary } = readConfiguredModels(config);
expect(primary).toBe("qwen/qwen3.7-plus");
expect(new Set(models.map((model) => model.providerKey))).toEqual(
new Set(["qwen", "minimax", "openai", "anthropic"]),
);
expect(models).toHaveLength(11);
expect(models.every((model) => model.apiKey === "sk-browser-preview-not-a-real-key")).toBe(
true,
);
await expect(createBrowserOpenClawMock().config.read()).resolves.toEqual({});
await api.config.write({});
await expect(api.config.read()).resolves.toEqual({});
expect(readConfiguredModels(config).models).toHaveLength(11);
expect(
readConfiguredModels(
await createBrowserOpenClawMock({ configuredModels: true }).config.read(),
).models,
).toHaveLength(11);
});

it("supports changing the preview default and rejects unknown models", async () => {
const api = createBrowserOpenClawMock({ configuredModels: true });
await expect(
api.model.selectDefault({
modelRef: "openai/gpt-5.4",
expectedConfig: await api.config.read(),
}),
).resolves.toEqual({
modelRef: "openai/gpt-5.4",
});
expect(readConfiguredModels(await api.config.read()).primary).toBe("openai/gpt-5.4");
await expect(
api.model.selectDefault({
modelRef: "unknown/model",
expectedConfig: await api.config.read(),
}),
).rejects.toThrow("Unknown browser preview model");
expect(readConfiguredModels(await api.config.read()).primary).toBe("openai/gpt-5.4");
});

it("loads preview catalogs without network or local runtime errors", async () => {
const api = createBrowserOpenClawMock({ configuredModels: true });
const { models } = readConfiguredModels(await api.config.read());
const qwen = models.find((model) => getModelRef(model) === "qwen/qwen3.7-plus")!;
await expect(
api.model.discoverModels({ baseUrl: qwen.baseUrl!, apiKey: qwen.apiKey! }),
).resolves.toMatchObject({
ok: true,
models: expect.arrayContaining(["qwen3.7-plus"]),
});
await expect(api.model.local.getCapabilities()).resolves.toMatchObject({
ok: true,
value: { supported: false, mutable: false },
});
await expect(api.model.local.prepare({ authChoice: "llama-cpp" })).resolves.toMatchObject({
ok: false,
});
});

it("provides the startup and subscription APIs used by browser previews", async () => {
const api = createBrowserOpenClawMock();

Expand Down
97 changes: 87 additions & 10 deletions desktop/renderer/src/browser-openclaw.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,57 @@
export function createBrowserOpenClawMock(): OpenClawAPI {
import { getModelRef, readConfiguredModels } from "./utils/configured-models";
import { MANAGED_MODEL_PROVIDERS } from "./utils/managed-model-providers";
import { selectPrimaryModelConfig } from "./utils/model-provider";

export function createBrowserOpenClawMock(
options: { configuredModels?: boolean } = {},
): OpenClawAPI {
let config: Record<string, unknown> = options.configuredModels
? {
models: {
mode: "merge",
providers: {
...Object.fromEntries(
MANAGED_MODEL_PROVIDERS.map((provider) => [
provider.id,
{
baseUrl: provider.baseUrl,
apiKey: "sk-browser-preview-not-a-real-key",
api: "openai-completions",
models: provider.models.map((model) => ({ ...model })),
},
]),
),
openai: {
baseUrl: "https://openai.example.invalid/v1",
apiKey: "sk-browser-preview-not-a-real-key",
api: "openai-responses",
models: [
{ id: "gpt-5.4", name: "GPT-5.4", input: ["text", "image"], reasoning: true },
{ id: "gpt-5-mini", name: "GPT-5 mini", input: ["text", "image"] },
],
},
anthropic: {
baseUrl: "https://anthropic.example.invalid",
apiKey: "sk-browser-preview-not-a-real-key",
api: "anthropic-messages",
models: [
{
id: "claude-sonnet-4-5",
name: "Claude Sonnet 4.5",
input: ["text", "image"],
},
],
},
},
},
agents: {
defaults: {
model: { primary: "qwen/qwen3.7-plus" },
models: { "openai/gpt-5.4": { params: { thinking: "high" } } },
},
},
}
: {};
const noop = () => {};
const noopAsync = async () => {};
const noopSub = () => noop;
Expand Down Expand Up @@ -39,9 +92,11 @@ export function createBrowserOpenClawMock(): OpenClawAPI {
},
config: {
getStateDir: async () => "",
read: async () => ({}),
read: async () => structuredClone(config),
readEnv: async () => ({}),
write: noopAsync,
write: async (nextConfig) => {
config = structuredClone(nextConfig);
},
isConfigured: async () => true,
needsSetup: async () => false,
},
Expand Down Expand Up @@ -234,8 +289,13 @@ export function createBrowserOpenClawMock(): OpenClawAPI {
cron: { list: async () => ({ jobs: [], pausedByMxc: false, requestedMode: "off" as const }) },
channels: { list: async () => ({ channels: [] }) },
model: {
selectDefault: async () => {
throw new Error("Default model selection requires the desktop application");
selectDefault: async ({ modelRef }) => {
if (!options.configuredModels)
throw new Error("Default model selection requires the desktop application");
if (!readConfiguredModels(config).models.some((model) => getModelRef(model) === modelRef))
throw new Error(`Unknown browser preview model: ${modelRef}`);
config = selectPrimaryModelConfig(config, modelRef);
return { modelRef };
},
session: {
get: async (sessionKey) => ({ sessionKey, modelRef: null, overridden: false }),
Expand All @@ -254,10 +314,22 @@ export function createBrowserOpenClawMock(): OpenClawAPI {
ok: false,
error: "Hardware detection requires the desktop application",
}),
getCapabilities: async () => ({
ok: false,
error: "Local models unavailable in browser development",
}),
getCapabilities: async () =>
options.configuredModels
? {
ok: true,
value: {
supported: false,
mutable: false,
gatewayVersion: null,
blockers: ["Local models unavailable in browser development"],
plugin: { installed: false, enabled: false, canInstall: false },
},
}
: {
ok: false,
error: "Local models unavailable in browser development",
},
detect: async () => ({
ok: false,
error: "Local models unavailable in browser development",
Expand Down Expand Up @@ -293,7 +365,12 @@ export function createBrowserOpenClawMock(): OpenClawAPI {
}),
onEvent: noopSub,
},
discoverModels: async () => ({ ok: false, error: "unavailable" }),
discoverModels: async ({ baseUrl }) => {
const provider = MANAGED_MODEL_PROVIDERS.find((item) => item.baseUrl === baseUrl);
return options.configuredModels && provider
? { ok: true, models: provider.models.map((model) => model.id) }
: { ok: false, error: "unavailable" };
},
registerCatalogModel: async () => {
throw new Error("Model registration requires the desktop Gateway");
},
Expand Down
Loading
Loading