groove-dev 0.27.140 → 0.27.142
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/integrations-registry.json +12 -44
- package/node_modules/@groove-dev/daemon/package.json +1 -1
- package/node_modules/@groove-dev/daemon/src/api.js +100 -23
- package/node_modules/@groove-dev/daemon/src/integrations.js +10 -0
- package/node_modules/@groove-dev/daemon/src/introducer.js +1 -1
- package/node_modules/@groove-dev/daemon/src/journalist.js +171 -1
- package/node_modules/@groove-dev/daemon/src/keeper.js +2 -2
- package/node_modules/@groove-dev/daemon/src/memory.js +8 -5
- package/node_modules/@groove-dev/daemon/src/model-lab.js +11 -0
- package/node_modules/@groove-dev/daemon/src/process.js +65 -0
- package/node_modules/@groove-dev/daemon/src/rotator.js +25 -8
- package/node_modules/@groove-dev/daemon/src/validate.js +8 -0
- package/node_modules/@groove-dev/gui/dist/assets/{codemirror-BQqYnZfL.js → codemirror-BYKpdS2W.js} +10 -10
- package/node_modules/@groove-dev/gui/dist/assets/index-Bjd91ufV.js +984 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-BqdwIFn4.css +1 -0
- package/node_modules/@groove-dev/gui/dist/index.html +3 -3
- package/node_modules/@groove-dev/gui/package.json +1 -1
- package/node_modules/@groove-dev/gui/src/app.jsx +0 -2
- package/node_modules/@groove-dev/gui/src/components/agents/agent-chat.jsx +3 -4
- package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +8 -2
- package/node_modules/@groove-dev/gui/src/components/agents/agent-file-tree.jsx +12 -8
- package/node_modules/@groove-dev/gui/src/components/agents/agent-panel.jsx +79 -5
- package/node_modules/@groove-dev/gui/src/components/agents/code-review.jsx +5 -4
- package/node_modules/@groove-dev/gui/src/components/agents/workspace-mode.jsx +109 -12
- package/node_modules/@groove-dev/gui/src/components/dashboard/context-gauges.jsx +111 -0
- package/node_modules/@groove-dev/gui/src/components/dashboard/routing-chart.jsx +70 -33
- package/node_modules/@groove-dev/gui/src/components/editor/ai-panel.jsx +77 -6
- package/node_modules/@groove-dev/gui/src/components/editor/code-editor.jsx +2 -68
- package/node_modules/@groove-dev/gui/src/components/editor/file-tree.jsx +2 -49
- package/node_modules/@groove-dev/gui/src/components/editor/terminal.jsx +15 -4
- package/node_modules/@groove-dev/gui/src/components/keeper/global-modals.jsx +10 -10
- package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +1 -2
- package/node_modules/@groove-dev/gui/src/components/layout/terminal-panel.jsx +151 -3
- package/node_modules/@groove-dev/gui/src/components/marketplace/integration-wizard.jsx +223 -18
- package/node_modules/@groove-dev/gui/src/stores/groove.js +107 -29
- package/node_modules/@groove-dev/gui/src/views/agents.jsx +114 -56
- package/node_modules/@groove-dev/gui/src/views/dashboard.jsx +2 -0
- package/node_modules/@groove-dev/gui/src/views/marketplace.jsx +3 -71
- package/node_modules/@groove-dev/gui/src/views/memory.jsx +9 -9
- package/node_modules/@groove-dev/gui/src/views/model-lab.jsx +1 -6
- package/node_modules/@groove-dev/gui/src/views/models.jsx +658 -565
- package/package.json +1 -1
- package/packages/cli/package.json +1 -1
- package/packages/daemon/integrations-registry.json +12 -44
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/api.js +100 -23
- package/packages/daemon/src/integrations.js +10 -0
- package/packages/daemon/src/introducer.js +1 -1
- package/packages/daemon/src/journalist.js +171 -1
- package/packages/daemon/src/keeper.js +2 -2
- package/packages/daemon/src/memory.js +8 -5
- package/packages/daemon/src/model-lab.js +11 -0
- package/packages/daemon/src/process.js +65 -0
- package/packages/daemon/src/rotator.js +25 -8
- package/packages/daemon/src/validate.js +8 -0
- package/packages/gui/dist/assets/{codemirror-BQqYnZfL.js → codemirror-BYKpdS2W.js} +10 -10
- package/packages/gui/dist/assets/index-Bjd91ufV.js +984 -0
- package/packages/gui/dist/assets/index-BqdwIFn4.css +1 -0
- package/packages/gui/dist/index.html +3 -3
- package/packages/gui/package.json +1 -1
- package/packages/gui/src/app.jsx +0 -2
- package/packages/gui/src/components/agents/agent-chat.jsx +3 -4
- package/packages/gui/src/components/agents/agent-feed.jsx +8 -2
- package/packages/gui/src/components/agents/agent-file-tree.jsx +12 -8
- package/packages/gui/src/components/agents/agent-panel.jsx +79 -5
- package/packages/gui/src/components/agents/code-review.jsx +5 -4
- package/packages/gui/src/components/agents/workspace-mode.jsx +109 -12
- package/packages/gui/src/components/dashboard/context-gauges.jsx +111 -0
- package/packages/gui/src/components/dashboard/routing-chart.jsx +70 -33
- package/packages/gui/src/components/editor/ai-panel.jsx +77 -6
- package/packages/gui/src/components/editor/code-editor.jsx +2 -68
- package/packages/gui/src/components/editor/file-tree.jsx +2 -49
- package/packages/gui/src/components/editor/terminal.jsx +15 -4
- package/packages/gui/src/components/keeper/global-modals.jsx +10 -10
- package/packages/gui/src/components/layout/activity-bar.jsx +1 -2
- package/packages/gui/src/components/layout/terminal-panel.jsx +151 -3
- package/packages/gui/src/components/marketplace/integration-wizard.jsx +223 -18
- package/packages/gui/src/stores/groove.js +107 -29
- package/packages/gui/src/views/agents.jsx +114 -56
- package/packages/gui/src/views/dashboard.jsx +2 -0
- package/packages/gui/src/views/marketplace.jsx +3 -71
- package/packages/gui/src/views/memory.jsx +9 -9
- package/packages/gui/src/views/model-lab.jsx +1 -6
- package/packages/gui/src/views/models.jsx +658 -565
- package/plan_files/keeper-manual.md +53 -42
- package/node_modules/@groove-dev/gui/dist/assets/index-BV9CAiw1.css +0 -1
- package/node_modules/@groove-dev/gui/dist/assets/index-DK6UIz0n.js +0 -8698
- package/node_modules/@groove-dev/gui/src/components/toys/toy-card.jsx +0 -78
- package/node_modules/@groove-dev/gui/src/components/toys/toy-creator.jsx +0 -144
- package/node_modules/@groove-dev/gui/src/components/toys/toy-launcher.jsx +0 -187
- package/node_modules/@groove-dev/gui/src/views/toys.jsx +0 -162
- package/packages/gui/dist/assets/index-BV9CAiw1.css +0 -1
- package/packages/gui/dist/assets/index-DK6UIz0n.js +0 -8698
- package/packages/gui/src/components/toys/toy-card.jsx +0 -78
- package/packages/gui/src/components/toys/toy-creator.jsx +0 -144
- package/packages/gui/src/components/toys/toy-launcher.jsx +0 -187
- package/packages/gui/src/views/toys.jsx +0 -162
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
2
|
-
import { useState, useEffect,
|
|
2
|
+
import { useState, useEffect, useRef, useMemo } 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';
|
|
@@ -9,12 +9,11 @@ import { useGrooveStore } from '../stores/groove';
|
|
|
9
9
|
import {
|
|
10
10
|
Search, Download, Trash2, HardDrive, Cpu, MemoryStick,
|
|
11
11
|
Check, Loader2, Box, ChevronDown, ChevronRight,
|
|
12
|
-
RefreshCw, Play, Square,
|
|
12
|
+
RefreshCw, Play, Square, Rocket, MoreHorizontal,
|
|
13
|
+
Sparkles, FlaskConical, ExternalLink,
|
|
13
14
|
} from 'lucide-react';
|
|
14
15
|
import { cn } from '../lib/cn';
|
|
15
16
|
|
|
16
|
-
const TIER_COLORS = { light: 'text-green-400', medium: 'text-blue-400', heavy: 'text-orange-400' };
|
|
17
|
-
|
|
18
17
|
function formatBytes(bytes) {
|
|
19
18
|
if (!bytes) return '—';
|
|
20
19
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
|
@@ -28,338 +27,211 @@ function formatSpeed(bytesPerSec) {
|
|
|
28
27
|
return `${(bytesPerSec / (1024 * 1024)).toFixed(1)} MB/s`;
|
|
29
28
|
}
|
|
30
29
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
30
|
+
const FILTERS = [
|
|
31
|
+
{ id: 'all', label: 'All' },
|
|
32
|
+
{ id: 'running', label: 'Running' },
|
|
33
|
+
{ id: 'ready', label: 'Ready' },
|
|
34
|
+
{ id: 'downloaded', label: 'Downloaded' },
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
const STATUS_CONFIG = {
|
|
38
|
+
running: { label: 'Running', variant: 'success', dot: 'pulse' },
|
|
39
|
+
ready: { label: 'Ready', variant: 'info', dot: true },
|
|
40
|
+
downloaded: { label: 'Downloaded', variant: 'purple', dot: true },
|
|
41
|
+
downloading: { label: 'Downloading', variant: 'accent', dot: 'pulse' },
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// ── Unified Model Card ──────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
function UnifiedModelCard({
|
|
47
|
+
model, serverRunning,
|
|
48
|
+
onStart, onStop, onSpawn, onDelete, onImport,
|
|
49
|
+
isLoading, isUnloading, isDeleting, isImporting,
|
|
50
|
+
}) {
|
|
51
|
+
const [menuOpen, setMenuOpen] = useState(false);
|
|
52
|
+
const menuRef = useRef(null);
|
|
50
53
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
}
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (!menuOpen) return;
|
|
56
|
+
const close = (e) => {
|
|
57
|
+
if (menuRef.current && !menuRef.current.contains(e.target)) setMenuOpen(false);
|
|
58
|
+
};
|
|
59
|
+
document.addEventListener('mousedown', close);
|
|
60
|
+
return () => document.removeEventListener('mousedown', close);
|
|
61
|
+
}, [menuOpen]);
|
|
80
62
|
|
|
81
|
-
|
|
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
|
-
}
|
|
63
|
+
const status = STATUS_CONFIG[model.status] || STATUS_CONFIG.ready;
|
|
100
64
|
|
|
101
|
-
// ---- Hardware Info ----
|
|
102
|
-
function HardwareBar({ hardware }) {
|
|
103
|
-
if (!hardware) return null;
|
|
104
65
|
return (
|
|
105
|
-
<div className=
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
66
|
+
<div className={cn(
|
|
67
|
+
'group rounded-xl border p-4 transition-all',
|
|
68
|
+
model.status === 'running'
|
|
69
|
+
? 'bg-success/5 border-success/20 hover:border-success/40'
|
|
70
|
+
: 'bg-surface-1 border-border-subtle hover:border-accent/30',
|
|
71
|
+
)}>
|
|
72
|
+
{/* Header: name + badges + menu */}
|
|
73
|
+
<div className="flex items-start justify-between gap-2 mb-2">
|
|
74
|
+
<div className="min-w-0 flex-1">
|
|
75
|
+
<span className="text-sm font-mono font-bold text-text-0 truncate block">{model.name}</span>
|
|
76
|
+
<div className="flex items-center gap-1.5 mt-1 flex-wrap">
|
|
77
|
+
<Badge variant={model.source === 'ollama' ? 'info' : 'purple'} className="text-2xs">
|
|
78
|
+
{model.source === 'ollama' ? 'Ollama' : 'GGUF'}
|
|
79
|
+
</Badge>
|
|
80
|
+
<Badge variant={status.variant} dot={status.dot} className="text-2xs">
|
|
81
|
+
{status.label}
|
|
82
|
+
</Badge>
|
|
83
|
+
{model.isInLab && (
|
|
84
|
+
<Badge variant="accent" className="text-2xs gap-0.5">
|
|
85
|
+
<FlaskConical size={8} /> Lab
|
|
86
|
+
</Badge>
|
|
87
|
+
)}
|
|
88
|
+
</div>
|
|
118
89
|
</div>
|
|
119
|
-
)}
|
|
120
|
-
{hardware.isAppleSilicon && (
|
|
121
|
-
<Badge variant="accent" className="text-2xs ml-auto">Unified Memory</Badge>
|
|
122
|
-
)}
|
|
123
|
-
</div>
|
|
124
|
-
);
|
|
125
|
-
}
|
|
126
90
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
91
|
+
{model.status !== 'downloading' && (
|
|
92
|
+
<div ref={menuRef} className="relative flex-shrink-0">
|
|
93
|
+
<button
|
|
94
|
+
onClick={() => setMenuOpen(!menuOpen)}
|
|
95
|
+
className="p-1.5 rounded-md text-text-4 hover:text-text-2 hover:bg-surface-3 transition-colors cursor-pointer"
|
|
96
|
+
>
|
|
97
|
+
<MoreHorizontal size={14} />
|
|
98
|
+
</button>
|
|
99
|
+
{menuOpen && (
|
|
100
|
+
<div className="absolute right-0 top-full mt-1 z-50 min-w-[160px] bg-surface-3 border border-border rounded-lg shadow-lg py-1">
|
|
101
|
+
{model.source === 'gguf' && (
|
|
102
|
+
<button
|
|
103
|
+
onClick={() => { onImport(model.id); setMenuOpen(false); }}
|
|
104
|
+
disabled={isImporting}
|
|
105
|
+
className="w-full text-left px-3 py-1.5 text-xs font-sans text-text-1 hover:bg-surface-4 transition-colors cursor-pointer flex items-center gap-2 disabled:opacity-40"
|
|
106
|
+
>
|
|
107
|
+
<Rocket size={12} /> Import to Ollama
|
|
108
|
+
</button>
|
|
109
|
+
)}
|
|
110
|
+
<button
|
|
111
|
+
onClick={() => { onDelete(model); setMenuOpen(false); }}
|
|
112
|
+
disabled={isDeleting}
|
|
113
|
+
className="w-full text-left px-3 py-1.5 text-xs font-sans text-danger hover:bg-danger/10 transition-colors cursor-pointer flex items-center gap-2 disabled:opacity-40"
|
|
114
|
+
>
|
|
115
|
+
<Trash2 size={12} /> Delete
|
|
116
|
+
</button>
|
|
117
|
+
</div>
|
|
118
|
+
)}
|
|
119
|
+
</div>
|
|
120
|
+
)}
|
|
121
|
+
</div>
|
|
131
122
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
<span className="
|
|
136
|
-
<span
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
123
|
+
{/* Specs */}
|
|
124
|
+
<div className="text-2xs text-text-3 font-sans mb-3 flex items-center gap-1.5 flex-wrap">
|
|
125
|
+
{model.parameters && <span>{model.parameters}</span>}
|
|
126
|
+
{model.parameters && model.quantization && <span className="text-text-4">·</span>}
|
|
127
|
+
{model.quantization && <span>{model.quantization}</span>}
|
|
128
|
+
{(model.parameters || model.quantization) && model.size && model.size !== '—' && (
|
|
129
|
+
<span className="text-text-4">·</span>
|
|
130
|
+
)}
|
|
131
|
+
{model.size && model.size !== '—' && <span>{model.size}</span>}
|
|
132
|
+
{model.vramGb && (
|
|
133
|
+
<>
|
|
134
|
+
<span className="text-text-4">·</span>
|
|
135
|
+
<span className="text-green-400">{model.vramGb} GB VRAM</span>
|
|
136
|
+
</>
|
|
137
|
+
)}
|
|
138
|
+
{model.repoId && (
|
|
139
|
+
<>
|
|
140
|
+
<span className="text-text-4">·</span>
|
|
141
|
+
<span className="truncate max-w-[140px]">{model.repoId}</span>
|
|
142
|
+
</>
|
|
143
|
+
)}
|
|
146
144
|
</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
145
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
<
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
{model.
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
{isRunning && <Badge variant="success" className="text-2xs">Running</Badge>}
|
|
146
|
+
{/* Download progress inline */}
|
|
147
|
+
{model.status === 'downloading' && model.download && (
|
|
148
|
+
<div className="mb-3 space-y-1">
|
|
149
|
+
<div className="flex items-center justify-between text-2xs font-sans text-text-3">
|
|
150
|
+
<span className="truncate">{model.download.filename}</span>
|
|
151
|
+
<span>{Math.round((model.download.percent || 0) * 100)}% {formatSpeed(model.download.speed)}</span>
|
|
152
|
+
</div>
|
|
153
|
+
<div className="h-1.5 bg-surface-3 rounded-full overflow-hidden">
|
|
154
|
+
<div
|
|
155
|
+
className="h-full bg-accent rounded-full transition-all"
|
|
156
|
+
style={{ width: `${Math.round((model.download.percent || 0) * 100)}%` }}
|
|
157
|
+
/>
|
|
158
|
+
</div>
|
|
159
|
+
<div className="text-2xs text-text-4">
|
|
160
|
+
{formatBytes(model.download.downloaded)} / {formatBytes(model.download.totalBytes)}
|
|
161
|
+
</div>
|
|
183
162
|
</div>
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
163
|
+
)}
|
|
164
|
+
{model.status === 'downloading' && model.pullProgress && (
|
|
165
|
+
<div className="mb-3">
|
|
166
|
+
<div className="flex items-center gap-2">
|
|
167
|
+
<Loader2 size={12} className="animate-spin text-accent flex-shrink-0" />
|
|
168
|
+
<span className="text-2xs text-text-3 font-sans truncate">
|
|
169
|
+
{model.pullProgress.progress || 'Pulling...'}
|
|
170
|
+
</span>
|
|
171
|
+
</div>
|
|
172
|
+
<div className="mt-1.5 h-1.5 bg-surface-3 rounded-full overflow-hidden">
|
|
173
|
+
<div className="h-full bg-accent rounded-full animate-pulse w-full" />
|
|
174
|
+
</div>
|
|
188
175
|
</div>
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
176
|
+
)}
|
|
177
|
+
|
|
178
|
+
{/* Action buttons */}
|
|
179
|
+
<div className="flex items-center gap-2 mt-auto">
|
|
180
|
+
{model.status === 'running' && (
|
|
181
|
+
<>
|
|
182
|
+
<button
|
|
183
|
+
onClick={() => onStop(model.name)}
|
|
184
|
+
disabled={isUnloading}
|
|
185
|
+
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-2xs font-sans font-medium text-text-2 hover:text-warning hover:bg-warning/10 transition-colors cursor-pointer disabled:opacity-40"
|
|
186
|
+
>
|
|
187
|
+
{isUnloading ? <Loader2 size={11} className="animate-spin" /> : <Square size={11} />}
|
|
188
|
+
Stop
|
|
189
|
+
</button>
|
|
190
|
+
<button
|
|
191
|
+
onClick={() => onSpawn(model.name)}
|
|
192
|
+
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"
|
|
193
|
+
>
|
|
194
|
+
<Rocket size={11} /> Spawn Agent
|
|
195
|
+
</button>
|
|
196
|
+
</>
|
|
197
|
+
)}
|
|
198
|
+
{model.status === 'ready' && (
|
|
199
|
+
<>
|
|
200
|
+
{serverRunning && (
|
|
201
|
+
<button
|
|
202
|
+
onClick={() => onStart(model.id)}
|
|
203
|
+
disabled={isLoading}
|
|
204
|
+
className="flex items-center gap-1.5 px-2.5 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"
|
|
205
|
+
>
|
|
206
|
+
{isLoading ? <Loader2 size={11} className="animate-spin" /> : <Play size={11} />}
|
|
207
|
+
Run
|
|
208
|
+
</button>
|
|
209
|
+
)}
|
|
210
|
+
<button
|
|
211
|
+
onClick={() => onSpawn(model.id)}
|
|
212
|
+
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"
|
|
213
|
+
>
|
|
214
|
+
<Rocket size={11} /> Spawn Agent
|
|
215
|
+
</button>
|
|
216
|
+
</>
|
|
217
|
+
)}
|
|
218
|
+
{model.status === 'downloaded' && (
|
|
192
219
|
<button
|
|
193
|
-
onClick={() =>
|
|
194
|
-
disabled={
|
|
195
|
-
className="flex items-center gap-1 px-2 py-1.5 rounded-md text-2xs font-sans font-medium
|
|
196
|
-
title="Load into memory"
|
|
220
|
+
onClick={() => onImport(model.id)}
|
|
221
|
+
disabled={isImporting}
|
|
222
|
+
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 disabled:opacity-40"
|
|
197
223
|
>
|
|
198
|
-
{
|
|
199
|
-
|
|
224
|
+
{isImporting ? <Loader2 size={11} className="animate-spin" /> : <Rocket size={11} />}
|
|
225
|
+
{isImporting ? 'Importing...' : 'Launch'}
|
|
200
226
|
</button>
|
|
201
227
|
)}
|
|
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, onImport, onDelete, importing, 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={() => onImport(model.id)}
|
|
248
|
-
disabled={importing === model.id || deleting === model.id}
|
|
249
|
-
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 disabled:opacity-40"
|
|
250
|
-
title="Import into Ollama so you can use it to spawn agents"
|
|
251
|
-
>
|
|
252
|
-
{importing === model.id ? <Loader2 size={11} className="animate-spin" /> : <Rocket size={11} />}
|
|
253
|
-
{importing === model.id ? 'Importing...' : 'Import to Ollama'}
|
|
254
|
-
</button>
|
|
255
|
-
<button
|
|
256
|
-
onClick={() => onDelete(model.id)}
|
|
257
|
-
disabled={deleting === model.id || importing === model.id}
|
|
258
|
-
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"
|
|
259
|
-
title="Delete model file"
|
|
260
|
-
>
|
|
261
|
-
{deleting === model.id ? <Loader2 size={13} className="animate-spin" /> : <Trash2 size={13} />}
|
|
262
|
-
</button>
|
|
263
|
-
</div>
|
|
264
|
-
</div>
|
|
265
|
-
);
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
// ---- Download Progress Bar ----
|
|
269
|
-
function DownloadProgress({ download }) {
|
|
270
|
-
const pct = Math.round((download.percent || 0) * 100);
|
|
271
|
-
return (
|
|
272
|
-
<div className="space-y-1">
|
|
273
|
-
<div className="flex items-center justify-between text-2xs font-sans text-text-3">
|
|
274
|
-
<span>{download.filename}</span>
|
|
275
|
-
<span>{pct}% {formatSpeed(download.speed)}</span>
|
|
276
|
-
</div>
|
|
277
|
-
<div className="h-1.5 bg-surface-3 rounded-full overflow-hidden">
|
|
278
|
-
<div className="h-full bg-accent rounded-full transition-all" style={{ width: `${pct}%` }} />
|
|
279
|
-
</div>
|
|
280
|
-
<div className="text-2xs text-text-4">
|
|
281
|
-
{formatBytes(download.downloaded)} / {formatBytes(download.totalBytes)}
|
|
282
|
-
</div>
|
|
283
|
-
</div>
|
|
284
|
-
);
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
// ---- Pull Progress (Ollama) ----
|
|
288
|
-
function PullProgress({ modelId, progress }) {
|
|
289
|
-
return (
|
|
290
|
-
<div className="flex items-center gap-2 px-4 py-2 bg-accent/5 border border-accent/20 rounded-lg">
|
|
291
|
-
<Loader2 size={14} className="animate-spin text-accent flex-shrink-0" />
|
|
292
|
-
<div className="flex-1 min-w-0">
|
|
293
|
-
<span className="text-xs font-mono text-text-0">{modelId}</span>
|
|
294
|
-
<div className="text-2xs text-text-3 font-sans truncate">{progress.progress || 'Pulling...'}</div>
|
|
295
228
|
</div>
|
|
296
229
|
</div>
|
|
297
230
|
);
|
|
298
231
|
}
|
|
299
232
|
|
|
300
|
-
//
|
|
301
|
-
function RecommendedModel({ model, systemRamGb, onPull, pulling, isInstalled }) {
|
|
302
|
-
const categoryIcons = { code: '{}', general: 'AI' };
|
|
303
|
-
const headroom = systemRamGb ? Math.round((1 - model.ramGb / systemRamGb) * 100) : null;
|
|
233
|
+
// ── File Picker (quantization variants for HuggingFace results) ──
|
|
304
234
|
|
|
305
|
-
return (
|
|
306
|
-
<div className={cn(
|
|
307
|
-
'flex items-center gap-3 px-4 py-3 border rounded-lg transition-colors',
|
|
308
|
-
isInstalled ? 'bg-success/5 border-success/20' : 'bg-surface-1 border-border-subtle hover:border-accent/20',
|
|
309
|
-
)}>
|
|
310
|
-
<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">
|
|
311
|
-
{categoryIcons[model.category] || 'AI'}
|
|
312
|
-
</div>
|
|
313
|
-
<div className="flex-1 min-w-0">
|
|
314
|
-
<div className="flex items-center gap-2">
|
|
315
|
-
<span className="text-sm font-mono font-bold text-text-0 truncate">{model.name}</span>
|
|
316
|
-
<span className={cn('text-2xs font-semibold capitalize', TIER_COLORS[model.tier])}>{model.tier}</span>
|
|
317
|
-
{isInstalled && <Badge variant="success" className="text-2xs gap-1"><Check size={8} /> Installed</Badge>}
|
|
318
|
-
</div>
|
|
319
|
-
<div className="text-2xs text-text-3 font-sans mt-0.5">{model.description}</div>
|
|
320
|
-
<div className="flex items-center gap-3 mt-1 text-2xs font-sans">
|
|
321
|
-
<span className="text-text-2">{model.sizeGb} GB download</span>
|
|
322
|
-
<span className="text-green-400 font-medium">{model.ramGb} GB RAM</span>
|
|
323
|
-
{headroom !== null && <span className="text-text-4">{headroom}% headroom</span>}
|
|
324
|
-
</div>
|
|
325
|
-
</div>
|
|
326
|
-
{isInstalled ? (
|
|
327
|
-
<span className="text-xs text-success font-sans font-medium px-3 py-1.5">Ready</span>
|
|
328
|
-
) : (
|
|
329
|
-
<button
|
|
330
|
-
onClick={() => onPull(model.id)}
|
|
331
|
-
disabled={pulling === model.id}
|
|
332
|
-
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"
|
|
333
|
-
>
|
|
334
|
-
{pulling === model.id ? <Loader2 size={12} className="animate-spin" /> : <Download size={12} />}
|
|
335
|
-
Pull
|
|
336
|
-
</button>
|
|
337
|
-
)}
|
|
338
|
-
</div>
|
|
339
|
-
);
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
// ---- Search Result Card (HuggingFace) ----
|
|
343
|
-
function SearchResult({ result, onExpand, expanded }) {
|
|
344
|
-
return (
|
|
345
|
-
<button
|
|
346
|
-
onClick={() => onExpand(expanded ? null : result.id)}
|
|
347
|
-
className="w-full text-left px-4 py-3 bg-surface-1 border border-border-subtle rounded-lg hover:border-accent/30 transition-colors cursor-pointer"
|
|
348
|
-
>
|
|
349
|
-
<div className="flex items-center gap-2">
|
|
350
|
-
<span className="text-sm font-mono font-bold text-text-0 truncate flex-1">{result.name}</span>
|
|
351
|
-
<span className="text-2xs text-text-4 font-sans">{result.author}</span>
|
|
352
|
-
{expanded ? <ChevronDown size={14} className="text-text-3" /> : <ChevronRight size={14} className="text-text-3" />}
|
|
353
|
-
</div>
|
|
354
|
-
<div className="text-2xs text-text-3 font-sans mt-0.5 flex gap-3">
|
|
355
|
-
<span>{result.downloads?.toLocaleString()} downloads</span>
|
|
356
|
-
<span>{result.likes} likes</span>
|
|
357
|
-
</div>
|
|
358
|
-
</button>
|
|
359
|
-
);
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
// ---- File Picker (quantization variants) ----
|
|
363
235
|
function FilePicker({ repoId, onDownload, systemRamGb }) {
|
|
364
236
|
const [files, setFiles] = useState(null);
|
|
365
237
|
const [loading, setLoading] = useState(true);
|
|
@@ -389,7 +261,6 @@ function FilePicker({ repoId, onDownload, systemRamGb }) {
|
|
|
389
261
|
if (loading) {
|
|
390
262
|
return <div className="py-3 px-4 text-2xs text-text-4 font-sans">Loading quantization variants...</div>;
|
|
391
263
|
}
|
|
392
|
-
|
|
393
264
|
if (!files?.length) {
|
|
394
265
|
return <div className="py-3 px-4 text-2xs text-text-4 font-sans">No GGUF files found in this repo.</div>;
|
|
395
266
|
}
|
|
@@ -405,7 +276,7 @@ function FilePicker({ repoId, onDownload, systemRamGb }) {
|
|
|
405
276
|
canRun ? 'bg-surface-2' : 'bg-red-500/5 border border-red-500/15',
|
|
406
277
|
)}>
|
|
407
278
|
<span className="font-mono text-text-1 truncate flex-1">{f.filename}</span>
|
|
408
|
-
{f.quantization && <Badge variant="
|
|
279
|
+
{f.quantization && <Badge variant="default" className="text-2xs">{f.quantization}</Badge>}
|
|
409
280
|
<span className="text-text-2 text-2xs w-16 text-right">{formatBytes(f.size)}</span>
|
|
410
281
|
{f.estimatedRamGb && (
|
|
411
282
|
<span className={cn(
|
|
@@ -420,7 +291,7 @@ function FilePicker({ repoId, onDownload, systemRamGb }) {
|
|
|
420
291
|
onClick={() => handleDownload(f)}
|
|
421
292
|
disabled={downloading === f.filename || !canRun}
|
|
422
293
|
className={cn(
|
|
423
|
-
'p-1 rounded transition-colors',
|
|
294
|
+
'p-1 rounded transition-colors cursor-pointer',
|
|
424
295
|
canRun ? 'text-accent hover:bg-accent/10' : 'text-text-4 cursor-not-allowed',
|
|
425
296
|
'disabled:opacity-40',
|
|
426
297
|
)}
|
|
@@ -434,22 +305,9 @@ function FilePicker({ repoId, onDownload, systemRamGb }) {
|
|
|
434
305
|
);
|
|
435
306
|
}
|
|
436
307
|
|
|
437
|
-
//
|
|
438
|
-
function SectionHeader({ title, count, icon: Icon }) {
|
|
439
|
-
return (
|
|
440
|
-
<div className="flex items-center gap-2 mb-2">
|
|
441
|
-
{Icon && <Icon size={14} className="text-text-3" />}
|
|
442
|
-
<span className="text-xs font-semibold font-sans text-text-2 uppercase tracking-wider">{title}</span>
|
|
443
|
-
{count !== undefined && (
|
|
444
|
-
<Badge variant="subtle" className="text-2xs">{count}</Badge>
|
|
445
|
-
)}
|
|
446
|
-
</div>
|
|
447
|
-
);
|
|
448
|
-
}
|
|
308
|
+
// ── Main View ────────────────────────────────────────────────────
|
|
449
309
|
|
|
450
|
-
// ---- Main View ----
|
|
451
310
|
export default function ModelsView() {
|
|
452
|
-
const [discoveryTab, setDiscoveryTab] = useState('recommended');
|
|
453
311
|
const [searchQuery, setSearchQuery] = useState('');
|
|
454
312
|
const [searchResults, setSearchResults] = useState([]);
|
|
455
313
|
const [searching, setSearching] = useState(false);
|
|
@@ -463,13 +321,19 @@ export default function ModelsView() {
|
|
|
463
321
|
const [ggufModels, setGgufModels] = useState([]);
|
|
464
322
|
const [deletingGguf, setDeletingGguf] = useState(null);
|
|
465
323
|
const [importingGguf, setImportingGguf] = useState(null);
|
|
324
|
+
const [filter, setFilter] = useState('all');
|
|
325
|
+
const [discoveryOpen, setDiscoveryOpen] = useState(true);
|
|
326
|
+
const [discoveryTab, setDiscoveryTab] = useState('recommended');
|
|
466
327
|
const toast = useToast();
|
|
328
|
+
const searchInputRef = useRef(null);
|
|
329
|
+
const discoveryRef = useRef(null);
|
|
467
330
|
|
|
468
331
|
const ollamaStatus = useGrooveStore((s) => s.ollamaStatus);
|
|
469
332
|
const installedModels = useGrooveStore((s) => s.ollamaInstalledModels);
|
|
470
333
|
const runningModels = useGrooveStore((s) => s.ollamaRunningModels);
|
|
471
334
|
const catalog = useGrooveStore((s) => s.ollamaCatalog);
|
|
472
335
|
const pullProgress = useGrooveStore((s) => s.ollamaPullProgress);
|
|
336
|
+
const labActiveModel = useGrooveStore((s) => s.labActiveModel);
|
|
473
337
|
const fetchOllamaStatus = useGrooveStore((s) => s.fetchOllamaStatus);
|
|
474
338
|
const startServer = useGrooveStore((s) => s.startOllamaServer);
|
|
475
339
|
const stopServer = useGrooveStore((s) => s.stopOllamaServer);
|
|
@@ -482,14 +346,14 @@ export default function ModelsView() {
|
|
|
482
346
|
|
|
483
347
|
const pollingRef = useRef(null);
|
|
484
348
|
|
|
485
|
-
//
|
|
349
|
+
// Poll Ollama status
|
|
486
350
|
useEffect(() => {
|
|
487
351
|
fetchOllamaStatus();
|
|
488
352
|
pollingRef.current = setInterval(fetchOllamaStatus, 10000);
|
|
489
353
|
return () => clearInterval(pollingRef.current);
|
|
490
354
|
}, [fetchOllamaStatus]);
|
|
491
355
|
|
|
492
|
-
// Fetch recommended
|
|
356
|
+
// Fetch recommended + GGUF on mount
|
|
493
357
|
useEffect(() => {
|
|
494
358
|
api.get('/models/recommended').then((data) => {
|
|
495
359
|
setRecommended(data.models || []);
|
|
@@ -499,7 +363,7 @@ export default function ModelsView() {
|
|
|
499
363
|
}).catch(() => {});
|
|
500
364
|
}, []);
|
|
501
365
|
|
|
502
|
-
// Poll active
|
|
366
|
+
// Poll active downloads
|
|
503
367
|
useEffect(() => {
|
|
504
368
|
const poll = setInterval(() => {
|
|
505
369
|
api.get('/models/downloads').then(setDownloads).catch(() => {});
|
|
@@ -507,7 +371,7 @@ export default function ModelsView() {
|
|
|
507
371
|
return () => clearInterval(poll);
|
|
508
372
|
}, []);
|
|
509
373
|
|
|
510
|
-
// WebSocket events for GGUF
|
|
374
|
+
// WebSocket events for GGUF downloads
|
|
511
375
|
useEffect(() => {
|
|
512
376
|
function handleWs(event) {
|
|
513
377
|
try {
|
|
@@ -541,6 +405,8 @@ export default function ModelsView() {
|
|
|
541
405
|
return () => { if (ws) ws.removeEventListener('message', handleWs); };
|
|
542
406
|
}, [toast]);
|
|
543
407
|
|
|
408
|
+
// ── Handlers ──────────────────────────────────────────────────
|
|
409
|
+
|
|
544
410
|
async function handleServerStart() {
|
|
545
411
|
setServerAction('starting');
|
|
546
412
|
try { await startServer(); } catch {}
|
|
@@ -602,13 +468,15 @@ export default function ModelsView() {
|
|
|
602
468
|
setDeletingGguf(null);
|
|
603
469
|
}
|
|
604
470
|
|
|
605
|
-
|
|
606
|
-
|
|
471
|
+
function handleDeleteUnified(model) {
|
|
472
|
+
if (model.source === 'gguf') handleDeleteGguf(model.id);
|
|
473
|
+
else handleDeleteModel(model.id);
|
|
607
474
|
}
|
|
608
475
|
|
|
609
476
|
async function handleSearch() {
|
|
610
477
|
if (!searchQuery.trim()) return;
|
|
611
478
|
setSearching(true);
|
|
479
|
+
setDiscoveryOpen(true);
|
|
612
480
|
setDiscoveryTab('search');
|
|
613
481
|
try {
|
|
614
482
|
const results = await api.get(`/models/search?q=${encodeURIComponent(searchQuery.trim())}`);
|
|
@@ -619,257 +487,482 @@ export default function ModelsView() {
|
|
|
619
487
|
setSearching(false);
|
|
620
488
|
}
|
|
621
489
|
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
const catalogByBase = {
|
|
625
|
-
|
|
626
|
-
const
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
490
|
+
// ── Computed: catalog lookup ───────────────────────────────────
|
|
491
|
+
|
|
492
|
+
const catalogByBase = useMemo(() => {
|
|
493
|
+
const map = {};
|
|
494
|
+
for (const c of catalog) {
|
|
495
|
+
const base = c.id.split(':')[0];
|
|
496
|
+
map[base] = c;
|
|
497
|
+
map[c.id] = c;
|
|
498
|
+
}
|
|
499
|
+
return map;
|
|
500
|
+
}, [catalog]);
|
|
630
501
|
|
|
631
502
|
function getCatalogEntry(modelId) {
|
|
632
503
|
if (catalogByBase[modelId]) return catalogByBase[modelId];
|
|
633
|
-
|
|
634
|
-
return catalogByBase[base] || null;
|
|
504
|
+
return catalogByBase[modelId.split(':')[0]] || null;
|
|
635
505
|
}
|
|
636
506
|
|
|
637
|
-
|
|
638
|
-
<div className="h-full flex flex-col bg-surface-0">
|
|
639
|
-
{/* Header */}
|
|
640
|
-
<div className="flex-shrink-0 px-5 pt-4 pb-3 border-b border-border space-y-3">
|
|
641
|
-
<div className="flex items-center justify-between">
|
|
642
|
-
<h1 className="text-base font-bold font-sans text-text-0">Local Models</h1>
|
|
643
|
-
<div className="flex items-center gap-2">
|
|
644
|
-
<Badge variant="subtle" className="text-2xs">{installedModels.length + ggufModels.length} installed</Badge>
|
|
645
|
-
{runningModels.length > 0 && (
|
|
646
|
-
<Badge variant="success" className="text-2xs">{runningModels.length} running</Badge>
|
|
647
|
-
)}
|
|
648
|
-
</div>
|
|
649
|
-
</div>
|
|
507
|
+
// ── Computed: lab model check ──────────────────────────────────
|
|
650
508
|
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
onStop={handleServerStop}
|
|
657
|
-
onRestart={handleServerRestart}
|
|
658
|
-
actionInProgress={serverAction}
|
|
659
|
-
/>
|
|
660
|
-
|
|
661
|
-
{/* Hardware Bar */}
|
|
662
|
-
<HardwareBar hardware={ollamaStatus.hardware} />
|
|
663
|
-
</div>
|
|
509
|
+
function isModelInLab(modelId) {
|
|
510
|
+
if (!labActiveModel) return false;
|
|
511
|
+
if (typeof labActiveModel === 'string') return labActiveModel === modelId;
|
|
512
|
+
return labActiveModel.name === modelId || labActiveModel.id === modelId;
|
|
513
|
+
}
|
|
664
514
|
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
515
|
+
// ── Computed: unified model list ──────────────────────────────
|
|
516
|
+
|
|
517
|
+
const unifiedModels = useMemo(() => {
|
|
518
|
+
const models = [];
|
|
519
|
+
const seen = new Set();
|
|
520
|
+
|
|
521
|
+
for (const m of runningModels) {
|
|
522
|
+
seen.add(m.name);
|
|
523
|
+
const installed = installedModels.find((im) => im.id === m.name);
|
|
524
|
+
const cat = getCatalogEntry(m.name);
|
|
525
|
+
models.push({
|
|
526
|
+
id: m.name,
|
|
527
|
+
name: m.name,
|
|
528
|
+
source: 'ollama',
|
|
529
|
+
status: 'running',
|
|
530
|
+
size: installed?.size || (m.size ? formatBytes(m.size) : '—'),
|
|
531
|
+
parameters: cat?.parameters || installed?.parameters,
|
|
532
|
+
quantization: installed?.quantization,
|
|
533
|
+
tier: installed?.tier,
|
|
534
|
+
vramGb: m.vram ? (m.vram / (1024 ** 3)).toFixed(1) : m.size ? (m.size / (1024 ** 3)).toFixed(1) : null,
|
|
535
|
+
isInLab: isModelInLab(m.name),
|
|
536
|
+
});
|
|
537
|
+
}
|
|
672
538
|
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
539
|
+
for (const m of installedModels) {
|
|
540
|
+
if (seen.has(m.id)) continue;
|
|
541
|
+
seen.add(m.id);
|
|
542
|
+
const cat = getCatalogEntry(m.id);
|
|
543
|
+
models.push({
|
|
544
|
+
id: m.id,
|
|
545
|
+
name: m.id,
|
|
546
|
+
source: 'ollama',
|
|
547
|
+
status: 'ready',
|
|
548
|
+
size: m.size || '—',
|
|
549
|
+
parameters: cat?.parameters || m.parameters,
|
|
550
|
+
quantization: m.quantization,
|
|
551
|
+
tier: m.tier,
|
|
552
|
+
category: m.category,
|
|
553
|
+
isInLab: isModelInLab(m.id),
|
|
554
|
+
catalogEntry: cat,
|
|
555
|
+
});
|
|
556
|
+
}
|
|
682
557
|
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
558
|
+
for (const m of ggufModels) {
|
|
559
|
+
seen.add(m.id);
|
|
560
|
+
models.push({
|
|
561
|
+
id: m.id,
|
|
562
|
+
name: m.id,
|
|
563
|
+
source: 'gguf',
|
|
564
|
+
status: 'downloaded',
|
|
565
|
+
size: m.sizeBytes ? formatBytes(m.sizeBytes) : '—',
|
|
566
|
+
parameters: m.parameters,
|
|
567
|
+
quantization: m.quantization,
|
|
568
|
+
isInLab: isModelInLab(m.id),
|
|
569
|
+
repoId: m.repoId,
|
|
570
|
+
contextWindow: m.contextWindow,
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
for (const d of downloads) {
|
|
575
|
+
if (seen.has(d.filename)) continue;
|
|
576
|
+
models.push({
|
|
577
|
+
id: `dl-${d.filename}`,
|
|
578
|
+
name: d.filename,
|
|
579
|
+
source: 'gguf',
|
|
580
|
+
status: 'downloading',
|
|
581
|
+
download: d,
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
for (const [id, prog] of Object.entries(pullProgress)) {
|
|
586
|
+
if (seen.has(id)) continue;
|
|
587
|
+
models.push({
|
|
588
|
+
id: `pull-${id}`,
|
|
589
|
+
name: id,
|
|
590
|
+
source: 'ollama',
|
|
591
|
+
status: 'downloading',
|
|
592
|
+
pullProgress: prog,
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
return models;
|
|
597
|
+
}, [runningModels, installedModels, ggufModels, downloads, pullProgress, labActiveModel, catalog]);
|
|
598
|
+
|
|
599
|
+
// ── Computed: filter + search ──────────────────────────────────
|
|
600
|
+
|
|
601
|
+
const filteredModels = useMemo(() => {
|
|
602
|
+
let list = unifiedModels;
|
|
603
|
+
if (filter === 'running') list = list.filter((m) => m.status === 'running');
|
|
604
|
+
else if (filter === 'ready') list = list.filter((m) => m.status === 'ready');
|
|
605
|
+
else if (filter === 'downloaded') list = list.filter((m) => m.status === 'downloaded' || m.status === 'downloading');
|
|
606
|
+
if (searchQuery.trim() && discoveryTab !== 'search') {
|
|
607
|
+
const q = searchQuery.toLowerCase();
|
|
608
|
+
list = list.filter((m) => m.name.toLowerCase().includes(q));
|
|
609
|
+
}
|
|
610
|
+
return list;
|
|
611
|
+
}, [unifiedModels, filter, searchQuery, discoveryTab]);
|
|
612
|
+
|
|
613
|
+
const filterCounts = useMemo(() => ({
|
|
614
|
+
all: unifiedModels.length,
|
|
615
|
+
running: unifiedModels.filter((m) => m.status === 'running').length,
|
|
616
|
+
ready: unifiedModels.filter((m) => m.status === 'ready').length,
|
|
617
|
+
downloaded: unifiedModels.filter((m) => m.status === 'downloaded' || m.status === 'downloading').length,
|
|
618
|
+
}), [unifiedModels]);
|
|
619
|
+
|
|
620
|
+
const hasNoModels = unifiedModels.length === 0;
|
|
621
|
+
|
|
622
|
+
// ── Render ─────────────────────────────────────────────────────
|
|
623
|
+
|
|
624
|
+
return (
|
|
625
|
+
<div className="h-full flex flex-col bg-surface-0">
|
|
626
|
+
{/* ════ ZONE 1: Sticky Toolbar ════ */}
|
|
627
|
+
<div className="flex-shrink-0 px-5 pt-4 pb-3 border-b border-border space-y-3">
|
|
628
|
+
|
|
629
|
+
{/* Server status row */}
|
|
630
|
+
{!ollamaStatus.installed ? (
|
|
631
|
+
<div className="flex items-center gap-2 bg-surface-1 border border-border-subtle rounded-lg px-3 py-2">
|
|
632
|
+
<span className="w-1.5 h-1.5 rounded-full bg-text-4 flex-shrink-0" />
|
|
633
|
+
<span className="text-xs font-sans text-text-3 font-medium">Ollama Not Installed</span>
|
|
634
|
+
<div className="flex-1" />
|
|
635
|
+
<a
|
|
636
|
+
href="https://ollama.ai/download"
|
|
637
|
+
target="_blank"
|
|
638
|
+
rel="noopener noreferrer"
|
|
639
|
+
className="text-2xs font-sans text-accent hover:underline flex items-center gap-1"
|
|
640
|
+
>
|
|
641
|
+
Install <ExternalLink size={10} />
|
|
642
|
+
</a>
|
|
643
|
+
</div>
|
|
644
|
+
) : ollamaStatus.serverRunning ? (
|
|
645
|
+
<div className="flex items-center gap-2 flex-wrap">
|
|
646
|
+
<span className="relative flex-shrink-0 w-1.5 h-1.5">
|
|
647
|
+
<span className="absolute inset-0 rounded-full bg-success" />
|
|
648
|
+
<span className="absolute inset-[-2px] rounded-full bg-success opacity-20 animate-pulse" />
|
|
649
|
+
</span>
|
|
650
|
+
<span className="text-xs font-sans text-text-1 font-medium">Ollama</span>
|
|
651
|
+
<span className="text-2xs font-mono text-text-4">:11434</span>
|
|
652
|
+
|
|
653
|
+
{ollamaStatus.hardware && (
|
|
654
|
+
<div className="flex items-center gap-1.5 ml-2">
|
|
655
|
+
<div className="flex items-center gap-1 px-2 py-0.5 rounded-md bg-surface-2 text-2xs font-sans text-text-2">
|
|
656
|
+
<MemoryStick size={10} className="text-text-3" />
|
|
657
|
+
{ollamaStatus.hardware.totalRamGb} GB
|
|
658
|
+
</div>
|
|
659
|
+
<div className="flex items-center gap-1 px-2 py-0.5 rounded-md bg-surface-2 text-2xs font-sans text-text-2">
|
|
660
|
+
<Cpu size={10} className="text-text-3" />
|
|
661
|
+
{ollamaStatus.hardware.cores} cores
|
|
662
|
+
</div>
|
|
663
|
+
{ollamaStatus.hardware.gpu && (
|
|
664
|
+
<div className="flex items-center gap-1 px-2 py-0.5 rounded-md bg-surface-2 text-2xs font-sans text-text-2">
|
|
665
|
+
<HardDrive size={10} className="text-text-3" />
|
|
666
|
+
{ollamaStatus.hardware.gpu.name}
|
|
667
|
+
{ollamaStatus.hardware.gpu.vram ? ` (${ollamaStatus.hardware.gpu.vram} GB)` : ''}
|
|
668
|
+
</div>
|
|
669
|
+
)}
|
|
670
|
+
{ollamaStatus.hardware.isAppleSilicon && (
|
|
671
|
+
<Badge variant="accent" className="text-2xs">Unified Memory</Badge>
|
|
672
|
+
)}
|
|
708
673
|
</div>
|
|
709
674
|
)}
|
|
675
|
+
|
|
676
|
+
<div className="flex-1" />
|
|
677
|
+
<button
|
|
678
|
+
onClick={handleServerRestart}
|
|
679
|
+
disabled={!!serverAction}
|
|
680
|
+
className="flex items-center gap-1 text-2xs font-sans text-text-3 hover:text-accent cursor-pointer transition-colors disabled:opacity-40"
|
|
681
|
+
>
|
|
682
|
+
<RefreshCw size={10} className={serverAction === 'restarting' ? 'animate-spin' : ''} />
|
|
683
|
+
{serverAction === 'restarting' ? 'Restarting...' : 'Restart'}
|
|
684
|
+
</button>
|
|
685
|
+
<button
|
|
686
|
+
onClick={handleServerStop}
|
|
687
|
+
disabled={!!serverAction}
|
|
688
|
+
className="flex items-center gap-1 text-2xs font-sans text-text-3 hover:text-danger cursor-pointer transition-colors disabled:opacity-40"
|
|
689
|
+
>
|
|
690
|
+
<Square size={10} />
|
|
691
|
+
{serverAction === 'stopping' ? 'Stopping...' : 'Stop'}
|
|
692
|
+
</button>
|
|
693
|
+
</div>
|
|
694
|
+
) : (
|
|
695
|
+
<div className="flex items-center gap-2 bg-danger/8 border border-danger/20 rounded-lg px-3 py-2">
|
|
696
|
+
<span className="w-1.5 h-1.5 rounded-full bg-danger flex-shrink-0" />
|
|
697
|
+
<span className="text-xs font-sans text-danger font-semibold">Ollama Stopped</span>
|
|
698
|
+
<span className="text-2xs font-mono text-text-4">:11434</span>
|
|
699
|
+
<div className="flex-1" />
|
|
700
|
+
<Button
|
|
701
|
+
variant="primary"
|
|
702
|
+
size="sm"
|
|
703
|
+
onClick={handleServerStart}
|
|
704
|
+
disabled={!!serverAction}
|
|
705
|
+
className="h-6 px-2.5 text-2xs gap-1"
|
|
706
|
+
>
|
|
707
|
+
<Play size={10} />
|
|
708
|
+
{serverAction === 'starting' ? 'Starting...' : 'Start Server'}
|
|
709
|
+
</Button>
|
|
710
710
|
</div>
|
|
711
|
+
)}
|
|
711
712
|
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
{
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
713
|
+
{/* Search + Filter row */}
|
|
714
|
+
<div className="flex items-center gap-2">
|
|
715
|
+
<div className="relative flex-1 max-w-md">
|
|
716
|
+
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-4" />
|
|
717
|
+
<input
|
|
718
|
+
ref={searchInputRef}
|
|
719
|
+
value={searchQuery}
|
|
720
|
+
onChange={(e) => setSearchQuery(e.target.value)}
|
|
721
|
+
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
|
722
|
+
placeholder="Search models or HuggingFace..."
|
|
723
|
+
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"
|
|
724
|
+
/>
|
|
725
|
+
</div>
|
|
726
|
+
|
|
727
|
+
<div className="flex items-center gap-1">
|
|
728
|
+
{FILTERS.map((f) => (
|
|
729
|
+
<button
|
|
730
|
+
key={f.id}
|
|
731
|
+
onClick={() => setFilter(f.id)}
|
|
732
|
+
className={cn(
|
|
733
|
+
'px-2.5 py-1 rounded-md text-2xs font-sans font-medium transition-colors cursor-pointer',
|
|
734
|
+
filter === f.id
|
|
735
|
+
? 'bg-accent/12 text-accent'
|
|
736
|
+
: 'text-text-3 hover:text-text-1 hover:bg-surface-3',
|
|
737
|
+
)}
|
|
738
|
+
>
|
|
739
|
+
{f.label}
|
|
740
|
+
{filterCounts[f.id] > 0 && (
|
|
741
|
+
<span className={cn('ml-1', filter === f.id ? 'text-accent/60' : 'text-text-4')}>
|
|
742
|
+
{filterCounts[f.id]}
|
|
743
|
+
</span>
|
|
744
|
+
)}
|
|
745
|
+
</button>
|
|
746
|
+
))}
|
|
741
747
|
</div>
|
|
748
|
+
</div>
|
|
749
|
+
</div>
|
|
742
750
|
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
751
|
+
{/* ════ ZONE 2 + 3: Scrollable Content ════ */}
|
|
752
|
+
<ScrollArea className="flex-1">
|
|
753
|
+
<div className="p-5 space-y-6">
|
|
754
|
+
|
|
755
|
+
{/* Empty State */}
|
|
756
|
+
{hasNoModels && !searchQuery.trim() && filter === 'all' ? (
|
|
757
|
+
<div className="flex flex-col items-center justify-center py-16 px-8">
|
|
758
|
+
<Box size={48} className="text-text-4 mb-4" />
|
|
759
|
+
<h2 className="text-lg font-sans font-bold text-text-0 mb-1">Get started with local models</h2>
|
|
760
|
+
<p className="text-sm text-text-3 font-sans text-center max-w-md mb-6">
|
|
761
|
+
Run AI models locally for privacy, speed, and zero API costs.
|
|
762
|
+
Pull popular models from Ollama or download GGUF files from HuggingFace.
|
|
763
|
+
</p>
|
|
764
|
+
<div className="flex gap-3">
|
|
765
|
+
<Button
|
|
766
|
+
variant="primary"
|
|
767
|
+
onClick={() => {
|
|
768
|
+
setDiscoveryOpen(true);
|
|
769
|
+
setDiscoveryTab('recommended');
|
|
770
|
+
discoveryRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
771
|
+
}}
|
|
772
|
+
className="gap-2"
|
|
773
|
+
>
|
|
774
|
+
<Download size={14} /> Pull from Ollama
|
|
775
|
+
</Button>
|
|
776
|
+
<Button
|
|
777
|
+
variant="secondary"
|
|
778
|
+
onClick={() => {
|
|
779
|
+
searchInputRef.current?.focus();
|
|
780
|
+
}}
|
|
781
|
+
className="gap-2"
|
|
782
|
+
>
|
|
783
|
+
<Search size={14} /> Search HuggingFace
|
|
784
|
+
</Button>
|
|
758
785
|
</div>
|
|
759
786
|
</div>
|
|
760
|
-
)
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
<div className="flex items-center justify-between mb-3">
|
|
768
|
-
<span className="text-xs font-semibold font-sans text-text-2 uppercase tracking-wider">Discover Models</span>
|
|
787
|
+
) : filteredModels.length === 0 ? (
|
|
788
|
+
<div className="text-center py-12">
|
|
789
|
+
<Search size={32} className="mx-auto text-text-4 mb-2" />
|
|
790
|
+
<p className="text-sm text-text-2 font-sans font-medium">No models match your filter</p>
|
|
791
|
+
<p className="text-xs text-text-3 font-sans mt-1">
|
|
792
|
+
Try changing the filter or clearing your search.
|
|
793
|
+
</p>
|
|
769
794
|
</div>
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
<div className="
|
|
773
|
-
|
|
774
|
-
<
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
795
|
+
) : (
|
|
796
|
+
/* ── Card Grid ── */
|
|
797
|
+
<div className="grid gap-3" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))' }}>
|
|
798
|
+
{filteredModels.map((model) => (
|
|
799
|
+
<UnifiedModelCard
|
|
800
|
+
key={model.id}
|
|
801
|
+
model={model}
|
|
802
|
+
serverRunning={ollamaStatus.serverRunning}
|
|
803
|
+
onStart={handleLoadModel}
|
|
804
|
+
onStop={handleUnloadModel}
|
|
805
|
+
onSpawn={spawnFromModel}
|
|
806
|
+
onDelete={handleDeleteUnified}
|
|
807
|
+
onImport={handleImportToOllama}
|
|
808
|
+
isLoading={loadingModel === model.id}
|
|
809
|
+
isUnloading={unloadingModel === model.id || unloadingModel === model.name}
|
|
810
|
+
isDeleting={deletingModel === model.id || deletingGguf === model.id}
|
|
811
|
+
isImporting={importingGguf === model.id}
|
|
781
812
|
/>
|
|
782
|
-
</div>
|
|
783
|
-
<Button onClick={handleSearch} disabled={searching} size="sm" variant="accent">
|
|
784
|
-
{searching ? <Loader2 size={14} className="animate-spin" /> : 'Search'}
|
|
785
|
-
</Button>
|
|
786
|
-
</div>
|
|
787
|
-
|
|
788
|
-
{/* Tabs */}
|
|
789
|
-
<div className="flex gap-1 mb-3">
|
|
790
|
-
{[
|
|
791
|
-
{ id: 'recommended', label: `Recommended (${recommended.length})` },
|
|
792
|
-
{ id: 'search', label: `Search (${searchResults.length})` },
|
|
793
|
-
].map((t) => (
|
|
794
|
-
<button
|
|
795
|
-
key={t.id}
|
|
796
|
-
onClick={() => setDiscoveryTab(t.id)}
|
|
797
|
-
className={cn(
|
|
798
|
-
'px-3 py-1 rounded-md text-xs font-sans font-medium transition-colors cursor-pointer',
|
|
799
|
-
discoveryTab === t.id ? 'bg-accent/12 text-accent' : 'text-text-3 hover:text-text-1 hover:bg-surface-3',
|
|
800
|
-
)}
|
|
801
|
-
>
|
|
802
|
-
{t.label}
|
|
803
|
-
</button>
|
|
804
813
|
))}
|
|
805
814
|
</div>
|
|
815
|
+
)}
|
|
806
816
|
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
</div>
|
|
822
|
-
{recommended.map((m) => {
|
|
823
|
-
const baseId = m.id.split(':')[0];
|
|
824
|
-
const isInstalled = installedModels.some((im) =>
|
|
825
|
-
im.id === m.id || im.id.startsWith(baseId + ':') || im.id === baseId
|
|
826
|
-
);
|
|
827
|
-
return (
|
|
828
|
-
<RecommendedModel
|
|
829
|
-
key={m.id}
|
|
830
|
-
model={m}
|
|
831
|
-
systemRamGb={ollamaStatus.hardware?.totalRamGb}
|
|
832
|
-
onPull={handlePull}
|
|
833
|
-
pulling={pullProgress[m.id] ? m.id : null}
|
|
834
|
-
isInstalled={isInstalled}
|
|
835
|
-
/>
|
|
836
|
-
);
|
|
837
|
-
})}
|
|
838
|
-
</>
|
|
839
|
-
)}
|
|
840
|
-
</>
|
|
841
|
-
)}
|
|
817
|
+
{/* ════ ZONE 3: Discovery ════ */}
|
|
818
|
+
<div ref={discoveryRef} className="border-t border-border-subtle pt-4">
|
|
819
|
+
<button
|
|
820
|
+
onClick={() => setDiscoveryOpen(!discoveryOpen)}
|
|
821
|
+
className="flex items-center gap-2 mb-3 cursor-pointer group"
|
|
822
|
+
>
|
|
823
|
+
{discoveryOpen
|
|
824
|
+
? <ChevronDown size={14} className="text-text-3 group-hover:text-text-1 transition-colors" />
|
|
825
|
+
: <ChevronRight size={14} className="text-text-3 group-hover:text-text-1 transition-colors" />}
|
|
826
|
+
<Sparkles size={14} className="text-text-3" />
|
|
827
|
+
<span className="text-xs font-semibold font-sans text-text-2 uppercase tracking-wider">
|
|
828
|
+
Discover Models
|
|
829
|
+
</span>
|
|
830
|
+
</button>
|
|
842
831
|
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
)
|
|
851
|
-
<
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
832
|
+
{discoveryOpen && (
|
|
833
|
+
<div className="space-y-4">
|
|
834
|
+
{/* Discovery tabs */}
|
|
835
|
+
<div className="flex gap-1">
|
|
836
|
+
{[
|
|
837
|
+
{ id: 'recommended', label: `Recommended (${recommended.length})` },
|
|
838
|
+
{ id: 'search', label: `Search Results (${searchResults.length})` },
|
|
839
|
+
].map((t) => (
|
|
840
|
+
<button
|
|
841
|
+
key={t.id}
|
|
842
|
+
onClick={() => setDiscoveryTab(t.id)}
|
|
843
|
+
className={cn(
|
|
844
|
+
'px-3 py-1 rounded-md text-xs font-sans font-medium transition-colors cursor-pointer',
|
|
845
|
+
discoveryTab === t.id ? 'bg-accent/12 text-accent' : 'text-text-3 hover:text-text-1 hover:bg-surface-3',
|
|
846
|
+
)}
|
|
847
|
+
>
|
|
848
|
+
{t.label}
|
|
849
|
+
</button>
|
|
850
|
+
))}
|
|
851
|
+
</div>
|
|
852
|
+
|
|
853
|
+
{/* Recommended — horizontal scroll */}
|
|
854
|
+
{discoveryTab === 'recommended' && (
|
|
855
|
+
<>
|
|
856
|
+
{recommended.length === 0 ? (
|
|
857
|
+
<div className="text-center py-8">
|
|
858
|
+
<Cpu size={32} className="mx-auto text-text-4 mb-2" />
|
|
859
|
+
<p className="text-sm text-text-2 font-sans font-medium">Detecting hardware...</p>
|
|
860
|
+
<p className="text-xs text-text-3 font-sans mt-1">Make sure Ollama is installed so we can check your system.</p>
|
|
867
861
|
</div>
|
|
868
|
-
)
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
862
|
+
) : (
|
|
863
|
+
<>
|
|
864
|
+
<div className="text-xs text-text-3 font-sans">
|
|
865
|
+
Top models for your system ({ollamaStatus.hardware?.totalRamGb || '?'} GB RAM). Click Pull to download via Ollama.
|
|
866
|
+
</div>
|
|
867
|
+
<div className="flex gap-3 overflow-x-auto pb-2">
|
|
868
|
+
{recommended.map((m) => {
|
|
869
|
+
const baseId = m.id.split(':')[0];
|
|
870
|
+
const isInstalled = installedModels.some((im) =>
|
|
871
|
+
im.id === m.id || im.id.startsWith(baseId + ':') || im.id === baseId
|
|
872
|
+
);
|
|
873
|
+
const headroom = ollamaStatus.hardware?.totalRamGb
|
|
874
|
+
? Math.round((1 - m.ramGb / ollamaStatus.hardware.totalRamGb) * 100)
|
|
875
|
+
: null;
|
|
876
|
+
const isPulling = !!pullProgress[m.id];
|
|
877
|
+
|
|
878
|
+
return (
|
|
879
|
+
<div
|
|
880
|
+
key={m.id}
|
|
881
|
+
className={cn(
|
|
882
|
+
'flex-shrink-0 w-[240px] p-3 rounded-xl border transition-colors',
|
|
883
|
+
isInstalled
|
|
884
|
+
? 'bg-success/5 border-success/20'
|
|
885
|
+
: 'bg-surface-1 border-border-subtle hover:border-accent/30',
|
|
886
|
+
)}
|
|
887
|
+
>
|
|
888
|
+
<div className="flex items-center gap-2 mb-1">
|
|
889
|
+
<span className="text-sm font-mono font-bold text-text-0 truncate">{m.name}</span>
|
|
890
|
+
{isInstalled && <Check size={12} className="text-success flex-shrink-0" />}
|
|
891
|
+
</div>
|
|
892
|
+
<div className="text-2xs text-text-3 font-sans line-clamp-1 mb-2">{m.description}</div>
|
|
893
|
+
<div className="flex items-center gap-2 text-2xs font-sans mb-2">
|
|
894
|
+
<span className="text-text-2">{m.sizeGb} GB</span>
|
|
895
|
+
<span className="text-green-400 font-medium">{m.ramGb} GB RAM</span>
|
|
896
|
+
{headroom !== null && <span className="text-text-4">{headroom}%</span>}
|
|
897
|
+
</div>
|
|
898
|
+
{isInstalled ? (
|
|
899
|
+
<Badge variant="success" className="text-2xs">Installed</Badge>
|
|
900
|
+
) : (
|
|
901
|
+
<button
|
|
902
|
+
onClick={() => pullModel(m.id)}
|
|
903
|
+
disabled={isPulling}
|
|
904
|
+
className="w-full flex items-center justify-center gap-1.5 h-7 rounded-md text-xs font-sans font-medium bg-accent/10 text-accent hover:bg-accent/20 transition-colors cursor-pointer disabled:opacity-40"
|
|
905
|
+
>
|
|
906
|
+
{isPulling ? <Loader2 size={12} className="animate-spin" /> : <Download size={12} />}
|
|
907
|
+
Pull
|
|
908
|
+
</button>
|
|
909
|
+
)}
|
|
910
|
+
</div>
|
|
911
|
+
);
|
|
912
|
+
})}
|
|
913
|
+
</div>
|
|
914
|
+
</>
|
|
915
|
+
)}
|
|
916
|
+
</>
|
|
917
|
+
)}
|
|
918
|
+
|
|
919
|
+
{/* Search results */}
|
|
920
|
+
{discoveryTab === 'search' && (
|
|
921
|
+
<>
|
|
922
|
+
{searching ? (
|
|
923
|
+
<div className="text-center py-8">
|
|
924
|
+
<Loader2 size={24} className="mx-auto text-accent animate-spin mb-2" />
|
|
925
|
+
<p className="text-sm text-text-3 font-sans">Searching HuggingFace...</p>
|
|
926
|
+
</div>
|
|
927
|
+
) : searchResults.length === 0 ? (
|
|
928
|
+
<div className="text-center py-8">
|
|
929
|
+
<Search size={32} className="mx-auto text-text-4 mb-2" />
|
|
930
|
+
<p className="text-sm text-text-2 font-sans font-medium">Search for GGUF models</p>
|
|
931
|
+
<p className="text-xs text-text-3 font-sans mt-1">
|
|
932
|
+
Type a query above and press Enter — try "qwen coder", "deepseek", "codestral", "llama"
|
|
933
|
+
</p>
|
|
934
|
+
</div>
|
|
935
|
+
) : (
|
|
936
|
+
<div className="space-y-2">
|
|
937
|
+
{searchResults.map((r) => (
|
|
938
|
+
<div key={r.id} className="space-y-1">
|
|
939
|
+
<button
|
|
940
|
+
onClick={() => setExpandedResult(expandedResult === r.id ? null : r.id)}
|
|
941
|
+
className="w-full text-left px-4 py-3 bg-surface-1 border border-border-subtle rounded-lg hover:border-accent/30 transition-colors cursor-pointer"
|
|
942
|
+
>
|
|
943
|
+
<div className="flex items-center gap-2">
|
|
944
|
+
<span className="text-sm font-mono font-bold text-text-0 truncate flex-1">{r.name}</span>
|
|
945
|
+
<span className="text-2xs text-text-4 font-sans">{r.author}</span>
|
|
946
|
+
{expandedResult === r.id
|
|
947
|
+
? <ChevronDown size={14} className="text-text-3" />
|
|
948
|
+
: <ChevronRight size={14} className="text-text-3" />}
|
|
949
|
+
</div>
|
|
950
|
+
<div className="text-2xs text-text-3 font-sans mt-0.5 flex gap-3">
|
|
951
|
+
<span>{r.downloads?.toLocaleString()} downloads</span>
|
|
952
|
+
<span>{r.likes} likes</span>
|
|
953
|
+
</div>
|
|
954
|
+
</button>
|
|
955
|
+
{expandedResult === r.id && (
|
|
956
|
+
<FilePicker repoId={r.id} systemRamGb={ollamaStatus.hardware?.totalRamGb} />
|
|
957
|
+
)}
|
|
958
|
+
</div>
|
|
959
|
+
))}
|
|
960
|
+
</div>
|
|
961
|
+
)}
|
|
962
|
+
</>
|
|
963
|
+
)}
|
|
964
|
+
</div>
|
|
965
|
+
)}
|
|
873
966
|
</div>
|
|
874
967
|
</div>
|
|
875
968
|
</ScrollArea>
|