groove-dev 0.27.124 → 0.27.125

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 (31) hide show
  1. package/node_modules/@groove-dev/cli/package.json +1 -1
  2. package/node_modules/@groove-dev/daemon/package.json +1 -1
  3. package/node_modules/@groove-dev/daemon/src/api.js +122 -0
  4. package/node_modules/@groove-dev/daemon/src/preview.js +28 -5
  5. package/node_modules/@groove-dev/daemon/src/process.js +21 -0
  6. package/node_modules/@groove-dev/daemon/src/providers/local.js +19 -20
  7. package/node_modules/@groove-dev/daemon/src/providers/ollama.js +66 -3
  8. package/node_modules/@groove-dev/gui/dist/assets/{index-BcmoHTm0.js → index-BU0bL6BB.js} +1748 -1748
  9. package/node_modules/@groove-dev/gui/dist/assets/{index-DWI-g_Sm.css → index-D3RyFPc0.css} +1 -1
  10. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  11. package/node_modules/@groove-dev/gui/package.json +1 -1
  12. package/node_modules/@groove-dev/gui/src/components/agents/spawn-wizard.jsx +66 -6
  13. package/node_modules/@groove-dev/gui/src/stores/groove.js +169 -0
  14. package/node_modules/@groove-dev/gui/src/views/agents.jsx +8 -10
  15. package/node_modules/@groove-dev/gui/src/views/models.jsx +507 -236
  16. package/package.json +1 -1
  17. package/packages/cli/package.json +1 -1
  18. package/packages/daemon/package.json +1 -1
  19. package/packages/daemon/src/api.js +122 -0
  20. package/packages/daemon/src/preview.js +28 -5
  21. package/packages/daemon/src/process.js +21 -0
  22. package/packages/daemon/src/providers/local.js +19 -20
  23. package/packages/daemon/src/providers/ollama.js +66 -3
  24. package/packages/gui/dist/assets/{index-BcmoHTm0.js → index-BU0bL6BB.js} +1748 -1748
  25. package/packages/gui/dist/assets/{index-DWI-g_Sm.css → index-D3RyFPc0.css} +1 -1
  26. package/packages/gui/dist/index.html +2 -2
  27. package/packages/gui/package.json +1 -1
  28. package/packages/gui/src/components/agents/spawn-wizard.jsx +66 -6
  29. package/packages/gui/src/stores/groove.js +169 -0
  30. package/packages/gui/src/views/agents.jsx +8 -10
  31. package/packages/gui/src/views/models.jsx +507 -236
@@ -1,18 +1,20 @@
1
1
  // FSL-1.1-Apache-2.0 — see LICENSE
2
- import { useState, useEffect, useCallback } from 'react';
2
+ import { useState, useEffect, useCallback, useRef } from 'react';
3
3
  import { ScrollArea } from '../components/ui/scroll-area';
4
4
  import { Badge } from '../components/ui/badge';
5
5
  import { Button } from '../components/ui/button';
6
- import { Input } from '../components/ui/input';
7
6
  import { api } from '../lib/api';
8
7
  import { useToast } from '../lib/hooks/use-toast';
9
8
  import { useGrooveStore } from '../stores/groove';
10
9
  import {
11
10
  Search, Download, Trash2, HardDrive, Cpu, MemoryStick,
12
- Check, X, Loader2, ExternalLink, Box, ChevronDown, ChevronRight,
11
+ Check, Loader2, Box, ChevronDown, ChevronRight,
12
+ RefreshCw, Play, Square, Zap, AlertCircle, Monitor, Rocket,
13
13
  } from 'lucide-react';
14
14
  import { cn } from '../lib/cn';
15
15
 
