@kapeta/local-cluster-service 0.74.2 → 0.76.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,6 @@ import { Definition } from '@kapeta/local-cluster-config';
7
7
  import {
8
8
  AIFileTypes,
9
9
  BlockCodeGenerator,
10
- CodeGenerator,
11
10
  CodeWriter,
12
11
  GeneratedFile,
13
12
  GeneratedResult,
@@ -21,20 +20,18 @@ import { STORM_ID, StormClient } from './stormClient';
21
20
  import {
22
21
  StormEvent,
23
22
  StormEventBlockStatusType,
24
- StormEventErrorDetailsFile,
25
23
  StormEventFileChunk,
26
24
  StormEventFileDone,
27
25
  StormEventFileLogical,
28
26
  StormEventScreen,
29
27
  } from './events';
30
28
  import { BlockDefinitionInfo, StormEventParser } from './event-parser';
31
- import { ConversationItem, StormFileImplementationPrompt, StormFileInfo, StormStream } from './stream';
29
+ import { StormFileImplementationPrompt, StormFileInfo, StormStream } from './stream';
32
30
  import { KapetaURI, parseKapetaUri } from '@kapeta/nodejs-utils';
33
31
  import { writeFile } from 'fs/promises';
34
32
  import path from 'path';
35
33
  import Path, { join } from 'path';
36
34
  import os from 'node:os';
37
- import { readFileSync, writeFileSync, existsSync } from 'fs';
38
35
  import YAML from 'yaml';
39
36
  import { PREDEFINED_BLOCKS } from './predefined';
40
37
  import { Archetype } from './archetype';
@@ -46,12 +43,6 @@ type ImplementationGenerator<T = StormFileImplementationPrompt> = (
46
43
  conversationId?: string
47
44
  ) => Promise<StormStream>;
48
45
 
49
- interface ErrorClassification {
50
- error: string;
51
- lineNumber: number;
52
- column: number;
53
- }
54
-
55
46
  const SIMULATED_DELAY = 1000;
56
47
  const ENABLE_SIMULATED_DELAY = false;
57
48
  class SimulatedFileDelay {
@@ -371,7 +362,7 @@ export class StormCodegen {
371
362
  });
372
363
  const uiEvents = [];
373
364
 
374
- const stormClient = new StormClient(this.uiSystemId);
365
+ const stormClient = new StormClient(blockUri.handle, this.uiSystemId);
375
366
  // generate screens
376
367
  if (uiTemplates.length) {
377
368
  const screenStream = await stormClient.listScreens({
@@ -540,249 +531,6 @@ export class StormCodegen {
540
531
  } satisfies StormEvent);
541
532
  }
542
533
 
543
- private async verifyAndFixCode(
544
- blockUri: KapetaURI,
545
- blockName: string,
546
- codeGenerator: CodeGenerator,
547
- basePath: string,
548
- filesToBeFixed: StormFileInfo[],
549
- allFiles: StormFileInfo[]
550
- ) {
551
- let attempts = 0;
552
- let validCode = false;
553
- for (let i = 0; i <= 3; i++) {
554
- attempts++;
555
- try {
556
- console.log(`Validating the code in ${basePath} attempt #${attempts}`);
557
- const result = await codeGenerator.validateForTarget(basePath);
558
- if (result && result.valid) {
559
- validCode = true;
560
- break;
561
- }
562
-
563
- if (result && !result.valid) {
564
- console.debug('Validation error:', result);
565
-
566
- this.emitBlockStatus(blockUri, blockName, StormEventBlockStatusType.PLANNING_FIX);
567
-
568
- const errors = await this.classifyErrors(result.error, basePath);
569
-
570
- if (errors.size > 0) {
571
- this.emitBlockStatus(blockUri, blockName, StormEventBlockStatusType.FIXING);
572
-
573
- const promises = Array.from(errors.entries()).map(([filename, fileErrors]) => {
574
- if (filesToBeFixed.some((file) => file.filename === filename)) {
575
- return this.tryToFixFile(
576
- blockUri,
577
- blockName,
578
- basePath,
579
- filename,
580
- fileErrors,
581
- allFiles,
582
- codeGenerator
583
- );
584
- }
585
- });
586
-
587
- await Promise.all(promises);
588
- }
589
-
590
- this.emitBlockStatus(blockUri, blockName, StormEventBlockStatusType.FIX_DONE);
591
- }
592
- } catch (e) {
593
- console.error('Error:', e);
594
- }
595
- }
596
-
597
- if (validCode) {
598
- console.log(`Validation successful after ${attempts} attempts`);
599
- } else {
600
- console.error(`Validation failed for ${basePath} after ${attempts} attempts`);
601
- }
602
- }
603
-
604
- private async tryToFixFile(
605
- blockUri: KapetaURI,
606
- blockName: string,
607
- basePath: string,
608
- filename: string,
609
- fileErrors: ErrorClassification[],
610
- allFiles: StormFileInfo[],
611
- codeGenerator: CodeGenerator
612
- ) {
613
- console.log(`Processing ${filename}`);
614
- const language = await codeGenerator.language();
615
- const relevantFiles = allFiles.filter((file) => file.type != AIFileTypes.IGNORE);
616
-
617
- for (let attempts = 1; attempts <= 5; attempts++) {
618
- if (fileErrors.length == 0) {
619
- console.log(`No more errors for ${filename}`);
620
- return;
621
- }
622
-
623
- console.log(`Errors in ${filename} - requesting error details`);
624
- const filesForContext = await this.getErrorDetailsForFile(
625
- basePath,
626
- filename,
627
- fileErrors[0],
628
- relevantFiles,
629
- language
630
- );
631
- console.log(`Get error details for ${filename} requesting code fixes`);
632
-
633
- const fix = this.createFixRequestForFile(
634
- basePath,
635
- filename,
636
- fileErrors[0],
637
- filesForContext,
638
- relevantFiles,
639
- language
640
- );
641
- const codeFixFile = await this.codeFix(blockUri, blockName, fix);
642
- console.log(`Got fixed code for ${filename}`);
643
- const filePath =
644
- codeFixFile.filename.indexOf(basePath) > -1
645
- ? codeFixFile.filename
646
- : join(basePath, codeFixFile.filename);
647
- const existing = readFileSync(filePath);
648
- if (
649
- existing.toString().replace(/(\r\n|\r|\n)+$/, '') == codeFixFile.content.replace(/(\r\n|\r|\n)+$/, '')
650
- ) {
651
- console.log(`${filename} not changed by gemini`);
652
- continue;
653
- }
654
- writeFileSync(filePath, codeFixFile.content);
655
-
656
- const result = await codeGenerator.validateForTarget(basePath);
657
- if (result && result.valid) {
658
- return;
659
- }
660
-
661
- const errors = await this.classifyErrors(result.error, basePath);
662
- fileErrors = errors.get(filename) ?? [];
663
- }
664
- }
665
-
666
- private async classifyErrors(errors: string, basePath: string): Promise<Map<string, ErrorClassification[]>> {
667
- const stormClient = new StormClient(this.uiSystemId);
668
- const errorStream = await stormClient.createErrorClassification(errors, []);
669
- const fixes = new Map<string, ErrorClassification[]>();
670
-
671
- this.out.on('aborted', () => {
672
- errorStream.abort();
673
- });
674
-
675
- errorStream.on('data', (evt) => {
676
- if (evt.type === 'ERROR_CLASSIFIER') {
677
- const eventFileName = this.removePrefix(basePath + '/', evt.payload.filename);
678
- const fix = {
679
- error: evt.payload.error,
680
- lineNumber: evt.payload.lineNumber,
681
- column: evt.payload.column,
682
- };
683
-
684
- let existingFixes = fixes.get(eventFileName);
685
- if (existingFixes) {
686
- existingFixes.push(fix);
687
- } else {
688
- fixes.set(eventFileName, [fix]);
689
- }
690
- }
691
- });
692
-
693
- await errorStream.waitForDone();
694
-
695
- return fixes;
696
- }
697
-
698
- private async getErrorDetailsForFile(
699
- basePath: string,
700
- filename: string,
701
- error: ErrorClassification,
702
- allFiles: StormFileInfo[],
703
- language: string
704
- ): Promise<string[]> {
705
- const filePath = filename.indexOf(basePath) > -1 ? filename : join(basePath, filename); // to compensate when compiler returns absolute path
706
- return new Promise<string[]>(async (resolve, reject) => {
707
- const request = {
708
- language: language,
709
- sourceFile: {
710
- filename: filename,
711
- content: readFileSync(filePath, 'utf8'),
712
- },
713
- error: error,
714
- projectFiles: allFiles.map((f) => f.filename),
715
- };
716
- const stormClient = new StormClient(this.uiSystemId);
717
- const detailsStream = await stormClient.createErrorDetails(JSON.stringify(request), []);
718
- detailsStream.on('data', (evt) => {
719
- if (evt.type === 'ERROR_DETAILS') {
720
- resolve(evt.payload.files);
721
- }
722
- reject(new Error('Error details: Unexpected event [' + evt.type + ']'));
723
- });
724
- this.out.on('aborted', () => {
725
- detailsStream.abort();
726
- reject(new Error('aborted'));
727
- });
728
- detailsStream.on('error', (err) => {
729
- reject(err);
730
- });
731
- await detailsStream.waitForDone();
732
- });
733
- }
734
-
735
- private createFixRequestForFile(
736
- basePath: string,
737
- filename: string,
738
- error: ErrorClassification,
739
- filesForContext: string[],
740
- allFiles: StormFileInfo[],
741
- language: string
742
- ): string {
743
- const files = new Set(filesForContext);
744
- files.add(filename);
745
-
746
- const requestedFiles = Array.from(files).flatMap((file) => {
747
- if (existsSync(file)) {
748
- return file;
749
- }
750
-
751
- // file does not exist - look for similar
752
- const candidateName = file.split('/').pop();
753
- return allFiles.filter((file) => file.filename.split('/').pop() === candidateName).map((f) => f.filename);
754
- });
755
-
756
- const filePath = filename.indexOf(basePath) > -1 ? filename : join(basePath, filename);
757
- const content = readFileSync(filePath, 'utf8');
758
- const affectedLine = this.getErrorLine(error, content);
759
-
760
- const fixRequest = {
761
- language: language,
762
- filename: filename,
763
- error: error.error,
764
- affectedLine: affectedLine,
765
- projectFiles: requestedFiles.map((filename) => {
766
- const filePath = filename.indexOf(basePath) > -1 ? filename : join(basePath, filename);
767
- const content = readFileSync(filePath, 'utf8');
768
- return { filename: filename, content: content };
769
- }),
770
- };
771
-
772
- return JSON.stringify(fixRequest);
773
- }
774
-
775
- private getErrorLine(errorDetails: ErrorClassification, sourceCode: string): string {
776
- const lines = sourceCode.split('\n');
777
- const errorLine = lines[errorDetails.lineNumber - 1];
778
-
779
- if (!errorLine) {
780
- return 'Error: Line number out of range.';
781
- }
782
-
783
- return errorLine;
784
- }
785
-
786
534
  removePrefix(prefix: string, str: string): string {
787
535
  if (str.startsWith(prefix)) {
788
536
  return str.slice(prefix.length);
@@ -790,49 +538,6 @@ export class StormCodegen {
790
538
  return str;
791
539
  }
792
540
 
793
- /**
794
- * Sends the code to the AI for a fix
795
- */
796
- private async codeFix(
797
- blockUri: KapetaURI,
798
- blockName: string,
799
- fix: string,
800
- history?: ConversationItem[]
801
- ): Promise<StormEventErrorDetailsFile> {
802
- return new Promise<StormEventErrorDetailsFile>(async (resolve, reject) => {
803
- try {
804
- const stormClient = new StormClient(this.uiSystemId);
805
- const fixStream = await stormClient.createCodeFix(fix, history, this.conversationId);
806
- let resolved = false;
807
- fixStream.on('data', (evt) => {
808
- if (this.handleFileEvents(blockUri, blockName, evt)) {
809
- return;
810
- }
811
- this.handleFileDoneOutput(blockUri, blockName, evt);
812
-
813
- if (evt.type === 'CODE_FIX') {
814
- resolved = true;
815
- resolve(evt.payload);
816
- }
817
- });
818
- this.out.on('aborted', () => {
819
- fixStream.abort();
820
- reject(new Error('aborted'));
821
- });
822
- fixStream.on('error', (err) => {
823
- reject(err);
824
- });
825
- fixStream.on('end', () => {
826
- if (!resolved) {
827
- reject(new Error('Code fix never returned a valid event'));
828
- }
829
- });
830
- } catch (e) {
831
- reject(e);
832
- }
833
- });
834
- }
835
-
836
541
  /**
837
542
  * Emits the text-based files to the stream
838
543
  */
@@ -152,6 +152,7 @@ router.post('/ui/conversations/:systemId/append', async (req: KapetaBodyRequest,
152
152
 
153
153
  router.post('/ui/create-system/:handle/:systemId', async (req: KapetaBodyRequest, res: Response) => {
154
154
  const systemId = req.params.systemId as string;
155
+ const handle = req.params.handle as string;
155
156
  const srcDir = getSystemBaseDir(systemId);
156
157
  const destDir = getSystemBaseImplDir(systemId);
157
158
 
@@ -162,7 +163,7 @@ router.post('/ui/create-system/:handle/:systemId', async (req: KapetaBodyRequest
162
163
  sendEvent(res, createPhaseStartEvent(StormEventPhaseType.IMPLEMENT_APIS));
163
164
 
164
165
  const pagesFromDisk = readFilesAndContent(srcDir);
165
- const client = new StormClient(systemId)
166
+ const client = new StormClient(handle, systemId)
166
167
  const pagesWithImplementation = await client.replaceMockWithAPICall({
167
168
  pages: pagesFromDisk,
168
169
  systemId: systemId,
@@ -206,7 +207,7 @@ router.post('/ui/create-system-simple/:handle/:systemId', async (req: KapetaBody
206
207
  //res.set(ConversationIdHeader, systemId);
207
208
 
208
209
  //sendEvent(res, createPhaseStartEvent(StormEventPhaseType.IMPLEMENT_APIS));
209
- const client = new StormClient(systemId);
210
+ const client = new StormClient(handle, systemId);
210
211
  try {
211
212
  const pagesFromDisk = readFilesAndContent(srcDir);
212
213
  const pagesWithImplementation = await client.replaceMockWithAPICall({
@@ -260,6 +261,23 @@ router.post('/ui/systems/:handle/:systemId/upload', async (req: KapetaBodyReques
260
261
  res.send({ ok: true });
261
262
  });
262
263
 
264
+ router.put('/ui/systems/:handle/:systemId/thumbnail', async (req: KapetaBodyRequest, res: Response) => {
265
+ const systemId = req.params.systemId as string;
266
+ await stormService.saveThumbnail(systemId, req.body as Buffer);
267
+ res.send({ ok: true });
268
+ });
269
+
270
+ router.get('/ui/systems/:handle/:systemId/thumbnail.png', async (req: KapetaBodyRequest, res: Response) => {
271
+ const systemId = req.params.systemId as string;
272
+ const thumbnail = await stormService.getThumbnail(systemId);
273
+ if (thumbnail) {
274
+ res.set('Content-Type', 'image/png');
275
+ res.send(thumbnail);
276
+ } else {
277
+ res.status(404).send({ error: 'No thumbnail found' });
278
+ }
279
+ });
280
+
263
281
  router.delete('/ui/serve/:systemId', async (req: KapetaBodyRequest, res: Response) => {
264
282
  const systemId = req.params.systemId as string | undefined;
265
283
  if (!systemId) {
@@ -289,7 +307,7 @@ router.post('/ui/screen', async (req: KapetaBodyRequest, res: Response) => {
289
307
 
290
308
  const parentConversationId = systemId ?? '';
291
309
 
292
- const queue = new PageQueue(parentConversationId, '', 5);
310
+ const queue = new PageQueue("", parentConversationId, '', 5);
293
311
  onRequestAborted(req, res, () => {
294
312
  queue.cancel();
295
313
  });
@@ -331,7 +349,7 @@ router.post('/:handle/ui/iterative', async (req: KapetaBodyRequest, res: Respons
331
349
  const conversationId = req.headers[ConversationIdHeader.toLowerCase()] as string | undefined;
332
350
 
333
351
  const aiRequest: BasePromptRequest = JSON.parse(req.stringBody ?? '{}');
334
- const client = new StormClient(conversationId); //todo is this correct we are using the landing page getConversationId down below as well
352
+ const client = new StormClient(handle, conversationId); //todo is this correct we are using the landing page getConversationId down below as well
335
353
  const landingPagesStream = await client.createUILandingPages(aiRequest, conversationId);
336
354
 
337
355
  onRequestAborted(req, res, () => {
@@ -394,7 +412,7 @@ router.post('/:handle/ui/iterative', async (req: KapetaBodyRequest, res: Respons
394
412
  systemPrompt.resolve(aiRequest.prompt);
395
413
  });
396
414
 
397
- const pageQueue = new PageQueue(systemId, await systemPrompt.promise, 5);
415
+ const pageQueue = new PageQueue(handle, systemId, await systemPrompt.promise, 5);
398
416
  onRequestAborted(req, res, () => {
399
417
  pageQueue.cancel();
400
418
  });
@@ -440,7 +458,7 @@ router.post('/:handle/ui', async (req: KapetaBodyRequest, res: Response) => {
440
458
  (req.headers[ConversationIdHeader.toLowerCase()] as string | undefined) || randomUUID();
441
459
 
442
460
  const aiRequest: BasePromptRequest = JSON.parse(req.stringBody ?? '{}');
443
- const stormClient = new StormClient(outerConversationId);
461
+ const stormClient = new StormClient(handle, outerConversationId);
444
462
  // Get user journeys
445
463
  const userJourneysStream = await stormClient.createUIUserJourneys(aiRequest, outerConversationId);
446
464
 
@@ -555,7 +573,7 @@ router.post('/:handle/ui', async (req: KapetaBodyRequest, res: Response) => {
555
573
  shellsStream.abort();
556
574
  });
557
575
 
558
- const queue = new PageQueue(outerConversationId, systemPrompt, 5);
576
+ const queue = new PageQueue(handle, outerConversationId, systemPrompt, 5);
559
577
  queue.setUiTheme(theme);
560
578
 
561
579
  shellsStream.on('data', (data: StormEvent) => {
@@ -662,7 +680,7 @@ router.post('/ui/edit', async (req: KapetaBodyRequest, res: Response) => {
662
680
  const aiRequest: StormContextRequest<UIPageEditRequest> = JSON.parse(req.stringBody ?? '{}');
663
681
  const storagePrefix = systemId ? systemId + '_' : 'mock_';
664
682
 
665
- const queue = new PageQueue(systemId!, '', 5);
683
+ const queue = new PageQueue("",systemId!, '', 5);
666
684
 
667
685
  onRequestAborted(req, res, () => {
668
686
  queue.cancel();
@@ -736,7 +754,7 @@ router.post('/ui/vote', async (req: KapetaBodyRequest, res: Response) => {
736
754
  const aiRequest: UIPageVoteRequest = JSON.parse(req.stringBody ?? '{}');
737
755
  const { topic, vote, mainConversationId } = aiRequest;
738
756
  try {
739
- const stormClient = new StormClient(mainConversationId);
757
+ const stormClient = new StormClient("", mainConversationId);
740
758
  await stormClient.voteUIPage(topic, conversationId, vote, mainConversationId);
741
759
  } catch (e: any) {
742
760
  res.status(500).send({ error: e.message });
@@ -748,7 +766,7 @@ router.post('/ui/get-vote', async (req: KapetaBodyRequest, res: Response) => {
748
766
  const aiRequest: UIPageGetVoteRequest = JSON.parse(req.stringBody ?? '{}');
749
767
  const { topic, mainConversationId } = aiRequest;
750
768
  try {
751
- const stormClient = new StormClient(mainConversationId);
769
+ const stormClient = new StormClient("", mainConversationId);
752
770
  const vote = await stormClient.getVoteUIPage(topic, conversationId, mainConversationId);
753
771
  res.send({ vote });
754
772
  } catch (e: any) {
@@ -780,7 +798,7 @@ async function handleAll(req: KapetaBodyRequest, res: Response) {
780
798
  const conversationId = req.headers[ConversationIdHeader.toLowerCase()] as string | undefined;
781
799
 
782
800
  const aiRequest: BasePromptRequest = JSON.parse(req.stringBody ?? '{}');
783
- const stormClient = new StormClient(systemId);
801
+ const stormClient = new StormClient(handle, systemId);
784
802
  const metaStream = await stormClient.createMetadata(aiRequest, conversationId);
785
803
 
786
804
  onRequestAborted(req, res, () => {
@@ -25,6 +25,7 @@ export const STORM_ID = 'storm';
25
25
 
26
26
  export const ConversationIdHeader = 'Conversation-Id';
27
27
  export const SystemIdHeader = 'System-Id';
28
+ export const HandleHeader = 'Handle';
28
29
 
29
30
  export interface UIShellsPrompt {
30
31
  theme?: string;
@@ -89,9 +90,11 @@ export interface BasePromptRequest {
89
90
  export class StormClient {
90
91
  private readonly _baseUrl: string;
91
92
  private readonly _systemId: string;
92
- constructor(systemId?: string) {
93
+ private readonly _handle: string;
94
+ constructor(handle: string, systemId?: string) {
93
95
  this._baseUrl = getRemoteUrl('ai-service', 'https://ai.kapeta.com');
94
96
  this._systemId = systemId || "";
97
+ this._handle = handle;
95
98
  }
96
99
 
97
100
  private async createOptions(
@@ -115,6 +118,9 @@ export class StormClient {
115
118
  if (this._systemId) {
116
119
  headers[SystemIdHeader] = this._systemId
117
120
  }
121
+ if (this._handle) {
122
+ headers[HandleHeader] = this._handle
123
+ }
118
124
  return {
119
125
  url,
120
126
  method: method,
@@ -251,6 +257,7 @@ export class StormClient {
251
257
  body: JSON.stringify(prompt.pages),
252
258
  headers: {
253
259
  'systemId': prompt.systemId,
260
+ 'conversationId': prompt.systemId,
254
261
  },
255
262
  });
256
263
  return (await response.json()) as HTMLPage[];
@@ -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);
@@ -131,14 +149,14 @@ export class StormService {
131
149
  },
132
150
  ['.']
133
151
  );
134
- const stormClient = new StormClient(systemId);
152
+ const stormClient = new StormClient(handle, systemId);
135
153
  await stormClient.uploadSystem(handle, systemId, await fs.readFile(tarballFile));
136
154
  }
137
155
 
138
156
  async installProjectById(handle: string, systemId: string) {
139
157
  const tarballFile = this.getConversationTarball(systemId);
140
158
  const destDir = path.dirname(tarballFile);
141
- const stormClient = new StormClient(systemId);
159
+ const stormClient = new StormClient(handle, systemId);
142
160
  const buffer = await stormClient.downloadSystem(handle, systemId);
143
161
  await fs.mkdir(destDir, { recursive: true });
144
162
  await fs.writeFile(tarballFile, buffer);
@@ -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();