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();