improve(ui): verify llama.cpp model setup

This commit is contained in:
Vincent Koc
2026-07-30 15:28:59 +08:00
parent 0090549188
commit e96b9d2cd0
15 changed files with 475 additions and 28 deletions

View File

@@ -25,22 +25,23 @@ deleting a manually installed runtime inside the OpenClaw package directory.
## Local text inference
Choose **Local model (llama.cpp)** during interactive onboarding. OpenClaw asks
before downloading the default model:
Choose **llama.cpp** during interactive onboarding. OpenClaw installs the
official provider plugin, then asks before downloading the default model:
`hf:bartowski/Qwen_Qwen3-4B-Instruct-2507-GGUF/Qwen_Qwen3-4B-Instruct-2507-Q4_K_M.gguf`
`hf:unsloth/gemma-4-E4B-it-GGUF/gemma-4-E4B-it-Q4_K_M.gguf`
The Qwen3 4B Instruct 2507 Q4_K_M file is about 2.5 GB. Budget roughly 3 GB of
RAM for model weights, plus context and OpenClaw runtime overhead. The default
context is automatically sized with an 8,192-token cap so it remains practical
on 8 GB machines. Configure a larger context only when the machine has enough
The Gemma 4 E4B IT Q4_K_M file is about 5.0 GB. OpenClaw offers this default
only on machines with at least 16 GiB of RAM, leaving room for model weights,
context, and Gateway overhead. The default context is automatically sized with
an 8,192-token cap. Configure a larger context only when the machine has enough
memory.
The onboarding discovery check is read-only. It offers llama.cpp automatically
only when the default or configured GGUF file is already in the model cache; it
never downloads during discovery. Ollama and LM Studio remain separate local
service choices and keep their own discovery flows. Manually choosing llama.cpp
is the path that prompts for the default model download.
is the path that installs the runtime, prompts for the default model download,
and verifies a real model reply before marking setup complete.
The provider uses the GGUF model's embedded chat template and native
node-llama-cpp function calling. Text streams token by token. Tool calls return

View File

@@ -16,7 +16,7 @@ native installs and updates.
## Configure text inference
Choose **Local model (llama.cpp)** during onboarding. After explicit consent,
Choose **llama.cpp** during onboarding. After explicit consent,
OpenClaw downloads Gemma 4 E4B IT Q4_K_M (approximately 5.0 GB) as the default.
The bundled download is offered only on machines with at least 16 GiB of RAM.
Discovery never downloads a model.

View File

@@ -17,6 +17,39 @@ content otherwise match the cited upstream files.
Provider names and marks remain the property of their respective owners.
## llama.cpp icon
`ProviderIcon-llamacpp.svg` is a metadata-cleaned copy of
`media/llama1-icon-transparent.svg` from the official llama.cpp repository at
commit `2cfef4d117d67ab1dec002915b48a15d11ee1973`:
- Source:
https://github.com/ggml-org/llama.cpp/blob/2cfef4d117d67ab1dec002915b48a15d11ee1973/media/llama1-icon-transparent.svg
- Upstream license:
https://github.com/ggml-org/llama.cpp/blob/master/LICENSE
MIT License
Copyright (c) 2023-2026 The ggml authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
## CodexBar license
MIT License

View File

@@ -0,0 +1,8 @@
<svg viewBox="0 0 250 250" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(-995.51066 -129.70875)" fill="white">
<path d="m1163.3 226.8-13.5 24c-17.8-13.7-44.2-15.7-62-1-28.7 23.7-26.7 78.5 18 78.8 12.5 0 23.1-5.9 34.5-9.8l6 23.9c-10.1 4.7-20.4 9.5-31.5 11-101.2 13.8-95.4-132.3-3.9-139.9 19.2-1.6 36.1 3.4 52.5 13z"/>
<path d="M1093.4 203.8c-15.4 4.6-29.7 13.1-40.5 25-2-24.2 3.4-73.1 30.3-82.7 4-1.4 17.7-4.9 17.3 2.2-.4 7.1-9.9 19.3-12.2 25.9-4 11.6-.3 19.6 5.2 29.7z"/>
<path d="M1131.4 307.8h-15v-17h-17v-14h15.5l1.5-1.5v-16.5h15v18h16v14h-16zm55 0h-15v-17h-16v-14h15.5l1.5-1.5v-16.5h15v16.5l1.5 1.5h15.5v14h-17z"/>
<path d="M1142.3 156.9c2 3-9.3 15.9-11.1 19.2-5.2 9.8-1.7 15.4 2.2 24.7-11.3-1.7-21.8-.3-33 1 2.5-21.5 14.6-52.8 41.9-44.9z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 792 B

