groove-dev 0.27.126 → 0.27.128

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 (50) hide show
  1. package/local-models/daemon-bridge.js +87 -0
  2. package/node_modules/@groove-dev/cli/package.json +1 -1
  3. package/node_modules/@groove-dev/daemon/package.json +1 -1
  4. package/node_modules/@groove-dev/daemon/src/api.js +149 -1
  5. package/node_modules/@groove-dev/daemon/src/index.js +2 -0
  6. package/node_modules/@groove-dev/daemon/src/model-lab.js +477 -0
  7. package/node_modules/@groove-dev/daemon/src/process.js +13 -0
  8. package/node_modules/@groove-dev/daemon/src/validate.js +216 -0
  9. package/node_modules/@groove-dev/gui/dist/assets/index-CY-CITov.css +1 -0
  10. package/node_modules/@groove-dev/gui/dist/assets/index-DXIaW0aK.js +8684 -0
  11. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  12. package/node_modules/@groove-dev/gui/package.json +1 -1
  13. package/node_modules/@groove-dev/gui/src/app.jsx +2 -0
  14. package/node_modules/@groove-dev/gui/src/components/lab/chat-playground.jsx +245 -0
  15. package/node_modules/@groove-dev/gui/src/components/lab/metrics-panel.jsx +135 -0
  16. package/node_modules/@groove-dev/gui/src/components/lab/parameter-panel.jsx +60 -0
  17. package/node_modules/@groove-dev/gui/src/components/lab/preset-manager.jsx +114 -0
  18. package/node_modules/@groove-dev/gui/src/components/lab/runtime-config.jsx +190 -0
  19. package/node_modules/@groove-dev/gui/src/components/lab/system-prompt-editor.jsx +106 -0
  20. package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +2 -1
  21. package/node_modules/@groove-dev/gui/src/stores/groove.js +327 -0
  22. package/node_modules/@groove-dev/gui/src/views/model-lab.jsx +211 -0
  23. package/node_modules/@groove-dev/gui/src/views/models.jsx +28 -3
  24. package/package.json +1 -1
  25. package/packages/cli/package.json +1 -1
  26. package/packages/daemon/package.json +1 -1
  27. package/packages/daemon/src/api.js +149 -1
  28. package/packages/daemon/src/index.js +2 -0
  29. package/packages/daemon/src/model-lab.js +477 -0
  30. package/packages/daemon/src/process.js +13 -0
  31. package/packages/daemon/src/validate.js +216 -0
  32. package/packages/gui/dist/assets/index-CY-CITov.css +1 -0
  33. package/packages/gui/dist/assets/index-DXIaW0aK.js +8684 -0
  34. package/packages/gui/dist/index.html +2 -2
  35. package/packages/gui/package.json +1 -1
  36. package/packages/gui/src/app.jsx +2 -0
  37. package/packages/gui/src/components/lab/chat-playground.jsx +245 -0
  38. package/packages/gui/src/components/lab/metrics-panel.jsx +135 -0
  39. package/packages/gui/src/components/lab/parameter-panel.jsx +60 -0
  40. package/packages/gui/src/components/lab/preset-manager.jsx +114 -0
  41. package/packages/gui/src/components/lab/runtime-config.jsx +190 -0
  42. package/packages/gui/src/components/lab/system-prompt-editor.jsx +106 -0
  43. package/packages/gui/src/components/layout/activity-bar.jsx +2 -1
  44. package/packages/gui/src/stores/groove.js +327 -0
  45. package/packages/gui/src/views/model-lab.jsx +211 -0
  46. package/packages/gui/src/views/models.jsx +28 -3
  47. package/node_modules/@groove-dev/gui/dist/assets/index-Do3uUrEW.css +0 -1
  48. package/node_modules/@groove-dev/gui/dist/assets/index-oPlKeRNb.js +0 -8682
  49. package/packages/gui/dist/assets/index-Do3uUrEW.css +0 -1
  50. package/packages/gui/dist/assets/index-oPlKeRNb.js +0 -8682
