import fs from 'fs/promises';
import { glob, Path } from 'glob';
import { filesystemManager } from './filesystemManager';
import path from 'path';
import { existsSync } from 'fs';
import { StormEvent, StormEventModelResponse, StormEventPromptImprove, StormEventUIShell } from './storm/events';
import * as tar from 'tar';
import { StormClient } from './storm/stormClient';
export class StormService {
private getConversationFile(conversationId: string) {
return path.join(filesystemManager.getProjectRootFolder()!, 'ai-systems', conversationId, 'events.ndjson');
}
private getConversationTarball(conversationId: string) {
return path.join(filesystemManager.getProjectRootFolder()!, 'ai-systems', conversationId, 'system.tar.gz');
}
private getThumbnailFile(conversationId: string) {
return path.join(filesystemManager.getProjectRootFolder()!, 'ai-systems', conversationId, 'thumbnail.png');
}
async listRemoteConversations() {
// i.e. conversations from org / user on registry
return [];
}
async listLocalConversations() {
const systemsFolder = path.join(filesystemManager.getProjectRootFolder()!, 'ai-systems');
const eventFiles = await glob('*/events.ndjson', {
cwd: systemsFolder,
stat: true,
withFileTypes: true,
});
// Returns list of UUIDs - probably want to make it more useful than that
const conversations: {
id: string;
description: string;
title: string;
url?: string;
lastModified?: number;
createdAt?: number;
thumbnail?: string;
}[] = [];
// Sort by modification time, newest first
eventFiles.sort((a, b) => (b.mtimeMs || 0) - (a.mtimeMs || 0));
for (const file of eventFiles) {
try {
const nldContents = await fs.readFile(file.fullpath(), 'utf8');
const events = nldContents.split('\n').map((e) => JSON.parse(e)) as {
// | { type: 'USER'; event: any } // IS stupid!
type: 'AI';
systemId: string;
event: StormEvent;
reason: string;
}[];
// find the shell and get the title tag
const shellEvent = events.find((e) => e.type === 'AI' && e.event.type === 'UI_SHELL')?.event as
| StormEventUIShell
| undefined;
const html = shellEvent?.payload.content;
const title = html?.match(/
(.*?)<\/title>/)?.[1];
const id = events.find((e) => e.type === 'AI')?.systemId;
const initialPrompt =
(
events.find((e) => e.type === 'AI' && e.event.type === 'PROMPT_IMPROVE')?.event as
| StormEventPromptImprove
| undefined
)?.payload?.prompt || (events[0] as any).text;
if (!id) {
continue;
}
let url = undefined;
// Find the last model response event that has a URL in the payload (in case it changed over time)
for (const evt of [...events].reverse()) {
const event = evt.event;
if (evt.type === 'AI' && event.type === 'MODEL_RESPONSE') {
// Look for a URL in the model response markdown
const regex = /\[(.*?)\]\((.*?)\)/g;
const match = regex.exec(event.payload.text);
const [, _linkText, linkUrl] = match || [];
if (linkUrl?.startsWith('http')) {
url = linkUrl;
break;
}
}
}
conversations.push({
id,
description: initialPrompt,
title: title || 'New system',
url,
lastModified: file.mtimeMs,
createdAt: file.birthtimeMs,
thumbnail: existsSync(this.getThumbnailFile(id)) ? `thumbnail.png?v=${file.mtimeMs}` : undefined,
});
} catch (e) {
console.error('Failed to load conversation at %s', file, e);
}
}
return conversations;
}
async getConversation(conversationId: string) {
const conversationFile = this.getConversationFile(conversationId);
if (!existsSync(conversationFile)) {
throw new Error('Conversation not found');
}
return fs.readFile(conversationFile, 'utf8');
}
async saveConversation(conversationId: string, events: StormEvent[]) {
const conversationFile = this.getConversationFile(conversationId);
await fs.mkdir(path.dirname(conversationFile), { recursive: true });
await fs.writeFile(conversationFile, events.map((e) => JSON.stringify(e)).join('\n'));
}
async appendConversation(conversationId: string, events: StormEvent[]) {
const conversationFile = this.getConversationFile(conversationId);
await fs.appendFile(conversationFile, events.map((e) => JSON.stringify(e)).join('\n'));
}
async deleteConversation(conversationId: string) {
const conversationFile = this.getConversationFile(conversationId);
await fs.unlink(conversationFile);
// if it matches a UUID, it's safe to delete the directory too
const conversationDir = path.dirname(conversationFile);
if (conversationDir.match(/\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/)) {
await fs.rm(conversationDir, { recursive: true, force: true });
}
}
async uploadConversation(handle: string, systemId: string) {
const tarballFile = this.getConversationTarball(systemId);
const destDir = path.dirname(tarballFile);
const tarballName = path.basename(tarballFile);
await tar.create(
{
file: tarballFile,
cwd: destDir,
gzip: true,
filter: (entry) => !entry.includes(tarballName),
},
['.']
);
const stormClient = new StormClient(handle, systemId);
await stormClient.uploadSystem(handle, systemId, await fs.readFile(tarballFile));
}
async installProjectById(handle: string, systemId: string) {
const tarballFile = this.getConversationTarball(systemId);
const destDir = path.dirname(tarballFile);
const stormClient = new StormClient(handle, systemId);
const buffer = await stormClient.downloadSystem(handle, systemId);
await fs.mkdir(destDir, { recursive: true });
await fs.writeFile(tarballFile, buffer);
await tar.extract({
file: tarballFile,
cwd: destDir,
});
await fs.unlink(tarballFile);
}
async saveThumbnail(systemId: string, thumbnail: Buffer) {
const thumbnailFile = this.getThumbnailFile(systemId);
await fs.mkdir(path.dirname(thumbnailFile), { recursive: true });
await fs.writeFile(thumbnailFile, thumbnail);
}
async getThumbnail(systemId: string) {
const thumbnailFile = this.getThumbnailFile(systemId);
if (existsSync(thumbnailFile)) {
return fs.readFile(thumbnailFile);
}
return null;
}
}
export default new StormService();