@kapeta/local-cluster-service 0.74.1 → 0.75.0
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/CHANGELOG.md +15 -0
- package/dist/cjs/src/middleware/stringBody.js +2 -1
- package/dist/cjs/src/storm/PageGenerator.js +6 -2
- package/dist/cjs/src/storm/events.d.ts +2 -0
- package/dist/cjs/src/storm/routes.js +27 -3
- package/dist/cjs/src/stormService.d.ts +7 -0
- package/dist/cjs/src/stormService.js +23 -2
- package/dist/esm/src/middleware/stringBody.js +2 -1
- package/dist/esm/src/storm/PageGenerator.js +6 -2
- package/dist/esm/src/storm/events.d.ts +2 -0
- package/dist/esm/src/storm/routes.js +27 -3
- package/dist/esm/src/stormService.d.ts +7 -0
- package/dist/esm/src/stormService.js +23 -2
- package/package.json +1 -1
- package/src/middleware/stringBody.ts +2 -1
- package/src/storm/PageGenerator.ts +11 -4
- package/src/storm/events.ts +2 -0
- package/src/storm/routes.ts +45 -14
- package/src/stormService.ts +36 -4
package/CHANGELOG.md
CHANGED
@@ -1,3 +1,18 @@
|
|
1
|
+
# [0.75.0](https://github.com/kapetacom/local-cluster-service/compare/v0.74.2...v0.75.0) (2024-09-30)
|
2
|
+
|
3
|
+
|
4
|
+
### Features
|
5
|
+
|
6
|
+
* add thumbnail support and timestamps to AI systems [CORE-3532] ([#262](https://github.com/kapetacom/local-cluster-service/issues/262)) ([31a3cad](https://github.com/kapetacom/local-cluster-service/commit/31a3cadba0d5034d3497b98616468e12ce3f586b))
|
7
|
+
|
8
|
+
## [0.74.2](https://github.com/kapetacom/local-cluster-service/compare/v0.74.1...v0.74.2) (2024-09-27)
|
9
|
+
|
10
|
+
|
11
|
+
### Bug Fixes
|
12
|
+
|
13
|
+
* add more bail checkpoints for aborted request ([1f4823c](https://github.com/kapetacom/local-cluster-service/commit/1f4823c648d21f3ea8c8f8f2ead156d1e43c7252))
|
14
|
+
* define conversationIds for pages as early as possible ([7b020a5](https://github.com/kapetacom/local-cluster-service/commit/7b020a52ddeba60329989dafda62abc8d3565039))
|
15
|
+
|
1
16
|
## [0.74.1](https://github.com/kapetacom/local-cluster-service/compare/v0.74.0...v0.74.1) (2024-09-27)
|
2
17
|
|
3
18
|
|
@@ -11,7 +11,8 @@ function stringBody(req, res, next) {
|
|
11
11
|
req.on('data', (chunk) => {
|
12
12
|
body.push(chunk);
|
13
13
|
}).on('end', () => {
|
14
|
-
req.
|
14
|
+
req.body = Buffer.concat(body);
|
15
|
+
req.stringBody = req.body.toString();
|
15
16
|
next();
|
16
17
|
});
|
17
18
|
}
|
@@ -37,6 +37,7 @@ const node_events_1 = require("node:events");
|
|
37
37
|
const p_queue_1 = __importDefault(require("p-queue"));
|
38
38
|
const page_utils_1 = require("./page-utils");
|
39
39
|
const mimetypes = __importStar(require("mime-types"));
|
40
|
+
const node_crypto_1 = require("node:crypto");
|
40
41
|
class PageQueue extends node_events_1.EventEmitter {
|
41
42
|
queue;
|
42
43
|
eventQueue;
|
@@ -166,6 +167,8 @@ class PageQueue extends node_events_1.EventEmitter {
|
|
166
167
|
}
|
167
168
|
this.pages.set(normalizedPath, reference.description);
|
168
169
|
initialPrompts.push({
|
170
|
+
conversationId: (0, node_crypto_1.randomUUID)(),
|
171
|
+
id: (0, node_crypto_1.randomUUID)(),
|
169
172
|
name: reference.name,
|
170
173
|
title: reference.title,
|
171
174
|
path: normalizedPath,
|
@@ -193,20 +196,21 @@ class PageQueue extends node_events_1.EventEmitter {
|
|
193
196
|
reason: 'reference',
|
194
197
|
created: Date.now(),
|
195
198
|
payload: {
|
199
|
+
id: prompt.id,
|
200
|
+
conversationId: prompt.conversationId,
|
196
201
|
name: prompt.name,
|
197
202
|
title: prompt.title,
|
198
203
|
filename: prompt.filename,
|
199
204
|
method: 'GET',
|
200
205
|
path: prompt.path,
|
201
206
|
prompt: prompt.description,
|
202
|
-
conversationId: '',
|
203
207
|
content: '',
|
204
208
|
description: prompt.description,
|
205
209
|
},
|
206
210
|
});
|
207
211
|
}
|
208
212
|
// Trigger but don't wait for the "bonus" pages
|
209
|
-
this.addPrompt(prompt).catch((err) => {
|
213
|
+
this.addPrompt(prompt, prompt.conversationId).catch((err) => {
|
210
214
|
console.error('Failed to generate page reference', prompt.name, err);
|
211
215
|
this.emit('error', err);
|
212
216
|
});
|
@@ -287,6 +287,7 @@ export interface StormEventPhases {
|
|
287
287
|
};
|
288
288
|
}
|
289
289
|
export interface Page {
|
290
|
+
id: string;
|
290
291
|
name: string;
|
291
292
|
filename: string;
|
292
293
|
title: string;
|
@@ -329,6 +330,7 @@ export interface UserJourneyScreen {
|
|
329
330
|
path: string;
|
330
331
|
method: string;
|
331
332
|
nextScreens: string[];
|
333
|
+
conversationId?: string;
|
332
334
|
}
|
333
335
|
export interface UserJourney {
|
334
336
|
title: string;
|
@@ -196,6 +196,22 @@ router.post('/ui/systems/:handle/:systemId/upload', async (req, res) => {
|
|
196
196
|
await stormService_1.default.uploadConversation(handle, systemId);
|
197
197
|
res.send({ ok: true });
|
198
198
|
});
|
199
|
+
router.put('/ui/systems/:handle/:systemId/thumbnail', async (req, res) => {
|
200
|
+
const systemId = req.params.systemId;
|
201
|
+
await stormService_1.default.saveThumbnail(systemId, req.body);
|
202
|
+
res.send({ ok: true });
|
203
|
+
});
|
204
|
+
router.get('/ui/systems/:handle/:systemId/thumbnail.png', async (req, res) => {
|
205
|
+
const systemId = req.params.systemId;
|
206
|
+
const thumbnail = await stormService_1.default.getThumbnail(systemId);
|
207
|
+
if (thumbnail) {
|
208
|
+
res.set('Content-Type', 'image/png');
|
209
|
+
res.send(thumbnail);
|
210
|
+
}
|
211
|
+
else {
|
212
|
+
res.status(404).send({ error: 'No thumbnail found' });
|
213
|
+
}
|
214
|
+
});
|
199
215
|
router.delete('/ui/serve/:systemId', async (req, res) => {
|
200
216
|
const systemId = req.params.systemId;
|
201
217
|
if (!systemId) {
|
@@ -357,11 +373,11 @@ router.post('/:handle/ui', async (req, res) => {
|
|
357
373
|
let systemPrompt = aiRequest.prompt;
|
358
374
|
userJourneysStream.on('data', (data) => {
|
359
375
|
try {
|
360
|
-
sendEvent(res, data);
|
361
376
|
if (data.type === 'PROMPT_IMPROVE') {
|
362
377
|
systemPrompt = data.payload.prompt;
|
363
378
|
}
|
364
379
|
if (data.type !== 'USER_JOURNEY') {
|
380
|
+
sendEvent(res, data);
|
365
381
|
return;
|
366
382
|
}
|
367
383
|
if (userJourneysStream.isAborted()) {
|
@@ -369,9 +385,11 @@ router.post('/:handle/ui', async (req, res) => {
|
|
369
385
|
}
|
370
386
|
data.payload.screens.forEach((screen) => {
|
371
387
|
if (!uniqueUserJourneyScreens[screen.name]) {
|
388
|
+
screen.conversationId = (0, crypto_1.randomUUID)();
|
372
389
|
uniqueUserJourneyScreens[screen.name] = screen;
|
373
390
|
}
|
374
391
|
});
|
392
|
+
sendEvent(res, data);
|
375
393
|
}
|
376
394
|
catch (e) {
|
377
395
|
console.error('Failed to process event', e);
|
@@ -422,6 +440,9 @@ router.post('/:handle/ui', async (req, res) => {
|
|
422
440
|
});
|
423
441
|
}
|
424
442
|
await waitForStormStream(userJourneysStream);
|
443
|
+
if (req.socket.closed) {
|
444
|
+
return;
|
445
|
+
}
|
425
446
|
// Get the UI shells
|
426
447
|
const shellsStream = await stormClient.createUIShells({
|
427
448
|
theme: theme || undefined,
|
@@ -456,6 +477,9 @@ router.post('/:handle/ui', async (req, res) => {
|
|
456
477
|
sendError(error, res);
|
457
478
|
});
|
458
479
|
await waitForStormStream(shellsStream);
|
480
|
+
if (req.socket.closed) {
|
481
|
+
return;
|
482
|
+
}
|
459
483
|
UI_SERVERS[outerConversationId] = new UIServer_1.UIServer(outerConversationId);
|
460
484
|
await UI_SERVERS[outerConversationId].start();
|
461
485
|
sendEvent(res, {
|
@@ -470,7 +494,7 @@ router.post('/:handle/ui', async (req, res) => {
|
|
470
494
|
onRequestAborted(req, res, () => {
|
471
495
|
queue.cancel();
|
472
496
|
});
|
473
|
-
queue.on('page', (
|
497
|
+
queue.on('page', (pageEvent) => sendPageEvent(outerConversationId, pageEvent, res));
|
474
498
|
queue.on('event', (event) => {
|
475
499
|
if (event.type === 'FILE_CHUNK') {
|
476
500
|
return;
|
@@ -493,7 +517,7 @@ router.post('/:handle/ui', async (req, res) => {
|
|
493
517
|
filename: screen.filename,
|
494
518
|
storage_prefix: outerConversationId + '_',
|
495
519
|
theme,
|
496
|
-
})
|
520
|
+
}, screen.conversationId)
|
497
521
|
.catch((e) => {
|
498
522
|
console.error('Failed to generate page for screen %s', screen.name, e);
|
499
523
|
sendError(e, res);
|
@@ -1,13 +1,18 @@
|
|
1
|
+
/// <reference types="node" />
|
1
2
|
import { StormEvent } from './storm/events';
|
2
3
|
export declare class StormService {
|
3
4
|
private getConversationFile;
|
4
5
|
private getConversationTarball;
|
6
|
+
private getThumbnailFile;
|
5
7
|
listRemoteConversations(): Promise<never[]>;
|
6
8
|
listLocalConversations(): Promise<{
|
7
9
|
id: string;
|
8
10
|
description: string;
|
9
11
|
title: string;
|
10
12
|
url?: string | undefined;
|
13
|
+
lastModified?: number | undefined;
|
14
|
+
createdAt?: number | undefined;
|
15
|
+
thumbnail?: string | undefined;
|
11
16
|
}[]>;
|
12
17
|
getConversation(conversationId: string): Promise<string>;
|
13
18
|
saveConversation(conversationId: string, events: StormEvent[]): Promise<void>;
|
@@ -15,6 +20,8 @@ export declare class StormService {
|
|
15
20
|
deleteConversation(conversationId: string): Promise<void>;
|
16
21
|
uploadConversation(handle: string, systemId: string): Promise<void>;
|
17
22
|
installProjectById(handle: string, systemId: string): Promise<void>;
|
23
|
+
saveThumbnail(systemId: string, thumbnail: Buffer): Promise<void>;
|
24
|
+
getThumbnail(systemId: string): Promise<Buffer | null>;
|
18
25
|
}
|
19
26
|
declare const _default: StormService;
|
20
27
|
export default _default;
|
@@ -41,6 +41,9 @@ class StormService {
|
|
41
41
|
getConversationTarball(conversationId) {
|
42
42
|
return path_1.default.join(filesystemManager_1.filesystemManager.getProjectRootFolder(), 'ai-systems', conversationId, 'system.tar.gz');
|
43
43
|
}
|
44
|
+
getThumbnailFile(conversationId) {
|
45
|
+
return path_1.default.join(filesystemManager_1.filesystemManager.getProjectRootFolder(), 'ai-systems', conversationId, 'thumbnail.png');
|
46
|
+
}
|
44
47
|
async listRemoteConversations() {
|
45
48
|
// i.e. conversations from org / user on registry
|
46
49
|
return [];
|
@@ -49,13 +52,16 @@ class StormService {
|
|
49
52
|
const systemsFolder = path_1.default.join(filesystemManager_1.filesystemManager.getProjectRootFolder(), 'ai-systems');
|
50
53
|
const eventFiles = await (0, glob_1.glob)('*/events.ndjson', {
|
51
54
|
cwd: systemsFolder,
|
52
|
-
|
55
|
+
stat: true,
|
56
|
+
withFileTypes: true,
|
53
57
|
});
|
54
58
|
// Returns list of UUIDs - probably want to make it more useful than that
|
55
59
|
const conversations = [];
|
60
|
+
// Sort by modification time, newest first
|
61
|
+
eventFiles.sort((a, b) => (b.mtimeMs || 0) - (a.mtimeMs || 0));
|
56
62
|
for (const file of eventFiles) {
|
57
63
|
try {
|
58
|
-
const nldContents = await promises_1.default.readFile(file, 'utf8');
|
64
|
+
const nldContents = await promises_1.default.readFile(file.fullpath(), 'utf8');
|
59
65
|
const events = nldContents.split('\n').map((e) => JSON.parse(e));
|
60
66
|
// find the shell and get the title tag
|
61
67
|
const shellEvent = events.find((e) => e.type === 'AI' && e.event.type === 'UI_SHELL')?.event;
|
@@ -86,6 +92,9 @@ class StormService {
|
|
86
92
|
description: initialPrompt,
|
87
93
|
title: title || 'New system',
|
88
94
|
url,
|
95
|
+
lastModified: file.mtimeMs,
|
96
|
+
createdAt: file.birthtimeMs,
|
97
|
+
thumbnail: (0, fs_1.existsSync)(this.getThumbnailFile(id)) ? `thumbnail.png?v=${file.mtimeMs}` : undefined,
|
89
98
|
});
|
90
99
|
}
|
91
100
|
catch (e) {
|
@@ -145,6 +154,18 @@ class StormService {
|
|
145
154
|
});
|
146
155
|
await promises_1.default.unlink(tarballFile);
|
147
156
|
}
|
157
|
+
async saveThumbnail(systemId, thumbnail) {
|
158
|
+
const thumbnailFile = this.getThumbnailFile(systemId);
|
159
|
+
await promises_1.default.mkdir(path_1.default.dirname(thumbnailFile), { recursive: true });
|
160
|
+
await promises_1.default.writeFile(thumbnailFile, thumbnail);
|
161
|
+
}
|
162
|
+
async getThumbnail(systemId) {
|
163
|
+
const thumbnailFile = this.getThumbnailFile(systemId);
|
164
|
+
if ((0, fs_1.existsSync)(thumbnailFile)) {
|
165
|
+
return promises_1.default.readFile(thumbnailFile);
|
166
|
+
}
|
167
|
+
return null;
|
168
|
+
}
|
148
169
|
}
|
149
170
|
exports.StormService = StormService;
|
150
171
|
exports.default = new StormService();
|
@@ -11,7 +11,8 @@ function stringBody(req, res, next) {
|
|
11
11
|
req.on('data', (chunk) => {
|
12
12
|
body.push(chunk);
|
13
13
|
}).on('end', () => {
|
14
|
-
req.
|
14
|
+
req.body = Buffer.concat(body);
|
15
|
+
req.stringBody = req.body.toString();
|
15
16
|
next();
|
16
17
|
});
|
17
18
|
}
|
@@ -37,6 +37,7 @@ const node_events_1 = require("node:events");
|
|
37
37
|
const p_queue_1 = __importDefault(require("p-queue"));
|
38
38
|
const page_utils_1 = require("./page-utils");
|
39
39
|
const mimetypes = __importStar(require("mime-types"));
|
40
|
+
const node_crypto_1 = require("node:crypto");
|
40
41
|
class PageQueue extends node_events_1.EventEmitter {
|
41
42
|
queue;
|
42
43
|
eventQueue;
|
@@ -166,6 +167,8 @@ class PageQueue extends node_events_1.EventEmitter {
|
|
166
167
|
}
|
167
168
|
this.pages.set(normalizedPath, reference.description);
|
168
169
|
initialPrompts.push({
|
170
|
+
conversationId: (0, node_crypto_1.randomUUID)(),
|
171
|
+
id: (0, node_crypto_1.randomUUID)(),
|
169
172
|
name: reference.name,
|
170
173
|
title: reference.title,
|
171
174
|
path: normalizedPath,
|
@@ -193,20 +196,21 @@ class PageQueue extends node_events_1.EventEmitter {
|
|
193
196
|
reason: 'reference',
|
194
197
|
created: Date.now(),
|
195
198
|
payload: {
|
199
|
+
id: prompt.id,
|
200
|
+
conversationId: prompt.conversationId,
|
196
201
|
name: prompt.name,
|
197
202
|
title: prompt.title,
|
198
203
|
filename: prompt.filename,
|
199
204
|
method: 'GET',
|
200
205
|
path: prompt.path,
|
201
206
|
prompt: prompt.description,
|
202
|
-
conversationId: '',
|
203
207
|
content: '',
|
204
208
|
description: prompt.description,
|
205
209
|
},
|
206
210
|
});
|
207
211
|
}
|
208
212
|
// Trigger but don't wait for the "bonus" pages
|
209
|
-
this.addPrompt(prompt).catch((err) => {
|
213
|
+
this.addPrompt(prompt, prompt.conversationId).catch((err) => {
|
210
214
|
console.error('Failed to generate page reference', prompt.name, err);
|
211
215
|
this.emit('error', err);
|
212
216
|
});
|
@@ -287,6 +287,7 @@ export interface StormEventPhases {
|
|
287
287
|
};
|
288
288
|
}
|
289
289
|
export interface Page {
|
290
|
+
id: string;
|
290
291
|
name: string;
|
291
292
|
filename: string;
|
292
293
|
title: string;
|
@@ -329,6 +330,7 @@ export interface UserJourneyScreen {
|
|
329
330
|
path: string;
|
330
331
|
method: string;
|
331
332
|
nextScreens: string[];
|
333
|
+
conversationId?: string;
|
332
334
|
}
|
333
335
|
export interface UserJourney {
|
334
336
|
title: string;
|
@@ -196,6 +196,22 @@ router.post('/ui/systems/:handle/:systemId/upload', async (req, res) => {
|
|
196
196
|
await stormService_1.default.uploadConversation(handle, systemId);
|
197
197
|
res.send({ ok: true });
|
198
198
|
});
|
199
|
+
router.put('/ui/systems/:handle/:systemId/thumbnail', async (req, res) => {
|
200
|
+
const systemId = req.params.systemId;
|
201
|
+
await stormService_1.default.saveThumbnail(systemId, req.body);
|
202
|
+
res.send({ ok: true });
|
203
|
+
});
|
204
|
+
router.get('/ui/systems/:handle/:systemId/thumbnail.png', async (req, res) => {
|
205
|
+
const systemId = req.params.systemId;
|
206
|
+
const thumbnail = await stormService_1.default.getThumbnail(systemId);
|
207
|
+
if (thumbnail) {
|
208
|
+
res.set('Content-Type', 'image/png');
|
209
|
+
res.send(thumbnail);
|
210
|
+
}
|
211
|
+
else {
|
212
|
+
res.status(404).send({ error: 'No thumbnail found' });
|
213
|
+
}
|
214
|
+
});
|
199
215
|
router.delete('/ui/serve/:systemId', async (req, res) => {
|
200
216
|
const systemId = req.params.systemId;
|
201
217
|
if (!systemId) {
|
@@ -357,11 +373,11 @@ router.post('/:handle/ui', async (req, res) => {
|
|
357
373
|
let systemPrompt = aiRequest.prompt;
|
358
374
|
userJourneysStream.on('data', (data) => {
|
359
375
|
try {
|
360
|
-
sendEvent(res, data);
|
361
376
|
if (data.type === 'PROMPT_IMPROVE') {
|
362
377
|
systemPrompt = data.payload.prompt;
|
363
378
|
}
|
364
379
|
if (data.type !== 'USER_JOURNEY') {
|
380
|
+
sendEvent(res, data);
|
365
381
|
return;
|
366
382
|
}
|
367
383
|
if (userJourneysStream.isAborted()) {
|
@@ -369,9 +385,11 @@ router.post('/:handle/ui', async (req, res) => {
|
|
369
385
|
}
|
370
386
|
data.payload.screens.forEach((screen) => {
|
371
387
|
if (!uniqueUserJourneyScreens[screen.name]) {
|
388
|
+
screen.conversationId = (0, crypto_1.randomUUID)();
|
372
389
|
uniqueUserJourneyScreens[screen.name] = screen;
|
373
390
|
}
|
374
391
|
});
|
392
|
+
sendEvent(res, data);
|
375
393
|
}
|
376
394
|
catch (e) {
|
377
395
|
console.error('Failed to process event', e);
|
@@ -422,6 +440,9 @@ router.post('/:handle/ui', async (req, res) => {
|
|
422
440
|
});
|
423
441
|
}
|
424
442
|
await waitForStormStream(userJourneysStream);
|
443
|
+
if (req.socket.closed) {
|
444
|
+
return;
|
445
|
+
}
|
425
446
|
// Get the UI shells
|
426
447
|
const shellsStream = await stormClient.createUIShells({
|
427
448
|
theme: theme || undefined,
|
@@ -456,6 +477,9 @@ router.post('/:handle/ui', async (req, res) => {
|
|
456
477
|
sendError(error, res);
|
457
478
|
});
|
458
479
|
await waitForStormStream(shellsStream);
|
480
|
+
if (req.socket.closed) {
|
481
|
+
return;
|
482
|
+
}
|
459
483
|
UI_SERVERS[outerConversationId] = new UIServer_1.UIServer(outerConversationId);
|
460
484
|
await UI_SERVERS[outerConversationId].start();
|
461
485
|
sendEvent(res, {
|
@@ -470,7 +494,7 @@ router.post('/:handle/ui', async (req, res) => {
|
|
470
494
|
onRequestAborted(req, res, () => {
|
471
495
|
queue.cancel();
|
472
496
|
});
|
473
|
-
queue.on('page', (
|
497
|
+
queue.on('page', (pageEvent) => sendPageEvent(outerConversationId, pageEvent, res));
|
474
498
|
queue.on('event', (event) => {
|
475
499
|
if (event.type === 'FILE_CHUNK') {
|
476
500
|
return;
|
@@ -493,7 +517,7 @@ router.post('/:handle/ui', async (req, res) => {
|
|
493
517
|
filename: screen.filename,
|
494
518
|
storage_prefix: outerConversationId + '_',
|
495
519
|
theme,
|
496
|
-
})
|
520
|
+
}, screen.conversationId)
|
497
521
|
.catch((e) => {
|
498
522
|
console.error('Failed to generate page for screen %s', screen.name, e);
|
499
523
|
sendError(e, res);
|
@@ -1,13 +1,18 @@
|
|
1
|
+
/// <reference types="node" />
|
1
2
|
import { StormEvent } from './storm/events';
|
2
3
|
export declare class StormService {
|
3
4
|
private getConversationFile;
|
4
5
|
private getConversationTarball;
|
6
|
+
private getThumbnailFile;
|
5
7
|
listRemoteConversations(): Promise<never[]>;
|
6
8
|
listLocalConversations(): Promise<{
|
7
9
|
id: string;
|
8
10
|
description: string;
|
9
11
|
title: string;
|
10
12
|
url?: string | undefined;
|
13
|
+
lastModified?: number | undefined;
|
14
|
+
createdAt?: number | undefined;
|
15
|
+
thumbnail?: string | undefined;
|
11
16
|
}[]>;
|
12
17
|
getConversation(conversationId: string): Promise<string>;
|
13
18
|
saveConversation(conversationId: string, events: StormEvent[]): Promise<void>;
|
@@ -15,6 +20,8 @@ export declare class StormService {
|
|
15
20
|
deleteConversation(conversationId: string): Promise<void>;
|
16
21
|
uploadConversation(handle: string, systemId: string): Promise<void>;
|
17
22
|
installProjectById(handle: string, systemId: string): Promise<void>;
|
23
|
+
saveThumbnail(systemId: string, thumbnail: Buffer): Promise<void>;
|
24
|
+
getThumbnail(systemId: string): Promise<Buffer | null>;
|
18
25
|
}
|
19
26
|
declare const _default: StormService;
|
20
27
|
export default _default;
|
@@ -41,6 +41,9 @@ class StormService {
|
|
41
41
|
getConversationTarball(conversationId) {
|
42
42
|
return path_1.default.join(filesystemManager_1.filesystemManager.getProjectRootFolder(), 'ai-systems', conversationId, 'system.tar.gz');
|
43
43
|
}
|
44
|
+
getThumbnailFile(conversationId) {
|
45
|
+
return path_1.default.join(filesystemManager_1.filesystemManager.getProjectRootFolder(), 'ai-systems', conversationId, 'thumbnail.png');
|
46
|
+
}
|
44
47
|
async listRemoteConversations() {
|
45
48
|
// i.e. conversations from org / user on registry
|
46
49
|
return [];
|
@@ -49,13 +52,16 @@ class StormService {
|
|
49
52
|
const systemsFolder = path_1.default.join(filesystemManager_1.filesystemManager.getProjectRootFolder(), 'ai-systems');
|
50
53
|
const eventFiles = await (0, glob_1.glob)('*/events.ndjson', {
|
51
54
|
cwd: systemsFolder,
|
52
|
-
|
55
|
+
stat: true,
|
56
|
+
withFileTypes: true,
|
53
57
|
});
|
54
58
|
// Returns list of UUIDs - probably want to make it more useful than that
|
55
59
|
const conversations = [];
|
60
|
+
// Sort by modification time, newest first
|
61
|
+
eventFiles.sort((a, b) => (b.mtimeMs || 0) - (a.mtimeMs || 0));
|
56
62
|
for (const file of eventFiles) {
|
57
63
|
try {
|
58
|
-
const nldContents = await promises_1.default.readFile(file, 'utf8');
|
64
|
+
const nldContents = await promises_1.default.readFile(file.fullpath(), 'utf8');
|
59
65
|
const events = nldContents.split('\n').map((e) => JSON.parse(e));
|
60
66
|
// find the shell and get the title tag
|
61
67
|
const shellEvent = events.find((e) => e.type === 'AI' && e.event.type === 'UI_SHELL')?.event;
|
@@ -86,6 +92,9 @@ class StormService {
|
|
86
92
|
description: initialPrompt,
|
87
93
|
title: title || 'New system',
|
88
94
|
url,
|
95
|
+
lastModified: file.mtimeMs,
|
96
|
+
createdAt: file.birthtimeMs,
|
97
|
+
thumbnail: (0, fs_1.existsSync)(this.getThumbnailFile(id)) ? `thumbnail.png?v=${file.mtimeMs}` : undefined,
|
89
98
|
});
|
90
99
|
}
|
91
100
|
catch (e) {
|
@@ -145,6 +154,18 @@ class StormService {
|
|
145
154
|
});
|
146
155
|
await promises_1.default.unlink(tarballFile);
|
147
156
|
}
|
157
|
+
async saveThumbnail(systemId, thumbnail) {
|
158
|
+
const thumbnailFile = this.getThumbnailFile(systemId);
|
159
|
+
await promises_1.default.mkdir(path_1.default.dirname(thumbnailFile), { recursive: true });
|
160
|
+
await promises_1.default.writeFile(thumbnailFile, thumbnail);
|
161
|
+
}
|
162
|
+
async getThumbnail(systemId) {
|
163
|
+
const thumbnailFile = this.getThumbnailFile(systemId);
|
164
|
+
if ((0, fs_1.existsSync)(thumbnailFile)) {
|
165
|
+
return promises_1.default.readFile(thumbnailFile);
|
166
|
+
}
|
167
|
+
return null;
|
168
|
+
}
|
148
169
|
}
|
149
170
|
exports.StormService = StormService;
|
150
171
|
exports.default = new StormService();
|
package/package.json
CHANGED
@@ -15,7 +15,8 @@ export function stringBody(req: StringBodyRequest, res: Response, next: NextFunc
|
|
15
15
|
req.on('data', (chunk) => {
|
16
16
|
body.push(chunk);
|
17
17
|
}).on('end', () => {
|
18
|
-
req.
|
18
|
+
req.body = Buffer.concat(body);
|
19
|
+
req.stringBody = req.body.toString();
|
19
20
|
next();
|
20
21
|
});
|
21
22
|
}
|
@@ -11,6 +11,7 @@ import PQueue from 'p-queue';
|
|
11
11
|
|
12
12
|
import { hasPageOnDisk, normalizePath, writeImageToDisk } from './page-utils';
|
13
13
|
import * as mimetypes from 'mime-types';
|
14
|
+
import { randomUUID } from 'node:crypto';
|
14
15
|
|
15
16
|
export interface ImagePrompt {
|
16
17
|
name: string;
|
@@ -22,7 +23,10 @@ export interface ImagePrompt {
|
|
22
23
|
content: string;
|
23
24
|
}
|
24
25
|
|
25
|
-
type InitialPrompt = Omit<UIPagePrompt, 'shell_page'> & {
|
26
|
+
type InitialPrompt = Omit<UIPagePrompt, 'shell_page'> & {
|
27
|
+
shellType?: 'public' | 'admin' | 'user';
|
28
|
+
};
|
29
|
+
type PagePrompt = InitialPrompt & { conversationId: string; id: string };
|
26
30
|
|
27
31
|
export class PageQueue extends EventEmitter {
|
28
32
|
private readonly queue: PQueue;
|
@@ -149,7 +153,7 @@ export class PageQueue extends EventEmitter {
|
|
149
153
|
new RegExp(`^${path.replaceAll('/*', '/[^/]+')}$`).test(url)
|
150
154
|
);
|
151
155
|
};
|
152
|
-
const initialPrompts:
|
156
|
+
const initialPrompts: PagePrompt[] = [];
|
153
157
|
const resourcePromises = references.map(async (reference) => {
|
154
158
|
if (
|
155
159
|
reference.url.startsWith('#') ||
|
@@ -185,6 +189,8 @@ export class PageQueue extends EventEmitter {
|
|
185
189
|
this.pages.set(normalizedPath, reference.description);
|
186
190
|
|
187
191
|
initialPrompts.push({
|
192
|
+
conversationId: randomUUID(),
|
193
|
+
id: randomUUID(),
|
188
194
|
name: reference.name,
|
189
195
|
title: reference.title,
|
190
196
|
path: normalizedPath,
|
@@ -215,20 +221,21 @@ export class PageQueue extends EventEmitter {
|
|
215
221
|
reason: 'reference',
|
216
222
|
created: Date.now(),
|
217
223
|
payload: {
|
224
|
+
id: prompt.id,
|
225
|
+
conversationId: prompt.conversationId,
|
218
226
|
name: prompt.name,
|
219
227
|
title: prompt.title,
|
220
228
|
filename: prompt.filename,
|
221
229
|
method: 'GET',
|
222
230
|
path: prompt.path,
|
223
231
|
prompt: prompt.description,
|
224
|
-
conversationId: '',
|
225
232
|
content: '',
|
226
233
|
description: prompt.description,
|
227
234
|
},
|
228
235
|
});
|
229
236
|
}
|
230
237
|
// Trigger but don't wait for the "bonus" pages
|
231
|
-
this.addPrompt(prompt).catch((err) => {
|
238
|
+
this.addPrompt(prompt, prompt.conversationId).catch((err) => {
|
232
239
|
console.error('Failed to generate page reference', prompt.name, err);
|
233
240
|
this.emit('error', err);
|
234
241
|
});
|
package/src/storm/events.ts
CHANGED
@@ -347,6 +347,7 @@ export interface StormEventPhases {
|
|
347
347
|
}
|
348
348
|
|
349
349
|
export interface Page {
|
350
|
+
id: string;
|
350
351
|
name: string;
|
351
352
|
filename: string;
|
352
353
|
title: string;
|
@@ -394,6 +395,7 @@ export interface UserJourneyScreen {
|
|
394
395
|
path: string;
|
395
396
|
method: string;
|
396
397
|
nextScreens: string[];
|
398
|
+
conversationId?: string;
|
397
399
|
}
|
398
400
|
|
399
401
|
export interface UserJourney {
|
package/src/storm/routes.ts
CHANGED
@@ -162,7 +162,7 @@ router.post('/ui/create-system/:handle/:systemId', async (req: KapetaBodyRequest
|
|
162
162
|
sendEvent(res, createPhaseStartEvent(StormEventPhaseType.IMPLEMENT_APIS));
|
163
163
|
|
164
164
|
const pagesFromDisk = readFilesAndContent(srcDir);
|
165
|
-
const client = new StormClient(systemId)
|
165
|
+
const client = new StormClient(systemId);
|
166
166
|
const pagesWithImplementation = await client.replaceMockWithAPICall({
|
167
167
|
pages: pagesFromDisk,
|
168
168
|
systemId: systemId,
|
@@ -260,6 +260,23 @@ router.post('/ui/systems/:handle/:systemId/upload', async (req: KapetaBodyReques
|
|
260
260
|
res.send({ ok: true });
|
261
261
|
});
|
262
262
|
|
263
|
+
router.put('/ui/systems/:handle/:systemId/thumbnail', async (req: KapetaBodyRequest, res: Response) => {
|
264
|
+
const systemId = req.params.systemId as string;
|
265
|
+
await stormService.saveThumbnail(systemId, req.body as Buffer);
|
266
|
+
res.send({ ok: true });
|
267
|
+
});
|
268
|
+
|
269
|
+
router.get('/ui/systems/:handle/:systemId/thumbnail.png', async (req: KapetaBodyRequest, res: Response) => {
|
270
|
+
const systemId = req.params.systemId as string;
|
271
|
+
const thumbnail = await stormService.getThumbnail(systemId);
|
272
|
+
if (thumbnail) {
|
273
|
+
res.set('Content-Type', 'image/png');
|
274
|
+
res.send(thumbnail);
|
275
|
+
} else {
|
276
|
+
res.status(404).send({ error: 'No thumbnail found' });
|
277
|
+
}
|
278
|
+
});
|
279
|
+
|
263
280
|
router.delete('/ui/serve/:systemId', async (req: KapetaBodyRequest, res: Response) => {
|
264
281
|
const systemId = req.params.systemId as string | undefined;
|
265
282
|
if (!systemId) {
|
@@ -458,11 +475,11 @@ router.post('/:handle/ui', async (req: KapetaBodyRequest, res: Response) => {
|
|
458
475
|
|
459
476
|
userJourneysStream.on('data', (data: StormEvent) => {
|
460
477
|
try {
|
461
|
-
sendEvent(res, data);
|
462
478
|
if (data.type === 'PROMPT_IMPROVE') {
|
463
479
|
systemPrompt = data.payload.prompt;
|
464
480
|
}
|
465
481
|
if (data.type !== 'USER_JOURNEY') {
|
482
|
+
sendEvent(res, data);
|
466
483
|
return;
|
467
484
|
}
|
468
485
|
|
@@ -472,9 +489,12 @@ router.post('/:handle/ui', async (req: KapetaBodyRequest, res: Response) => {
|
|
472
489
|
|
473
490
|
data.payload.screens.forEach((screen) => {
|
474
491
|
if (!uniqueUserJourneyScreens[screen.name]) {
|
492
|
+
screen.conversationId = randomUUID();
|
475
493
|
uniqueUserJourneyScreens[screen.name] = screen;
|
476
494
|
}
|
477
495
|
});
|
496
|
+
|
497
|
+
sendEvent(res, data);
|
478
498
|
} catch (e) {
|
479
499
|
console.error('Failed to process event', e);
|
480
500
|
}
|
@@ -528,6 +548,10 @@ router.post('/:handle/ui', async (req: KapetaBodyRequest, res: Response) => {
|
|
528
548
|
|
529
549
|
await waitForStormStream(userJourneysStream);
|
530
550
|
|
551
|
+
if (req.socket.closed) {
|
552
|
+
return;
|
553
|
+
}
|
554
|
+
|
531
555
|
// Get the UI shells
|
532
556
|
const shellsStream = await stormClient.createUIShells(
|
533
557
|
{
|
@@ -574,6 +598,10 @@ router.post('/:handle/ui', async (req: KapetaBodyRequest, res: Response) => {
|
|
574
598
|
|
575
599
|
await waitForStormStream(shellsStream);
|
576
600
|
|
601
|
+
if (req.socket.closed) {
|
602
|
+
return;
|
603
|
+
}
|
604
|
+
|
577
605
|
UI_SERVERS[outerConversationId] = new UIServer(outerConversationId);
|
578
606
|
await UI_SERVERS[outerConversationId].start();
|
579
607
|
|
@@ -591,7 +619,7 @@ router.post('/:handle/ui', async (req: KapetaBodyRequest, res: Response) => {
|
|
591
619
|
queue.cancel();
|
592
620
|
});
|
593
621
|
|
594
|
-
queue.on('page', (
|
622
|
+
queue.on('page', (pageEvent: StormEventPage) => sendPageEvent(outerConversationId, pageEvent, res));
|
595
623
|
|
596
624
|
queue.on('event', (event: StormEvent) => {
|
597
625
|
if (event.type === 'FILE_CHUNK') {
|
@@ -607,17 +635,20 @@ router.post('/:handle/ui', async (req: KapetaBodyRequest, res: Response) => {
|
|
607
635
|
|
608
636
|
for (const screen of Object.values(uniqueUserJourneyScreens)) {
|
609
637
|
queue
|
610
|
-
.addPrompt(
|
611
|
-
|
612
|
-
|
613
|
-
|
614
|
-
|
615
|
-
|
616
|
-
|
617
|
-
|
618
|
-
|
619
|
-
|
620
|
-
|
638
|
+
.addPrompt(
|
639
|
+
{
|
640
|
+
prompt: screen.requirements,
|
641
|
+
method: screen.method,
|
642
|
+
path: screen.path,
|
643
|
+
description: screen.requirements,
|
644
|
+
name: screen.name,
|
645
|
+
title: screen.title,
|
646
|
+
filename: screen.filename,
|
647
|
+
storage_prefix: outerConversationId + '_',
|
648
|
+
theme,
|
649
|
+
},
|
650
|
+
screen.conversationId
|
651
|
+
)
|
621
652
|
.catch((e) => {
|
622
653
|
console.error('Failed to generate page for screen %s', screen.name, e);
|
623
654
|
sendError(e as any, res);
|
package/src/stormService.ts
CHANGED
@@ -1,5 +1,5 @@
|
|
1
1
|
import fs from 'fs/promises';
|
2
|
-
import { glob } from 'glob';
|
2
|
+
import { glob, Path } from 'glob';
|
3
3
|
import { filesystemManager } from './filesystemManager';
|
4
4
|
import path from 'path';
|
5
5
|
import { existsSync } from 'fs';
|
@@ -16,6 +16,10 @@ export class StormService {
|
|
16
16
|
return path.join(filesystemManager.getProjectRootFolder()!, 'ai-systems', conversationId, 'system.tar.gz');
|
17
17
|
}
|
18
18
|
|
19
|
+
private getThumbnailFile(conversationId: string) {
|
20
|
+
return path.join(filesystemManager.getProjectRootFolder()!, 'ai-systems', conversationId, 'thumbnail.png');
|
21
|
+
}
|
22
|
+
|
19
23
|
async listRemoteConversations() {
|
20
24
|
// i.e. conversations from org / user on registry
|
21
25
|
return [];
|
@@ -25,13 +29,24 @@ export class StormService {
|
|
25
29
|
const systemsFolder = path.join(filesystemManager.getProjectRootFolder()!, 'ai-systems');
|
26
30
|
const eventFiles = await glob('*/events.ndjson', {
|
27
31
|
cwd: systemsFolder,
|
28
|
-
|
32
|
+
stat: true,
|
33
|
+
withFileTypes: true,
|
29
34
|
});
|
30
35
|
// Returns list of UUIDs - probably want to make it more useful than that
|
31
|
-
const conversations: {
|
36
|
+
const conversations: {
|
37
|
+
id: string;
|
38
|
+
description: string;
|
39
|
+
title: string;
|
40
|
+
url?: string;
|
41
|
+
lastModified?: number;
|
42
|
+
createdAt?: number;
|
43
|
+
thumbnail?: string;
|
44
|
+
}[] = [];
|
45
|
+
// Sort by modification time, newest first
|
46
|
+
eventFiles.sort((a, b) => (b.mtimeMs || 0) - (a.mtimeMs || 0));
|
32
47
|
for (const file of eventFiles) {
|
33
48
|
try {
|
34
|
-
const nldContents = await fs.readFile(file
|
49
|
+
const nldContents = await fs.readFile(file.fullpath(), 'utf8');
|
35
50
|
const events = nldContents.split('\n').map((e) => JSON.parse(e)) as {
|
36
51
|
// | { type: 'USER'; event: any } // IS stupid!
|
37
52
|
type: 'AI';
|
@@ -79,6 +94,9 @@ export class StormService {
|
|
79
94
|
description: initialPrompt,
|
80
95
|
title: title || 'New system',
|
81
96
|
url,
|
97
|
+
lastModified: file.mtimeMs,
|
98
|
+
createdAt: file.birthtimeMs,
|
99
|
+
thumbnail: existsSync(this.getThumbnailFile(id)) ? `thumbnail.png?v=${file.mtimeMs}` : undefined,
|
82
100
|
});
|
83
101
|
} catch (e) {
|
84
102
|
console.error('Failed to load conversation at %s', file, e);
|
@@ -148,6 +166,20 @@ export class StormService {
|
|
148
166
|
});
|
149
167
|
await fs.unlink(tarballFile);
|
150
168
|
}
|
169
|
+
|
170
|
+
async saveThumbnail(systemId: string, thumbnail: Buffer) {
|
171
|
+
const thumbnailFile = this.getThumbnailFile(systemId);
|
172
|
+
await fs.mkdir(path.dirname(thumbnailFile), { recursive: true });
|
173
|
+
await fs.writeFile(thumbnailFile, thumbnail);
|
174
|
+
}
|
175
|
+
|
176
|
+
async getThumbnail(systemId: string) {
|
177
|
+
const thumbnailFile = this.getThumbnailFile(systemId);
|
178
|
+
if (existsSync(thumbnailFile)) {
|
179
|
+
return fs.readFile(thumbnailFile);
|
180
|
+
}
|
181
|
+
return null;
|
182
|
+
}
|
151
183
|
}
|
152
184
|
|
153
185
|
export default new StormService();
|