Skip to content

Commit 157867d

Browse files
committed
Add custom model storage location, harden MCP spawn-failure handling
- Settings -> Ollama Server -> Model storage location lets users pick where Ollama downloads/stores models (sets OLLAMA_MODELS and restarts the managed Ollama process; warns instead if Ollama is running externally, since we can't safely restart a process we didn't start). - Stability: an MCP stdio server that fails to launch (bad command) now fails its initialize/tools-list request immediately instead of hanging for the full 30s timeout, since child_process only emits error (not exit) for that case.
1 parent 1eae5b9 commit 157867d

10 files changed

Lines changed: 215 additions & 2 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ Beyond chat, Modelforge includes an **agentic mode** — the model can read/writ
4848
- **GPU offload control** — set how many model layers Ollama offloads to GPU (`num_gpu`) per chat, per project, or as a global default; leave it blank to let Ollama decide automatically.
4949
- **Pull models directly from Hugging Face** — paste any GGUF model's Hugging Face URL (or `hf.co/user/repo`) into the model search box alongside the curated catalog and Ollama's own library.
5050
- Models with reliable tool/function-calling support are flagged with a 🔧 badge, so picking a good Agent mode model doesn't require guesswork.
51+
- **Custom model storage location** — Settings → Ollama Server → "Model storage location" lets you point downloaded models at any folder (e.g. a larger or faster drive) instead of Ollama's default location. If this app started Ollama, it restarts it automatically with the new location; if Ollama is running outside the app, you're told to restart it yourself.
5152

5253
**Customization & control**
5354
- English and Turkish UI localization.

