UNPKG

@stackbit/utils

Version:
473 lines 18.6 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.Actions = void 0; const eventsource_1 = __importDefault(require("eventsource")); const axios_1 = __importDefault(require("axios")); const url_1 = require("url"); const client_1 = require("@notionhq/client"); const notion_to_md_1 = require("notion-to-md"); function GenerateContentFromPreset({ label, mainListField, customPrompt, allowOverrideCustomPrompt, modelsConfig, siteId }) { return { type: 'model', name: '_create_from_preset_ai', label: label ?? 'Generate content with AI', models: modelsConfig ? modelsConfig.map((model) => model.name) : undefined, inputFields: [ { type: 'slug', name: 'slug', label: 'Slug' }, ...(siteId ? [ { type: 'string', name: '_spark_ai_site_id', hidden: true, default: siteId } ] : []), ...(allowOverrideCustomPrompt ? [ { type: 'text', name: 'customPrompt', label: 'Custom Prompt', default: customPrompt } ] : []) ], run: async (options) => { const logger = options.getLogger(); logger.info(`generate content from preset`); const schemas = options.getSchemas(); const modelsByName = {}; for (const schema of schemas) { for (const model of schema.models) { modelsByName[model.name] = model; } } const sparkClient = new SparkClient({ logger }); const modelConf = (modelsConfig ?? []).find((model) => model.name === options.actionModel.name); if (modelConf) { mainListField = modelConf.mainListField ?? mainListField; customPrompt = modelConf.customPrompt ?? customPrompt; } const progressCallback = createProgressCallback(options.progress); progressCallback({ percent: 0, categoryMessage: 'Initializing', progressMessage: 'Initializing' }); const contentMarkdown = await fetchContent(options, progressCallback, logger); if (!contentMarkdown || contentMarkdown.length === 0) { throw new Error('Content must not be empty'); } const result = await sparkClient.performContentGen({ contentMarkdown, knowledge: getKnowledgeForInputData(options.inputData), preset: options.inputData?.presetData, customPrompt: options.inputData?.customPrompt ?? customPrompt, modelName: options.actionModel.name, sectionsField: mainListField, engineName: 'openai', modelsByName, progressCallback }); if (options.inputData?.slug) { result.slug = options.inputData?.slug; } logger.info('create document'); progressCallback({ percent: 95, categoryMessage: 'Saving content', progressMessage: 'Saving content' }); const { documentId } = await options.contentSourceActions.createDocumentFromObject({ modelName: options.actionModel.name, object: result }); logger.info(`created document with ID: '${documentId}'`); return { success: 'Successfully generated document', result: { documentId } }; } }; } class SparkClient { constructor({ logger }) { this.logger = logger; } async performContentGen({ contentMarkdown, knowledge, preset, modelName, sectionsField, customPrompt, engineName, modelsByName, progressCallback }) { const presetData = JSON.stringify(preset); const modelsByNameData = JSON.stringify(modelsByName); this.logger.debug('initialize content-gen workload'); const baseUrl = process.env.SPARK_URL ?? 'https://api-create.services.netlify.com/spark'; const initializedWorkloadResult = await (0, axios_1.default)({ url: `${baseUrl}/api/v1/workload/content-gen`, method: 'post', headers: { 'Content-Type': 'application/json', ...(process.env.SPARK_API_KEY ? { 'x-spark-api-key': process.env.SPARK_API_KEY } : null) }, data: { inputs: { model: modelName, engine: engineName ?? 'openai', sectionsField: sectionsField, customPrompt: customPrompt }, uploads: { createData: { preset: hashCode(presetData), modelsByName: hashCode(modelsByNameData) }, sourceData: { sourceContent: hashCode(contentMarkdown) } }, knowledge } }).catch((error) => { throw new Error(`Failed to initialize AI workload: ${error.message}`); }); // all of our signed urls are set on this workload result object. const workloadData = initializedWorkloadResult.data; this.logger.debug('initialized workload'); if (!workloadData.startWorkload) { throw new Error('Workload does not contain startWorkload URL'); } // upload the resources we specified that we would upload on the original init call this.logger.debug('uploading workload createData'); progressCallback({ percent: 5, categoryMessage: 'Initializing', progressMessage: 'Uploading content' }); await Promise.all([ (0, axios_1.default)({ url: workloadData.requiredUploads.createData.preset, method: 'post', headers: { 'Content-Type': 'application/json' }, data: preset }), (0, axios_1.default)({ url: workloadData.requiredUploads.createData.modelsByName, method: 'post', headers: { 'Content-Type': 'application/json' }, data: modelsByName }), (0, axios_1.default)({ url: workloadData.requiredUploads.sourceData.sourceContent, method: 'post', headers: { 'Content-Type': 'application/json' }, data: contentMarkdown }) ]).catch((error) => { throw new Error(`Failed to upload data to AI workload: ${error.message}`); }); // now that all files are uploaded, kick off the workload await (0, axios_1.default)({ url: workloadData.startWorkload, method: 'post' }).catch((error) => { throw new Error(`Failed to start AI workload: ${error.message}`); }); this.logger.debug('started workload'); // wait until the workload is done await this.waitUntilDone({ sseUrl: workloadData.progress.sse, progressCallback: (sparkEvent) => { progressCallback(null, sparkEvent); } }); progressCallback({ percent: 90, categoryMessage: 'Transforming content', progressMessage: 'Content transformation completed' }); // fetch the result this.logger.debug('workload finished, fetching workload result'); const workloadResult = await (0, axios_1.default)({ url: workloadData.workloadResult, method: 'get' }).catch((error) => { throw new Error(`Failed to get AI workload result: ${error.message}`); }); this.logger.debug('got workload result', { status: workloadResult.status, statusText: workloadResult.statusText }); return workloadResult.data; } waitUntilDone({ sseUrl, progressCallback, retryCount = 0 }) { this.logger.debug('subscribe to server-side-events', { retryCount }); const sse = new eventsource_1.default(sseUrl); return new Promise((resolve, reject) => { sse.onerror = (event) => { sse.close(); this.logger.error('server-side-event error', { event }); if (retryCount < 3) { setTimeout(() => { resolve(this.waitUntilDone({ sseUrl, progressCallback, retryCount: retryCount + 1 })); }, 1000); } else { this.logger.error('got 3 server-side-event errors, aborting'); reject(new Error('AI Workload failed: could not establish SSE channel')); } }; sse.addEventListener('message', (event) => { this.logger.debug('eventsource event:', { event }); try { const sparkEvent = JSON.parse(event.data); if (!sparkEvent.latest) { return; } const percentage = sparkEvent.latest.percentage; if (typeof percentage === 'number' && percentage >= 100) { sse.close(); if (sparkEvent.latest.systemFailure) { reject(new Error(`AI Workload failed: ${sparkEvent.latest.errorMessage}`)); } else { resolve(); } } else { progressCallback?.(sparkEvent); } } catch (error) { sse.close(); reject(new Error('AI Workload failed: could not parse Spark event')); } }); }); } } function createProgressCallback(progressCallback) { const localProgressBefore = []; const localProgressAfter = []; let remoteProgress = []; let gotRemoteEvents = false; const adjustPercentage = (percentage) => { return 10 + Math.round((percentage ?? 0) * 0.8); }; return (localEvent, remoteSparkEvent) => { let sparkEvent; if (remoteSparkEvent) { gotRemoteEvents = true; remoteProgress = remoteSparkEvent.allProgress?.map((step) => { return { ...step, percentage: adjustPercentage(step.percentage) }; }) ?? remoteProgress; if (remoteSparkEvent.latest) { sparkEvent = { ...remoteSparkEvent, latest: { ...remoteSparkEvent.latest, percentage: adjustPercentage(remoteSparkEvent.latest.percentage) }, allProgress: localProgressBefore.concat(remoteProgress ?? []) }; } } else if (localEvent) { const latest = createSparkEventProgressMessage(localEvent); if (!gotRemoteEvents) { localProgressBefore.push(latest); sparkEvent = { latest, allProgress: localProgressBefore }; } else { localProgressAfter.push(latest); sparkEvent = { latest, allProgress: localProgressBefore.concat(remoteProgress, localProgressAfter) }; } } if (sparkEvent?.latest) { progressCallback?.({ percent: sparkEvent.latest?.percentage, message: JSON.stringify(sparkEvent) }); } }; } function createSparkEventProgressMessage(options) { return { percentage: options.percent, categoryMessage: options.categoryMessage, progressMessage: options.progressMessage, systemFailure: options.systemFailure, errorMessage: options.errorMessage }; } function hashCode(str) { let hash = 0; for (let i = 0; i < str.length; i++) { const code = str.charCodeAt(i); hash = (hash << 5) - hash + code; hash = hash & hash; // Convert to 32bit integer } return hash; } function getKnowledgeForInputData(inputData) { if (!inputData) { return undefined; } if (inputData._spark_ai_knowledge) { return inputData._spark_ai_knowledge; } const selected = []; if (inputData._spark_ai_voiceTone) { selected.push({ type: 'voice-and-tone', id: inputData._spark_ai_voiceTone }); } if (inputData._spark_ai_targetAudience) { selected.push({ type: 'target-audience', id: inputData._spark_ai_targetAudience }); } if (selected.length === 0) { return undefined; } return { scopes: { shared: 'netlify-known' }, selected }; } async function fetchContent(options, progressCallback, logger) { const googleDoc = await fetchGoogleDoc(options, progressCallback, logger); if (googleDoc) { logger.debug('Got Google doc'); return googleDoc; } const notionPage = await fetchNotionPage(options, progressCallback, logger); if (notionPage) { logger.debug('Got Notion page'); return notionPage; } return options.inputData?._spark_ai_content; } async function fetchGoogleDoc(options, progressCallback, logger) { const googleDocUrl = options.inputData?._spark_ai_googleDocUrl ?? options.inputData?._spark_ai_content; const match = googleDocUrl?.match(/docs\.google\.com\/document\/d\/([^/?#]+)/); if (!match) { return; } const googleDocId = match[1]; if (!googleDocId) { return; } logger.debug('Found Google doc URL'); let googleConnection; if (options.currentUser && 'connections' in options.currentUser && Array.isArray(options.currentUser.connections)) { googleConnection = options.currentUser.connections.find((connection) => connection.type === 'google'); } if (!googleConnection || !('accessToken' in googleConnection)) { logger.debug('User does not have Google connection'); throw new Error('Please connect your Google account'); } progressCallback({ percent: 2, categoryMessage: 'Initializing', progressMessage: `Fetching from ${googleDocUrl}` }); logger.debug('Fetching Google doc'); // const url = `https://docs.google.com/feeds/download/documents/export/Export?id=${documentId}&exportFormat=markdown`; const url = `https://www.googleapis.com/drive/v3/files/${googleDocId}/export?mimeType=text/markdown`; const googleDocResult = await (0, axios_1.default)({ url: url, method: 'get', headers: { Authorization: `Bearer ${googleConnection.accessToken}` } }).catch((error) => { throw new Error(`Failed to fetch Google doc: ${error.message}`); }); return googleDocResult.data; } async function fetchNotionPage(options, progressCallback, logger) { // Example for notion page URLs // https://www.notion.so/{COMPANY_SLUG}/{PAGE_SLUG}-{PAGE_ID} // https://www.notion.so/{PAGE_ID} // https://notion.so/{PAGE_SLUG}-{PAGE_ID} const notionUrl = options.inputData?._spark_ai_notionUrl ?? options.inputData?._spark_ai_content; if (!/notion\.so\//.test(notionUrl)) { return undefined; } let urlObject; try { urlObject = new url_1.URL(notionUrl); } catch (error) { return undefined; } if (!['www.notion.so', 'notion.so'].includes(urlObject.hostname)) { return undefined; } // The notion page ID is encoded into the last section of the URL path after the last hyphen // https://www.notion.so/Hello-World-12a4dc5adgh230e5ad5ad56456b457d9 // ↳ Notion Page ID ↲ const pathParts = urlObject.pathname.replace(/^\//, '').split('/'); const notionPageSlugAndId = pathParts[pathParts.length - 1]; if (!notionPageSlugAndId) { return undefined; } const pageSlugAndIdParts = notionPageSlugAndId.split('-'); const notionPageId = pageSlugAndIdParts[pageSlugAndIdParts.length - 1]; if (!notionPageId) { return undefined; } logger.debug(`Found Notion page URL with ID: ${notionPageId}`); let notionConnection; if (options.currentUser && 'connections' in options.currentUser && Array.isArray(options.currentUser.connections)) { notionConnection = options.currentUser.connections.find((connection) => connection.type === 'notion'); } if (!notionConnection || !('accessToken' in notionConnection)) { logger.debug('User does not have Notion connection'); throw new Error('Please connect your Notion account'); } progressCallback({ percent: 2, categoryMessage: 'Initializing', progressMessage: `Fetching from ${notionUrl}` }); logger.debug('Fetching Notion page'); const notionClient = new client_1.Client({ auth: notionConnection.accessToken }); const n2m = new notion_to_md_1.NotionToMarkdown({ notionClient }); const mdBlocks = await n2m.pageToMarkdown(notionPageId); const mdString = n2m.toMarkdownString(mdBlocks); return mdString.parent; } exports.Actions = { GenerateContentFromPreset }; //# sourceMappingURL=ai-actions.js.map