groove-dev 0.27.56 → 0.27.58
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/ai-chat/CHAT_MASTER_PLAN.md +25 -5
- 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 +37 -9
- package/node_modules/@groove-dev/daemon/src/conversations.js +257 -20
- package/node_modules/@groove-dev/gui/dist/assets/index-C5WTeZO4.css +1 -0
- package/node_modules/@groove-dev/gui/dist/assets/{index-Bb8CIVBT.js → index-oLUl--Me.js} +1736 -1736
- 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/components/chat/chat-header.jsx +28 -10
- package/node_modules/@groove-dev/gui/src/components/chat/chat-input.jsx +9 -23
- package/node_modules/@groove-dev/gui/src/components/chat/chat-messages.jsx +19 -6
- package/node_modules/@groove-dev/gui/src/components/chat/chat-view.jsx +18 -10
- package/node_modules/@groove-dev/gui/src/components/chat/conversation-list.jsx +18 -13
- package/node_modules/@groove-dev/gui/src/components/chat/model-picker.jsx +22 -10
- package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +2 -2
- package/node_modules/@groove-dev/gui/src/stores/groove.js +68 -5
- 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 +37 -9
- package/packages/daemon/src/conversations.js +257 -20
- package/packages/gui/dist/assets/index-C5WTeZO4.css +1 -0
- package/packages/gui/dist/assets/{index-Bb8CIVBT.js → index-oLUl--Me.js} +1736 -1736
- package/packages/gui/dist/index.html +2 -2
- package/packages/gui/package.json +1 -1
- package/packages/gui/src/components/chat/chat-header.jsx +28 -10
- package/packages/gui/src/components/chat/chat-input.jsx +9 -23
- package/packages/gui/src/components/chat/chat-messages.jsx +19 -6
- package/packages/gui/src/components/chat/chat-view.jsx +18 -10
- package/packages/gui/src/components/chat/conversation-list.jsx +18 -13
- package/packages/gui/src/components/chat/model-picker.jsx +22 -10
- package/packages/gui/src/components/layout/activity-bar.jsx +2 -2
- package/packages/gui/src/stores/groove.js +68 -5
- package/node_modules/@groove-dev/gui/dist/assets/index-DOy_oMyr.css +0 -1
- package/packages/gui/dist/assets/index-DOy_oMyr.css +0 -1
|
@@ -14,11 +14,31 @@ Teams are powerful but heavy. Sometimes you just want to ask a question, researc
|
|
|
14
14
|
|
|
15
15
|
## Architecture
|
|
16
16
|
|
|
17
|
-
Chat
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
-
|
|
21
|
-
-
|
|
17
|
+
Chat supports two modes — the user picks which one fits the moment:
|
|
18
|
+
|
|
19
|
+
### API Mode (default, lightweight)
|
|
20
|
+
- No agent process spawned — each message is a one-shot callHeadless() call
|
|
21
|
+
- Client sends conversation history with each request (context managed client-side)
|
|
22
|
+
- Cheap, fast, casual — like talking to ChatGPT or Claude.ai
|
|
23
|
+
- Uses the journalist's existing callHeadless() infrastructure (works across all providers)
|
|
24
|
+
- Response streamed back via a streaming HTTP response or WebSocket
|
|
25
|
+
- Perfect for: quick questions, brainstorming, research, casual chat
|
|
26
|
+
- Groove Network models are always API mode (inherently one-shot)
|
|
27
|
+
|
|
28
|
+
### Agent Mode (heavyweight)
|
|
29
|
+
- Spawns a full chat agent with tools, file access, session resume
|
|
30
|
+
- Agent persists between messages — maintains its own context
|
|
31
|
+
- Can read/write files, run commands, search codebase
|
|
32
|
+
- Session resume means zero cold-start when returning
|
|
33
|
+
- More expensive but more powerful
|
|
34
|
+
- Perfect for: code reviews, implementation help, deep analysis
|
|
35
|
+
|
|
36
|
+
### Mode Switching
|
|
37
|
+
- Toggle in the chat header next to the model picker: "Chat" (API) vs "Agent" (full)
|
|
38
|
+
- Default is API mode — lightweight until you need power
|
|
39
|
+
- Can switch mid-conversation — upgrading to Agent spawns an agent with the conversation history as context
|
|
40
|
+
- Downgrading to API kills the agent, continues with client-side history
|
|
41
|
+
- Conversations persist metadata (title, model, mode, timestamps, pinned status) separately from agent lifecycle
|
|
22
42
|
- Layer 7 memory gives continuity across conversations
|
|
23
43
|
|
|
24
44
|
```
|
|
@@ -818,12 +818,15 @@ export function createApi(app, daemon) {
|
|
|
818
818
|
|
|
819
819
|
app.post('/api/conversations', async (req, res) => {
|
|
820
820
|
try {
|
|
821
|
-
const { provider, model, title } = req.body;
|
|
821
|
+
const { provider, model, title, mode } = req.body;
|
|
822
822
|
if (!provider || typeof provider !== 'string') {
|
|
823
823
|
return res.status(400).json({ error: 'provider is required' });
|
|
824
824
|
}
|
|
825
|
-
|
|
826
|
-
|
|
825
|
+
if (mode && mode !== 'api' && mode !== 'agent') {
|
|
826
|
+
return res.status(400).json({ error: 'mode must be "api" or "agent"' });
|
|
827
|
+
}
|
|
828
|
+
const conversation = await daemon.conversations.create(provider, model, title, mode || 'api');
|
|
829
|
+
daemon.audit.log('conversation.create', { id: conversation.id, provider, model, mode: conversation.mode });
|
|
827
830
|
res.status(201).json(conversation);
|
|
828
831
|
} catch (err) {
|
|
829
832
|
res.status(400).json({ error: err.message });
|
|
@@ -836,14 +839,20 @@ export function createApi(app, daemon) {
|
|
|
836
839
|
res.json(conversation);
|
|
837
840
|
});
|
|
838
841
|
|
|
839
|
-
app.patch('/api/conversations/:id', (req, res) => {
|
|
842
|
+
app.patch('/api/conversations/:id', async (req, res) => {
|
|
840
843
|
try {
|
|
841
844
|
const conv = daemon.conversations.get(req.params.id);
|
|
842
845
|
if (!conv) return res.status(404).json({ error: 'Conversation not found' });
|
|
843
846
|
if (req.body.title !== undefined) daemon.conversations.rename(req.params.id, req.body.title);
|
|
844
847
|
if (req.body.pinned !== undefined) daemon.conversations.pin(req.params.id, req.body.pinned);
|
|
845
848
|
if (req.body.archived !== undefined) daemon.conversations.archive(req.params.id, req.body.archived);
|
|
846
|
-
|
|
849
|
+
if (req.body.mode !== undefined) {
|
|
850
|
+
if (req.body.mode !== 'api' && req.body.mode !== 'agent') {
|
|
851
|
+
return res.status(400).json({ error: 'mode must be "api" or "agent"' });
|
|
852
|
+
}
|
|
853
|
+
await daemon.conversations.setMode(req.params.id, req.body.mode);
|
|
854
|
+
}
|
|
855
|
+
daemon.audit.log('conversation.update', { id: req.params.id, mode: req.body.mode });
|
|
847
856
|
res.json(daemon.conversations.get(req.params.id));
|
|
848
857
|
} catch (err) {
|
|
849
858
|
res.status(400).json({ error: err.message });
|
|
@@ -864,19 +873,27 @@ export function createApi(app, daemon) {
|
|
|
864
873
|
|
|
865
874
|
app.post('/api/conversations/:id/message', async (req, res) => {
|
|
866
875
|
try {
|
|
867
|
-
const { message } = req.body;
|
|
876
|
+
const { message, history } = req.body;
|
|
868
877
|
if (!message || typeof message !== 'string' || !message.trim()) {
|
|
869
878
|
return res.status(400).json({ error: 'message is required' });
|
|
870
879
|
}
|
|
871
880
|
const conv = daemon.conversations.get(req.params.id);
|
|
872
881
|
if (!conv) return res.status(404).json({ error: 'Conversation not found' });
|
|
873
882
|
|
|
874
|
-
const agent = daemon.registry.get(conv.agentId);
|
|
875
|
-
if (!agent) return res.status(400).json({ error: 'Agent no longer exists' });
|
|
876
|
-
|
|
877
883
|
daemon.conversations.autoTitle(req.params.id, message.trim());
|
|
878
884
|
daemon.conversations.touchUpdatedAt(req.params.id);
|
|
879
885
|
|
|
886
|
+
// API mode — lightweight headless streaming, no agent spawned
|
|
887
|
+
if (conv.mode === 'api' || !conv.agentId) {
|
|
888
|
+
await daemon.conversations.sendMessage(req.params.id, message.trim(), history || []);
|
|
889
|
+
daemon.audit.log('conversation.message', { id: req.params.id, mode: 'api' });
|
|
890
|
+
return res.json({ status: 'streaming', mode: 'api' });
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// Agent mode — existing behavior
|
|
894
|
+
const agent = daemon.registry.get(conv.agentId);
|
|
895
|
+
if (!agent) return res.status(400).json({ error: 'Agent no longer exists' });
|
|
896
|
+
|
|
880
897
|
// Record user feedback for journalist context
|
|
881
898
|
if (daemon.journalist) daemon.journalist.recordUserFeedback(agent, message.trim());
|
|
882
899
|
|
|
@@ -951,6 +968,17 @@ export function createApi(app, daemon) {
|
|
|
951
968
|
}
|
|
952
969
|
});
|
|
953
970
|
|
|
971
|
+
app.post('/api/conversations/:id/stop', (req, res) => {
|
|
972
|
+
try {
|
|
973
|
+
const conv = daemon.conversations.get(req.params.id);
|
|
974
|
+
if (!conv) return res.status(404).json({ error: 'Conversation not found' });
|
|
975
|
+
daemon.conversations.stopStreaming(req.params.id);
|
|
976
|
+
res.json({ ok: true });
|
|
977
|
+
} catch (err) {
|
|
978
|
+
res.status(400).json({ error: err.message });
|
|
979
|
+
}
|
|
980
|
+
});
|
|
981
|
+
|
|
954
982
|
// --- Approvals ---
|
|
955
983
|
|
|
956
984
|
app.get('/api/approvals', (req, res) => {
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
5
5
|
import { resolve } from 'path';
|
|
6
6
|
import { randomUUID } from 'crypto';
|
|
7
|
+
import { spawn as cpSpawn } from 'child_process';
|
|
8
|
+
import { getProvider, getInstalledProviders } from './providers/index.js';
|
|
7
9
|
|
|
8
10
|
export class ConversationManager {
|
|
9
11
|
constructor(daemon) {
|
|
@@ -53,28 +55,34 @@ export class ConversationManager {
|
|
|
53
55
|
return null;
|
|
54
56
|
}
|
|
55
57
|
|
|
56
|
-
async create(provider, model, title) {
|
|
58
|
+
async create(provider, model, title, mode = 'api') {
|
|
57
59
|
const id = randomUUID().slice(0, 12);
|
|
58
60
|
const now = new Date().toISOString();
|
|
59
61
|
|
|
60
|
-
|
|
61
|
-
const workingDir = defaultTeam?.workingDir || this.daemon.projectDir;
|
|
62
|
+
let agentId = null;
|
|
62
63
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
64
|
+
if (mode === 'agent') {
|
|
65
|
+
const defaultTeam = this.daemon.teams.getDefault();
|
|
66
|
+
const workingDir = defaultTeam?.workingDir || this.daemon.projectDir;
|
|
67
|
+
|
|
68
|
+
const agent = await this.daemon.processes.spawn({
|
|
69
|
+
role: 'chat',
|
|
70
|
+
provider,
|
|
71
|
+
model: model || null,
|
|
72
|
+
workingDir,
|
|
73
|
+
teamId: defaultTeam?.id || null,
|
|
74
|
+
permission: 'full',
|
|
75
|
+
});
|
|
76
|
+
agentId = agent.id;
|
|
77
|
+
}
|
|
71
78
|
|
|
72
79
|
const conversation = {
|
|
73
80
|
id,
|
|
74
81
|
title: title || 'New Chat',
|
|
75
|
-
agentId
|
|
76
|
-
provider
|
|
77
|
-
model:
|
|
82
|
+
agentId,
|
|
83
|
+
provider,
|
|
84
|
+
model: model || null,
|
|
85
|
+
mode: mode === 'agent' ? 'agent' : 'api',
|
|
78
86
|
createdAt: now,
|
|
79
87
|
updatedAt: now,
|
|
80
88
|
pinned: false,
|
|
@@ -90,6 +98,9 @@ export class ConversationManager {
|
|
|
90
98
|
get(id) {
|
|
91
99
|
const conv = this.conversations.get(id);
|
|
92
100
|
if (!conv) return null;
|
|
101
|
+
if (conv.mode === 'api' || !conv.agentId) {
|
|
102
|
+
return { ...conv, agentStatus: conv.agentStatus || null };
|
|
103
|
+
}
|
|
93
104
|
const agent = this.daemon.registry.get(conv.agentId);
|
|
94
105
|
return {
|
|
95
106
|
...conv,
|
|
@@ -99,6 +110,9 @@ export class ConversationManager {
|
|
|
99
110
|
|
|
100
111
|
list() {
|
|
101
112
|
const all = [...this.conversations.values()].map((conv) => {
|
|
113
|
+
if (conv.mode === 'api' || !conv.agentId) {
|
|
114
|
+
return { ...conv, agentStatus: conv.agentStatus || null };
|
|
115
|
+
}
|
|
102
116
|
const agent = this.daemon.registry.get(conv.agentId);
|
|
103
117
|
return {
|
|
104
118
|
...conv,
|
|
@@ -149,14 +163,19 @@ export class ConversationManager {
|
|
|
149
163
|
const conv = this.conversations.get(id);
|
|
150
164
|
if (!conv) throw new Error('Conversation not found');
|
|
151
165
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
166
|
+
if (conv.agentId) {
|
|
167
|
+
const agent = this.daemon.registry.get(conv.agentId);
|
|
168
|
+
if (agent && (agent.status === 'running' || agent.status === 'starting')) {
|
|
169
|
+
try { await this.daemon.processes.kill(conv.agentId); } catch { /* ignore */ }
|
|
170
|
+
}
|
|
171
|
+
if (agent) {
|
|
172
|
+
this.daemon.registry.remove(conv.agentId);
|
|
173
|
+
}
|
|
158
174
|
}
|
|
159
175
|
|
|
176
|
+
// Kill any active API mode streaming process
|
|
177
|
+
this._killStreamingProcess(id);
|
|
178
|
+
|
|
160
179
|
this.conversations.delete(id);
|
|
161
180
|
this._save();
|
|
162
181
|
this.daemon.broadcast({ type: 'conversation:deleted', data: { id } });
|
|
@@ -180,4 +199,222 @@ export class ConversationManager {
|
|
|
180
199
|
this._save();
|
|
181
200
|
this.daemon.broadcast({ type: 'conversation:updated', data: conv });
|
|
182
201
|
}
|
|
202
|
+
|
|
203
|
+
async setMode(id, mode) {
|
|
204
|
+
const conv = this.conversations.get(id);
|
|
205
|
+
if (!conv) throw new Error('Conversation not found');
|
|
206
|
+
if (mode !== 'api' && mode !== 'agent') throw new Error('Mode must be "api" or "agent"');
|
|
207
|
+
if (conv.mode === mode) return conv;
|
|
208
|
+
|
|
209
|
+
if (mode === 'agent') {
|
|
210
|
+
const defaultTeam = this.daemon.teams.getDefault();
|
|
211
|
+
const workingDir = defaultTeam?.workingDir || this.daemon.projectDir;
|
|
212
|
+
const agent = await this.daemon.processes.spawn({
|
|
213
|
+
role: 'chat',
|
|
214
|
+
provider: conv.provider,
|
|
215
|
+
model: conv.model || null,
|
|
216
|
+
workingDir,
|
|
217
|
+
teamId: defaultTeam?.id || null,
|
|
218
|
+
permission: 'full',
|
|
219
|
+
});
|
|
220
|
+
conv.agentId = agent.id;
|
|
221
|
+
} else {
|
|
222
|
+
// Switching to API mode — kill the agent if running
|
|
223
|
+
this._killStreamingProcess(id);
|
|
224
|
+
if (conv.agentId) {
|
|
225
|
+
const agent = this.daemon.registry.get(conv.agentId);
|
|
226
|
+
if (agent && (agent.status === 'running' || agent.status === 'starting')) {
|
|
227
|
+
try { await this.daemon.processes.kill(conv.agentId); } catch { /* ignore */ }
|
|
228
|
+
}
|
|
229
|
+
if (agent) this.daemon.registry.remove(conv.agentId);
|
|
230
|
+
conv.agentId = null;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
conv.mode = mode;
|
|
235
|
+
conv.updatedAt = new Date().toISOString();
|
|
236
|
+
this._save();
|
|
237
|
+
this.daemon.broadcast({ type: 'conversation:updated', data: conv });
|
|
238
|
+
return conv;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
_buildHistoryPrompt(history, newMessage) {
|
|
242
|
+
const parts = [];
|
|
243
|
+
if (history && history.length > 0) {
|
|
244
|
+
parts.push('Previous conversation:');
|
|
245
|
+
for (const msg of history) {
|
|
246
|
+
const role = msg.from === 'user' ? 'User' : 'Assistant';
|
|
247
|
+
parts.push(`${role}: ${msg.text}`);
|
|
248
|
+
}
|
|
249
|
+
parts.push('');
|
|
250
|
+
}
|
|
251
|
+
parts.push(`User: ${newMessage}`);
|
|
252
|
+
return parts.join('\n');
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
_getStreamingProcesses() {
|
|
256
|
+
if (!this._streamingProcesses) this._streamingProcesses = new Map();
|
|
257
|
+
return this._streamingProcesses;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
_killStreamingProcess(conversationId) {
|
|
261
|
+
const procs = this._getStreamingProcesses();
|
|
262
|
+
const proc = procs.get(conversationId);
|
|
263
|
+
if (proc && !proc.killed) {
|
|
264
|
+
proc.kill();
|
|
265
|
+
}
|
|
266
|
+
procs.delete(conversationId);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async sendMessage(id, message, history) {
|
|
270
|
+
const conv = this.conversations.get(id);
|
|
271
|
+
if (!conv) throw new Error('Conversation not found');
|
|
272
|
+
if (conv.mode !== 'api') throw new Error('sendMessage only works in API mode');
|
|
273
|
+
|
|
274
|
+
// Kill any previous streaming process for this conversation
|
|
275
|
+
this._killStreamingProcess(id);
|
|
276
|
+
|
|
277
|
+
const prompt = this._buildHistoryPrompt(history, message);
|
|
278
|
+
|
|
279
|
+
// Resolve the provider for this conversation
|
|
280
|
+
let provider = getProvider(conv.provider);
|
|
281
|
+
let modelId = conv.model;
|
|
282
|
+
|
|
283
|
+
if (!provider || !provider.constructor.isInstalled()) {
|
|
284
|
+
const priority = ['claude-code', 'gemini', 'codex', 'ollama'];
|
|
285
|
+
const installed = getInstalledProviders();
|
|
286
|
+
const fallbackId = priority.find((p) => installed.some((i) => i.id === p));
|
|
287
|
+
if (!fallbackId) throw new Error('No provider available for chat');
|
|
288
|
+
provider = getProvider(fallbackId);
|
|
289
|
+
modelId = null;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const headlessCmd = provider.buildHeadlessCommand(prompt, modelId);
|
|
293
|
+
const { command, args, env, stdin: stdinData, cwd } = headlessCmd;
|
|
294
|
+
|
|
295
|
+
const spawnOpts = {
|
|
296
|
+
env: { ...process.env, ...env },
|
|
297
|
+
cwd: cwd || this.daemon.projectDir,
|
|
298
|
+
stdio: stdinData ? ['pipe', 'pipe', 'pipe'] : ['ignore', 'pipe', 'pipe'],
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
const proc = cpSpawn(command, args, spawnOpts);
|
|
302
|
+
this._getStreamingProcesses().set(id, proc);
|
|
303
|
+
|
|
304
|
+
if (stdinData) {
|
|
305
|
+
proc.stdin.write(stdinData);
|
|
306
|
+
proc.stdin.end();
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
let fullOutput = '';
|
|
310
|
+
|
|
311
|
+
proc.stdout.on('data', (data) => {
|
|
312
|
+
const text = data.toString();
|
|
313
|
+
fullOutput += text;
|
|
314
|
+
|
|
315
|
+
// Parse provider output for streaming chunks
|
|
316
|
+
const lines = text.split('\n');
|
|
317
|
+
for (const line of lines) {
|
|
318
|
+
const trimmed = line.trim();
|
|
319
|
+
if (!trimmed) continue;
|
|
320
|
+
|
|
321
|
+
// Try to parse as JSON (stream-json format)
|
|
322
|
+
try {
|
|
323
|
+
const json = JSON.parse(trimmed);
|
|
324
|
+
|
|
325
|
+
// Claude Code stream-json: assistant message content
|
|
326
|
+
if (json.type === 'assistant' && json.message?.content) {
|
|
327
|
+
for (const block of json.message.content) {
|
|
328
|
+
if (block.type === 'text' && block.text) {
|
|
329
|
+
this.daemon.broadcast({
|
|
330
|
+
type: 'conversation:chunk',
|
|
331
|
+
data: { conversationId: id, text: block.text },
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Claude Code stream-json: content_block_delta
|
|
339
|
+
if (json.type === 'content_block_delta' && json.delta?.text) {
|
|
340
|
+
this.daemon.broadcast({
|
|
341
|
+
type: 'conversation:chunk',
|
|
342
|
+
data: { conversationId: id, text: json.delta.text },
|
|
343
|
+
});
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Claude Code stream-json: result block — skip broadcasting since
|
|
348
|
+
// the content was already streamed via assistant/content_block_delta
|
|
349
|
+
if (json.type === 'result' && json.result) {
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Groove Network: token events
|
|
354
|
+
if (json.type === 'token' && json.text != null) {
|
|
355
|
+
this.daemon.broadcast({
|
|
356
|
+
type: 'conversation:chunk',
|
|
357
|
+
data: { conversationId: id, text: json.text },
|
|
358
|
+
});
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Groove Network: done/complete/result
|
|
363
|
+
if ((json.type === 'done' || json.type === 'complete' || json.type === 'result') && json.text) {
|
|
364
|
+
this.daemon.broadcast({
|
|
365
|
+
type: 'conversation:chunk',
|
|
366
|
+
data: { conversationId: id, text: json.text },
|
|
367
|
+
});
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Gemini / Codex: content text
|
|
372
|
+
if (json.content?.[0]?.text) {
|
|
373
|
+
this.daemon.broadcast({
|
|
374
|
+
type: 'conversation:chunk',
|
|
375
|
+
data: { conversationId: id, text: json.content[0].text },
|
|
376
|
+
});
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
} catch { /* not JSON — treat as raw text */ }
|
|
380
|
+
|
|
381
|
+
// Non-JSON output: broadcast raw text (some providers output plain text)
|
|
382
|
+
if (!trimmed.startsWith('{')) {
|
|
383
|
+
this.daemon.broadcast({
|
|
384
|
+
type: 'conversation:chunk',
|
|
385
|
+
data: { conversationId: id, text: trimmed },
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
proc.on('error', (err) => {
|
|
392
|
+
this._getStreamingProcesses().delete(id);
|
|
393
|
+
this.daemon.broadcast({
|
|
394
|
+
type: 'conversation:error',
|
|
395
|
+
data: { conversationId: id, error: err.message },
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
proc.on('exit', (code) => {
|
|
400
|
+
this._getStreamingProcesses().delete(id);
|
|
401
|
+
this.daemon.broadcast({
|
|
402
|
+
type: 'conversation:complete',
|
|
403
|
+
data: { conversationId: id, exitCode: code },
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
const timeout = setTimeout(() => {
|
|
408
|
+
if (!proc.killed) proc.kill();
|
|
409
|
+
}, 120_000);
|
|
410
|
+
proc.on('exit', () => clearTimeout(timeout));
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
stopStreaming(id) {
|
|
414
|
+
this._killStreamingProcess(id);
|
|
415
|
+
this.daemon.broadcast({
|
|
416
|
+
type: 'conversation:complete',
|
|
417
|
+
data: { conversationId: id, stopped: true },
|
|
418
|
+
});
|
|
419
|
+
}
|
|
183
420
|
}
|