groove-dev 0.27.128 → 0.27.130

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. package/node_modules/@groove-dev/cli/package.json +1 -1
  2. package/node_modules/@groove-dev/daemon/package.json +1 -1
  3. package/node_modules/@groove-dev/daemon/src/api.js +21 -0
  4. package/node_modules/@groove-dev/daemon/src/model-lab.js +46 -0
  5. package/node_modules/@groove-dev/gui/dist/assets/{index-DXIaW0aK.js → index-6zTBb9ik.js} +1749 -1749
  6. package/node_modules/@groove-dev/gui/dist/assets/index-Ch9Mlf9w.css +1 -0
  7. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  8. package/node_modules/@groove-dev/gui/package.json +1 -1
  9. package/node_modules/@groove-dev/gui/src/components/lab/runtime-config.jsx +193 -3
  10. package/node_modules/@groove-dev/gui/src/components/ui/combobox.jsx +118 -0
  11. package/node_modules/@groove-dev/gui/src/stores/groove.js +54 -1
  12. package/node_modules/@groove-dev/gui/src/views/model-lab.jsx +25 -21
  13. package/package.json +1 -1
  14. package/packages/cli/package.json +1 -1
  15. package/packages/daemon/package.json +1 -1
  16. package/packages/daemon/src/api.js +21 -0
  17. package/packages/daemon/src/model-lab.js +46 -0
  18. package/packages/gui/dist/assets/{index-DXIaW0aK.js → index-6zTBb9ik.js} +1749 -1749
  19. package/packages/gui/dist/assets/index-Ch9Mlf9w.css +1 -0
  20. package/packages/gui/dist/index.html +2 -2
  21. package/packages/gui/package.json +1 -1
  22. package/packages/gui/src/components/lab/runtime-config.jsx +193 -3
  23. package/packages/gui/src/components/ui/combobox.jsx +118 -0
  24. package/packages/gui/src/stores/groove.js +54 -1
  25. package/packages/gui/src/views/model-lab.jsx +25 -21
  26. package/node_modules/@groove-dev/gui/dist/assets/index-CY-CITov.css +0 -1
  27. package/packages/gui/dist/assets/index-CY-CITov.css +0 -1
