diff --git a/docs/plugins/llama-cpp.md b/docs/plugins/llama-cpp.md index a6c8e41a20d8..6a2c420530cb 100644 --- a/docs/plugins/llama-cpp.md +++ b/docs/plugins/llama-cpp.md @@ -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 diff --git a/extensions/llama-cpp/README.md b/extensions/llama-cpp/README.md index 4b55b8550b60..2732c284fd1d 100644 --- a/extensions/llama-cpp/README.md +++ b/extensions/llama-cpp/README.md @@ -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. diff --git a/ui/public/provider-icons/ATTRIBUTION.md b/ui/public/provider-icons/ATTRIBUTION.md index 30582271f1d1..f01b55115579 100644 --- a/ui/public/provider-icons/ATTRIBUTION.md +++ b/ui/public/provider-icons/ATTRIBUTION.md @@ -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 diff --git a/ui/public/provider-icons/ProviderIcon-llamacpp.svg b/ui/public/provider-icons/ProviderIcon-llamacpp.svg new file mode 100644 index 000000000000..aa8af2abcad1 --- /dev/null +++ b/ui/public/provider-icons/ProviderIcon-llamacpp.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/ui/src/components/provider-icon.ts b/ui/src/components/provider-icon.ts index ab6454c5c9e8..dd68ba37c950 100644 --- a/ui/src/components/provider-icon.ts +++ b/ui/src/components/provider-icon.ts @@ -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> = { 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> = { anthropic: "Anthropic", google: "Google", "github-copilot": "GitHub", + "llama-cpp": "llama.cpp", openai: "OpenAI", moonshot: "Moonshot AI", opencode: "OpenCode", diff --git a/ui/src/components/wizard-step-controls.ts b/ui/src/components/wizard-step-controls.ts index 4015a8b6e468..10c2f56e639a 100644 --- a/ui/src/components/wizard-step-controls.ts +++ b/ui/src/components/wizard-step-controls.ts @@ -80,6 +80,15 @@ function renderContinueStep(props: WizardStepControlsProps) { `; } +function renderProgressStep(step: WizardStep) { + return html` +
+ + ${renderMessage(step)} +
+ `; +} + 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); } diff --git a/ui/src/e2e/model-setup.e2e.test.ts b/ui/src/e2e/model-setup.e2e.test.ts index 2a57414dcc8e..d43b26b730d7 100644 --- a/ui/src/e2e/model-setup.e2e.test.ts +++ b/ui/src/e2e/model-setup.e2e.test.ts @@ -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", diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 3d299704ca02..b483d08ee873 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -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", diff --git a/ui/src/pages/model-setup/model-setup-page.test.ts b/ui/src/pages/model-setup/model-setup-page.test.ts index 55a5308c3a5a..45b781713e25 100644 --- a/ui/src/pages/model-setup/model-setup-page.test.ts +++ b/ui/src/pages/model-setup/model-setup-page.test.ts @@ -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('[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('[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(); diff --git a/ui/src/pages/model-setup/model-setup-page.ts b/ui/src/pages/model-setup/model-setup-page.ts index ea36eaa1b995..0e1e298ad6f7 100644 --- a/ui/src/pages/model-setup/model-setup-page.ts +++ b/ui/src/pages/model-setup/model-setup-page.ts @@ -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(); private readonly iconRequests = new Map< string, @@ -511,6 +512,9 @@ export class ModelSetupPage extends OpenClawLightDomElement { } private async handleWizardDone(startMethod: ModelSetupWizardStartMethod): Promise { + 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`
diff --git a/ui/src/pages/model-setup/prepare-options.ts b/ui/src/pages/model-setup/prepare-options.ts index ca04675dafea..e86f5ede1b59 100644 --- a/ui/src/pages/model-setup/prepare-options.ts +++ b/ui/src/pages/model-setup/prepare-options.ts @@ -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}`); +} diff --git a/ui/src/pages/model-setup/view.test.ts b/ui/src/pages/model-setup/view.test.ts index 1e242e73c603..c4a3bf91f5af 100644 --- a/ui/src/pages/model-setup/view.test.ts +++ b/ui/src/pages/model-setup/view.test.ts @@ -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(); + }); }); diff --git a/ui/src/pages/model-setup/view.ts b/ui/src/pages/model-setup/view.ts index ff881303e9b7..ee0264897a60 100644 --- a/ui/src/pages/model-setup/view.ts +++ b/ui/src/pages/model-setup/view.ts @@ -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")} `, diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index 753ce0c52ad5..6d6e1e7c7ebd 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -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); diff --git a/ui/src/styles/wizard-step-controls.css b/ui/src/styles/wizard-step-controls.css index d3c98f428719..c37030b019ab 100644 --- a/ui/src/styles/wizard-step-controls.css +++ b/ui/src/styles/wizard-step-controls.css @@ -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); + } +}