/** * Copyright 2023 Kapeta Inc. * SPDX-License-Identifier: BUSL-1.1 */ import { KapetaAPI } from '@kapeta/nodejs-api-client'; import { getRemoteUrl } from '../utils/utils'; import readLine from 'node:readline/promises'; import { Readable } from 'node:stream'; import { ConversationItem, CreateSimpleBackendRequest, HTMLPage, ImplementAPIClients, StormContextRequest, StormFileImplementationPrompt, StormStream, StormUIImplementationPrompt, StormUIListPrompt, } from './stream'; import { Page, StormEventPageUrl } from './events'; import createFetch from 'fetch-retry'; import { Agent, fetch as undiciFetch } from 'undici'; const fetchWithRetries = createFetch(global.fetch, { retries: 5, retryDelay: 10 }); export const STORM_ID = 'storm'; export const ConversationIdHeader = 'Conversation-Id'; export const SystemIdHeader = 'System-Id'; export const HandleHeader = 'Handle'; export interface UIShellsPrompt { theme?: string; pages: { name: string; title: string; filename: string; path: string; method: string; requirements: string; }[]; } export interface UIPagePrompt { name: string; title: string; filename: string; prompt: string; path: string; method: string; description: string; storage_prefix: string; shell_page?: string; // contents of theme.md theme?: string; // whether this prompt is for a global edit global_edit?: boolean; // The prompt that was used to start the conversation (user prompt or improved prompt) system_prompt?: string; // The pages we have already created existing_pages?: { path: string; description: string; }[]; // The images we have already created existing_images?: { path: string; description: string; }[]; // The page that referenced this page referenced_from?: { path: string; content: string; }; } export interface UIPageSamplePrompt extends UIPagePrompt { variantId: string; } export interface UIPageEditPrompt { planDescription: string; blockDescription: string; pages: Page[]; prompt: string; } export interface UIPageEditRequest { planDescription: string; blockDescription: string; pages: StormEventPageUrl['payload'][]; prompt: BasePromptRequest; } export interface UIPageVoteRequest { topic: string; vote: -1 | 0 | 1; mainConversationId: string; } export interface UIPageGetVoteRequest { topic: string; mainConversationId: string; } export interface BasePromptRequest { prompt: string; skipImprovement?: boolean; } export class StormClient { private readonly _baseUrl: string; private readonly _systemId: string; private readonly _handle: string; private readonly _sharedSecret: string; constructor(handle: string, systemId?: string) { this._baseUrl = getRemoteUrl('ai-service', 'https://ai.kapeta.com'); this._systemId = systemId || ''; this._handle = handle; this._sharedSecret = process.env.SHARED_SECRET || '@keep-this-super-secret!'; } private async createOptions( path: string, method: string, body: StormContextRequest ): Promise { const url = `${this._baseUrl}${path}`; const headers: { [k: string]: string } = { ...this.getSharedSecretHeader(), 'Content-Type': 'application/json', }; const api = new KapetaAPI(); if (api.hasToken()) { const token = await api.getAccessToken(); //headers['Authorization'] = `Bearer ${token}`; } if (body.conversationId) { headers[ConversationIdHeader] = body.conversationId; } if (this._systemId) { headers[SystemIdHeader] = this._systemId; } if (this._handle) { headers[HandleHeader] = this._handle; } return { url, method: method, body: JSON.stringify(body), headers, }; } private async send( path: string, body: StormContextRequest, method: string = 'POST' ): Promise { const stringPrompt = typeof body.prompt === 'string' ? body.prompt : JSON.stringify(body.prompt); const options = await this.createOptions(path, method, { prompt: stringPrompt, conversationId: body.conversationId, }); const abort = new AbortController(); options.signal = abort.signal; const response = await fetchWithRetries(options.url, options); if (response.status !== 200) { throw new Error( `Got error response from ${options.url}: ${response.status}\nContent: ${await response.text()}` ); } const conversationId = response.headers.get(ConversationIdHeader); const out = new StormStream(stringPrompt, conversationId); const jsonLStream = readLine.createInterface(Readable.fromWeb(response.body!)); jsonLStream.on('line', (line) => { out.addJSONLine(line); }); jsonLStream.on('error', (error) => { out.emit('error', error); }); jsonLStream.on('close', () => { out.end(); }); out.on('aborted', () => { try { abort.abort(); } catch (e) { console.warn('Error aborting stream', e); } }); return out; } public createMetadata(prompt: BasePromptRequest, conversationId?: string) { return this.send('/v2/all', { prompt: prompt, conversationId, }); } public createUIPages(prompt: string, conversationId?: string) { return this.send('/v2/ui/pages', { prompt: prompt, conversationId, }); } public createUIUserJourneys(prompt: BasePromptRequest, conversationId?: string) { return this.send('/v2/ui/user-journeys', { prompt: prompt, conversationId, }); } public createTheme(prompt: BasePromptRequest, conversationId?: string) { return this.send('/v2/ui/theme', { prompt: prompt, conversationId, }); } public createUIShells(prompt: UIShellsPrompt, conversationId?: string) { return this.send('/v2/ui/shells', { prompt: JSON.stringify(prompt), conversationId: conversationId, }); } public createUILandingPages(prompt: BasePromptRequest, conversationId?: string) { return this.send('/v2/ui/landing-pages', { prompt: prompt, conversationId, }); } public createUIPage(prompt: UIPagePrompt, conversationId?: string, history?: ConversationItem[]) { return this.send('/v2/ui/page', { prompt: prompt, conversationId, history, }); } public async voteUIPage(topic: string, conversationId: string, vote: -1 | 0 | 1, mainConversationId?: string) { const options = await this.createOptions('/v2/ui/vote', 'POST', { prompt: JSON.stringify({ topic, vote, mainConversationId }), conversationId, }); return fetch(options.url, options); } public async getVoteUIPage(topic: string, conversationId: string, mainConversationId?: string) { const options = await this.createOptions('/v2/ui/get-vote', 'POST', { prompt: JSON.stringify({ topic, mainConversationId }), conversationId, }); const response = await fetch(options.url, options); return response.json() as Promise<{ vote: -1 | 0 | 1 }>; } public async replaceMockWithAPICall(prompt: ImplementAPIClients): Promise { const u = `${this._baseUrl}/v2/ui/implement-api-clients-all`; try { const headers: { [key: string]: any } = this.getSharedSecretHeader(); headers[HandleHeader] = this._handle; headers[ConversationIdHeader] = this._systemId; headers[SystemIdHeader] = this._systemId; const response = await fetch(u, { method: 'POST', body: JSON.stringify(prompt.pages), headers: headers, }); if (!response.ok) { console.error('Failed to implement api clients', response.status, await response.text()); return []; } return (await response.json()) as HTMLPage[]; } catch (error) { console.error('Failed to implement api clients', error); return []; } } public async generatePrompt(pages: string[]): Promise { const u = `${this._baseUrl}/v2/ui/prompt`; const response = await fetch(u, { method: 'POST', body: JSON.stringify({ pages: pages, }), headers: this.getSharedSecretHeader(), }); return await response.text(); } public async createSimpleBackend(handle: string, systemId: string, input: CreateSimpleBackendRequest) { const u = `${this._baseUrl}/v2/create-simple-backend/${handle}/${systemId}`; const headers: { [key: string]: any } = this.getSharedSecretHeader(); headers[HandleHeader] = this._handle; headers[ConversationIdHeader] = this._systemId; headers[SystemIdHeader] = this._systemId; // Fetch with undici to override the default timeouts const response = await undiciFetch(u, { method: 'POST', body: JSON.stringify({ pages: input.pages, }), headers: headers, dispatcher: new Agent({ headersTimeout: 0, bodyTimeout: 0, }), }); if (!response.ok) { throw new Error(`HTTP error! Status: ${response.status}`); } return await response.text(); } public classifyUIReferences(prompt: string, conversationId?: string) { return this.send('/v2/ui/references', { prompt: prompt, conversationId, }); } public editPages(prompt: UIPageEditPrompt, conversationId?: string) { return this.send('/v2/ui/edit', { prompt: JSON.stringify(prompt), conversationId, }); } public listScreens(prompt: StormUIListPrompt, conversationId?: string) { return this.send('/v2/ui/list', { prompt, conversationId, }); } public createImage(prompt: string, conversationId?: string) { return this.send('/v2/ui/image', { prompt, conversationId, }); } public createUIImplementation(prompt: StormUIImplementationPrompt, conversationId?: string) { return this.send('/v2/ui/merge', { prompt, conversationId, }); } public createServiceImplementation(prompt: StormFileImplementationPrompt, conversationId?: string) { return this.send('/v2/services/merge', { prompt, conversationId, }); } public createErrorClassification(prompt: string, history?: ConversationItem[], conversationId?: string) { return this.send('/v2/code/errorclassifier', { conversationId: conversationId, prompt, }); } public createCodeFix(prompt: string, history?: ConversationItem[], conversationId?: string) { return this.send('/v2/code/fix', { conversationId: conversationId, prompt, }); } public createErrorDetails(prompt: string, history?: ConversationItem[], conversationId?: string) { return this.send('/v2/code/errordetails', { conversationId: conversationId, prompt, }); } public generateCode(prompt: StormFileImplementationPrompt, conversationId?: string) { return this.send('/v2/code/generate', { prompt, conversationId: conversationId, }); } async deleteUIPageConversation(conversationId: string) { const options = await this.createOptions('/v2/ui/page', 'DELETE', { prompt: '', conversationId: conversationId, }); const response = await fetch(options.url, options); return response.text(); } async downloadSystem(handle: string, conversationId: string) { const u = `${this._baseUrl}/v2/systems/${handle}/${conversationId}/download`; const response = await fetch(u, { headers: this.getSharedSecretHeader(), }); if (!response.ok) { throw new Error(`Failed to download system: ${response.status}`); } return Buffer.from(await response.arrayBuffer()); } async uploadSystem(handle: string, conversationId: string, buffer: Buffer) { const u = `${this._baseUrl}/v2/systems/${handle}/${conversationId}`; const response = await fetch(u, { method: 'PUT', body: buffer, headers: { ...this.getSharedSecretHeader(), 'content-type': 'application/zip', }, }); return response; } private getSharedSecretHeader() { return { SharedSecret: this._sharedSecret, }; } }