groove-dev 0.19.2 → 0.19.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (24) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/node_modules/@groove-dev/cli/bin/groove.js +1 -1
  3. package/node_modules/@groove-dev/daemon/src/api.js +68 -0
  4. package/node_modules/@groove-dev/daemon/src/providers/ollama.js +164 -26
  5. package/node_modules/@groove-dev/gui/.groove/codebase-index.json +3 -3
  6. package/node_modules/@groove-dev/gui/.groove/daemon.host +1 -0
  7. package/node_modules/@groove-dev/gui/.groove/daemon.pid +1 -0
  8. package/node_modules/@groove-dev/gui/.groove/state.json +1 -1
  9. package/node_modules/@groove-dev/gui/.groove/timeline.json +504 -0
  10. package/node_modules/@groove-dev/gui/AGENTS_REGISTRY.md +9 -0
  11. package/node_modules/@groove-dev/gui/dist/assets/{index-DCakRxDE.css → index-DgIS4D-j.css} +1 -1
  12. package/node_modules/@groove-dev/gui/dist/assets/{index-DbujgqhS.js → index-eysbqREF.js} +123 -123
  13. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  14. package/node_modules/@groove-dev/gui/src/components/agents/agent-config.jsx +9 -3
  15. package/node_modules/@groove-dev/gui/src/components/agents/ollama-setup.jsx +375 -0
  16. package/package.json +1 -1
  17. package/packages/cli/bin/groove.js +1 -1
  18. package/packages/daemon/src/api.js +68 -0
  19. package/packages/daemon/src/providers/ollama.js +164 -26
  20. package/packages/gui/dist/assets/{index-DCakRxDE.css → index-DgIS4D-j.css} +1 -1
  21. package/packages/gui/dist/assets/{index-DbujgqhS.js → index-eysbqREF.js} +123 -123
  22. package/packages/gui/dist/index.html +2 -2
  23. package/packages/gui/src/components/agents/agent-config.jsx +9 -3
  24. package/packages/gui/src/components/agents/ollama-setup.jsx +375 -0
@@ -5,12 +5,12 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <link rel="icon" type="image/png" href="/favicon.png" />
7
7
  <title>Groove GUI</title>
8
- <script type="module" crossorigin src="/assets/index-DbujgqhS.js"></script>
8
+ <script type="module" crossorigin src="/assets/index-eysbqREF.js"></script>
9
9
  <link rel="modulepreload" crossorigin href="/assets/vendor-C0HXlhrU.js">
10
10
  <link rel="modulepreload" crossorigin href="/assets/reactflow-BQPfi37R.js">
11
11
  <link rel="modulepreload" crossorigin href="/assets/codemirror-BBL3i_JW.js">
12
12
  <link rel="modulepreload" crossorigin href="/assets/xterm--7_ns2zW.js">
13
- <link rel="stylesheet" crossorigin href="/assets/index-DCakRxDE.css">
13
+ <link rel="stylesheet" crossorigin href="/assets/index-DgIS4D-j.css">
14
14
  </head>
15
15
  <body>
16
16
  <div id="root"></div>
@@ -14,6 +14,7 @@ import { FolderBrowser } from './folder-browser';
14
14
  import { api } from '../../lib/api';
15
15
  import { cn } from '../../lib/cn';
16
16
  import { timeAgo } from '../../lib/format';
17
+ import { OllamaSetup } from './ollama-setup';
17
18
 
18
19
  /* ── Segmented Control ─────────────────────────────────────── */
19
20
 
