@kapeta/local-cluster-service 0.72.0 → 0.74.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,17 @@
1
+ # [0.74.0](https://github.com/kapetacom/local-cluster-service/compare/v0.73.0...v0.74.0) (2024-09-26)
2
+
3
+
4
+ ### Features
5
+
6
+ * add URL extraction to conversations endpoint ([#259](https://github.com/kapetacom/local-cluster-service/issues/259)) ([b4a38cd](https://github.com/kapetacom/local-cluster-service/commit/b4a38cdd97d94e3c69091f87bd10487c000ba631))
7
+
8
+ # [0.73.0](https://github.com/kapetacom/local-cluster-service/compare/v0.72.0...v0.73.0) (2024-09-25)
9
+
10
+
11
+ ### Features
12
+
13
+ * add conversation CRUD endpoints and zip upload [CORE-3499] ([#257](https://github.com/kapetacom/local-cluster-service/issues/257)) ([81c1c31](https://github.com/kapetacom/local-cluster-service/commit/81c1c3193e676f1c9e4a802cbf060ca63c8f909d))
14
+
1
15
  # [0.72.0](https://github.com/kapetacom/local-cluster-service/compare/v0.71.6...v0.72.0) (2024-09-24)
2
16
 
3
17
 
@@ -404,5 +404,13 @@ export interface StormEventSystemReady {
404
404
  systemUrl: string;
405
405
  };
406
406
  }
407
- export type StormEvent = StormEventCreateBlock | StormEventCreateConnection | StormEventCreatePlanProperties | StormEventInvalidResponse | StormEventPlanRetry | StormEventCreateDSL | StormEventCreateDSLResource | StormEventError | StormEventScreen | StormEventScreenCandidate | StormEventFileLogical | StormEventFileState | StormEventFileDone | StormEventFileFailed | StormEventFileChunk | StormEventDone | StormEventDefinitionChange | StormEventErrorClassifier | StormEventCodeFix | StormEventErrorDetails | StormEventBlockReady | StormEventPhases | StormEventBlockStatus | StormEventCreateDSLRetry | StormEventUserJourney | StormEventUIShell | StormEventPage | StormEventPageUrl | StormEventPromptImprove | StormEventLandingPage | StormEventReferenceClassification | StormEventApiBase | StormEventUIStarted | StormImage | StormEventSystemReady;
407
+ export interface StormEventModelResponse {
408
+ type: 'MODEL_RESPONSE';
409
+ reason: string;
410
+ created: number;
411
+ payload: {
412
+ text: string;
413
+ };
414
+ }
415
+ export type StormEvent = StormEventCreateBlock | StormEventCreateConnection | StormEventCreatePlanProperties | StormEventInvalidResponse | StormEventPlanRetry | StormEventCreateDSL | StormEventCreateDSLResource | StormEventError | StormEventScreen | StormEventScreenCandidate | StormEventFileLogical | StormEventFileState | StormEventFileDone | StormEventFileFailed | StormEventFileChunk | StormEventDone | StormEventDefinitionChange | StormEventErrorClassifier | StormEventCodeFix | StormEventErrorDetails | StormEventBlockReady | StormEventPhases | StormEventBlockStatus | StormEventCreateDSLRetry | StormEventUserJourney | StormEventUIShell | StormEventPage | StormEventPageUrl | StormEventPromptImprove | StormEventLandingPage | StormEventReferenceClassification | StormEventApiBase | StormEventUIStarted | StormImage | StormEventSystemReady | StormEventModelResponse;
408
416
  export {};
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.writeConversationToFile = exports.readConversationFromFile = exports.readPageFromDisk = exports.readPageFromDiskAsString = exports.resolveReadPath = exports.getSystemBaseImplDir = exports.getSystemBaseDir = exports.hasPageOnDisk = exports.writeImageToDisk = exports.writeAssetToDisk = exports.writePageToDisk = exports.normalizePath = exports.SystemIdHeader = void 0;
7
+ const filesystemManager_1 = require("../filesystemManager");
7
8
  const node_os_1 = __importDefault(require("node:os"));
8
9
  const path_1 = __importDefault(require("path"));
9
10
  const fs_extra_1 = __importDefault(require("fs-extra"));
@@ -61,7 +62,7 @@ function hasPageOnDisk(systemId, path, method) {
61
62
  }
62
63
  exports.hasPageOnDisk = hasPageOnDisk;
63
64
  function getSystemBaseDir(systemId) {
64
- return path_1.default.join(node_os_1.default.tmpdir(), 'ai-systems', systemId);
65
+ return path_1.default.join(filesystemManager_1.filesystemManager.getProjectRootFolder() || node_os_1.default.tmpdir(), 'ai-systems', systemId);
65
66
  }
66
67
  exports.getSystemBaseDir = getSystemBaseDir;
67
68
  function getSystemBaseImplDir(systemId) {
@@ -23,9 +23,10 @@ const node_uuid_1 = __importDefault(require("node-uuid"));
23
23
  const page_utils_1 = require("./page-utils");
24
24
  const UIServer_1 = require("./UIServer");
25
25
  const crypto_1 = require("crypto");
26
- const PageGenerator_1 = require("./PageGenerator");
27
26
  const utils_1 = require("./utils");
28
27
  const utils_2 = require("../utils/utils");
28
+ const stormService_1 = __importDefault(require("../stormService"));
29
+ const PageGenerator_1 = require("./PageGenerator");
29
30
  const UI_SERVERS = {};
30
31
  const router = (0, express_promise_router_1.default)();
31
32
  router.use('/', cors_1.corsHandler);
@@ -70,6 +71,43 @@ router.post('/ui/serve/:systemId', async (req, res) => {
70
71
  }
71
72
  res.status(200).send({ status: 'running', url: svr.getUrl(), resetUrl: svr.resolveUrlFromPath('/_reset') });
72
73
  });
74
+ router.get('/ui/conversations', async (req, res) => {
75
+ const local = await stormService_1.default.listLocalConversations();
76
+ const remote = await stormService_1.default.listRemoteConversations();
77
+ res.send({
78
+ local,
79
+ remote,
80
+ });
81
+ });
82
+ router.get('/ui/conversations/:systemId', async (req, res) => {
83
+ const systemId = req.params.systemId;
84
+ const eventsFile = (0, page_utils_1.getSystemBaseDir)(systemId) + '/events.ndjson';
85
+ res.set('Content-Type', 'application/x-ndjson');
86
+ res.set('Access-Control-Expose-Headers', stormClient_1.ConversationIdHeader);
87
+ res.set(stormClient_1.ConversationIdHeader, systemId);
88
+ if (!fs_extra_1.default.existsSync(eventsFile)) {
89
+ res.status(404).send({ error: 'No events found' });
90
+ return;
91
+ }
92
+ res.send(fs_extra_1.default.readFileSync(eventsFile));
93
+ });
94
+ router.put('/ui/conversations/:systemId', async (req, res) => {
95
+ const systemId = req.params.systemId;
96
+ const events = req.stringBody ? JSON.parse(req.stringBody) : [];
97
+ await stormService_1.default.saveConversation(systemId, events);
98
+ res.send({ ok: true });
99
+ });
100
+ router.delete('/ui/conversations/:systemId', async (req, res) => {
101
+ const systemId = req.params.systemId;
102
+ await stormService_1.default.deleteConversation(systemId);
103
+ res.send({ ok: true });
104
+ });
105
+ router.post('/ui/conversations/:systemId/append', async (req, res) => {
106
+ const systemId = req.params.systemId;
107
+ const events = req.stringBody ? JSON.parse(req.stringBody) : [];
108
+ await stormService_1.default.appendConversation(systemId, events);
109
+ res.send({ ok: true });
110
+ });
73
111
  router.post('/ui/create-system/:handle/:systemId', async (req, res) => {
74
112
  const systemId = req.params.systemId;
75
113
  const srcDir = (0, page_utils_1.getSystemBaseDir)(systemId);
@@ -142,6 +180,18 @@ router.post('/ui/create-system-simple/:handle/:systemId', async (req, res) => {
142
180
  }
143
181
  }
144
182
  });
183
+ router.post('/ui/systems/:handle/:systemId/download', async (req, res) => {
184
+ const systemId = req.params.systemId;
185
+ const handle = req.params.handle;
186
+ await stormService_1.default.installProjectById(handle, systemId);
187
+ res.send({ ok: true });
188
+ });
189
+ router.post('/ui/systems/:handle/:systemId/upload', async (req, res) => {
190
+ const systemId = req.params.systemId;
191
+ const handle = req.params.handle;
192
+ await stormService_1.default.uploadConversation(handle, systemId);
193
+ res.send({ ok: true });
194
+ });
145
195
  router.delete('/ui/serve/:systemId', async (req, res) => {
146
196
  const systemId = req.params.systemId;
147
197
  if (!systemId) {
@@ -1,4 +1,5 @@
1
1
  /// <reference types="node" />
2
+ /// <reference types="node" />
2
3
  import { ConversationItem, CreateSimpleBackendRequest, HTMLPage, ImplementAPIClients, StormFileImplementationPrompt, StormStream, StormUIImplementationPrompt, StormUIListPrompt } from './stream';
3
4
  import { Page, StormEventPageUrl } from './events';
4
5
  export declare const STORM_ID = "storm";
@@ -84,6 +85,8 @@ declare class StormClient {
84
85
  createErrorDetails(prompt: string, history?: ConversationItem[], conversationId?: string): Promise<StormStream>;
85
86
  generateCode(prompt: StormFileImplementationPrompt, conversationId?: string): Promise<StormStream>;
86
87
  deleteUIPageConversation(conversationId: string): Promise<string>;
88
+ downloadSystem(handle: string, conversationId: string): Promise<Buffer>;
89
+ uploadSystem(handle: string, conversationId: string, buffer: Buffer): Promise<Response>;
87
90
  }
88
91
  export declare const stormClient: StormClient;
89
92
  export {};
@@ -139,7 +139,7 @@ class StormClient {
139
139
  method: 'POST',
140
140
  body: JSON.stringify(prompt.pages),
141
141
  });
142
- return await response.json();
142
+ return (await response.json());
143
143
  }
144
144
  async generatePrompt(pages) {
145
145
  const u = `${this._baseUrl}/v2/ui/prompt`;
@@ -232,5 +232,24 @@ class StormClient {
232
232
  const response = await fetch(options.url, options);
233
233
  return response.text();
234
234
  }
235
+ async downloadSystem(handle, conversationId) {
236
+ const u = `${this._baseUrl}/v2/systems/${handle}/${conversationId}/download`;
237
+ const response = await fetch(u);
238
+ if (!response.ok) {
239
+ throw new Error(`Failed to download system: ${response.status}`);
240
+ }
241
+ return Buffer.from(await response.arrayBuffer());
242
+ }
243
+ async uploadSystem(handle, conversationId, buffer) {
244
+ const u = `${this._baseUrl}/v2/systems/${handle}/${conversationId}`;
245
+ const response = await fetch(u, {
246
+ method: 'PUT',
247
+ body: buffer,
248
+ headers: {
249
+ 'content-type': 'application/zip',
250
+ },
251
+ });
252
+ return response;
253
+ }
235
254
  }
236
255
  exports.stormClient = new StormClient();
@@ -0,0 +1,20 @@
1
+ import { StormEvent } from './storm/events';
2
+ export declare class StormService {
3
+ private getConversationFile;
4
+ private getConversationTarball;
5
+ listRemoteConversations(): Promise<never[]>;
6
+ listLocalConversations(): Promise<{
7
+ id: string;
8
+ description: string;
9
+ title: string;
10
+ url?: string | undefined;
11
+ }[]>;
12
+ getConversation(conversationId: string): Promise<string>;
13
+ saveConversation(conversationId: string, events: StormEvent[]): Promise<void>;
14
+ appendConversation(conversationId: string, events: StormEvent[]): Promise<void>;
15
+ deleteConversation(conversationId: string): Promise<void>;
16
+ uploadConversation(handle: string, conversationId: string): Promise<void>;
17
+ installProjectById(handle: string, conversationId: string): Promise<void>;
18
+ }
19
+ declare const _default: StormService;
20
+ export default _default;
@@ -0,0 +1,148 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || function (mod) {
19
+ if (mod && mod.__esModule) return mod;
20
+ var result = {};
21
+ if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
22
+ __setModuleDefault(result, mod);
23
+ return result;
24
+ };
25
+ var __importDefault = (this && this.__importDefault) || function (mod) {
26
+ return (mod && mod.__esModule) ? mod : { "default": mod };
27
+ };
28
+ Object.defineProperty(exports, "__esModule", { value: true });
29
+ exports.StormService = void 0;
30
+ const promises_1 = __importDefault(require("fs/promises"));
31
+ const glob_1 = require("glob");
32
+ const filesystemManager_1 = require("./filesystemManager");
33
+ const path_1 = __importDefault(require("path"));
34
+ const fs_1 = require("fs");
35
+ const tar = __importStar(require("tar"));
36
+ const stormClient_1 = require("./storm/stormClient");
37
+ class StormService {
38
+ getConversationFile(conversationId) {
39
+ return path_1.default.join(filesystemManager_1.filesystemManager.getProjectRootFolder(), 'ai-systems', conversationId, 'events.ndjson');
40
+ }
41
+ getConversationTarball(conversationId) {
42
+ return path_1.default.join(filesystemManager_1.filesystemManager.getProjectRootFolder(), 'ai-systems', conversationId, 'system.tar.gz');
43
+ }
44
+ async listRemoteConversations() {
45
+ // i.e. conversations from org / user on registry
46
+ return [];
47
+ }
48
+ async listLocalConversations() {
49
+ const systemsFolder = path_1.default.join(filesystemManager_1.filesystemManager.getProjectRootFolder(), 'ai-systems');
50
+ const eventFiles = await (0, glob_1.glob)('*/events.ndjson', {
51
+ cwd: systemsFolder,
52
+ absolute: true,
53
+ });
54
+ // Returns list of UUIDs - probably want to make it more useful than that
55
+ const conversations = [];
56
+ for (const file of eventFiles) {
57
+ try {
58
+ const nldContents = await promises_1.default.readFile(file, 'utf8');
59
+ const events = nldContents.split('\n').map((e) => JSON.parse(e));
60
+ // find the shell and get the title tag
61
+ const shellEvent = events.find((e) => e.type === 'AI' && e.event.type === 'UI_SHELL')?.event;
62
+ const html = shellEvent?.payload.content;
63
+ const title = html?.match(/<title>(.*?)<\/title>/)?.[1];
64
+ const id = events.find((e) => e.type === 'AI')?.systemId;
65
+ const initialPrompt = events.find((e) => e.type === 'AI' && e.event.type === 'PROMPT_IMPROVE')?.event?.payload?.prompt || events[0].text;
66
+ if (!id) {
67
+ continue;
68
+ }
69
+ let url = undefined;
70
+ // Find the last model response event that has a URL in the payload (in case it changed over time)
71
+ for (const evt of [...events].reverse()) {
72
+ const event = evt.event;
73
+ if (evt.type === 'AI' && event.type === 'MODEL_RESPONSE') {
74
+ // Look for a URL in the model response markdown
75
+ const regex = /\[(.*?)\]\((.*?)\)/g;
76
+ const match = regex.exec(event.payload.text);
77
+ const [, _linkText, linkUrl] = match || [];
78
+ if (linkUrl?.startsWith('http')) {
79
+ url = linkUrl;
80
+ break;
81
+ }
82
+ }
83
+ }
84
+ conversations.push({
85
+ id,
86
+ description: initialPrompt,
87
+ title: title || 'New system',
88
+ url,
89
+ });
90
+ }
91
+ catch (e) {
92
+ console.error('Failed to load conversation at %s', file, e);
93
+ }
94
+ }
95
+ return conversations;
96
+ }
97
+ async getConversation(conversationId) {
98
+ const conversationFile = this.getConversationFile(conversationId);
99
+ if (!(0, fs_1.existsSync)(conversationFile)) {
100
+ throw new Error('Conversation not found');
101
+ }
102
+ return promises_1.default.readFile(conversationFile, 'utf8');
103
+ }
104
+ async saveConversation(conversationId, events) {
105
+ const conversationFile = this.getConversationFile(conversationId);
106
+ await promises_1.default.mkdir(path_1.default.dirname(conversationFile), { recursive: true });
107
+ await promises_1.default.writeFile(conversationFile, events.map((e) => JSON.stringify(e)).join('\n'));
108
+ }
109
+ async appendConversation(conversationId, events) {
110
+ const conversationFile = this.getConversationFile(conversationId);
111
+ await promises_1.default.appendFile(conversationFile, events.map((e) => JSON.stringify(e)).join('\n'));
112
+ }
113
+ async deleteConversation(conversationId) {
114
+ const conversationFile = this.getConversationFile(conversationId);
115
+ await promises_1.default.unlink(conversationFile);
116
+ // if it matches a UUID, it's safe to delete the directory too
117
+ const conversationDir = path_1.default.dirname(conversationFile);
118
+ if (conversationDir.match(/\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/)) {
119
+ await promises_1.default.rm(conversationDir, { recursive: true, force: true });
120
+ }
121
+ }
122
+ async uploadConversation(handle, conversationId) {
123
+ const tarballFile = this.getConversationTarball(conversationId);
124
+ const destDir = path_1.default.dirname(tarballFile);
125
+ const tarballName = path_1.default.basename(tarballFile);
126
+ await tar.create({
127
+ file: tarballFile,
128
+ cwd: destDir,
129
+ gzip: true,
130
+ filter: (entry) => !entry.includes(tarballName),
131
+ }, ['.']);
132
+ await stormClient_1.stormClient.uploadSystem(handle, conversationId, await promises_1.default.readFile(tarballFile));
133
+ }
134
+ async installProjectById(handle, conversationId) {
135
+ const tarballFile = this.getConversationTarball(conversationId);
136
+ const destDir = path_1.default.dirname(tarballFile);
137
+ const buffer = await stormClient_1.stormClient.downloadSystem(handle, conversationId);
138
+ await promises_1.default.mkdir(destDir, { recursive: true });
139
+ await promises_1.default.writeFile(tarballFile, buffer);
140
+ await tar.extract({
141
+ file: tarballFile,
142
+ cwd: destDir,
143
+ });
144
+ await promises_1.default.unlink(tarballFile);
145
+ }
146
+ }
147
+ exports.StormService = StormService;
148
+ exports.default = new StormService();
@@ -404,5 +404,13 @@ export interface StormEventSystemReady {
404
404
  systemUrl: string;
405
405
  };
406
406
  }
407
- export type StormEvent = StormEventCreateBlock | StormEventCreateConnection | StormEventCreatePlanProperties | StormEventInvalidResponse | StormEventPlanRetry | StormEventCreateDSL | StormEventCreateDSLResource | StormEventError | StormEventScreen | StormEventScreenCandidate | StormEventFileLogical | StormEventFileState | StormEventFileDone | StormEventFileFailed | StormEventFileChunk | StormEventDone | StormEventDefinitionChange | StormEventErrorClassifier | StormEventCodeFix | StormEventErrorDetails | StormEventBlockReady | StormEventPhases | StormEventBlockStatus | StormEventCreateDSLRetry | StormEventUserJourney | StormEventUIShell | StormEventPage | StormEventPageUrl | StormEventPromptImprove | StormEventLandingPage | StormEventReferenceClassification | StormEventApiBase | StormEventUIStarted | StormImage | StormEventSystemReady;
407
+ export interface StormEventModelResponse {
408
+ type: 'MODEL_RESPONSE';
409
+ reason: string;
410
+ created: number;
411
+ payload: {
412
+ text: string;
413
+ };
414
+ }
415
+ export type StormEvent = StormEventCreateBlock | StormEventCreateConnection | StormEventCreatePlanProperties | StormEventInvalidResponse | StormEventPlanRetry | StormEventCreateDSL | StormEventCreateDSLResource | StormEventError | StormEventScreen | StormEventScreenCandidate | StormEventFileLogical | StormEventFileState | StormEventFileDone | StormEventFileFailed | StormEventFileChunk | StormEventDone | StormEventDefinitionChange | StormEventErrorClassifier | StormEventCodeFix | StormEventErrorDetails | StormEventBlockReady | StormEventPhases | StormEventBlockStatus | StormEventCreateDSLRetry | StormEventUserJourney | StormEventUIShell | StormEventPage | StormEventPageUrl | StormEventPromptImprove | StormEventLandingPage | StormEventReferenceClassification | StormEventApiBase | StormEventUIStarted | StormImage | StormEventSystemReady | StormEventModelResponse;
408
416
  export {};
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.writeConversationToFile = exports.readConversationFromFile = exports.readPageFromDisk = exports.readPageFromDiskAsString = exports.resolveReadPath = exports.getSystemBaseImplDir = exports.getSystemBaseDir = exports.hasPageOnDisk = exports.writeImageToDisk = exports.writeAssetToDisk = exports.writePageToDisk = exports.normalizePath = exports.SystemIdHeader = void 0;
7
+ const filesystemManager_1 = require("../filesystemManager");
7
8
  const node_os_1 = __importDefault(require("node:os"));
8
9
  const path_1 = __importDefault(require("path"));
9
10
  const fs_extra_1 = __importDefault(require("fs-extra"));
@@ -61,7 +62,7 @@ function hasPageOnDisk(systemId, path, method) {
61
62
  }
62
63
  exports.hasPageOnDisk = hasPageOnDisk;
63
64
  function getSystemBaseDir(systemId) {
64
- return path_1.default.join(node_os_1.default.tmpdir(), 'ai-systems', systemId);
65
+ return path_1.default.join(filesystemManager_1.filesystemManager.getProjectRootFolder() || node_os_1.default.tmpdir(), 'ai-systems', systemId);
65
66
  }
66
67
  exports.getSystemBaseDir = getSystemBaseDir;
67
68
  function getSystemBaseImplDir(systemId) {
@@ -23,9 +23,10 @@ const node_uuid_1 = __importDefault(require("node-uuid"));
23
23
  const page_utils_1 = require("./page-utils");
24
24
  const UIServer_1 = require("./UIServer");
25
25
  const crypto_1 = require("crypto");
26
- const PageGenerator_1 = require("./PageGenerator");
27
26
  const utils_1 = require("./utils");
28
27
  const utils_2 = require("../utils/utils");
28
+ const stormService_1 = __importDefault(require("../stormService"));
29
+ const PageGenerator_1 = require("./PageGenerator");
29
30
  const UI_SERVERS = {};
30
31
  const router = (0, express_promise_router_1.default)();
31
32
  router.use('/', cors_1.corsHandler);
@@ -70,6 +71,43 @@ router.post('/ui/serve/:systemId', async (req, res) => {
70
71
  }
71
72
  res.status(200).send({ status: 'running', url: svr.getUrl(), resetUrl: svr.resolveUrlFromPath('/_reset') });
72
73
  });
74
+ router.get('/ui/conversations', async (req, res) => {
75
+ const local = await stormService_1.default.listLocalConversations();
76
+ const remote = await stormService_1.default.listRemoteConversations();
77
+ res.send({
78
+ local,
79
+ remote,
80
+ });
81
+ });
82
+ router.get('/ui/conversations/:systemId', async (req, res) => {
83
+ const systemId = req.params.systemId;
84
+ const eventsFile = (0, page_utils_1.getSystemBaseDir)(systemId) + '/events.ndjson';
85
+ res.set('Content-Type', 'application/x-ndjson');
86
+ res.set('Access-Control-Expose-Headers', stormClient_1.ConversationIdHeader);
87
+ res.set(stormClient_1.ConversationIdHeader, systemId);
88
+ if (!fs_extra_1.default.existsSync(eventsFile)) {
89
+ res.status(404).send({ error: 'No events found' });
90
+ return;
91
+ }
92
+ res.send(fs_extra_1.default.readFileSync(eventsFile));
93
+ });
94
+ router.put('/ui/conversations/:systemId', async (req, res) => {
95
+ const systemId = req.params.systemId;
96
+ const events = req.stringBody ? JSON.parse(req.stringBody) : [];
97
+ await stormService_1.default.saveConversation(systemId, events);
98
+ res.send({ ok: true });
99
+ });
100
+ router.delete('/ui/conversations/:systemId', async (req, res) => {
101
+ const systemId = req.params.systemId;
102
+ await stormService_1.default.deleteConversation(systemId);
103
+ res.send({ ok: true });
104
+ });
105
+ router.post('/ui/conversations/:systemId/append', async (req, res) => {
106
+ const systemId = req.params.systemId;
107
+ const events = req.stringBody ? JSON.parse(req.stringBody) : [];
108
+ await stormService_1.default.appendConversation(systemId, events);
109
+ res.send({ ok: true });
110
+ });
73
111
  router.post('/ui/create-system/:handle/:systemId', async (req, res) => {
74
112
  const systemId = req.params.systemId;
75
113
  const srcDir = (0, page_utils_1.getSystemBaseDir)(systemId);
@@ -142,6 +180,18 @@ router.post('/ui/create-system-simple/:handle/:systemId', async (req, res) => {
142
180
  }
143
181
  }
144
182
  });
183
+ router.post('/ui/systems/:handle/:systemId/download', async (req, res) => {
184
+ const systemId = req.params.systemId;
185
+ const handle = req.params.handle;
186
+ await stormService_1.default.installProjectById(handle, systemId);
187
+ res.send({ ok: true });
188
+ });
189
+ router.post('/ui/systems/:handle/:systemId/upload', async (req, res) => {
190
+ const systemId = req.params.systemId;
191
+ const handle = req.params.handle;
192
+ await stormService_1.default.uploadConversation(handle, systemId);
193
+ res.send({ ok: true });
194
+ });
145
195
  router.delete('/ui/serve/:systemId', async (req, res) => {
146
196
  const systemId = req.params.systemId;
147
197
  if (!systemId) {
@@ -1,4 +1,5 @@
1
1
  /// <reference types="node" />
2
+ /// <reference types="node" />
2
3
  import { ConversationItem, CreateSimpleBackendRequest, HTMLPage, ImplementAPIClients, StormFileImplementationPrompt, StormStream, StormUIImplementationPrompt, StormUIListPrompt } from './stream';
3
4
  import { Page, StormEventPageUrl } from './events';
4
5
  export declare const STORM_ID = "storm";
@@ -84,6 +85,8 @@ declare class StormClient {
84
85
  createErrorDetails(prompt: string, history?: ConversationItem[], conversationId?: string): Promise<StormStream>;
85
86
  generateCode(prompt: StormFileImplementationPrompt, conversationId?: string): Promise<StormStream>;
86
87
  deleteUIPageConversation(conversationId: string): Promise<string>;
88
+ downloadSystem(handle: string, conversationId: string): Promise<Buffer>;
89
+ uploadSystem(handle: string, conversationId: string, buffer: Buffer): Promise<Response>;
87
90
  }
88
91
  export declare const stormClient: StormClient;
89
92
  export {};
@@ -139,7 +139,7 @@ class StormClient {
139
139
  method: 'POST',
140
140
  body: JSON.stringify(prompt.pages),
141
141
  });
142
- return await response.json();
142
+ return (await response.json());
143
143
  }
144
144
  async generatePrompt(pages) {
145
145
  const u = `${this._baseUrl}/v2/ui/prompt`;
@@ -232,5 +232,24 @@ class StormClient {
232
232
  const response = await fetch(options.url, options);
233
233
  return response.text();
234
234
  }
235
+ async downloadSystem(handle, conversationId) {
236
+ const u = `${this._baseUrl}/v2/systems/${handle}/${conversationId}/download`;
237
+ const response = await fetch(u);
238
+ if (!response.ok) {
239
+ throw new Error(`Failed to download system: ${response.status}`);
240
+ }
241
+ return Buffer.from(await response.arrayBuffer());
242
+ }
243
+ async uploadSystem(handle, conversationId, buffer) {
244
+ const u = `${this._baseUrl}/v2/systems/${handle}/${conversationId}`;
245
+ const response = await fetch(u, {
246
+ method: 'PUT',
247
+ body: buffer,
248
+ headers: {
249
+ 'content-type': 'application/zip',
250
+ },
251
+ });
252
+ return response;
253
+ }
235
254
  }
236
255
  exports.stormClient = new StormClient();
@@ -0,0 +1,20 @@
1
+ import { StormEvent } from './storm/events';
2
+ export declare class StormService {
3
+ private getConversationFile;
4
+ private getConversationTarball;
5
+ listRemoteConversations(): Promise<never[]>;
6
+ listLocalConversations(): Promise<{
7
+ id: string;
8
+ description: string;
9
+ title: string;
10
+ url?: string | undefined;
11
+ }[]>;
12
+ getConversation(conversationId: string): Promise<string>;
13
+ saveConversation(conversationId: string, events: StormEvent[]): Promise<void>;
14
+ appendConversation(conversationId: string, events: StormEvent[]): Promise<void>;
15
+ deleteConversation(conversationId: string): Promise<void>;
16
+ uploadConversation(handle: string, conversationId: string): Promise<void>;
17
+ installProjectById(handle: string, conversationId: string): Promise<void>;
18
+ }
19
+ declare const _default: StormService;
20
+ export default _default;
@@ -0,0 +1,148 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || function (mod) {
19
+ if (mod && mod.__esModule) return mod;
20
+ var result = {};
21
+ if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
22
+ __setModuleDefault(result, mod);
23
+ return result;
24
+ };
25
+ var __importDefault = (this && this.__importDefault) || function (mod) {
26
+ return (mod && mod.__esModule) ? mod : { "default": mod };
27
+ };
28
+ Object.defineProperty(exports, "__esModule", { value: true });
29
+ exports.StormService = void 0;
30
+ const promises_1 = __importDefault(require("fs/promises"));
31
+ const glob_1 = require("glob");
32
+ const filesystemManager_1 = require("./filesystemManager");
33
+ const path_1 = __importDefault(require("path"));
34
+ const fs_1 = require("fs");
35
+ const tar = __importStar(require("tar"));
36
+ const stormClient_1 = require("./storm/stormClient");
37
+ class StormService {
38
+ getConversationFile(conversationId) {
39
+ return path_1.default.join(filesystemManager_1.filesystemManager.getProjectRootFolder(), 'ai-systems', conversationId, 'events.ndjson');
40
+ }
41
+ getConversationTarball(conversationId) {
42
+ return path_1.default.join(filesystemManager_1.filesystemManager.getProjectRootFolder(), 'ai-systems', conversationId, 'system.tar.gz');
43
+ }
44
+ async listRemoteConversations() {
45
+ // i.e. conversations from org / user on registry
46
+ return [];
47
+ }
48
+ async listLocalConversations() {
49
+ const systemsFolder = path_1.default.join(filesystemManager_1.filesystemManager.getProjectRootFolder(), 'ai-systems');
50
+ const eventFiles = await (0, glob_1.glob)('*/events.ndjson', {
51
+ cwd: systemsFolder,
52
+ absolute: true,
53
+ });
54
+ // Returns list of UUIDs - probably want to make it more useful than that
55
+ const conversations = [];
56
+ for (const file of eventFiles) {
57
+ try {
58
+ const nldContents = await promises_1.default.readFile(file, 'utf8');
59
+ const events = nldContents.split('\n').map((e) => JSON.parse(e));
60
+ // find the shell and get the title tag
61
+ const shellEvent = events.find((e) => e.type === 'AI' && e.event.type === 'UI_SHELL')?.event;
62
+ const html = shellEvent?.payload.content;
63
+ const title = html?.match(/<title>(.*?)<\/title>/)?.[1];
64
+ const id = events.find((e) => e.type === 'AI')?.systemId;
65
+ const initialPrompt = events.find((e) => e.type === 'AI' && e.event.type === 'PROMPT_IMPROVE')?.event?.payload?.prompt || events[0].text;
66
+ if (!id) {
67
+ continue;
68
+ }
69
+ let url = undefined;
70
+ // Find the last model response event that has a URL in the payload (in case it changed over time)
71
+ for (const evt of [...events].reverse()) {
72
+ const event = evt.event;
73
+ if (evt.type === 'AI' && event.type === 'MODEL_RESPONSE') {
74
+ // Look for a URL in the model response markdown
75
+ const regex = /\[(.*?)\]\((.*?)\)/g;
76
+ const match = regex.exec(event.payload.text);
77
+ const [, _linkText, linkUrl] = match || [];
78
+ if (linkUrl?.startsWith('http')) {
79
+ url = linkUrl;
80
+ break;
81
+ }
82
+ }
83
+ }
84
+ conversations.push({
85
+ id,
86
+ description: initialPrompt,
87
+ title: title || 'New system',
88
+ url,
89
+ });
90
+ }
91
+ catch (e) {
92
+ console.error('Failed to load conversation at %s', file, e);
93
+ }
94
+ }
95
+ return conversations;
96
+ }
97
+ async getConversation(conversationId) {
98
+ const conversationFile = this.getConversationFile(conversationId);
99
+ if (!(0, fs_1.existsSync)(conversationFile)) {
100
+ throw new Error('Conversation not found');
101
+ }
102
+ return promises_1.default.readFile(conversationFile, 'utf8');
103
+ }
104
+ async saveConversation(conversationId, events) {
105
+ const conversationFile = this.getConversationFile(conversationId);
106
+ await promises_1.default.mkdir(path_1.default.dirname(conversationFile), { recursive: true });
107
+ await promises_1.default.writeFile(conversationFile, events.map((e) => JSON.stringify(e)).join('\n'));
108
+ }
109
+ async appendConversation(conversationId, events) {
110
+ const conversationFile = this.getConversationFile(conversationId);
111
+ await promises_1.default.appendFile(conversationFile, events.map((e) => JSON.stringify(e)).join('\n'));
112
+ }
113
+ async deleteConversation(conversationId) {
114
+ const conversationFile = this.getConversationFile(conversationId);
115
+ await promises_1.default.unlink(conversationFile);
116
+ // if it matches a UUID, it's safe to delete the directory too
117
+ const conversationDir = path_1.default.dirname(conversationFile);
118
+ if (conversationDir.match(/\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/)) {
119
+ await promises_1.default.rm(conversationDir, { recursive: true, force: true });
120
+ }
121
+ }
122
+ async uploadConversation(handle, conversationId) {
123
+ const tarballFile = this.getConversationTarball(conversationId);
124
+ const destDir = path_1.default.dirname(tarballFile);
125
+ const tarballName = path_1.default.basename(tarballFile);
126
+ await tar.create({
127
+ file: tarballFile,
128
+ cwd: destDir,
129
+ gzip: true,
130
+ filter: (entry) => !entry.includes(tarballName),
131
+ }, ['.']);
132
+ await stormClient_1.stormClient.uploadSystem(handle, conversationId, await promises_1.default.readFile(tarballFile));
133
+ }
134
+ async installProjectById(handle, conversationId) {
135
+ const tarballFile = this.getConversationTarball(conversationId);
136
+ const destDir = path_1.default.dirname(tarballFile);
137
+ const buffer = await stormClient_1.stormClient.downloadSystem(handle, conversationId);
138
+ await promises_1.default.mkdir(destDir, { recursive: true });
139
+ await promises_1.default.writeFile(tarballFile, buffer);
140
+ await tar.extract({
141
+ file: tarballFile,
142
+ cwd: destDir,
143
+ });
144
+ await promises_1.default.unlink(tarballFile);
145
+ }
146
+ }
147
+ exports.StormService = StormService;
148
+ exports.default = new StormService();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kapeta/local-cluster-service",
3
- "version": "0.72.0",
3
+ "version": "0.74.0",
4
4
  "description": "Manages configuration, ports and service discovery for locally running Kapeta systems",
5
5
  "type": "commonjs",
6
6
  "exports": {
@@ -72,7 +72,7 @@
72
72
  "express-promise-router": "^4.1.1",
73
73
  "fetch-retry": "^6.0.0",
74
74
  "fs-extra": "^11.1.0",
75
- "glob": "^7.1.6",
75
+ "glob": "^11.0.0",
76
76
  "gunzip-maybe": "^1.4.2",
77
77
  "js-yaml": "^4.1.0",
78
78
  "lodash": "^4.17.15",
@@ -87,6 +87,7 @@
87
87
  "request": "2.88.2",
88
88
  "socket.io": "^4.5.2",
89
89
  "stream-json": "^1.8.0",
90
+ "tar": "^7.4.3",
90
91
  "tar-stream": "^3.1.6",
91
92
  "typescript": "^5.1.6",
92
93
  "uuid": "^9.0.1",
@@ -99,7 +100,6 @@
99
100
  "@types/async-lock": "^1.4.0",
100
101
  "@types/express": "^4.17.17",
101
102
  "@types/fs-extra": "^11.0.1",
102
- "@types/glob": "^8.1.0",
103
103
  "@types/gunzip-maybe": "^1.4.0",
104
104
  "@types/jest": "^29.5.4",
105
105
  "@types/js-yaml": "^4.0.9",
@@ -480,7 +480,16 @@ export interface StormEventSystemReady {
480
480
  created: number;
481
481
  payload: {
482
482
  systemUrl: string;
483
- }
483
+ };
484
+ }
485
+
486
+ export interface StormEventModelResponse {
487
+ type: 'MODEL_RESPONSE';
488
+ reason: string;
489
+ created: number;
490
+ payload: {
491
+ text: string;
492
+ };
484
493
  }
485
494
 
486
495
  export type StormEvent =
@@ -518,4 +527,5 @@ export type StormEvent =
518
527
  | StormEventApiBase
519
528
  | StormEventUIStarted
520
529
  | StormImage
521
- | StormEventSystemReady;
530
+ | StormEventSystemReady
531
+ | StormEventModelResponse;
@@ -3,6 +3,7 @@
3
3
  * SPDX-License-Identifier: BUSL-1.1
4
4
  */
5
5
  import { StormEventFileDone, StormEventPage, StormImage } from './events';
6
+ import { filesystemManager } from '../filesystemManager';
6
7
 
7
8
  import { Response } from 'express';
8
9
  import os from 'node:os';
@@ -12,6 +13,7 @@ import FSExtra from 'fs-extra';
12
13
  import { ConversationItem } from './stream';
13
14
  import exp from 'node:constants';
14
15
  import { ImagePrompt } from './PageGenerator';
16
+ import clusterConfiguration from '@kapeta/local-cluster-config';
15
17
 
16
18
  export const SystemIdHeader = 'System-Id';
17
19
 
@@ -76,7 +78,7 @@ export function hasPageOnDisk(systemId: string, path: string, method: string) {
76
78
  }
77
79
 
78
80
  export function getSystemBaseDir(systemId: string) {
79
- return Path.join(os.tmpdir(), 'ai-systems', systemId);
81
+ return Path.join(filesystemManager.getProjectRootFolder() || os.tmpdir(), 'ai-systems', systemId);
80
82
  }
81
83
 
82
84
  export function getSystemBaseImplDir(systemId: string) {
@@ -49,9 +49,10 @@ import {
49
49
  } from './page-utils';
50
50
  import { UIServer } from './UIServer';
51
51
  import { randomUUID } from 'crypto';
52
- import { PageQueue } from './PageGenerator';
53
52
  import { copyDirectory, createFuture, readFilesAndContent } from './utils';
54
53
  import { getRemoteUrl } from '../utils/utils';
54
+ import stormService from '../stormService';
55
+ import { PageQueue } from './PageGenerator';
55
56
 
56
57
  const UI_SERVERS: { [key: string]: UIServer } = {};
57
58
  const router = Router();
@@ -105,6 +106,50 @@ router.post('/ui/serve/:systemId', async (req: KapetaBodyRequest, res: Response)
105
106
  res.status(200).send({ status: 'running', url: svr.getUrl(), resetUrl: svr.resolveUrlFromPath('/_reset') });
106
107
  });
107
108
 
109
+ router.get('/ui/conversations', async (req: KapetaBodyRequest, res: Response) => {
110
+ const local = await stormService.listLocalConversations();
111
+ const remote = await stormService.listRemoteConversations();
112
+ res.send({
113
+ local,
114
+ remote,
115
+ });
116
+ });
117
+
118
+ router.get('/ui/conversations/:systemId', async (req: KapetaBodyRequest, res: Response) => {
119
+ const systemId = req.params.systemId as string;
120
+ const eventsFile = getSystemBaseDir(systemId) + '/events.ndjson';
121
+
122
+ res.set('Content-Type', 'application/x-ndjson');
123
+ res.set('Access-Control-Expose-Headers', ConversationIdHeader);
124
+ res.set(ConversationIdHeader, systemId);
125
+
126
+ if (!FS.existsSync(eventsFile)) {
127
+ res.status(404).send({ error: 'No events found' });
128
+ return;
129
+ }
130
+ res.send(FS.readFileSync(eventsFile));
131
+ });
132
+
133
+ router.put('/ui/conversations/:systemId', async (req: KapetaBodyRequest, res: Response) => {
134
+ const systemId = req.params.systemId as string;
135
+ const events: StormEvent[] = req.stringBody ? JSON.parse(req.stringBody) : [];
136
+ await stormService.saveConversation(systemId, events);
137
+ res.send({ ok: true });
138
+ });
139
+
140
+ router.delete('/ui/conversations/:systemId', async (req: KapetaBodyRequest, res: Response) => {
141
+ const systemId = req.params.systemId as string;
142
+ await stormService.deleteConversation(systemId);
143
+ res.send({ ok: true });
144
+ });
145
+
146
+ router.post('/ui/conversations/:systemId/append', async (req: KapetaBodyRequest, res: Response) => {
147
+ const systemId = req.params.systemId as string;
148
+ const events: StormEvent[] = req.stringBody ? JSON.parse(req.stringBody) : [];
149
+ await stormService.appendConversation(systemId, events);
150
+ res.send({ ok: true });
151
+ });
152
+
108
153
  router.post('/ui/create-system/:handle/:systemId', async (req: KapetaBodyRequest, res: Response) => {
109
154
  const systemId = req.params.systemId as string;
110
155
  const srcDir = getSystemBaseDir(systemId);
@@ -120,6 +165,7 @@ router.post('/ui/create-system/:handle/:systemId', async (req: KapetaBodyRequest
120
165
  const pagesWithImplementation = await stormClient.replaceMockWithAPICall({
121
166
  pages: pagesFromDisk,
122
167
  });
168
+
123
169
  await copyDirectory(srcDir, destDir, (fileName, content) => {
124
170
  // find the page from result1 and write the content to the file
125
171
  const page = pagesWithImplementation.find((p) => p.fileName === fileName);
@@ -197,6 +243,20 @@ router.post('/ui/create-system-simple/:handle/:systemId', async (req: KapetaBody
197
243
  }
198
244
  });
199
245
 
246
+ router.post('/ui/systems/:handle/:systemId/download', async (req: KapetaBodyRequest, res: Response) => {
247
+ const systemId = req.params.systemId as string;
248
+ const handle = req.params.handle as string;
249
+ await stormService.installProjectById(handle, systemId);
250
+ res.send({ ok: true });
251
+ });
252
+
253
+ router.post('/ui/systems/:handle/:systemId/upload', async (req: KapetaBodyRequest, res: Response) => {
254
+ const systemId = req.params.systemId as string;
255
+ const handle = req.params.handle as string;
256
+ await stormService.uploadConversation(handle, systemId);
257
+ res.send({ ok: true });
258
+ });
259
+
200
260
  router.delete('/ui/serve/:systemId', async (req: KapetaBodyRequest, res: Response) => {
201
261
  const systemId = req.params.systemId as string | undefined;
202
262
  if (!systemId) {
@@ -371,6 +431,7 @@ router.post('/:handle/ui/iterative', async (req: KapetaBodyRequest, res: Respons
371
431
 
372
432
  router.post('/:handle/ui', async (req: KapetaBodyRequest, res: Response) => {
373
433
  const handle = req.params.handle as string;
434
+
374
435
  try {
375
436
  const outerConversationId =
376
437
  (req.headers[ConversationIdHeader.toLowerCase()] as string | undefined) || randomUUID();
@@ -929,6 +990,7 @@ function sendEvent(res: Response, evt: StormEvent) {
929
990
  if (res.closed) {
930
991
  return;
931
992
  }
993
+
932
994
  res.write(JSON.stringify(evt) + '\n');
933
995
  }
934
996
 
@@ -8,7 +8,8 @@ import readLine from 'node:readline/promises';
8
8
  import { Readable } from 'node:stream';
9
9
  import {
10
10
  ConversationItem,
11
- CreateSimpleBackendRequest, HTMLPage,
11
+ CreateSimpleBackendRequest,
12
+ HTMLPage,
12
13
  ImplementAPIClients,
13
14
  StormContextRequest,
14
15
  StormFileImplementationPrompt,
@@ -245,7 +246,7 @@ class StormClient {
245
246
  method: 'POST',
246
247
  body: JSON.stringify(prompt.pages),
247
248
  });
248
- return await response.json() as HTMLPage[];
249
+ return (await response.json()) as HTMLPage[];
249
250
  }
250
251
 
251
252
  public async generatePrompt(pages: string[]): Promise<string> {
@@ -353,6 +354,28 @@ class StormClient {
353
354
 
354
355
  return response.text();
355
356
  }
357
+
358
+ async downloadSystem(handle: string, conversationId: string) {
359
+ const u = `${this._baseUrl}/v2/systems/${handle}/${conversationId}/download`;
360
+ const response = await fetch(u);
361
+ if (!response.ok) {
362
+ throw new Error(`Failed to download system: ${response.status}`);
363
+ }
364
+ return Buffer.from(await response.arrayBuffer());
365
+ }
366
+
367
+ async uploadSystem(handle: string, conversationId: string, buffer: Buffer) {
368
+ const u = `${this._baseUrl}/v2/systems/${handle}/${conversationId}`;
369
+ const response = await fetch(u, {
370
+ method: 'PUT',
371
+ body: buffer,
372
+ headers: {
373
+ 'content-type': 'application/zip',
374
+ },
375
+ });
376
+
377
+ return response;
378
+ }
356
379
  }
357
380
 
358
381
  export const stormClient = new StormClient();
@@ -0,0 +1,151 @@
1
+ import fs from 'fs/promises';
2
+ import { glob } from 'glob';
3
+ import { filesystemManager } from './filesystemManager';
4
+ import path from 'path';
5
+ import { existsSync } from 'fs';
6
+ import { StormEvent, StormEventModelResponse, StormEventPromptImprove, StormEventUIShell } from './storm/events';
7
+ import * as tar from 'tar';
8
+ import { stormClient } from './storm/stormClient';
9
+
10
+ export class StormService {
11
+ private getConversationFile(conversationId: string) {
12
+ return path.join(filesystemManager.getProjectRootFolder()!, 'ai-systems', conversationId, 'events.ndjson');
13
+ }
14
+
15
+ private getConversationTarball(conversationId: string) {
16
+ return path.join(filesystemManager.getProjectRootFolder()!, 'ai-systems', conversationId, 'system.tar.gz');
17
+ }
18
+
19
+ async listRemoteConversations() {
20
+ // i.e. conversations from org / user on registry
21
+ return [];
22
+ }
23
+
24
+ async listLocalConversations() {
25
+ const systemsFolder = path.join(filesystemManager.getProjectRootFolder()!, 'ai-systems');
26
+ const eventFiles = await glob('*/events.ndjson', {
27
+ cwd: systemsFolder,
28
+ absolute: true,
29
+ });
30
+ // Returns list of UUIDs - probably want to make it more useful than that
31
+ const conversations: { id: string; description: string; title: string; url?: string }[] = [];
32
+ for (const file of eventFiles) {
33
+ try {
34
+ const nldContents = await fs.readFile(file as string, 'utf8');
35
+ const events = nldContents.split('\n').map((e) => JSON.parse(e)) as {
36
+ // | { type: 'USER'; event: any } // IS stupid!
37
+ type: 'AI';
38
+ systemId: string;
39
+ event: StormEvent;
40
+ reason: string;
41
+ }[];
42
+ // find the shell and get the title tag
43
+ const shellEvent = events.find((e) => e.type === 'AI' && e.event.type === 'UI_SHELL')?.event as
44
+ | StormEventUIShell
45
+ | undefined;
46
+ const html = shellEvent?.payload.content;
47
+ const title = html?.match(/<title>(.*?)<\/title>/)?.[1];
48
+ const id = events.find((e) => e.type === 'AI')?.systemId;
49
+
50
+ const initialPrompt =
51
+ (
52
+ events.find((e) => e.type === 'AI' && e.event.type === 'PROMPT_IMPROVE')?.event as
53
+ | StormEventPromptImprove
54
+ | undefined
55
+ )?.payload?.prompt || (events[0] as any).text;
56
+
57
+ if (!id) {
58
+ continue;
59
+ }
60
+
61
+ let url = undefined;
62
+ // Find the last model response event that has a URL in the payload (in case it changed over time)
63
+ for (const evt of [...events].reverse()) {
64
+ const event = evt.event;
65
+ if (evt.type === 'AI' && event.type === 'MODEL_RESPONSE') {
66
+ // Look for a URL in the model response markdown
67
+ const regex = /\[(.*?)\]\((.*?)\)/g;
68
+ const match = regex.exec(event.payload.text);
69
+ const [, _linkText, linkUrl] = match || [];
70
+ if (linkUrl?.startsWith('http')) {
71
+ url = linkUrl;
72
+ break;
73
+ }
74
+ }
75
+ }
76
+
77
+ conversations.push({
78
+ id,
79
+ description: initialPrompt,
80
+ title: title || 'New system',
81
+ url,
82
+ });
83
+ } catch (e) {
84
+ console.error('Failed to load conversation at %s', file, e);
85
+ }
86
+ }
87
+
88
+ return conversations;
89
+ }
90
+
91
+ async getConversation(conversationId: string) {
92
+ const conversationFile = this.getConversationFile(conversationId);
93
+ if (!existsSync(conversationFile)) {
94
+ throw new Error('Conversation not found');
95
+ }
96
+ return fs.readFile(conversationFile, 'utf8');
97
+ }
98
+
99
+ async saveConversation(conversationId: string, events: StormEvent[]) {
100
+ const conversationFile = this.getConversationFile(conversationId);
101
+ await fs.mkdir(path.dirname(conversationFile), { recursive: true });
102
+ await fs.writeFile(conversationFile, events.map((e) => JSON.stringify(e)).join('\n'));
103
+ }
104
+
105
+ async appendConversation(conversationId: string, events: StormEvent[]) {
106
+ const conversationFile = this.getConversationFile(conversationId);
107
+ await fs.appendFile(conversationFile, events.map((e) => JSON.stringify(e)).join('\n'));
108
+ }
109
+
110
+ async deleteConversation(conversationId: string) {
111
+ const conversationFile = this.getConversationFile(conversationId);
112
+ await fs.unlink(conversationFile);
113
+
114
+ // if it matches a UUID, it's safe to delete the directory too
115
+ const conversationDir = path.dirname(conversationFile);
116
+ if (conversationDir.match(/\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/)) {
117
+ await fs.rm(conversationDir, { recursive: true, force: true });
118
+ }
119
+ }
120
+
121
+ async uploadConversation(handle: string, conversationId: string) {
122
+ const tarballFile = this.getConversationTarball(conversationId);
123
+ const destDir = path.dirname(tarballFile);
124
+ const tarballName = path.basename(tarballFile);
125
+ await tar.create(
126
+ {
127
+ file: tarballFile,
128
+ cwd: destDir,
129
+ gzip: true,
130
+ filter: (entry) => !entry.includes(tarballName),
131
+ },
132
+ ['.']
133
+ );
134
+ await stormClient.uploadSystem(handle, conversationId, await fs.readFile(tarballFile));
135
+ }
136
+
137
+ async installProjectById(handle: string, conversationId: string) {
138
+ const tarballFile = this.getConversationTarball(conversationId);
139
+ const destDir = path.dirname(tarballFile);
140
+ const buffer = await stormClient.downloadSystem(handle, conversationId);
141
+ await fs.mkdir(destDir, { recursive: true });
142
+ await fs.writeFile(tarballFile, buffer);
143
+ await tar.extract({
144
+ file: tarballFile,
145
+ cwd: destDir,
146
+ });
147
+ await fs.unlink(tarballFile);
148
+ }
149
+ }
150
+
151
+ export default new StormService();