16
+ const TIER_COLORS = { light: 'text-green-400', medium: 'text-blue-400', heavy: 'text-orange-400' };
17
+
16
18
  function formatBytes(bytes) {
17
19
  if (!bytes) return '—';
18
20
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
@@ -26,11 +28,81 @@ function formatSpeed(bytesPerSec) {
26
28
  return `${(bytesPerSec / (1024 * 1024)).toFixed(1)} MB/s`;
27
29
  }
28
30
 
31
+ // ---- Server Status Bar ----
32
+ function ServerStatusBar({ serverRunning, installed, onStart, onStop, onRestart, actionInProgress }) {
33
+ if (!installed) {
34
+ return (
35
+ <div className="flex items-center gap-2 bg-surface-1 border border-border-subtle rounded-lg px-3 py-2">
36
+ <span className="w-[6px] h-[6px] rounded-full bg-text-4 flex-shrink-0" />
37
+ <span className="text-xs font-sans text-text-3 font-medium">Ollama Not Installed</span>
38
+ <div className="flex-1" />
39
+ <a
40
+ href="https://ollama.ai/download"
41
+ target="_blank"
42
+ rel="noopener noreferrer"
43
+ className="text-2xs font-sans text-accent hover:underline"
44
+ >
45
+ Install Ollama
46
+ </a>
47
+ </div>
48
+ );
49
+ }
50
+
51
+ if (serverRunning) {
52
+ return (
53
+ <div className="flex items-center gap-2 bg-success/8 border border-success/20 rounded-lg px-3 py-2">
54
+ <span className="relative flex-shrink-0 w-[6px] h-[6px]">
55
+ <span className="absolute inset-0 rounded-full bg-success" />
56
+ <span className="absolute inset-[-2px] rounded-full bg-success opacity-20 animate-pulse" />
57
+ </span>
58
+ <span className="text-xs font-sans text-success font-semibold">Server Running</span>
59
+ <span className="text-2xs font-mono text-text-4">:11434</span>
60
+ <div className="flex-1" />
61
+ <button
62
+ onClick={onRestart}
63
+ disabled={!!actionInProgress}
64
+ className="flex items-center gap-1 text-2xs font-sans text-text-3 hover:text-accent cursor-pointer transition-colors disabled:opacity-40"
65
+ >
66
+ <RefreshCw size={10} className={actionInProgress === 'restarting' ? 'animate-spin' : ''} />
67
+ {actionInProgress === 'restarting' ? 'Restarting...' : 'Restart'}
68
+ </button>
69
+ <button
70
+ onClick={onStop}
71
+ disabled={!!actionInProgress}
72
+ className="flex items-center gap-1 text-2xs font-sans text-text-3 hover:text-danger cursor-pointer transition-colors disabled:opacity-40"
73
+ >
74
+ <Square size={10} />
75
+ {actionInProgress === 'stopping' ? 'Stopping...' : 'Stop'}
76
+ </button>
77
+ </div>
78
+ );
79
+ }
80
+
81
+ return (
82
+ <div className="flex items-center gap-2 bg-danger/8 border border-danger/20 rounded-lg px-3 py-2">
83
+ <span className="w-[6px] h-[6px] rounded-full bg-danger flex-shrink-0" />
84
+ <span className="text-xs font-sans text-danger font-semibold">Server Stopped</span>
85
+ <span className="text-2xs font-mono text-text-4">:11434</span>
86
+ <div className="flex-1" />
87
+ <Button
88
+ variant="primary"
89
+ size="sm"
90
+ onClick={onStart}
91
+ disabled={!!actionInProgress}
92
+ className="h-6 px-2.5 text-2xs gap-1"
93
+ >
94
+ <Play size={10} />
95
+ {actionInProgress === 'starting' ? 'Starting...' : 'Start Server'}
96
+ </Button>
97
+ </div>
98
+ );
99
+ }
100
+
29
101
  // ---- Hardware Info ----
30
102
  function HardwareBar({ hardware }) {
31
103
  if (!hardware) return null;
32
104
  return (
33
- <div className="flex items-center gap-4 px-4 py-2.5 bg-surface-1 border border-border-subtle rounded-lg text-xs font-sans text-text-2">
105
+ <div className="flex items-center gap-4 px-3 py-2 bg-surface-1 border border-border-subtle rounded-lg text-xs font-sans text-text-2">
34
106
  <div className="flex items-center gap-1.5">
35
107
  <MemoryStick size={14} className="text-text-3" />
36
108
  <span>{hardware.totalRamGb} GB RAM</span>
@@ -45,15 +117,109 @@ function HardwareBar({ hardware }) {
45
117
  <span>{hardware.gpu.name}{hardware.gpu.vram ? ` (${hardware.gpu.vram} GB)` : ''}</span>
46
118
  </div>
47
119
  )}
48
- {hardware.recommended?.code && (
49
- <div className="ml-auto text-accent">
50
- Recommended: {hardware.recommended.code}
51
- </div>
120
+ {hardware.isAppleSilicon && (
121
+ <Badge variant="accent" className="text-2xs ml-auto">Unified Memory</Badge>
52
122
  )}
53
123
  </div>
54
124
  );
55
125
  }
56
126
 
127
+ // ---- Running Model Card ----
128
+ function RunningModelCard({ model, onUnload, onSpawn, unloading }) {
129
+ const sizeGb = model.size ? (model.size / (1024 ** 3)).toFixed(1) : '?';
130
+ const vramGb = model.vram ? (model.vram / (1024 ** 3)).toFixed(1) : sizeGb;
131
+
132
+ return (
133
+ <div className="flex items-center gap-3 px-4 py-3 bg-success/5 border border-success/20 rounded-lg">
134
+ <span className="relative flex-shrink-0 w-2 h-2">
135
+ <span className="absolute inset-0 rounded-full bg-success" />
136
+ <span className="absolute inset-[-2px] rounded-full bg-success opacity-20 animate-pulse" />
137
+ </span>
138
+ <div className="flex-1 min-w-0">
139
+ <div className="flex items-center gap-2">
140
+ <span className="text-sm font-mono font-bold text-text-0 truncate">{model.name}</span>
141
+ <Badge variant="success" className="text-2xs">Running</Badge>
142
+ </div>
143
+ <div className="text-2xs text-text-3 font-sans mt-0.5">
144
+ {vramGb} GB VRAM &middot; loaded in memory
145
+ </div>
146
+ </div>
147
+ <button
148
+ onClick={() => onSpawn(model.name)}
149
+ className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-2xs font-sans font-medium bg-accent/10 text-accent hover:bg-accent/20 transition-colors cursor-pointer"
150
+ >
151
+ <Rocket size={11} />
152
+ Spawn Agent
153
+ </button>
154
+ <button
155
+ onClick={() => onUnload(model.name)}
156
+ disabled={unloading === model.name}
157
+ className="p-1.5 rounded-md text-text-4 hover:text-warning hover:bg-warning/10 transition-colors cursor-pointer disabled:opacity-40"
158
+ title="Unload from memory"
159
+ >
160
+ {unloading === model.name ? <Loader2 size={14} className="animate-spin" /> : <Square size={14} />}
161
+ </button>
162
+ </div>
163
+ );
164
+ }
165
+
166
+ // ---- Installed Model Card ----
167
+ function InstalledModelCard({ model, catalogEntry, isRunning, onStart, onSpawn, onDelete, loading, deleting, serverRunning }) {
168
+ return (
169
+ <div className="flex items-center gap-3 px-4 py-3 bg-surface-1 border border-border-subtle rounded-lg">
170
+ <Box size={18} className={cn('flex-shrink-0', isRunning ? 'text-success' : 'text-accent')} />
171
+ <div className="flex-1 min-w-0">
172
+ <div className="flex items-center gap-2">
173
+ <span className="text-sm font-mono font-bold text-text-0 truncate">{model.id}</span>
174
+ {model.tier && (
175
+ <span className={cn('text-2xs font-semibold capitalize', TIER_COLORS[model.tier] || 'text-text-3')}>
176
+ {model.tier}
177
+ </span>
178
+ )}
179
+ {model.category && model.category !== 'other' && (
180
+ <Badge variant="subtle" className="text-2xs">{model.category}</Badge>
181
+ )}
182
+ {isRunning && <Badge variant="success" className="text-2xs">Running</Badge>}
183
+ </div>
184
+ <div className="text-2xs text-text-3 font-sans mt-0.5">
185
+ {model.size || '—'}
186
+ {catalogEntry?.ramGb && <> &middot; ~{catalogEntry.ramGb} GB RAM needed</>}
187
+ {catalogEntry?.description && <> &middot; {catalogEntry.description}</>}
188
+ </div>
189
+ </div>
190
+ <div className="flex items-center gap-1">
191
+ {!isRunning && serverRunning && (
192
+ <button
193
+ onClick={() => onStart(model.id)}
194
+ disabled={!!loading}
195
+ className="flex items-center gap-1 px-2 py-1.5 rounded-md text-2xs font-sans font-medium text-text-2 hover:text-success hover:bg-success/10 transition-colors cursor-pointer disabled:opacity-40"
196
+ title="Load into memory"
197
+ >
198
+ {loading === model.id ? <Loader2 size={11} className="animate-spin" /> : <Play size={11} />}
199
+ Start
200
+ </button>
201
+ )}
202
+ <button
203
+ onClick={() => onSpawn(model.id)}
204
+ className="flex items-center gap-1 px-2 py-1.5 rounded-md text-2xs font-sans font-medium text-accent hover:bg-accent/10 transition-colors cursor-pointer"
205
+ title="Spawn an agent with this model"
206
+ >
207
+ <Rocket size={11} />
208
+ Spawn
209
+ </button>
210
+ <button
211
+ onClick={() => onDelete(model.id)}
212
+ disabled={deleting === model.id}
213
+ className="p-1.5 rounded-md text-text-4 hover:text-red-400 hover:bg-red-400/10 transition-colors cursor-pointer disabled:opacity-40"
214
+ title="Delete model"
215
+ >
216
+ {deleting === model.id ? <Loader2 size={13} className="animate-spin" /> : <Trash2 size={13} />}
217
+ </button>
218
+ </div>
219
+ </div>
220
+ );
221
+ }
222
+
57
223
  // ---- Download Progress Bar ----
58
224
  function DownloadProgress({ download }) {
59
225
  const pct = Math.round((download.percent || 0) * 100);
@@ -73,33 +239,57 @@ function DownloadProgress({ download }) {
73
239
  );
74
240
  }
75
241
 
76
- // ---- Installed Model Card ----
77
- function InstalledModel({ model, onDelete }) {
78
- const [deleting, setDeleting] = useState(false);
79
- const tierColors = { light: 'text-green-400', medium: 'text-blue-400', heavy: 'text-orange-400' };
242
+ // ---- Pull Progress (Ollama) ----
243
+ function PullProgress({ modelId, progress }) {
244
+ return (
245
+ <div className="flex items-center gap-2 px-4 py-2 bg-accent/5 border border-accent/20 rounded-lg">
246
+ <Loader2 size={14} className="animate-spin text-accent flex-shrink-0" />
247
+ <div className="flex-1 min-w-0">
248
+ <span className="text-xs font-mono text-text-0">{modelId}</span>
249
+ <div className="text-2xs text-text-3 font-sans truncate">{progress.progress || 'Pulling...'}</div>
250
+ </div>
251
+ </div>
252
+ );
253
+ }
254
+
255
+ // ---- Recommended Model Card ----
256
+ function RecommendedModel({ model, systemRamGb, onPull, pulling, isInstalled }) {
257
+ const categoryIcons = { code: '{}', general: 'AI' };
258
+ const headroom = systemRamGb ? Math.round((1 - model.ramGb / systemRamGb) * 100) : null;
80
259
 
81
260
  return (
82
- <div className="flex items-center gap-3 px-4 py-3 bg-surface-1 border border-border-subtle rounded-lg">
83
- <Box size={18} className="text-accent flex-shrink-0" />
261
+ <div className={cn(
262
+ 'flex items-center gap-3 px-4 py-3 border rounded-lg transition-colors',
263
+ isInstalled ? 'bg-success/5 border-success/20' : 'bg-surface-1 border-border-subtle hover:border-accent/20',
264
+ )}>
265
+ <div className="w-9 h-9 rounded-lg bg-surface-3 flex items-center justify-center text-xs font-mono text-text-2 flex-shrink-0">
266
+ {categoryIcons[model.category] || 'AI'}
267
+ </div>
84
268
  <div className="flex-1 min-w-0">
85
269
  <div className="flex items-center gap-2">
86
- <span className="text-sm font-mono font-bold text-text-0 truncate">{model.id}</span>
87
- {model.quantization && <Badge variant="subtle" className="text-2xs">{model.quantization}</Badge>}
88
- {model.parameters && <Badge variant="subtle" className="text-2xs">{model.parameters}</Badge>}
89
- <span className={cn('text-2xs font-medium capitalize', tierColors[model.tier] || 'text-text-3')}>{model.tier}</span>
270
+ <span className="text-sm font-mono font-bold text-text-0 truncate">{model.name}</span>
271
+ <span className={cn('text-2xs font-semibold capitalize', TIER_COLORS[model.tier])}>{model.tier}</span>
272
+ {isInstalled && <Badge variant="success" className="text-2xs gap-1"><Check size={8} /> Installed</Badge>}
90
273
  </div>
91
- <div className="text-2xs text-text-3 font-sans mt-0.5">
92
- {formatBytes(model.sizeBytes)} &middot; ctx {(model.contextWindow || 0).toLocaleString()} &middot; {model.category}
93
- {model.repoId && <span className="text-text-4"> &middot; {model.repoId}</span>}
274
+ <div className="text-2xs text-text-3 font-sans mt-0.5">{model.description}</div>
275
+ <div className="flex items-center gap-3 mt-1 text-2xs font-sans">
276
+ <span className="text-text-2">{model.sizeGb} GB download</span>
277
+ <span className="text-green-400 font-medium">{model.ramGb} GB RAM</span>
278
+ {headroom !== null && <span className="text-text-4">{headroom}% headroom</span>}
94
279
  </div>
95
280
  </div>
96
- <button
97
- onClick={async () => { setDeleting(true); await onDelete(model.id); setDeleting(false); }}
98
- disabled={deleting}
99
- className="p-1.5 rounded-md text-text-4 hover:text-red-400 hover:bg-red-400/10 transition-colors"
100
- >
101
- {deleting ? <Loader2 size={14} className="animate-spin" /> : <Trash2 size={14} />}
102
- </button>
281
+ {isInstalled ? (
282
+ <span className="text-xs text-success font-sans font-medium px-3 py-1.5">Ready</span>
283
+ ) : (
284
+ <button
285
+ onClick={() => onPull(model.id)}
286
+ disabled={pulling === model.id}
287
+ className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-sans font-medium bg-accent/10 text-accent hover:bg-accent/20 transition-colors cursor-pointer disabled:opacity-40"
288
+ >
289
+ {pulling === model.id ? <Loader2 size={12} className="animate-spin" /> : <Download size={12} />}
290
+ Pull
291
+ </button>
292
+ )}
103
293
  </div>
104
294
  );
105
295
  }
@@ -199,44 +389,14 @@ function FilePicker({ repoId, onDownload, systemRamGb }) {
199
389
  );
200
390
  }
201
391
 
202
- // ---- Recommended Model Card ----
203
- function RecommendedModel({ model, systemRamGb, onPull, pulling, isInstalled }) {
204
- const tierColors = { light: 'text-green-400', medium: 'text-blue-400', heavy: 'text-orange-400' };
205
- const categoryIcons = { code: '{}', general: 'AI' };
206
- const headroom = systemRamGb ? Math.round((1 - model.ramGb / systemRamGb) * 100) : null;
207
-
392
+ // ---- Section Header ----
393
+ function SectionHeader({ title, count, icon: Icon }) {
208
394
  return (
209
- <div className={cn(
210
- 'flex items-center gap-3 px-4 py-3 border rounded-lg transition-colors',
211
- isInstalled ? 'bg-success/5 border-success/20' : 'bg-surface-1 border-border-subtle hover:border-accent/20',
212
- )}>
213
- <div className="w-9 h-9 rounded-lg bg-surface-3 flex items-center justify-center text-xs font-mono text-text-2 flex-shrink-0">
214
- {categoryIcons[model.category] || 'AI'}
215
- </div>
216
- <div className="flex-1 min-w-0">
217
- <div className="flex items-center gap-2">
218
- <span className="text-sm font-mono font-bold text-text-0 truncate">{model.name}</span>
219
- <span className={cn('text-2xs font-semibold capitalize', tierColors[model.tier])}>{model.tier}</span>
220
- {isInstalled && <Badge variant="success" className="text-2xs gap-1"><Check size={8} /> Installed</Badge>}
221
- </div>
222
- <div className="text-2xs text-text-3 font-sans mt-0.5">{model.description}</div>
223
- <div className="flex items-center gap-3 mt-1 text-2xs font-sans">
224
- <span className="text-text-2">{model.sizeGb} GB download</span>
225
- <span className="text-green-400 font-medium">{model.ramGb} GB RAM</span>
226
- {headroom !== null && <span className="text-text-4">{headroom}% headroom</span>}
227
- </div>
228
- </div>
229
- {isInstalled ? (
230
- <span className="text-xs text-success font-sans font-medium px-3 py-1.5">Ready</span>
231
- ) : (
232
- <button
233
- onClick={() => onPull(model.id)}
234
- disabled={pulling === model.id}
235
- className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-sans font-medium bg-accent/10 text-accent hover:bg-accent/20 transition-colors cursor-pointer disabled:opacity-40"
236
- >
237
- {pulling === model.id ? <Loader2 size={12} className="animate-spin" /> : <Download size={12} />}
238
- Pull
239
- </button>
395
+ <div className="flex items-center gap-2 mb-2">
396
+ {Icon && <Icon size={14} className="text-text-3" />}
397
+ <span className="text-xs font-semibold font-sans text-text-2 uppercase tracking-wider">{title}</span>
398
+ {count !== undefined && (
399
+ <Badge variant="subtle" className="text-2xs">{count}</Badge>
240
400
  )}
241
401
  </div>
242
402
  );
@@ -244,74 +404,59 @@ function RecommendedModel({ model, systemRamGb, onPull, pulling, isInstalled })
244
404
 
245
405
  // ---- Main View ----
246
406
  export default function ModelsView() {
247
- const [tab, setTab] = useState('recommended'); // recommended | installed | search
407
+ const [discoveryTab, setDiscoveryTab] = useState('recommended');
248
408
  const [searchQuery, setSearchQuery] = useState('');
249
409
  const [searchResults, setSearchResults] = useState([]);
250
410
  const [searching, setSearching] = useState(false);
251
- const [installed, setInstalled] = useState([]);
252
411
  const [recommended, setRecommended] = useState([]);
253
412
  const [downloads, setDownloads] = useState([]);
254
- const [hardware, setHardware] = useState(null);
255
413
  const [expandedResult, setExpandedResult] = useState(null);
256
- const [pulling, setPulling] = useState(null);
257
- const [ollamaModels, setOllamaModels] = useState([]);
414
+ const [serverAction, setServerAction] = useState(null);
415
+ const [loadingModel, setLoadingModel] = useState(null);
416
+ const [unloadingModel, setUnloadingModel] = useState(null);
417
+ const [deletingModel, setDeletingModel] = useState(null);
258
418
  const toast = useToast();
259
419
 
260
- // Fetch installed models
261
- const fetchInstalled = useCallback(() => {
262
- api.get('/models/installed').then((data) => {
263
- setInstalled(data.models || []);
264
- }).catch(() => {});
265
- }, []);
266
-
267
- const fetchOllamaModels = useCallback(() => {
268
- api.get('/providers/ollama/models').then((data) => {
269
- setOllamaModels((data.installed || []).map((m) => m.id));
270
- }).catch(() => {});
271
- }, []);
420
+ const ollamaStatus = useGrooveStore((s) => s.ollamaStatus);
421
+ const installedModels = useGrooveStore((s) => s.ollamaInstalledModels);
422
+ const runningModels = useGrooveStore((s) => s.ollamaRunningModels);
423
+ const catalog = useGrooveStore((s) => s.ollamaCatalog);
424
+ const pullProgress = useGrooveStore((s) => s.ollamaPullProgress);
425
+ const fetchOllamaStatus = useGrooveStore((s) => s.fetchOllamaStatus);
426
+ const startServer = useGrooveStore((s) => s.startOllamaServer);
427
+ const stopServer = useGrooveStore((s) => s.stopOllamaServer);
428
+ const restartServer = useGrooveStore((s) => s.restartOllamaServer);
429
+ const pullModel = useGrooveStore((s) => s.pullOllamaModel);
430
+ const deleteModel = useGrooveStore((s) => s.deleteOllamaModel);
431
+ const loadModel = useGrooveStore((s) => s.loadOllamaModel);
432
+ const unloadModel = useGrooveStore((s) => s.unloadOllamaModel);
433
+ const spawnFromModel = useGrooveStore((s) => s.spawnFromModel);
434
+
435
+ const pollingRef = useRef(null);
436
+
437
+ // Fetch status on mount and poll every 10s
438
+ useEffect(() => {
439
+ fetchOllamaStatus();
440
+ pollingRef.current = setInterval(fetchOllamaStatus, 10000);
441
+ return () => clearInterval(pollingRef.current);
442
+ }, [fetchOllamaStatus]);
272
443
 
273
- // Fetch hardware info + recommended models + Ollama installed
444
+ // Fetch recommended models
274
445
  useEffect(() => {
275
- api.get('/providers/ollama/hardware').then(setHardware).catch(() => {});
276
446
  api.get('/models/recommended').then((data) => {
277
447
  setRecommended(data.models || []);
278
- if (!hardware && data.hardware) setHardware(data.hardware);
279
448
  }).catch(() => {});
280
- fetchInstalled();
281
- fetchOllamaModels();
282
- }, [fetchInstalled, fetchOllamaModels]);
283
-
284
- async function handlePull(modelId) {
285
- setPulling(modelId);
286
- try {
287
- await api.post('/providers/ollama/pull', { model: modelId });
288
- toast.success(`${modelId} ready to use`);
289
- // Refresh all model lists so UI reflects the new install
290
- fetchInstalled();
291
- fetchOllamaModels();
292
- // Also optimistically mark it installed immediately
293
- setOllamaModels((prev) => [...prev, modelId]);
294
- } catch (err) {
295
- toast.error(`Pull failed: ${err.message}`);
296
- }
297
- setPulling(null);
298
- }
449
+ }, []);
299
450
 
300
- // Listen for download progress via WebSocket
451
+ // Poll active GGUF downloads
301
452
  useEffect(() => {
302
- const unsub = useGrooveStore.subscribe((state, prev) => {
303
- // Refresh on model events
304
- });
305
-
306
- // Poll active downloads
307
453
  const poll = setInterval(() => {
308
454
  api.get('/models/downloads').then(setDownloads).catch(() => {});
309
455
  }, 2000);
310
-
311
- return () => { unsub(); clearInterval(poll); };
456
+ return () => clearInterval(poll);
312
457
  }, []);
313
458
 
314
- // WebSocket events for download progress
459
+ // WebSocket events for GGUF download progress
315
460
  useEffect(() => {
316
461
  function handleWs(event) {
317
462
  try {
@@ -329,7 +474,6 @@ export default function ModelsView() {
329
474
  }
330
475
  if (msg.type === 'model:download:complete') {
331
476
  setDownloads((prev) => prev.filter((d) => d.filename !== msg.data.filename));
332
- fetchInstalled();
333
477
  toast.success(`${msg.data.filename} downloaded`);
334
478
  }
335
479
  if (msg.type === 'model:download:error') {
@@ -338,15 +482,55 @@ export default function ModelsView() {
338
482
  }
339
483
  } catch {}
340
484
  }
341
- const ws = useGrooveStore.getState()._ws;
485
+ const ws = useGrooveStore.getState().ws;
342
486
  if (ws) ws.addEventListener('message', handleWs);
343
487
  return () => { if (ws) ws.removeEventListener('message', handleWs); };
344
- }, [fetchInstalled, toast]);
488
+ }, [toast]);
489
+
490
+ async function handleServerStart() {
491
+ setServerAction('starting');
492
+ try { await startServer(); } catch {}
493
+ setServerAction(null);
494
+ }
495
+
496
+ async function handleServerStop() {
497
+ setServerAction('stopping');
498
+ try { await stopServer(); } catch {}
499
+ setServerAction(null);
500
+ }
501
+
502
+ async function handleServerRestart() {
503
+ setServerAction('restarting');
504
+ try { await restartServer(); } catch {}
505
+ setServerAction(null);
506
+ }
507
+
508
+ async function handleLoadModel(modelId) {
509
+ setLoadingModel(modelId);
510
+ try { await loadModel(modelId); } catch {}
511
+ setLoadingModel(null);
512
+ }
513
+
514
+ async function handleUnloadModel(modelId) {
515
+ setUnloadingModel(modelId);
516
+ try { await unloadModel(modelId); } catch {}
517
+ setUnloadingModel(null);
518
+ }
519
+
520
+ async function handleDeleteModel(modelId) {
521
+ setDeletingModel(modelId);
522
+ try { await deleteModel(modelId); } catch {}
523
+ setDeletingModel(null);
524
+ }
525
+
526
+ async function handlePull(modelId) {
527
+ pullModel(modelId);
528
+ }
345
529
 
346
530
  async function handleSearch() {
347
531
  if (!searchQuery.trim()) return;
348
532
  setSearching(true);
349
- setTab('search');
533
+ setDiscoveryTab('search');
350
534
  try {
351
535
  const results = await api.get(`/models/search?q=${encodeURIComponent(searchQuery.trim())}`);
352
536
  setSearchResults(results);
@@ -356,14 +540,19 @@ export default function ModelsView() {
356
540
  setSearching(false);
357
541
  }
358
542
 
359
- async function handleDelete(modelId) {
360
- try {
361
- await api.delete(`/models/${modelId}`);
362
- setInstalled((prev) => prev.filter((m) => m.id !== modelId));
363
- toast.success('Model deleted');
364
- } catch (err) {
365
- toast.error(err.message);
366
- }
543
+ const installedIds = new Set(installedModels.map((m) => m.id));
544
+ const runningIds = new Set(runningModels.map((m) => m.name));
545
+ const catalogByBase = {};
546
+ for (const c of catalog) {
547
+ const base = c.id.split(':')[0];
548
+ catalogByBase[base] = c;
549
+ catalogByBase[c.id] = c;
550
+ }
551
+
552
+ function getCatalogEntry(modelId) {
553
+ if (catalogByBase[modelId]) return catalogByBase[modelId];
554
+ const base = modelId.split(':')[0];
555
+ return catalogByBase[base] || null;
367
556
  }
368
557
 
369
558
  return (
@@ -372,50 +561,29 @@ export default function ModelsView() {
372
561
  <div className="flex-shrink-0 px-5 pt-4 pb-3 border-b border-border space-y-3">
373
562
  <div className="flex items-center justify-between">
374
563
  <h1 className="text-base font-bold font-sans text-text-0">Local Models</h1>
375
- <Badge variant="subtle" className="text-2xs">{installed.length} installed</Badge>
376
- </div>
377
-
378
- <HardwareBar hardware={hardware} />
379
-
380
- {/* Search */}
381
- <div className="flex gap-2">
382
- <div className="relative flex-1">
383
- <Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-4" />
384
- <input
385
- value={searchQuery}
386
- onChange={(e) => setSearchQuery(e.target.value)}
387
- onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
388
- placeholder="Search HuggingFace for GGUF models..."
389
- className="w-full h-8 pl-9 pr-3 text-sm rounded-md bg-surface-1 border border-border text-text-0 font-sans placeholder:text-text-4 focus:outline-none focus:ring-1 focus:ring-accent"
390
- />
564
+ <div className="flex items-center gap-2">
565
+ <Badge variant="subtle" className="text-2xs">{installedModels.length} installed</Badge>
566
+ {runningModels.length > 0 && (
567
+ <Badge variant="success" className="text-2xs">{runningModels.length} running</Badge>
568
+ )}
391
569
  </div>
392
- <Button onClick={handleSearch} disabled={searching} size="sm" variant="accent">
393
- {searching ? <Loader2 size={14} className="animate-spin" /> : 'Search'}
394
- </Button>
395
570
  </div>
396
571
 
397
- {/* Tabs */}
398
- <div className="flex gap-1">
399
- {[
400
- { id: 'recommended', label: `Recommended (${recommended.length})` },
401
- { id: 'installed', label: `Installed (${installed.length})` },
402
- { id: 'search', label: `Search (${searchResults.length})` },
403
- ].map((t) => (
404
- <button
405
- key={t.id}
406
- onClick={() => setTab(t.id)}
407
- className={cn(
408
- 'px-3 py-1 rounded-md text-xs font-sans font-medium transition-colors cursor-pointer',
409
- tab === t.id ? 'bg-accent/12 text-accent' : 'text-text-3 hover:text-text-1 hover:bg-surface-3',
410
- )}
411
- >
412
- {t.label}
413
- </button>
414
- ))}
415
- </div>
572
+ {/* Server Status Bar */}
573
+ <ServerStatusBar
574
+ serverRunning={ollamaStatus.serverRunning}
575
+ installed={ollamaStatus.installed}
576
+ onStart={handleServerStart}
577
+ onStop={handleServerStop}
578
+ onRestart={handleServerRestart}
579
+ actionInProgress={serverAction}
580
+ />
581
+
582
+ {/* Hardware Bar */}
583
+ <HardwareBar hardware={ollamaStatus.hardware} />
416
584
  </div>
417
585
 
418
- {/* Active Downloads */}
586
+ {/* Active Downloads (GGUF) */}
419
587
  {downloads.length > 0 && (
420
588
  <div className="px-5 py-3 border-b border-border space-y-2">
421
589
  <div className="text-xs font-sans font-semibold text-text-2">Downloading</div>
@@ -423,85 +591,188 @@ export default function ModelsView() {
423
591
  </div>
424
592
  )}
425
593
 
594
+ {/* Ollama Pull Progress */}
595
+ {Object.keys(pullProgress).length > 0 && (
596
+ <div className="px-5 py-3 border-b border-border space-y-2">
597
+ <div className="text-xs font-sans font-semibold text-text-2">Pulling Models</div>
598
+ {Object.entries(pullProgress).map(([id, prog]) => (
599
+ <PullProgress key={id} modelId={id} progress={prog} />
600
+ ))}
601
+ </div>
602
+ )}
603
+
426
604
  {/* Content */}
427
605
  <ScrollArea className="flex-1">
428
- <div className="px-5 py-4 space-y-2">
429
- {tab === 'recommended' && (
430
- <>
431
- {recommended.length === 0 ? (
432
- <div className="text-center py-12">
433
- <Cpu size={40} className="mx-auto text-text-4 mb-3" />
434
- <p className="text-sm text-text-2 font-sans font-medium">Detecting hardware...</p>
435
- <p className="text-xs text-text-3 font-sans mt-1">Make sure Ollama is installed so we can check your system.</p>
436
- </div>
437
- ) : (
606
+ <div className="px-5 py-4 space-y-6">
607
+ {/* Running Models Section */}
608
+ <div>
609
+ <SectionHeader title="Running Models" count={runningModels.length} icon={Zap} />
610
+ {runningModels.length === 0 ? (
611
+ <div className="px-4 py-4 bg-surface-1 border border-border-subtle rounded-lg text-center">
612
+ <p className="text-xs text-text-3 font-sans">
613
+ {ollamaStatus.serverRunning
614
+ ? 'No models loaded — start one below'
615
+ : 'Start the server to load models'}
616
+ </p>
617
+ </div>
618
+ ) : (
619
+ <div className="space-y-2">
620
+ {runningModels.map((m) => (
621
+ <RunningModelCard
622
+ key={m.name}
623
+ model={m}
624
+ onUnload={handleUnloadModel}
625
+ onSpawn={spawnFromModel}
626
+ unloading={unloadingModel}
627
+ />
628
+ ))}
629
+ </div>
630
+ )}
631
+ </div>
632
+
633
+ {/* Installed Models Section */}
634
+ <div>
635
+ <SectionHeader title="Installed Models" count={installedModels.length} icon={HardDrive} />
636
+ {installedModels.length === 0 ? (
637
+ <div className="px-4 py-6 bg-surface-1 border border-border-subtle rounded-lg text-center">
638
+ <Box size={32} className="mx-auto text-text-4 mb-2" />
639
+ <p className="text-sm text-text-2 font-sans font-medium">No models installed</p>
640
+ <p className="text-xs text-text-3 font-sans mt-1">
641
+ Pull a model from the Recommended section below, or search HuggingFace.
642
+ </p>
643
+ </div>
644
+ ) : (
645
+ <div className="space-y-2">
646
+ {installedModels.map((m) => (
647
+ <InstalledModelCard
648
+ key={m.id}
649
+ model={m}
650
+ catalogEntry={getCatalogEntry(m.id)}
651
+ isRunning={runningIds.has(m.id)}
652
+ onStart={handleLoadModel}
653
+ onSpawn={spawnFromModel}
654
+ onDelete={handleDeleteModel}
655
+ loading={loadingModel}
656
+ deleting={deletingModel}
657
+ serverRunning={ollamaStatus.serverRunning}
658
+ />
659
+ ))}
660
+ </div>
661
+ )}
662
+ </div>
663
+
664
+ {/* Divider */}
665
+ <div className="border-t border-border-subtle" />
666
+
667
+ {/* Discovery Section */}
668
+ <div>
669
+ <div className="flex items-center justify-between mb-3">
670
+ <span className="text-xs font-semibold font-sans text-text-2 uppercase tracking-wider">Discover Models</span>
671
+ </div>
672
+
673
+ {/* Search */}
674
+ <div className="flex gap-2 mb-3">
675
+ <div className="relative flex-1">
676
+ <Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-4" />
677
+ <input
678
+ value={searchQuery}
679
+ onChange={(e) => setSearchQuery(e.target.value)}
680
+ onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
681
+ placeholder="Search HuggingFace for GGUF models..."
682
+ className="w-full h-8 pl-9 pr-3 text-sm rounded-md bg-surface-1 border border-border text-text-0 font-sans placeholder:text-text-4 focus:outline-none focus:ring-1 focus:ring-accent"
683
+ />
684
+ </div>
685
+ <Button onClick={handleSearch} disabled={searching} size="sm" variant="accent">
686
+ {searching ? <Loader2 size={14} className="animate-spin" /> : 'Search'}
687
+ </Button>
688
+ </div>
689
+
690
+ {/* Tabs */}
691
+ <div className="flex gap-1 mb-3">
692
+ {[
693
+ { id: 'recommended', label: `Recommended (${recommended.length})` },
694
+ { id: 'search', label: `Search (${searchResults.length})` },
695
+ ].map((t) => (
696
+ <button
697
+ key={t.id}
698
+ onClick={() => setDiscoveryTab(t.id)}
699
+ className={cn(
700
+ 'px-3 py-1 rounded-md text-xs font-sans font-medium transition-colors cursor-pointer',
701
+ discoveryTab === t.id ? 'bg-accent/12 text-accent' : 'text-text-3 hover:text-text-1 hover:bg-surface-3',
702
+ )}
703
+ >
704
+ {t.label}
705
+ </button>
706
+ ))}
707
+ </div>
708
+
709
+ {/* Tab content */}
710
+ <div className="space-y-2">
711
+ {discoveryTab === 'recommended' && (
438
712
  <>
439
- <div className="text-xs text-text-3 font-sans mb-2">
440
- Top models for your system ({hardware?.totalRamGb || '?'} GB RAM). Click Pull to download via Ollama.
441
- </div>
442
- {recommended.map((m) => {
443
- // Check if this model (or a variant) is already installed in Ollama
444
- const baseId = m.id.split(':')[0];
445
- const isInstalled = ollamaModels.some((id) => id === m.id || id.startsWith(baseId + ':') || id === baseId);
446
- return (
447
- <RecommendedModel
448
- key={m.id}
449
- model={m}
450
- systemRamGb={hardware?.totalRamGb}
451
- onPull={handlePull}
452
- pulling={pulling}
453
- isInstalled={isInstalled}
454
- />
455
- );
456
- })}
713
+ {recommended.length === 0 ? (
714
+ <div className="text-center py-8">
715
+ <Cpu size={32} className="mx-auto text-text-4 mb-2" />
716
+ <p className="text-sm text-text-2 font-sans font-medium">Detecting hardware...</p>
717
+ <p className="text-xs text-text-3 font-sans mt-1">Make sure Ollama is installed so we can check your system.</p>
718
+ </div>
719
+ ) : (
720
+ <>
721
+ <div className="text-xs text-text-3 font-sans mb-2">
722
+ Top models for your system ({ollamaStatus.hardware?.totalRamGb || '?'} GB RAM). Click Pull to download via Ollama.
723
+ </div>
724
+ {recommended.map((m) => {
725
+ const baseId = m.id.split(':')[0];
726
+ const isInstalled = installedModels.some((im) =>
727
+ im.id === m.id || im.id.startsWith(baseId + ':') || im.id === baseId
728
+ );
729
+ return (
730
+ <RecommendedModel
731
+ key={m.id}
732
+ model={m}
733
+ systemRamGb={ollamaStatus.hardware?.totalRamGb}
734
+ onPull={handlePull}
735
+ pulling={pullProgress[m.id] ? m.id : null}
736
+ isInstalled={isInstalled}
737
+ />
738
+ );
739
+ })}
740
+ </>
741
+ )}
457
742
  </>
458
743
  )}
459
- </>
460
- )}
461
-
462
- {tab === 'installed' && (
463
- <>
464
- {installed.length === 0 ? (
465
- <div className="text-center py-12">
466
- <Box size={40} className="mx-auto text-text-4 mb-3" />
467
- <p className="text-sm text-text-2 font-sans font-medium">No local models yet</p>
468
- <p className="text-xs text-text-3 font-sans mt-1">Search HuggingFace to download GGUF models, or pull models via Ollama.</p>
469
- </div>
470
- ) : (
471
- installed.map((m) => <InstalledModel key={m.id} model={m} onDelete={handleDelete} />)
472
- )}
473
- </>
474
- )}
475
744
 
476
- {tab === 'search' && (
477
- <>
478
- {searching ? (
479
- <div className="text-center py-12">
480
- <Loader2 size={24} className="mx-auto text-accent animate-spin mb-3" />
481
- <p className="text-sm text-text-3 font-sans">Searching HuggingFace...</p>
482
- </div>
483
- ) : searchResults.length === 0 ? (
484
- <div className="text-center py-12">
485
- <Search size={40} className="mx-auto text-text-4 mb-3" />
486
- <p className="text-sm text-text-2 font-sans font-medium">Search for GGUF models</p>
487
- <p className="text-xs text-text-3 font-sans mt-1">Try "qwen coder", "deepseek", "codestral", "llama"</p>
488
- </div>
489
- ) : (
490
- searchResults.map((r) => (
491
- <div key={r.id} className="space-y-1">
492
- <SearchResult
493
- result={r}
494
- expanded={expandedResult === r.id}
495
- onExpand={setExpandedResult}
496
- />
497
- {expandedResult === r.id && (
498
- <FilePicker repoId={r.id} onDownload={() => fetchInstalled()} systemRamGb={hardware?.totalRamGb} />
499
- )}
500
- </div>
501
- ))
745
+ {discoveryTab === 'search' && (
746
+ <>
747
+ {searching ? (
748
+ <div className="text-center py-8">
749
+ <Loader2 size={24} className="mx-auto text-accent animate-spin mb-2" />
750
+ <p className="text-sm text-text-3 font-sans">Searching HuggingFace...</p>
751
+ </div>
752
+ ) : searchResults.length === 0 ? (
753
+ <div className="text-center py-8">
754
+ <Search size={32} className="mx-auto text-text-4 mb-2" />
755
+ <p className="text-sm text-text-2 font-sans font-medium">Search for GGUF models</p>
756
+ <p className="text-xs text-text-3 font-sans mt-1">Try "qwen coder", "deepseek", "codestral", "llama"</p>
757
+ </div>
758
+ ) : (
759
+ searchResults.map((r) => (
760
+ <div key={r.id} className="space-y-1">
761
+ <SearchResult
762
+ result={r}
763
+ expanded={expandedResult === r.id}
764
+ onExpand={setExpandedResult}
765
+ />
766
+ {expandedResult === r.id && (
767
+ <FilePicker repoId={r.id} systemRamGb={ollamaStatus.hardware?.totalRamGb} />
768
+ )}
769
+ </div>
770
+ ))
771
+ )}
772
+ </>
502
773
  )}
503
- </>
504
- )}
774
+ </div>
775
+ </div>
505
776
  </div>
506
777
  </ScrollArea>
507
778
  </div>