mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 05:51:39 +00:00
improve(ui): verify llama.cpp model setup
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
8
ui/public/provider-icons/ProviderIcon-llamacpp.svg
Normal file
8
ui/public/provider-icons/ProviderIcon-llamacpp.svg
Normal 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 |
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
`,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user