groove-dev 0.27.124 → 0.27.126

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 (33) 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-Do3uUrEW.css +1 -0
  9. package/node_modules/@groove-dev/gui/dist/assets/{index-BcmoHTm0.js → index-oPlKeRNb.js} +1749 -1749
  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 +580 -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-Do3uUrEW.css +1 -0
  25. package/packages/gui/dist/assets/{index-BcmoHTm0.js → index-oPlKeRNb.js} +1749 -1749
  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 +580 -236
  32. package/node_modules/@groove-dev/gui/dist/assets/index-DWI-g_Sm.css +0 -1
  33. package/packages/gui/dist/assets/index-DWI-g_Sm.css +0 -1
@@ -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,145 @@ 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
+
223
+ // ---- Downloaded GGUF Model Card ----
224
+ function GgufModelCard({ model, onDelete, deleting }) {
225
+ return (
226
+ <div className="flex items-center gap-3 px-4 py-3 bg-surface-1 border border-border-subtle rounded-lg">
227
+ <Box size={18} className="flex-shrink-0 text-purple-400" />
228
+ <div className="flex-1 min-w-0">
229
+ <div className="flex items-center gap-2">
230
+ <span className="text-sm font-mono font-bold text-text-0 truncate">{model.id}</span>
231
+ {model.quantization && (
232
+ <Badge variant="subtle" className="text-2xs">{model.quantization}</Badge>
233
+ )}
234
+ {model.parameters && (
235
+ <span className="text-2xs font-semibold text-blue-400">{model.parameters}</span>
236
+ )}
237
+ <Badge variant="accent" className="text-2xs">GGUF</Badge>
238
+ </div>
239
+ <div className="text-2xs text-text-3 font-sans mt-0.5">
240
+ {model.sizeBytes ? formatBytes(model.sizeBytes) : '—'}
241
+ {model.repoId && <> &middot; {model.repoId}</>}
242
+ {model.contextWindow && <> &middot; {(model.contextWindow / 1024).toFixed(0)}K context</>}
243
+ </div>
244
+ </div>
245
+ <div className="flex items-center gap-1">
246
+ <button
247
+ onClick={() => onDelete(model.id)}
248
+ disabled={deleting === model.id}
249
+ 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"
250
+ title="Delete model"
251
+ >
252
+ {deleting === model.id ? <Loader2 size={13} className="animate-spin" /> : <Trash2 size={13} />}
253
+ </button>
254
+ </div>
255
+ </div>
256
+ );
257
+ }
258
+
57
259
  // ---- Download Progress Bar ----
58
260
  function DownloadProgress({ download }) {
59
261
  const pct = Math.round((download.percent || 0) * 100);
@@ -73,33 +275,57 @@ function DownloadProgress({ download }) {
73
275
  );
74
276
  }
75
277
 
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' };
278
+ // ---- Pull Progress (Ollama) ----
279
+ function PullProgress({ modelId, progress }) {
280
+ return (
281
+ <div className="flex items-center gap-2 px-4 py-2 bg-accent/5 border border-accent/20 rounded-lg">
282
+ <Loader2 size={14} className="animate-spin text-accent flex-shrink-0" />
283
+ <div className="flex-1 min-w-0">
284
+ <span className="text-xs font-mono text-text-0">{modelId}</span>
285
+ <div className="text-2xs text-text-3 font-sans truncate">{progress.progress || 'Pulling...'}</div>
286
+ </div>
287
+ </div>
288
+ );
289
+ }
290
+
291
+ // ---- Recommended Model Card ----
292
+ function RecommendedModel({ model, systemRamGb, onPull, pulling, isInstalled }) {
293
+ const categoryIcons = { code: '{}', general: 'AI' };
294
+ const headroom = systemRamGb ? Math.round((1 - model.ramGb / systemRamGb) * 100) : null;
80
295
 
81
296
  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" />
297
+ <div className={cn(
298
+ 'flex items-center gap-3 px-4 py-3 border rounded-lg transition-colors',
299
+ isInstalled ? 'bg-success/5 border-success/20' : 'bg-surface-1 border-border-subtle hover:border-accent/20',
300
+ )}>
301
+ <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">
302
+ {categoryIcons[model.category] || 'AI'}
303
+ </div>
84
304
  <div className="flex-1 min-w-0">
85
305
  <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>
306
+ <span className="text-sm font-mono font-bold text-text-0 truncate">{model.name}</span>
307
+ <span className={cn('text-2xs font-semibold capitalize', TIER_COLORS[model.tier])}>{model.tier}</span>
308
+ {isInstalled && <Badge variant="success" className="text-2xs gap-1"><Check size={8} /> Installed</Badge>}
90
309
  </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>}
