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.
- package/local-models/daemon-bridge.js +87 -0
- package/node_modules/@groove-dev/cli/package.json +1 -1
- package/node_modules/@groove-dev/daemon/package.json +1 -1
- package/node_modules/@groove-dev/daemon/src/api.js +149 -1
- package/node_modules/@groove-dev/daemon/src/index.js +2 -0
- package/node_modules/@groove-dev/daemon/src/model-lab.js +477 -0
- package/node_modules/@groove-dev/daemon/src/process.js +13 -0
- package/node_modules/@groove-dev/daemon/src/validate.js +216 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-CY-CITov.css +1 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-DXIaW0aK.js +8684 -0
- package/node_modules/@groove-dev/gui/dist/index.html +2 -2
- package/node_modules/@groove-dev/gui/package.json +1 -1
- package/node_modules/@groove-dev/gui/src/app.jsx +2 -0
- package/node_modules/@groove-dev/gui/src/components/lab/chat-playground.jsx +245 -0
- package/node_modules/@groove-dev/gui/src/components/lab/metrics-panel.jsx +135 -0
- package/node_modules/@groove-dev/gui/src/components/lab/parameter-panel.jsx +60 -0
- package/node_modules/@groove-dev/gui/src/components/lab/preset-manager.jsx +114 -0
- package/node_modules/@groove-dev/gui/src/components/lab/runtime-config.jsx +190 -0
- package/node_modules/@groove-dev/gui/src/components/lab/system-prompt-editor.jsx +106 -0
- package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +2 -1
- package/node_modules/@groove-dev/gui/src/stores/groove.js +327 -0
- package/node_modules/@groove-dev/gui/src/views/model-lab.jsx +211 -0
- package/node_modules/@groove-dev/gui/src/views/models.jsx +28 -3
- package/package.json +1 -1
- package/packages/cli/package.json +1 -1
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/api.js +149 -1
- package/packages/daemon/src/index.js +2 -0
- package/packages/daemon/src/model-lab.js +477 -0
- package/packages/daemon/src/process.js +13 -0
- package/packages/daemon/src/validate.js +216 -0
- package/packages/gui/dist/assets/index-CY-CITov.css +1 -0
- package/packages/gui/dist/assets/index-DXIaW0aK.js +8684 -0
- package/packages/gui/dist/index.html +2 -2
- package/packages/gui/package.json +1 -1
- package/packages/gui/src/app.jsx +2 -0
- package/packages/gui/src/components/lab/chat-playground.jsx +245 -0
- package/packages/gui/src/components/lab/metrics-panel.jsx +135 -0
- package/packages/gui/src/components/lab/parameter-panel.jsx +60 -0
- package/packages/gui/src/components/lab/preset-manager.jsx +114 -0
- package/packages/gui/src/components/lab/runtime-config.jsx +190 -0
- package/packages/gui/src/components/lab/system-prompt-editor.jsx +106 -0
- package/packages/gui/src/components/layout/activity-bar.jsx +2 -1
- package/packages/gui/src/stores/groove.js +327 -0
- package/packages/gui/src/views/model-lab.jsx +211 -0
- package/packages/gui/src/views/models.jsx +28 -3
- package/node_modules/@groove-dev/gui/dist/assets/index-Do3uUrEW.css +0 -1
- package/node_modules/@groove-dev/gui/dist/assets/index-oPlKeRNb.js +0 -8682
- package/packages/gui/dist/assets/index-Do3uUrEW.css +0 -1
- 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) {
|