@kapeta/local-cluster-service 0.61.2 → 0.62.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.
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Copyright 2023 Kapeta Inc.
3
+ * SPDX-License-Identifier: BUSL-1.1
4
+ */
5
+
6
+ import uuid from 'node-uuid';
7
+ import { stormClient, UIPagePrompt } from './stormClient';
8
+ import { ReferenceClassification, StormEvent, StormEventPage, StormEventReferenceClassification } from './events';
9
+ import { EventEmitter } from 'node:events';
10
+ import { PromiseQueue } from './PromiseQueue';
11
+
12
+ export class PageQueue extends EventEmitter {
13
+ private readonly queue: PromiseQueue;
14
+ private readonly systemId: string;
15
+ private readonly references: Map<string, PageGenerator> = new Map();
16
+
17
+ constructor(systemId: string, concurrency: number = 5) {
18
+ super();
19
+ this.systemId = systemId;
20
+ this.queue = new PromiseQueue(concurrency);
21
+ }
22
+
23
+ on(event: 'event', listener: (data: StormEvent) => void): this;
24
+ on(event: 'page', listener: (data: StormEventPage) => void): this;
25
+
26
+ on(event: string, listener: (...args: any[]) => void): this {
27
+ return super.on(event, listener);
28
+ }
29
+
30
+ emit(type: 'event', event: StormEvent): boolean;
31
+ emit(type: 'page', event: StormEventPage): boolean;
32
+ emit(eventName: string | symbol, ...args: any[]): boolean {
33
+ return super.emit(eventName, ...args);
34
+ }
35
+
36
+ public addPrompt(initialPrompt: UIPagePrompt) {
37
+ if (this.references.has(initialPrompt.path)) {
38
+ console.log('Ignoring duplicate prompt', initialPrompt.path);
39
+ return Promise.resolve();
40
+ }
41
+ console.log('processing prompt', initialPrompt.path);
42
+ const generator = new PageGenerator(initialPrompt);
43
+ this.references.set(initialPrompt.path, generator);
44
+ return this.addPageGenerator(generator);
45
+ }
46
+
47
+ private async addPageGenerator(generator: PageGenerator) {
48
+ generator.on('event', (event: StormEvent) => this.emit('event', event));
49
+ generator.on('page_refs', ({ event, references }) => {
50
+ this.emit('page', event);
51
+ references.forEach((reference) => {
52
+ if (
53
+ reference.url.startsWith('#') ||
54
+ reference.url.startsWith('javascript:') ||
55
+ reference.url.startsWith('http://') ||
56
+ reference.url.startsWith('https://')
57
+ ) {
58
+ return;
59
+ }
60
+
61
+ switch (reference.type) {
62
+ case 'image':
63
+ console.log('Ignoring image reference', reference);
64
+ break;
65
+ case 'css':
66
+ case 'javascript':
67
+ //console.log('Ignoring reference', reference);
68
+ break;
69
+ case 'html':
70
+ console.log('Adding page generator for', reference);
71
+ const paths = Array.from(this.references.keys());
72
+ this.addPrompt({
73
+ name: reference.name,
74
+ title: reference.title,
75
+ path: reference.url,
76
+ method: 'GET',
77
+ storage_prefix: this.systemId + '_',
78
+ prompt:
79
+ `Implement a page for ${reference.name} at ${reference.url} with the following description: ${reference.description}.\n` +
80
+ `The page was referenced from this page: \`\`\`html\n${event.payload.content}\n\`\`\`\n` +
81
+ (paths.length > 0
82
+ ? `\nThese paths are already implemented:\n- ${paths.join('\n - ')}\n\n`
83
+ : ''),
84
+ description: reference.description,
85
+ filename: '',
86
+ });
87
+ break;
88
+ }
89
+ });
90
+ });
91
+ return this.queue.add(() => generator.generate());
92
+ }
93
+
94
+ public cancel() {
95
+ this.queue.cancel();
96
+ }
97
+
98
+ public wait() {
99
+ return this.queue.wait();
100
+ }
101
+ }
102
+
103
+ export class PageGenerator extends EventEmitter {
104
+ private readonly conversationId: string;
105
+ private prompt: UIPagePrompt;
106
+
107
+ constructor(prompt: UIPagePrompt, conversationId: string = uuid.v4()) {
108
+ super();
109
+ this.conversationId = conversationId;
110
+ this.prompt = prompt;
111
+ }
112
+
113
+ on(event: 'event', listener: (data: StormEvent) => void): this;
114
+ on(
115
+ event: 'page_refs',
116
+ listener: (data: { event: StormEventPage; references: ReferenceClassification[] }) => void
117
+ ): this;
118
+
119
+ on(event: string, listener: (...args: any[]) => void): this {
120
+ return super.on(event, listener);
121
+ }
122
+
123
+ emit(type: 'event', event: StormEvent): boolean;
124
+ emit(type: 'page_refs', event: { event: StormEventPage; references: ReferenceClassification[] }): boolean;
125
+ emit(eventName: string | symbol, ...args: any[]): boolean {
126
+ return super.emit(eventName, ...args);
127
+ }
128
+
129
+ public async generate() {
130
+ return new Promise<void>(async (resolve) => {
131
+ const screenStream = await stormClient.createUIPage(this.prompt, this.conversationId);
132
+
133
+ const promises: Promise<void>[] = [];
134
+
135
+ screenStream.on('data', (event: StormEvent) => {
136
+ if (event.type === 'PAGE') {
137
+ event.payload.conversationId = this.conversationId;
138
+
139
+ promises.push(
140
+ (async () => {
141
+ const references = await this.resolveReferences(event.payload.content);
142
+ //console.log('Resolved references for page', references, event.payload);
143
+ this.emit('page_refs', {
144
+ event,
145
+ references,
146
+ });
147
+ })()
148
+ );
149
+ return;
150
+ }
151
+
152
+ this.emit('event', event);
153
+ });
154
+
155
+ screenStream.on('end', () => {
156
+ Promise.allSettled(promises).finally(resolve);
157
+ });
158
+
159
+ await screenStream.waitForDone();
160
+ });
161
+ }
162
+
163
+ private async resolveReferences(content: string) {
164
+ const referenceStream = await stormClient.classifyUIReferences(content);
165
+
166
+ const references: ReferenceClassification[] = [];
167
+
168
+ referenceStream.on('data', (referenceData: StormEvent) => {
169
+ if (referenceData.type !== 'REF_CLASSIFICATION') {
170
+ return;
171
+ }
172
+
173
+ //console.log('Processing reference classification', referenceData);
174
+ references.push(referenceData.payload);
175
+ });
176
+
177
+ await referenceStream.waitForDone();
178
+
179
+ return references;
180
+ }
181
+ }
@@ -406,6 +406,40 @@ export interface StormEventPromptImprove {
406
406
  };