310
+ <div className="text-2xs text-text-3 font-sans mt-0.5">{model.description}</div>
311
+ <div className="flex items-center gap-3 mt-1 text-2xs font-sans">
312
+ <span className="text-text-2">{model.sizeGb} GB download</span>
313
+ <span className="text-green-400 font-medium">{model.ramGb} GB RAM</span>
314
+ {headroom !== null && <span className="text-text-4">{headroom}% headroom</span>}
94
315
  </div>
95
316
  </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>
317
+ {isInstalled ? (
318
+ <span className="text-xs text-success font-sans font-medium px-3 py-1.5">Ready</span>
319
+ ) : (
320
+ <button
321
+ onClick={() => onPull(model.id)}
322
+ disabled={pulling === model.id}
323
+ 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"
324
+ >
325
+ {pulling === model.id ? <Loader2 size={12} className="animate-spin" /> : <Download size={12} />}
326
+ Pull
327
+ </button>
328
+ )}
103
329
  </div>
104
330
  );
105
331
  }
@@ -199,44 +425,14 @@ function FilePicker({ repoId, onDownload, systemRamGb }) {
199
425
  );
200
426
  }
201
427
 
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
-
428
+ // ---- Section Header ----
429
+ function SectionHeader({ title, count, icon: Icon }) {
208
430
  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>
431
+ <div className="flex items-center gap-2 mb-2">
432
+ {Icon && <Icon size={14} className="text-text-3" />}
433
+ <span className="text-xs font-semibold font-sans text-text-2 uppercase tracking-wider">{title}</span>
434
+ {count !== undefined && (
435
+ <Badge variant="subtle" className="text-2xs">{count}</Badge>
240
436
  )}
241
437
  </div>
242
438
  );
@@ -244,74 +440,64 @@ function RecommendedModel({ model, systemRamGb, onPull, pulling, isInstalled })
244
440
 
245
441
  // ---- Main View ----
246
442
  export default function ModelsView() {
247
- const [tab, setTab] = useState('recommended'); // recommended | installed | search
443
+ const [discoveryTab, setDiscoveryTab] = useState('recommended');
248
444
  const [searchQuery, setSearchQuery] = useState('');
249
445
  const [searchResults, setSearchResults] = useState([]);
250
446
  const [searching, setSearching] = useState(false);
251
- const [installed, setInstalled] = useState([]);
252
447
  const [recommended, setRecommended] = useState([]);
253
448
  const [downloads, setDownloads] = useState([]);
254
- const [hardware, setHardware] = useState(null);
255
449
  const [expandedResult, setExpandedResult] = useState(null);
256
- const [pulling, setPulling] = useState(null);
257
- const [ollamaModels, setOllamaModels] = useState([]);
450
+ const [serverAction, setServerAction] = useState(null);
451
+ const [loadingModel, setLoadingModel] = useState(null);
452
+ const [unloadingModel, setUnloadingModel] = useState(null);
453
+ const [deletingModel, setDeletingModel] = useState(null);
454
+ const [ggufModels, setGgufModels] = useState([]);
455
+ const [deletingGguf, setDeletingGguf] = useState(null);
258
456
  const toast = useToast();
259
457
 
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
- }, []);
458
+ const ollamaStatus = useGrooveStore((s) => s.ollamaStatus);
459
+ const installedModels = useGrooveStore((s) => s.ollamaInstalledModels);
460
+ const runningModels = useGrooveStore((s) => s.ollamaRunningModels);
461
+ const catalog = useGrooveStore((s) => s.ollamaCatalog);
462
+ const pullProgress = useGrooveStore((s) => s.ollamaPullProgress);
463
+ const fetchOllamaStatus = useGrooveStore((s) => s.fetchOllamaStatus);
464
+ const startServer = useGrooveStore((s) => s.startOllamaServer);
465
+ const stopServer = useGrooveStore((s) => s.stopOllamaServer);
466
+ const restartServer = useGrooveStore((s) => s.restartOllamaServer);
467
+ const pullModel = useGrooveStore((s) => s.pullOllamaModel);
468
+ const deleteModel = useGrooveStore((s) => s.deleteOllamaModel);
469
+ const loadModel = useGrooveStore((s) => s.loadOllamaModel);
470
+ const unloadModel = useGrooveStore((s) => s.unloadOllamaModel);
471
+ const spawnFromModel = useGrooveStore((s) => s.spawnFromModel);
472
+
473
+ const pollingRef = useRef(null);
474
+
475
+ // Fetch status on mount and poll every 10s
476
+ useEffect(() => {
477
+ fetchOllamaStatus();
478
+ pollingRef.current = setInterval(fetchOllamaStatus, 10000);
479
+ return () => clearInterval(pollingRef.current);
480
+ }, [fetchOllamaStatus]);
272
481
 
