@kapeta/local-cluster-service 0.67.4 → 0.68.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.
@@ -7,7 +7,7 @@ import uuid from 'node-uuid';
7
7
  import { stormClient, UIPagePrompt } from './stormClient';
8
8
  import { ReferenceClassification, StormEvent, StormEventPage, StormImage, UIShell } from './events';
9
9
  import { EventEmitter } from 'node:events';
10
- import { createFuture, Future, FuturePromise, PromiseQueue } from './PromiseQueue';
10
+ import { PromiseQueue } from './PromiseQueue';
11
11
  import { hasPageOnDisk } from './page-utils';
12
12
 
13
13
  export interface ImagePrompt {
@@ -24,6 +24,7 @@ type InitialPrompt = Omit<UIPagePrompt, 'shell_page'> & { shellType?: 'public' |
24
24
 
25
25
  export class PageQueue extends EventEmitter {
26
26
  private readonly queue: PromiseQueue;
27
+ private readonly eventQueue: PromiseQueue;
27
28
  private readonly systemId: string;
28
29
  private readonly systemPrompt: string;
29
30
  private readonly references: Map<string, PageGenerator> = new Map();
@@ -37,19 +38,22 @@ export class PageQueue extends EventEmitter {
37
38
  this.systemId = systemId;
38
39
  this.systemPrompt = systemPrompt;
39
40
  this.queue = new PromiseQueue(concurrency);
41
+ this.eventQueue = new PromiseQueue(Number.MAX_VALUE);
40
42
  }
41
43
 
42
- on(event: 'event', listener: (data: StormEvent) => void): this;
43
- on(event: 'page', listener: (data: StormEventPage) => void): this;
44
- on(event: 'image', listener: (data: StormImage, source: ImagePrompt, future: FuturePromise<void>) => void): this;
44
+ on(event: 'event', listener: (data: StormEvent) => void | Promise<void>): this;
45
+ on(event: 'page', listener: (data: StormEventPage) => void | Promise<void>): this;
46
+ on(event: 'image', listener: (data: StormImage, source: ImagePrompt) => void | Promise<void>): this;
45
47
 
46
- on(event: string, listener: (...args: any[]) => void): this {
47
- return super.on(event, listener);
48
+ on(event: string, listener: (...args: any[]) => void | Promise<void>): this {
49
+ return super.on(event, (...args) => {
50
+ void this.eventQueue.add(async () => listener(...args));
51
+ });
48
52
  }
49
53
 
50
54
  emit(type: 'event', event: StormEvent): boolean;
51
55
  emit(type: 'page', event: StormEventPage): boolean;
52
- emit(type: 'image', event: StormImage, source: ImagePrompt, future: FuturePromise<void>): boolean;
56
+ emit(type: 'image', event: StormImage, source: ImagePrompt): boolean;
53
57
  emit(eventName: string | symbol, ...args: any[]): boolean {
54
58
  return super.emit(eventName, ...args);
55
59
  }
@@ -81,6 +85,7 @@ export class PageQueue extends EventEmitter {
81
85
  //console.log('Ignoring duplicate prompt', initialPrompt.path);
82
86
  return Promise.resolve();
83
87
  }
88
+ console.log('Generating page for', initialPrompt.method, initialPrompt.path);
84
89
 
85
90
  const prompt: UIPagePrompt = {
86
91
  ...initialPrompt,
@@ -104,7 +109,7 @@ export class PageQueue extends EventEmitter {
104
109
  }
105
110
 
106
111
  private wrapPagePrompt(pagePath: string, prompt: string): string {
107
- let promptPrefix = this.getPrefix();
112
+ const promptPrefix = this.getPrefix();
108
113
  let promptPostfix = '';
109
114
 
110
115
  if (this.pages.size > 0) {
@@ -129,10 +134,10 @@ export class PageQueue extends EventEmitter {
129
134
 
130
135
  private async addPageGenerator(generator: PageGenerator) {
131
136
  generator.on('event', (event: StormEvent) => this.emit('event', event));
132
- generator.on('page_refs', async ({ event, references, future }) => {
137
+ generator.on('page_refs', async ({ event, references }) => {
133
138
  try {
134
139
  const initialPrompts: InitialPrompt[] = [];
135
- let promises = references.map(async (reference) => {
140
+ const resourcePromises = references.map(async (reference) => {
136
141
  if (
137
142
  reference.url.startsWith('#') ||
138
143
  reference.url.startsWith('javascript:') ||
@@ -174,8 +179,12 @@ export class PageQueue extends EventEmitter {
174
179
  }
175
180
  });
176
181
 
177
- await Promise.allSettled(promises);
178
- initialPrompts.forEach((prompt) => {
182
+ // Wait for resources to be generated
183
+ await Promise.allSettled(resourcePromises);
184
+ this.emit('page', event);
185
+
186
+ // Emit any new pages after the current page to increase responsiveness
187
+ const newPages = initialPrompts.map((prompt) => {
179
188
  if (!this.hasPrompt(prompt.path)) {
180
189
  this.emit('page', {
181
190
  type: 'PAGE',
@@ -194,12 +203,12 @@ export class PageQueue extends EventEmitter {
194
203
  },
195
204
  });
196
205
  }
197
- this.addPrompt(prompt);
206
+ return this.addPrompt(prompt);
198
207
  });
199
-
200
- this.emit('page', event);
201
- } finally {
202
- future.resolve();
208
+ await Promise.allSettled(newPages);
209
+ } catch (e) {
210
+ console.error('Failed to process event', e);
211
+ throw e;
203
212
  }
204
213
  });
205
214
  return this.queue.add(() => generator.generate());
@@ -207,10 +216,12 @@ export class PageQueue extends EventEmitter {
207
216
 
208
217
  public cancel() {
209
218
  this.queue.cancel();
219
+ this.eventQueue.cancel();
210
220
  }
211
221
 
212
- public wait() {
213
- return this.queue.wait();
222
+ public async wait() {
223
+ await this.eventQueue.wait();
224
+ await this.queue.wait();
214
225
  }
215
226
 
216
227
  private async addImagePrompt(prompt: ImagePrompt) {
@@ -225,29 +236,18 @@ export class PageQueue extends EventEmitter {
225
236
  );
226
237
 
227
238
  //console.log('Adding image prompt', prompt);
228
-
229
- const futures: FuturePromise<void>[] = [];
230
-
231
- result.on('data', async (event: StormEvent) => {
239
+ result.on('data', (event: StormEvent) => {
232
240
  if (event.type === 'IMAGE') {
233
- const future = createFuture();
234
- futures.push(future);
235
- this.emit('image', event, prompt, future);
236
- setTimeout(() => {
237
- if (!future.isResolved()) {
238
- console.log('Image prompt timed out', prompt);
239
- future.reject(new Error('Image prompt timed out'));
240
- }
241
- }, 30000);
241
+ this.emit('image', event, prompt);
242
242
  }
243
243
  });
244
244
 
245
245
  await result.waitForDone();
246
- await Promise.allSettled(futures.map((f) => f.promise));
247
246
  }
248
247
  }
249
248
 
250
249
  export class PageGenerator extends EventEmitter {
250
+ private readonly eventQueue: PromiseQueue;
251
251
  private readonly conversationId: string;
252
252
  public readonly prompt: UIPagePrompt;
253
253
 
@@ -255,64 +255,54 @@ export class PageGenerator extends EventEmitter {
255
255
  super();
256
256
  this.conversationId = conversationId;
257
257
  this.prompt = prompt;
258
+ this.eventQueue = new PromiseQueue(Number.MAX_VALUE);
258
259
  }
259
260
 
260
261
  on(event: 'event', listener: (data: StormEvent) => void): this;
261
262
  on(
262
263
  event: 'page_refs',
263
- listener: (data: {
264
- event: StormEventPage;
265
- references: ReferenceClassification[];
266
- future: FuturePromise<void>;
267
- }) => void
264
+ listener: (data: { event: StormEventPage; references: ReferenceClassification[] }) => Promise<void>
268
265
  ): this;
269
266
 
270
267
  on(event: string, listener: (...args: any[]) => void): this {
271
- return super.on(event, listener);
268
+ return super.on(event, (...args) => {
269
+ void this.eventQueue.add(async () => listener(...args));
270
+ });
272
271
  }
273
272
 
274
273
  emit(type: 'event', event: StormEvent): boolean;
275
- emit(
276
- type: 'page_refs',
277
- event: { event: StormEventPage; references: ReferenceClassification[]; future: FuturePromise<void> }
278
- ): boolean;
274
+ emit(type: 'page_refs', event: { event: StormEventPage; references: ReferenceClassification[] }): boolean;
279
275
  emit(eventName: string | symbol, ...args: any[]): boolean {
280
276
  return super.emit(eventName, ...args);
281
277
  }
282
278
 
283
279
  public async generate() {
284
- return new Promise<void>(async (resolve) => {
285
- const promises: Promise<void>[] = [];
286
- const screenStream = await stormClient.createUIPage(this.prompt, this.conversationId);
287
-
288
- screenStream.on('data', (event: StormEvent) => {
289
- if (event.type === 'PAGE') {
290
- event.payload.conversationId = this.conversationId;
291
-
292
- promises.push(
293
- (async () => {
294
- const references = await this.resolveReferences(event.payload.content);
295
- //console.log('Resolved references for page', references, event.payload);
296
- const future = createFuture();
297
- this.emit('page_refs', {
298
- event,
299
- references,
300
- future,
301
- });
302
-
303
- await future.promise;
304
- })()
305
- );
306
- return;
307
- }
308
-
309
- this.emit('event', event);
310
- });
280
+ const promises: Promise<void>[] = [];
281
+ const screenStream = await stormClient.createUIPage(this.prompt, this.conversationId);
282
+
283
+ screenStream.on('data', (event: StormEvent) => {
284
+ if (event.type === 'PAGE') {
285
+ event.payload.conversationId = this.conversationId;
286
+
287
+ promises.push(
288
+ (async () => {
289
+ const references = await this.resolveReferences(event.payload.content);
290
+ //console.log('Resolved references for page', references, event.payload);
291
+ this.emit('page_refs', {
292
+ event,
293
+ references,
294
+ });
295
+ })()
296
+ );
297
+ return;
298
+ }
311
299
 
312
- await screenStream.waitForDone();
313
- await Promise.all(promises);
314
- resolve();
300
+ this.emit('event', event);
315
301
  });
302
+
303
+ await screenStream.waitForDone();
304
+ await Promise.all(promises);
305
+ await this.eventQueue.wait();
316
306
  }
317
307
 
318
308
  private async resolveReferences(content: string) {
@@ -19,7 +19,7 @@ function normalizePath(path: string) {
19
19
  return path
20
20
  .replace(/\?.*$/gi, '')
21
21
  .replace(/:[a-z][a-z_]*\b/gi, '*')
22
- .replace(/\{[a-z]+}/gi, '*');
22
+ .replace(/\{[a-z-.]+}/gi, '*');
23
23
  }
24
24
 
25
25
  export async function writePageToDisk(systemId: string, event: StormEventPage) {
@@ -69,13 +69,8 @@ export async function writeImageToDisk(systemId: string, event: StormImage, prom
69
69
  }
70
70
 
71
71
  export function hasPageOnDisk(systemId: string, method: string, path: string) {
72
- if (!systemId || !method || !path) {
73
- return false;
74
- }
75
- const baseDir = getSystemBaseDir(systemId);
76
- const filePath = getFilePath(method);
77
- const fullPath = Path.join(baseDir, normalizePath(path), filePath);
78
- return FS.existsSync(fullPath);
72
+ const fullPath = resolveReadPath(systemId, method, path);
73
+ return !!fullPath && FS.existsSync(fullPath);
79
74
  }
80
75
 
81
76
  export function getSystemBaseDir(systemId: string) {
@@ -114,7 +109,7 @@ export function resolveReadPath(systemId: string, path: string, method: string)
114
109
 
115
110
  let currentPath = '';
116
111
 
117
- for (let part in parts) {
112
+ for (let part of parts) {
118
113
  const thisPath = Path.join(currentPath, part);
119
114
  const starPath = Path.join(currentPath, '*');
120
115
  const thisPathDir = Path.join(baseDir, thisPath);
@@ -130,7 +125,6 @@ export function resolveReadPath(systemId: string, path: string, method: string)
130
125
  continue;
131
126
  }
132
127
 
133
- console.log('Path not found', thisPathDir, starPathDir);
134
128
  // Path not found
135
129
  return null;
136
130
  }
@@ -150,15 +144,101 @@ export function readPageFromDiskAsString(systemId: string, path: string, method:
150
144
  export function readPageFromDisk(systemId: string, path: string, method: string, res: Response) {
151
145
  const filePath = resolveReadPath(systemId, path, method);
152
146
  if (!filePath || !FS.existsSync(filePath)) {
153
- res.status(404).send('Page not found');
147
+ if (method === 'HEAD') {
148
+ // For HEAD requests, only return the status and headers
149
+ res.status(202).set('Retry-After', '3').end();
150
+ } else {
151
+ // For GET requests, return the fallback HTML with status 202
152
+ res.status(202).set('Retry-After', '3').send(getFallbackHtml(path, method));
153
+ }
154
154
  return;
155
155
  }
156
156
 
157
157
  res.type(filePath.split('.').pop() as string);
158
158
 
159
159
  const content = FS.readFileSync(filePath);
160
- res.write(content);
161
- res.end();
160
+
161
+ if (method === 'HEAD') {
162
+ // For HEAD requests, just end the response after setting headers
163
+ res.status(200).end();
164
+ } else {
165
+ // For GET requests, return the full content
166
+ res.write(content);
167
+ res.end();
168
+ }
169
+ }
170
+
171
+ function getFallbackHtml(path: string, method: string): string {
172
+ return `
173
+ <!DOCTYPE html>
174
+ <html lang="en">
175
+
176
+ <head>
177
+ <meta charset="UTF-8">
178
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
179
+ <title>Page Not Ready</title>
180
+ <style>
181
+ @import url('https://fonts.googleapis.com/css2?family=Roboto:wght@400;600&display=swap');
182
+
183
+ body {
184
+ margin: 0;
185
+ padding: 0;
186
+ display: flex;
187
+ align-items: center;
188
+ justify-content: center;
189
+ height: 100vh;
190
+ font-family: 'Roboto', sans-serif;
191
+ background: #1E1F20;
192
+ color: white;
193
+ text-align: center;
194
+ }
195
+
196
+ h1 {
197
+ font-size: 2rem;
198
+ font-weight: 600;
199
+ margin-bottom: 1rem;
200
+ }
201
+
202
+ p {
203
+ font-size: 1rem;
204
+ font-weight: 400;
205
+ }
206
+ </style>
207
+ </head>
208
+
209
+ <body>
210
+ <div>
211
+ <h1>Page Not Ready</h1>
212
+ <p>Henrik is still working on this page. Please wait...</p>
213
+ </div>
214
+ <script>
215
+ const checkInterval = 3000;
216
+ function checkPageReady() {
217
+ fetch('${path}', { method: 'HEAD' })
218
+ .then(response => {
219
+ if (response.status === 200) {
220
+ // The page is ready, reload to fetch it
221
+ window.location.reload();
222
+ } else if (response.status === 202) {
223
+ const retryAfter = response.headers.get('Retry-After');
224
+ const retryInterval = retryAfter ? parseInt(retryAfter) * 1000 : 3000;
225
+ setTimeout(checkPageReady, retryInterval);
226
+ } else {
227
+ // Handle other unexpected statuses
228
+ setTimeout(checkPageReady, 3000);
229
+ }
230
+ })
231
+ .catch(error => {
232
+ console.error('Error checking page status:', error);
233
+ setTimeout(checkPageReady, checkInterval);
234
+ });
235
+ }
236
+ setTimeout(checkPageReady, checkInterval);
237
+ </script>
238
+ </body>
239
+
240
+ </html>
241
+ `;
162
242
  }
163
243
 
164
244
  export interface Conversation {
@@ -102,24 +102,17 @@ router.post('/ui/screen', async (req: KapetaBodyRequest, res: Response) => {
102
102
 
103
103
  const promises: Promise<void>[] = [];
104
104
 
105
- queue.on('page', (data) => {
106
- if (systemId) {
107
- promises.push(sendPageEvent(systemId, data, res));
108
- }
109
- });
105
+ queue.on('page', (data) => (systemId ? sendPageEvent(systemId, data, res) : undefined));
110
106
 
111
- queue.on('image', async (screenData, prompt, future) => {
107
+ queue.on('image', async (screenData, prompt) => {
112
108
  if (!systemId) {
113
109
  return;
114
110
  }
115
111
  try {
116
- const promise = handleImageEvent(systemId, screenData, prompt);
117
- promises.push(promise);
118
- await promise;
119
- future.resolve();
112
+ await handleImageEvent(systemId, screenData, prompt);
120
113
  } catch (e) {
121
114
  console.error('Failed to handle image event', e);
122
- future.reject(e);
115
+ throw e;
123
116
  }
124
117
  });
125
118
 
@@ -229,19 +222,16 @@ router.post('/:handle/ui/iterative', async (req: KapetaBodyRequest, res: Respons
229
222
  pageQueue.cancel();
230
223
  });
231
224
 
232
- pageQueue.on('page', (screenData: StormEventPage) => {
233
- pageEventPromises.push(sendPageEvent(landingPagesStream.getConversationId(), screenData, res));
234
- });
225
+ pageQueue.on('page', (screenData: StormEventPage) =>
226
+ sendPageEvent(landingPagesStream.getConversationId(), screenData, res)
227
+ );
235
228
 
236
- pageQueue.on('image', async (screenData, prompt, future) => {
229
+ pageQueue.on('image', async (screenData, prompt) => {
237
230
  try {
238
- const promise = handleImageEvent(landingPagesStream.getConversationId(), screenData, prompt);
239
- pageEventPromises.push(promise);
240
- await promise;
241
- future.resolve();
231
+ await handleImageEvent(landingPagesStream.getConversationId(), screenData, prompt);
242
232
  } catch (e) {
243
233
  console.error('Failed to handle image event', e);
244
- future.reject(e);
234
+ throw e;
245
235
  }
246
236
  });
247
237
 
@@ -420,25 +410,18 @@ router.post('/:handle/ui', async (req: KapetaBodyRequest, res: Response) => {
420
410
  created: Date.now(),
421
411
  });
422
412
 
423
- const pagePromises: Promise<void>[] = [];
424
413
  onRequestAborted(req, res, () => {
425
414
  queue.cancel();
426
415
  });
427
416
 
428
- const pageEventPromises: Promise<void>[] = [];
429
- queue.on('page', (screenData: StormEventPage) => {
430
- pageEventPromises.push(sendPageEvent(outerConversationId, screenData, res));
431
- });
417
+ queue.on('page', (screenData: StormEventPage) => sendPageEvent(outerConversationId, screenData, res));
432
418
 
433
- queue.on('image', async (screenData, prompt, future) => {
419
+ queue.on('image', async (screenData, prompt) => {
434
420
  try {
435
- const promise = handleImageEvent(outerConversationId, screenData, prompt);
436
- pageEventPromises.push(promise);
437
- await promise;
438
- future.resolve();
421
+ await handleImageEvent(outerConversationId, screenData, prompt);
439
422
  } catch (e) {
440
423
  console.error('Failed to handle image event', e);
441
- future.reject(e);
424
+ throw e;
442
425
  }
443
426
  });
444
427
 
@@ -447,8 +430,8 @@ router.post('/:handle/ui', async (req: KapetaBodyRequest, res: Response) => {
447
430
  });
448
431
 
449
432
  for (const screen of Object.values(uniqueUserJourneyScreens)) {
450
- pagePromises.push(
451
- queue.addPrompt({
433
+ queue
434
+ .addPrompt({
452
435
  prompt: screen.requirements,
453
436
  method: screen.method,
454
437
  path: screen.path,
@@ -459,7 +442,9 @@ router.post('/:handle/ui', async (req: KapetaBodyRequest, res: Response) => {
459
442
  storage_prefix: outerConversationId + '_',
460
443
  theme,
461
444
  })
462
- );
445
+ .catch((e) => {
446
+ console.error('Failed to generate page for screen %s', screen.name, e);
447
+ });
463
448
  }
464
449
 
465
450
  if (userJourneysStream.isAborted()) {
@@ -467,8 +452,6 @@ router.post('/:handle/ui', async (req: KapetaBodyRequest, res: Response) => {
467
452
  }
468
453
 
469
454
  await queue.wait();
470
- await Promise.allSettled(pagePromises);
471
- await Promise.allSettled(pageEventPromises);
472
455
 
473
456
  sendDone(res);
474
457
  } catch (err) {