407
407
  }
408
408
 
409
+ export interface LandingPage {
410
+ name: string;
411
+ title: string;
412
+ filename: string;
413
+ create_prompt: string;
414
+ path: string;
415
+ archetype: string;
416
+ requires_authentication: boolean;
417
+ }
418
+
419
+ // Event for defining a landing pages
420
+ export interface StormEventLandingPage {
421
+ type: 'LANDING_PAGE';
422
+ reason: string;
423
+ created: number;
424
+ payload: LandingPage;
425
+ }
426
+
427
+ export interface ReferenceClassification {
428
+ name: string;
429
+ title: string;
430
+ url: string;
431
+ description: string;
432
+ type: 'image' | 'css' | 'javascript' | 'html';
433
+ source: 'local' | 'cdn' | 'example';
434
+ }
435
+ // Event for reference classification
436
+ export interface StormEventReferenceClassification {
437
+ type: 'REF_CLASSIFICATION';
438
+ reason: string;
439
+ created: number;
440
+ payload: ReferenceClassification;
441
+ }
442
+
409
443
  export type StormEvent =
410
444
  | StormEventCreateBlock
411
445
  | StormEventCreateConnection
@@ -435,4 +469,6 @@ export type StormEvent =
435
469
  | StormEventUIShell
436
470
  | StormEventPage
437
471
  | StormEventPageUrl
438
- | StormEventPromptImprove;
472
+ | StormEventPromptImprove
473
+ | StormEventLandingPage
474
+ | StormEventReferenceClassification;
@@ -7,18 +7,23 @@ import { Response } from 'express';
7
7
  import os from 'node:os';