@@ -0,0 +1,477 @@
1
+ // GROOVE — Model Lab
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+
4
+ import { resolve } from 'path';
5
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, unlinkSync } from 'fs';
6
+ import { randomUUID } from 'crypto';
7
+
8
+ const RUNTIME_TYPES = ['ollama', 'vllm', 'llama-cpp', 'tgi', 'openai-compatible'];
9
+ const DEFAULT_OLLAMA_ENDPOINT = 'http://localhost:11434';
10
+
11
+ export class ModelLab {
12
+ constructor(daemon) {
13
+ this.daemon = daemon;
14
+ this.runtimesPath = resolve(daemon.grooveDir, 'lab-runtimes.json');
15
+ this.presetsPath = resolve(daemon.grooveDir, 'lab-presets.json');
16
+ this.sessionsDir = resolve(daemon.grooveDir, 'lab-sessions');
17
+ this.runtimes = new Map();
18
+ this.presets = new Map();
19
+ this.sessions = new Map();
20
+ this._ensureDirs();
21
+ this._load();
22
+ }
23
+
24
+ _ensureDirs() {
25
+ try { mkdirSync(this.sessionsDir, { recursive: true }); } catch { /* best-effort */ }
26
+ }
27
+
28
+ _load() {
29
+ // Load runtimes
30
+ if (existsSync(this.runtimesPath)) {
31
+ try {
32
+ const data = JSON.parse(readFileSync(this.runtimesPath, 'utf8'));
33
+ if (Array.isArray(data)) {
34
+ for (const rt of data) this.runtimes.set(rt.id, rt);
35
+ }
36
+ } catch { /* ignore corrupt file */ }
37
+ }
38
+
39
+ // Load presets
40
+ if (existsSync(this.presetsPath)) {
41
+ try {
42
+ const data = JSON.parse(readFileSync(this.presetsPath, 'utf8'));
43
+ if (Array.isArray(data)) {
44
+ for (const p of data) this.presets.set(p.id, p);
45
+ }
46
+ } catch { /* ignore corrupt file */ }
47
+ }
48
+
49
+ // Load session index from disk
50
+ try {
51
+ for (const file of readdirSync(this.sessionsDir)) {
52
+ if (!file.endsWith('.json')) continue;
53
+ try {
54
+ const session = JSON.parse(readFileSync(resolve(this.sessionsDir, file), 'utf8'));
55
+ this.sessions.set(session.id, session);
56
+ } catch { /* skip corrupt session */ }
57
+ }
58
+ } catch { /* dir may not exist yet */ }
59
+ }
60
+
61
+ _saveRuntimes() {
62
+ writeFileSync(this.runtimesPath, JSON.stringify([...this.runtimes.values()], null, 2));
63
+ }
64
+
65
+ _savePresets() {
66
+ writeFileSync(this.presetsPath, JSON.stringify([...this.presets.values()], null, 2));
67
+ }
68
+
69
+ _saveSession(session) {
70
+ writeFileSync(
71
+ resolve(this.sessionsDir, `${session.id}.json`),
72
+ JSON.stringify(session, null, 2)
73
+ );
74
+ }
75
+
76
+ // ─── Runtimes ───────────────────────────────────────────────
77
+
78
+ async addRuntime({ name, type, endpoint, apiKey, models }) {
79
+ const id = randomUUID().slice(0, 8);
80
+ const runtime = {
81
+ id,
82
+ name,
83
+ type,
84
+ endpoint: endpoint.replace(/\/+$/, ''),
85
+ apiKey: apiKey || null,
86
+ models: models || [],
87
+ createdAt: new Date().toISOString(),
88
+ };
89
+ this.runtimes.set(id, runtime);
90
+ this._saveRuntimes();
91
+ this.daemon.broadcast({ type: 'lab:runtime:added', data: runtime });
92
+ this.daemon.audit.log('lab.runtime.add', { id, name, runtimeType: type });
93
+ return runtime;
94
+ }
95
+
96
+ removeRuntime(id) {
97
+ const rt = this.runtimes.get(id);
98
+ if (!rt) return null;
99
+ this.runtimes.delete(id);
100
+ this._saveRuntimes();
101
+ this.daemon.broadcast({ type: 'lab:runtime:removed', data: { id } });
102
+ this.daemon.audit.log('lab.runtime.remove', { id, name: rt.name });
103
+ return rt;
104
+ }
105
+
106
+ getRuntime(id) {
107
+ return this.runtimes.get(id) || null;
108
+ }
109
+
110
+ listRuntimes() {
111
+ return [...this.runtimes.values()];
112
+ }
113
+
114
+ async testRuntime(id) {
115
+ const rt = this.runtimes.get(id);
116
+ if (!rt) throw new Error('Runtime not found');
117
+
118
+ const start = Date.now();
119
+ const models = await this._discoverModels(rt);
120
+ const latency = Date.now() - start;
121
+
122
+ rt.models = models;
123
+ rt.lastTested = new Date().toISOString();
124
+ this._saveRuntimes();
125
+
126
+ return { ok: true, latency, models };
127
+ }
128
+
129
+ async discoverModels(id) {
130
+ const rt = this.runtimes.get(id);
131
+ if (!rt) throw new Error('Runtime not found');
132
+ const models = await this._discoverModels(rt);
133
+ rt.models = models;
134
+ this._saveRuntimes();
135
+ return models;
136
+ }
137
+
138
+ async _discoverModels(rt) {
139
+ if (rt.type === 'ollama') {
140
+ return this._discoverOllamaModels(rt.endpoint);
141
+ }
142
+ return this._discoverOpenAIModels(rt.endpoint, rt.apiKey);
143
+ }
144
+
145
+ async _discoverOllamaModels(endpoint) {
146
+ const resp = await fetch(`${endpoint}/api/tags`, {
147
+ signal: AbortSignal.timeout(10000),
148
+ });
149
+ if (!resp.ok) throw new Error(`Ollama /api/tags returned ${resp.status}`);
150
+ const data = await resp.json();
151
+ return (data.models || []).map((m) => ({
152
+ id: m.name || m.model,
153
+ name: m.name || m.model,
154
+ size: m.size || null,
155
+ modified: m.modified_at || null,
156
+ }));
157
+ }
158
+
159
+ async _discoverOpenAIModels(endpoint, apiKey) {
160
+ const headers = { 'Content-Type': 'application/json' };
161
+ if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
162
+ const resp = await fetch(`${endpoint}/v1/models`, {
163
+ headers,
164
+ signal: AbortSignal.timeout(10000),
165
+ });
166
+ if (!resp.ok) throw new Error(`/v1/models returned ${resp.status}`);
167
+ const data = await resp.json();
168
+ return (data.data || []).map((m) => ({
169
+ id: m.id,
170
+ name: m.id,
171
+ owned_by: m.owned_by || null,
172
+ }));
173
+ }
174
+
175
+ async getRuntimeStatus(rt) {
176
+ try {
177
+ const start = Date.now();
178
+ if (rt.type === 'ollama') {
179
+ const resp = await fetch(`${rt.endpoint}/api/tags`, {
180
+ signal: AbortSignal.timeout(5000),
181
+ });
182
+ return { online: resp.ok, latency: Date.now() - start };
183
+ }
184
+ const headers = {};
185
+ if (rt.apiKey) headers['Authorization'] = `Bearer ${rt.apiKey}`;
186
+ const resp = await fetch(`${rt.endpoint}/v1/models`, {
187
+ headers,
188
+ signal: AbortSignal.timeout(5000),
189
+ });
190
+ return { online: resp.ok, latency: Date.now() - start };
191
+ } catch {
192
+ return { online: false, latency: null };
193
+ }
194
+ }
195
+
196
+ async getOllamaMemoryUsage(endpoint) {
197
+ try {
198
+ const resp = await fetch(`${endpoint}/api/ps`, {
199
+ signal: AbortSignal.timeout(5000),
200
+ });
201
+ if (!resp.ok) return null;
202
+ const data = await resp.json();
203
+ return data.models || [];
204
+ } catch {
205
+ return null;
206
+ }
207
+ }
208
+
209
+ // ─── Inference ──────────────────────────────────────────────
210
+
211
+ async *streamInference({ runtimeId, model, messages, parameters, sessionId }) {
212
+ const rt = this.runtimes.get(runtimeId);
213
+ if (!rt) throw new Error('Runtime not found');
214
+ if (!model) throw new Error('Model is required');
215
+ if (!Array.isArray(messages) || messages.length === 0) {
216
+ throw new Error('Messages array is required');
217
+ }
218
+
219
+ // Build request body — all runtimes use OpenAI-compatible format
220
+ const body = {
221
+ model,
222
+ messages,
223
+ stream: true,
224
+ ...this._buildParameterBody(parameters || {}),
225
+ };
226
+
227
+ const endpoint = rt.type === 'ollama'
228
+ ? `${rt.endpoint}/v1/chat/completions`
229
+ : `${rt.endpoint}/v1/chat/completions`;
230
+
231
+ const headers = { 'Content-Type': 'application/json' };
232
+ if (rt.apiKey) headers['Authorization'] = `Bearer ${rt.apiKey}`;
233
+
234
+ const requestStart = Date.now();
235
+ let ttft = null;
236
+ let completionTokens = 0;
237
+ let promptTokens = 0;
238
+ let totalTokens = 0;
239
+ let generationStart = null;
240
+ let fullContent = '';
241
+
242
+ const resp = await fetch(endpoint, {
243
+ method: 'POST',
244
+ headers,
245
+ body: JSON.stringify(body),
246
+ signal: AbortSignal.timeout(300000),
247
+ });
248
+
249
+ if (!resp.ok) {
250
+ let errorMsg;
251
+ try { errorMsg = (await resp.json()).error?.message || `HTTP ${resp.status}`; } catch { errorMsg = `HTTP ${resp.status}`; }
252
+ throw new Error(errorMsg);
253
+ }
254
+
255
+ const reader = resp.body.getReader();
256
+ const decoder = new TextDecoder();
257
+ let buffer = '';
258
+
259
+ try {
260
+ while (true) {
261
+ const { done, value } = await reader.read();
262
+ if (done) break;
263
+
264
+ buffer += decoder.decode(value, { stream: true });
265
+ const lines = buffer.split('\n');
266
+ buffer = lines.pop() || '';
267
+
268
+ for (const line of lines) {
269
+ const trimmed = line.trim();
270
+ if (!trimmed || !trimmed.startsWith('data: ')) continue;
271
+ const payload = trimmed.slice(6);
272
+ if (payload === '[DONE]') continue;
273
+
274
+ try {
275
+ const chunk = JSON.parse(payload);
276
+ const delta = chunk.choices?.[0]?.delta;
277
+ if (delta?.content) {
278
+ if (ttft === null) {
279
+ ttft = Date.now() - requestStart;
280
+ generationStart = Date.now();
281
+ }
282
+ fullContent += delta.content;
283
+ completionTokens++;
284
+ yield { type: 'token', content: delta.content };
285
+ }
286
+ // Capture usage from final chunk if provided
287
+ if (chunk.usage) {
288
+ promptTokens = chunk.usage.prompt_tokens || 0;
289
+ totalTokens = chunk.usage.total_tokens || 0;
290
+ if (chunk.usage.completion_tokens) {
291
+ completionTokens = chunk.usage.completion_tokens;
292
+ }
293
+ }
294
+ } catch { /* skip malformed chunk */ }
295
+ }
296
+ }
297
+ } finally {
298
+ reader.releaseLock();
299
+ }
300
+
301
+ const generationTime = generationStart ? Date.now() - generationStart : Date.now() - requestStart;
302
+ const tokensPerSec = generationTime > 0 ? (completionTokens / (generationTime / 1000)) : 0;
303
+
304
+ // Ollama memory usage
305
+ let memoryUsage = null;
306
+ if (rt.type === 'ollama') {
307
+ memoryUsage = await this.getOllamaMemoryUsage(rt.endpoint);
308
+ }
309
+
310
+ // Persist to session if sessionId provided
311
+ if (sessionId) {
312
+ this._appendToSession(sessionId, messages, {
313
+ role: 'assistant',
314
+ content: fullContent,
315
+ });
316
+ }
317
+
318
+ yield {
319
+ type: 'done',
320
+ metrics: {
321
+ ttft,
322
+ tokensPerSec: Math.round(tokensPerSec * 100) / 100,
323
+ totalTokens: totalTokens || (promptTokens + completionTokens),
324
+ promptTokens,
325
+ completionTokens,
326
+ generationTime,
327
+ memoryUsage,
328
+ },
329
+ };
330
+ }
331
+
332
+ _buildParameterBody(params) {
333
+ const body = {};
334
+ if (params.temperature !== undefined) body.temperature = params.temperature;
335
+ if (params.top_p !== undefined) body.top_p = params.top_p;
336
+ if (params.top_k !== undefined) body.top_k = params.top_k;
337
+ if (params.repeat_penalty !== undefined) body.repeat_penalty = params.repeat_penalty;
338
+ if (params.max_tokens !== undefined) body.max_tokens = params.max_tokens;
339
+ if (params.stop !== undefined) body.stop = params.stop;
340
+ if (params.frequency_penalty !== undefined) body.frequency_penalty = params.frequency_penalty;
341
+ if (params.presence_penalty !== undefined) body.presence_penalty = params.presence_penalty;
342
+ return body;
343
+ }
344
+
345
+ // ─── Presets ────────────────────────────────────────────────
346
+
347
+ listPresets() {
348
+ return [...this.presets.values()];
349
+ }
350
+
351
+ getPreset(id) {
352
+ return this.presets.get(id) || null;
353
+ }
354
+
355
+ createPreset({ name, runtimeId, model, parameters, systemPrompt }) {
356
+ const id = randomUUID().slice(0, 8);
357
+ const now = new Date().toISOString();
358
+ const preset = {
359
+ id,
360
+ name,
361
+ runtimeId: runtimeId || null,
362
+ model: model || null,
363
+ parameters: parameters || {},
364
+ systemPrompt: systemPrompt || '',
365
+ created: now,
366
+ updated: now,
367
+ };
368
+ this.presets.set(id, preset);
369
+ this._savePresets();
370
+ this.daemon.broadcast({ type: 'lab:preset:created', data: preset });
371
+ this.daemon.audit.log('lab.preset.create', { id, name });
372
+ return preset;
373
+ }
374
+
375
+ updatePreset(id, updates) {
376
+ const preset = this.presets.get(id);
377
+ if (!preset) return null;
378
+ const allowed = ['name', 'runtimeId', 'model', 'parameters', 'systemPrompt'];
379
+ for (const key of allowed) {
380
+ if (updates[key] !== undefined) preset[key] = updates[key];
381
+ }
382
+ preset.updated = new Date().toISOString();
383
+ this.presets.set(id, preset);
384
+ this._savePresets();
385
+ this.daemon.broadcast({ type: 'lab:preset:updated', data: preset });
386
+ this.daemon.audit.log('lab.preset.update', { id, name: preset.name });
387
+ return preset;
388
+ }
389
+
390
+ deletePreset(id) {
391
+ const preset = this.presets.get(id);
392
+ if (!preset) return null;
393
+ this.presets.delete(id);
394
+ this._savePresets();
395
+ this.daemon.broadcast({ type: 'lab:preset:deleted', data: { id } });
396
+ this.daemon.audit.log('lab.preset.delete', { id, name: preset.name });
397
+ return preset;
398
+ }
399
+
400
+ // ─── Sessions ───────────────────────────────────────────────
401
+
402
+ listSessions() {
403
+ return [...this.sessions.values()].map((s) => ({
404
+ id: s.id,
405
+ name: s.name,
406
+ runtimeId: s.runtimeId,
407
+ model: s.model,
408
+ messageCount: s.messages.length,
409
+ created: s.created,
410
+ updated: s.updated,
411
+ }));
412
+ }
413
+
414
+ getSession(id) {
415
+ return this.sessions.get(id) || null;
416
+ }
417
+
418
+ _appendToSession(sessionId, inputMessages, assistantMessage) {
419
+ let session = this.sessions.get(sessionId);
420
+ if (!session) {
421
+ session = {
422
+ id: sessionId,
423
+ name: `Session ${sessionId.slice(0, 6)}`,
424
+ runtimeId: null,
425
+ model: null,
426
+ messages: [],
427
+ created: new Date().toISOString(),
428
+ updated: new Date().toISOString(),
429
+ };
430
+ }
431
+
432
+ // Add any new user messages not already in the session
433
+ const existingCount = session.messages.length;
434
+ const newMessages = inputMessages.slice(
435
+ Math.max(0, existingCount)
436
+ );
437
+ session.messages.push(...newMessages);
438
+ session.messages.push(assistantMessage);
439
+ session.updated = new Date().toISOString();
440
+
441
+ this.sessions.set(sessionId, session);
442
+ this._saveSession(session);
443
+ }
444
+
445
+ // ─── Auto-detect Ollama ─────────────────────────────────────
446
+
447
+ async autoDetectOllama() {
448
+ try {
449
+ const existing = [...this.runtimes.values()].find(
450
+ (r) => r.type === 'ollama' && r.endpoint === DEFAULT_OLLAMA_ENDPOINT
451
+ );
452
+ if (existing) return existing;
453
+
454
+ const resp = await fetch(`${DEFAULT_OLLAMA_ENDPOINT}/api/tags`, {
455
+ signal: AbortSignal.timeout(3000),
456
+ });
457
+ if (!resp.ok) return null;
458
+
459
+ const data = await resp.json();
460
+ const models = (data.models || []).map((m) => ({
461
+ id: m.name || m.model,
462
+ name: m.name || m.model,
463
+ size: m.size || null,
464
+ modified: m.modified_at || null,
465
+ }));
466
+
467
+ return this.addRuntime({
468
+ name: 'Ollama (local)',
469
+ type: 'ollama',
470
+ endpoint: DEFAULT_OLLAMA_ENDPOINT,
471
+ models,
472
+ });
473
+ } catch {
474
+ return null;
475
+ }
476
+ }
477
+ }
@@ -676,6 +676,19 @@ export class ProcessManager {
676
676
  }
677
677
  }
678
678
 
679
+ // Resolve lab preset — apply runtime/model/parameters from a saved preset
680
+ if (config.labPresetId && this.daemon.modelLab) {
681
+ const preset = this.daemon.modelLab.getPreset(config.labPresetId);
682
+ if (preset) {
683
+ if (preset.model && !config.model) config.model = preset.model;
684
+ if (preset.runtimeId) {
685
+ const rt = this.daemon.modelLab.getRuntime(preset.runtimeId);
686
+ if (rt && rt.type === 'ollama' && !config.provider) config.provider = 'ollama';
687
+ }
688
+ config._labPreset = preset;
689
+ }
690
+ }
691
+
679
692
  // Resolve provider — auto-detect best installed if not specified
680
693
  let providerName = config.provider;
681
694
  if (!providerName && this.daemon.config?.defaultProvider) {