273
- // Fetch hardware info + recommended models + Ollama installed
482
+ // Fetch recommended models and GGUF downloads
274
483
  useEffect(() => {
275
- api.get('/providers/ollama/hardware').then(setHardware).catch(() => {});
276
484
  api.get('/models/recommended').then((data) => {
277
485
  setRecommended(data.models || []);
278
- if (!hardware && data.hardware) setHardware(data.hardware);
279
486
  }).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
- }
487
+ api.get('/models/installed').then((data) => {
488
+ setGgufModels((data.models || []).filter((m) => m.exists));
489
+ }).catch(() => {});
490
+ }, []);
299
491
 
300
- // Listen for download progress via WebSocket
492
+ // Poll active GGUF downloads
301
493
  useEffect(() => {
302
- const unsub = useGrooveStore.subscribe((state, prev) => {
303
- // Refresh on model events
304
- });
305
-
306
- // Poll active downloads
307
494
  const poll = setInterval(() => {
308
495
  api.get('/models/downloads').then(setDownloads).catch(() => {});
309
496
  }, 2000);
310
-
311
- return () => { unsub(); clearInterval(poll); };
497
+ return () => clearInterval(poll);
312
498
  }, []);
313
499
 
314
- // WebSocket events for download progress
500
+ // WebSocket events for GGUF download progress
315
501
  useEffect(() => {
316
502
  function handleWs(event) {
317
503
  try {
@@ -329,8 +515,10 @@ export default function ModelsView() {
329
515
  }
330
516
  if (msg.type === 'model:download:complete') {
331
517
  setDownloads((prev) => prev.filter((d) => d.filename !== msg.data.filename));
332
- fetchInstalled();
333
518
  toast.success(`${msg.data.filename} downloaded`);
519
+ api.get('/models/installed').then((data) => {
520
+ setGgufModels((data.models || []).filter((m) => m.exists));
521
+ }).catch(() => {});
334
522
  }
335
523
  if (msg.type === 'model:download:error') {
336
524
  setDownloads((prev) => prev.filter((d) => d.filename !== msg.data.filename));
@@ -338,15 +526,67 @@ export default function ModelsView() {
338
526
  }
339
527
  } catch {}
340
528
  }
341
- const ws = useGrooveStore.getState()._ws;
529
+ const ws = useGrooveStore.getState().ws;
342
530
  if (ws) ws.addEventListener('message', handleWs);
343
531
  return () => { if (ws) ws.removeEventListener('message', handleWs); };
344
- }, [fetchInstalled, toast]);
532
+ }, [toast]);
533
+
534
+ async function handleServerStart() {
535
+ setServerAction('starting');
536
+ try { await startServer(); } catch {}
537
+ setServerAction(null);
538
+ }
539
+
540
+ async function handleServerStop() {
541
+ setServerAction('stopping');
542
+ try { await stopServer(); } catch {}
543
+ setServerAction(null);
544
+ }
545
+
546
+ async function handleServerRestart() {
547
+ setServerAction('restarting');
548
+ try { await restartServer(); } catch {}
549
+ setServerAction(null);
550
+ }
551
+
552
+ async function handleLoadModel(modelId) {
553
+ setLoadingModel(modelId);
554
+ try { await loadModel(modelId); } catch {}
555
+ setLoadingModel(null);
556
+ }
557
+
558
+ async function handleUnloadModel(modelId) {
559
+ setUnloadingModel(modelId);
560
+ try { await unloadModel(modelId); } catch {}
561
+ setUnloadingModel(null);
562
+ }
563
+
564
+ async function handleDeleteModel(modelId) {
565
+ setDeletingModel(modelId);
566
+ try { await deleteModel(modelId); } catch {}
567
+ setDeletingModel(null);
568
+ }
569
+
570
+ async function handleDeleteGguf(modelId) {
571
+ setDeletingGguf(modelId);
572
+ try {
573
+ await api.delete(`/models/${encodeURIComponent(modelId)}`);
574
+ setGgufModels((prev) => prev.filter((m) => m.id !== modelId));
575
+ toast.success(`Removed ${modelId}`);
576
+ } catch (err) {
577
+ toast.error(`Delete failed: ${err.message}`);
578
+ }
579
+ setDeletingGguf(null);
580
+ }
581
+
582
+ async function handlePull(modelId) {
583
+ pullModel(modelId);
584
+ }
345
585
 
