@kapeta/local-cluster-service 0.65.0 → 0.67.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.
@@ -5,22 +5,27 @@
5
5
 
6
6
  import uuid from 'node-uuid';
7
7
  import { stormClient, UIPagePrompt } from './stormClient';
8
- import {
9
- ReferenceClassification,
10
- StormEvent,
11
- StormEventPage,
12
- StormEventReferenceClassification,
13
- UIShell,
14
- } from './events';
8
+ import { ReferenceClassification, StormEvent, StormEventPage, StormImage, UIShell } from './events';
15
9
  import { EventEmitter } from 'node:events';
16
- import { PromiseQueue } from './PromiseQueue';
10
+ import { createFuture, Future, FuturePromise, PromiseQueue } from './PromiseQueue';
17
11
  import { hasPageOnDisk } from './page-utils';
18
12
 
13
+ export interface ImagePrompt {
14
+ name: string;
15
+ description: string;
16
+ source: 'local' | 'cdn' | 'example';
17
+ title: string;
18
+ type: 'image' | 'css' | 'javascript' | 'html';
19
+ url: string;
20
+ content: string;
21
+ }
22
+
19
23
  export class PageQueue extends EventEmitter {
20
24
  private readonly queue: PromiseQueue;
21
25
  private readonly systemId: string;
22
26
  private readonly references: Map<string, PageGenerator> = new Map();
23
27
  private uiShells: UIShell[] = [];
28
+ private theme = '';
24
29
 
25
30
  constructor(systemId: string, concurrency: number = 5) {
26
31
  super();
@@ -30,6 +35,7 @@ export class PageQueue extends EventEmitter {
30
35
 
31
36
  on(event: 'event', listener: (data: StormEvent) => void): this;
32
37
  on(event: 'page', listener: (data: StormEventPage) => void): this;
38
+ on(event: 'image', listener: (data: StormImage, source: ImagePrompt, future: FuturePromise<void>) => void): this;
33
39
 
34
40
  on(event: string, listener: (...args: any[]) => void): this {
35
41
  return super.on(event, listener);
@@ -37,6 +43,7 @@ export class PageQueue extends EventEmitter {
37
43
 
38
44
  emit(type: 'event', event: StormEvent): boolean;
39
45
  emit(type: 'page', event: StormEventPage): boolean;
46
+ emit(type: 'image', event: StormImage, source: ImagePrompt, future: FuturePromise<void>): boolean;
40
47
  emit(eventName: string | symbol, ...args: any[]): boolean {
41
48
  return super.emit(eventName, ...args);
42
49
  }
@@ -45,24 +52,29 @@ export class PageQueue extends EventEmitter {
45
52
  this.uiShells.push(uiShell);
46
53
  }
47
54
 
55
+ public setUiTheme(theme: string) {
56
+ this.theme = theme;
57
+ }
58
+
48
59
  public addPrompt(
49
60
  initialPrompt: Omit<UIPagePrompt, 'shell_page'>,
50
61
  conversationId: string = uuid.v4(),
51
62
  overwrite: boolean = false
52
63
  ) {
53
64
  if (!overwrite && this.references.has(initialPrompt.path)) {
54
- console.log('Ignoring duplicate prompt', initialPrompt.path);
65
+ //console.log('Ignoring duplicate prompt', initialPrompt.path);
55
66
  return Promise.resolve();
56
67
  }
57
68
 
58
69
  if (!overwrite && hasPageOnDisk(this.systemId, initialPrompt.method, initialPrompt.path)) {
59
- console.log('Ignoring prompt with existing page', initialPrompt.path);
70
+ //console.log('Ignoring prompt with existing page', initialPrompt.path);
60
71
  return Promise.resolve();
61
72
  }
62
73
 
63
74
  const prompt: UIPagePrompt = {
64
75
  ...initialPrompt,
65
76
  shell_page: this.uiShells.find((shell) => shell.screens.includes(initialPrompt.name))?.content,
77
+ theme: this.theme,
66
78
  };
67
79
 
68
80
  const generator = new PageGenerator(prompt, conversationId);
@@ -73,9 +85,8 @@ export class PageQueue extends EventEmitter {
73
85
 
74
86
  private async addPageGenerator(generator: PageGenerator) {
75
87
  generator.on('event', (event: StormEvent) => this.emit('event', event));
76
- generator.on('page_refs', ({ event, references }) => {
77
- this.emit('page', event);
78
- references.forEach((reference) => {
88
+ generator.on('page_refs', async ({ event, references }) => {
89
+ const promises = references.map(async (reference) => {
79
90
  if (
80
91
  reference.url.startsWith('#') ||
81
92
  reference.url.startsWith('javascript:') ||
@@ -87,14 +98,17 @@ export class PageQueue extends EventEmitter {
87
98
 
88
99
  switch (reference.type) {
89
100
  case 'image':
90
- console.log('Ignoring image reference', reference);
101
+ await this.addImagePrompt({
102
+ ...reference,
103
+ content: event.payload.content,
104
+ });
91
105
  break;
92
106
  case 'css':
93
107
  case 'javascript':
94
108
  //console.log('Ignoring reference', reference);
95
109
  break;
96
110
  case 'html':
97
- console.log('Adding page generator for', reference);
111
+ //console.log('Adding page generator for', reference);
98
112
  const paths = Array.from(this.references.keys());
99
113
  this.addPrompt({
100
114
  name: reference.name,
@@ -110,10 +124,14 @@ export class PageQueue extends EventEmitter {
110
124
  : ''),
111
125
  description: reference.description,
112
126
  filename: '',
127
+ theme: this.theme,
113
128
  });
114
129
  break;
115
130
  }
116
131
  });
132
+
133
+ await Promise.allSettled(promises);
134
+ this.emit('page', event);
117
135
  });
118
136
  return this.queue.add(() => generator.generate());
119
137
  }
@@ -125,6 +143,31 @@ export class PageQueue extends EventEmitter {
125
143
  public wait() {
126
144
  return this.queue.wait();
127
145
  }
146
+
147
+ private async addImagePrompt(prompt: ImagePrompt) {
148
+ const result = await stormClient.createImage(
149
+ `Create an image for the url "${prompt.url}" with this description: ${prompt.description}`.trim()
150
+ );
151
+
152
+ const futures: FuturePromise<void>[] = [];
153
+
154
+ result.on('data', async (event: StormEvent) => {
155
+ if (event.type === 'IMAGE') {
156
+ const future = createFuture();
157
+ futures.push(future);
158
+ this.emit('image', event, prompt, future);
159
+ setTimeout(() => {
160
+ if (!future.isResolved()) {
161
+ console.log('Image prompt timed out', prompt);
162
+ future.reject(new Error('Image prompt timed out'));
163
+ }
164
+ }, 30000);
165
+ }
166
+ });
167
+
168
+ await result.waitForDone();
169
+ await Promise.allSettled(futures.map((f) => f.promise));
170
+ }
128
171
  }
129
172
 
130
173
  export class PageGenerator extends EventEmitter {
@@ -4,6 +4,40 @@
4
4
  */
5
5
  export type Future<T> = () => Promise<T>;
6
6
 
7
+ export type FuturePromise<T> = {
8
+ promise: Promise<T>;
9
+ resolve: (value: T) => void;
10
+ reject: (reason: any) => void;
11
+ isResolved: () => boolean;
12
+ };
13
+
14
+ export function createFuture<T = void>(): FuturePromise<T> {
15
+ let resolved = false;
16
+ let resolve: (value: T) => void = () => {
17
+ resolved = true;
18
+ };
19
+ let reject: (reason: any) => void = () => {
20
+ resolved = true;
21
+ };
22
+
23
+ const promise = new Promise<T>((res, rej) => {
24
+ resolve = (value: T) => {
25
+ resolved = true;
26
+ res(value);
27
+ };
28
+ reject = (reason: any) => {
29
+ resolved = true;
30
+ rej(reason);
31
+ };
32
+ });
33
+ return {
34
+ promise,
35
+ resolve,
36
+ reject,
37
+ isResolved: () => resolved,
38
+ };
39
+ }
40
+
7
41
  type InternalFuture<T> = {
8
42
  execute: Future<T>;
9
43
  promise: Promise<T>;
@@ -3,10 +3,11 @@
3
3
  * SPDX-License-Identifier: BUSL-1.1
4
4
  */
5
5
  import express, { Express, Request, Response } from 'express';
6
- import { readPageFromDisk } from './page-utils';
6
+ import { getSystemBaseDir, readPageFromDisk } from './page-utils';
7
7
  import { clusterService } from '../clusterService';
8
8
  import { createServer, Server } from 'http';
9
9
  import { StormEventPage } from './events';
10
+ import { join } from 'path';
10
11
 
11
12
  export class UIServer {
12
13
  private readonly systemId: string;
@@ -30,6 +31,9 @@ export class UIServer {
30
31
  );
31
32
  });
32
33
 
34
+ // Make it possible to serve static assets
35
+ app.use(express.static(join(getSystemBaseDir(this.systemId), 'public'), { fallthrough: true }));
36
+
33
37
  app.all('/*', (req: Request, res: Response) => {
34
38
  readPageFromDisk(this.systemId, req.params[0], req.method, res);
35
39
  });
@@ -311,6 +311,15 @@ export interface StormEventDone {
311
311
  created: number;
312
312
  }
313
313
 
314
+ export interface StormImage {
315
+ type: 'IMAGE';
316
+ reason: string;
317
+ created: number;
318
+ payload: {
319
+ href: string;
320
+ };
321
+ }
322
+
314
323
  export interface StormEventDefinitionChange {
315
324
  type: 'DEFINITION_CHANGE';
316
325
  reason: string;
@@ -494,4 +503,5 @@ export type StormEvent =
494
503
  | StormEventLandingPage
495
504
  | StormEventReferenceClassification
496
505
  | StormEventApiBase
497
- | StormEventUIStarted;
506
+ | StormEventUIStarted
507
+ | StormImage;
@@ -2,7 +2,8 @@
2
2
  * Copyright 2023 Kapeta Inc.
3
3
  * SPDX-License-Identifier: BUSL-1.1
4
4
  */
5
- import { StormEventPage } from './events';
5
+ import { StormEventFileDone, StormEventPage, StormImage } from './events';
6
+
6
7
  import { Response } from 'express';
7
8
  import os from 'node:os';
8
9
  import Path from 'path';
@@ -10,6 +11,7 @@ import FS from 'fs-extra';
10
11
  import FSExtra from 'fs-extra';
11
12
  import { ConversationItem } from './stream';
12
13
  import exp from 'node:constants';
14
+ import { ImagePrompt } from './PageGenerator';
13
15
 
14
16
  export const SystemIdHeader = 'System-Id';
15
17
 
@@ -21,7 +23,7 @@ function normalizePath(path: string) {
21
23
  }
22
24
 
23
25
  export async function writePageToDisk(systemId: string, event: StormEventPage) {
24
- const baseDir = getBaseDir(systemId);
26
+ const baseDir = getSystemBaseDir(systemId);
25
27
  const filePath = getFilePath(event.payload.method);
26
28
  const path = Path.join(baseDir, normalizePath(event.payload.path), filePath);
27
29
  await FS.ensureDir(Path.dirname(path));
@@ -34,14 +36,46 @@ export async function writePageToDisk(systemId: string, event: StormEventPage) {
34
36
  };
35
37
  }
36
38
 
39
+ export async function writeAssetToDisk(systemId: string, event: StormEventFileDone) {
40
+ const baseDir = getSystemBaseDir(systemId);
41
+ const path = Path.join(baseDir, 'public', event.payload.filename);
42
+ await FS.ensureDir(Path.dirname(path));
43
+ await FS.writeFile(path, event.payload.content);
44
+
45
+ return {
46
+ path,
47
+ };
48
+ }
49
+
50
+ export async function writeImageToDisk(systemId: string, event: StormImage, prompt: ImagePrompt) {
51
+ const baseDir = getSystemBaseDir(systemId);
52
+ const path = Path.join(baseDir, normalizePath(prompt.url));
53
+
54
+ const response = await fetch(event.payload.href);
55
+ if (!response.ok || !response.body) {
56
+ throw new Error(`Failed to fetch image: ${event.payload.href}`);
57
+ }
58
+
59
+ await FS.ensureDir(Path.dirname(path));
60
+
61
+ const buffer = await response.arrayBuffer();
62
+ await FS.writeFile(path, Buffer.from(buffer));
63
+
64
+ console.log(`Image written to disk: ${event.payload.href} > ${path}`);
65
+
66
+ return {
67
+ path,
68
+ };
69
+ }
70
+
37
71
  export function hasPageOnDisk(systemId: string, method: string, path: string) {
38
- const baseDir = getBaseDir(systemId);
72
+ const baseDir = getSystemBaseDir(systemId);
39
73
  const filePath = getFilePath(method);
40
74
  const fullPath = Path.join(baseDir, normalizePath(path), filePath);
41
75
  return FS.existsSync(fullPath);
42
76
  }
43
77
 
44
- function getBaseDir(systemId: string) {
78
+ export function getSystemBaseDir(systemId: string) {
45
79
  return Path.join(os.tmpdir(), 'ai-systems', systemId);
46
80
  }
47
81
 
@@ -50,13 +84,24 @@ function getFilePath(method: string) {
50
84
  }
51
85
 
52
86
  export function resolveReadPath(systemId: string, path: string, method: string) {
53
- const baseDir = getBaseDir(systemId);
87
+ const baseDir = getSystemBaseDir(systemId);
54
88
 
55
89
  path = normalizePath(path);
56
90
 
57
- const filePath = getFilePath(method);
91
+ let fullPath = Path.join(baseDir, path);
92
+
93
+ //First check if there is a file at the exact path
94
+ try {
95
+ const stat = FS.statSync(fullPath);
96
+ if (stat && stat.isFile()) {
97
+ return fullPath;
98
+ }
99
+ } catch (e) {
100
+ // Ignore
101
+ }
58
102
 
59
- const fullPath = Path.join(baseDir, path, filePath);
103
+ const filePath = getFilePath(method);
104
+ fullPath = Path.join(baseDir, path, filePath);
60
105
 
61
106
  if (FS.existsSync(fullPath)) {
62
107
  return fullPath;
@@ -108,7 +153,7 @@ export function readPageFromDisk(systemId: string, path: string, method: string,
108
153
 
109
154
  res.type(filePath.split('.').pop() as string);
110
155
 
111
- const content = FS.readFileSync(filePath, 'utf8');
156
+ const content = FS.readFileSync(filePath);
112
157
  res.write(content);
113
158
  res.end();
114
159
  }
@@ -23,7 +23,7 @@ import {
23
23
  UIPageVoteRequest,
24
24
  UIPageGetVoteRequest,
25
25
  } from './stormClient';
26
- import { Page, StormEvent, StormEventPage, StormEventPhaseType, UserJourneyScreen } from './events';
26
+ import { Page, StormEvent, StormEventPage, StormEventPhaseType, StormImage, UserJourneyScreen } from './events';
27
27
 
28
28
  import {
29
29
  createPhaseEndEvent,
@@ -35,9 +35,17 @@ import {
35
35
  import { StormCodegen } from './codegen';
36
36
  import { assetManager } from '../assetManager';
37
37
  import uuid from 'node-uuid';
38
- import { readPageFromDisk, readPageFromDiskAsString, SystemIdHeader, writePageToDisk } from './page-utils';
38
+ import {
39
+ readPageFromDisk,
40
+ readPageFromDiskAsString,
41
+ SystemIdHeader,
42
+ writeAssetToDisk,
43
+ writeImageToDisk,
44
+ writePageToDisk,
45
+ } from './page-utils';
39
46
  import { UIServer } from './UIServer';
40
- import { PageQueue } from './PageGenerator';
47
+ import { randomUUID } from 'crypto';
48
+ import { ImagePrompt, PageQueue } from './PageGenerator';
41
49
 
42
50
  const UI_SERVERS: { [key: string]: UIServer } = {};
43
51
  const router = Router();
@@ -107,6 +115,21 @@ router.post('/ui/screen', async (req: KapetaBodyRequest, res: Response) => {
107
115
  }
108
116
  });
109
117
 
118
+ queue.on('image', async (screenData, prompt, future) => {
119
+ if (!systemId) {
120
+ return;
121
+ }
122
+ try {
123
+ const promise = handleImageEvent(systemId, screenData, prompt);
124
+ promises.push(promise);
125
+ await promise;
126
+ future.resolve();
127
+ } catch (e) {
128
+ console.error('Failed to handle image event', e);
129
+ future.reject(e);
130
+ }
131
+ });
132
+
110
133
  await queue.addPrompt(aiRequest, conversationId, true);
111
134
 
112
135
  await queue.wait();
@@ -185,6 +208,8 @@ router.post('/:handle/ui/iterative', async (req: KapetaBodyRequest, res: Respons
185
208
  title: landingPage.title,
186
209
  filename: landingPage.filename,
187
210
  storage_prefix: systemId + '_',
211
+ // TODO: Add themes to this request type
212
+ theme: '',
188
213
  });
189
214
  } catch (e) {
190
215
  console.error('Failed to process event', e);
@@ -202,6 +227,18 @@ router.post('/:handle/ui/iterative', async (req: KapetaBodyRequest, res: Respons
202
227
  pageEventPromises.push(sendPageEvent(landingPagesStream.getConversationId(), screenData, res));
203
228
  });
204
229
 
230
+ pageQueue.on('image', async (screenData, prompt, future) => {
231
+ try {
232
+ const promise = handleImageEvent(landingPagesStream.getConversationId(), screenData, prompt);
233
+ pageEventPromises.push(promise);
234
+ await promise;
235
+ future.resolve();
236
+ } catch (e) {
237
+ console.error('Failed to handle image event', e);
238
+ future.reject(e);
239
+ }
240
+ });
241
+
205
242
  pageQueue.on('event', (screenData: StormEvent) => {
206
243
  sendEvent(res, screenData);
207
244
  });
@@ -226,13 +263,13 @@ router.post('/:handle/ui/iterative', async (req: KapetaBodyRequest, res: Respons
226
263
  router.post('/:handle/ui', async (req: KapetaBodyRequest, res: Response) => {
227
264
  const handle = req.params.handle as string;
228
265
  try {
229
- const conversationId = req.headers[ConversationIdHeader.toLowerCase()] as string | undefined;
266
+ const outerConversationId =
267
+ (req.headers[ConversationIdHeader.toLowerCase()] as string | undefined) || randomUUID();
230
268
 
231
269
  const aiRequest: BasePromptRequest = JSON.parse(req.stringBody ?? '{}');
232
270
 
233
271
  // Get user journeys
234
- const userJourneysStream = await stormClient.createUIUserJourneys(aiRequest, conversationId);
235
- const outerConversationId = userJourneysStream.getConversationId();
272
+ const userJourneysStream = await stormClient.createUIUserJourneys(aiRequest, outerConversationId);
236
273
 
237
274
  onRequestAborted(req, res, () => {
238
275
  userJourneysStream.abort();
@@ -271,11 +308,53 @@ router.post('/:handle/ui', async (req: KapetaBodyRequest, res: Response) => {
271
308
  sendError(error, res);
272
309
  });
273
310
 
311
+ let theme = '';
312
+ try {
313
+ const themeStream = await stormClient.createTheme(aiRequest, outerConversationId);
314
+ onRequestAborted(req, res, () => {
315
+ themeStream.abort();
316
+ });
317
+
318
+ themeStream.on('data', (evt) => {
319
+ sendEvent(res, evt);
320
+ if (evt.type === 'FILE_DONE') {
321
+ theme = evt.payload.content;
322
+ writeAssetToDisk(outerConversationId, evt).catch((err) => {
323
+ sendEvent(res, {
324
+ type: 'ERROR_INTERNAL',
325
+ created: new Date().getTime(),
326
+ payload: { error: err.message },
327
+ reason: 'Failed to save theme',
328
+ });
329
+ });
330
+ }
331
+ });
332
+ themeStream.on('error', (error) => {
333
+ console.error(error);
334
+ sendEvent(res, {
335
+ type: 'ERROR_INTERNAL',
336
+ created: new Date().getTime(),
337
+ payload: { error: error.message },
338
+ reason: 'Failed to create theme',
339
+ });
340
+ });
341
+ await waitForStormStream(themeStream);
342
+ } catch (e: any) {
343
+ console.error('Failed to generate theme', e);
344
+ sendEvent(res, {
345
+ type: 'ERROR_INTERNAL',
346
+ created: new Date().getTime(),
347
+ payload: { error: e.message },
348
+ reason: 'Failed to create theme',
349
+ });
350
+ }
351
+
274
352
  await waitForStormStream(userJourneysStream);
275
353
 
276
354
  // Get the UI shells
277
355
  const shellsStream = await stormClient.createUIShells(
278
356
  {
357
+ theme: theme ? `// filename: theme.css\n${theme}` : undefined,
279
358
  pages: Object.values(uniqueUserJourneyScreens).map((screen) => ({
280
359
  name: screen.name,
281
360
  title: screen.title,
@@ -285,7 +364,7 @@ router.post('/:handle/ui', async (req: KapetaBodyRequest, res: Response) => {
285
364
  requirements: screen.requirements,
286
365
  })),
287
366
  },
288
- conversationId
367
+ outerConversationId
289
368
  );
290
369
 
291
370
  onRequestAborted(req, res, () => {
@@ -293,8 +372,10 @@ router.post('/:handle/ui', async (req: KapetaBodyRequest, res: Response) => {
293
372
  });
294
373
 
295
374
  const queue = new PageQueue(outerConversationId, 5);
375
+ queue.setUiTheme(theme);
376
+
296
377
  shellsStream.on('data', (data: StormEvent) => {
297
- console.log('Processing shell event', data);
378
+ //console.log('Processing shell event', data);
298
379
  sendEvent(res, data);
299
380
 
300
381
  if (data.type !== 'UI_SHELL') {
@@ -341,6 +422,18 @@ router.post('/:handle/ui', async (req: KapetaBodyRequest, res: Response) => {
341
422
  pageEventPromises.push(sendPageEvent(outerConversationId, screenData, res));
342
423
  });
343
424
 
425
+ queue.on('image', async (screenData, prompt, future) => {
426
+ try {
427
+ const promise = handleImageEvent(outerConversationId, screenData, prompt);
428
+ pageEventPromises.push(promise);
429
+ await promise;
430
+ future.resolve();
431
+ } catch (e) {
432
+ console.error('Failed to handle image event', e);
433
+ future.reject(e);
434
+ }
435
+ });
436
+
344
437
  queue.on('event', (screenData: StormEvent) => {
345
438
  sendEvent(res, screenData);
346
439
  });
@@ -356,6 +449,7 @@ router.post('/:handle/ui', async (req: KapetaBodyRequest, res: Response) => {
356
449
  title: screen.title,
357
450
  filename: screen.filename,
358
451
  storage_prefix: outerConversationId + '_',
452
+ theme,
359
453
  })
360
454
  );
361
455
  }
@@ -712,4 +806,12 @@ async function sendPageEvent(mainConversationId: string, data: StormEventPage, r
712
806
  sendEvent(res, convertPageEvent(data, data.payload.conversationId, mainConversationId));
713
807
  }
714
808
 
809
+ async function handleImageEvent(mainConversationId: string, data: StormImage, prompt: ImagePrompt) {
810
+ try {
811
+ await writeImageToDisk(mainConversationId, data, prompt);
812
+ } catch (err) {
813
+ console.error('Failed to write image to disk', err);
814
+ }
815
+ }
816
+
715
817
  export default router;
@@ -22,6 +22,7 @@ export const STORM_ID = 'storm';
22
22
  export const ConversationIdHeader = 'Conversation-Id';
23
23
 
24
24
  export interface UIShellsPrompt {
25
+ theme?: string;
25
26
  pages: {
26
27
  name: string;
27
28
  title: string;
@@ -42,6 +43,8 @@ export interface UIPagePrompt {
42
43
  description: string;
43
44
  storage_prefix: string;
44
45
  shell_page?: string;
46
+ // contents of theme.css
47
+ theme: string;
45
48
  }
46
49
 
47
50
  export interface UIPageSamplePrompt extends UIPagePrompt {
@@ -185,6 +188,13 @@ class StormClient {
185
188
  });
186
189
  }
187
190
 
191
+ public createTheme(prompt: BasePromptRequest, conversationId?: string) {
192
+ return this.send('/v2/ui/theme', {
193
+ prompt: prompt,
194
+ conversationId,
195
+ });
196
+ }
197
+
188
198
  public createUIShells(prompt: UIShellsPrompt, conversationId?: string) {
189
199
  return this.send('/v2/ui/shells', {
190
200
  prompt: JSON.stringify(prompt),
@@ -253,6 +263,13 @@ class StormClient {
253
263
  });
254
264
  }
255
265
 
266
+ public createImage(prompt: string, conversationId?: string) {
267
+ return this.send('/v2/ui/image', {
268
+ prompt,
269
+ conversationId,
270
+ });
271
+ }
272
+
256
273
  public createUIImplementation(prompt: StormUIImplementationPrompt, conversationId?: string) {
257
274
  return this.send('/v2/ui/merge', {
258
275
  prompt,