8
8
  import Path from 'path';
9
9
  import FS from 'fs-extra';
10
+ import FSExtra from 'fs-extra';
11
+ import { ConversationItem } from './stream';
12
+ import exp from 'node:constants';
10
13
 
11
14
  export const SystemIdHeader = 'System-Id';
12
15
 
16
+ function normalizePath(path: string) {
17
+ return path
18
+ .replace(/\?.*$/gi, '')
19
+ .replace(/:[a-z][a-z_]*\b/gi, '*')
20
+ .replace(/\{[a-z]+}/gi, '*');
21
+ }
22
+
13
23
  export async function writePageToDisk(systemId: string, event: StormEventPage) {
14
- const path = Path.join(
15
- os.tmpdir(),
16
- 'ai-systems',
17
- systemId,
18
- event.payload.path,
19
- event.payload.method.toLowerCase(),
20
- 'index.html'
21
- );
24
+ const baseDir = getBaseDir(systemId);
25
+ const filePath = getFilePath(event.payload.method);
26
+ const path = Path.join(baseDir, normalizePath(event.payload.path), filePath);
22
27
  await FS.ensureDir(Path.dirname(path));
23
28
  await FS.writeFile(path, event.payload.content);
24
29
 
@@ -29,9 +34,58 @@ export async function writePageToDisk(systemId: string, event: StormEventPage) {
29
34
  };
30
35
  }
31
36
 
