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.
- package/node_modules/@groove-dev/cli/package.json +1 -1
- package/node_modules/@groove-dev/daemon/package.json +1 -1
- package/node_modules/@groove-dev/daemon/src/api.js +122 -0
- package/node_modules/@groove-dev/daemon/src/preview.js +28 -5
- package/node_modules/@groove-dev/daemon/src/process.js +21 -0
- package/node_modules/@groove-dev/daemon/src/providers/local.js +19 -20
- package/node_modules/@groove-dev/daemon/src/providers/ollama.js +66 -3
- package/node_modules/@groove-dev/gui/dist/assets/index-Do3uUrEW.css +1 -0
- package/node_modules/@groove-dev/gui/dist/assets/{index-BcmoHTm0.js → index-oPlKeRNb.js} +1749 -1749
- package/node_modules/@groove-dev/gui/dist/index.html +2 -2
- package/node_modules/@groove-dev/gui/package.json +1 -1
- package/node_modules/@groove-dev/gui/src/components/agents/spawn-wizard.jsx +66 -6
- package/node_modules/@groove-dev/gui/src/stores/groove.js +169 -0
- package/node_modules/@groove-dev/gui/src/views/agents.jsx +8 -10
- package/node_modules/@groove-dev/gui/src/views/models.jsx +580 -236
- package/package.json +1 -1
- package/packages/cli/package.json +1 -1
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/api.js +122 -0
- package/packages/daemon/src/preview.js +28 -5
- package/packages/daemon/src/process.js +21 -0
- package/packages/daemon/src/providers/local.js +19 -20
- package/packages/daemon/src/providers/ollama.js +66 -3
- package/packages/gui/dist/assets/index-Do3uUrEW.css +1 -0
- package/packages/gui/dist/assets/{index-BcmoHTm0.js → index-oPlKeRNb.js} +1749 -1749
- package/packages/gui/dist/index.html +2 -2
- package/packages/gui/package.json +1 -1
- package/packages/gui/src/components/agents/spawn-wizard.jsx +66 -6
- package/packages/gui/src/stores/groove.js +169 -0
- package/packages/gui/src/views/agents.jsx +8 -10
- package/packages/gui/src/views/models.jsx +580 -236
- package/node_modules/@groove-dev/gui/dist/assets/index-DWI-g_Sm.css +0 -1
- 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,
|
|
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-
|
|
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.
|
|
49
|
-
<
|
|
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 · 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 && <> · ~{catalogEntry.ramGb} GB RAM needed</>}
|
|
187
|
+
{catalogEntry?.description && <> · {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 && <> · {model.repoId}</>}
|
|
242
|
+
{model.contextWindow && <> · {(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
|
-
// ----
|
|
77
|
-
function
|
|
78
|
-
|
|
79
|
-
|
|
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=
|
|
83
|
-
|
|
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.
|
|
87
|
-
|
|
88
|
-
{
|
|
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
|
-
|
|
93
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
// ----
|
|
203
|
-
function
|
|
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=
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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 [
|
|
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 [
|
|
257
|
-
const [
|
|
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
|
-
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
//
|
|
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().
|
|
529
|
+
const ws = useGrooveStore.getState().ws;
|
|
342
530
|
if (ws) ws.addEventListener('message', handleWs);
|
|
343
531
|
return () => { if (ws) ws.removeEventListener('message', handleWs); };
|
|
344
|
-
}, [
|
|
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
|
-
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
<
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
{/*
|
|
398
|
-
<
|
|
399
|
-
{
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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-
|
|
429
|
-
{
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
-
{
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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>
|