mirror of
https://github.com/cline/cline.git
synced 2026-10-24 02:02:27 +08:00
feat(desktop): add Local Models settings page for managing Ollama models
This commit is contained in:
@@ -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";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user