37
+ function getBaseDir(systemId: string) {
38
+ return Path.join(os.tmpdir(), 'ai-systems', systemId);
39
+ }
40
+
41
+ function getFilePath(method: string) {
42
+ return Path.join(method.toLowerCase(), 'index.html');
43
+ }
44
+
45
+ export function resolveReadPath(systemId: string, path: string, method: string) {
46
+ const baseDir = getBaseDir(systemId);
47
+
48
+ path = normalizePath(path);
49
+
50
+ const filePath = getFilePath(method);
51
+
52
+ const fullPath = Path.join(baseDir, path, filePath);
53
+
54
+ if (FS.existsSync(fullPath)) {
55
+ return fullPath;
56
+ }
57
+
58
+ const parts = path.split(/\*/g);
59
+
60
+ let currentPath = '';
61
+
62
+ for (let part in parts) {
63
+ const thisPath = Path.join(currentPath, part);
64
+ const starPath = Path.join(currentPath, '*');
65
+ const thisPathDir = Path.join(baseDir, thisPath);
66
+ const starPathDir = Path.join(baseDir, starPath);
67
+
68
+ if (FS.existsSync(thisPathDir)) {
69
+ currentPath = thisPath;
70
+ continue;
71
+ }
72
+
73
+ if (FS.existsSync(starPathDir)) {
74
+ currentPath = starPath;
75
+ continue;
76
+ }
77
+
78
+ console.log('Path not found', thisPathDir, starPathDir);
79
+ // Path not found
80
+ return null;
81
+ }
82
+
83
+ return Path.join(baseDir, currentPath, filePath);
84
+ }
85
+
32
86
  export function readPageFromDiskAsString(systemId: string, path: string, method: string) {
33
- const filePath = Path.join(os.tmpdir(), 'ai-systems', systemId, path, method.toLowerCase(), 'index.html');
34
- if (!FS.existsSync(filePath)) {
87
+ const filePath = resolveReadPath(systemId, path, method);
88
+ if (!filePath || !FS.existsSync(filePath)) {
35
89
  return null;
36
90
  }
37
91
 
@@ -39,8 +93,8 @@ export function readPageFromDiskAsString(systemId: string, path: string, method:
39
93
  }
40
94
 
41
95
  export function readPageFromDisk(systemId: string, path: string, method: string, res: Response) {
42
- const filePath = Path.join(os.tmpdir(), 'ai-systems', systemId, path, method.toLowerCase(), 'index.html');
43
- if (!FS.existsSync(filePath)) {
96
+ const filePath = resolveReadPath(systemId, path, method);
97
+ if (!filePath || !FS.existsSync(filePath)) {
44
98
  res.status(404).send('Page not found');
45
99
  return;
46
100
  }
@@ -51,3 +105,25 @@ export function readPageFromDisk(systemId: string, path: string, method: string,
51
105
  res.write(content);
52
106
  res.end();
53
107
  }
108
+
109
+ export interface Conversation {
110
+ messages: ConversationItem[];
111
+ variantId: string;
112
+ type: 'page';
113
+ filename: string;
114
+ }
115
+
116
+ export function readConversationFromFile(filename: string): Conversation[] {
117
+ if (!FS.existsSync(filename)) {
118
+ return [];
119
+ }
120
+ const content = FS.readFileSync(filename).toString();
121
+ if (!content.trim()) {
122
+ return [];
123
+ }
124
+ return content.split(/\n/g).map((line) => JSON.parse(line) as Conversation);
125
+ }
126
+
127
+ export function writeConversationToFile(filename: string, conversations: Conversation[]) {
128
+ FS.writeFileSync(filename, conversations.map((conversation) => JSON.stringify(conversation)).join('\n'));
129
+ }
@@ -12,8 +12,17 @@ import { corsHandler } from '../middleware/cors';
12
12
  import { stringBody } from '../middleware/stringBody';
13
13
  import { KapetaBodyRequest } from '../types';
14
14
  import { StormCodegenRequest, StormContextRequest, StormCreateBlockRequest, StormStream } from './stream';
15
- import { ConversationIdHeader, stormClient, UIPagePrompt, UIPageEditPrompt, UIPageEditRequest } from './stormClient';
15
+
16
+ import {
17
+ ConversationIdHeader,
18
+ stormClient,
19
+ UIPagePrompt,
20
+ UIPageEditPrompt,
21
+ UIPageEditRequest,
22
+ UIPageSamplePrompt,
23
+ } from './stormClient';
16
24
  import { Page, StormEvent, StormEventPage, StormEventPhaseType, UIShell, UserJourneyScreen } from './events';
25
+
17
26
  import {
18
27
  createPhaseEndEvent,
19
28
  createPhaseStartEvent,
@@ -25,8 +34,17 @@ import { StormCodegen } from './codegen';
25
34
  import { assetManager } from '../assetManager';
26
35
  import uuid from 'node-uuid';
27
36
  import { PromiseQueue } from './PromiseQueue';
28
- import { readPageFromDisk, readPageFromDiskAsString, SystemIdHeader, writePageToDisk } from './page-utils';
37
+ import {
38
+ readConversationFromFile,
39
+ readPageFromDisk,
40
+ readPageFromDiskAsString,
41
+ SystemIdHeader,
42
+ writeConversationToFile,
43
+ writePageToDisk,
44
+ } from './page-utils';
29
45
  import { UIServer } from './UIServer';
46
+ import { PageQueue } from './PageGenerator';
47
+ import FSExtra from 'fs-extra';
30
48
 
31
49
  const UI_SERVERS: { [key: string]: UIServer } = {};
32
50
  const router = Router();
@@ -34,6 +52,8 @@ const router = Router();
34
52
  router.use('/', corsHandler);
35
53
  router.use('/', stringBody);
36
54
 
55
+ const samplesBaseDir = Path.join(__dirname, 'samples');
56
+
37
57
  function convertPageEvent(screenData: StormEvent, innerConversationId: string, mainConversationId: string): StormEvent {
38
58
  if (screenData.type === 'PAGE') {
39
59
  const server: UIServer | undefined = UI_SERVERS[mainConversationId];
@@ -75,22 +95,25 @@ router.post('/ui/screen', async (req: KapetaBodyRequest, res: Response) => {
75
95
  const aiRequest: UIPagePrompt = JSON.parse(req.stringBody ?? '{}');
76
96
  aiRequest.storage_prefix = systemId ? systemId + '_' : 'mock_';
77
97
 
78
- const screenStream = await stormClient.createUIPage(aiRequest, conversationId);
98
+ res.set('Content-Type', 'application/x-ndjson');
99
+ res.set('Access-Control-Expose-Headers', ConversationIdHeader);
100
+ res.set(ConversationIdHeader, conversationId);
101
+
102
+ const parentConversationId = systemId ?? '';
79
103
 
104
+ const queue = new PageQueue(parentConversationId, 5);
80
105
  onRequestAborted(req, res, () => {
81
- screenStream.abort();
106
+ queue.cancel();
82
107
  });
83
108
 
84
- res.set('Content-Type', 'application/x-ndjson');
85
- res.set('Access-Control-Expose-Headers', ConversationIdHeader);
86
- res.set(ConversationIdHeader, screenStream.getConversationId());
109
+ await queue.addPrompt(aiRequest);
87
110
 
88
111
  const promises: Promise<void>[] = [];
89
- screenStream.on('data', (data: StormEvent) => {
112
+ queue.on('page', (data) => {
90
113
  switch (data.type) {
91
114
  case 'PAGE':
92
115
  console.log('Processing page event', data);
93
- data.payload.conversationId = screenStream.getConversationId();
116
+
94
117
  if (systemId) {
95
118
  promises.push(sendPageEvent(systemId, data, res));
96
119
  }
@@ -99,7 +122,7 @@ router.post('/ui/screen', async (req: KapetaBodyRequest, res: Response) => {
99
122
  sendEvent(res, data);
100
123
  });
101
124
 
102
- await waitForStormStream(screenStream);
125
+ await queue.wait();
103
126
  await Promise.allSettled(promises);
104
127
 
105
128
  sendDone(res);
@@ -125,6 +148,93 @@ router.delete('/:handle/ui', async (req: KapetaBodyRequest, res: Response) => {
125
148
  }
126
149
  });
127
150
 
151
+ router.post('/:handle/ui/iterative', async (req: KapetaBodyRequest, res: Response) => {
152
+ const handle = req.params.handle as string;
153
+ try {
154
+ const conversationId = req.headers[ConversationIdHeader.toLowerCase()] as string | undefined;
155
+
156
+ const aiRequest: StormContextRequest = JSON.parse(req.stringBody ?? '{}');
157
+
158
+ const landingPagesStream = await stormClient.createUILandingPages(aiRequest.prompt, conversationId);
159
+
160
+ onRequestAborted(req, res, () => {
161
+ landingPagesStream.abort();
162
+ });
163
+
164
+ res.set('Content-Type', 'application/x-ndjson');
165
+ res.set('Access-Control-Expose-Headers', ConversationIdHeader);
166
+
167
+ res.set(ConversationIdHeader, landingPagesStream.getConversationId());
168
+
169
+ const promises: { [key: string]: Promise<void> } = {};
170
+ const pageEventPromises: Promise<void>[] = [];
171
+ const systemId = landingPagesStream.getConversationId();
172
+ const pageQueue = new PageQueue(systemId, 5);
173
+
174
+ landingPagesStream.on('data', async (data: StormEvent) => {
175
+ try {
176
+ sendEvent(res, data);
177
+ if (data.type !== 'LANDING_PAGE') {
178
+ return;
179
+ }
180
+
181
+ if (landingPagesStream.isAborted()) {
182
+ return;
183
+ }
184
+ const landingPage = data.payload;
185
+ if (landingPage.name in promises) {
186
+ return;
187
+ }
188
+
189
+ // We add the landing pages to the prompt queue.
190
+ // These will then be analysed - creating further pages as needed
191
+ promises[landingPage.name] = pageQueue.addPrompt({
192
+ prompt: landingPage.create_prompt,
193
+ method: 'GET',
194
+ path: landingPage.path,
195
+ description: landingPage.create_prompt,
196
+ name: landingPage.name,
197
+ title: landingPage.title,
198
+ filename: landingPage.filename,
199
+ storage_prefix: systemId + '_',
200
+ });
201
+ } catch (e) {
202
+ console.error('Failed to process event', e);
203
+ }
204
+ });
205
+
206
+ UI_SERVERS[systemId] = new UIServer(systemId);
207
+ await UI_SERVERS[systemId].start();
208
+
209
+ onRequestAborted(req, res, () => {
210
+ pageQueue.cancel();
211
+ });
212
+
213
+ pageQueue.on('page', (screenData: StormEventPage) => {
214
+ pageEventPromises.push(sendPageEvent(landingPagesStream.getConversationId(), screenData, res));
215
+ });
216
+
217
+ pageQueue.on('event', (screenData: StormEvent) => {
218
+ sendEvent(res, screenData);
219
+ });
220
+
221
+ await waitForStormStream(landingPagesStream);
222
+ await pageQueue.wait();
223
+ await Promise.allSettled(pageEventPromises);
224
+
225
+ if (landingPagesStream.isAborted()) {
226
+ return;
227
+ }
228
+
229
+ sendDone(res);
230
+ } catch (err) {
231
+ sendError(err as Error, res);
232
+ if (!res.closed) {
233
+ res.end();
234
+ }
235
+ }
236
+ });
237
+
128
238
  router.post('/:handle/ui', async (req: KapetaBodyRequest, res: Response) => {
129
239
  const handle = req.params.handle as string;
130
240
  try {
@@ -223,75 +333,40 @@ router.post('/:handle/ui', async (req: KapetaBodyRequest, res: Response) => {
223
333
  await UI_SERVERS[outerConversationId].start();
224
334
 
225
335
  // Get the pages (5 at a time)
226
- const queue = new PromiseQueue(5);
227
-
336
+ const queue = new PageQueue(outerConversationId, 5);
337
+ const pagePromises: Promise<void>[] = [];
228
338
  onRequestAborted(req, res, () => {
229
339
  queue.cancel();
230
340
  });
231
341
 
342
+ const pageEventPromises: Promise<void>[] = [];
343
+ queue.on('page', (screenData: StormEventPage) => {
344
+ pageEventPromises.push(sendPageEvent(outerConversationId, screenData, res));
345
+ });
346
+
347
+ queue.on('event', (screenData: StormEvent) => {
348
+ sendEvent(res, screenData);
349
+ });
350
+
232
351
  for (const screen of Object.values(uniqueUserJourneyScreens)) {
233
- void queue.add(
234
- () =>
235
- new Promise(async (resolve, reject) => {
236
- try {
237
- const innerConversationId = uuid.v4();
238
- const screenStream = await stormClient.createUIPage(
239
- {
240
- prompt: screen.requirements,
241
- method: screen.method,
242
- path: screen.path,
243
- description: screen.requirements,
244
- name: screen.name,
245
- title: screen.title,
246
- filename: screen.filename,
247
- storage_prefix: outerConversationId + '_',
248
- shell_page: uiShells.find((shell) => shell.screens.includes(screen.name))?.content,
249
- },
250
- innerConversationId
251
- );
252
-
253
- const promiseList: Promise<void>[] = [];
254
- screenStream.on('data', (screenData: StormEvent) => {
255
- if (screenData.type === 'PAGE') {
256
- promiseList.push(
257
- sendPageEvent(
258
- outerConversationId,
259
- {
260
- ...screenData,
261
- payload: {
262
- ...screenData.payload,
263
- conversationId: innerConversationId,
264
- },
265
- },
266
- res
267
- )
268
- );
269
- } else {
270
- sendEvent(res, screenData);
271
- }
272
- });
273
-
274
- screenStream.on('end', async () => {
275
- try {
276
- await Promise.allSettled(promiseList).finally(() => resolve(true));
277
- } catch (error) {
278
- console.error('Failed to process screen', error);
279
- }
280
- });
281
-
282
- screenStream.on('error', (error) => {
283
- console.error('Error on screenStream', error);
284
- screenStream.abort();
285
- });
286
- } catch (e) {
287
- console.error('Failed to process screen', e);
288
- reject(e);
289
- }
290
- })
352
+ pagePromises.push(
353
+ queue.addPrompt({
354
+ prompt: screen.requirements,
355
+ method: screen.method,
356
+ path: screen.path,
357
+ description: screen.requirements,
358
+ name: screen.name,
359
+ title: screen.title,
360
+ filename: screen.filename,
361
+ storage_prefix: outerConversationId + '_',
362
+ shell_page: uiShells.find((shell) => shell.screens.includes(screen.name))?.content,
363
+ })
291
364
  );
292
365
  }
293
366
 
294
367
  await queue.wait();
368
+ await Promise.allSettled(pagePromises);
369
+ await Promise.allSettled(pageEventPromises);
295
370
 
296
371
  if (userJourneysStream.isAborted()) {
297
372
  return;
@@ -581,6 +656,7 @@ function sendError(err: Error, res: Response) {
581
656
  res.status(400).send({ error: err.message });
582
657
  }
583
658
  }
659
+
584
660
  function waitForStormStream(result: StormStream) {
585
661
  return result.waitForDone();
586
662
  }