> = {
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`