diff --git a/src/app/setup-api/ai-models/configure/route.ts b/src/app/setup-api/ai-models/configure/route.ts index effdf5181..777de6069 100644 --- a/src/app/setup-api/ai-models/configure/route.ts +++ b/src/app/setup-api/ai-models/configure/route.ts @@ -458,6 +458,15 @@ export async function POST(request: Request) { clawaiTier?: string; model?: string; oauthHandoff?: boolean; + /** + * Explicit "make this the model that answers", as distinct from "install + * it and keep it available". Enabling a local model deliberately does NOT + * take over from the provider the customer chose, so the Settings panel's + * "Switch to Gemma 4" button had no way to actually switch — it ran the + * same enable flow and silently left the harness where it was. This flag + * is that missing intent; omitted, the promote policy is unchanged. + */ + activate?: boolean; }; try { body = await request.json(); @@ -607,7 +616,10 @@ export async function POST(request: Request) { ); } - const shouldPromoteLocalToPrimary = isLocalScope && !configStore.ai_model_configured; + // A fresh device promotes its first local model automatically; an existing + // device only promotes when the user explicitly asked to switch to it. + const shouldPromoteLocalToPrimary = + isLocalScope && (!configStore.ai_model_configured || body.activate === true); // Resolve the ClawBox AI tier once and reuse it for both the primary // model selection (below) and the config-store write (further down). // Inlining the same `?? storedTier ?? DEFAULT_TIER` chain in two @@ -963,7 +975,19 @@ export async function POST(request: Request) { // quietly take the device off the provider the customer chose. if ((ocProvider === "llamacpp" || ocProvider === "ollama") && (await getActiveHarness()) === "hermes") { try { - await applyLocalAiToHermes({ provider: ocProvider, model: config.defaultModel }); + await applyLocalAiToHermes({ + provider: ocProvider, + // Hermes wants the bare model id, not the `llamacpp/…` qualified + // form — matching the openclaw-absent branch above. + model: config.defaultModel.replace(/^(?:llamacpp|ollama)\//, ""), + // This branch runs on the `dual` SKU, where OpenClaw exists but + // Hermes is the harness actually answering. Without carrying the + // promotion through, "Switch to Gemma 4" moved OpenClaw's primary + // and left Hermes pointed at its old provider — the same + // configured-but-not-active split this change exists to remove, + // reproduced on the one SKU that has both. + makeDefault: shouldPromoteLocalToPrimary, + }); } catch (err) { // Non-fatal: the local model is configured and running either way. console.error("[ai-models/configure] Hermes local provider registration failed:", err); diff --git a/src/app/setup-api/ai-models/status/route.ts b/src/app/setup-api/ai-models/status/route.ts index 5c00560e5..f2a1fcb77 100644 --- a/src/app/setup-api/ai-models/status/route.ts +++ b/src/app/setup-api/ai-models/status/route.ts @@ -15,6 +15,10 @@ const PROVIDER_LABELS: Record = { openrouter: "OpenRouter", ollama: "Ollama Local", llamacpp: "llama.cpp Local", + // Hermes' id for the on-device model. Without an entry here the raw + // "clawlocal" leaked into the UI as a provider name. Matches the wording in + // lib/hermes-providers.ts so the same model isn't called two things. + clawlocal: "Gemma 4 (on-device)", }; const CLAWBOX_AI_TIER_CONFIG_KEY = "clawai_tier"; diff --git a/src/app/setup-api/llamacpp/install/route.ts b/src/app/setup-api/llamacpp/install/route.ts index 7a30f20e4..1b20fa141 100644 --- a/src/app/setup-api/llamacpp/install/route.ts +++ b/src/app/setup-api/llamacpp/install/route.ts @@ -202,7 +202,11 @@ function startLlamaCpp(spec: ReturnType, alias: st ); } -async function configureLlamaCpp(alias: string, scope: ConfigureScope): Promise<{ ok: boolean; error?: string }> { +async function configureLlamaCpp( + alias: string, + scope: ConfigureScope, + activate: boolean, +): Promise<{ ok: boolean; error?: string }> { const req = new Request("http://localhost/setup-api/ai-models/configure", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -211,6 +215,7 @@ async function configureLlamaCpp(alias: string, scope: ConfigureScope): Promise< apiKey: alias, authMode: "local", scope, + activate, }), }); const res = await configureAiModel(req); @@ -222,7 +227,7 @@ async function configureLlamaCpp(alias: string, scope: ConfigureScope): Promise< } export async function POST(request: Request) { - let body: { model?: string; scope?: ConfigureScope }; + let body: { model?: string; scope?: ConfigureScope; activate?: boolean }; try { body = await request.json(); } catch { @@ -231,6 +236,9 @@ export async function POST(request: Request) { const alias = body.model?.trim() || getDefaultLlamaCppModel(); const scope = body.scope === "local" ? "local" : "primary"; + // Only an explicit "switch to it" click sets this; a plain enable leaves the + // customer's chosen provider in place. + const activate = body.activate === true; if (!MODEL_ID_RE.test(alias)) { return NextResponse.json({ error: "Invalid llama.cpp model ID" }, { status: 400 }); } @@ -246,7 +254,7 @@ export async function POST(request: Request) { const existingModels = await queryLlamaCppModels(spec.baseUrl); if (existingModels.includes(alias)) { emit(controller, { status: "llama.cpp is already running. Applying configuration..." }); - const configured = await configureLlamaCpp(alias, scope); + const configured = await configureLlamaCpp(alias, scope, activate); if (!configured.ok) { emit(controller, { error: configured.error }); controller.close(); @@ -324,7 +332,7 @@ export async function POST(request: Request) { const models = await queryLlamaCppModels(spec.baseUrl); if (models.includes(alias)) { emit(controller, { status: "llama.cpp is ready. Applying ClawBox configuration..." }); - const configured = await configureLlamaCpp(alias, scope); + const configured = await configureLlamaCpp(alias, scope, activate); if (!configured.ok) { emit(controller, { error: configured.error }); controller.close(); diff --git a/src/components/AIModelsStep.tsx b/src/components/AIModelsStep.tsx index e58cf90eb..649cda93d 100644 --- a/src/components/AIModelsStep.tsx +++ b/src/components/AIModelsStep.tsx @@ -45,6 +45,15 @@ interface AIModelsStepProps { defaultProviderId?: string; currentProviderId?: string | null; currentModel?: string | null; + /** + * Whether the local model is the harness's ACTIVE selection, as opposed to + * merely installed. `currentProviderId` only ever reported the latter, so the + * llama.cpp panel showed an "already configured" pill — and hid its own + * switch button — on devices that were not actually using the model. + * Undefined keeps the old provider-id-derived behaviour for callers that + * don't know the difference (the setup wizard). + */ + localAiIsActive?: boolean; openClawAIOfferRequest?: number; requestedProviderId?: string | null; providerSelectionRequest?: number; @@ -400,6 +409,7 @@ export default function AIModelsStep({ defaultProviderId, currentProviderId = null, currentModel = null, + localAiIsActive, openClawAIOfferRequest = 0, requestedProviderId = null, providerSelectionRequest = 0, @@ -1820,7 +1830,7 @@ export default function AIModelsStep({ void; - saveLlamaCppConfig: (model: string) => void; + saveLlamaCppConfig: (model: string, options?: { activate?: boolean }) => void; buttonClassName?: string; buttonSpinner?: ReactNode; } @@ -63,7 +63,7 @@ export default function LlamaCppModelPanel({ let buttonLabel: string; if (llamaCppSaving) { - buttonLabel = "Enabling Gemma 4..."; + buttonLabel = canSwitchToGemma ? "Switching to Gemma 4..." : "Enabling Gemma 4..."; } else if (canSwitchToGemma) { buttonLabel = "Switch to Gemma 4"; } else { @@ -101,7 +101,11 @@ export default function LlamaCppModelPanel({ ) : (