346
586
  async function handleSearch() {
347
587
  if (!searchQuery.trim()) return;
348
588
  setSearching(true);
349
- setTab('search');
589
+ setDiscoveryTab('search');
350
590
  try {
351
591
  const results = await api.get(`/models/search?q=${encodeURIComponent(searchQuery.trim())}`);
352
592
  setSearchResults(results);
@@ -356,14 +596,19 @@ export default function ModelsView() {
356
596
  setSearching(false);
357
597
  }
358
598
 
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
- }
599
+ const installedIds = new Set(installedModels.map((m) => m.id));
600
+ const runningIds = new Set(runningModels.map((m) => m.name));
601
+ const catalogByBase = {};
602
+ for (const c of catalog) {
603
+ const base = c.id.split(':')[0];
604
+ catalogByBase[base] = c;
605
+ catalogByBase[c.id] = c;
606
+ }
607
+
608
+ function getCatalogEntry(modelId) {
609
+ if (catalogByBase[modelId]) return catalogByBase[modelId];
610
+ const base = modelId.split(':')[0];
611
+ return catalogByBase[base] || null;
367
612
  }
368
613
 
369
614
  return (
@@ -372,50 +617,29 @@ export default function ModelsView() {
372
617
  <div className="flex-shrink-0 px-5 pt-4 pb-3 border-b border-border space-y-3">
373
618
  <div className="flex items-center justify-between">
374
619
  <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
- />
620
+ <div className="flex items-center gap-2">
621
+ <Badge variant="subtle" className="text-2xs">{installedModels.length + ggufModels.length} installed</Badge>
622
+ {runningModels.length > 0 && (
623
+ <Badge variant="success" className="text-2xs">{runningModels.length} running</Badge>
624
+ )}
391
625
  </div>
392
- <Button onClick={handleSearch} disabled={searching} size="sm" variant="accent">
393
- {searching ? <Loader2 size={14} className="animate-spin" /> : 'Search'}
394
- </Button>
395
626
  </div>
396
627
 
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>
628
+ {/* Server Status Bar */}
629
+ <ServerStatusBar
630
+ serverRunning={ollamaStatus.serverRunning}
631
+ installed={ollamaStatus.installed}
632
+ onStart={handleServerStart}
633
+ onStop={handleServerStop}
634
+ onRestart={handleServerRestart}
635
+ actionInProgress={serverAction}
636
+ />
637
+
638
+ {/* Hardware Bar */}
639
+ <HardwareBar hardware={ollamaStatus.hardware} />
416
640
  </div>
417
641
 
418
- {/* Active Downloads */}
642
+ {/* Active Downloads (GGUF) */}
419
643
  {downloads.length > 0 && (
420
644
  <div className="px-5 py-3 border-b border-border space-y-2">
421
645
  <div className="text-xs font-sans font-semibold text-text-2">Downloading</div>
@@ -423,85 +647,205 @@ export default function ModelsView() {
423
647
  </div>
424
648
  )}
425
649
 
650
+ {/* Ollama Pull Progress */}
651
+ {Object.keys(pullProgress).length > 0 && (
652
+ <div className="px-5 py-3 border-b border-border space-y-2">
653
+ <div className="text-xs font-sans font-semibold text-text-2">Pulling Models</div>
654
+ {Object.entries(pullProgress).map(([id, prog]) => (
655
+ <PullProgress key={id} modelId={id} progress={prog} />
656
+ ))}
657
+ </div>
658
+ )}
659
+
426
660
  {/* Content */}
427
661
  <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
- ) : (
438
- <>
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
- })}
457
- </>
458
- )}
459
- </>
662
+ <div className="px-5 py-4 space-y-6">
663
+ {/* Running Models Section */}
664
+ <div>
665
+ <SectionHeader title="Running Models" count={runningModels.length} icon={Zap} />
666
+ {runningModels.length === 0 ? (
667
+ <div className="px-4 py-4 bg-surface-1 border border-border-subtle rounded-lg text-center">
668
+ <p className="text-xs text-text-3 font-sans">
669
+ {ollamaStatus.serverRunning
670
+ ? 'No models loaded — start one below'
671
+ : 'Start the server to load models'}
672
+ </p>
673
+ </div>
674
+ ) : (
675
+ <div className="space-y-2">
676
+ {runningModels.map((m) => (
677
+ <RunningModelCard
678
+ key={m.name}
679
+ model={m}
680
+ onUnload={handleUnloadModel}
681
+ onSpawn={spawnFromModel}
682
+ unloading={unloadingModel}
683
+ />
684
+ ))}
685
+ </div>
686
+ )}
687
+ </div>
688
+
689
+ {/* Installed Models Section */}
690
+ <div>
691
+ <SectionHeader title="Installed Models" count={installedModels.length} icon={HardDrive} />
692
+ {installedModels.length === 0 ? (
693
+ <div className="px-4 py-6 bg-surface-1 border border-border-subtle rounded-lg text-center">
694
+ <Box size={32} className="mx-auto text-text-4 mb-2" />
695
+ <p className="text-sm text-text-2 font-sans font-medium">No models installed</p>
696
+ <p className="text-xs text-text-3 font-sans mt-1">
697
+ Pull a model from the Recommended section below, or search HuggingFace.
698
+ </p>
699
+ </div>
700
+ ) : (
701
+ <div className="space-y-2">
702
+ {installedModels.map((m) => (
703
+ <InstalledModelCard
704
+ key={m.id}
705
+ model={m}
706
+ catalogEntry={getCatalogEntry(m.id)}
707
+ isRunning={runningIds.has(m.id)}
708
+ onStart={handleLoadModel}
709
+ onSpawn={spawnFromModel}
710
+ onDelete={handleDeleteModel}
711
+ loading={loadingModel}
712
+ deleting={deletingModel}
713
+ serverRunning={ollamaStatus.serverRunning}
714
+ />
715
+ ))}
716
+ </div>
717
+ )}
718
+ </div>
719
+
720
+ {/* Downloaded GGUF Models Section */}
721
+ {ggufModels.length > 0 && (
722
+ <div>
723
+ <SectionHeader title="Downloaded Models (GGUF)" count={ggufModels.length} icon={Download} />
724
+ <div className="space-y-2">
725
+ {ggufModels.map((m) => (
726
+ <GgufModelCard
727
+ key={m.id}
728
+ model={m}
729
+ onDelete={handleDeleteGguf}
730
+ deleting={deletingGguf}
731
+ />
732
+ ))}
733
+ </div>
734
+ </div>
460
735
  )}