@@ -0,0 +1,118 @@
1
+ // FSL-1.1-Apache-2.0 — see LICENSE
2
+ import { useState, useRef, useEffect, useCallback } from 'react';
3
+ import { cn } from '../../lib/cn';
4
+ import { ChevronDown } from 'lucide-react';
5
+
6
+ export function Combobox({ value, onChange, options = [], placeholder, className, renderOption }) {
7
+ const [open, setOpen] = useState(false);
8
+ const [query, setQuery] = useState('');
9
+ const [highlightIndex, setHighlightIndex] = useState(-1);
10
+ const wrapperRef = useRef(null);
11
+ const inputRef = useRef(null);
12
+
13
+ const filtered = query
14
+ ? options.filter((o) => (o.name || o.id).toLowerCase().includes(query.toLowerCase()))
15
+ : options;
16
+
17
+ useEffect(() => {
18
+ function handleClickOutside(e) {
19
+ if (wrapperRef.current && !wrapperRef.current.contains(e.target)) {
20
+ setOpen(false);
21
+ }
22
+ }
23
+ document.addEventListener('mousedown', handleClickOutside);
24
+ return () => document.removeEventListener('mousedown', handleClickOutside);
25
+ }, []);
26
+
27
+ useEffect(() => {
28
+ setHighlightIndex(-1);
29
+ }, [query, open]);
30
+
31
+ const handleSelect = useCallback((val) => {
32
+ onChange(val);
33
+ setQuery('');
34
+ setOpen(false);
35
+ inputRef.current?.blur();
36
+ }, [onChange]);
37
+
38
+ function handleKeyDown(e) {
39
+ if (!open && (e.key === 'ArrowDown' || e.key === 'Enter')) {
40
+ setOpen(true);
41
+ return;
42
+ }
43
+ if (!open) return;
44
+
45
+ if (e.key === 'ArrowDown') {
46
+ e.preventDefault();
47
+ setHighlightIndex((i) => Math.min(i + 1, filtered.length - 1));
48
+ } else if (e.key === 'ArrowUp') {
49
+ e.preventDefault();
50
+ setHighlightIndex((i) => Math.max(i - 1, 0));
51
+ } else if (e.key === 'Enter') {
52
+ e.preventDefault();
53
+ if (highlightIndex >= 0 && filtered[highlightIndex]) {
54
+ handleSelect(filtered[highlightIndex].id || filtered[highlightIndex].name);
55
+ } else if (query.trim()) {
56
+ handleSelect(query.trim());
57
+ }
58
+ } else if (e.key === 'Escape') {
59
+ setOpen(false);
60
+ setQuery('');
61
+ }
62
+ }
63
+
64
+ const displayValue = value && !open ? (options.find((o) => (o.id || o.name) === value)?.name || value) : query;
65
+
66
+ return (
67
+ <div ref={wrapperRef} className={cn('relative', className)}>
68
+ <div className="relative">
69
+ <input
70
+ ref={inputRef}
71
+ value={displayValue}
72
+ onChange={(e) => { setQuery(e.target.value); setOpen(true); }}
73
+ onFocus={() => setOpen(true)}
74
+ onKeyDown={handleKeyDown}
75
+ placeholder={placeholder}
76
+ className={cn(
77
+ 'h-8 w-full rounded-md pl-3 pr-8 text-sm',
78
+ 'bg-surface-1 border border-border text-text-0 font-sans',
79
+ 'placeholder:text-text-4',
80
+ 'focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent',
81
+ 'transition-colors duration-100',
82
+ )}
83
+ />
84
+ <ChevronDown
85
+ size={12}
86
+ className="absolute right-2.5 top-1/2 -translate-y-1/2 text-text-4 pointer-events-none"
87
+ />
88
+ </div>
89
+
90
+ {open && filtered.length > 0 && (
91
+ <div className="absolute left-0 right-0 top-full mt-1 z-30 bg-surface-1 border border-border rounded-md shadow-xl py-1 max-h-48 overflow-y-auto">
92
+ {filtered.map((option, i) => (
93
+ <button
94
+ key={option.id || option.name}
95
+ onClick={() => handleSelect(option.id || option.name)}
96
+ onMouseEnter={() => setHighlightIndex(i)}
97
+ className={cn(
98
+ 'w-full text-left px-3 py-1.5 text-sm font-sans cursor-pointer transition-colors',
99
+ (option.id || option.name) === value && 'text-accent',
100
+ highlightIndex === i ? 'bg-accent/10 text-text-0' : 'text-text-2 hover:bg-surface-5 hover:text-text-0',
101
+ )}
102
+ >
103
+ {renderOption ? renderOption(option) : (option.name || option.id)}
104
+ </button>
105
+ ))}
106
+ </div>
107
+ )}
108
+
109
+ {open && filtered.length === 0 && query && (
110
+ <div className="absolute left-0 right-0 top-full mt-1 z-30 bg-surface-1 border border-border rounded-md shadow-xl py-2 px-3">
111
+ <p className="text-xs text-text-3 font-sans">
112
+ Press Enter to use &ldquo;{query}&rdquo;
113
+ </p>
114
+ </div>
115
+ )}
116
+ </div>
117
+ );
118
+ }
@@ -219,6 +219,11 @@ export const useGrooveStore = create((set, get) => ({
219
219
  }),
220
220
  labSystemPrompt: localStorage.getItem('groove:labSystemPrompt') || '',
221
221
  labStreaming: false,
222
+ labLocalModels: [],
223
+ labLaunching: null,
224
+ labLlamaInstalled: null,
225
+ labLaunchPhase: null,
226
+ labLaunchError: null,
222
227
 
223
228
  // ── Onboarding ────────────────────────────────────────────
224
229
  onboardingComplete: localStorage.getItem('groove:onboardingComplete') === 'true',
@@ -628,6 +633,17 @@ export const useGrooveStore = create((set, get) => ({
628
633
  get().fetchOllamaStatus();
629
634
  break;
630
635
 
636
+ case 'lab:runtime:added':
637
+ case 'lab:runtime:updated':
638
+ case 'lab:runtime:removed':
639
+ get().fetchLabRuntimes();
640
+ break;
641
+
642
+ case 'lab:preset:created':
643
+ case 'lab:preset:updated':
644
+ case 'lab:preset:deleted':
645
+ break;
646
+
631
647
  case 'rotation:start':
632
648
  break;
633
649
 
@@ -3282,7 +3298,11 @@ export const useGrooveStore = create((set, get) => ({
3282
3298
 
3283
3299
  async fetchLabRuntimes() {
3284
3300
  try {
3285
- const data = await api.get('/lab/runtimes');
3301
+ const raw = await api.get('/lab/runtimes');
3302
+ const data = raw.map((rt) => ({
3303
+ ...rt,
3304
+ status: rt.online === true ? 'connected' : rt.online === false ? 'error' : rt.status,
3305
+ }));
3286
3306
  set({ labRuntimes: data });
3287
3307
  persistJSON('groove:labRuntimes', data);
3288
3308
  if (data.length > 0 && !get().labActiveRuntime) {
@@ -3293,6 +3313,39 @@ export const useGrooveStore = create((set, get) => ({
3293
3313
  } catch { /* backend may not have lab endpoints yet */ }
3294
3314
  },
3295
3315
 
3316
+ async fetchLabLocalModels() {
3317
+ try {
3318
+ const data = await api.get('/lab/local-models');
3319
+ set({ labLocalModels: data });
3320
+ } catch { set({ labLocalModels: [] }); }
3321
+ },
3322
+
3323
+ async checkLlamaStatus() {
3324
+ try {
3325
+ const data = await api.get('/llama/status');
3326
+ set({ labLlamaInstalled: !!data.installed });
3327
+ } catch { set({ labLlamaInstalled: false }); }
3328
+ },
3329
+
3330
+ async launchLocalModel(modelId) {
3331
+ set({ labLaunching: modelId, labLaunchPhase: 'starting', labLaunchError: null });
3332
+ try {
3333
+ const result = await api.post('/lab/launch-local', { modelId });
3334
+ const runtimes = await api.get('/lab/runtimes');
3335
+ set({ labRuntimes: runtimes });
3336
+ persistJSON('groove:labRuntimes', runtimes);
3337
+ get().setLabActiveRuntime(result.runtime.id);
3338
+ set({ labActiveModel: result.model, labLaunching: null, labLaunchPhase: 'ready' });
3339
+ get().addToast('success', `Launched ${result.model}`);
3340
+ setTimeout(() => { if (get().labLaunchPhase === 'ready') set({ labLaunchPhase: null }); }, 3000);
3341
+ return result;
3342
+ } catch (err) {
3343
+ set({ labLaunching: null, labLaunchPhase: 'error', labLaunchError: err.message });
3344
+ get().addToast('error', 'Failed to launch model', err.message);
3345
+ throw err;
3346
+ }
3347
+ },
3348
+
3296
3349
  async addLabRuntime(runtime) {
3297
3350
  try {
3298
3351
  const created = await api.post('/lab/runtimes', runtime);
@@ -2,10 +2,10 @@
2
2
  import { useState, useCallback, useRef, useEffect } from 'react';
3
3
  import { useGrooveStore } from '../stores/groove';
4
4
  import { ScrollArea } from '../components/ui/scroll-area';
5
- import { Select, SelectTrigger, SelectContent, SelectItem } from '../components/ui/select';
6
5
  import { Badge } from '../components/ui/badge';
7
6
  import { Tooltip } from '../components/ui/tooltip';
8
- import { RuntimeConfig } from '../components/lab/runtime-config';
7
+ import { Combobox } from '../components/ui/combobox';
8
+ import { RuntimeConfig, LaunchModel } from '../components/lab/runtime-config';
9
9
  import { ParameterPanel } from '../components/lab/parameter-panel';
10
10
  import { SystemPromptEditor } from '../components/lab/system-prompt-editor';
11
11
  import { ChatPlayground } from '../components/lab/chat-playground';
@@ -32,25 +32,21 @@ function ModelSelector() {
32
32
  return (
33
33
  <div className="space-y-1.5">
34
34
  <span className="text-xs font-semibold font-sans text-text-2 uppercase tracking-wider">Model</span>
35
- {models.length > 0 ? (
36
- <Select value={activeModel || ''} onValueChange={setActiveModel}>
37
- <SelectTrigger placeholder="Select model" />
38
- <SelectContent>
39
- {models.map((m) => (
40
- <SelectItem key={m.id || m.name} value={m.id || m.name}>
41
- <div className="flex items-center gap-2">
42
- <Box size={12} className="text-text-3" />
43
- <span>{m.name}</span>
44
- {m.size && <span className="text-2xs text-text-4 font-mono">{m.size}</span>}
45
- </div>
46
- </SelectItem>
47
- ))}
48
- </SelectContent>
49
- </Select>
50
- ) : (
51
- <div className="px-3 py-2 rounded-md bg-surface-1 border border-border-subtle">
52
- <p className="text-2xs text-text-4 font-sans">No models discovered</p>
53
- </div>
35
+ <Combobox
36
+ value={activeModel || ''}
37
+ onChange={setActiveModel}
38
+ options={models.map((m) => ({ id: m.id || m.name, name: m.name, size: m.size }))}
39
+ placeholder="Select or type a model name"
40
+ renderOption={(o) => (
41
+ <div className="flex items-center gap-2">
42
+ <Box size={12} className="text-text-3 flex-shrink-0" />
43
+ <span className="truncate">{o.name}</span>
44
+ {o.size && <span className="text-2xs text-text-4 font-mono flex-shrink-0">{o.size}</span>}
45
+ </div>
46
+ )}
47
+ />
48
+ {!activeModel && models.length === 0 && (
49
+ <p className="text-2xs text-text-4 font-sans px-1">No models discovered — type a model name or test the runtime</p>
54
50
  )}
55
51
  </div>
56
52
  );
@@ -70,8 +66,14 @@ function ResizeHandle({ onMouseDown, direction = 'vertical' }) {
70
66
 
71
67
  export default function ModelLabView() {
72
68
  const fetchLabRuntimes = useGrooveStore((s) => s.fetchLabRuntimes);
69
+
73
70
  useEffect(() => { fetchLabRuntimes(); }, [fetchLabRuntimes]);
74
71
 
72
+ useEffect(() => {
73
+ const interval = setInterval(fetchLabRuntimes, 30000);
74
+ return () => clearInterval(interval);
75
+ }, [fetchLabRuntimes]);
76
+
75
77
  const [leftCollapsed, setLeftCollapsed] = useState(false);
76
78
  const [rightCollapsed, setRightCollapsed] = useState(false);
77
79
  const [leftWidth, setLeftWidth] = useState(LEFT_DEFAULT);
@@ -169,6 +171,8 @@ export default function ModelLabView() {
169
171
  >
170
172
  <ScrollArea className="h-full">
171
173
  <div className="px-4 py-4 space-y-5">
174
+ <LaunchModel />
175
+ <div className="border-t border-border-subtle" />
172
176
  <RuntimeConfig />
173
177
  <div className="border-t border-border-subtle" />
174
178
  <ModelSelector />
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "groove-dev",
3
- "version": "0.27.128",
3
+ "version": "0.27.130",
4
4
  "description": "Open-source agent orchestration layer — the AI company OS. Local model agent engine (GGUF/Ollama/llama-server), HuggingFace model browser, MCP integrations (Slack, Gmail, Stripe, 15+), agent scheduling (cron), business roles (CMO, CFO, EA). GUI dashboard, multi-agent coordination, zero cold-start, infinite sessions. Works with Claude Code, Codex, Gemini CLI, Ollama, any local model.",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "author": "Groove Dev <hello@groovedev.ai> (https://groovedev.ai)",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.27.128",
3
+ "version": "0.27.130",
4
4
  "description": "GROOVE CLI — manage AI coding agents from your terminal",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/daemon",
3
- "version": "0.27.128",
3
+ "version": "0.27.130",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -6669,6 +6669,27 @@ Keep responses concise. Help them think, don't lecture them about the system the
6669
6669
  }
6670
6670
  });
6671
6671
 
6672
+ app.get('/api/lab/local-models', (req, res) => {
6673
+ try {
6674
+ res.json(daemon.modelLab.listLocalModels());
6675
+ } catch (err) {
6676
+ res.status(500).json({ error: err.message });
6677
+ }
6678
+ });
6679
+
6680
+ app.post('/api/lab/launch-local', async (req, res) => {
6681
+ try {
6682
+ const { modelId } = req.body;
6683
+ if (!modelId || typeof modelId !== 'string') {
6684
+ return res.status(400).json({ error: 'modelId is required' });
6685
+ }
6686
+ const result = await daemon.modelLab.launchLocalModel(modelId);
6687
+ res.json(result);
6688
+ } catch (err) {
6689
+ res.status(400).json({ error: err.message });
6690
+ }
6691
+ });
6692
+
6672
6693
  app.post('/api/lab/inference', async (req, res) => {
6673
6694
  try {
6674
6695
  const params = validateLabInferenceParams(req.body);
@@ -442,6 +442,52 @@ export class ModelLab {
442
442
  this._saveSession(session);
443
443
  }
444
444
 
445
+ // ─── Launch Local GGUF ───────────────────────────────────────
446
+
447
+ async launchLocalModel(modelId) {
448
+ const mm = this.daemon.modelManager;
449
+ const ls = this.daemon.llamaServer;
450
+ if (!mm || !ls) throw new Error('Local model serving not available');
451
+
452
+ const model = mm.getModel(modelId);
453
+ if (!model) throw new Error('Model not found in local store');
454
+
455
+ const modelPath = mm.getModelPath(modelId);
456
+ if (!modelPath) throw new Error('Model file not found on disk');
457
+
458
+ const endpoint = await ls.ensureServer(modelPath);
459
+ if (!endpoint) throw new Error('Failed to start inference server');
460
+
461
+ const existing = [...this.runtimes.values()].find(
462
+ (r) => r._localModelId === modelId,
463
+ );
464
+ if (existing) {
465
+ existing.endpoint = endpoint;
466
+ existing.models = [{ id: model.filename, name: model.filename, size: model.sizeBytes }];
467
+ this._saveRuntimes();
468
+ this.daemon.broadcast({ type: 'lab:runtime:updated', data: existing });
469
+ return { runtime: existing, model: model.filename };
470
+ }
471
+
472
+ const label = model.filename.replace(/\.gguf$/i, '');
473
+ const runtime = await this.addRuntime({
474
+ name: `${label} (local)`,
475
+ type: 'llama-cpp',
476
+ endpoint,
477
+ models: [{ id: model.filename, name: model.filename, size: model.sizeBytes }],
478
+ });
479
+ runtime._localModelId = modelId;
480
+ this._saveRuntimes();
481
+
482
+ return { runtime, model: model.filename };
483
+ }
484
+
485
+ listLocalModels() {
486
+ const mm = this.daemon.modelManager;
487
+ if (!mm) return [];
488
+ return mm.getInstalled().filter((m) => m.exists);
489
+ }
490
+
445
491
  // ─── Auto-detect Ollama ─────────────────────────────────────
446
492
 
447
493
  async autoDetectOllama() {