feat(desktop): add Local Models settings page for managing Ollama models

This commit is contained in:
Saoud Rizwan
2026-10-19 22:37:42 +00:00
parent 7b42f7763b
commit d5dd3887b3
13 changed files with 1189 additions and 1 deletions
+2 -1
View File
@@ -32,7 +32,7 @@
"test:chat-ui": "vitest run webview/components/views/chat/chat-messages.test.tsx webview/components/views/chat/messages webview/components/ui/markdown.test.tsx webview/components/ui/markdown.interactions.test.tsx --config vitest.config.ts",
"pretest:settings-ui": "bun run build:ui",
"test:settings-ui": "vitest run webview/components/views/settings webview/components/ui/switch-integration.test.tsx --config vitest.config.ts",
"test:sidecar": "vitest run sidecar scripts/telemetry-define-args.test.ts webview/hooks/chat-session/helpers.test.ts webview/hooks/use-chat-session.test.tsx webview/hooks/use-session-history.test.tsx webview/hooks/use-prompt-draft.test.tsx webview/lib/changelog.test.ts webview/lib/cloud-repositories.test.ts webview/lib/cloud-session-error.test.ts webview/lib/model-selection.test.ts webview/lib/local-environment-selection.test.ts webview/lib/image-attachments.test.ts webview/lib/session-status.test.ts webview/lib/whats-new.test.ts webview/lib/workspace-paths.test.ts webview/components/agent-sidebar.test.tsx webview/components/views/chat/chat-input-bar.test.tsx webview/components/views/chat/chat-messages.test.tsx webview/components/views/chat/environment-selector.test.tsx webview/components/views/chat/welcome-chat.test.tsx webview/components/views/chat/welcome-workspace-controls.test.tsx webview/components/views/sessions/sessions-view.test.tsx webview/components/views/settings/settings-view.test.tsx --config vitest.config.ts",
"test:sidecar": "vitest run sidecar scripts/telemetry-define-args.test.ts webview/hooks/chat-session/helpers.test.ts webview/hooks/use-chat-session.test.tsx webview/hooks/use-session-history.test.tsx webview/hooks/use-prompt-draft.test.tsx webview/lib/changelog.test.ts webview/lib/cloud-repositories.test.ts webview/lib/cloud-session-error.test.ts webview/lib/model-selection.test.ts webview/lib/local-environment-selection.test.ts webview/lib/image-attachments.test.ts webview/lib/local-models.test.ts webview/lib/session-status.test.ts webview/lib/whats-new.test.ts webview/lib/workspace-paths.test.ts webview/components/agent-sidebar.test.tsx webview/components/views/chat/chat-input-bar.test.tsx webview/components/views/chat/chat-messages.test.tsx webview/components/views/chat/environment-selector.test.tsx webview/components/views/chat/welcome-chat.test.tsx webview/components/views/chat/welcome-workspace-controls.test.tsx webview/components/views/sessions/sessions-view.test.tsx webview/components/views/settings/settings-view.test.tsx --config vitest.config.ts",
"test:sidecar-startup": "bun test scripts/desktop-startup.test.ts",
"test:desktop-client": "vitest run webview/lib/desktop-client.test.ts --config vitest.config.ts",
"clean": "rm -rf webview/.next webview/out node_modules dist && (cd src-tauri && rm -rf target node_modules dist)"
@@ -92,6 +92,7 @@
"mermaid": "11.16.1",
"next": "16.2.11",
"next-themes": "^0.4.6",
"ollama": "^0.6.4",
"pino": "^10.3.1",
"posthog-node": "^5.8.0",
"radix-ui": "^1.4.3",
@@ -205,6 +205,9 @@ Supported commands:
| `save_provider_settings` | `saveLocalProviderSettings` |
| `add_provider` | `addLocalProvider` |
| `run_provider_oauth_login` | `loginLocalProvider` |
| `get_local_models_status` | OS hardware probe (`nvidia-smi` for NVIDIA GPUs) + `ollama` SDK `version`/`list`/`ps` |
| `pull_ollama_model` / `cancel_ollama_pull` | `ollama` SDK `pull`; progress is broadcast as `ollama_pull_progress` events and survives closing the page |
| `load_ollama_model` / `unload_ollama_model` / `delete_ollama_model` | `ollama` SDK `generate` (`keep_alive` -1 / 0) and `delete` |
| `list_chat_sessions` | `SqliteSessionStore` + file discovery, merged with cloud sessions |
| `list_discovered_sessions` | Merged discovery (local + cloud) |
| `read_session_messages` | Session data readers; cloud sessions read through the sandbox Hub |
@@ -133,6 +133,10 @@ import {
refreshDesktopFeatureFlags,
} from "./feature-flags";
import { clearLegacyProviderCredentials } from "./legacy-provider-credentials";
import {
handleLocalModelsCommand,
LOCAL_MODELS_COMMANDS,
} from "./local-models";
import {
installMarketplaceEntryForDesktopCommand,
listMarketplaceInstalledEntries,
@@ -2882,6 +2886,11 @@ export async function handleCommand(
}
}
// ── Local models (Ollama) ──────────────────────────────────────────
if (LOCAL_MODELS_COMMANDS.has(command)) {
return await handleLocalModelsCommand(ctx, command, args);
}
// ── Provider management ────────────────────────────────────────────
if (command === "list_provider_catalog") {
const manager = new ProviderSettingsManager();
@@ -0,0 +1,253 @@
import { execFile } from "node:child_process";
import os from "node:os";
import { promisify } from "node:util";
import { ProviderSettingsManager } from "@cline/core";
import { Ollama } from "ollama";
import {
type LocalHardwareInfo,
type LocalModelsStatus,
OLLAMA_PULL_PROGRESS_EVENT,
type OllamaPullProgress,
} from "../webview/lib/local-models";
import { broadcastEvent } from "./context";
import type { SidecarContext } from "./types";
const execFileAsync = promisify(execFile);
const BYTES_PER_GB = 1024 ** 3;
const DEFAULT_OLLAMA_BASE_URL = "http://localhost:11434";
const PROGRESS_THROTTLE_MS = 250;
export const LOCAL_MODELS_COMMANDS = new Set([
"get_local_models_status",
"pull_ollama_model",
"cancel_ollama_pull",
"load_ollama_model",
"unload_ollama_model",
"delete_ollama_model",
]);
let hardwarePromise: Promise<LocalHardwareInfo> | undefined;
// Downloads outlive the settings page that started them (an 18 GB pull takes a
// while), so they are tracked here and progress is broadcast to every window.
const activePulls = new Map<
string,
{ progress: OllamaPullProgress; abort: () => void }
>();
async function detectNvidiaGpus(): Promise<LocalHardwareInfo["gpus"]> {
try {
const { stdout } = await execFileAsync(
"nvidia-smi",
["--query-gpu=name,memory.total", "--format=csv,noheader,nounits"],
{ timeout: 3000, windowsHide: true },
);
return stdout
.split("\n")
.map((line) => line.split(",").map((part) => part.trim()))
.filter(([name, memoryMib]) => name && Number(memoryMib) > 0)
.map(([name, memoryMib]) => ({
name,
memoryGb: Number(memoryMib) / 1024,
}));
} catch {
return [];
}
}
async function detectHardware(): Promise<LocalHardwareInfo> {
const platform = os.platform();
const arch = os.arch();
const cpus = os.cpus();
// Apple Silicon GPUs share system memory, so there is no separate VRAM to query.
const unifiedMemory = platform === "darwin" && arch === "arm64";
return {
platform,
arch,
cpuModel: cpus[0]?.model.trim() ?? "",
cpuCores: cpus.length,
totalMemoryGb: os.totalmem() / BYTES_PER_GB,
unifiedMemory,
gpus: unifiedMemory ? [] : await detectNvidiaGpus(),
};
}
function readOllamaSettings(): { baseUrl: string; contextWindow?: number } {
const settings = new ProviderSettingsManager().getProviderSettings("ollama");
return {
baseUrl: settings?.baseUrl?.trim() ?? "",
contextWindow: settings?.contextWindow,
};
}
function createOllamaClient(): Ollama {
const host =
readOllamaSettings()
.baseUrl.replace(/\/+$/, "")
.replace(/\/(?:v1|api)$/, "") || DEFAULT_OLLAMA_BASE_URL;
return new Ollama({ host });
}
function readModel(args: Record<string, unknown> | undefined): string {
const model = typeof args?.model === "string" ? args.model.trim() : "";
if (!model) {
throw new Error("model is required");
}
return model;
}
async function getLocalModelsStatus(): Promise<LocalModelsStatus> {
hardwarePromise ??= detectHardware();
const hardware = await hardwarePromise;
const { baseUrl, contextWindow } = readOllamaSettings();
const base = {
hardware,
baseUrl,
contextWindow,
activePulls: [...activePulls.values()].map((pull) => pull.progress),
};
const client = createOllamaClient();
try {
const [{ version }, list, ps] = await Promise.all([
client.version(),
client.list(),
client.ps(),
]);
return {
...base,
ollamaReachable: true,
ollamaVersion: version,
installed: list.models.map((model) => ({
name: model.name,
sizeBytes: model.size,
parameterSize: model.details?.parameter_size ?? "",
quantization: model.details?.quantization_level ?? "",
})),
running: ps.models.map((model) => ({
name: model.name,
sizeBytes: model.size,
sizeVramBytes: model.size_vram,
expiresAtMs: model.expires_at
? new Date(model.expires_at).getTime()
: 0,
// Reported by Ollama 0.7+, but not yet in the SDK's types.
contextLength:
(model as { context_length?: number }).context_length ?? 0,
})),
};
} catch {
return { ...base, ollamaReachable: false, installed: [], running: [] };
}
}
function startOllamaPull(
ctx: SidecarContext,
model: string,
): OllamaPullProgress {
const existing = activePulls.get(model);
if (existing) {
return existing.progress;
}
let aborted = false;
let abortStream: (() => void) | undefined;
const pull = {
progress: { model, status: "starting", completed: 0, total: 0 },
abort: () => {
aborted = true;
abortStream?.();
},
};
activePulls.set(model, pull);
void (async () => {
let final: OllamaPullProgress = {
model,
status: "success",
completed: 0,
total: 0,
done: true,
};
try {
const stream = await createOllamaClient().pull({ model, stream: true });
abortStream = () => stream.abort();
if (aborted) {
stream.abort();
}
let lastNotifiedAt = 0;
for await (const part of stream) {
const statusChanged = part.status !== pull.progress.status;
pull.progress = {
model,
status: part.status,
// Layer-less status lines ("pulling manifest") carry no byte counts; keep the last ones.
completed:
part.completed ?? (statusChanged ? 0 : pull.progress.completed),
total: part.total ?? (statusChanged ? 0 : pull.progress.total),
};
const now = Date.now();
if (statusChanged || now - lastNotifiedAt >= PROGRESS_THROTTLE_MS) {
lastNotifiedAt = now;
broadcastEvent(ctx, OLLAMA_PULL_PROGRESS_EVENT, pull.progress);
}
}
} catch (error) {
final = {
...final,
status: "error",
error: aborted
? undefined
: error instanceof Error
? error.message
: String(error),
};
} finally {
activePulls.delete(model);
broadcastEvent(ctx, OLLAMA_PULL_PROGRESS_EVENT, final);
}
})();
return pull.progress;
}
export async function handleLocalModelsCommand(
ctx: SidecarContext,
command: string,
args: Record<string, unknown> | undefined,
): Promise<unknown> {
switch (command) {
case "get_local_models_status":
return await getLocalModelsStatus();
case "pull_ollama_model":
return startOllamaPull(ctx, readModel(args));
case "cancel_ollama_pull":
activePulls.get(readModel(args))?.abort();
return {};
case "load_ollama_model": {
// num_ctx must match what Cline sends with each request, otherwise
// Ollama reloads the model on the first message.
const contextWindow =
typeof args?.contextWindow === "number" ? args.contextWindow : 0;
await createOllamaClient().generate({
model: readModel(args),
prompt: "",
// Stay loaded until explicitly unloaded; requests without a keep_alive don't shorten it.
keep_alive: -1,
...(contextWindow > 0 ? { options: { num_ctx: contextWindow } } : {}),
});
return {};
}
case "unload_ollama_model":
await createOllamaClient().generate({
model: readModel(args),
prompt: "",
keep_alive: 0,
});
return {};
case "delete_ollama_model":
await createOllamaClient().delete({ model: readModel(args) });
return {};
default:
throw new Error(`Unsupported local models command: ${command}`);
}
}
@@ -14,6 +14,7 @@ import {
CircleUserRound,
Clock3,
Cloud,
Cpu,
Filter,
FolderTree,
GitFork,
@@ -154,6 +155,7 @@ function hubPort(url: string | null): string | null {
const SETTINGS_SECTION_ICONS = {
General: SlidersHorizontal,
Providers: Plug,
"Local Models": Cpu,
Voice: Mic,
Channels: Radio,
Schedules: Clock3,
@@ -55,6 +55,8 @@ import {
isSupportedImageAttachment,
} from "@/lib/image-attachments";
import {
MODEL_SELECTION_CHANGED_EVENT,
type ModelSelectionChangedDetail,
readModelSelectionStorageFromWindow,
readReasoningSelectionStorageFromWindow,
writeModelSelectionStorageToWindow,
@@ -2064,6 +2066,28 @@ const ModelSelector = memo(function ModelSelector({
}
}, [lastSelection, persistSelection]);
useEffect(() => {
if (includeCloudModels) return;
const handleSelectionChanged = (event: Event) => {
const { providerId, modelId } = (
event as CustomEvent<ModelSelectionChangedDetail>
).detail;
setLastSelection(readModelSelectionStorageFromWindow());
if (isBusy) return;
onProviderChange(providerId);
onModelChange(modelId);
};
window.addEventListener(
MODEL_SELECTION_CHANGED_EVENT,
handleSelectionChanged,
);
return () =>
window.removeEventListener(
MODEL_SELECTION_CHANGED_EVENT,
handleSelectionChanged,
);
}, [includeCloudModels, isBusy, onModelChange, onProviderChange]);
useEffect(() => {
if (providers.length === 0) {
return;
@@ -0,0 +1,617 @@
"use client";
import {
Download,
Loader2,
Play,
RefreshCw,
Square,
Trash2,
X,
} from "lucide-react";
import {
type ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { desktopClient, openExternalUrl } from "@/lib/desktop-client";
import {
DEFAULT_LOCAL_CONTEXT_WINDOW,
evaluateLocalModelFit,
getLocalMemoryBudget,
getRecommendedLocalModel,
LOCAL_MODEL_CATALOG,
type LocalHardwareInfo,
type LocalModelCatalogEntry,
type LocalModelFit,
type LocalModelsStatus,
normalizeOllamaModelName,
OLLAMA_PULL_PROGRESS_EVENT,
type OllamaPullProgress,
} from "@/lib/local-models";
import {
readModelSelectionStorageFromWindow,
selectChatModel,
} from "@/lib/model-selection";
import {
loadProviderModels,
publishProviderModels,
} from "@/lib/provider-model-catalog";
import { cn } from "@/lib/utils";
import { PageFrame, PageHeader } from "../page-layout";
type ModelAction = "using" | "loading" | "unloading" | "deleting";
const FIT_LABELS: Record<LocalModelFit, { text: string; className: string }> = {
great: {
text: "Runs well on this machine",
className: "text-emerald-600 dark:text-emerald-400",
},
slow: {
text: "Will run slowly on this machine",
className: "text-amber-700 dark:text-amber-300",
},
"too-large": {
text: "Too large for this machine",
className: "text-destructive",
},
};
const formatGb = (bytes: number) => `${(bytes / 1e9).toFixed(1)} GB`;
const formatContext = (tokens: number) =>
`${Math.round(tokens / 1024)}K context`;
function readActiveOllamaModel(): string | undefined {
const selection = readModelSelectionStorageFromWindow();
const model = selection.lastModelByProvider.ollama;
return selection.lastProvider === "ollama" && model
? normalizeOllamaModelName(model)
: undefined;
}
function describeHardware(hardware: LocalHardwareInfo): string {
const memory = `${Math.round(hardware.totalMemoryGb)} GB`;
if (hardware.unifiedMemory) {
return `${hardware.cpuModel} · ${memory} unified memory`;
}
if (hardware.gpus.length > 0) {
const gpus = hardware.gpus
.map((gpu) => `${gpu.name} (${Math.round(gpu.memoryGb)} GB)`)
.join(", ");
return `${gpus} · ${memory} RAM`;
}
return `${hardware.cpuModel} · ${memory} RAM · No supported GPU detected`;
}
function describeRunning(running: LocalModelsStatus["running"][number]) {
const placement =
running.sizeVramBytes <= 0
? "on CPU"
: running.sizeVramBytes >= running.sizeBytes
? "on GPU"
: `${Math.round((running.sizeVramBytes / running.sizeBytes) * 100)}% on GPU`;
const parts = [`Loaded ${placement}`, formatGb(running.sizeBytes)];
if (running.contextLength > 0) {
parts.push(formatContext(running.contextLength));
}
// Ollama reports models pinned with keep_alive -1 as expiring centuries from now.
const oneYearMs = 365 * 24 * 60 * 60 * 1000;
if (running.expiresAtMs - Date.now() >= oneYearMs) {
parts.push("stays loaded until unloaded");
} else if (running.expiresAtMs > 0) {
const time = new Date(running.expiresAtMs).toLocaleTimeString([], {
hour: "numeric",
minute: "2-digit",
});
parts.push(`unloads at ${time} if idle`);
}
return parts.join(" · ");
}
export function LocalModelsContent() {
const [status, setStatus] = useState<LocalModelsStatus>();
const [refreshing, setRefreshing] = useState(false);
const [pulls, setPulls] = useState<Record<string, OllamaPullProgress>>({});
const [actions, setActions] = useState<Record<string, ModelAction>>({});
const [errors, setErrors] = useState<Record<string, string>>({});
const [activeModel, setActiveModel] = useState(readActiveOllamaModel);
const setModelError = useCallback(
(model: string, message: string | undefined) => {
setErrors(({ [model]: _, ...rest }) =>
message ? { ...rest, [model]: message } : rest,
);
},
[],
);
const refresh = useCallback(async () => {
setRefreshing(true);
try {
const next = await desktopClient.invoke<LocalModelsStatus>(
"get_local_models_status",
);
setStatus(next);
// Downloads keep running in the backend while this page is closed.
setPulls((prev) =>
Object.fromEntries(
next.activePulls.map((pull) => [
pull.model,
prev[pull.model] ?? pull,
]),
),
);
} catch (error) {
console.error("Failed to read local models status:", error);
} finally {
setRefreshing(false);
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
useEffect(
() =>
desktopClient.subscribe(OLLAMA_PULL_PROGRESS_EVENT, (payload) => {
const progress = payload as OllamaPullProgress;
if (!progress.done) {
setPulls((prev) => ({ ...prev, [progress.model]: progress }));
return;
}
setPulls(({ [progress.model]: _, ...rest }) => rest);
setModelError(progress.model, progress.error);
void refresh();
}),
[refresh, setModelError],
);
// Loaded models can be unloaded by Ollama's idle timeout or from outside Cline.
useEffect(() => {
const onFocus = () => void refresh();
window.addEventListener("focus", onFocus);
return () => window.removeEventListener("focus", onFocus);
}, [refresh]);
const nextExpiryMs = useMemo(() => {
const expiries = (status?.running ?? [])
.map((model) => model.expiresAtMs)
.filter((ms) => ms > Date.now());
return expiries.length > 0 ? Math.min(...expiries) : undefined;
}, [status]);
useEffect(() => {
if (!nextExpiryMs || nextExpiryMs - Date.now() > 60 * 60 * 1000) {
return;
}
const timer = window.setTimeout(
() => void refresh(),
nextExpiryMs - Date.now() + 1000,
);
return () => window.clearTimeout(timer);
}, [nextExpiryMs, refresh]);
const runAction = useCallback(
async (model: string, action: ModelAction, run: () => Promise<unknown>) => {
setActions((prev) => ({ ...prev, [model]: action }));
setModelError(model, undefined);
try {
await run();
} catch (error) {
setModelError(
model,
error instanceof Error ? error.message : String(error),
);
} finally {
setActions(({ [model]: _, ...rest }) => rest);
void refresh();
}
},
[refresh, setModelError],
);
const startPull = useCallback(
async (model: string) => {
setModelError(model, undefined);
try {
const progress = await desktopClient.invoke<OllamaPullProgress>(
"pull_ollama_model",
{ model },
);
setPulls((prev) => ({ ...prev, [model]: prev[model] ?? progress }));
} catch (error) {
setModelError(
model,
error instanceof Error ? error.message : String(error),
);
}
},
[setModelError],
);
const contextWindowFor = (model: string) => {
if (model === activeModel && status?.contextWindow) {
return status.contextWindow;
}
return (
LOCAL_MODEL_CATALOG.find(
(entry) => normalizeOllamaModelName(entry.id) === model,
)?.contextWindow ?? DEFAULT_LOCAL_CONTEXT_WINDOW
);
};
const loadModel = (model: string, contextWindow: number) =>
// Loading a large model can take minutes.
desktopClient.invoke(
"load_ollama_model",
{ model, contextWindow },
{ timeoutMs: null },
);
const switchToModel = (model: string, contextWindow: number) =>
runAction(model, "using", async () => {
await desktopClient.invoke("save_provider_settings", {
provider: "ollama",
settings: { model, contextWindow },
});
// The composer only offers providers whose models it knows about.
publishProviderModels("ollama", await loadProviderModels("ollama"));
selectChatModel("ollama", model);
setActiveModel(model);
// Warm the model up so the first message doesn't wait on a cold load.
await loadModel(model, contextWindow);
});
const installed = useMemo(
() =>
new Map(
(status?.installed ?? []).map((model) => [
normalizeOllamaModelName(model.name),
model,
]),
),
[status],
);
const running = useMemo(
() =>
new Map(
(status?.running ?? []).map((model) => [
normalizeOllamaModelName(model.name),
model,
]),
),
[status],
);
const hardware = status?.hardware;
const recommended = hardware ? getRecommendedLocalModel(hardware) : undefined;
const catalogIds = new Set(
LOCAL_MODEL_CATALOG.map((entry) => normalizeOllamaModelName(entry.id)),
);
const otherInstalled = [...installed.entries()].filter(
([name]) => !catalogIds.has(name),
);
const reachable = status?.ollamaReachable ?? false;
const renderModelRow = (
model: string,
{
title,
entry,
details,
}: { title: string; entry?: LocalModelCatalogEntry; details: ReactNode },
) => {
const name = normalizeOllamaModelName(model);
const isInstalled = installed.has(name);
const runningModel = running.get(name);
const pull = pulls[model];
const action = actions[name];
const error = errors[model] ?? errors[name];
const isActive = name === activeModel;
const fit =
entry && hardware
? evaluateLocalModelFit(entry.memoryGb, hardware)
: undefined;
const pullPercent =
pull && pull.total > 0 ? (pull.completed / pull.total) * 100 : 0;
return (
<div
className="flex flex-col gap-2 border-b px-4 py-4 last:border-b-0"
key={model}
>
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-foreground">{title}</span>
{entry && entry === recommended ? (
<Badge>Recommended for you</Badge>
) : null}
{isActive ? (
<Badge
className="border-emerald-400/50 text-emerald-600 dark:text-emerald-400"
variant="outline"
>
In use
</Badge>
) : null}
{entry?.tags.map((tag) => (
<Badge key={tag} variant="muted">
{tag}
</Badge>
))}
</div>
{entry ? (
<p className="text-sm text-muted-foreground">{entry.description}</p>
) : null}
<p className="text-xs text-muted-foreground">
{details}
{fit ? (
<span className={FIT_LABELS[fit].className}>
{" "}
· {FIT_LABELS[fit].text}
</span>
) : null}
</p>
{runningModel ? (
<p className="flex items-center gap-1.5 text-xs text-foreground">
<span className="inline-block size-2 rounded-full bg-emerald-500" />
{describeRunning(runningModel)}
</p>
) : null}
{pull ? (
<div className="flex max-w-md flex-col gap-1">
<Progress className="h-1.5" value={pullPercent} />
<div className="flex justify-between gap-2 text-xs text-muted-foreground">
<span className="truncate">
{pull.status.startsWith("pulling") && pull.total > 0
? `Downloading ${formatGb(pull.completed)} of ${formatGb(pull.total)}`
: pull.status}
</span>
{pull.total > 0 ? <span>{Math.floor(pullPercent)}%</span> : null}
</div>
</div>
) : null}
{error ? (
<p className="break-words text-xs text-destructive" role="alert">
{error}
</p>
) : null}
<div className="mt-1 flex flex-wrap gap-2">
{!isInstalled && !pull ? (
<Button
disabled={!reachable}
onClick={() => void startPull(model)}
size="sm"
type="button"
variant={fit === "too-large" ? "outline" : "default"}
>
<Download />
Download{entry ? ` (${entry.sizeGb} GB)` : ""}
</Button>
) : null}
{pull ? (
<Button
onClick={() =>
void desktopClient.invoke("cancel_ollama_pull", { model })
}
size="sm"
type="button"
variant="outline"
>
<X />
Cancel
</Button>
) : null}
{isInstalled && !isActive ? (
<Button
disabled={!!action}
onClick={() =>
void switchToModel(
name,
entry?.contextWindow ?? DEFAULT_LOCAL_CONTEXT_WINDOW,
)
}
size="sm"
type="button"
>
{action === "using" ? <Loader2 className="animate-spin" /> : null}
Use with Cline
</Button>
) : null}
{isInstalled && !runningModel ? (
<Button
disabled={!!action}
onClick={() =>
void runAction(name, "loading", () =>
loadModel(name, contextWindowFor(name)),
)
}
size="sm"
type="button"
variant="outline"
>
{action === "loading" ? (
<Loader2 className="animate-spin" />
) : (
<Play />
)}
{action === "loading" ? "Loading…" : "Load"}
</Button>
) : null}
{runningModel ? (
<Button
disabled={!!action}
onClick={() =>
void runAction(name, "unloading", () =>
desktopClient.invoke("unload_ollama_model", { model: name }),
)
}
size="sm"
type="button"
variant="outline"
>
{action === "unloading" ? (
<Loader2 className="animate-spin" />
) : (
<Square />
)}
Unload
</Button>
) : null}
{isInstalled ? (
<Button
aria-label={`Delete ${title}`}
disabled={!!action}
onClick={() =>
void runAction(name, "deleting", () =>
desktopClient.invoke("delete_ollama_model", { model: name }),
)
}
size="sm"
title="Delete from disk"
type="button"
variant="ghost"
>
{action === "deleting" ? (
<Loader2 className="animate-spin" />
) : (
<Trash2 />
)}
</Button>
) : null}
</div>
</div>
);
};
const budget = hardware ? getLocalMemoryBudget(hardware) : undefined;
return (
<PageFrame>
<PageHeader
description={
<>
Run models on your own machine with Ollama. Download one, then click{" "}
<span className="font-medium text-foreground">Use with Cline</span>{" "}
to switch Cline to it with the right settings.
</>
}
title="Local Models"
/>
<section className="flex max-w-3xl flex-col gap-6">
{hardware ? (
<div className="flex flex-col gap-1 rounded-lg border bg-card px-4 py-3">
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Your machine
</span>
<span className="text-sm text-foreground">
{describeHardware(hardware)}
</span>
{budget ? (
<span className="text-xs text-muted-foreground">
{budget.gpuGb > 0
? `Models needing up to ~${Math.floor(budget.gpuGb)} GB run fully on the GPU.`
: "Without a supported GPU, models run on the CPU and respond slowly."}
</span>
) : null}
</div>
) : null}
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2 text-sm">
<span
className={cn(
"inline-block size-2 rounded-full",
!status
? "bg-muted-foreground/40"
: reachable
? "bg-emerald-500"
: "bg-destructive",
)}
/>
<span className="flex-1 text-foreground">
{!status
? "Checking for Ollama…"
: reachable
? `Ollama ${status.ollamaVersion} is running`
: "Ollama isn't running"}
</span>
<Button
aria-label="Refresh"
disabled={refreshing}
onClick={() => void refresh()}
size="icon"
type="button"
variant="ghost"
>
<RefreshCw className={refreshing ? "animate-spin" : ""} />
</Button>
</div>
{status && !reachable ? (
<p className="text-sm text-muted-foreground">
<button
className="text-primary underline-offset-4 hover:underline"
onClick={() =>
void openExternalUrl("https://ollama.com/download")
}
type="button"
>
Install Ollama
</button>{" "}
and start it (or run <code>ollama serve</code>). Cline looks for
it at {status.baseUrl || "http://localhost:11434"}, which you can
change under Providers → Ollama.
</p>
) : null}
</div>
<div>
<p className="mb-2 text-sm font-semibold text-foreground">
Recommended models
</p>
<div className="overflow-hidden rounded-lg border">
{LOCAL_MODEL_CATALOG.map((entry) =>
renderModelRow(entry.id, {
title: entry.name,
entry,
details: `${entry.id} · ${entry.sizeGb} GB download · needs ~${entry.memoryGb} GB memory · ${formatContext(entry.contextWindow)}`,
}),
)}
</div>
</div>
{otherInstalled.length > 0 ? (
<div>
<p className="mb-2 text-sm font-semibold text-foreground">
Other downloaded models
</p>
<div className="overflow-hidden rounded-lg border">
{otherInstalled.map(([name, model]) =>
renderModelRow(name, {
title: model.name,
details: [
formatGb(model.sizeBytes),
model.parameterSize,
model.quantization,
]
.filter(Boolean)
.join(" · "),
}),
)}
</div>
</div>
) : null}
<p className="text-xs text-muted-foreground">
Models loaded here stay in memory until you unload them. A model
Ollama loads on its own, when a message arrives for it, is unloaded
after a few minutes idle (configurable with{" "}
<code>OLLAMA_KEEP_ALIVE</code>). The server URL can be changed under
Providers → Ollama.
</p>
</section>
</PageFrame>
);
}
@@ -8,6 +8,7 @@
const ALL_SETTINGS_SECTIONS = [
"General",
"Providers",
"Local Models",
"Voice",
"Channels",
"Schedules",
@@ -67,6 +67,7 @@ import { AddProviderContent, type AddProviderPayload } from "./add-provider";
import { ChannelsContent } from "./channels-view";
import { CustomizeView } from "./customize-view";
import { ImportContent } from "./import-view";
import { LocalModelsContent } from "./local-models-view";
import { NotificationSettings } from "./notification-settings";
import {
ProviderDetailContent,
@@ -608,6 +609,8 @@ export function SettingsView({
{providerContent}
{addProviderDialog}
</>
) : activeNav === "Local Models" ? (
<LocalModelsContent />
) : activeNav === "Voice" ? (
<VoiceInputContent
onOpenModelProviders={() => onNavigateSection("Providers")}
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import {
evaluateLocalModelFit,
getRecommendedLocalModel,
normalizeOllamaModelName,
} from "./local-models";
const mac = (totalMemoryGb: number) => ({
totalMemoryGb,
unifiedMemory: true,
gpus: [],
});
const pc = (totalMemoryGb: number, vramGb?: number) => ({
totalMemoryGb,
unifiedMemory: false,
gpus: vramGb ? [{ name: "GPU", memoryGb: vramGb }] : [],
});
describe("local models", () => {
it("rates fit against GPU memory first, then system memory", () => {
expect(evaluateLocalModelFit(23, pc(64, 24))).toBe("great");
expect(evaluateLocalModelFit(23, pc(32, 12))).toBe("slow");
expect(evaluateLocalModelFit(56, pc(16, 8))).toBe("too-large");
expect(evaluateLocalModelFit(10, pc(32))).toBe("slow");
});
it("recommends Qwen3.8 27B when it fits on the GPU", () => {
expect(getRecommendedLocalModel(mac(32))?.id).toBe("qwen3.8:27b");
expect(getRecommendedLocalModel(pc(32, 24))?.id).toBe("qwen3.8:27b");
});
it("falls back to smaller models on smaller machines", () => {
expect(getRecommendedLocalModel(mac(24))?.id).toBe("gpt-oss:20b");
expect(getRecommendedLocalModel(mac(16))?.id).toBe("qwen3:8b");
});
it("prefers mixture-of-experts models when only the CPU can run them", () => {
expect(getRecommendedLocalModel(pc(32))?.id).toBe("gpt-oss:20b");
expect(getRecommendedLocalModel(pc(4))).toBeUndefined();
});
it("treats untagged Ollama names as :latest", () => {
expect(normalizeOllamaModelName("laguna-xs-2.1")).toBe(
"laguna-xs-2.1:latest",
);
expect(normalizeOllamaModelName("qwen3.8:27b")).toBe("qwen3.8:27b");
});
});
@@ -0,0 +1,198 @@
/**
* Curated Ollama models that work well with Cline, each with the context
* window Cline should run it at. Ordered by how strongly we recommend them, so
* the first one that fits a machine is its recommendation.
*
* `memoryGb` estimates weights plus KV cache at `contextWindow`; it is what
* decides whether a model fits on the user's hardware.
*/
export interface LocalModelCatalogEntry {
id: string;
name: string;
description: string;
sizeGb: number;
memoryGb: number;
contextWindow: number;
tags: string[];
}
export const LOCAL_MODEL_CATALOG: LocalModelCatalogEntry[] = [
{
id: "qwen3.8:27b",
name: "Qwen3.8 27B",
description:
"The strongest all-round local model for agentic coding. Reliable tool use across long tasks.",
sizeGb: 18,
memoryGb: 23,
contextWindow: 65536,
tags: ["Best overall", "Vision"],
},
{
id: "laguna-xs-2.1",
name: "Laguna XS 2.1",
description:
"Mixture-of-experts model from Poolside built for long-horizon coding. Only 3B active parameters, so it's fast.",
sizeGb: 20,
memoryGb: 24,
contextWindow: 65536,
tags: ["Fast"],
},
{
id: "qwen3.6:35b-a3b",
name: "Qwen3.6 35B A3B",
description:
"Fast mixture-of-experts Qwen with solid agentic coding. A good pick when speed matters more than depth.",
sizeGb: 24,
memoryGb: 26,
contextWindow: 65536,
tags: ["Fast", "Vision"],
},
{
id: "devstral-small-2:24b",
name: "Devstral Small 2",
description:
"Mistral's agentic coding model, tuned for exploring codebases and editing many files.",
sizeGb: 15,
memoryGb: 21,
contextWindow: 32768,
tags: ["Vision"],
},
{
id: "gpt-oss:20b",
name: "gpt-oss 20B",
description:
"OpenAI's open-weight reasoning model. Light on memory and quick, a great fit for 16-24 GB machines.",
sizeGb: 14,
memoryGb: 16,
contextWindow: 65536,
tags: ["Fast"],
},
{
id: "qwen3-coder-next",
name: "Qwen3-Coder-Next",
description:
"80B mixture-of-experts coder with 3B active parameters, for machines with 64 GB or more.",
sizeGb: 52,
memoryGb: 56,
contextWindow: 65536,
tags: ["Fast"],
},
{
id: "qwen3:8b",
name: "Qwen3 8B",
description:
"Small enough for 8-16 GB machines. Handles simple edits, but struggles with larger multi-step tasks.",
sizeGb: 5.2,
memoryGb: 10,
contextWindow: 32768,
tags: ["Lightweight"],
},
];
/** Context window for installed models that aren't in the catalog; matches the SDK's Ollama default. */
export const DEFAULT_LOCAL_CONTEXT_WINDOW = 32768;
export interface LocalHardwareInfo {
platform: string;
arch: string;
cpuModel: string;
cpuCores: number;
totalMemoryGb: number;
unifiedMemory: boolean;
gpus: { name: string; memoryGb: number }[];
}
export interface OllamaPullProgress {
model: string;
status: string;
completed: number;
total: number;
/** Set on the final event of a download. */
done?: boolean;
error?: string;
}
/** Payload of `get_local_models_status`. */
export interface LocalModelsStatus {
hardware: LocalHardwareInfo;
/** The Ollama provider's saved base URL and context window. */
baseUrl: string;
contextWindow?: number;
ollamaReachable: boolean;
ollamaVersion?: string;
installed: {
name: string;
sizeBytes: number;
parameterSize: string;
quantization: string;
}[];
running: {
name: string;
sizeBytes: number;
sizeVramBytes: number;
expiresAtMs: number;
contextLength: number;
}[];
activePulls: OllamaPullProgress[];
}
export const OLLAMA_PULL_PROGRESS_EVENT = "ollama_pull_progress";
/**
* - `great`: fits entirely in GPU (or Apple unified) memory
* - `slow`: only fits by spilling onto the CPU
* - `too-large`: doesn't fit in memory at all
*/
export type LocalModelFit = "great" | "slow" | "too-large";
type LocalHardware = Pick<
LocalHardwareInfo,
"totalMemoryGb" | "unifiedMemory" | "gpus"
>;
export function getLocalMemoryBudget(hardware: LocalHardware): {
gpuGb: number;
totalGb: number;
} {
if (hardware.unifiedMemory) {
// macOS lets the GPU use roughly three quarters of unified memory.
return {
gpuGb: hardware.totalMemoryGb * 0.75,
totalGb: hardware.totalMemoryGb * 0.85,
};
}
const gpuGb = hardware.gpus.reduce((sum, gpu) => sum + gpu.memoryGb, 0);
return { gpuGb, totalGb: gpuGb + hardware.totalMemoryGb * 0.7 };
}
export function evaluateLocalModelFit(
memoryGb: number,
hardware: LocalHardware,
): LocalModelFit {
const { gpuGb, totalGb } = getLocalMemoryBudget(hardware);
if (memoryGb <= gpuGb) {
return "great";
}
return memoryGb <= totalGb ? "slow" : "too-large";
}
export function getRecommendedLocalModel(
hardware: LocalHardware,
): LocalModelCatalogEntry | undefined {
const great = LOCAL_MODEL_CATALOG.find(
(entry) => evaluateLocalModelFit(entry.memoryGb, hardware) === "great",
);
if (great) {
return great;
}
// Mixture-of-experts models only compute a few billion parameters per token, so they hold up far better on CPU.
const slow = LOCAL_MODEL_CATALOG.filter(
(entry) => evaluateLocalModelFit(entry.memoryGb, hardware) === "slow",
);
return slow.find((entry) => entry.tags.includes("Fast")) ?? slow[0];
}
/** Ollama treats an untagged name as `:latest`. */
export function normalizeOllamaModelName(name: string): string {
return name.includes(":") ? name : `${name}:latest`;
}
@@ -87,6 +87,34 @@ export function writeModelSelectionStorageToWindow(
);
}
export const MODEL_SELECTION_CHANGED_EVENT = "cline:model-selection-changed";
export type ModelSelectionChangedDetail = {
providerId: string;
modelId: string;
};
/**
* Makes a model the default for new chats and switches the open composer to
* it, for selections made outside the composer (e.g. Settings → Local Models).
*/
export function selectChatModel(providerId: string, modelId: string): void {
const selection = readModelSelectionStorageFromWindow();
writeModelSelectionStorageToWindow({
lastProvider: providerId,
lastModelByProvider: {
...selection.lastModelByProvider,
[providerId]: modelId,
},
});
window.dispatchEvent(
new CustomEvent<ModelSelectionChangedDetail>(
MODEL_SELECTION_CHANGED_EVENT,
{ detail: { providerId, modelId } },
),
);
}
export const REASONING_SELECTION_STORAGE_KEY =
"cline.code.reasoning-selection.v1";
+1
View File
@@ -221,6 +221,7 @@
"mermaid": "11.16.1",
"next": "16.2.11",
"next-themes": "^0.4.6",
"ollama": "^0.6.4",
"pino": "^10.3.1",
"posthog-node": "^5.8.0",
"radix-ui": "^1.4.3",