461
736
 
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} />)
737
+ {/* Divider */}
738
+ <div className="border-t border-border-subtle" />
739
+
740
+ {/* Discovery Section */}
741
+ <div>
742
+ <div className="flex items-center justify-between mb-3">
743
+ <span className="text-xs font-semibold font-sans text-text-2 uppercase tracking-wider">Discover Models</span>
744
+ </div>
745
+
746
+ {/* Search */}
747
+ <div className="flex gap-2 mb-3">
748
+ <div className="relative flex-1">
749
+ <Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-4" />
750
+ <input
751
+ value={searchQuery}
752
+ onChange={(e) => setSearchQuery(e.target.value)}
753
+ onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
754
+ placeholder="Search HuggingFace for GGUF models..."
755
+ 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"
756
+ />
757
+ </div>
758
+ <Button onClick={handleSearch} disabled={searching} size="sm" variant="accent">
759
+ {searching ? <Loader2 size={14} className="animate-spin" /> : 'Search'}
760
+ </Button>
761
+ </div>
762
+
763
+ {/* Tabs */}
764
+ <div className="flex gap-1 mb-3">
765
+ {[
766
+ { id: 'recommended', label: `Recommended (${recommended.length})` },
767
+ { id: 'search', label: `Search (${searchResults.length})` },
768
+ ].map((t) => (
769
+ <button
770
+ key={t.id}
771
+ onClick={() => setDiscoveryTab(t.id)}
772
+ className={cn(
773
+ 'px-3 py-1 rounded-md text-xs font-sans font-medium transition-colors cursor-pointer',
774
+ discoveryTab === t.id ? 'bg-accent/12 text-accent' : 'text-text-3 hover:text-text-1 hover:bg-surface-3',
775
+ )}
776
+ >
777
+ {t.label}
778
+ </button>
779
+ ))}
780
+ </div>
781
+
782
+ {/* Tab content */}
783
+ <div className="space-y-2">
784
+ {discoveryTab === 'recommended' && (
785
+ <>
786
+ {recommended.length === 0 ? (
787
+ <div className="text-center py-8">
788
+ <Cpu size={32} className="mx-auto text-text-4 mb-2" />
789
+ <p className="text-sm text-text-2 font-sans font-medium">Detecting hardware...</p>
790
+ <p className="text-xs text-text-3 font-sans mt-1">Make sure Ollama is installed so we can check your system.</p>
791
+ </div>
792
+ ) : (
793
+ <>
794
+ <div className="text-xs text-text-3 font-sans mb-2">
795
+ Top models for your system ({ollamaStatus.hardware?.totalRamGb || '?'} GB RAM). Click Pull to download via Ollama.
796
+ </div>
797
+ {recommended.map((m) => {
798
+ const baseId = m.id.split(':')[0];
799
+ const isInstalled = installedModels.some((im) =>
800
+ im.id === m.id || im.id.startsWith(baseId + ':') || im.id === baseId
801
+ );
802
+ return (
803
+ <RecommendedModel
804
+ key={m.id}
805
+ model={m}
806
+ systemRamGb={ollamaStatus.hardware?.totalRamGb}
807
+ onPull={handlePull}
808
+ pulling={pullProgress[m.id] ? m.id : null}
809
+ isInstalled={isInstalled}
810
+ />
811
+ );
812
+ })}
813
+ </>
814
+ )}
815
+ </>
472
816
  )}