‎app/src/main.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import * as path from "node:path";
2+
import * as fs from "node:fs";
23
import { app, BrowserWindow, ipcMain, IpcMainInvokeEvent, dialog, shell } from "electron";
34
import * as ollama from "./ollama-manager";
45
import { logger, getLogPath, getLogTail } from "./logger";
@@ -150,6 +151,34 @@ function registerIpcHandlers(): void {
150151
ipcMain.handle("ollama:start", () => ollama.start());
151152
ipcMain.handle("ollama:stop", () => ollama.stop());
152153
ipcMain.handle("ollama:listModels", () => ollama.listModels());
154+
ipcMain.handle("ollama:pickModelsDir", async () => {
155+
const result = mainWindow
156+
? await dialog.showOpenDialog(mainWindow, { properties: ["openDirectory", "createDirectory"] })
157+
: await dialog.showOpenDialog({ properties: ["openDirectory", "createDirectory"] });
158+
return result.canceled || result.filePaths.length === 0 ? null : result.filePaths[0];
159+
});
160+
161+
ipcMain.handle("ollama:setModelsDir", async (_event: IpcMainInvokeEvent, dir: string | null) => {
162+
if (dir) {
163+
requireString(dir, "models directory");
164+
try {
165+
fs.mkdirSync(dir, { recursive: true });
166+
fs.accessSync(dir, fs.constants.W_OK);
167+
} catch (err) {
168+
return { error: `Can't use that folder: ${(err as Error).message}` };
169+
}
170+
}
171+
settingsStore.saveSettings({ modelsDir: dir ?? undefined });
172+
ollama.setModelsDir(dir);
173+
try {
174+
return await ollama.restartWithCurrentConfig();
175+
} catch (err) {
176+
const error = err as Error;
177+
logger.error(`Failed to restart Ollama after changing models directory: ${error.message}`);
178+
return { started: false, error: error.message };
179+
}
180+
});
181+
153182
ipcMain.handle("ollama:deleteModel", (_event: IpcMainInvokeEvent, name: string) =>
154183
ollama.deleteModel(requireString(name, "model name"))
155184
);
@@ -408,6 +437,7 @@ app.whenReady().then(async () => {
408437
setupMenu(() => mainWindow, () => checkForUpdatesManually(() => mainWindow));
409438
createWindow();
410439
ollama.setHost(settingsStore.getSettings().ollamaHost);
440+
ollama.setModelsDir(settingsStore.getSettings().modelsDir);
411441
await ollama.start();
412442
setupAutoUpdater(() => mainWindow);
413443
void connectEnabledMcpServers();

‎app/src/mcp-client.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,13 @@ async function connectStdio(config: McpServerConfig): Promise<Connection> {
105105
conn.pending.clear();
106106
});
107107
proc.on("error", (err) => {
108+
// Fires for launch failures (e.g. command not found) — unlike a normal
109+
// exit, Node doesn't also emit "exit" in that case, so without this the
110+
// pending initialize/tools-list request would just sit until its 30s
111+
// timeout instead of failing immediately with a useful message.
108112
conn.lastError = err.message;
113+
for (const { reject } of conn.pending.values()) reject(err);
114+
conn.pending.clear();
109115
});
110116

111117
try {

‎app/src/ollama-manager.test.ts‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { describe, it, expect } from "vitest";
2+
import { setHost, getHost, setModelsDir, getModelsDir } from "./ollama-manager";
3+
4+
describe("ollama-manager config", () => {
5+
it("defaults to the local Ollama host", () => {
6+
setHost(undefined);
7+
expect(getHost()).toBe("http://127.0.0.1:11434");
8+
});
9+
10+
it("trims a custom host and strips trailing slashes", () => {
11+
setHost(" http://example.com:11434/ ");
12+
expect(getHost()).toBe("http://example.com:11434");
13+
setHost(undefined); // reset for other tests
14+
});
15+
16+
it("falls back to the default host for an empty string", () => {
17+
setHost("");
18+
expect(getHost()).toBe("http://127.0.0.1:11434");
19+
});
20+
21+
it("has no models directory override by default", () => {
22+
setModelsDir(undefined);
23+
expect(getModelsDir()).toBeUndefined();
24+
});
25+
26+
it("stores a trimmed models directory", () => {
27+
setModelsDir(" /data/models ");
28+
expect(getModelsDir()).toBe("/data/models");
29+
setModelsDir(undefined); // reset for other tests
30+
});
31+
32+
it("clears the models directory override for an empty string", () => {
33+
setModelsDir("");
34+
expect(getModelsDir()).toBeUndefined();
35+
});
36+
});

‎app/src/ollama-manager.ts‎

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { ChatMessage, ChatChunk, ChatOptions, ToolDefinition } from "./prov
55

66
const DEFAULT_HOST = "http://127.0.0.1:11434";
77
let HOST = DEFAULT_HOST;
8+
let MODELS_DIR: string | undefined;
89
let child: ChildProcess | null = null;
910
let weStartedIt = false;
1011

@@ -16,6 +17,17 @@ export function getHost(): string {
1617
return HOST;
1718
}
1819

20+
// Ollama reads its models directory from the OLLAMA_MODELS env var at
21+
// process launch — there's no way to change it for an already-running
22+
// server, so this only takes effect the next time we spawn `ollama serve`.
23+
export function setModelsDir(dir: string | null | undefined): void {
24+
MODELS_DIR = dir && dir.trim() ? dir.trim() : undefined;
25+
}
26+
27+
export function getModelsDir(): string | undefined {
28+
return MODELS_DIR;
29+
}
30+
1931
function isLocalHost(): boolean {
2032
try {
2133
const { hostname } = new URL(HOST);
@@ -71,7 +83,10 @@ export async function start(): Promise<OllamaStartResult> {
7183
}
7284

7385
try {
74-
child = spawn("ollama", ["serve"], { stdio: "ignore" });
86+
child = spawn("ollama", ["serve"], {
87+
stdio: "ignore",
88+
env: MODELS_DIR ? { ...process.env, OLLAMA_MODELS: MODELS_DIR } : process.env,
89+
});
7590
weStartedIt = true;
7691

7792
child.on("exit", () => {
@@ -104,6 +119,29 @@ export function stop(): void {
104119
}
105120
}
106121

122+
export interface RestartResult extends OllamaStartResult {
123+
// true when Ollama is running but wasn't launched by this app (started
124+
// externally, or from before the models directory was changed this
125+
// session) — we can't safely kill a process we didn't start, so the new
126+
// location won't take effect until the user restarts Ollama themselves.
127+
external?: boolean;
128+
}
129+
130+
export async function restartWithCurrentConfig(): Promise<RestartResult> {
131+
if (child && weStartedIt) {
132+
stop();
133+
for (let i = 0; i < 10; i++) {
134+
await sleep(300);
135+
if (!(await isRunning())) break;
136+
}
137+
return start();
138+
}
139+
if (await isRunning()) {
140+
return { started: false, external: true };
141+
}
142+
return start();
143+
}
144+
107145
// Ollama error responses are JSON like {"error": "..."} — surface that text
108146
// instead of just the HTTP status so users see something actionable
109147
// ("model 'foo' not found" beats "Failed to list models: 404").

‎app/src/preload.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { contextBridge, ipcRenderer } from "electron";
2-
import type { PullProgress } from "./ollama-manager";
2+
import type { PullProgress, RestartResult } from "./ollama-manager";
33
import type { AttachedFile, MediaAttachment } from "./file-reader";
44
import type { ChatMessage, ChatChunk, ChatOptions, ProviderId } from "./providers/types";
55
import type { McpServerConfig, McpServerStatus } from "./mcp-client";
@@ -26,6 +26,8 @@ contextBridge.exposeInMainWorld("api", {
2626
stop: () => ipcRenderer.invoke("ollama:stop"),
2727
listModels: () => ipcRenderer.invoke("ollama:listModels"),
2828
deleteModel: (name: string) => ipcRenderer.invoke("ollama:deleteModel", name),
29+
pickModelsDir: (): Promise<string | null> => ipcRenderer.invoke("ollama:pickModelsDir"),
30+
setModelsDir: (dir: string | null): Promise<RestartResult> => ipcRenderer.invoke("ollama:setModelsDir", dir),
2931

3032
pullModel: (name: string, onProgress: (chunk: PullProgress) => void) => {
3133
const requestId = randomId();

‎app/src/settings-store.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ export interface PromptPreset {
2323
export interface AppSettings {
2424
defaultModel: string | null;
2525
ollamaHost: string;
26+
// undefined = Ollama's own default location. Only takes effect the next
27+
// time this app (re)starts a local `ollama serve` process.
28+
modelsDir?: string;
2629
temperature: number;
2730
topP: number;
2831
maxTokens: number;

‎frontend/src/lib/translations.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,13 @@ export interface Dictionary {
114114
runLint: string;
115115
runFormat: string;
116116
newFile: string;
117+
modelsDir: string;
118+
modelsDirHint: string;
119+
modelsDirDefault: string;
120+
chooseFolder: string;
121+
modelsDirApplied: string;
122+
modelsDirFailed: string;
123+
modelsDirExternalWarning: string;
117124
resetToDefault: string;
118125
usingCustomPrompt: string;
119126
dataManagement: string;
@@ -266,6 +273,13 @@ export const en: Dictionary = {
266273
runLint: "Lint",
267274
runFormat: "Format",
268275
newFile: "New file",
276+
modelsDir: "Model storage location",
277+
modelsDirHint: "Where Ollama downloads and stores model files. Changing this restarts Ollama (only if this app started it) — running downloads will be interrupted.",
278+
modelsDirDefault: "Default (Ollama's own location)",
279+
chooseFolder: "Choose folder...",
280+
modelsDirApplied: "Applied — Ollama restarted with the new location.",
281+
modelsDirFailed: "Couldn't restart Ollama with the new location. Try starting it manually.",
282+
modelsDirExternalWarning: "Saved, but Ollama is running outside this app — restart it manually (with OLLAMA_MODELS set to this folder) for the change to take effect.",
269283
resetToDefault: "Reset to default",
270284
usingCustomPrompt: "Custom prompt for this chat",
271285
dataManagement: "Data management",
@@ -426,6 +440,13 @@ export const tr: Dictionary = {
426440
runLint: "Lint",
427441
runFormat: "Biçimlendir",
428442
newFile: "Yeni dosya",
443+
modelsDir: "Model depolama konumu",
444+
modelsDirHint: "Ollama'nın model dosyalarını indirdiği ve sakladığı yer. Bunu değiştirmek Ollama'yı yeniden başlatır (yalnızca bu uygulama başlattıysa) — devam eden indirmeler kesintiye uğrar.",
445+
modelsDirDefault: "Varsayılan (Ollama'nın kendi konumu)",
446+
chooseFolder: "Klasör seç...",
447+
modelsDirApplied: "Uygulandı — Ollama yeni konumla yeniden başlatıldı.",
448+
modelsDirFailed: "Ollama yeni konumla yeniden başlatılamadı. Elle başlatmayı deneyin.",
449+
modelsDirExternalWarning: "Kaydedildi, ancak Ollama bu uygulamanın dışında çalışıyor — değişikliğin etkili olması için Ollama'yı bu klasör OLLAMA_MODELS olarak ayarlanmış şekilde elle yeniden başlatın.",
429450
resetToDefault: "Varsayılana dön",
430451
usingCustomPrompt: "Bu sohbet için özel istem",
431452
dataManagement: "Veri yönetimi",

‎frontend/src/pages/Settings.tsx‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,8 @@ export default function Settings() {
110110
const [userDataPath, setUserDataPath] = useState<string | null>(null);
111111
const [importMessage, setImportMessage] = useState<string | null>(null);
112112
const [ollamaHostInput, setOllamaHostInput] = useState("");
113+
const [modelsDirStatus, setModelsDirStatus] = useState<string | null>(null);
114+
const [changingModelsDir, setChangingModelsDir] = useState(false);
113115
const [newPresetName, setNewPresetName] = useState("");
114116
const [editingPresetId, setEditingPresetId] = useState<string | null>(null);
115117
const [editDraftName, setEditDraftName] = useState("");
@@ -210,6 +212,40 @@ export default function Settings() {
210212
refreshInstalled();
211213
}
212214

215+
async function chooseModelsDir() {
216+
const dir = await window.api.ollama.pickModelsDir();
217+
if (!dir) return;
218+
setChangingModelsDir(true);
219+
setModelsDirStatus(null);
220+
const result = await window.api.ollama.setModelsDir(dir);
221+
if (result.error) {
222+
setModelsDirStatus(result.error);
223+
} else {
224+
const updated = await window.api.settings.get();
225+
setSettings(updated);
226+
setModelsDirStatus(
227+
result.external ? t.modelsDirExternalWarning : result.started || result.alreadyRunning ? t.modelsDirApplied : t.modelsDirFailed
228+
);
229+
window.api.ollama.status().then(setRunning);
230+
refreshInstalled();
231+
}
232+
setChangingModelsDir(false);
233+
}
234+
235+
async function resetModelsDir() {
236+
setChangingModelsDir(true);
237+
setModelsDirStatus(null);
238+
const result = await window.api.ollama.setModelsDir(null);
239+
setModelsDirStatus(
240+
result.external ? t.modelsDirExternalWarning : result.started || result.alreadyRunning ? t.modelsDirApplied : t.modelsDirFailed
241+
);
242+
const updated = await window.api.settings.get();
243+
setSettings(updated);
244+
window.api.ollama.status().then(setRunning);
245+
refreshInstalled();
246+
setChangingModelsDir(false);
247+
}
248+
213249
async function toggleServer() {
214250
if (running) {
215251
await window.api.ollama.stop();
@@ -454,6 +490,39 @@ export default function Settings() {
454490
</Button>
455491
</div>
456492
</SettingsRow>
493+
<SettingsRow label={t.modelsDir} description={t.modelsDirHint} stacked>
494+
<div className="flex flex-wrap items-center gap-2">
495+
<span className="truncate rounded border border-border bg-muted px-2 py-1 font-mono text-xs">
496+
{settings?.modelsDir ?? t.modelsDirDefault}
497+
</span>
498+
<Button
499+
size="sm"
500+
variant="outline"
501+
disabled={changingModelsDir}
502+
onClick={chooseModelsDir}
503+
className="gap-1.5"
504+
>
505+
{changingModelsDir ? (
506+
<Loader2 className="size-3.5 animate-spin" />
507+
) : (
508+
<FolderOpen className="size-3.5" />
509+
)}
510+
{t.chooseFolder}
511+
</Button>
512+
{settings?.modelsDir && (
513+
<Button
514+
size="sm"
515+
variant="ghost"
516+
disabled={changingModelsDir}
517+
onClick={resetModelsDir}
518+
className="text-xs text-muted-foreground"
519+
>
520+
{t.resetToDefault}
521+
</Button>
522+
)}
523+
</div>
524+
{modelsDirStatus && <p className="text-xs text-muted-foreground">{modelsDirStatus}</p>}
525+
</SettingsRow>
457526
</SettingsSection>
458527

459528
<SettingsSection title={t.appearance}>

‎frontend/src/types/electron.d.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ export interface AppSettings {
145145
ttsVoiceURI?: string;
146146
ttsAutoRead?: boolean;
147147
mcpServers?: McpServerConfig[];
148+
modelsDir?: string;
148149
}
149150

150151
export interface ChatOptions {
@@ -167,6 +168,10 @@ export interface OllamaStartResult {
167168
error?: string;
168169
}
169170

171+
export interface RestartResult extends OllamaStartResult {
172+
external?: boolean;
173+
}
174+
170175
export interface ChatSession {
171176
id: string;
172177
title: string;
@@ -244,6 +249,8 @@ export interface ElectronApi {
244249
stop: () => Promise<void>;
245250
listModels: () => Promise<OllamaModel[]>;
246251
deleteModel: (name: string) => Promise<{ deleted: boolean }>;
252+
pickModelsDir: () => Promise<string | null>;
253+
setModelsDir: (dir: string | null) => Promise<RestartResult>;
247254
pullModel: (name: string, onProgress: (chunk: PullProgress) => void) => Promise<{ done: boolean; error?: string }>;
248255
};
249256
chat: {

0 commit comments

Comments
 (0)