View File

@@ -37,6 +37,7 @@ const PROVIDER_ICON_NAMES = new Set([
"kimi",
"kiro",
"litellm",
"llamacpp",
"llmproxy",
"manus",
"mimo",
@@ -71,6 +72,7 @@ const PROVIDER_ICON_ALIASES: Readonly<Record<string, string>> = {
google: "gemini",
"google-gemini-cli": "gemini",
"github-copilot": "copilot",
"llama-cpp": "llamacpp",
// CodexBar names its bundled OpenAI knot asset "codex".
openai: "codex",
moonshot: "kimi",
@@ -87,6 +89,7 @@ const PROVIDER_DISPLAY_LABELS: Readonly<Record<string, string>> = {
anthropic: "Anthropic",
google: "Google",
"github-copilot": "GitHub",
"llama-cpp": "llama.cpp",
openai: "OpenAI",
moonshot: "Moonshot AI",
opencode: "OpenCode",

View File

@@ -80,6 +80,15 @@ function renderContinueStep(props: WizardStepControlsProps) {
`;
}
function renderProgressStep(step: WizardStep) {
return html`
<div class="wizard-step__progress" role="status" aria-live="polite">
<span class="wizard-step__spinner" aria-hidden="true"></span>
${renderMessage(step)}
</div>
`;
}
function renderTextStep(props: WizardStepControlsProps) {
const step = props.step;
const value = typeof props.value === "string" ? props.value : "";
@@ -221,10 +230,12 @@ export function renderWizardStepControls(
return renderConfirmStep(props);
case "multiselect":
return renderMultiselectStep(props);
// These carry no input of their own: they show whatever the step supplies
// (message, link, device code) behind a single Continue.
case "note":
case "progress":
return props.step.executor === "gateway"
? renderProgressStep(props.step)
: renderContinueStep(props);
// These show whatever the step supplies behind a single Continue.
case "note":
case "action":
return renderContinueStep(props);
}

View File

@@ -345,10 +345,29 @@ describeControlUiE2e("Control UI Model Setup mocked Gateway E2E", () => {
const start = await gateway.waitForRequest("openclaw.setup.prepare.start");
expect(start.params).toMatchObject({ authChoice: "ollama" });
if (artifactDir) {
await mkdir(artifactDir, { recursive: true });
await page.screenshot({
animations: "disabled",
fullPage: true,
path: path.join(artifactDir, "ollama-mode-select-desktop.png"),
});
}
await page.getByRole("radio", { name: /Local only/u }).check();
await page.getByRole("button", { name: "Continue" }).click();
const baseUrl = page.getByLabel("Ollama base URL");
await expect.poll(() => baseUrl.inputValue()).toBe("http://127.0.0.1:11434");
if (artifactDir) {
await page.screenshot({
animations: "disabled",
fullPage: true,
path: path.join(artifactDir, "ollama-host-desktop.png"),
});
}
await page.getByRole("button", { name: "Submit" }).click();
await page.getByText("Ollama could not be reached at http://127.0.0.1:11434.").waitFor();
await page
@@ -413,6 +432,167 @@ describeControlUiE2e("Control UI Model Setup mocked Gateway E2E", () => {
}
});
it("downloads, verifies, and opens chat with llama.cpp", async () => {
const context = await browser.newContext({
colorScheme: "dark",
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const initialDetection = {
candidates: [],
manualProviders: [],
workspace: "/tmp/openclaw-e2e",
setupComplete: false,
};
const modelRef = "llama-cpp/gemma-4-e4b-it-q4_k_m";
const gateway = await installMockGateway(page, {
featureMethods: [
"chat.metadata",
"chat.startup",
"openclaw.setup.detect",
"openclaw.setup.activate",
"openclaw.setup.prepare.start",
"wizard.next",
],
methodResponses: {
"openclaw.setup.detect": initialDetection,
"openclaw.setup.prepare.start": {
sessionId: "llama-cpp-prepare-session",
done: false,
status: "running",
},
"openclaw.setup.activate": {
ok: true,
modelRef,
latencyMs: 731,
lines: ["Model ready"],
},
"wizard.next": {
sequence: [
{
done: false,
status: "running",
step: {
id: "llama-cpp-consent",
type: "confirm",
message:
"Download Gemma 4 E4B IT Q4_K_M (about 5.0 GB) for local llama.cpp inference?",
initialValue: false,
},
},
{
done: false,
status: "running",
step: {
id: "llama-cpp-download-20",
type: "progress",
message: "Downloading Gemma 4 E4B… 20% (1.0/5.0 GB, 38 MB/s)",
executor: "gateway",
},
},
{
done: false,
status: "running",
step: {
id: "llama-cpp-download-100",
type: "progress",
message: "Gemma 4 E4B model downloaded",
executor: "gateway",
},
},
{ done: true, status: "done" },
],
},
},
});
try {
const response = await page.goto(`${server.baseUrl}settings/model-setup`);
expect(response?.status()).toBe(200);
const llamaCppRow = page.locator('[data-prepare-choice="llama-cpp"]');
await llamaCppRow.getByRole("button", { name: "Check & set up" }).waitFor();
await expect
.poll(() => llamaCppRow.locator('[data-provider-icon="llamacpp"]').count())
.toBe(1);
await expect.poll(() => llamaCppRow.textContent()).not.toContain("Gemma");
await expect.poll(() => llamaCppRow.textContent()).not.toContain("GB");
await expect.poll(() => llamaCppRow.textContent()).not.toContain("RAM");
if (artifactDir) {
await mkdir(artifactDir, { recursive: true });
await page.screenshot({
animations: "disabled",
fullPage: true,
path: path.join(artifactDir, "llama-cpp-offer-desktop.png"),
});
}
await llamaCppRow.getByRole("button", { name: "Check & set up" }).click();
const start = await gateway.waitForRequest("openclaw.setup.prepare.start");
expect(start.params).toMatchObject({ authChoice: "llama-cpp" });
await page.getByRole("heading", { name: "Set up a local model" }).waitFor();
await page.getByText("Download Gemma 4 E4B IT Q4_K_M").waitFor();
if (artifactDir) {
await page.screenshot({
animations: "disabled",
fullPage: true,
path: path.join(artifactDir, "llama-cpp-confirm-desktop.png"),
});
}
await gateway.setMethodResponse("openclaw.setup.detect", {
...initialDetection,
candidates: [
{
kind: "provider-auto:llama-cpp",
brandId: "llama-cpp",
label: "llama.cpp",
detail: "Gemma 4 E4B downloaded",
modelRef,
recommended: true,
credentials: true,
},
],
});
await page.getByRole("button", { name: "Yes" }).click();
await page.getByRole("heading", { name: "Connection verified" }).waitFor();
await expect
.poll(() => page.locator(".model-setup-success").textContent())
.toContain(modelRef);
await expect
.poll(() => page.locator(".model-setup-success").textContent())
.toContain("Verified in 731 ms");
const activate = await gateway.waitForRequest("openclaw.setup.activate");
expect(activate.params).toEqual({
kind: "provider-auto:llama-cpp",
modelRef,
});
if (artifactDir) {
await page.screenshot({
animations: "disabled",
fullPage: true,
path: path.join(artifactDir, "llama-cpp-ready-desktop.png"),
});
await page.setViewportSize({ height: 844, width: 390 });
await page.screenshot({
animations: "disabled",
fullPage: true,
path: path.join(artifactDir, "llama-cpp-ready-mobile.png"),
});
}
await page.getByRole("button", { name: "Start chatting" }).click();
await expect.poll(() => new URL(page.url()).pathname).toBe("/chat");
} finally {
await context.close();
}
});
it("turns an unverifiable Gemini CLI login into direct recovery actions", async () => {
const context = await browser.newContext({
colorScheme: "dark",

View File

@@ -2021,15 +2021,14 @@ export const en: TranslationMap = {
more: "More sign-in options",
},
prepare: {
title: "Set up a local model",
intro:
"OpenClaw checks the local service, confirms tool support, and helps prepare a compatible model.",
button: "Set up / Download model",
title: "Run a model locally",
intro: "Use a local model service, or run a private GGUF model directly inside this Gateway.",
ollamaButton: "Check & set up",
ollamaLabel: "Ollama",
ollamaHint: "Connect to the Ollama service on this Gateway and prepare a tools-capable model",
llamaCppLabel: "Local model (llama.cpp)",
llamaCppHint: "Download an approximately 5.0 GB local model; requires 16 GB RAM",
llamaCppLabel: "llama.cpp",
llamaCppNotReady:
"llama.cpp did not produce a usable local model. Review the setup result, then retry.",
},
manual: {
title: "Connect with an API key or token",

View File

@@ -277,6 +277,114 @@ describe("ModelSetupPage catalog icons", () => {
});
});
it("verifies a prepared llama.cpp model before showing success", async () => {
const { context: baseContext, client, request } = createContext();
const runtimeConfig = {
runExternalMutation: vi.fn(async (task) => ({
ok: true as const,
value: await task(client),
refresh: { ok: true as const },
})),
} as unknown as ApplicationContext["runtimeConfig"];
const context = { ...baseContext, runtimeConfig } as ApplicationContext;
request.mockImplementation(async (method: string) => {
if (method === "openclaw.setup.prepare.start") {
return { sessionId: "prepare-session", done: false, status: "running" };
}
if (method === "wizard.next") {
return { done: true, status: "done" };
}
if (method === "openclaw.setup.detect") {
return {
...detection,
candidates: [
{
kind: "existing-model",
label: "Existing llama.cpp model",
detail: "Already configured",
modelRef: "llama-cpp/custom",
recommended: false,
credentials: true,
},
{
kind: "provider-auto:llama-cpp",
brandId: "llama-cpp",
label: "llama.cpp",
detail: "Gemma 4 E4B downloaded",
modelRef: "llama-cpp/gemma-4-e4b-it-q4_k_m",
recommended: true,
credentials: true,
},
],
};
}
if (method === "openclaw.setup.activate") {
return {
ok: true,
modelRef: "llama-cpp/gemma-4-e4b-it-q4_k_m",
latencyMs: 731,
lines: ["Model ready"],
};
}
return {};
});
const { page } = await mountPage(context, {
state: { phase: "ready", result: detection },
client,
firstRun: false,
});
page.querySelector<HTMLButtonElement>('[data-prepare-choice="llama-cpp"] button')?.click();
await vi.waitFor(() => {
expect(request).toHaveBeenCalledWith(
"openclaw.setup.activate",
{
kind: "provider-auto:llama-cpp",
modelRef: "llama-cpp/gemma-4-e4b-it-q4_k_m",
},
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
expect(page.textContent).toContain("Connection verified");
expect(page.textContent).toContain("llama-cpp/gemma-4-e4b-it-q4_k_m");
expect(page.textContent).toContain("Verified in 731 ms");
});
});
it("keeps an incomplete llama.cpp setup visible instead of claiming success", async () => {
const { context, client, request } = createContext();
request.mockImplementation(async (method: string) => {
if (method === "openclaw.setup.prepare.start") {
return { sessionId: "prepare-session", done: false, status: "running" };
}
if (method === "wizard.next") {
return { done: true, status: "done" };
}
if (method === "openclaw.setup.detect") {
return detection;
}
return {};
});
const { page } = await mountPage(context, {
state: { phase: "ready", result: detection },
client,
firstRun: false,
});
page.querySelector<HTMLButtonElement>('[data-prepare-choice="llama-cpp"] button')?.click();
await vi.waitFor(() => {
expect(page.textContent).toContain(
"llama.cpp did not produce a usable local model. Review the setup result, then retry.",
);
});
expect(request).not.toHaveBeenCalledWith(
"openclaw.setup.activate",
expect.anything(),
expect.anything(),
);
});
it("flushes a pending config draft before one-shot activation and refreshes afterward", async () => {
vi.useFakeTimers();
const { context, client, request, runtimeConfig } = createContext();

View File

@@ -18,7 +18,7 @@ import { isGatewayMethodAdvertised } from "../../lib/gateway-methods.ts";
import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
import { SubscriptionsController } from "../../lit/subscriptions-controller.ts";
import { fetchCatalogIconBlobUrl } from "../plugins/icon-loader.ts";
import type { ModelSetupPrepareOption } from "./prepare-options.ts";
import { findPreparedModelCandidate, type ModelSetupPrepareOption } from "./prepare-options.ts";
import { detectModelSetup, verifyModelSetup } from "./rpc.ts";
import {
activationTargetId,
@@ -93,6 +93,7 @@ export class ModelSetupPage extends OpenClawLightDomElement {
private observedClient: GatewayBrowserClient | null = null;
private dataClient: GatewayBrowserClient | null = null;
private pendingPrepareOption: ModelSetupPrepareOption | null = null;
private readonly iconMisses = new Set<string>();
private readonly iconRequests = new Map<
string,
@@ -511,6 +512,9 @@ export class ModelSetupPage extends OpenClawLightDomElement {
}
private async handleWizardDone(startMethod: ModelSetupWizardStartMethod): Promise<void> {
const prepareOption =
startMethod === "openclaw.setup.prepare.start" ? this.pendingPrepareOption : null;
this.pendingPrepareOption = null;
const result = await this.detect();
if (!result) {
this.wizard.fail(t("modelSetup.errors.requestFailed"));
@@ -526,6 +530,26 @@ export class ModelSetupPage extends OpenClawLightDomElement {
modelRef: result.configuredModel ?? t("modelSetup.success.configuredModel"),
};
}
if (prepareOption?.activateAfterPrepare) {
const candidate = findPreparedModelCandidate(result, prepareOption.id);
if (!candidate) {
this.wizard.fail(t("modelSetup.prepare.llamaCppNotReady"));
return;
}
this.wizard.close();
this.activateCandidate(candidate);
return;
}
this.wizard.close();
}
private cancelWizard(): void {
this.pendingPrepareOption = null;
void this.wizard.cancel();
}
private closeWizard(): void {
this.pendingPrepareOption = null;
this.wizard.close();
}
@@ -574,10 +598,12 @@ export class ModelSetupPage extends OpenClawLightDomElement {
onVerify: () => void this.verifyConnection(),
onActivateCandidate: (candidate) => this.activateCandidate(candidate),
onStartAuth: (option: AuthOption) => {
this.pendingPrepareOption = null;
this.wizardMode = "auth";
void this.wizard.start(option.id);
},
onStartPrepare: (option: ModelSetupPrepareOption) => {
this.pendingPrepareOption = option;
this.wizardMode = "prepare";
void this.wizard.start(option.id, "openclaw.setup.prepare.start");
},
@@ -603,8 +629,8 @@ export class ModelSetupPage extends OpenClawLightDomElement {
},
onWizardValueChange: (value) => (this.wizardValue = value),
onWizardAnswer: (value, includeValue) => void this.wizard.answer(value, includeValue),
onWizardCancel: () => void this.wizard.cancel(),
onWizardClose: () => this.wizard.close(),
onWizardCancel: () => this.cancelWizard(),
onWizardClose: () => this.closeWizard(),
});
return html`
<section class="content-header">

View File

@@ -6,6 +6,7 @@ export type ModelSetupPrepareOption = {
brandId?: string;
label: string;
hint?: string;
activateAfterPrepare?: boolean;
icon?: string;
website?: string;
};
@@ -24,7 +25,9 @@ export function listModelSetupPrepareOptions(
id: "llama-cpp",
brandId: "llama-cpp",
label: t("modelSetup.prepare.llamaCppLabel"),
hint: t("modelSetup.prepare.llamaCppHint"),
// Keep model choice and resource requirements in the provider-owned
// consent step so local runtimes remain peers in this list.
activateAfterPrepare: true,
},
];
const presented = [
@@ -46,3 +49,10 @@ export function listModelSetupPrepareOptions(
return wire ? Object.assign({}, choice, wire, { id: choice.id }) : choice;
});
}
export function findPreparedModelCandidate(
result: SystemAgentSetupDetectResult,
providerId: ModelSetupPrepareOption["id"],
) {
return result.candidates.find((candidate) => candidate.kind === `provider-auto:${providerId}`);
}

View File

@@ -182,7 +182,7 @@ describe("renderModelSetup", () => {
expect(text(container)).toContain("Found, but needs attention");
expect(text(container)).toContain("OpenClaw could not confirm a usable login");
expect(text(container)).toContain("Sign in with a provider");
expect(text(container)).toContain("Set up a local model");
expect(text(container)).toContain("Run a model locally");
expect(text(container)).toContain("Connect with an API key or token");
expect(
container.querySelector('[data-manual-provider="openai"][data-selected]'),
@@ -496,7 +496,12 @@ describe("renderModelSetup", () => {
'[data-prepare-choice="llama-cpp"] button',
);
expect(ollama?.textContent).toContain("Check & set up");
expect(llamaCpp).not.toBeNull();
expect(llamaCpp?.textContent).toContain("Check & set up");
const llamaCppRow = container.querySelector('[data-prepare-choice="llama-cpp"]');
expect(llamaCppRow?.querySelector('[data-provider-icon="llamacpp"]')).not.toBeNull();
expect(text(llamaCppRow!)).toContain("llama.cpp");
expect(text(llamaCppRow!)).not.toContain("Gemma");
expect(llamaCppRow?.classList.contains("model-setup__prepare-row--featured")).toBe(false);
ollama?.click();
expect(onStartPrepare).toHaveBeenCalledWith(expect.objectContaining({ id: "ollama" }));
@@ -991,7 +996,7 @@ describe("renderModelSetup", () => {
expect(text(confirm)).toContain("No");
});
it.each(["multiselect", "progress", "action"] as const)("renders the %s wizard step", (type) => {
it.each(["multiselect", "action"] as const)("renders the %s wizard step", (type) => {
const container = wizardStep({
id: type,
type,
@@ -1003,4 +1008,31 @@ describe("renderModelSetup", () => {
expect(text(container)).toContain(`${type} message`);
expect(text(container)).toContain("Continue");
});
it("renders gateway progress without an answer control", () => {
const container = wizardStep({
id: "download",
type: "progress",
message: "Downloading Gemma 4 E4B… 42%",
executor: "gateway",
});
expect(text(container)).toContain("Downloading Gemma 4 E4B… 42%");
expect(container.querySelector('[role="status"]')).not.toBeNull();
expect(container.querySelector(".wizard-step__spinner")).not.toBeNull();
expect(container.querySelector(".wizard-step__progress button")).toBeNull();
});
it("keeps a Continue action for client progress", () => {
const container = wizardStep({
id: "client-progress",
type: "progress",
message: "Waiting for the local client",
executor: "client",
});
expect(text(container)).toContain("Waiting for the local client");
expect(text(container)).toContain("Continue");
expect(container.querySelector('[role="status"]')).toBeNull();
});
});

View File

@@ -429,9 +429,7 @@ function renderPrepare(props: ModelSetupViewProps, result: SystemAgentSetupDetec
?disabled=${props.actionsDisabled}
@click=${() => props.onStartPrepare(option)}
>
${option.id === "ollama"
? t("modelSetup.prepare.ollamaButton")
: t("modelSetup.prepare.button")}
${t("modelSetup.prepare.ollamaButton")}
</button>
</div>
`,

View File

@@ -5747,6 +5747,10 @@ td.data-table-key-col {
color: #4285f4;
}
.provider-brand-icon[data-provider-icon="llamacpp"] {
color: #ff8236;
}
.provider-brand-icon[data-provider-icon="opencode"],
.provider-brand-icon[data-provider-icon="opencodego"] {
color: var(--text-strong);

View File

@@ -48,3 +48,37 @@
display: flex;
gap: 8px;
}
.wizard-step__progress {
display: flex;
align-items: center;
gap: 12px;
min-height: 44px;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--bg);
}
.wizard-step__spinner {
width: 18px;
height: 18px;
flex: 0 0 18px;
border: 2px solid color-mix(in srgb, var(--accent) 24%, transparent);
border-top-color: var(--accent);
border-radius: 50%;
animation: wizard-step-spin 800ms linear infinite;
}
@keyframes wizard-step-spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.wizard-step__spinner {
animation: none;
border-color: var(--accent);
}
}