groove-dev 0.27.74 → 0.27.77
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/node_modules/@groove-dev/cli/package.json +1 -1
- package/node_modules/@groove-dev/daemon/package.json +1 -1
- package/node_modules/@groove-dev/daemon/src/api.js +256 -4
- package/node_modules/@groove-dev/daemon/src/conversations.js +16 -0
- package/node_modules/@groove-dev/daemon/src/index.js +41 -1
- package/node_modules/@groove-dev/daemon/src/preview.js +32 -2
- package/node_modules/@groove-dev/daemon/src/process.js +9 -1
- package/node_modules/@groove-dev/daemon/src/providers/base.js +4 -0
- package/node_modules/@groove-dev/daemon/src/providers/codex.js +38 -0
- package/node_modules/@groove-dev/daemon/src/providers/grok.js +156 -0
- package/node_modules/@groove-dev/daemon/src/providers/index.js +5 -1
- package/node_modules/@groove-dev/daemon/src/providers/nano-banana.js +103 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-BbmPDhuW.js +8616 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-kbR5tOHu.css +1 -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.css +41 -0
- package/node_modules/@groove-dev/gui/src/app.jsx +2 -0
- package/node_modules/@groove-dev/gui/src/components/chat/chat-header.jsx +16 -5
- package/node_modules/@groove-dev/gui/src/components/chat/chat-input.jsx +49 -11
- package/node_modules/@groove-dev/gui/src/components/chat/chat-messages.jsx +144 -24
- package/node_modules/@groove-dev/gui/src/components/chat/chat-view.jsx +26 -2
- package/node_modules/@groove-dev/gui/src/components/chat/model-picker.jsx +105 -52
- package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +5 -2
- package/node_modules/@groove-dev/gui/src/components/layout/welcome-splash.jsx +215 -88
- package/node_modules/@groove-dev/gui/src/components/preview/preview-toolbar.jsx +109 -0
- package/node_modules/@groove-dev/gui/src/components/preview/preview-workspace.jsx +278 -0
- package/node_modules/@groove-dev/gui/src/components/preview/screenshot-overlay.jsx +237 -0
- package/node_modules/@groove-dev/gui/src/components/ui/toast.jsx +6 -2
- package/node_modules/@groove-dev/gui/src/stores/groove.js +149 -9
- package/node_modules/@groove-dev/gui/src/views/preview.jsx +6 -0
- package/node_modules/@groove-dev/gui/src/views/settings.jsx +199 -114
- 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 +256 -4
- package/packages/daemon/src/conversations.js +16 -0
- package/packages/daemon/src/index.js +41 -1
- package/packages/daemon/src/preview.js +32 -2
- package/packages/daemon/src/process.js +9 -1
- package/packages/daemon/src/providers/base.js +4 -0
- package/packages/daemon/src/providers/codex.js +38 -0
- package/packages/daemon/src/providers/grok.js +156 -0
- package/packages/daemon/src/providers/index.js +5 -1
- package/packages/daemon/src/providers/nano-banana.js +103 -0
- package/packages/gui/dist/assets/index-BbmPDhuW.js +8616 -0
- package/packages/gui/dist/assets/index-kbR5tOHu.css +1 -0
- package/packages/gui/dist/index.html +2 -2
- package/packages/gui/package.json +1 -1
- package/packages/gui/src/app.css +41 -0
- package/packages/gui/src/app.jsx +2 -0
- package/packages/gui/src/components/chat/chat-header.jsx +16 -5
- package/packages/gui/src/components/chat/chat-input.jsx +49 -11
- package/packages/gui/src/components/chat/chat-messages.jsx +144 -24
- package/packages/gui/src/components/chat/chat-view.jsx +26 -2
- package/packages/gui/src/components/chat/model-picker.jsx +105 -52
- package/packages/gui/src/components/layout/activity-bar.jsx +5 -2
- package/packages/gui/src/components/layout/welcome-splash.jsx +215 -88
- package/packages/gui/src/components/preview/preview-toolbar.jsx +109 -0
- package/packages/gui/src/components/preview/preview-workspace.jsx +278 -0
- package/packages/gui/src/components/preview/screenshot-overlay.jsx +237 -0
- package/packages/gui/src/components/ui/toast.jsx +6 -2
- package/packages/gui/src/stores/groove.js +149 -9
- package/packages/gui/src/views/preview.jsx +6 -0
- package/packages/gui/src/views/settings.jsx +199 -114
- package/welcome.png +0 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-DFP3r2yE.js +0 -8615
- package/node_modules/@groove-dev/gui/dist/assets/index-QR7lyguO.css +0 -1
- package/packages/gui/dist/assets/index-DFP3r2yE.js +0 -8615
- package/packages/gui/dist/assets/index-QR7lyguO.css +0 -1
|
@@ -9,6 +9,7 @@ import { spawn, execFile, execFileSync } from 'child_process';
|
|
|
9
9
|
import { createHash, randomUUID } from 'crypto';
|
|
10
10
|
import { hostname, networkInterfaces, homedir } from 'os';
|
|
11
11
|
import { StringDecoder } from 'string_decoder';
|
|
12
|
+
import { request as httpRequest } from 'http';
|
|
12
13
|
import { lookup as mimeLookup } from './mimetypes.js';
|
|
13
14
|
import { listProviders, getProvider, clearInstallCache, getProviderMetadata, getProviderPath, setProviderPaths } from './providers/index.js';
|
|
14
15
|
import { OllamaProvider } from './providers/ollama.js';
|
|
@@ -97,12 +98,18 @@ export function createApi(app, daemon) {
|
|
|
97
98
|
next();
|
|
98
99
|
});
|
|
99
100
|
|
|
100
|
-
// Security headers
|
|
101
|
+
// Security headers — preview proxy routes get relaxed framing policy so the
|
|
102
|
+
// GUI can iframe the proxied dev server content.
|
|
101
103
|
app.use((req, res, next) => {
|
|
102
104
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
103
|
-
res.setHeader('X-Frame-Options', 'DENY');
|
|
104
105
|
res.setHeader('X-XSS-Protection', '0');
|
|
105
|
-
|
|
106
|
+
const isPreviewProxy = req.path.match(/^\/api\/preview\/[^/]+\/proxy/);
|
|
107
|
+
if (isPreviewProxy) {
|
|
108
|
+
res.setHeader('Content-Security-Policy', "default-src * 'unsafe-inline' 'unsafe-eval' data: blob:; frame-ancestors 'self'");
|
|
109
|
+
} else {
|
|
110
|
+
res.setHeader('X-Frame-Options', 'DENY');
|
|
111
|
+
res.setHeader('Content-Security-Policy', "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; connect-src 'self' ws://localhost:* ws://127.0.0.1:* http://localhost:* http://127.0.0.1:*; font-src 'self' data:; object-src 'none'; base-uri 'self'; frame-src 'self'; frame-ancestors 'none'");
|
|
112
|
+
}
|
|
106
113
|
next();
|
|
107
114
|
});
|
|
108
115
|
|
|
@@ -1258,6 +1265,66 @@ export function createApi(app, daemon) {
|
|
|
1258
1265
|
}
|
|
1259
1266
|
});
|
|
1260
1267
|
|
|
1268
|
+
// --- Image Generation ---
|
|
1269
|
+
|
|
1270
|
+
app.post('/api/conversations/:id/generate-image', async (req, res) => {
|
|
1271
|
+
try {
|
|
1272
|
+
const { prompt, model, size, quality } = req.body;
|
|
1273
|
+
if (!prompt || typeof prompt !== 'string' || !prompt.trim()) {
|
|
1274
|
+
return res.status(400).json({ error: 'prompt is required' });
|
|
1275
|
+
}
|
|
1276
|
+
const conv = daemon.conversations.get(req.params.id);
|
|
1277
|
+
if (!conv) return res.status(404).json({ error: 'Conversation not found' });
|
|
1278
|
+
|
|
1279
|
+
let providerName = conv.provider;
|
|
1280
|
+
let provider = getProvider(providerName);
|
|
1281
|
+
|
|
1282
|
+
// If a specific image model was requested, find the right provider
|
|
1283
|
+
if (model) {
|
|
1284
|
+
const imageProviders = ['codex', 'grok', 'nano-banana'];
|
|
1285
|
+
for (const pid of imageProviders) {
|
|
1286
|
+
const p = getProvider(pid);
|
|
1287
|
+
if (p?.constructor.models.some((m) => m.id === model)) {
|
|
1288
|
+
provider = p;
|
|
1289
|
+
providerName = pid;
|
|
1290
|
+
break;
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
if (!provider?.generateImage) {
|
|
1296
|
+
return res.status(400).json({ error: 'Provider does not support image generation' });
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
const apiKey = daemon.conversations._getApiKey(providerName);
|
|
1300
|
+
if (!apiKey) {
|
|
1301
|
+
return res.status(400).json({ error: `No API key configured for ${providerName}` });
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
daemon.broadcast({
|
|
1305
|
+
type: 'conversation:image-progress',
|
|
1306
|
+
data: { conversationId: req.params.id, status: 'generating', prompt: prompt.trim() },
|
|
1307
|
+
});
|
|
1308
|
+
|
|
1309
|
+
const result = await provider.generateImage(prompt.trim(), { model, size, quality, apiKey });
|
|
1310
|
+
|
|
1311
|
+
daemon.broadcast({
|
|
1312
|
+
type: 'conversation:image',
|
|
1313
|
+
data: { conversationId: req.params.id, ...result, prompt: prompt.trim() },
|
|
1314
|
+
});
|
|
1315
|
+
|
|
1316
|
+
daemon.conversations.touchUpdatedAt(req.params.id);
|
|
1317
|
+
daemon.audit.log('conversation.image', { id: req.params.id, model: result.model, provider: result.provider });
|
|
1318
|
+
res.json(result);
|
|
1319
|
+
} catch (err) {
|
|
1320
|
+
daemon.broadcast({
|
|
1321
|
+
type: 'conversation:image-progress',
|
|
1322
|
+
data: { conversationId: req.params.id, status: 'error', error: err.message },
|
|
1323
|
+
});
|
|
1324
|
+
res.status(500).json({ error: err.message });
|
|
1325
|
+
}
|
|
1326
|
+
});
|
|
1327
|
+
|
|
1261
1328
|
// --- Approvals ---
|
|
1262
1329
|
|
|
1263
1330
|
app.get('/api/approvals', (req, res) => {
|
|
@@ -3385,6 +3452,191 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
3385
3452
|
res.json(result);
|
|
3386
3453
|
});
|
|
3387
3454
|
|
|
3455
|
+
// --- Preview Proxy (same-origin iframe support) ---
|
|
3456
|
+
// Forwards HTTP requests to the dev server so the GUI can iframe the preview
|
|
3457
|
+
// without cross-origin issues. WebSocket upgrade for HMR is handled in index.js.
|
|
3458
|
+
app.all('/api/preview/:teamId/proxy/*', (req, res) => {
|
|
3459
|
+
const entry = daemon.preview?.get(req.params.teamId);
|
|
3460
|
+
if (!entry || !entry.url) return res.status(404).json({ error: 'No active preview for this team' });
|
|
3461
|
+
|
|
3462
|
+
let targetUrl;
|
|
3463
|
+
try { targetUrl = new URL(entry.url); } catch { return res.status(500).json({ error: 'Invalid preview URL' }); }
|
|
3464
|
+
|
|
3465
|
+
const proxyPath = req.params[0] || '';
|
|
3466
|
+
const search = req.url.includes('?') ? '?' + req.url.split('?').slice(1).join('?') : '';
|
|
3467
|
+
const fullPath = '/' + proxyPath + search;
|
|
3468
|
+
|
|
3469
|
+
const headers = { ...req.headers };
|
|
3470
|
+
delete headers.host;
|
|
3471
|
+
headers.host = targetUrl.host;
|
|
3472
|
+
|
|
3473
|
+
const proxyReq = httpRequest({
|
|
3474
|
+
hostname: targetUrl.hostname,
|
|
3475
|
+
port: targetUrl.port,
|
|
3476
|
+
path: fullPath,
|
|
3477
|
+
method: req.method,
|
|
3478
|
+
headers,
|
|
3479
|
+
}, (proxyRes) => {
|
|
3480
|
+
const fwdHeaders = { ...proxyRes.headers };
|
|
3481
|
+
delete fwdHeaders['content-security-policy'];
|
|
3482
|
+
delete fwdHeaders['x-frame-options'];
|
|
3483
|
+
res.writeHead(proxyRes.statusCode, fwdHeaders);
|
|
3484
|
+
proxyRes.pipe(res);
|
|
3485
|
+
});
|
|
3486
|
+
|
|
3487
|
+
proxyReq.on('error', (err) => {
|
|
3488
|
+
if (!res.headersSent) res.status(502).json({ error: `Proxy error: ${err.message}` });
|
|
3489
|
+
});
|
|
3490
|
+
req.pipe(proxyReq);
|
|
3491
|
+
});
|
|
3492
|
+
|
|
3493
|
+
// Also handle the bare path (no trailing wildcard)
|
|
3494
|
+
app.all('/api/preview/:teamId/proxy', (req, res) => {
|
|
3495
|
+
const entry = daemon.preview?.get(req.params.teamId);
|
|
3496
|
+
if (!entry || !entry.url) return res.status(404).json({ error: 'No active preview for this team' });
|
|
3497
|
+
|
|
3498
|
+
let targetUrl;
|
|
3499
|
+
try { targetUrl = new URL(entry.url); } catch { return res.status(500).json({ error: 'Invalid preview URL' }); }
|
|
3500
|
+
|
|
3501
|
+
const search = req.url.includes('?') ? '?' + req.url.split('?').slice(1).join('?') : '';
|
|
3502
|
+
|
|
3503
|
+
const headers = { ...req.headers };
|
|
3504
|
+
delete headers.host;
|
|
3505
|
+
headers.host = targetUrl.host;
|
|
3506
|
+
|
|
3507
|
+
const proxyReq = httpRequest({
|
|
3508
|
+
hostname: targetUrl.hostname,
|
|
3509
|
+
port: targetUrl.port,
|
|
3510
|
+
path: '/' + search,
|
|
3511
|
+
method: req.method,
|
|
3512
|
+
headers,
|
|
3513
|
+
}, (proxyRes) => {
|
|
3514
|
+
const fwdHeaders = { ...proxyRes.headers };
|
|
3515
|
+
delete fwdHeaders['content-security-policy'];
|
|
3516
|
+
delete fwdHeaders['x-frame-options'];
|
|
3517
|
+
res.writeHead(proxyRes.statusCode, fwdHeaders);
|
|
3518
|
+
proxyRes.pipe(res);
|
|
3519
|
+
});
|
|
3520
|
+
|
|
3521
|
+
proxyReq.on('error', (err) => {
|
|
3522
|
+
if (!res.headersSent) res.status(502).json({ error: `Proxy error: ${err.message}` });
|
|
3523
|
+
});
|
|
3524
|
+
req.pipe(proxyReq);
|
|
3525
|
+
});
|
|
3526
|
+
|
|
3527
|
+
// --- Iteration endpoint (planner routing for live preview feedback) ---
|
|
3528
|
+
app.post('/api/preview/:teamId/iterate', async (req, res) => {
|
|
3529
|
+
try {
|
|
3530
|
+
const { message, screenshot } = req.body;
|
|
3531
|
+
if (!message || typeof message !== 'string' || !message.trim()) {
|
|
3532
|
+
return res.status(400).json({ error: 'message is required and must be a non-empty string' });
|
|
3533
|
+
}
|
|
3534
|
+
|
|
3535
|
+
const teamId = req.params.teamId;
|
|
3536
|
+
const agents = daemon.registry.getAll().filter((a) => a.teamId === teamId);
|
|
3537
|
+
const planner = agents.find((a) => a.role === 'planner');
|
|
3538
|
+
|
|
3539
|
+
if (!planner) {
|
|
3540
|
+
return res.status(400).json({ error: 'No planner found for this team. Iteration routing requires a planner-based team.' });
|
|
3541
|
+
}
|
|
3542
|
+
|
|
3543
|
+
const terminal = new Set(['completed', 'crashed', 'stopped', 'killed']);
|
|
3544
|
+
const feedbackPrompt = [
|
|
3545
|
+
'ITERATION REQUEST: The user is viewing the live preview and wants changes.',
|
|
3546
|
+
'',
|
|
3547
|
+
`User feedback: ${message.trim()}`,
|
|
3548
|
+
'',
|
|
3549
|
+
screenshot ? 'The user attached a screenshot highlighting what they want changed.' : '',
|
|
3550
|
+
'',
|
|
3551
|
+
'Analyze this feedback and route it to the appropriate team agent (frontend, backend, or fullstack) by writing .groove/recommended-team.json. Be specific about what files to change and what the change should be.',
|
|
3552
|
+
].filter(Boolean).join('\n');
|
|
3553
|
+
|
|
3554
|
+
if (terminal.has(planner.status)) {
|
|
3555
|
+
const newAgent = await daemon.processes.spawn({
|
|
3556
|
+
role: planner.role,
|
|
3557
|
+
scope: planner.scope,
|
|
3558
|
+
provider: planner.provider,
|
|
3559
|
+
model: planner.model,
|
|
3560
|
+
prompt: feedbackPrompt,
|
|
3561
|
+
permission: planner.permission || 'full',
|
|
3562
|
+
workingDir: planner.workingDir,
|
|
3563
|
+
name: planner.name,
|
|
3564
|
+
teamId: planner.teamId,
|
|
3565
|
+
});
|
|
3566
|
+
daemon.audit.log('preview.iterate', { teamId, plannerId: newAgent.id, respawned: true });
|
|
3567
|
+
return res.json({ status: 'routed', plannerAgent: newAgent.id, message: 'Feedback sent to respawned planner for routing' });
|
|
3568
|
+
}
|
|
3569
|
+
|
|
3570
|
+
if (daemon.processes.hasAgentLoop(planner.id)) {
|
|
3571
|
+
await daemon.processes.sendMessage(planner.id, feedbackPrompt);
|
|
3572
|
+
} else if (daemon.processes.isRunning(planner.id)) {
|
|
3573
|
+
daemon.processes.queueMessage(planner.id, feedbackPrompt);
|
|
3574
|
+
} else {
|
|
3575
|
+
return res.status(400).json({ error: 'Planner exists but is not reachable. Try again.' });
|
|
3576
|
+
}
|
|
3577
|
+
|
|
3578
|
+
daemon.audit.log('preview.iterate', { teamId, plannerId: planner.id, respawned: false });
|
|
3579
|
+
res.json({ status: 'routed', plannerAgent: planner.id, message: 'Feedback sent to planner for routing' });
|
|
3580
|
+
} catch (err) {
|
|
3581
|
+
res.status(500).json({ error: err.message });
|
|
3582
|
+
}
|
|
3583
|
+
});
|
|
3584
|
+
|
|
3585
|
+
// --- Screenshot storage for preview iteration ---
|
|
3586
|
+
app.post('/api/preview/:teamId/screenshot', (req, res) => {
|
|
3587
|
+
try {
|
|
3588
|
+
const { image, filename } = req.body;
|
|
3589
|
+
if (!image || typeof image !== 'string') {
|
|
3590
|
+
return res.status(400).json({ error: 'image (base64 string) is required' });
|
|
3591
|
+
}
|
|
3592
|
+
|
|
3593
|
+
const teamId = req.params.teamId;
|
|
3594
|
+
const agents = daemon.registry.getAll().filter((a) => a.teamId === teamId);
|
|
3595
|
+
const teamAgent = agents[0];
|
|
3596
|
+
if (!teamAgent) return res.status(404).json({ error: 'No agents found for this team' });
|
|
3597
|
+
|
|
3598
|
+
const workDir = teamAgent.workingDir || daemon.projectDir;
|
|
3599
|
+
const screenshotDir = resolve(workDir, '.groove', 'screenshots');
|
|
3600
|
+
mkdirSync(screenshotDir, { recursive: true });
|
|
3601
|
+
|
|
3602
|
+
const ts = Date.now();
|
|
3603
|
+
const safeName = (filename || 'screenshot').replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
3604
|
+
const fname = `${ts}-${safeName}.png`;
|
|
3605
|
+
const filePath = resolve(screenshotDir, fname);
|
|
3606
|
+
|
|
3607
|
+
const base64Data = image.replace(/^data:image\/\w+;base64,/, '');
|
|
3608
|
+
writeFileSync(filePath, Buffer.from(base64Data, 'base64'));
|
|
3609
|
+
|
|
3610
|
+
const relativePath = `.groove/screenshots/${fname}`;
|
|
3611
|
+
daemon.audit.log('preview.screenshot', { teamId, path: relativePath });
|
|
3612
|
+
res.json({
|
|
3613
|
+
path: relativePath,
|
|
3614
|
+
url: `/api/preview/${teamId}/screenshots/${fname}`,
|
|
3615
|
+
});
|
|
3616
|
+
} catch (err) {
|
|
3617
|
+
res.status(500).json({ error: err.message });
|
|
3618
|
+
}
|
|
3619
|
+
});
|
|
3620
|
+
|
|
3621
|
+
app.get('/api/preview/:teamId/screenshots/:filename', (req, res) => {
|
|
3622
|
+
const teamId = req.params.teamId;
|
|
3623
|
+
const fname = req.params.filename;
|
|
3624
|
+
if (!fname || fname.includes('..') || fname.includes('/') || fname.includes('\\')) {
|
|
3625
|
+
return res.status(400).json({ error: 'Invalid filename' });
|
|
3626
|
+
}
|
|
3627
|
+
|
|
3628
|
+
const agents = daemon.registry.getAll().filter((a) => a.teamId === teamId);
|
|
3629
|
+
const teamAgent = agents[0];
|
|
3630
|
+
if (!teamAgent) return res.status(404).json({ error: 'No agents found for this team' });
|
|
3631
|
+
|
|
3632
|
+
const workDir = teamAgent.workingDir || daemon.projectDir;
|
|
3633
|
+
const filePath = resolve(workDir, '.groove', 'screenshots', fname);
|
|
3634
|
+
if (!existsSync(filePath)) return res.status(404).json({ error: 'Screenshot not found' });
|
|
3635
|
+
|
|
3636
|
+
res.setHeader('Content-Type', 'image/png');
|
|
3637
|
+
createReadStream(filePath).pipe(res);
|
|
3638
|
+
});
|
|
3639
|
+
|
|
3388
3640
|
// Clean up stale artifacts. Scope to a single team when teamId is provided —
|
|
3389
3641
|
// wiping every agent's working dir on a global cleanup would delete other
|
|
3390
3642
|
// in-flight teams' unlaunched plans. When called with no teamId, only the
|
|
@@ -4166,7 +4418,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
4166
4418
|
const ALLOWED_KEYS = [
|
|
4167
4419
|
'port', 'journalistInterval', 'rotationThreshold', 'autoRotation',
|
|
4168
4420
|
'qcThreshold', 'maxAgents', 'defaultProvider', 'defaultWorkingDir',
|
|
4169
|
-
'onboardingDismissed', 'defaultModel',
|
|
4421
|
+
'onboardingDismissed', 'defaultModel', 'defaultChatProvider', 'defaultChatModel',
|
|
4170
4422
|
];
|
|
4171
4423
|
for (const key of Object.keys(req.body)) {
|
|
4172
4424
|
if (!ALLOWED_KEYS.includes(key)) {
|
|
@@ -56,6 +56,13 @@ export class ConversationManager {
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
async create(provider, model, title, mode = 'api') {
|
|
59
|
+
if (!provider && this.daemon.config?.defaultChatProvider) {
|
|
60
|
+
provider = this.daemon.config.defaultChatProvider;
|
|
61
|
+
}
|
|
62
|
+
if (!model && this.daemon.config?.defaultChatModel) {
|
|
63
|
+
model = this.daemon.config.defaultChatModel;
|
|
64
|
+
}
|
|
65
|
+
|
|
59
66
|
const id = randomUUID().slice(0, 12);
|
|
60
67
|
const now = new Date().toISOString();
|
|
61
68
|
|
|
@@ -285,6 +292,8 @@ export class ConversationManager {
|
|
|
285
292
|
'claude-code': 'ANTHROPIC_API_KEY',
|
|
286
293
|
'codex': 'OPENAI_API_KEY',
|
|
287
294
|
'gemini': 'GEMINI_API_KEY',
|
|
295
|
+
'grok': 'XAI_API_KEY',
|
|
296
|
+
'nano-banana': 'GEMINI_API_KEY',
|
|
288
297
|
};
|
|
289
298
|
const envVar = envMap[providerName];
|
|
290
299
|
if (envVar && process.env[envVar]) return process.env[envVar];
|
|
@@ -355,6 +364,13 @@ export class ConversationManager {
|
|
|
355
364
|
// Fallback: headless CLI spawn (for providers without streamChat or missing API key)
|
|
356
365
|
const prompt = this._buildHistoryPrompt(history, message);
|
|
357
366
|
const headlessCmd = provider.buildHeadlessCommand(prompt, modelId);
|
|
367
|
+
if (!headlessCmd) {
|
|
368
|
+
this.daemon.broadcast({
|
|
369
|
+
type: 'conversation:error',
|
|
370
|
+
data: { conversationId: id, error: `${providerName} requires an API key for chat` },
|
|
371
|
+
});
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
358
374
|
const { command, args, env, stdin: stdinData, cwd } = headlessCmd;
|
|
359
375
|
|
|
360
376
|
const spawnOpts = {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// GROOVE Daemon — Entry Point
|
|
2
2
|
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
3
|
|
|
4
|
-
import { createServer as createHttpServer } from 'http';
|
|
4
|
+
import { createServer as createHttpServer, request as httpProxyRequest } from 'http';
|
|
5
5
|
import { createServer as createNetServer } from 'net';
|
|
6
6
|
import { execFileSync } from 'child_process';
|
|
7
7
|
import { resolve } from 'path';
|
|
@@ -208,6 +208,46 @@ export class Daemon {
|
|
|
208
208
|
this.federationWss.handleUpgrade(req, socket, head, (ws) => {
|
|
209
209
|
this.federation.handleWsUpgrade(ws, daemonId, callerIp, signatureHeader);
|
|
210
210
|
});
|
|
211
|
+
} else if (req.url?.startsWith('/api/preview/') && req.url.includes('/proxy')) {
|
|
212
|
+
// HMR WebSocket proxy — forward to the dev server's WebSocket
|
|
213
|
+
const match = req.url.match(/^\/api\/preview\/([^/]+)\/proxy\/?(.*)$/);
|
|
214
|
+
if (!match || !this.preview) { socket.destroy(); return; }
|
|
215
|
+
const entry = this.preview.get(match[1]);
|
|
216
|
+
if (!entry?.url) { socket.destroy(); return; }
|
|
217
|
+
let targetUrl;
|
|
218
|
+
try { targetUrl = new URL(entry.url); } catch { socket.destroy(); return; }
|
|
219
|
+
const wsPath = '/' + (match[2] || '');
|
|
220
|
+
const opts = {
|
|
221
|
+
hostname: targetUrl.hostname,
|
|
222
|
+
port: targetUrl.port,
|
|
223
|
+
path: wsPath,
|
|
224
|
+
method: 'GET',
|
|
225
|
+
headers: { ...req.headers, host: targetUrl.host },
|
|
226
|
+
};
|
|
227
|
+
const upstream = httpProxyRequest(opts);
|
|
228
|
+
upstream.on('upgrade', (_res, upSocket, upHead) => {
|
|
229
|
+
const skipHeaders = new Set(['upgrade', 'connection', 'sec-websocket-accept']);
|
|
230
|
+
const extra = Object.entries(_res.headers)
|
|
231
|
+
.filter(([k]) => !skipHeaders.has(k))
|
|
232
|
+
.map(([k, v]) => `${k}: ${v}\r\n`).join('');
|
|
233
|
+
socket.write(
|
|
234
|
+
'HTTP/1.1 101 Switching Protocols\r\n' +
|
|
235
|
+
`Upgrade: ${_res.headers.upgrade || 'websocket'}\r\n` +
|
|
236
|
+
`Connection: Upgrade\r\n` +
|
|
237
|
+
`Sec-WebSocket-Accept: ${_res.headers['sec-websocket-accept'] || ''}\r\n` +
|
|
238
|
+
extra +
|
|
239
|
+
'\r\n'
|
|
240
|
+
);
|
|
241
|
+
if (upHead.length) socket.write(upHead);
|
|
242
|
+
upSocket.pipe(socket);
|
|
243
|
+
socket.pipe(upSocket);
|
|
244
|
+
upSocket.on('error', () => socket.destroy());
|
|
245
|
+
socket.on('error', () => upSocket.destroy());
|
|
246
|
+
upSocket.on('close', () => socket.destroy());
|
|
247
|
+
socket.on('close', () => upSocket.destroy());
|
|
248
|
+
});
|
|
249
|
+
upstream.on('error', () => socket.destroy());
|
|
250
|
+
upstream.end();
|
|
211
251
|
} else {
|
|
212
252
|
this.wss.handleUpgrade(req, socket, head, (ws) => {
|
|
213
253
|
this.wss.emit('connection', ws, req);
|
|
@@ -17,7 +17,7 @@ import { existsSync, readFileSync, statSync } from 'fs';
|
|
|
17
17
|
import { createServer } from 'http';
|
|
18
18
|
import { lookup as mimeLookup } from './mimetypes.js';
|
|
19
19
|
|
|
20
|
-
const READY_TIMEOUT_MS =
|
|
20
|
+
const READY_TIMEOUT_MS = 120_000; // give dev servers 2 minutes (large projects need npm install)
|
|
21
21
|
const MAX_STDOUT_BYTES = 256 * 1024;
|
|
22
22
|
// Strip CSI/OSC/other ANSI escape sequences — Vite prints URLs with inline
|
|
23
23
|
// bold/color codes (e.g. "http://localhost:\x1b[1m5175\x1b[22m/") which would
|
|
@@ -147,11 +147,41 @@ export class PreviewService {
|
|
|
147
147
|
});
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
_autoDetectDevCommand(baseDir) {
|
|
151
|
+
const pkgPath = resolve(baseDir, 'package.json');
|
|
152
|
+
if (!existsSync(pkgPath)) return null;
|
|
153
|
+
try {
|
|
154
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
155
|
+
const scripts = pkg.scripts || {};
|
|
156
|
+
for (const name of ['dev', 'start', 'serve']) {
|
|
157
|
+
if (scripts[name]) return `npm run ${name}`;
|
|
158
|
+
}
|
|
159
|
+
} catch { /* malformed package.json */ }
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
150
163
|
_launchDevServer(teamId, baseDir, preview) {
|
|
151
|
-
|
|
164
|
+
let command = String(preview.command || '').trim();
|
|
165
|
+
if (!command) {
|
|
166
|
+
command = this._autoDetectDevCommand(baseDir) || '';
|
|
167
|
+
}
|
|
152
168
|
if (!command) {
|
|
153
169
|
return Promise.resolve({ launched: false, reason: 'no_command' });
|
|
154
170
|
}
|
|
171
|
+
// If command references an npm script, verify it exists in package.json
|
|
172
|
+
const npmRunMatch = command.match(/npm\s+run\s+(\S+)/);
|
|
173
|
+
if (npmRunMatch) {
|
|
174
|
+
const scriptName = npmRunMatch[1];
|
|
175
|
+
const pkgPath = resolve(baseDir, 'package.json');
|
|
176
|
+
try {
|
|
177
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
178
|
+
if (!pkg.scripts || !pkg.scripts[scriptName]) {
|
|
179
|
+
return Promise.resolve({ launched: false, reason: 'no_dev_script' });
|
|
180
|
+
}
|
|
181
|
+
} catch {
|
|
182
|
+
return Promise.resolve({ launched: false, reason: 'no_dev_script' });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
155
185
|
const urlPattern = preview.urlPattern
|
|
156
186
|
? new RegExp(preview.urlPattern)
|
|
157
187
|
: /https?:\/\/(localhost|127\.0\.0\.1|0\.0\.0\.0):\d+/;
|
|
@@ -114,6 +114,9 @@ CRITICAL — NEVER DO THESE:
|
|
|
114
114
|
- NEVER kill the daemon process. No "kill <pid>", "pkill groove", "killall node", etc.
|
|
115
115
|
- NEVER run "./promote.sh", "./promote-local.sh", or any publish/deploy script.
|
|
116
116
|
- NEVER start long-running dev servers (vite dev, npm start, next dev, etc.).
|
|
117
|
+
- NEVER use 'git add -f' or 'git add --force' to bypass .gitignore. If a file is gitignored, it should stay gitignored. Only stage files that git tracks normally. If .gitignore prevents staging, report it in your output — do NOT force-add.
|
|
118
|
+
- NEVER use 'git push --force' or 'git push -f'. Force-pushing can destroy shared history.
|
|
119
|
+
- NEVER modify .gitignore to include files that were previously excluded.
|
|
117
120
|
|
|
118
121
|
Restarting the daemon destroys ALL other agents currently running in other teams. Verification is done via "npm run build" and "npm test", which exit cleanly. If code changes require a daemon restart to take effect, report that in your output so the user can restart manually — do NOT do it yourself.
|
|
119
122
|
|
|
@@ -1161,6 +1164,11 @@ For normal file edits within your scope, proceed without review.
|
|
|
1161
1164
|
const workingDir = plan.workingDir;
|
|
1162
1165
|
preview.launch(teamId, workingDir, plan.preview).then((result) => {
|
|
1163
1166
|
if (!result.launched) {
|
|
1167
|
+
const intentionalSkips = new Set(['no_preview', 'cli', 'none', 'no_command', 'no_dev_script']);
|
|
1168
|
+
if (intentionalSkips.has(result.reason)) {
|
|
1169
|
+
console.log(`[Groove] Preview for team ${teamId} intentionally skipped: ${result.reason}`);
|
|
1170
|
+
return;
|
|
1171
|
+
}
|
|
1164
1172
|
console.warn(`[Groove] Preview for team ${teamId} did not launch: ${result.reason}`);
|
|
1165
1173
|
this.daemon.broadcast({
|
|
1166
1174
|
type: 'preview:failed',
|
|
@@ -1171,7 +1179,7 @@ For normal file edits within your scope, proceed without review.
|
|
|
1171
1179
|
}
|
|
1172
1180
|
}).catch((err) => {
|
|
1173
1181
|
console.error(`[Groove] Preview launch error for team ${teamId}:`, err.message);
|
|
1174
|
-
this.daemon.broadcast({ type: 'preview:failed', teamId, reason: err.message });
|
|
1182
|
+
this.daemon.broadcast({ type: 'preview:failed', teamId, kind: plan.preview?.kind, reason: err.message });
|
|
1175
1183
|
});
|
|
1176
1184
|
}
|
|
1177
1185
|
|
|
@@ -43,6 +43,8 @@ export class CodexProvider extends Provider {
|
|
|
43
43
|
{ id: 'gpt-5.4-nano', name: 'GPT-5.4 Nano', tier: 'light', maxContext: 200000, pricing: { input: 0.0004, output: 0.0016 } },
|
|
44
44
|
{ id: 'gpt-5-mini', name: 'GPT-5 Mini', tier: 'medium', maxContext: 200000, pricing: { input: 0.0005, output: 0.002 } },
|
|
45
45
|
{ id: 'gpt-5-nano', name: 'GPT-5 Nano', tier: 'light', maxContext: 200000, pricing: { input: 0.0001, output: 0.0004 } },
|
|
46
|
+
{ id: 'gpt-image-2', name: 'GPT Image 2', tier: 'medium', type: 'image', pricing: { perImage: 0.07 } },
|
|
47
|
+
{ id: 'gpt-image-1', name: 'GPT Image 1', tier: 'medium', type: 'image', pricing: { perImage: 0.02 } },
|
|
46
48
|
];
|
|
47
49
|
|
|
48
50
|
static isInstalled() {
|
|
@@ -186,6 +188,42 @@ export class CodexProvider extends Provider {
|
|
|
186
188
|
return controller;
|
|
187
189
|
}
|
|
188
190
|
|
|
191
|
+
async generateImage(prompt, options = {}) {
|
|
192
|
+
const apiKey = options.apiKey;
|
|
193
|
+
if (!apiKey) throw new Error('OPENAI_API_KEY required for image generation');
|
|
194
|
+
|
|
195
|
+
const body = {
|
|
196
|
+
model: options.model || 'gpt-image-1',
|
|
197
|
+
prompt,
|
|
198
|
+
n: 1,
|
|
199
|
+
};
|
|
200
|
+
if (options.size) body.size = options.size;
|
|
201
|
+
if (options.quality) body.quality = options.quality;
|
|
202
|
+
|
|
203
|
+
const res = await fetch('https://api.openai.com/v1/images/generations', {
|
|
204
|
+
method: 'POST',
|
|
205
|
+
headers: {
|
|
206
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
207
|
+
'Content-Type': 'application/json',
|
|
208
|
+
},
|
|
209
|
+
body: JSON.stringify(body),
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
if (!res.ok) {
|
|
213
|
+
const text = await res.text();
|
|
214
|
+
throw new Error(`OpenAI Image API ${res.status}: ${text.slice(0, 200)}`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const data = await res.json();
|
|
218
|
+
const image = data.data?.[0];
|
|
219
|
+
return {
|
|
220
|
+
url: image?.url || null,
|
|
221
|
+
b64_json: image?.b64_json || null,
|
|
222
|
+
model: body.model,
|
|
223
|
+
provider: 'codex',
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
189
227
|
parseOutput(line) {
|
|
190
228
|
const trimmed = line.trim();
|
|
191
229
|
if (!trimmed) return null;
|