mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06: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
|
## Local text inference
|
||||||
|
|
||||||
Choose **Local model (llama.cpp)** during interactive onboarding. OpenClaw asks
|
Choose **llama.cpp** during interactive onboarding. OpenClaw installs the
|
||||||
before downloading the default model:
|
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
|
The Gemma 4 E4B IT Q4_K_M file is about 5.0 GB. OpenClaw offers this default
|
||||||
RAM for model weights, plus context and OpenClaw runtime overhead. The default
|
only on machines with at least 16 GiB of RAM, leaving room for model weights,
|
||||||
context is automatically sized with an 8,192-token cap so it remains practical
|
context, and Gateway overhead. The default context is automatically sized with
|
||||||
on 8 GB machines. Configure a larger context only when the machine has enough
|
an 8,192-token cap. Configure a larger context only when the machine has enough
|
||||||
memory.
|
memory.
|
||||||
|
|
||||||
The onboarding discovery check is read-only. It offers llama.cpp automatically
|
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
|
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
|
never downloads during discovery. Ollama and LM Studio remain separate local
|
||||||
service choices and keep their own discovery flows. Manually choosing llama.cpp
|
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
|
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
|
node-llama-cpp function calling. Text streams token by token. Tool calls return
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ native installs and updates.
|
|||||||
|
|
||||||
## Configure text inference
|
## 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.
|
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.
|
The bundled download is offered only on machines with at least 16 GiB of RAM.
|
||||||
Discovery never downloads a model.
|
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.
|
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
|
## CodexBar license
|
||||||
|
|
||||||
MIT License
|
MIT License
|
||||||
|
|||||||
@@ -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",
|
"kimi",
|
||||||
"kiro",
|
"kiro",
|
||||||
"litellm",
|
"litellm",
|
||||||
|
"llamacpp",
|
||||||
"llmproxy",
|
"llmproxy",
|
||||||
"manus",
|
"manus",
|
||||||
"mimo",
|
"mimo",
|
||||||
@@ -71,6 +72,7 @@ const PROVIDER_ICON_ALIASES: Readonly<Record<string, string>> = {
|
|||||||
google: "gemini",
|
google: "gemini",
|
||||||
"google-gemini-cli": "gemini",
|
"google-gemini-cli": "gemini",
|
||||||
"github-copilot": "copilot",
|
"github-copilot": "copilot",
|
||||||
|
"llama-cpp": "llamacpp",
|
||||||
// CodexBar names its bundled OpenAI knot asset "codex".
|
// CodexBar names its bundled OpenAI knot asset "codex".
|
||||||
openai: "codex",
|
openai: "codex",
|
||||||
moonshot: "kimi",
|
moonshot: "kimi",
|
||||||
@@ -87,6 +89,7 @@ const PROVIDER_DISPLAY_LABELS: Readonly<Record<string, string>> = {
|
|||||||
anthropic: "Anthropic",
|
anthropic: "Anthropic",
|
||||||
google: "Google",
|
google: "Google",
|
||||||
"github-copilot": "GitHub",
|
"github-copilot": "GitHub",
|
||||||
|
"llama-cpp": "llama.cpp",
|
||||||
openai: "OpenAI",
|
openai: "OpenAI",
|
||||||
moonshot: "Moonshot AI",
|
moonshot: "Moonshot AI",
|
||||||
opencode: "OpenCode",
|
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) {
|
function renderTextStep(props: WizardStepControlsProps) {
|
||||||
const step = props.step;
|
const step = props.step;
|
||||||
const value = typeof props.value === "string" ? props.value : "";
|
const value = typeof props.value === "string" ? props.value : "";
|
||||||
@@ -221,10 +230,12 @@ export function renderWizardStepControls(
|
|||||||
return renderConfirmStep(props);
|
return renderConfirmStep(props);
|
||||||
case "multiselect":
|
case "multiselect":
|
||||||
return renderMultiselectStep(props);
|
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":
|
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":
|
case "action":
|
||||||
return renderContinueStep(props);
|
return renderContinueStep(props);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -345,10 +345,29 @@ describeControlUiE2e("Control UI Model Setup mocked Gateway E2E", () => {
|
|||||||
|
|
||||||
const start = await gateway.waitForRequest("openclaw.setup.prepare.start");
|
const start = await gateway.waitForRequest("openclaw.setup.prepare.start");
|
||||||
expect(start.params).toMatchObject({ authChoice: "ollama" });
|
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("radio", { name: /Local only/u }).check();
|
||||||
await page.getByRole("button", { name: "Continue" }).click();
|
await page.getByRole("button", { name: "Continue" }).click();
|
||||||
const baseUrl = page.getByLabel("Ollama base URL");
|
const baseUrl = page.getByLabel("Ollama base URL");
|
||||||
await expect.poll(() => baseUrl.inputValue()).toBe("http://127.0.0.1:11434");
|
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.getByRole("button", { name: "Submit" }).click();
|
||||||
await page.getByText("Ollama could not be reached at http://127.0.0.1:11434.").waitFor();
|
await page.getByText("Ollama could not be reached at http://127.0.0.1:11434.").waitFor();
|
||||||
await page
|
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 () => {
|
it("turns an unverifiable Gemini CLI login into direct recovery actions", async () => {
|
||||||
const context = await browser.newContext({
|
const context = await browser.newContext({
|
||||||
colorScheme: "dark",
|
colorScheme: "dark",
|
||||||
|
|||||||
@@ -2021,15 +2021,14 @@ export const en: TranslationMap = {
|
|||||||
more: "More sign-in options",
|
more: "More sign-in options",
|
||||||
},
|
},
|
||||||
prepare: {
|
prepare: {
|
||||||
title: "Set up a local model",
|
title: "Run a model locally",
|
||||||
intro:
|
intro: "Use a local model service, or run a private GGUF model directly inside this Gateway.",
|
||||||
"OpenClaw checks the local service, confirms tool support, and helps prepare a compatible model.",
|
|
||||||
button: "Set up / Download model",
|
|
||||||
ollamaButton: "Check & set up",
|
ollamaButton: "Check & set up",
|
||||||
ollamaLabel: "Ollama",
|
ollamaLabel: "Ollama",
|
||||||
ollamaHint: "Connect to the Ollama service on this Gateway and prepare a tools-capable model",
|
ollamaHint: "Connect to the Ollama service on this Gateway and prepare a tools-capable model",
|
||||||
llamaCppLabel: "Local model (llama.cpp)",
|
llamaCppLabel: "llama.cpp",
|
||||||
llamaCppHint: "Download an approximately 5.0 GB local model; requires 16 GB RAM",
|
llamaCppNotReady:
|
||||||
|
"llama.cpp did not produce a usable local model. Review the setup result, then retry.",
|
||||||
},
|
},
|
||||||
manual: {
|
manual: {
|
||||||
title: "Connect with an API key or token",
|
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 () => {
|
it("flushes a pending config draft before one-shot activation and refreshes afterward", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const { context, client, request, runtimeConfig } = createContext();
|
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 { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
|
||||||
import { SubscriptionsController } from "../../lit/subscriptions-controller.ts";
|
import { SubscriptionsController } from "../../lit/subscriptions-controller.ts";
|
||||||
import { fetchCatalogIconBlobUrl } from "../plugins/icon-loader.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 { detectModelSetup, verifyModelSetup } from "./rpc.ts";
|
||||||
import {
|
import {
|
||||||
activationTargetId,
|
activationTargetId,
|
||||||
@@ -93,6 +93,7 @@ export class ModelSetupPage extends OpenClawLightDomElement {
|
|||||||
|
|
||||||
private observedClient: GatewayBrowserClient | null = null;
|
private observedClient: GatewayBrowserClient | null = null;
|
||||||
private dataClient: GatewayBrowserClient | null = null;
|
private dataClient: GatewayBrowserClient | null = null;
|
||||||
|
private pendingPrepareOption: ModelSetupPrepareOption | null = null;
|
||||||
private readonly iconMisses = new Set<string>();
|
private readonly iconMisses = new Set<string>();
|
||||||
private readonly iconRequests = new Map<
|
private readonly iconRequests = new Map<
|
||||||
string,
|
string,
|
||||||
@@ -511,6 +512,9 @@ export class ModelSetupPage extends OpenClawLightDomElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async handleWizardDone(startMethod: ModelSetupWizardStartMethod): Promise<void> {
|
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();
|
const result = await this.detect();
|
||||||
if (!result) {
|
if (!result) {
|
||||||
this.wizard.fail(t("modelSetup.errors.requestFailed"));
|
this.wizard.fail(t("modelSetup.errors.requestFailed"));
|
||||||
@@ -526,6 +530,26 @@ export class ModelSetupPage extends OpenClawLightDomElement {
|
|||||||
modelRef: result.configuredModel ?? t("modelSetup.success.configuredModel"),
|
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();
|
this.wizard.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -574,10 +598,12 @@ export class ModelSetupPage extends OpenClawLightDomElement {
|
|||||||
onVerify: () => void this.verifyConnection(),
|
onVerify: () => void this.verifyConnection(),
|
||||||
onActivateCandidate: (candidate) => this.activateCandidate(candidate),
|
onActivateCandidate: (candidate) => this.activateCandidate(candidate),
|
||||||
onStartAuth: (option: AuthOption) => {
|
onStartAuth: (option: AuthOption) => {
|
||||||
|
this.pendingPrepareOption = null;
|
||||||
this.wizardMode = "auth";
|
this.wizardMode = "auth";
|
||||||
void this.wizard.start(option.id);
|
void this.wizard.start(option.id);
|
||||||
},
|
},
|
||||||
onStartPrepare: (option: ModelSetupPrepareOption) => {
|
onStartPrepare: (option: ModelSetupPrepareOption) => {
|
||||||
|
this.pendingPrepareOption = option;
|
||||||
this.wizardMode = "prepare";
|
this.wizardMode = "prepare";
|
||||||
void this.wizard.start(option.id, "openclaw.setup.prepare.start");
|
void this.wizard.start(option.id, "openclaw.setup.prepare.start");
|
||||||
},
|
},
|
||||||
@@ -603,8 +629,8 @@ export class ModelSetupPage extends OpenClawLightDomElement {
|
|||||||
},
|
},
|
||||||
onWizardValueChange: (value) => (this.wizardValue = value),
|
onWizardValueChange: (value) => (this.wizardValue = value),
|
||||||
onWizardAnswer: (value, includeValue) => void this.wizard.answer(value, includeValue),
|
onWizardAnswer: (value, includeValue) => void this.wizard.answer(value, includeValue),
|
||||||
onWizardCancel: () => void this.wizard.cancel(),
|
onWizardCancel: () => this.cancelWizard(),
|
||||||
onWizardClose: () => this.wizard.close(),
|
onWizardClose: () => this.closeWizard(),
|
||||||
});
|
});
|
||||||
return html`
|
return html`
|
||||||
<section class="content-header">
|
<section class="content-header">
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export type ModelSetupPrepareOption = {
|
|||||||
brandId?: string;
|
brandId?: string;
|
||||||
label: string;
|
label: string;
|
||||||
hint?: string;
|
hint?: string;
|
||||||
|
activateAfterPrepare?: boolean;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
website?: string;
|
website?: string;
|
||||||
};
|
};
|
||||||
@@ -24,7 +25,9 @@ export function listModelSetupPrepareOptions(
|
|||||||
id: "llama-cpp",
|
id: "llama-cpp",
|
||||||
brandId: "llama-cpp",
|
brandId: "llama-cpp",
|
||||||
label: t("modelSetup.prepare.llamaCppLabel"),
|
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 = [
|
const presented = [
|
||||||
@@ -46,3 +49,10 @@ export function listModelSetupPrepareOptions(
|
|||||||
return wire ? Object.assign({}, choice, wire, { id: choice.id }) : choice;
|
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("Found, but needs attention");
|
||||||
expect(text(container)).toContain("OpenClaw could not confirm a usable login");
|
expect(text(container)).toContain("OpenClaw could not confirm a usable login");
|
||||||
expect(text(container)).toContain("Sign in with a provider");
|
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(text(container)).toContain("Connect with an API key or token");
|
||||||
expect(
|
expect(
|
||||||
container.querySelector('[data-manual-provider="openai"][data-selected]'),
|
container.querySelector('[data-manual-provider="openai"][data-selected]'),
|
||||||
@@ -496,7 +496,12 @@ describe("renderModelSetup", () => {
|
|||||||
'[data-prepare-choice="llama-cpp"] button',
|
'[data-prepare-choice="llama-cpp"] button',
|
||||||
);
|
);
|
||||||
expect(ollama?.textContent).toContain("Check & set up");
|
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();
|
ollama?.click();
|
||||||
expect(onStartPrepare).toHaveBeenCalledWith(expect.objectContaining({ id: "ollama" }));
|
expect(onStartPrepare).toHaveBeenCalledWith(expect.objectContaining({ id: "ollama" }));
|
||||||
|
|
||||||
@@ -991,7 +996,7 @@ describe("renderModelSetup", () => {
|
|||||||
expect(text(confirm)).toContain("No");
|
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({
|
const container = wizardStep({
|
||||||
id: type,
|
id: type,
|
||||||
type,
|
type,
|
||||||
@@ -1003,4 +1008,31 @@ describe("renderModelSetup", () => {
|
|||||||
expect(text(container)).toContain(`${type} message`);
|
expect(text(container)).toContain(`${type} message`);
|
||||||
expect(text(container)).toContain("Continue");
|
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}
|
?disabled=${props.actionsDisabled}
|
||||||
@click=${() => props.onStartPrepare(option)}
|
@click=${() => props.onStartPrepare(option)}
|
||||||
>
|
>
|
||||||
${option.id === "ollama"
|
${t("modelSetup.prepare.ollamaButton")}
|
||||||
? t("modelSetup.prepare.ollamaButton")
|
|
||||||
: t("modelSetup.prepare.button")}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
|
|||||||
@@ -5747,6 +5747,10 @@ td.data-table-key-col {
|
|||||||
color: #4285f4;
|
color: #4285f4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.provider-brand-icon[data-provider-icon="llamacpp"] {
|
||||||
|
color: #ff8236;
|
||||||
|
}
|
||||||
|
|
||||||
.provider-brand-icon[data-provider-icon="opencode"],
|
.provider-brand-icon[data-provider-icon="opencode"],
|
||||||
.provider-brand-icon[data-provider-icon="opencodego"] {
|
.provider-brand-icon[data-provider-icon="opencodego"] {
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
|
|||||||
@@ -48,3 +48,37 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
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