groove-dev 0.27.196 → 0.27.197
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 +3 -5
- package/node_modules/@groove-dev/daemon/src/chatstore.js +104 -0
- package/node_modules/@groove-dev/daemon/src/index.js +3 -2
- package/node_modules/@groove-dev/daemon/src/process.js +1 -1
- package/node_modules/@groove-dev/daemon/src/routes/agents.js +1 -175
- package/node_modules/@groove-dev/daemon/src/routes/chat-history.js +34 -0
- package/node_modules/@groove-dev/daemon/test/chatstore.test.js +86 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-Da2nbd6M.js +1043 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-zmrIbwNm.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.jsx +0 -2
- package/node_modules/@groove-dev/gui/src/components/agents/agent-config.jsx +2 -2
- package/node_modules/@groove-dev/gui/src/components/agents/spawn-wizard.jsx +3 -3
- package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +2 -3
- package/node_modules/@groove-dev/gui/src/components/layout/breadcrumb-bar.jsx +1 -1
- package/node_modules/@groove-dev/gui/src/components/layout/command-palette.jsx +1 -1
- package/node_modules/@groove-dev/gui/src/stores/groove.js +5 -163
- package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +52 -2
- package/node_modules/@groove-dev/gui/src/views/marketplace.jsx +1 -1
- package/node_modules/@groove-dev/gui/src/views/settings.jsx +0 -61
- 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 +3 -5
- package/packages/daemon/src/chatstore.js +104 -0
- package/packages/daemon/src/index.js +3 -2
- package/packages/daemon/src/process.js +1 -1
- package/packages/daemon/src/routes/agents.js +1 -175
- package/packages/daemon/src/routes/chat-history.js +34 -0
- package/packages/gui/dist/assets/index-Da2nbd6M.js +1043 -0
- package/packages/gui/dist/assets/index-zmrIbwNm.css +1 -0
- package/packages/gui/dist/index.html +2 -2
- package/packages/gui/package.json +1 -1
- package/packages/gui/src/App.jsx +0 -2
- package/packages/gui/src/components/agents/agent-config.jsx +2 -2
- package/packages/gui/src/components/agents/spawn-wizard.jsx +3 -3
- package/packages/gui/src/components/layout/activity-bar.jsx +2 -3
- package/packages/gui/src/components/layout/breadcrumb-bar.jsx +1 -1
- package/packages/gui/src/components/layout/command-palette.jsx +1 -1
- package/packages/gui/src/stores/groove.js +5 -163
- package/packages/gui/src/stores/slices/agents-slice.js +52 -2
- package/packages/gui/src/views/marketplace.jsx +1 -1
- package/packages/gui/src/views/settings.jsx +0 -61
- package/node_modules/@groove-dev/daemon/src/conversations.js +0 -576
- package/node_modules/@groove-dev/gui/dist/assets/index-Cat5pJUx.css +0 -1
- package/node_modules/@groove-dev/gui/dist/assets/index-L8xSnvHj.js +0 -1070
- package/node_modules/@groove-dev/gui/src/components/chat/chat-header.jsx +0 -198
- package/node_modules/@groove-dev/gui/src/components/chat/chat-input.jsx +0 -367
- package/node_modules/@groove-dev/gui/src/components/chat/chat-messages.jsx +0 -527
- package/node_modules/@groove-dev/gui/src/components/chat/chat-view.jsx +0 -212
- package/node_modules/@groove-dev/gui/src/components/chat/conversation-list.jsx +0 -163
- package/node_modules/@groove-dev/gui/src/components/chat/model-picker.jsx +0 -201
- package/node_modules/@groove-dev/gui/src/stores/slices/chat-slice.js +0 -227
- package/node_modules/@groove-dev/gui/src/views/chat.jsx +0 -6
- package/packages/daemon/src/conversations.js +0 -576
- package/packages/gui/dist/assets/index-Cat5pJUx.css +0 -1
- package/packages/gui/dist/assets/index-L8xSnvHj.js +0 -1070
- package/packages/gui/src/components/chat/chat-header.jsx +0 -198
- package/packages/gui/src/components/chat/chat-input.jsx +0 -367
- package/packages/gui/src/components/chat/chat-messages.jsx +0 -527
- package/packages/gui/src/components/chat/chat-view.jsx +0 -212
- package/packages/gui/src/components/chat/conversation-list.jsx +0 -163
- package/packages/gui/src/components/chat/model-picker.jsx +0 -201
- package/packages/gui/src/stores/slices/chat-slice.js +0 -227
- package/packages/gui/src/views/chat.jsx +0 -6
|
@@ -28,6 +28,7 @@ import { registerFileRoutes, resetEditorRoot } from './routes/files.js';
|
|
|
28
28
|
import { registerNetworkRoutes } from './routes/network.js';
|
|
29
29
|
import { registerScheduleRoutes } from './routes/schedules.js';
|
|
30
30
|
import { registerInnerChatRoutes } from './routes/innerchat.js';
|
|
31
|
+
import { registerChatHistoryRoutes } from './routes/chat-history.js';
|
|
31
32
|
import { registerWatchRoutes } from './routes/watch.js';
|
|
32
33
|
import { registerAutoAgentRoutes } from './routes/auto-agents.js';
|
|
33
34
|
|
|
@@ -179,6 +180,7 @@ export function createApi(app, daemon) {
|
|
|
179
180
|
registerNetworkRoutes(app, daemon);
|
|
180
181
|
registerScheduleRoutes(app, daemon);
|
|
181
182
|
registerInnerChatRoutes(app, daemon);
|
|
183
|
+
registerChatHistoryRoutes(app, daemon);
|
|
182
184
|
registerWatchRoutes(app, daemon);
|
|
183
185
|
registerAutoAgentRoutes(app, daemon);
|
|
184
186
|
|
|
@@ -250,8 +252,6 @@ export function createApi(app, daemon) {
|
|
|
250
252
|
}
|
|
251
253
|
});
|
|
252
254
|
|
|
253
|
-
// --- Conversations ---
|
|
254
|
-
|
|
255
255
|
// --- Approvals ---
|
|
256
256
|
|
|
257
257
|
app.get('/api/approvals', (req, res) => {
|
|
@@ -1348,10 +1348,8 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
1348
1348
|
}
|
|
1349
1349
|
|
|
1350
1350
|
daemon.config.defaultProvider = provider;
|
|
1351
|
-
daemon.config.defaultChatProvider = provider;
|
|
1352
1351
|
if (model && typeof model === 'string' && model.length <= 100) {
|
|
1353
1352
|
daemon.config.defaultModel = model.trim();
|
|
1354
|
-
daemon.config.defaultChatModel = model.trim();
|
|
1355
1353
|
}
|
|
1356
1354
|
const { saveConfig } = await import('./firstrun.js');
|
|
1357
1355
|
saveConfig(daemon.grooveDir, daemon.config);
|
|
@@ -1439,7 +1437,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
1439
1437
|
const ALLOWED_KEYS = [
|
|
1440
1438
|
'port', 'journalistInterval', 'rotationThreshold', 'autoRotation',
|
|
1441
1439
|
'qcThreshold', 'maxAgents', 'defaultProvider', 'defaultWorkingDir',
|
|
1442
|
-
'onboardingDismissed', 'defaultModel',
|
|
1440
|
+
'onboardingDismissed', 'defaultModel',
|
|
1443
1441
|
'dataSharingDismissed', 'replayCeiling', 'velocityCeiling', 'journalistModelTier',
|
|
1444
1442
|
'resumeBudgetChars',
|
|
1445
1443
|
];
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
2
|
+
|
|
3
|
+
import { resolve } from 'path';
|
|
4
|
+
import { existsSync, readFileSync, writeFileSync, renameSync } from 'fs';
|
|
5
|
+
|
|
6
|
+
// Per-agent history cap and attachment handling mirror the GUI's own limits so
|
|
7
|
+
// the two stay in step.
|
|
8
|
+
const MAX_PER_AGENT = 200;
|
|
9
|
+
const SAVE_DEBOUNCE_MS = 1500;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Server-side chat history.
|
|
13
|
+
*
|
|
14
|
+
* The GUI used to keep chat history only in the browser's localStorage, which
|
|
15
|
+
* is scoped per origin (scheme://host:PORT). For a remote GUI reached over an
|
|
16
|
+
* SSH tunnel the local port changes across reconnects, so the origin — and thus
|
|
17
|
+
* the entire chat store — changed out from under the user, stranding history on
|
|
18
|
+
* old ports. Keeping it on the daemon makes it independent of port, origin, and
|
|
19
|
+
* even which machine connects: reconnect from anywhere and the chats are there.
|
|
20
|
+
*/
|
|
21
|
+
export class ChatStore {
|
|
22
|
+
constructor(daemon) {
|
|
23
|
+
this.daemon = daemon;
|
|
24
|
+
this.path = resolve(daemon.grooveDir, 'chat-history.json');
|
|
25
|
+
this.history = this._load();
|
|
26
|
+
this._saveTimer = null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
_load() {
|
|
30
|
+
try {
|
|
31
|
+
if (existsSync(this.path)) {
|
|
32
|
+
const data = JSON.parse(readFileSync(this.path, 'utf8'));
|
|
33
|
+
if (data && typeof data === 'object') return data;
|
|
34
|
+
}
|
|
35
|
+
} catch { /* corrupt or missing — start empty rather than throw */ }
|
|
36
|
+
return {};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
_scheduleSave() {
|
|
40
|
+
if (this._saveTimer) return;
|
|
41
|
+
this._saveTimer = setTimeout(() => {
|
|
42
|
+
this._saveTimer = null;
|
|
43
|
+
this._saveNow();
|
|
44
|
+
}, SAVE_DEBOUNCE_MS);
|
|
45
|
+
if (this._saveTimer.unref) this._saveTimer.unref();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
_saveNow() {
|
|
49
|
+
try {
|
|
50
|
+
const tmp = `${this.path}.tmp`;
|
|
51
|
+
writeFileSync(tmp, JSON.stringify(this.history), { mode: 0o600 });
|
|
52
|
+
renameSync(tmp, this.path); // atomic — a reader never sees a half-written file
|
|
53
|
+
} catch { /* best effort */ }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Strip base64 attachment payloads — metadata only. Keeping data URLs would
|
|
57
|
+
// bloat the file the same way it bloated localStorage.
|
|
58
|
+
_clean(message) {
|
|
59
|
+
if (!message || typeof message !== 'object') return null;
|
|
60
|
+
const out = { ...message };
|
|
61
|
+
if (Array.isArray(out.attachments) && out.attachments.length) {
|
|
62
|
+
out.attachments = out.attachments.map(({ dataUrl, ...rest }) => rest);
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
append(agentId, message) {
|
|
68
|
+
if (!agentId) return;
|
|
69
|
+
const clean = this._clean(message);
|
|
70
|
+
if (!clean) return;
|
|
71
|
+
const arr = this.history[agentId] || [];
|
|
72
|
+
arr.push(clean);
|
|
73
|
+
this.history[agentId] = arr.slice(-MAX_PER_AGENT);
|
|
74
|
+
this._scheduleSave();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Replace an agent's whole history — used when the GUI syncs a batch (e.g.
|
|
78
|
+
// messages it recorded while briefly disconnected).
|
|
79
|
+
replace(agentId, messages) {
|
|
80
|
+
if (!agentId || !Array.isArray(messages)) return;
|
|
81
|
+
this.history[agentId] = messages.map((m) => this._clean(m)).filter(Boolean).slice(-MAX_PER_AGENT);
|
|
82
|
+
this._scheduleSave();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
getAll() {
|
|
86
|
+
return this.history;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
get(agentId) {
|
|
90
|
+
return this.history[agentId] || [];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
remove(agentId) {
|
|
94
|
+
if (this.history[agentId]) {
|
|
95
|
+
delete this.history[agentId];
|
|
96
|
+
this._scheduleSave();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
stop() {
|
|
101
|
+
if (this._saveTimer) { clearTimeout(this._saveTimer); this._saveTimer = null; }
|
|
102
|
+
this._saveNow();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -44,9 +44,9 @@ import { ModelLab } from './model-lab.js';
|
|
|
44
44
|
import { LlamaServerManager } from './llama-server.js';
|
|
45
45
|
import { MLXServerManager } from './mlx-server.js';
|
|
46
46
|
import { RepoImporter } from './repo-import.js';
|
|
47
|
-
import { ConversationManager } from './conversations.js';
|
|
48
47
|
import { Toys } from './toys.js';
|
|
49
48
|
import { InnerChat } from './innerchat.js';
|
|
49
|
+
import { ChatStore } from './chatstore.js';
|
|
50
50
|
import { Watcher } from './watcher.js';
|
|
51
51
|
import { AutoState } from './autostate.js';
|
|
52
52
|
import { Orchestrator } from './orchestrator.js';
|
|
@@ -139,7 +139,6 @@ export class Daemon {
|
|
|
139
139
|
this.rotator = new Rotator(this);
|
|
140
140
|
this.adaptive = new AdaptiveThresholds(this.grooveDir);
|
|
141
141
|
this.teams = new Teams(this);
|
|
142
|
-
this.conversations = new ConversationManager(this);
|
|
143
142
|
this.credentials = new CredentialStore(this.grooveDir);
|
|
144
143
|
this.classifier = new TaskClassifier();
|
|
145
144
|
this.router = new ModelRouter(this);
|
|
@@ -163,6 +162,7 @@ export class Daemon {
|
|
|
163
162
|
this.modelLab = new ModelLab(this);
|
|
164
163
|
this.toys = new Toys(this);
|
|
165
164
|
this.innerchat = new InnerChat(this);
|
|
165
|
+
this.chatStore = new ChatStore(this);
|
|
166
166
|
this.watcher = new Watcher(this);
|
|
167
167
|
this.autoState = new AutoState(this.grooveDir);
|
|
168
168
|
this.orchestrator = new Orchestrator(this);
|
|
@@ -874,6 +874,7 @@ export class Daemon {
|
|
|
874
874
|
this.scheduler.stop();
|
|
875
875
|
this.watcher.stop();
|
|
876
876
|
this.innerchat.stop();
|
|
877
|
+
this.chatStore.stop();
|
|
877
878
|
this.orchestrator.stop();
|
|
878
879
|
this.timeline.stop();
|
|
879
880
|
if (this._gcInterval) clearInterval(this._gcInterval);
|
|
@@ -71,7 +71,7 @@ Do NOT write code unless explicitly asked. Use your MCP tools (database queries,
|
|
|
71
71
|
- Adapting tone and style to the audience (formal, conversational, technical, marketing)
|
|
72
72
|
- Editing and polishing drafts for grammar, flow, and impact
|
|
73
73
|
- Researching topics to produce accurate, substantive writing
|
|
74
|
-
You CAN use code tools to create and edit text files, markdown documents, and structured content. For best results, apply a writing skill from the
|
|
74
|
+
You CAN use code tools to create and edit text files, markdown documents, and structured content. For best results, apply a writing skill from the Integrations tab that matches your task.
|
|
75
75
|
|
|
76
76
|
`,
|
|
77
77
|
chat: `You are a Chat agent — a conversational companion, friend, and assistant. You are warm, curious, and genuinely engaged. You can discuss anything: ideas, philosophy, science, culture, coding, life.
|
|
@@ -2,9 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import { resolve } from 'path';
|
|
4
4
|
import { existsSync, readFileSync, readdirSync, statSync, writeFileSync, mkdirSync } from 'fs';
|
|
5
|
-
import { validateAgentConfig
|
|
5
|
+
import { validateAgentConfig } from '../validate.js';
|
|
6
6
|
import { ROLE_INTEGRATIONS, wrapWithRoleReminder } from '../process.js';
|
|
7
|
-
import { getProvider } from '../providers/index.js';
|
|
8
7
|
import { deliverInstruction } from '../deliver.js';
|
|
9
8
|
import { renameAgent } from '../rename.js';
|
|
10
9
|
|
|
@@ -218,179 +217,6 @@ export function registerAgentRoutes(app, daemon) {
|
|
|
218
217
|
res.json(suggestion);
|
|
219
218
|
});
|
|
220
219
|
|
|
221
|
-
// --- Conversations ---
|
|
222
|
-
|
|
223
|
-
app.get('/api/conversations', (req, res) => {
|
|
224
|
-
res.json({ conversations: daemon.conversations.list() });
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
app.post('/api/conversations', async (req, res) => {
|
|
228
|
-
try {
|
|
229
|
-
const { provider, model, title, mode, reasoning_effort, verbosity } = req.body;
|
|
230
|
-
if (provider && typeof provider !== 'string') {
|
|
231
|
-
return res.status(400).json({ error: 'provider must be a string' });
|
|
232
|
-
}
|
|
233
|
-
if (mode && mode !== 'api' && mode !== 'agent') {
|
|
234
|
-
return res.status(400).json({ error: 'mode must be "api" or "agent"' });
|
|
235
|
-
}
|
|
236
|
-
const validatedEffort = validateReasoningEffort(reasoning_effort);
|
|
237
|
-
const validatedVerbosity = validateVerbosity(verbosity);
|
|
238
|
-
const conversation = await daemon.conversations.create(provider, model, title, mode || 'api', {
|
|
239
|
-
reasoningEffort: validatedEffort,
|
|
240
|
-
verbosity: validatedVerbosity,
|
|
241
|
-
});
|
|
242
|
-
daemon.audit.log('conversation.create', { id: conversation.id, provider, model, mode: conversation.mode });
|
|
243
|
-
res.status(201).json(conversation);
|
|
244
|
-
} catch (err) {
|
|
245
|
-
res.status(400).json({ error: err.message });
|
|
246
|
-
}
|
|
247
|
-
});
|
|
248
|
-
|
|
249
|
-
app.get('/api/conversations/:id', (req, res) => {
|
|
250
|
-
const conversation = daemon.conversations.get(req.params.id);
|
|
251
|
-
if (!conversation) return res.status(404).json({ error: 'Conversation not found' });
|
|
252
|
-
res.json(conversation);
|
|
253
|
-
});
|
|
254
|
-
|
|
255
|
-
app.patch('/api/conversations/:id', async (req, res) => {
|
|
256
|
-
try {
|
|
257
|
-
const conv = daemon.conversations.get(req.params.id);
|
|
258
|
-
if (!conv) return res.status(404).json({ error: 'Conversation not found' });
|
|
259
|
-
if (req.body.title !== undefined) daemon.conversations.rename(req.params.id, req.body.title);
|
|
260
|
-
if (req.body.pinned !== undefined) daemon.conversations.pin(req.params.id, req.body.pinned);
|
|
261
|
-
if (req.body.archived !== undefined) daemon.conversations.archive(req.params.id, req.body.archived);
|
|
262
|
-
if (req.body.model !== undefined || req.body.provider !== undefined) {
|
|
263
|
-
const newProvider = req.body.provider || conv.provider;
|
|
264
|
-
const newModel = req.body.model || conv.model;
|
|
265
|
-
daemon.conversations.updateModel(req.params.id, newProvider, newModel);
|
|
266
|
-
}
|
|
267
|
-
if (req.body.mode !== undefined) {
|
|
268
|
-
if (req.body.mode !== 'api' && req.body.mode !== 'agent') {
|
|
269
|
-
return res.status(400).json({ error: 'mode must be "api" or "agent"' });
|
|
270
|
-
}
|
|
271
|
-
await daemon.conversations.setMode(req.params.id, req.body.mode);
|
|
272
|
-
}
|
|
273
|
-
if (req.body.reasoning_effort !== undefined || req.body.verbosity !== undefined) {
|
|
274
|
-
const validatedEffort = req.body.reasoning_effort !== undefined ? validateReasoningEffort(req.body.reasoning_effort) : undefined;
|
|
275
|
-
const validatedVerbosity = req.body.verbosity !== undefined ? validateVerbosity(req.body.verbosity) : undefined;
|
|
276
|
-
daemon.conversations.updateReasoningSettings(req.params.id, validatedEffort, validatedVerbosity);
|
|
277
|
-
}
|
|
278
|
-
daemon.audit.log('conversation.update', { id: req.params.id, provider: req.body.provider, model: req.body.model, mode: req.body.mode });
|
|
279
|
-
res.json(daemon.conversations.get(req.params.id));
|
|
280
|
-
} catch (err) {
|
|
281
|
-
res.status(400).json({ error: err.message });
|
|
282
|
-
}
|
|
283
|
-
});
|
|
284
|
-
|
|
285
|
-
app.delete('/api/conversations/:id', async (req, res) => {
|
|
286
|
-
try {
|
|
287
|
-
const conv = daemon.conversations.get(req.params.id);
|
|
288
|
-
if (!conv) return res.status(404).json({ error: 'Conversation not found' });
|
|
289
|
-
await daemon.conversations.delete(req.params.id);
|
|
290
|
-
daemon.audit.log('conversation.delete', { id: req.params.id });
|
|
291
|
-
res.json({ ok: true });
|
|
292
|
-
} catch (err) {
|
|
293
|
-
res.status(400).json({ error: err.message });
|
|
294
|
-
}
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
app.post('/api/conversations/:id/message', async (req, res) => {
|
|
298
|
-
try {
|
|
299
|
-
const { message, history, reasoning_effort, verbosity } = req.body;
|
|
300
|
-
if (!message || typeof message !== 'string' || !message.trim()) {
|
|
301
|
-
return res.status(400).json({ error: 'message is required' });
|
|
302
|
-
}
|
|
303
|
-
const validatedEffort = validateReasoningEffort(reasoning_effort);
|
|
304
|
-
const validatedVerbosity = validateVerbosity(verbosity);
|
|
305
|
-
|
|
306
|
-
const conv = daemon.conversations.get(req.params.id);
|
|
307
|
-
if (!conv) return res.status(404).json({ error: 'Conversation not found' });
|
|
308
|
-
|
|
309
|
-
daemon.conversations.autoTitle(req.params.id, message.trim());
|
|
310
|
-
daemon.conversations.touchUpdatedAt(req.params.id);
|
|
311
|
-
|
|
312
|
-
await daemon.conversations.sendMessage(req.params.id, message.trim(), history || [], {
|
|
313
|
-
reasoningEffort: validatedEffort,
|
|
314
|
-
verbosity: validatedVerbosity,
|
|
315
|
-
});
|
|
316
|
-
daemon.audit.log('conversation.message', { id: req.params.id, mode: conv.mode || 'api' });
|
|
317
|
-
res.json({ status: 'streaming', mode: conv.mode || 'api' });
|
|
318
|
-
} catch (err) {
|
|
319
|
-
res.status(400).json({ error: err.message });
|
|
320
|
-
}
|
|
321
|
-
});
|
|
322
|
-
|
|
323
|
-
app.post('/api/conversations/:id/stop', (req, res) => {
|
|
324
|
-
try {
|
|
325
|
-
const conv = daemon.conversations.get(req.params.id);
|
|
326
|
-
if (!conv) return res.status(404).json({ error: 'Conversation not found' });
|
|
327
|
-
daemon.conversations.stopStreaming(req.params.id);
|
|
328
|
-
res.json({ ok: true });
|
|
329
|
-
} catch (err) {
|
|
330
|
-
res.status(400).json({ error: err.message });
|
|
331
|
-
}
|
|
332
|
-
});
|
|
333
|
-
|
|
334
|
-
// --- Image Generation ---
|
|
335
|
-
|
|
336
|
-
app.post('/api/conversations/:id/generate-image', async (req, res) => {
|
|
337
|
-
try {
|
|
338
|
-
const { prompt, model, size, quality } = req.body;
|
|
339
|
-
if (!prompt || typeof prompt !== 'string' || !prompt.trim()) {
|
|
340
|
-
return res.status(400).json({ error: 'prompt is required' });
|
|
341
|
-
}
|
|
342
|
-
const conv = daemon.conversations.get(req.params.id);
|
|
343
|
-
if (!conv) return res.status(404).json({ error: 'Conversation not found' });
|
|
344
|
-
|
|
345
|
-
let providerName = conv.provider;
|
|
346
|
-
let provider = getProvider(providerName);
|
|
347
|
-
|
|
348
|
-
// If a specific image model was requested, find the right provider
|
|
349
|
-
if (model) {
|
|
350
|
-
const imageProviders = ['codex', 'grok', 'nano-banana'];
|
|
351
|
-
for (const pid of imageProviders) {
|
|
352
|
-
const p = getProvider(pid);
|
|
353
|
-
if (p?.constructor.models.some((m) => m.id === model)) {
|
|
354
|
-
provider = p;
|
|
355
|
-
providerName = pid;
|
|
356
|
-
break;
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
if (!provider?.generateImage) {
|
|
362
|
-
return res.status(400).json({ error: 'Provider does not support image generation' });
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
const apiKey = daemon.conversations._getApiKey(providerName);
|
|
366
|
-
if (!apiKey) {
|
|
367
|
-
return res.status(400).json({ error: `No API key configured for ${providerName}` });
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
daemon.broadcast({
|
|
371
|
-
type: 'conversation:image-progress',
|
|
372
|
-
data: { conversationId: req.params.id, status: 'generating', prompt: prompt.trim() },
|
|
373
|
-
});
|
|
374
|
-
|
|
375
|
-
const result = await provider.generateImage(prompt.trim(), { model, size, quality, apiKey });
|
|
376
|
-
|
|
377
|
-
daemon.broadcast({
|
|
378
|
-
type: 'conversation:image',
|
|
379
|
-
data: { conversationId: req.params.id, ...result, prompt: prompt.trim() },
|
|
380
|
-
});
|
|
381
|
-
|
|
382
|
-
daemon.conversations.touchUpdatedAt(req.params.id);
|
|
383
|
-
daemon.audit.log('conversation.image', { id: req.params.id, model: result.model, provider: result.provider });
|
|
384
|
-
res.json(result);
|
|
385
|
-
} catch (err) {
|
|
386
|
-
daemon.broadcast({
|
|
387
|
-
type: 'conversation:image-progress',
|
|
388
|
-
data: { conversationId: req.params.id, status: 'error', error: err.message },
|
|
389
|
-
});
|
|
390
|
-
res.status(500).json({ error: err.message });
|
|
391
|
-
}
|
|
392
|
-
});
|
|
393
|
-
|
|
394
220
|
// Stop an agent's current work without killing the agent
|
|
395
221
|
app.post('/api/agents/:id/stop', async (req, res) => {
|
|
396
222
|
try {
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
2
|
+
|
|
3
|
+
export function registerChatHistoryRoutes(app, daemon) {
|
|
4
|
+
// Full history for all agents — the GUI loads this on connect so chats are
|
|
5
|
+
// present regardless of which origin/port the tunnel came up on.
|
|
6
|
+
app.get('/api/chat-history', (req, res) => {
|
|
7
|
+
res.json({ history: daemon.chatStore.getAll() });
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
// Append a single message for an agent.
|
|
11
|
+
app.post('/api/chat-history/:agentId', (req, res) => {
|
|
12
|
+
const { message } = req.body || {};
|
|
13
|
+
if (!message || typeof message !== 'object') {
|
|
14
|
+
return res.status(400).json({ error: 'message object required' });
|
|
15
|
+
}
|
|
16
|
+
daemon.chatStore.append(req.params.agentId, message);
|
|
17
|
+
res.json({ ok: true });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// Replace an agent's whole history (batch sync).
|
|
21
|
+
app.put('/api/chat-history/:agentId', (req, res) => {
|
|
22
|
+
const { messages } = req.body || {};
|
|
23
|
+
if (!Array.isArray(messages)) {
|
|
24
|
+
return res.status(400).json({ error: 'messages array required' });
|
|
25
|
+
}
|
|
26
|
+
daemon.chatStore.replace(req.params.agentId, messages);
|
|
27
|
+
res.json({ ok: true });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
app.delete('/api/chat-history/:agentId', (req, res) => {
|
|
31
|
+
daemon.chatStore.remove(req.params.agentId);
|
|
32
|
+
res.json({ ok: true });
|
|
33
|
+
});
|
|
34
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// GROOVE — ChatStore tests
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
|
|
4
|
+
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { mkdtempSync, rmSync, existsSync, readFileSync } from 'fs';
|
|
7
|
+
import { tmpdir } from 'os';
|
|
8
|
+
import { resolve } from 'path';
|
|
9
|
+
import { ChatStore } from '../src/chatstore.js';
|
|
10
|
+
|
|
11
|
+
describe('ChatStore', () => {
|
|
12
|
+
let dir, store;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
dir = mkdtempSync(resolve(tmpdir(), 'groove-chat-'));
|
|
16
|
+
store = new ChatStore({ grooveDir: dir });
|
|
17
|
+
});
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
store.stop();
|
|
20
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('appends and returns per-agent history', () => {
|
|
24
|
+
store.append('a1', { from: 'user', text: 'hello', timestamp: 1 });
|
|
25
|
+
store.append('a1', { from: 'agent', text: 'hi', timestamp: 2 });
|
|
26
|
+
store.append('a2', { from: 'user', text: 'other', timestamp: 3 });
|
|
27
|
+
|
|
28
|
+
assert.equal(store.get('a1').length, 2);
|
|
29
|
+
assert.equal(store.get('a2').length, 1);
|
|
30
|
+
assert.deepEqual(store.getAll().a1.map((m) => m.text), ['hello', 'hi']);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('strips attachment data URLs but keeps metadata', () => {
|
|
34
|
+
store.append('a1', {
|
|
35
|
+
from: 'user', text: 'pic', timestamp: 1,
|
|
36
|
+
attachments: [{ name: 'x.png', size: 9, dataUrl: 'data:image/png;base64,AAAA' }],
|
|
37
|
+
});
|
|
38
|
+
const m = store.get('a1')[0];
|
|
39
|
+
assert.equal(m.attachments[0].dataUrl, undefined);
|
|
40
|
+
assert.equal(m.attachments[0].name, 'x.png');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('persists across instances (survives a daemon restart)', () => {
|
|
44
|
+
store.append('a1', { from: 'user', text: 'durable', timestamp: 1 });
|
|
45
|
+
store.stop(); // flushes synchronously
|
|
46
|
+
|
|
47
|
+
const reloaded = new ChatStore({ grooveDir: dir });
|
|
48
|
+
assert.equal(reloaded.get('a1')[0].text, 'durable');
|
|
49
|
+
reloaded.stop();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('writes the file atomically (no stray .tmp left)', () => {
|
|
53
|
+
store.append('a1', { from: 'user', text: 'x', timestamp: 1 });
|
|
54
|
+
store.stop();
|
|
55
|
+
assert.ok(existsSync(resolve(dir, 'chat-history.json')));
|
|
56
|
+
assert.ok(!existsSync(resolve(dir, 'chat-history.json.tmp')));
|
|
57
|
+
const onDisk = JSON.parse(readFileSync(resolve(dir, 'chat-history.json'), 'utf8'));
|
|
58
|
+
assert.equal(onDisk.a1[0].text, 'x');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('caps per-agent history', () => {
|
|
62
|
+
for (let i = 0; i < 250; i++) store.append('a1', { from: 'user', text: `m${i}`, timestamp: i });
|
|
63
|
+
const h = store.get('a1');
|
|
64
|
+
assert.equal(h.length, 200);
|
|
65
|
+
assert.equal(h[0].text, 'm50'); // oldest 50 dropped
|
|
66
|
+
assert.equal(h.at(-1).text, 'm249');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('replace() swaps an agent history wholesale', () => {
|
|
70
|
+
store.append('a1', { from: 'user', text: 'old' });
|
|
71
|
+
store.replace('a1', [{ from: 'user', text: 'new1' }, { from: 'agent', text: 'new2' }]);
|
|
72
|
+
assert.deepEqual(store.get('a1').map((m) => m.text), ['new1', 'new2']);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('remove() deletes an agent history', () => {
|
|
76
|
+
store.append('a1', { from: 'user', text: 'x' });
|
|
77
|
+
store.remove('a1');
|
|
78
|
+
assert.equal(store.get('a1').length, 0);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('starts empty when the file is missing or corrupt', () => {
|
|
82
|
+
const empty = new ChatStore({ grooveDir: mkdtempSync(resolve(tmpdir(), 'groove-chat-empty-')) });
|
|
83
|
+
assert.deepEqual(empty.getAll(), {});
|
|
84
|
+
empty.stop();
|
|
85
|
+
});
|
|
86
|
+
});
|