groove-dev 0.27.73 → 0.27.75
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 +18 -2
- package/node_modules/@groove-dev/daemon/src/process.js +6 -1
- package/node_modules/@groove-dev/daemon/src/providers/base.js +4 -0
- package/node_modules/@groove-dev/daemon/src/providers/claude-code.js +2 -1
- package/node_modules/@groove-dev/daemon/src/providers/codex.js +41 -1
- package/node_modules/@groove-dev/daemon/src/providers/gemini.js +2 -1
- package/node_modules/@groove-dev/daemon/src/providers/grok.js +156 -0
- package/node_modules/@groove-dev/daemon/src/providers/index.js +26 -9
- package/node_modules/@groove-dev/daemon/src/providers/nano-banana.js +103 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-CAT9SCJi.js +8620 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-CVzz6zyb.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 +29 -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 +40 -7
- package/node_modules/@groove-dev/gui/src/components/chat/chat-messages.jsx +149 -31
- 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 +81 -0
- package/node_modules/@groove-dev/gui/src/components/preview/preview-workspace.jsx +263 -0
- package/node_modules/@groove-dev/gui/src/components/preview/screenshot-overlay.jsx +203 -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 +278 -123
- 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 +18 -2
- package/packages/daemon/src/process.js +6 -1
- package/packages/daemon/src/providers/base.js +4 -0
- package/packages/daemon/src/providers/claude-code.js +2 -1
- package/packages/daemon/src/providers/codex.js +41 -1
- package/packages/daemon/src/providers/gemini.js +2 -1
- package/packages/daemon/src/providers/grok.js +156 -0
- package/packages/daemon/src/providers/index.js +26 -9
- package/packages/daemon/src/providers/nano-banana.js +103 -0
- package/packages/gui/dist/assets/index-CAT9SCJi.js +8620 -0
- package/packages/gui/dist/assets/index-CVzz6zyb.css +1 -0
- package/packages/gui/dist/index.html +2 -2
- package/packages/gui/package.json +1 -1
- package/packages/gui/src/app.css +29 -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 +40 -7
- package/packages/gui/src/components/chat/chat-messages.jsx +149 -31
- 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 +81 -0
- package/packages/gui/src/components/preview/preview-workspace.jsx +263 -0
- package/packages/gui/src/components/preview/screenshot-overlay.jsx +203 -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 +278 -123
- package/node_modules/@groove-dev/gui/dist/assets/index-BFc7Ov6v.css +0 -1
- package/node_modules/@groove-dev/gui/dist/assets/index-Deza1S0i.js +0 -8615
- package/packages/gui/dist/assets/index-BFc7Ov6v.css +0 -1
- package/packages/gui/dist/assets/index-Deza1S0i.js +0 -8615
|
@@ -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,8 +147,24 @@ 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
|
}
|
|
@@ -1161,6 +1161,11 @@ For normal file edits within your scope, proceed without review.
|
|
|
1161
1161
|
const workingDir = plan.workingDir;
|
|
1162
1162
|
preview.launch(teamId, workingDir, plan.preview).then((result) => {
|
|
1163
1163
|
if (!result.launched) {
|
|
1164
|
+
const intentionalSkips = new Set(['no_preview', 'cli', 'none']);
|
|
1165
|
+
if (intentionalSkips.has(result.reason)) {
|
|
1166
|
+
console.log(`[Groove] Preview for team ${teamId} intentionally skipped: ${result.reason}`);
|
|
1167
|
+
return;
|
|
1168
|
+
}
|
|
1164
1169
|
console.warn(`[Groove] Preview for team ${teamId} did not launch: ${result.reason}`);
|
|
1165
1170
|
this.daemon.broadcast({
|
|
1166
1171
|
type: 'preview:failed',
|
|
@@ -1171,7 +1176,7 @@ For normal file edits within your scope, proceed without review.
|
|
|
1171
1176
|
}
|
|
1172
1177
|
}).catch((err) => {
|
|
1173
1178
|
console.error(`[Groove] Preview launch error for team ${teamId}:`, err.message);
|
|
1174
|
-
this.daemon.broadcast({ type: 'preview:failed', teamId, reason: err.message });
|
|
1179
|
+
this.daemon.broadcast({ type: 'preview:failed', teamId, kind: plan.preview?.kind, reason: err.message });
|
|
1175
1180
|
});
|
|
1176
1181
|
}
|
|
1177
1182
|
|
|
@@ -46,7 +46,8 @@ export class ClaudeCodeProvider extends Provider {
|
|
|
46
46
|
|
|
47
47
|
static isInstalled() {
|
|
48
48
|
try {
|
|
49
|
-
|
|
49
|
+
const cmd = process.platform === 'win32' ? 'where claude' : 'which claude';
|
|
50
|
+
execSync(cmd, { stdio: 'ignore' });
|
|
50
51
|
return true;
|
|
51
52
|
} catch {
|
|
52
53
|
return false;
|
|
@@ -36,17 +36,21 @@ export class CodexProvider extends Provider {
|
|
|
36
36
|
// Auth hint — Codex uses its own auth system, not just env vars
|
|
37
37
|
static authHint = 'Codex requires `codex login` — run: echo "YOUR_KEY" | codex login --with-api-key';
|
|
38
38
|
static models = [
|
|
39
|
+
{ id: 'gpt-5.5', name: 'GPT-5.5', tier: 'heavy', maxContext: 200000, pricing: { input: 0.03, output: 0.12 } },
|
|
39
40
|
{ id: 'gpt-5.4-pro', name: 'GPT-5.4 Pro', tier: 'heavy', maxContext: 200000, pricing: { input: 0.015, output: 0.06 } },
|
|
40
41
|
{ id: 'gpt-5.4', name: 'GPT-5.4', tier: 'heavy', maxContext: 200000, pricing: { input: 0.005, output: 0.02 } },
|
|
41
42
|
{ id: 'gpt-5.4-mini', name: 'GPT-5.4 Mini', tier: 'medium', maxContext: 200000, pricing: { input: 0.001, output: 0.004 } },
|
|
42
43
|
{ id: 'gpt-5.4-nano', name: 'GPT-5.4 Nano', tier: 'light', maxContext: 200000, pricing: { input: 0.0004, output: 0.0016 } },
|
|
43
44
|
{ id: 'gpt-5-mini', name: 'GPT-5 Mini', tier: 'medium', maxContext: 200000, pricing: { input: 0.0005, output: 0.002 } },
|
|
44
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 } },
|
|
45
48
|
];
|
|
46
49
|
|
|
47
50
|
static isInstalled() {
|
|
48
51
|
try {
|
|
49
|
-
|
|
52
|
+
const cmd = process.platform === 'win32' ? 'where codex' : 'which codex';
|
|
53
|
+
execSync(cmd, { stdio: 'ignore' });
|
|
50
54
|
return true;
|
|
51
55
|
} catch {
|
|
52
56
|
return false;
|
|
@@ -184,6 +188,42 @@ export class CodexProvider extends Provider {
|
|
|
184
188
|
return controller;
|
|
185
189
|
}
|
|
186
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
|
+
|
|
187
227
|
parseOutput(line) {
|
|
188
228
|
const trimmed = line.trim();
|
|
189
229
|
if (!trimmed) return null;
|
|
@@ -40,7 +40,8 @@ export class GeminiProvider extends Provider {
|
|
|
40
40
|
|
|
41
41
|
static isInstalled() {
|
|
42
42
|
try {
|
|
43
|
-
|
|
43
|
+
const cmd = process.platform === 'win32' ? 'where gemini' : 'which gemini';
|
|
44
|
+
execSync(cmd, { stdio: 'ignore' });
|
|
44
45
|
return true;
|
|
45
46
|
} catch {
|
|
46
47
|
return false;
|