@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 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.stringBody = Buffer.concat(body).toString();
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', (screenData) => sendPageEvent(outerConversationId, screenData, res));
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
- absolute: true,
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.stringBody = Buffer.concat(body).toString();
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', (screenData) => sendPageEvent(outerConversationId, screenData, res));
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
- absolute: true,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kapeta/local-cluster-service",
3
- "version": "0.74.1",
3
+ "version": "0.75.0",
4
4
  "description": "Manages configuration, ports and service discovery for locally running Kapeta systems",
5
5
  "type": "commonjs",
6
6
  "exports": {
@@ -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.stringBody = Buffer.concat(body).toString();
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'> & { shellType?: 'public' | 'admin' | 'user' };
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: InitialPrompt[] = [];
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
  });
@@ -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 {
@@ -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', (screenData: StormEventPage) => sendPageEvent(outerConversationId, screenData, res));
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
- prompt: screen.requirements,
612
- method: screen.method,
613
- path: screen.path,
614
- description: screen.requirements,
615
- name: screen.name,
616
- title: screen.title,
617
- filename: screen.filename,
618
- storage_prefix: outerConversationId + '_',
619
- theme,
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);
@@ -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
- absolute: true,
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: { id: string; description: string; title: string; url?: string }[] = [];
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 as string, 'utf8');
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();