@@ -318,10 +319,15 @@ export function AgentConfig({ agent }) {
318
319
  </button>
319
320
 
320
321
  {/* Expanded: models + key management */}
321
- {isExpanded && (
322
+ {isExpanded && p.authType === 'local' && (
322
323
  <div className="border-t border-border-subtle">
323
- {/* API Key row — skip for local providers like Ollama */}
324
- {p.authType !== 'local' && (!available || p.hasKey) && (
324
+ <OllamaSetup isInstalled={available} onModelChange={loadProviders} />
325
+ </div>
326
+ )}
327
+ {isExpanded && p.authType !== 'local' && (
328
+ <div className="border-t border-border-subtle">
329
+ {/* API Key row */}
330
+ {(!available || p.hasKey) && (
325
331
  <div className="px-3 py-2 bg-surface-1/50">
326
332
  {settingKeyFor === p.id ? (
327
333
  <div className="flex gap-1.5">
@@ -0,0 +1,375 @@
1
+ // FSL-1.1-Apache-2.0 — see LICENSE
2
+ import { useState, useEffect } from 'react';
3
+ import {
4
+ Download, Check, Cpu, HardDrive, RefreshCw, Copy,
5
+ Trash2, ChevronDown, Star, Zap, AlertCircle, Monitor,
6
+ } from 'lucide-react';
7
+ import { Button } from '../ui/button';
8
+ import { Badge } from '../ui/badge';
9
+ import { cn } from '../../lib/cn';
10
+ import { api } from '../../lib/api';
11
+ import { useGrooveStore } from '../../stores/groove';
12
+
13
+ const CATEGORY_LABELS = { code: 'Code', general: 'General' };
14
+ const TIER_COLORS = { light: 'text-success', medium: 'text-accent', heavy: 'text-warning' };
15
+
16
+ function formatSize(gb) {
17
+ return gb < 1 ? `${Math.round(gb * 1024)} MB` : `${gb} GB`;
18
+ }
19
+
20
+ /* ── Hardware Info Bar ──────────────────────────────────────── */
21
+
22
+ function HardwareBar({ hardware }) {
23
+ if (!hardware) return null;
24
+ const { totalRamGb, gpu, isAppleSilicon } = hardware;
25
+ return (
26
+ <div className="flex items-center gap-3 bg-surface-0 rounded-lg border border-border-subtle px-3 py-2.5">
27
+ <Monitor size={14} className="text-text-3 flex-shrink-0" />
28
+ <div className="flex-1 min-w-0">
29
+ <div className="flex items-center gap-2 text-xs font-sans">
30
+ <span className="text-text-0 font-semibold">{totalRamGb} GB RAM</span>
31
+ {gpu && (
32
+ <>
33
+ <span className="text-text-4">·</span>
34
+ <span className="text-text-2">{gpu.name}</span>
35
+ </>
36
+ )}
37
+ {isAppleSilicon && (
38
+ <Badge variant="accent" className="text-2xs">Unified Memory</Badge>
39
+ )}
40
+ </div>
41
+ </div>
42
+ </div>
43
+ );
44
+ }
45
+
46
+ /* ── Install Section (not installed) ───────────────────────── */
47
+
48
+ function InstallSection({ onRecheck }) {
49
+ const [data, setData] = useState(null);
50
+ const [checking, setChecking] = useState(false);
51
+ const [starting, setStarting] = useState(false);
52
+ const [copied, setCopied] = useState(false);
53
+ const addToast = useGrooveStore((s) => s.addToast);
54
+
55
+ useEffect(() => {
56
+ api.post('/providers/ollama/check').then(setData).catch(() => {});
57
+ }, []);
58
+
59
+ async function handleRecheck() {
60
+ setChecking(true);
61
+ try {
62
+ const result = await api.post('/providers/ollama/check');
63
+ setData(result);
64
+ if (result.installed && result.serverRunning) {
65
+ addToast('success', 'Ollama is ready!');
66
+ onRecheck();
67
+ } else if (result.installed) {
68
+ addToast('info', 'Ollama installed — server needs to start');
69
+ } else {
70
+ addToast('info', 'Ollama not found — install and try again');
71
+ }
72
+ } catch {}
73
+ setChecking(false);
74
+ }
75
+
76
+ async function handleStartServer() {
77
+ setStarting(true);
78
+ try {
79
+ const result = await api.post('/providers/ollama/serve');
80
+ if (result.ok) {
81
+ addToast('success', 'Ollama server started!');
82
+ // Recheck to update state
83
+ const check = await api.post('/providers/ollama/check');
84
+ setData(check);
85
+ if (check.serverRunning) onRecheck();
86
+ }
87
+ } catch (err) {
88
+ addToast('error', 'Could not start server', err.message);
89
+ }
90
+ setStarting(false);
91
+ }
92
+
93
+ function handleCopy(text) {
94
+ navigator.clipboard.writeText(text);
95
+ setCopied(true);
96
+ setTimeout(() => setCopied(false), 2000);
97
+ }
98
+
99
+ if (!data) return <div className="py-4 text-center text-xs text-text-4 font-sans">Loading...</div>;
100
+
101
+ const { hardware, install, requirements, installed, serverRunning } = data;
102
+ const canRun = hardware.totalRamGb >= requirements.minRAM;
103
+ const recommended = hardware.recommended;
104
+
105
+ return (
106
+ <div className="space-y-3 p-3">
107
+ <HardwareBar hardware={hardware} />
108
+
109
+ {canRun ? (
110
+ <div className="flex items-start gap-2 bg-success/8 border border-success/20 rounded-lg px-3 py-2.5">
111
+ <Check size={14} className="text-success flex-shrink-0 mt-0.5" />
112
+ <div className="text-xs font-sans">
113
+ <span className="text-success font-semibold">Your system is ready.</span>
114
+ <span className="text-text-2 ml-1">
115
+ {recommended.code
116
+ ? `Recommended: ${recommended.code}`
117
+ : `${hardware.totalRamGb} GB RAM available`}
118
+ </span>
119
+ </div>
120
+ </div>
121
+ ) : (
122
+ <div className="flex items-start gap-2 bg-warning/8 border border-warning/20 rounded-lg px-3 py-2.5">
123
+ <AlertCircle size={14} className="text-warning flex-shrink-0 mt-0.5" />
124
+ <div className="text-xs font-sans text-text-2">
125
+ <span className="text-warning font-semibold">{hardware.totalRamGb} GB RAM detected.</span>
126
+ {' '}Minimum {requirements.minRAM} GB needed. Smallest models may still work.
127
+ </div>
128
+ </div>
129
+ )}
130
+
131
+ {/* State: Installed but server not running */}
132
+ {installed && !serverRunning && (
133
+ <div className="space-y-2">
134
+ <div className="flex items-start gap-2 bg-warning/8 border border-warning/20 rounded-lg px-3 py-2.5">
135
+ <AlertCircle size={14} className="text-warning flex-shrink-0 mt-0.5" />
136
+ <div className="text-xs font-sans text-text-2">
137
+ <span className="text-warning font-semibold">Ollama installed but server not running.</span>
138
+ {' '}The server needs to be running to pull and use models.
139
+ </div>
140
+ </div>
141
+ <Button variant="primary" size="md" onClick={handleStartServer} disabled={starting} className="w-full gap-1.5">
142
+ <Zap size={12} />
143
+ {starting ? 'Starting...' : 'Start Ollama Server'}
144
+ </Button>
145
+ </div>
146
+ )}
147
+
148
+ {/* State: Not installed */}
149
+ {!installed && (
150
+ <div className="space-y-1.5">
151
+ <p className="text-xs font-semibold text-text-1 font-sans">Install Ollama</p>
152
+ <div className="flex items-center gap-2">
153
+ <code className="flex-1 bg-surface-0 border border-border-subtle rounded-md px-3 py-2 text-xs font-mono text-text-1 truncate">
154
+ {install.command}
155
+ </code>
156
+ <Button
157
+ variant="secondary"
158
+ size="sm"
159
+ onClick={() => handleCopy(install.command)}
160
+ className="h-8 px-2.5 gap-1 flex-shrink-0"
161
+ >
162
+ {copied ? <Check size={12} /> : <Copy size={12} />}
163
+ {copied ? 'Copied' : 'Copy'}
164
+ </Button>
165
+ </div>
166
+ {install.alt && (
167
+ <p className="text-2xs text-text-4 font-sans">{install.alt}</p>
168
+ )}
169
+ </div>
170
+ )}
171
+
172
+ <Button variant="secondary" size="md" onClick={handleRecheck} disabled={checking} className="w-full gap-1.5">
173
+ <RefreshCw size={12} className={checking ? 'animate-spin' : ''} />
174
+ {checking ? 'Checking...' : installed ? 'Check again' : 'I installed it — check again'}
175
+ </Button>
176
+ </div>
177
+ );
178
+ }
179
+
180
+ /* ── Model Row ─────────────────────────────────────────────── */
181
+
182
+ function ModelRow({ model, isInstalled, isRecommended, canRun, onPull, onDelete, pulling }) {
183
+ const isPulling = pulling === model.id;
184
+
185
+ return (
186
+ <div className={cn(
187
+ 'flex items-center gap-2 px-3 py-2 border-t border-border-subtle transition-colors',
188
+ !canRun && 'opacity-40',
189
+ )}>
190
+ {isInstalled ? (
191
+ <Check size={12} className="text-success flex-shrink-0" />
192
+ ) : (
193
+ <div className="w-3" />
194
+ )}
195
+ <div className="flex-1 min-w-0">
196
+ <div className="flex items-center gap-1.5">
197
+ <span className="text-xs font-mono text-text-1 truncate">{model.name}</span>
198
+ {isRecommended && <Star size={10} className="text-warning flex-shrink-0" />}
199
+ </div>
200
+ <div className="flex items-center gap-2 mt-0.5">
201
+ <span className={cn('text-2xs font-semibold font-sans', TIER_COLORS[model.tier])}>
202
+ {model.tier}
203
+ </span>
204
+ <span className="text-2xs text-text-4 font-sans">{formatSize(model.sizeGb)}</span>
205
+ <span className="text-2xs text-text-4 font-sans">· {model.ramGb} GB RAM</span>
206
+ </div>
207
+ </div>
208
+ {isInstalled ? (
209
+ <button
210
+ onClick={() => onDelete(model.id)}
211
+ className="p-1.5 text-text-4 hover:text-danger rounded transition-colors cursor-pointer"
212
+ title="Remove model"
213
+ >
214
+ <Trash2 size={12} />
215
+ </button>
216
+ ) : canRun ? (
217
+ <Button
218
+ variant="secondary"
219
+ size="sm"
220
+ onClick={() => onPull(model.id)}
221
+ disabled={!!pulling}
222
+ className="h-7 px-2 text-2xs gap-1"
223
+ >
224
+ {isPulling ? (
225
+ <><RefreshCw size={10} className="animate-spin" /> Pulling...</>
226
+ ) : (
227
+ <><Download size={10} /> Pull</>
228
+ )}
229
+ </Button>
230
+ ) : (
231
+ <span className="text-2xs text-text-4 font-sans">Needs {model.ramGb} GB</span>
232
+ )}
233
+ </div>
234
+ );
235
+ }
236
+
237
+ /* ── Model Browser (installed) ─────────────────────────────── */
238
+
239
+ function ModelBrowser({ onModelChange }) {
240
+ const [data, setData] = useState(null);
241
+ const [pulling, setPulling] = useState(null);
242
+ const [category, setCategory] = useState('code');
243
+ const [showAll, setShowAll] = useState(false);
244
+ const addToast = useGrooveStore((s) => s.addToast);
245
+
246
+ function load() {
247
+ api.get('/providers/ollama/models').then(setData).catch(() => {});
248
+ }
249
+
250
+ useEffect(() => { load(); }, []);
251
+
252
+ async function handlePull(modelId) {
253
+ setPulling(modelId);
254
+ try {
255
+ await api.post('/providers/ollama/pull', { model: modelId });
256
+ addToast('success', `Pulled ${modelId}`);
257
+ load();
258
+ if (onModelChange) onModelChange();
259
+ } catch (err) {
260
+ addToast('error', `Pull failed: ${err.message}`);
261
+ }
262
+ setPulling(null);
263
+ }
264
+
265
+ async function handleDelete(modelId) {
266
+ try {
267
+ await api.delete(`/providers/ollama/models/${encodeURIComponent(modelId)}`);
268
+ addToast('info', `Removed ${modelId}`);
269
+ load();
270
+ if (onModelChange) onModelChange();
271
+ } catch (err) {
272
+ addToast('error', `Delete failed: ${err.message}`);
273
+ }
274
+ }
275
+
276
+ if (!data) return <div className="py-4 text-center text-xs text-text-4 font-sans">Loading...</div>;
277
+
278
+ const { installed, catalog, hardware } = data;
279
+ const installedIds = new Set(installed.map((m) => m.id));
280
+ const maxRam = hardware.totalRamGb;
281
+ const recommended = [hardware.recommended?.code, hardware.recommended?.general].filter(Boolean);
282
+
283
+ const filtered = catalog.filter((m) => m.category === category);
284
+ const visible = showAll ? filtered : filtered.filter((m) => m.ramGb <= maxRam);
285
+
286
+ return (
287
+ <div className="space-y-2 p-3">
288
+ <HardwareBar hardware={hardware} />
289
+
290
+ {/* Installed count */}
291
+ {installed.length > 0 && (
292
+ <div className="flex items-center gap-1.5 text-xs font-sans text-text-2">
293
+ <HardDrive size={12} className="text-text-3" />
294
+ <span className="font-semibold">{installed.length}</span> model{installed.length !== 1 ? 's' : ''} installed
295
+ </div>
296
+ )}
297
+
298
+ {/* Category tabs */}
299
+ <div className="flex bg-surface-0 rounded-lg p-0.5 border border-border-subtle">
300
+ {Object.entries(CATEGORY_LABELS).map(([key, label]) => (
301
+ <button
302
+ key={key}
303
+ onClick={() => setCategory(key)}
304
+ className={cn(
305
+ 'flex-1 px-3 py-1.5 text-2xs font-semibold font-sans rounded-md transition-all cursor-pointer',
306
+ category === key
307
+ ? 'bg-accent/15 text-accent shadow-sm'
308
+ : 'text-text-3 hover:text-text-1',
309
+ )}
310
+ >
311
+ {label}
312
+ </button>
313
+ ))}
314
+ </div>
315
+
316
+ {/* Model list */}
317
+ <div className="rounded-lg border border-border-subtle bg-surface-0 overflow-hidden">
318
+ {visible.map((model) => (
319
+ <ModelRow
320
+ key={model.id}
321
+ model={model}
322
+ isInstalled={installedIds.has(model.id)}
323
+ isRecommended={recommended.includes(model.id)}
324
+ canRun={model.ramGb <= maxRam}
325
+ onPull={handlePull}
326
+ onDelete={handleDelete}
327
+ pulling={pulling}
328
+ />
329
+ ))}
330
+ {visible.length === 0 && (
331
+ <div className="px-3 py-4 text-center text-xs text-text-4 font-sans">
332
+ No {category} models available for your hardware
333
+ </div>
334
+ )}
335
+ </div>
336
+
337
+ {/* Show models beyond RAM */}
338
+ {!showAll && filtered.length > visible.length && (
339
+ <button
340
+ onClick={() => setShowAll(true)}
341
+ className="flex items-center gap-1 text-2xs text-text-3 hover:text-accent font-sans cursor-pointer transition-colors"
342
+ >
343
+ <ChevronDown size={10} />
344
+ Show {filtered.length - visible.length} more (exceed your RAM)
345
+ </button>
346
+ )}
347
+ </div>
348
+ );
349
+ }
350
+
351
+ /* ── Main Export ────────────────────────────────────────────── */
352
+
353
+ export function OllamaSetup({ isInstalled: initialInstalled, onModelChange }) {
354
+ const [ready, setReady] = useState(false);
355
+ const [checked, setChecked] = useState(false);
356
+
357
+ // On mount, verify server is actually running (not just binary installed)
358
+ useEffect(() => {
359
+ if (initialInstalled) {
360
+ api.post('/providers/ollama/check')
361
+ .then((data) => { setReady(data.installed && data.serverRunning); setChecked(true); })
362
+ .catch(() => setChecked(true));
363
+ } else {
364
+ setChecked(true);
365
+ }
366
+ }, [initialInstalled]);
367
+
368
+ if (!checked) return <div className="py-4 text-center text-xs text-text-4 font-sans">Checking Ollama...</div>;
369
+
370
+ if (!ready) {
371
+ return <InstallSection onRecheck={() => { setReady(true); if (onModelChange) onModelChange(); }} />;
372
+ }
373
+
374
+ return <ModelBrowser onModelChange={onModelChange} />;
375
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "groove-dev",
3
- "version": "0.19.2",
3
+ "version": "0.19.4",
4
4
  "description": "Open-source agent orchestration layer — the AI company OS. MCP integrations (Slack, Gmail, Stripe, 15+), agent scheduling (cron), business roles (CMO, CFO, EA). GUI dashboard, multi-agent coordination, zero cold-start, infinite sessions. Works with Claude Code, Codex, Gemini CLI, Ollama.",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "author": "Groove Dev <hello@groovedev.ai> (https://groovedev.ai)",
@@ -24,7 +24,7 @@ import { federationPair, federationUnpair, federationList, federationStatus } fr
24
24
  program
25
25
  .name('groove')
26
26
  .description('Agent orchestration layer for AI coding tools')
27
- .version('0.19.2');
27
+ .version('0.19.4');
28
28
 
29
29
  program
30
30
  .command('start')
@@ -7,6 +7,7 @@ import { fileURLToPath } from 'url';
7
7
  import { existsSync, readFileSync, readdirSync, statSync, writeFileSync, mkdirSync, unlinkSync, renameSync, rmSync, createReadStream } from 'fs';
8
8
  import { lookup as mimeLookup } from './mimetypes.js';
9
9
  import { listProviders, getProvider } from './providers/index.js';
10
+ import { OllamaProvider } from './providers/ollama.js';
10
11
  import { validateAgentConfig } from './validate.js';
11
12
 
12
13
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -126,6 +127,73 @@ export function createApi(app, daemon) {
126
127
  res.json(providers);
127
128
  });
128
129
 
130
+ // --- Ollama ---
131
+
132
+ app.get('/api/providers/ollama/hardware', (req, res) => {
133
+ res.json(OllamaProvider.getSystemHardware());
134
+ });
135
+
136
+ app.get('/api/providers/ollama/models', (req, res) => {
137
+ const installed = OllamaProvider.isInstalled() ? OllamaProvider.getInstalledModels() : [];
138
+ const catalog = OllamaProvider.catalog;
139
+ const hardware = OllamaProvider.getSystemHardware();
140
+ res.json({ installed, catalog, hardware });
141
+ });
142
+
143
+ app.post('/api/providers/ollama/pull', async (req, res) => {
144
+ const { model } = req.body;
145
+ if (!model) return res.status(400).json({ error: 'model is required' });
146
+ if (!OllamaProvider.isInstalled()) return res.status(400).json({ error: 'Ollama is not installed' });
147
+ const broadcast = daemon.broadcast || (() => {});
148
+ try {
149
+ broadcast({ type: 'ollama:pull:start', model });
150
+ await OllamaProvider.pullModel(model, (progress) => {
151
+ broadcast({ type: 'ollama:pull:progress', model, progress: progress.trim() });
152
+ });
153
+ broadcast({ type: 'ollama:pull:complete', model });
154
+ daemon.audit.log('ollama.pull', { model });
155
+ res.json({ ok: true, model });
156
+ } catch (err) {
157
+ broadcast({ type: 'ollama:pull:error', model, error: err.message });
158
+ res.status(500).json({ error: `Pull failed: ${err.message}` });
159
+ }
160
+ });
161
+
162
+ app.delete('/api/providers/ollama/models/:model', (req, res) => {
163
+ if (!OllamaProvider.isInstalled()) return res.status(400).json({ error: 'Ollama is not installed' });
164
+ const success = OllamaProvider.deleteModel(req.params.model);
165
+ if (success) {
166
+ daemon.audit.log('ollama.delete', { model: req.params.model });
167
+ res.json({ ok: true });
168
+ } else {
169
+ res.status(500).json({ error: 'Failed to delete model' });
170
+ }
171
+ });
172
+
173
+ app.post('/api/providers/ollama/check', async (req, res) => {
174
+ const installed = OllamaProvider.isInstalled();
175
+ const serverRunning = installed ? await OllamaProvider.isServerRunning() : false;
176
+ const install = OllamaProvider.installCommand();
177
+ const hardware = OllamaProvider.getSystemHardware();
178
+ const requirements = OllamaProvider.hardwareRequirements();
179
+ res.json({ installed, serverRunning, install, hardware, requirements });
180
+ });
181
+
182
+ app.post('/api/providers/ollama/serve', async (req, res) => {
183
+ if (!OllamaProvider.isInstalled()) return res.status(400).json({ error: 'Ollama is not installed' });
184
+ const already = await OllamaProvider.isServerRunning();
185
+ if (already) return res.json({ ok: true, alreadyRunning: true });
186
+ const result = OllamaProvider.startServer();
187
+ if (result.started) {
188
+ // Wait a moment for server to come up
189
+ await new Promise((r) => setTimeout(r, 2000));
190
+ const running = await OllamaProvider.isServerRunning();
191
+ res.json({ ok: running, method: result.method });
192
+ } else {
193
+ res.status(500).json({ error: 'Could not start server', command: result.command });
194
+ }
195
+ });
196
+
129
197
  // --- Credentials ---
130
198
 
131
199
  app.get('/api/credentials', (req, res) => {