473
- </>
474
- )}
475
817
 
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
- ))
818
+ {discoveryTab === 'search' && (
819
+ <>
820
+ {searching ? (
821
+ <div className="text-center py-8">
822
+ <Loader2 size={24} className="mx-auto text-accent animate-spin mb-2" />
823
+ <p className="text-sm text-text-3 font-sans">Searching HuggingFace...</p>
824
+ </div>
825
+ ) : searchResults.length === 0 ? (
826
+ <div className="text-center py-8">
827
+ <Search size={32} className="mx-auto text-text-4 mb-2" />
828
+ <p className="text-sm text-text-2 font-sans font-medium">Search for GGUF models</p>
829
+ <p className="text-xs text-text-3 font-sans mt-1">Try "qwen coder", "deepseek", "codestral", "llama"</p>
830
+ </div>
831
+ ) : (
832
+ searchResults.map((r) => (
833
+ <div key={r.id} className="space-y-1">
834
+ <SearchResult
835
+ result={r}
836
+ expanded={expandedResult === r.id}
837
+ onExpand={setExpandedResult}
838
+ />
839
+ {expandedResult === r.id && (
840
+ <FilePicker repoId={r.id} systemRamGb={ollamaStatus.hardware?.totalRamGb} />
841
+ )}
842
+ </div>
843
+ ))
844
+ )}
845
+ </>
502
846
  )}
503
- </>
504
- )}
847
+ </div>
848
+ </div>
505
849
  </div>
506
850
  </ScrollArea>
507
851
  </div>