From 0464011d3ca845bc0451cbff9455d0d1a6d5618c Mon Sep 17 00:00:00 2001 From: blindmansion Date: Sat, 17 Aug 2024 22:09:36 -0400 Subject: [PATCH 1/2] Break up settings tab --- packages/cannoli-plugin/src/main.ts | 1549 +---------------- .../cannoli-plugin/src/modals/customModal.ts | 405 +++++ packages/cannoli-plugin/src/modals/editVal.ts | 42 + .../src/modals/httpTemplateEditor.ts | 189 ++ .../src/modals/runPriceAlert.ts | 87 + .../src/modals/versionTwoModal.ts | 16 + .../cannoli-plugin/src/modals/viewVals.ts | 123 ++ .../src/settings/sections/actionSettings.ts | 73 + .../src/settings/sections/bakingSettings.ts | 57 + .../src/settings/sections/canvasSettings.ts | 25 + .../settings/sections/chatCannoliSettings.ts | 50 + .../src/settings/sections/llmSettings.ts | 78 + .../sections/llmSettings/anthropicSettings.ts | 97 ++ .../llmSettings/azureOpenAISettings.ts | 126 ++ .../sections/llmSettings/geminiSettings.ts | 63 + .../sections/llmSettings/groqSettings.ts | 63 + .../sections/llmSettings/ollamaSettings.ts | 63 + .../sections/llmSettings/openAISettings.ts | 105 ++ .../sections/noteExtractionSettings.ts | 68 + .../src/settings/sections/secretsSettings.ts | 74 + .../sections/transcriptionSettings.ts | 69 + .../src/settings/sections/valtownSettings.ts | 39 + .../cannoli-plugin/src/settings/settings.ts | 96 + .../src/settings/settingsTab.ts | 63 + packages/cannoli-plugin/src/val_templates.ts | 178 -- .../cannoli-plugin/src/vault_interface.ts | 406 +---- 26 files changed, 2079 insertions(+), 2125 deletions(-) create mode 100644 packages/cannoli-plugin/src/modals/customModal.ts create mode 100644 packages/cannoli-plugin/src/modals/editVal.ts create mode 100644 packages/cannoli-plugin/src/modals/httpTemplateEditor.ts create mode 100644 packages/cannoli-plugin/src/modals/runPriceAlert.ts create mode 100644 packages/cannoli-plugin/src/modals/versionTwoModal.ts create mode 100644 packages/cannoli-plugin/src/modals/viewVals.ts create mode 100644 packages/cannoli-plugin/src/settings/sections/actionSettings.ts create mode 100644 packages/cannoli-plugin/src/settings/sections/bakingSettings.ts create mode 100644 packages/cannoli-plugin/src/settings/sections/canvasSettings.ts create mode 100644 packages/cannoli-plugin/src/settings/sections/chatCannoliSettings.ts create mode 100644 packages/cannoli-plugin/src/settings/sections/llmSettings.ts create mode 100644 packages/cannoli-plugin/src/settings/sections/llmSettings/anthropicSettings.ts create mode 100644 packages/cannoli-plugin/src/settings/sections/llmSettings/azureOpenAISettings.ts create mode 100644 packages/cannoli-plugin/src/settings/sections/llmSettings/geminiSettings.ts create mode 100644 packages/cannoli-plugin/src/settings/sections/llmSettings/groqSettings.ts create mode 100644 packages/cannoli-plugin/src/settings/sections/llmSettings/ollamaSettings.ts create mode 100644 packages/cannoli-plugin/src/settings/sections/llmSettings/openAISettings.ts create mode 100644 packages/cannoli-plugin/src/settings/sections/noteExtractionSettings.ts create mode 100644 packages/cannoli-plugin/src/settings/sections/secretsSettings.ts create mode 100644 packages/cannoli-plugin/src/settings/sections/transcriptionSettings.ts create mode 100644 packages/cannoli-plugin/src/settings/sections/valtownSettings.ts create mode 100644 packages/cannoli-plugin/src/settings/settings.ts create mode 100644 packages/cannoli-plugin/src/settings/settingsTab.ts delete mode 100644 packages/cannoli-plugin/src/val_templates.ts diff --git a/packages/cannoli-plugin/src/main.ts b/packages/cannoli-plugin/src/main.ts index f65b2bf..67c7ab2 100644 --- a/packages/cannoli-plugin/src/main.ts +++ b/packages/cannoli-plugin/src/main.ts @@ -1,18 +1,13 @@ import { - App, - Modal, Notice, Plugin, - PluginSettingTab, RequestUrlParam, - Setting, TFile, TFolder, addIcon, requestUrl, } from "obsidian"; import { - HttpTemplate, ResponseTextFetcher, SupportedProviders, CanvasData, @@ -27,8 +22,6 @@ import { Replacer, run, bake, - BakeLanguage, - BakeRuntime, CannoliFunctionInfo, parseCannoliFunctionInfo } from "@deablabs/cannoli-core"; @@ -38,101 +31,12 @@ import { VaultInterface } from "./vault_interface"; import { CanvasPersistor } from "./canvas"; import { dataviewQuery, smartConnectionsQuery, modalMaker } from "./actions"; import { BakeResult } from "@deablabs/cannoli-core"; - -interface CannoliSettings { - llmProvider: SupportedProviders; - ollamaBaseUrl: string; - ollamaModel: string; - ollamaTemperature: number; - azureAPIKey: string; - azureModel: string; - azureTemperature: number; - azureOpenAIApiDeploymentName: string; - azureOpenAIApiInstanceName: string; - azureOpenAIApiVersion: string; - azureBaseURL: string; - geminiModel: string; - geminiAPIKey: string; - geminiTemperature: number; - anthropicModel: string; - anthropicAPIKey: string; - anthropicTemperature: number; - anthropicBaseURL: string; - groqModel: string; - groqAPIKey: string; - groqTemperature: number; - openaiAPIKey: string; - openaiBaseURL: string; - requestThreshold: number; - defaultModel: string; - defaultTemperature: number; - httpTemplates: HttpTemplate[]; - includeFilenameAsHeader: boolean; - includePropertiesInExtractedNotes: boolean; - includeLinkInExtractedNotes: boolean; - chatFormatString: string; - enableAudioTriggeredCannolis?: boolean; - deleteAudioFilesAfterAudioTriggeredCannolis?: boolean; - transcriptionPrompt?: string; - autoScrollWithTokenStream: boolean; - pLimit: number; - contentIsColorless: boolean; - valTownAPIKey: string; - exaAPIKey: string; - bakedCannoliFolder: string; - bakeLanguage: BakeLanguage; - bakeRuntime: BakeRuntime; - bakeIndent: "2" | "4"; - seenVersion2Modal: boolean; - secrets: { name: string; value: string; visibility: string }[]; -} - -const DEFAULT_SETTINGS: CannoliSettings = { - llmProvider: "openai", - ollamaBaseUrl: "http://127.0.0.1:11434", - ollamaModel: "llama2", - ollamaTemperature: 1, - azureModel: "", - azureAPIKey: "", - azureTemperature: 1, - azureOpenAIApiDeploymentName: "", - azureOpenAIApiInstanceName: "", - azureOpenAIApiVersion: "", - azureBaseURL: "", - geminiModel: "gemini-1.0-pro-latest", - geminiAPIKey: "", - geminiTemperature: 1, - anthropicModel: "claude-3-opus-20240229", - anthropicAPIKey: "", - anthropicTemperature: 1, - anthropicBaseURL: "", - groqModel: "llama3-70b-8192", - groqAPIKey: "", - groqTemperature: 1, - openaiAPIKey: "", - openaiBaseURL: "", - requestThreshold: 20, - defaultModel: "gpt-4o", - defaultTemperature: 1, - httpTemplates: [], - includeFilenameAsHeader: false, - includePropertiesInExtractedNotes: false, - includeLinkInExtractedNotes: false, - chatFormatString: `---\n# {{role}}\n\n{{content}}`, - enableAudioTriggeredCannolis: false, - deleteAudioFilesAfterAudioTriggeredCannolis: false, - autoScrollWithTokenStream: false, - pLimit: 50, - contentIsColorless: false, - valTownAPIKey: "", - exaAPIKey: "", - bakedCannoliFolder: "Baked Cannoli", - bakeLanguage: "typescript", - bakeRuntime: "node", - bakeIndent: "2", - seenVersion2Modal: false, - secrets: [], -}; +import { Version2Modal } from "./modals/versionTwoModal"; +import { ValTownModal } from "./modals/viewVals"; +import { EditValModal } from "./modals/editVal"; +import { RunPriceAlertModal } from "./modals/runPriceAlert"; +import { CannoliSettings, DEFAULT_SETTINGS } from "./settings/settings"; +import { CannoliSettingTab } from "./settings/settingsTab"; export default class Cannoli extends Plugin { settings: CannoliSettings; @@ -1451,1444 +1355,3 @@ export default class Cannoli extends Plugin { }; } -export class EditValModal extends Modal { - onContinue: () => void; - onCancel: () => void; - - constructor( - app: App, - onContinue: () => void, - onCancel: () => void, - ) { - super(app); - this.onContinue = onContinue; - this.onCancel = onCancel; - } - - onOpen() { - const { contentEl } = this; - contentEl.createEl("h1", { text: "Val Already Exists" }); - - contentEl.createEl("p", { - text: "A Val with this name already exists. Would you like to update the existing Val with the new content?", - }); - - const panel = new Setting(contentEl); - panel.addButton((btn) => btn.setButtonText("Yes, Update") - .setCta() - .onClick(() => { - this.close(); - this.onContinue(); - })); - panel.addButton((btn) => btn.setButtonText("No, Cancel").onClick(() => { - this.close(); - this.onCancel(); - })); - } - - onClose() { - const { contentEl } = this; - contentEl.empty(); - } -} -// } - -export class RunPriceAlertModal extends Modal { - usage: Record; - onContinue: () => void; - onCancel: () => void; - requestThreshold: number; - - constructor( - app: App, - usage: Record, - requestThreshold: number, - onContinue: () => void, - onCancel: () => void, - ) { - super(app); - this.usage = usage; - this.onContinue = onContinue; - this.onCancel = onCancel; - this.requestThreshold = requestThreshold; - } - - onOpen() { - const { contentEl } = this; - - let totalCalls = 0; - let totalPromptTokens = 0; - - for (const usage of Object.values(this.usage)) { - totalCalls += usage.numberOfCalls; - totalPromptTokens += usage.promptTokens ?? 0; - } - - contentEl.createEl("h1", { text: "Run usage alert" }); - contentEl.createEl("p", { - text: `This run exceeds the AI requests threshold defined in your settings: ${this.requestThreshold}`, - }); - - // Convert usage object to array - for (const [model, usage] of Object.entries(this.usage)) { - contentEl.createEl("h2", { text: `Model: ${model}` }); - contentEl - .createEl("p", { - text: `\t\tEstimated prompt tokens: ${usage.promptTokens}`, - }) - .addClass("whitespace"); - contentEl - .createEl("p", { - text: `\t\tNumber of AI requests: ${usage.numberOfCalls}`, - }) - .addClass("whitespace"); - } - - contentEl.createEl("h2", { - text: `Total AI requests: ${totalCalls}`, - }); - - contentEl.createEl("h2", { - text: `Total estimated prompt tokens: ${totalPromptTokens}`, - }); - - const panel = new Setting(contentEl); - - panel.addButton((btn) => - btn.setButtonText("Cancel").onClick(() => { - this.close(); - this.onCancel(); - }) - ); - - panel.addButton((btn) => - btn - .setButtonText("Run anyway") - .setCta() - .onClick(() => { - this.close(); - this.onContinue(); - }) - ); - } - - onClose() { - const { contentEl } = this; - contentEl.empty(); - } -} - -export class HttpTemplateEditorModal extends Modal { - template: HttpTemplate; - onSave: (template: HttpTemplate) => void; - onCancel: () => void; - - constructor( - app: App, - template: HttpTemplate, - onSave: (template: HttpTemplate) => void, - onCancel: () => void - ) { - super(app); - this.template = template; - this.onSave = onSave; - this.onCancel = onCancel; - } - - onOpen() { - const { contentEl } = this; - - contentEl.addClass("http-template-editor"); - contentEl.createEl("h1", { text: "Edit action node template" }); - - // Add some space between the header and the description - contentEl.createEl("div", { cls: "spacer" }); - - // Insert a spacer element - contentEl.createEl("div", { cls: "spacer", attr: { style: "height: 20px;" } }); - - const createDescription = (text: string) => { - const p = contentEl.createEl("p", { - cls: "http-template-description", - }); - // Allow newlines in the description - p.innerHTML = text.replace(/\n/g, "
"); - return p; - }; - - // Brief description of what this modal does - createDescription( - `This modal allows you to edit the template for an action node. You can use this template to predefine the structure of http requests.\n\nUse {{variableName}} syntax to insert variables anywhere in the request. If there's only one variable, it will be replaced with whatever is written to the action node. If there are multiple variables, the action node will look for the variables in the available named arrows.` - ); - - const createInputGroup = ( - labelText: string, - inputElement: HTMLElement, - id: string - ) => { - const div = contentEl.createEl("div", { - cls: "http-template-group", - }); - const label = div.createEl("label", { text: labelText }); - label.htmlFor = id; - inputElement.setAttribute("id", id); - div.appendChild(inputElement); - }; - - - - const nameInput = contentEl.createEl("input", { - type: "text", - value: this.template.name || "", - }) as HTMLInputElement; - nameInput.setAttribute("id", "name-input"); - createInputGroup("Name:", nameInput, "name-input"); - - const urlInput = contentEl.createEl("input", { - type: "text", - value: this.template.url || "", - }) as HTMLInputElement; - urlInput.setAttribute("id", "url-input"); - urlInput.setAttribute("placeholder", "https://example.com/{{path}}"); - createInputGroup("URL:", urlInput, "url-input"); - - // Create a select element for HTTP methods - const methodSelect = contentEl.createEl("select") as HTMLSelectElement; - const methods = ["GET", "POST", "PUT", "DELETE"]; - methods.forEach((method) => { - const option = methodSelect.createEl("option", { - text: method, - value: method, - }); - // If the current template's method matches, select this option - if (this.template.method === method) { - option.selected = true; - } - }); - createInputGroup("Method:", methodSelect, "method-select"); - - const headersValue = - this.template.headers && - this.template.headers.length > 0 - ? this.template.headers - : `{ "Content-Type": "application/json" }`; - - const headersInput = contentEl.createEl("textarea") as HTMLTextAreaElement; - - headersInput.value = headersValue; - headersInput.setAttribute("rows", "3"); - headersInput.setAttribute("placeholder", `{ "Content-Type": "application/json" }`); - - createInputGroup("Headers: (optional)", headersInput, "headers-input"); - - // Body template input - const bodyInput = contentEl.createEl("textarea", { - placeholder: - "Enter body. Use {{variableName}} for variables.", - }) as HTMLTextAreaElement; - - const bodyValue = this.template.body ?? this.template.bodyTemplate ?? ''; - - const formattedBody = this.formatBody(bodyValue); - bodyInput.value = formattedBody; - bodyInput.setAttribute("rows", "3"); - bodyInput.setAttribute( - "placeholder", - "Enter body template. Use {{variableName}} for variables." - ); - createInputGroup( - "Body: (optional)", - bodyInput, - "body-input" - ); - - const panel = new Setting(contentEl); - - panel.addButton((btn) => - btn.setButtonText("Cancel").onClick(() => { - this.close(); - this.onCancel(); - }) - ); - - panel.addButton((btn) => - btn - .setButtonText("Save") - .setCta() - .onClick(() => { - if (!urlInput.value) { - alert("URL is required"); - return; - } - - try { - JSON.parse(headersInput.value || "{}"); - } catch (error) { - alert( - "Invalid JSON format for headers. Please correct and try again." - ); - return; - } - - // Updating template object - this.template.name = nameInput.value; - this.template.url = urlInput.value; - this.template.headers = headersInput.value; - this.template.method = methodSelect.value; - this.template.body = bodyInput.value; - - // Delete deprecated bodyTemplate - delete this.template.bodyTemplate; - - this.close(); - this.onSave(this.template); - }) - ); - } - - onClose() { - const { contentEl } = this; - contentEl.empty(); - } - - formatBody(body: string): string { - try { - // Try to parse the body as JSON - const parsedBody = JSON.parse(body); - - // If successful, stringify it with proper formatting - return JSON.stringify(parsedBody, null, 2); - } catch (error) { - // If parsing failed, return the body as-is - return body; - } - } -} - -export class ValTownModal extends Modal { - cannoliFunctions: Array<{ link: string; moduleUrl: string; httpEndpointUrl: string; cannoliFunctionInfo: CannoliFunctionInfo, identicalToLocal: boolean, localExists: boolean }>; - openCanvas: (canvasName: string) => boolean; - valtownApiKey: string; - bakeToValTown: (canvasName: string) => Promise; - getCannoliFunctions: () => Promise>; - createCanvas: (name: string, canvas: string) => void; - - constructor(app: App, cannoliFunctions: Array<{ link: string; moduleUrl: string; httpEndpointUrl: string; cannoliFunctionInfo: CannoliFunctionInfo, identicalToLocal: boolean, localExists: boolean }>, getCannoliFunctions: () => Promise>, openCanvas: (canvasName: string) => boolean, valtownApiKey: string, bakeToValTown: (canvasName: string) => Promise, createCanvas: (name: string, canvas: string) => void) { - super(app); - this.cannoliFunctions = cannoliFunctions; - this.openCanvas = openCanvas; - this.valtownApiKey = valtownApiKey; - this.bakeToValTown = bakeToValTown; - this.getCannoliFunctions = getCannoliFunctions; - this.createCanvas = createCanvas; - } - - onOpen() { - const { contentEl } = this; - contentEl.createEl("h1", { text: "Cannoli Vals" }); - - // Add CSS styles for table borders - const style = document.createElement('style'); - style.textContent = ` - .cannoli-table, .cannoli-table th, .cannoli-table td { - border: 1px solid grey; - border-collapse: collapse; - } - .cannoli-table th, .cannoli-table td { - padding: 8px; - } - .synced { - color: green; - } - `; - document.head.appendChild(style); - - const table = contentEl.createEl("table", { cls: "cannoli-table" }); - const tbody = table.createEl("tbody"); - - this.cannoliFunctions.forEach((func) => { - const { canvasName } = func.cannoliFunctionInfo; - const displayName = canvasName.replace(/\.canvas|\.cno/g, ""); - - const row = tbody.createEl("tr"); - - row.createEl("td", { text: displayName }); - - const canvasCell = row.createEl("td"); - const openCanvasButton = canvasCell.createEl("button", { text: "Canvas" }); - openCanvasButton.addEventListener("click", () => { - const found = this.openCanvas(canvasName); - if (found) { - this.close(); - } - }); - - const valCell = row.createEl("td"); - const openValButton = valCell.createEl("button", { text: "Val" }); - openValButton.addEventListener("click", () => { - window.open(func.link, "_blank"); - }); - - const copyUrlCell = row.createEl("td"); - const copyButton = copyUrlCell.createEl("button", { text: "📋 URL" }); - copyButton.addEventListener("click", () => { - navigator.clipboard.writeText(func.httpEndpointUrl).then(() => { - new Notice("HTTP Endpoint URL copied to clipboard"); - }).catch((err) => { - console.error("Failed to copy text: ", err); - }); - }); - - const copyCurlCell = row.createEl("td"); - const copyCurlButton = copyCurlCell.createEl("button", { text: "📋 cURL" }); - copyCurlButton.addEventListener("click", () => { - const curlCommand = `curl -X POST ${func.httpEndpointUrl} \\ --H "Authorization: Bearer ${this.valtownApiKey}" \\ --H "Content-Type: application/json" \\ -${Object.keys(func.cannoliFunctionInfo.params).length > 0 ? `-d '${JSON.stringify(Object.fromEntries(Object.keys(func.cannoliFunctionInfo.params).map(param => [param, "value"])), null, 2)}'` : ''}`; - navigator.clipboard.writeText(curlCommand).then(() => { - new Notice("cURL command copied to clipboard"); - }).catch((err) => { - console.error("Failed to copy text: ", err); - }); - }); - - const syncStatusCell = row.createEl("td"); - - if (!func.localExists) { - const syncButton = syncStatusCell.createEl("button", { text: "Download" }); - syncButton.addEventListener("click", async () => { - this.createCanvas(func.cannoliFunctionInfo.canvasName, JSON.stringify(func.cannoliFunctionInfo.cannoli)); - new Notice(`Copied ${func.cannoliFunctionInfo.canvasName} to vault`); - const newCannoliFunctions = await this.getCannoliFunctions(); - const newModal = new ValTownModal(this.app, newCannoliFunctions, this.getCannoliFunctions, this.openCanvas, this.valtownApiKey, this.bakeToValTown, this.createCanvas); - this.close(); - newModal.open(); - }); - } else if (func.identicalToLocal) { - syncStatusCell.createEl("span", { text: "Synced", cls: "synced" }); - } else { - const syncButton = syncStatusCell.createEl("button", { text: "Upload" }); - syncButton.addEventListener("click", async () => { - await this.bakeToValTown(canvasName); - const newCannoliFunctions = await this.getCannoliFunctions(); - const newModal = new ValTownModal(this.app, newCannoliFunctions, this.getCannoliFunctions, this.openCanvas, this.valtownApiKey, this.bakeToValTown, this.createCanvas); - this.close(); - newModal.open(); - }); - } - }); - } - - onClose() { - const { contentEl } = this; - contentEl.empty(); - } -} - -class Version2Modal extends Modal { - paragraph: HTMLParagraphElement; - - constructor(app: App, paragraph: HTMLParagraphElement) { - super(app); - this.paragraph = paragraph; - } - - onOpen() { - const { contentEl } = this; - contentEl.createEl("h1", { text: "Cannoli 2.0" }); - contentEl.appendChild(this.paragraph); - } -} - -class CannoliSettingTab extends PluginSettingTab { - plugin: Cannoli; - - constructor(app: App, plugin: Cannoli) { - super(app, plugin); - this.plugin = plugin; - } - - display(): void { - const { containerEl } = this; - - containerEl.empty(); - - // Add a header - containerEl.createEl("h1", { text: "Cannoli Settings" }); - - containerEl.appendChild(this.plugin.createVersion2UpdateParagraph()); - - // Add button to add sample folder - new Setting(containerEl) - .setName("Add Cannoli College") - .setDesc( - "Add a folder of sample cannolis to your vault to walk you through the basics of Cannoli. (Delete and re-add this folder to get the latest version after an update.)" - ) - .addButton((button) => - button.setButtonText("Add").onClick(() => { - this.plugin.addSampleFolder(); - }) - ); - - // Add dropdown for AI provider with options OpenAI and Ollama - new Setting(containerEl) - .setName("AI Provider") - .setDesc( - "Choose which provider settings to edit. This dropdown will also select your default provider, which can be overridden at the node level using config arrows." - ) - .addDropdown((dropdown) => { - dropdown.addOption("openai", "OpenAI"); - dropdown.addOption("azure_openai", "Azure OpenAI"); - dropdown.addOption("ollama", "Ollama"); - dropdown.addOption("gemini", "Gemini"); - dropdown.addOption("anthropic", "Anthropic"); - dropdown.addOption("groq", "Groq"); - dropdown.setValue( - this.plugin.settings.llmProvider ?? - DEFAULT_SETTINGS.llmProvider - ); - dropdown.onChange(async (value) => { - this.plugin.settings.llmProvider = value as SupportedProviders; - await this.plugin.saveSettings(); - this.display(); - }); - }); - - containerEl.createEl("h1", { text: "LLM" }); - - if (this.plugin.settings.llmProvider === "openai") { - new Setting(containerEl) - .setName("OpenAI API key") - .setDesc( - "This key will be used to make all openai LLM calls. Be aware that complex cannolis, especially those with many GPT-4 calls, can be expensive to run." - ) - .addText((text) => - text - .setValue(this.plugin.settings.openaiAPIKey) - .setPlaceholder("sk-...") - .onChange(async (value) => { - this.plugin.settings.openaiAPIKey = value; - await this.plugin.saveSettings(); - }).inputEl.setAttribute("type", "password") - ); - - // Request threshold setting. This is the number of AI requests at which the user will be alerted before running a Cannoli - new Setting(containerEl) - .setName("AI requests threshold") - .setDesc( - "If the cannoli you are about to run is estimated to make more than this amount of AI requests, you will be alerted before running it." - ) - .addText((text) => - text - .setValue( - Number.isInteger(this.plugin.settings.requestThreshold) - ? this.plugin.settings.requestThreshold.toString() - : DEFAULT_SETTINGS.requestThreshold.toString() - ) - .onChange(async (value) => { - // If it's not empty and it's an integer, save it - if (!isNaN(parseInt(value)) && Number.isInteger(parseInt(value))) { - this.plugin.settings.requestThreshold = parseInt(value); - await this.plugin.saveSettings(); - } else { - // Otherwise, reset it to the default - this.plugin.settings.requestThreshold = DEFAULT_SETTINGS.requestThreshold; - await this.plugin.saveSettings(); - } - }) - ); - - // Default LLM model setting - new Setting(containerEl) - .setName("Default LLM model") - .setDesc( - "This model will be used for all LLM nodes unless overridden with a config arrow. (Note that special arrow types rely on function calling, which is not available in all models.)" - ) - .addText((text) => - text - .setValue(this.plugin.settings.defaultModel) - .onChange(async (value) => { - this.plugin.settings.defaultModel = value; - await this.plugin.saveSettings(); - }) - ); - - // Default LLM temperature setting - new Setting(containerEl) - .setName("Default LLM temperature") - .setDesc( - "This temperature will be used for all LLM nodes unless overridden with a config arrow." - ) - .addText((text) => - text - .setValue( - !isNaN(this.plugin.settings.defaultTemperature) && - this.plugin.settings.defaultTemperature - ? this.plugin.settings.defaultTemperature.toString() - : DEFAULT_SETTINGS.defaultTemperature.toString() - ) - .onChange(async (value) => { - // If it's not empty and it's a number, save it - if (!isNaN(parseFloat(value))) { - this.plugin.settings.defaultTemperature = - parseFloat(value); - await this.plugin.saveSettings(); - } else { - // Otherwise, reset it to the default - this.plugin.settings.defaultTemperature = - DEFAULT_SETTINGS.defaultTemperature; - await this.plugin.saveSettings(); - } - }) - ); - // openai base url setting - new Setting(containerEl) - .setName("Openai base url") - .setDesc( - "This url will be used to make openai llm calls against a different endpoint. This is useful for switching to an azure enterprise endpoint, or, some other openai compatible service." - ) - .addText((text) => - text - .setValue(this.plugin.settings.openaiBaseURL) - .setPlaceholder("https://api.openai.com/v1/") - .onChange(async (value) => { - this.plugin.settings.openaiBaseURL = value; - await this.plugin.saveSettings(); - }) - ); - } else if (this.plugin.settings.llmProvider === "azure_openai") { - // azure openai api key setting - new Setting(containerEl) - .setName("Azure OpenAI API key") - .setDesc( - "This key will be used to make all Azure OpenAI LLM calls. Be aware that complex cannolis, can be expensive to run." - ) - .addText((text) => - text - .setValue(this.plugin.settings.azureAPIKey) - .setPlaceholder("sk-...") - .onChange(async (value) => { - this.plugin.settings.azureAPIKey = value; - await this.plugin.saveSettings(); - }).inputEl.setAttribute("type", "password") - ); - // azure openai model setting - new Setting(containerEl) - .setName("Azure OpenAI model") - .setDesc( - "This model will be used for all LLM nodes unless overridden with a config arrow." - ) - .addText((text) => - text - .setValue(this.plugin.settings.azureModel) - .onChange(async (value) => { - this.plugin.settings.azureModel = value; - await this.plugin.saveSettings(); - }) - ); - // Default LLM temperature setting - new Setting(containerEl) - .setName("LLM temperature") - .setDesc( - "This temperature will be used for all LLM nodes unless overridden with a config arrow." - ) - .addText((text) => - text - .setValue( - !isNaN(this.plugin.settings.azureTemperature) && - this.plugin.settings.azureTemperature - ? this.plugin.settings.azureTemperature.toString() - : DEFAULT_SETTINGS.azureTemperature.toString() - ) - .onChange(async (value) => { - // If it's not empty and it's a number, save it - if (!isNaN(parseFloat(value))) { - this.plugin.settings.azureTemperature = - parseFloat(value); - await this.plugin.saveSettings(); - } else { - // Otherwise, reset it to the default - this.plugin.settings.azureTemperature = - DEFAULT_SETTINGS.azureTemperature; - await this.plugin.saveSettings(); - } - }) - ); - // azure openai api deployment name setting - new Setting(containerEl) - .setName("Azure OpenAI API deployment name") - .setDesc( - "This deployment will be used to make all Azure OpenAI LLM calls." - ) - .addText((text) => - text - .setValue(this.plugin.settings.azureOpenAIApiDeploymentName) - .setPlaceholder("deployment-name") - .onChange(async (value) => { - this.plugin.settings.azureOpenAIApiDeploymentName = value; - await this.plugin.saveSettings(); - }) - ); - - // azure openai api instance name setting - new Setting(containerEl) - .setName("Azure OpenAI API instance name") - .setDesc( - "This instance will be used to make all Azure OpenAI LLM calls." - ) - .addText((text) => - text - .setValue(this.plugin.settings.azureOpenAIApiInstanceName) - .setPlaceholder("instance-name") - .onChange(async (value) => { - this.plugin.settings.azureOpenAIApiInstanceName = value; - await this.plugin.saveSettings(); - }) - ); - - // azure openai api version setting - new Setting(containerEl) - .setName("Azure OpenAI API version") - .setDesc( - "This version will be used to make all Azure OpenAI LLM calls. Be aware that complex cannolis, can be expensive to run." - ) - .addText((text) => - text - .setValue(this.plugin.settings.azureOpenAIApiVersion) - .setPlaceholder("xxxx-xx-xx") - .onChange(async (value) => { - this.plugin.settings.azureOpenAIApiVersion = value; - await this.plugin.saveSettings(); - }) - ); - - // azure base url setting - new Setting(containerEl) - .setName("Azure base url") - .setDesc( - "This url will be used to make azure openai llm calls against a different endpoint. This is useful for switching to an azure enterprise endpoint, or, some other openai compatible service." - ) - .addText((text) => - text - .setValue(this.plugin.settings.azureBaseURL) - .setPlaceholder("https://api.openai.com/v1/") - .onChange(async (value) => { - this.plugin.settings.azureBaseURL = value; - await this.plugin.saveSettings(); - }) - ); - } else if (this.plugin.settings.llmProvider === "ollama") { - // ollama base url setting - new Setting(containerEl) - .setName("Ollama base url") - .setDesc( - "This url will be used to make all ollama LLM calls. Be aware that ollama models have different features and capabilities that may not be compatible with all features of cannoli." - ) - .addText((text) => - text - .setValue(this.plugin.settings.ollamaBaseUrl) - .setPlaceholder("https://ollama.com") - .onChange(async (value) => { - this.plugin.settings.ollamaBaseUrl = value; - await this.plugin.saveSettings(); - }) - ); - // ollama model setting - new Setting(containerEl) - .setName("Ollama model") - .setDesc( - "This model will be used for all LLM nodes unless overridden with a config arrow. (Note that special arrow types rely on function calling, which is not available in all models.)" - ) - .addText((text) => - text - .setValue(this.plugin.settings.ollamaModel) - .onChange(async (value) => { - this.plugin.settings.ollamaModel = value; - await this.plugin.saveSettings(); - }) - ); - // Default LLM temperature setting - new Setting(containerEl) - .setName("Default LLM temperature") - .setDesc( - "This temperature will be used for all LLM nodes unless overridden with a config arrow." - ) - .addText((text) => - text - .setValue( - !isNaN(this.plugin.settings.ollamaTemperature) && - this.plugin.settings.ollamaTemperature - ? this.plugin.settings.ollamaTemperature.toString() - : DEFAULT_SETTINGS.ollamaTemperature.toString() - ) - .onChange(async (value) => { - // If it's not empty and it's a number, save it - if (!isNaN(parseFloat(value))) { - this.plugin.settings.ollamaTemperature = - parseFloat(value); - await this.plugin.saveSettings(); - } else { - // Otherwise, reset it to the default - this.plugin.settings.ollamaTemperature = - DEFAULT_SETTINGS.ollamaTemperature; - await this.plugin.saveSettings(); - } - }) - ); - } else if (this.plugin.settings.llmProvider === "gemini") { - // gemini api key setting - new Setting(containerEl) - .setName("Gemini API key") - .setDesc( - "This key will be used to make all Gemini LLM calls. Be aware that complex cannolis, can be expensive to run." - ) - .addText((text) => - text - .setValue(this.plugin.settings.geminiAPIKey) - .setPlaceholder("sk-...") - .onChange(async (value) => { - this.plugin.settings.geminiAPIKey = value; - await this.plugin.saveSettings(); - }).inputEl.setAttribute("type", "password") - ); - // gemini model setting - new Setting(containerEl) - .setName("Gemini model") - .setDesc( - "This model will be used for all LLM nodes unless overridden with a config arrow." - ) - .addText((text) => - text - .setValue(this.plugin.settings.geminiModel) - .onChange(async (value) => { - this.plugin.settings.geminiModel = value; - await this.plugin.saveSettings(); - }) - ); - // Default LLM temperature setting - new Setting(containerEl) - .setName("Default LLM temperature") - .setDesc( - "This temperature will be used for all LLM nodes unless overridden with a config arrow." - ) - .addText((text) => - text - .setValue( - !isNaN(this.plugin.settings.geminiTemperature) && - this.plugin.settings.geminiTemperature - ? this.plugin.settings.geminiTemperature.toString() - : DEFAULT_SETTINGS.geminiTemperature.toString() - ) - .onChange(async (value) => { - // If it's not empty and it's a number, save it - if (!isNaN(parseFloat(value))) { - this.plugin.settings.geminiTemperature = - parseFloat(value); - await this.plugin.saveSettings(); - } else { - // Otherwise, reset it to the default - this.plugin.settings.geminiTemperature = - DEFAULT_SETTINGS.geminiTemperature; - await this.plugin.saveSettings(); - } - }) - ); - } else if (this.plugin.settings.llmProvider === "anthropic") { - new Setting(containerEl) - .setName("Create proxy server on Val Town") - .setDesc("Anthropic requests currently require a proxy server. This button will use your Val Town API key to create a new HTTP val on your Val Town account which will handle all Anthropic requests.") - .addButton((button) => - button - .setButtonText("Create proxy server") - .onClick(async () => { - // If they don't have a valtown API key, give a notice - if (!this.plugin.settings.valTownAPIKey) { - alert("You don't have a Val Town API key. Please enter one in the settings."); - return; - } - - // call the create proxy server function - await this.plugin.createProxyServer(); - - await this.plugin.saveSettings(); - this.display(); - }) - ); - - new Setting(containerEl) - .setName("Anthropic base URL") - .setDesc("This base URL will be used to make all Anthropic LLM calls.") - .addText((text) => - text - .setValue(this.plugin.settings.anthropicBaseURL) - .setPlaceholder("https://api.anthropic.com/v1/") - .onChange(async (value) => { - this.plugin.settings.anthropicBaseURL = value; - await this.plugin.saveSettings(); - }) - ); - - // anthropic api key setting - new Setting(containerEl) - .setName("Anthropic API key") - .setDesc( - "This key will be used to make all Anthropic LLM calls. Be aware that complex cannolis, can be expensive to run." - ) - .addText((text) => - text - .setValue(this.plugin.settings.anthropicAPIKey) - .setPlaceholder("sk-...") - .onChange(async (value) => { - this.plugin.settings.anthropicAPIKey = value; - await this.plugin.saveSettings(); - }).inputEl.setAttribute("type", "password") - ); - // anthropic model setting - new Setting(containerEl) - .setName("Anthropic model") - .setDesc( - "This model will be used for all LLM nodes unless overridden with a config arrow." - ) - .addText((text) => - text - .setValue(this.plugin.settings.anthropicModel) - .onChange(async (value) => { - this.plugin.settings.anthropicModel = value; - await this.plugin.saveSettings(); - }) - ); - // Default LLM temperature setting - new Setting(containerEl) - .setName("Default LLM temperature") - .setDesc( - "This temperature will be used for all LLM nodes unless overridden with a config arrow." - ) - .addText((text) => - text - .setValue( - !isNaN(this.plugin.settings.anthropicTemperature) && - this.plugin.settings.anthropicTemperature - ? this.plugin.settings.anthropicTemperature.toString() - : DEFAULT_SETTINGS.anthropicTemperature.toString() - ) - .onChange(async (value) => { - // If it's not empty and it's a number, save it - if (!isNaN(parseFloat(value))) { - this.plugin.settings.anthropicTemperature = - parseFloat(value); - await this.plugin.saveSettings(); - } else { - // Otherwise, reset it to the default - this.plugin.settings.anthropicTemperature = - DEFAULT_SETTINGS.anthropicTemperature; - await this.plugin.saveSettings(); - } - }) - ); - } else if (this.plugin.settings.llmProvider === "groq") { - // groq api key setting - new Setting(containerEl) - .setName("Groq API key") - .setDesc( - "This key will be used to make all Groq LLM calls. Be aware that complex cannolis, can be expensive to run." - ) - .addText((text) => - text - .setValue(this.plugin.settings.groqAPIKey) - .setPlaceholder("sk-...") - .onChange(async (value) => { - this.plugin.settings.groqAPIKey = value; - await this.plugin.saveSettings(); - }).inputEl.setAttribute("type", "password") - ); - // groq model setting - new Setting(containerEl) - .setName("Groq model") - .setDesc( - "This model will be used for all LLM nodes unless overridden with a config arrow." - ) - .addText((text) => - text - .setValue(this.plugin.settings.groqModel) - .onChange(async (value) => { - this.plugin.settings.groqModel = value; - await this.plugin.saveSettings(); - }) - ); - // Default LLM temperature setting - new Setting(containerEl) - .setName("Default LLM temperature") - .setDesc( - "This temperature will be used for all LLM nodes unless overridden with a config arrow." - ) - .addText((text) => - text - .setValue( - !isNaN(this.plugin.settings.groqTemperature) && - this.plugin.settings.groqTemperature - ? this.plugin.settings.groqTemperature.toString() - : DEFAULT_SETTINGS.groqTemperature.toString() - ) - .onChange(async (value) => { - // If it's not empty and it's a number, save it - if (!isNaN(parseFloat(value))) { - this.plugin.settings.groqTemperature = - parseFloat(value); - await this.plugin.saveSettings(); - } else { - // Otherwise, reset it to the default - this.plugin.settings.groqTemperature = - DEFAULT_SETTINGS.groqTemperature; - await this.plugin.saveSettings(); - } - }) - ); - } - - new Setting(containerEl) - .setName("LLM call concurrency limit (pLimit)") - .setDesc( - "The maximum number of LLM calls that can be made at once. Decrease this if you are running into rate limiting issues." - ) - .addText((text) => - text - .setValue( - Number.isInteger(this.plugin.settings.pLimit) - ? this.plugin.settings.pLimit.toString() - : DEFAULT_SETTINGS.pLimit.toString() - ) - .onChange(async (value) => { - // If it's not empty and it's a positive integer, save it - if (!isNaN(parseInt(value)) && parseInt(value) > 0) { - this.plugin.settings.pLimit = parseInt(value); - await this.plugin.saveSettings(); - } else { - // Otherwise, reset it to the default - this.plugin.settings.pLimit = - DEFAULT_SETTINGS.pLimit; - await this.plugin.saveSettings(); - } - }) - ); - - containerEl.createEl("h1", { text: "Canvas preferences" }); - - // Add toggle for contentIsColorless - new Setting(containerEl) - .setName("Parse colorless nodes as content nodes") - .setDesc( - "Toggle this if you'd like colorless (grey) nodes to be interpreted as content nodes rather than call nodes. Purple nodes will then be interpreted as call nodes." - ) - .addToggle((toggle) => - toggle - .setValue( - this.plugin.settings.contentIsColorless ?? - DEFAULT_SETTINGS.contentIsColorless - ) - .onChange(async (value) => { - this.plugin.settings.contentIsColorless = value; - await this.plugin.saveSettings(); - }) - ); - - // Put header here - containerEl.createEl("h1", { text: "Note extraction" }); - - // Toggle adding filenames as headers when extracting text from files - new Setting(containerEl) - .setName( - "Include filenames as headers in extracted notes by default" - ) - .setDesc( - `When extracting a note in a cannoli, include the filename as a top-level header. This default can be overridden by adding "#" or "!#" after the note link in a reference like this: {{[[Stuff]]#}} or {{[[Stuff]]!#}}.` - ) - .addToggle((toggle) => - toggle - .setValue( - this.plugin.settings.includeFilenameAsHeader || false - ) - .onChange(async (value) => { - this.plugin.settings.includeFilenameAsHeader = value; - await this.plugin.saveSettings(); - }) - ); - - // Toggle including properties (YAML frontmatter) when extracting text from files - new Setting(containerEl) - .setName( - "Include properties when extracting or editing notes by default" - ) - .setDesc( - `When extracting or editing a note in a cannoli, include the note's properties (YAML frontmatter). This default can be overridden by adding "^" or "!^" after the note link in a reference like this: {{[[Stuff]]^}} or {{[[Stuff]]!^}}.` - ) - .addToggle((toggle) => - toggle - .setValue( - this.plugin.settings - .includePropertiesInExtractedNotes || false - ) - .onChange(async (value) => { - this.plugin.settings.includePropertiesInExtractedNotes = - value; - await this.plugin.saveSettings(); - }) - ); - - // Toggle including markdown links when extracting text from files - new Setting(containerEl) - .setName( - "Include markdown links when extracting or editing notes by default" - ) - .setDesc( - `When extracting or editing a note in a cannoli, include the note's markdown link above the content. This default can be overridden by adding "@" or "!@" after the note link in a reference like this: {{[[Stuff]]@}} or {{[[Stuff]]!@}}.` - ) - .addToggle((toggle) => - toggle - .setValue( - this.plugin.settings.includeLinkInExtractedNotes || - false - ) - .onChange(async (value) => { - this.plugin.settings.includeLinkInExtractedNotes = - value; - await this.plugin.saveSettings(); - }) - ); - - containerEl.createEl("h1", { text: "Chat cannolis" }); - - // Chat format string setting, error if invalid - new Setting(containerEl) - .setName("Chat format string") - .setDesc( - "This string will be used to format chat messages when using chat arrows. This string must contain the placeholders {{role}} and {{content}}, which will be replaced with the role and content of the message, respectively." - ) - .addTextArea((text) => - text - .setValue(this.plugin.settings.chatFormatString) - .onChange(async (value) => { - // Check if the format string is valid - const rolePlaceholder = "{{role}}"; - const contentPlaceholder = "{{content}}"; - if ( - !value.includes(rolePlaceholder) || - !value.includes(contentPlaceholder) - ) { - alert( - `Invalid format string. Please include both ${rolePlaceholder} and ${contentPlaceholder}.` - ); - return; - } - - this.plugin.settings.chatFormatString = value; - await this.plugin.saveSettings(); - }) - ); - - new Setting(containerEl) - .setName("Auto-scroll with token stream") - .setDesc( - "Move the cursor forward every time a token is streamed in from a chat arrow. This will lock the scroll position to the bottom of the note." - ) - .addToggle((toggle) => - toggle - .setValue( - this.plugin.settings.autoScrollWithTokenStream || false - ) - .onChange(async (value) => { - this.plugin.settings.autoScrollWithTokenStream = value; - await this.plugin.saveSettings(); - }) - ); - - containerEl.createEl("h1", { text: "Transcription" }); - - // Toggle voice recording triggered cannolis - new Setting(containerEl) - .setName("Enable audio recorder triggered cannolis") - .setDesc( - `Enable cannolis to be triggered by audio recordings. When you make a recording in a note with a cannoli property: (1) The audio file will be transcribed using Whisper. (2) The file reference will be replaced with the transcript. (3) The cannoli defined in the property will run.` - ) - .addToggle((toggle) => - toggle - .setValue( - this.plugin.settings.enableAudioTriggeredCannolis || - false - ) - .onChange(async (value) => { - this.plugin.settings.enableAudioTriggeredCannolis = - value; - await this.plugin.saveSettings(); - this.display(); - }) - ); - - if (this.plugin.settings.enableAudioTriggeredCannolis) { - // Transcription prompt - new Setting(containerEl) - .addTextArea((text) => - text - .setPlaceholder( - "Enter prompt to improve transcription accuracy" - ) - .setValue( - this.plugin.settings.transcriptionPrompt || "" - ) - .onChange(async (value) => { - this.plugin.settings.transcriptionPrompt = value; - await this.plugin.saveSettings(); - }) - ) - .setName("Transcription prompt") - .setDesc( - "Use this prompt to guide the style and vocabulary of the transcription. (i.e. the level of punctuation, format and spelling of uncommon words in the prompt will be mimicked in the transcription)" - ); - - // Toggle deleting audio files after starting an audio triggered cannoli - new Setting(containerEl) - .setName("Delete audio files after transcription") - .setDesc( - "After a recording is transcribed, delete the audio file." - ) - .addToggle((toggle) => - toggle - .setValue( - this.plugin.settings - .deleteAudioFilesAfterAudioTriggeredCannolis || - false - - // eslint-disable-next-line no-mixed-spaces-and-tabs - ) - .onChange(async (value) => { - this.plugin.settings.deleteAudioFilesAfterAudioTriggeredCannolis = - value; - await this.plugin.saveSettings(); - }) - ); - } - - containerEl.createEl("h1", { text: "Secrets" }); - - new Setting(containerEl) - .setName("Secrets") - .setDesc(`These secrets will be available in all of your cannolis, using "{{secret name}}". They are not stored in the canvas, and wil not be included when you bake a cannoli.`) - .addButton((button) => - button.setButtonText("+ Secret").onClick(() => { - // Create a new secret object - const newSecret = { name: "", value: "", visibility: "password" }; - this.plugin.settings.secrets.push(newSecret); - this.plugin.saveSettings(); - // Refresh the settings pane to reflect the changes - this.display(); - }) - ); - - // Iterate through saved secrets and display them - for (const secret of this.plugin.settings.secrets) { - new Setting(containerEl) - .addText((text) => - text - .setValue(secret.name) - .setPlaceholder("Secret Name") - .onChange(async (value) => { - secret.name = value; - await this.plugin.saveSettings(); - }) - ) - .addText((text) => - text - .setValue(secret.value) - .setPlaceholder("Secret Value") - .onChange(async (value) => { - secret.value = value; - await this.plugin.saveSettings(); - }) - .inputEl.setAttribute("type", secret.visibility || "password") - ) - .addButton((button) => - button.setButtonText("👁️").onClick(async () => { - secret.visibility = secret.visibility === "password" ? "text" : "password"; - this.plugin.saveSettings(); - this.display(); - }) - ) - .addButton((button) => - button - .setButtonText("📋") - .setTooltip("Copy to clipboard") - .onClick(async () => { - await navigator.clipboard.writeText(secret.value); - new Notice("Secret value copied to clipboard"); - }) - ) - .addButton((button) => - button - .setButtonText("Delete") - .setWarning() - .onClick(() => { - const index = this.plugin.settings.secrets.indexOf(secret); - if (index > -1) { - this.plugin.settings.secrets.splice(index, 1); - this.plugin.saveSettings(); - // Refresh the settings pane to reflect the changes - this.display(); - } - }) - ); - } - - containerEl.createEl("h1", { text: "Baking" }); - - // Filepath for baked cannoli folder - new Setting(containerEl) - .setName("Baked cannoli folder") - .setDesc("The path to the folder where baked cannoli will be saved. There can be subfolders.") - .addText((text) => - text - .setValue(this.plugin.settings.bakedCannoliFolder) - .onChange(async (value) => { - this.plugin.settings.bakedCannoliFolder = value; - await this.plugin.saveSettings(); - }) - ); - - new Setting(containerEl) - .setName("Language") - .addDropdown((dropdown) => { - dropdown.addOption("typescript", "Typescript"); - dropdown.addOption("javascript", "Javascript"); - dropdown.setValue(this.plugin.settings.bakeLanguage); - dropdown.onChange(async (value) => { - this.plugin.settings.bakeLanguage = value as BakeLanguage; - await this.plugin.saveSettings(); - }); - }); - - new Setting(containerEl) - .setName("Runtime") - .addDropdown((dropdown) => { - dropdown.addOption("node", "Node"); - dropdown.addOption("deno", "Deno"); - dropdown.addOption("bun", "Bun"); - dropdown.setValue(this.plugin.settings.bakeRuntime); - dropdown.onChange(async (value) => { - this.plugin.settings.bakeRuntime = value as BakeRuntime; - await this.plugin.saveSettings(); - }); - }); - - new Setting(containerEl) - .setName("Indent") - .addDropdown((dropdown) => { - dropdown.addOption("2", "2"); - dropdown.addOption("4", "4"); - dropdown.setValue(this.plugin.settings.bakeIndent); - dropdown.onChange(async (value) => { - this.plugin.settings.bakeIndent = value as "2" | "4"; - await this.plugin.saveSettings(); - }); - }); - - // ValTown section - containerEl.createEl("h1", { text: "ValTown" }); - - new Setting(containerEl) - .setName("ValTown API key") - .setDesc( - `This key will be used to create Vals on your Val Town account when you run the "Create Val" command.` - ) - .addText((text) => - text - .setValue(this.plugin.settings.valTownAPIKey) - .setPlaceholder("...") - .onChange(async (value) => { - this.plugin.settings.valTownAPIKey = value; - await this.plugin.saveSettings(); - }).inputEl.setAttribute("type", "password") - ); - - new Setting(containerEl) - .setName("View vals") - .setDesc(`View information about your Cannoli Vals. This modal can also be opened using the "View vals" command.`) - .addButton((button) => - button.setButtonText("Open").onClick(async () => { - // new Notice("Fetching all your Cannolis..."); - try { - const modal = new ValTownModal(this.app, await this.plugin.getAllCannoliFunctions(), this.plugin.getAllCannoliFunctions, this.plugin.openCanvas, this.plugin.settings.valTownAPIKey, this.plugin.bakeToValTown, this.plugin.createCanvas); - modal.open(); - } catch (error) { - new Notice("Failed to fetch Cannoli functions."); - console.error(error); - } - }) - ); - - containerEl.createEl("h1", { text: "Action nodes" }); - - new Setting(containerEl) - .setName("Action node templates") - .setDesc("Manage default HTTP templates for action nodes.") - .addButton((button) => - button.setButtonText("+ Template").onClick(() => { - // Create a new command object to pass to the modal - const newCommand: HttpTemplate = { - name: "", - url: "", - headers: `{ "Content-Type": "application/json" }`, - id: "", - method: "GET", - }; - - // Open the modal to edit the new template - new HttpTemplateEditorModal( - this.app, - newCommand, - (command) => { - this.plugin.settings.httpTemplates.push(command); - this.plugin.saveSettings(); - // Refresh the settings pane to reflect the changes - this.display(); - }, - () => { } - ).open(); - }) - ); - - // Iterate through saved templates and display them - for (const template of this.plugin.settings.httpTemplates) { - new Setting(containerEl) - .setName(template.name) - .addButton((button) => - button.setButtonText("Edit").onClick(() => { - // Open the modal to edit the existing template - new HttpTemplateEditorModal( - this.app, - template, - (updatedTemplate) => { - Object.assign(template, updatedTemplate); - this.plugin.saveSettings(); - // Refresh the settings pane to reflect the changes - this.display(); - }, - () => { } - ).open(); - }) - ) - .addButton((button) => - button.setButtonText("Delete").onClick(() => { - const index = - this.plugin.settings.httpTemplates.indexOf( - template - ); - if (index > -1) { - this.plugin.settings.httpTemplates.splice(index, 1); - this.plugin.saveSettings(); - // Refresh the settings pane to reflect the changes - this.display(); - } - }) - ); - } - } -} diff --git a/packages/cannoli-plugin/src/modals/customModal.ts b/packages/cannoli-plugin/src/modals/customModal.ts new file mode 100644 index 0000000..08289e7 --- /dev/null +++ b/packages/cannoli-plugin/src/modals/customModal.ts @@ -0,0 +1,405 @@ +import { App, DropdownComponent, Modal, TextAreaComponent, TextComponent, ToggleComponent } from "obsidian"; +import moment from 'moment'; + +interface ModalComponent { + type: 'text' | 'input'; + content: string; + name: string; + fieldType: string; + options?: string[]; + format: string; +} + +interface ModalParagraph { + components: ModalComponent[]; +} + +export class CustomModal extends Modal { + layout: string; + callback: (result: Record | Error) => void; + title: string; + paragraphs: ModalParagraph[]; + isSubmitted: boolean; + values: Record; + + constructor(app: App, layout: string, callback: (result: Record | Error) => void, title: string) { + super(app); + this.layout = layout; + this.callback = callback; + this.title = title; + this.paragraphs = this.parseLayout(); + this.isSubmitted = false; + this.values = {}; + } + + parseLayout(): ModalParagraph[] { + const regex = /(\s*)==([\s\S]+?)==|([^=]+)/g; + const paragraphs: ModalParagraph[] = [{ components: [] }]; + let match; + + while ((match = regex.exec(this.layout)) !== null) { + if (match[2]) { // Input component + const trimmedContent = match[2].trim(); + let fieldContent: string | string[]; + + // Check if content is a JSON array + if (trimmedContent.startsWith('[') && trimmedContent.endsWith(']')) { + try { + // Attempt to parse as JSON, handling potential escaping issues + fieldContent = JSON.parse(trimmedContent.replace(/\\n/g, '\n').replace(/\\"/g, '"')); + } catch (e) { + console.error('Failed to parse JSON options:', e); + // If parsing fails, treat it as a comma-separated list + fieldContent = trimmedContent.slice(1, -1).split(',').map(item => + item.trim().replace(/^["']|["']$/g, '') // Remove surrounding quotes if present + ); + } + } else { + fieldContent = trimmedContent; + } + + let fieldName: string, fieldType: string, options: string[] | undefined; + + if (Array.isArray(fieldContent)) { + // If fieldContent is an array, assume the first element is the field name and type + const [nameAndType, ...rest] = fieldContent; + [fieldName, fieldType] = this.parseNameAndType(nameAndType as string); + options = rest.map(String); + } else { + // If fieldContent is a string, use parseField as before + [fieldName, fieldType, options] = this.parseField(fieldContent); + } + + const format = this.parseField(Array.isArray(fieldContent) ? fieldContent[0] : fieldContent)[3] || + (fieldType === 'date' ? 'YYYY-MM-DD' : + fieldType === 'time' ? 'HH:mm' : + fieldType === 'datetime' ? 'YYYY-MM-DDTHH:mm' : ''); + + if (match[1]) { // Preserve leading whitespace + paragraphs[paragraphs.length - 1].components.push({ + type: 'text', + content: match[1], + name: "", + fieldType: "text", + format: format + }); + } + paragraphs[paragraphs.length - 1].components.push({ + type: 'input', + content: fieldName, + name: fieldName, + fieldType: fieldType, + options: options, + format: format + }); + } else if (match[3]) { // Text component + const textParts = match[3].split('\n'); + textParts.forEach((part, index) => { + if (index > 0) { + paragraphs.push({ components: [] }); + } + paragraphs[paragraphs.length - 1].components.push({ + type: 'text', + content: part, + name: "", + fieldType: "text", + format: "" + }); + }); + } + } + + return paragraphs; + } + + parseField(field: string): [string, string, string[] | undefined, string | undefined] { + // Remove double equals signs + const content = field.trim(); + + // Find the index of the first opening parenthesis + const openParenIndex = content.indexOf('('); + + let name: string; + let type: string = 'text'; // Default type + let optionsString: string = ''; + let format: string | undefined; + + if (openParenIndex === -1) { + // No parentheses found, everything is the name + name = content; + } else { + // Find the matching closing parenthesis + const closeParenIndex = content.indexOf(')', openParenIndex); + if (closeParenIndex === -1) { + // Mismatched parentheses, treat everything as name + name = content; + } else { + name = content.slice(0, openParenIndex).trim(); + type = content.slice(openParenIndex + 1, closeParenIndex).trim(); + const remainingContent = content.slice(closeParenIndex + 1).trim(); + + // Check if there's content after the type declaration + if (remainingContent) { + if (type === 'date' || type === 'time' || type === 'datetime') { + format = remainingContent; + } else { + optionsString = remainingContent; + } + } + } + } + + // Parse options if present + let options: string[] | undefined; + if (optionsString.startsWith('[') && optionsString.endsWith(']')) { + try { + // Attempt to parse as JSON, handling potential escaping issues + options = JSON.parse(optionsString.replace(/\\n/g, '\n').replace(/\\"/g, '"')); + } catch (e) { + // If parsing fails, treat it as a comma-separated list + options = optionsString.slice(1, -1).split(',').map(item => + item.trim().replace(/^["']|["']$/g, '') // Remove surrounding quotes if present + ); + } + + // Ensure options is an array of strings + if (Array.isArray(options)) { + options = options.flat().map(String); + } else { + options = [String(options)]; + } + } else { + options = optionsString ? optionsString.split(',').map(o => o.trim()).filter(o => o !== '') : undefined; + } + + return [name, type, options, format]; + } + + parseNameAndType(content: string): [string, string] { + // Remove any leading or trailing whitespace + content = content.trim(); + + // Find the index of the last opening parenthesis + const openParenIndex = content.lastIndexOf('('); + + if (openParenIndex === -1) { + // If there's no parenthesis, assume it's all name and type is default + return [content, 'text']; + } + + // Find the matching closing parenthesis + const closeParenIndex = content.indexOf(')', openParenIndex); + + if (closeParenIndex === -1) { + // If there's no closing parenthesis, assume it's all name + return [content, 'text']; + } + + // Extract the name (everything before the last opening parenthesis) + const name = content.slice(0, openParenIndex).trim(); + + // Extract the type (everything between the parentheses) + const type = content.slice(openParenIndex + 1, closeParenIndex).trim(); + + return [name, type || 'text']; + } + + onOpen() { + const { contentEl } = this; + contentEl.empty(); + + contentEl.createEl("h1", { text: this.title }); + const form = contentEl.createEl("form"); + form.onsubmit = (e) => { + e.preventDefault(); + this.handleSubmit(); + }; + + this.paragraphs.forEach(paragraph => { + const p = form.createEl("p"); + paragraph.components.forEach(component => { + if (component.type === 'text') { + p.appendText(component.content); + } else { + this.createInputComponent(p, component); + } + }); + }); + + form.createEl("button", { text: "Submit", type: "submit" }); + this.addStyle(); + this.modalEl.addClass('cannoli-modal'); + } + + addStyle() { + const style = document.createElement('style'); + style.textContent = ` + .cannoli-modal .modal-content p { + white-space: pre-wrap; + } + .cannoli-modal .obsidian-style-time-input { + background-color: var(--background-modifier-form-field); + border: 1px solid var(--background-modifier-border); + color: var(--text-normal); + padding: var(--size-4-1) var(--size-4-2); + border-radius: var(--radius-s); + font-size: var(--font-ui-small); + line-height: var(--line-height-tight); + width: auto; + font-family: var(--font-interface); + position: relative; + } + .cannoli-modal .obsidian-style-time-input:focus { + outline: none; + box-shadow: 0 0 0 2px var(--background-modifier-border-focus); + } + .cannoli-modal .obsidian-style-time-input:hover { + background-color: var(--background-modifier-form-field-hover); + } + `; + document.head.appendChild(style); + } + + createInputComponent(paragraph: HTMLParagraphElement, component: ModalComponent) { + let settingComponent; + + const updateValue = (value: string) => { + if (component.fieldType === 'date') { + const formattedValue = moment(value, 'YYYY-MM-DD').format(component.format); + this.values[component.name] = formattedValue; + } else if (component.fieldType === 'time') { + const formattedValue = moment(value, 'HH:mm').format(component.format || 'HH:mm'); + this.values[component.name] = formattedValue; + } else if (component.fieldType === 'datetime') { + const formattedValue = moment(value, 'YYYY-MM-DDTHH:mm').format(component.format || 'YYYY-MM-DDTHH:mm'); + this.values[component.name] = formattedValue; + } else { + this.values[component.name] = value; + } + }; + + switch (component.fieldType) { + case 'textarea': + settingComponent = new TextAreaComponent(paragraph) + .setPlaceholder(component.content) + .onChange(updateValue); + updateValue(''); + break; + case 'toggle': { + const toggleContainer = paragraph.createSpan({ cls: 'toggle-container' }); + settingComponent = new ToggleComponent(toggleContainer) + .setValue(false) + .onChange(value => updateValue(value ? "true" : "false")); + updateValue("false"); + break; + } + case 'dropdown': + if (component.options && component.options.length > 0) { + settingComponent = new DropdownComponent(paragraph) + .addOptions(Object.fromEntries(component.options.map(opt => [opt, opt]))) + .onChange(updateValue); + updateValue(component.options[0]); + } + break; + case 'date': { + settingComponent = new TextComponent(paragraph) + .setPlaceholder(component.content) + .onChange((value) => { + updateValue(value); + }); + settingComponent.inputEl.type = 'date'; + const defaultDate = moment().format(component.format); + settingComponent.inputEl.value = moment(defaultDate, component.format).format('YYYY-MM-DD'); + updateValue(settingComponent.inputEl.value); + break; + } + case 'time': { + settingComponent = new TextComponent(paragraph) + .setPlaceholder(component.content) + .onChange((value) => { + updateValue(value); + }); + settingComponent.inputEl.type = 'time'; + const defaultTime = moment().format('HH:mm'); + settingComponent.inputEl.value = defaultTime; + updateValue(settingComponent.inputEl.value); + + // Add custom styling to the time input + settingComponent.inputEl.addClass('obsidian-style-time-input'); + break; + } + case 'datetime': { + settingComponent = new TextComponent(paragraph) + .setPlaceholder(component.content) + .onChange((value) => { + updateValue(value); + }); + settingComponent.inputEl.type = 'datetime-local'; + const defaultDateTime = moment().format(component.format); + settingComponent.inputEl.value = defaultDateTime; + updateValue(settingComponent.inputEl.value); + break; + } + case 'text': + case '': + case undefined: + default: + settingComponent = new TextComponent(paragraph) + .setPlaceholder(component.content) + .onChange(updateValue); + updateValue(''); + break; + + } + + if (settingComponent) { + if (settingComponent instanceof ToggleComponent) { + settingComponent.toggleEl.setAttribute('data-name', component.name); + settingComponent.toggleEl.setAttribute('aria-label', component.content); + } else if ('inputEl' in settingComponent) { + settingComponent.inputEl.name = component.name; + settingComponent.inputEl.setAttribute('aria-label', component.content); + } + } + + // Add custom CSS to align the toggle + const style = document.createElement('style'); + style.textContent = ` + .toggle-container { + display: inline-flex; + align-items: center; + vertical-align: middle; + margin-left: 4px; + } + .toggle-container .checkbox-container { + margin: 0; + } + `; + document.head.appendChild(style); + } + + onClose() { + if (!this.isSubmitted) { + this.callback(new Error("Modal closed without submission")); + } + } + + handleSubmit() { + const result = { ...this.values }; + + this.paragraphs.forEach(paragraph => { + paragraph.components.forEach(component => { + if (component.type === 'input') { + if (!(component.name in result) || + ((component.fieldType === 'text' || component.fieldType === 'textarea') && result[component.name].trim() === '')) { + result[component.name] = "No input"; + } + } + }); + }); + + this.isSubmitted = true; + this.close(); + this.callback(result); + } +} diff --git a/packages/cannoli-plugin/src/modals/editVal.ts b/packages/cannoli-plugin/src/modals/editVal.ts new file mode 100644 index 0000000..64de721 --- /dev/null +++ b/packages/cannoli-plugin/src/modals/editVal.ts @@ -0,0 +1,42 @@ +import { App, Modal, Setting } from "obsidian"; + +export class EditValModal extends Modal { + onContinue: () => void; + onCancel: () => void; + + constructor( + app: App, + onContinue: () => void, + onCancel: () => void, + ) { + super(app); + this.onContinue = onContinue; + this.onCancel = onCancel; + } + + onOpen() { + const { contentEl } = this; + contentEl.createEl("h1", { text: "Val Already Exists" }); + + contentEl.createEl("p", { + text: "A Val with this name already exists. Would you like to update the existing Val with the new content?", + }); + + const panel = new Setting(contentEl); + panel.addButton((btn) => btn.setButtonText("Yes, Update") + .setCta() + .onClick(() => { + this.close(); + this.onContinue(); + })); + panel.addButton((btn) => btn.setButtonText("No, Cancel").onClick(() => { + this.close(); + this.onCancel(); + })); + } + + onClose() { + const { contentEl } = this; + contentEl.empty(); + } +} \ No newline at end of file diff --git a/packages/cannoli-plugin/src/modals/httpTemplateEditor.ts b/packages/cannoli-plugin/src/modals/httpTemplateEditor.ts new file mode 100644 index 0000000..75b07f2 --- /dev/null +++ b/packages/cannoli-plugin/src/modals/httpTemplateEditor.ts @@ -0,0 +1,189 @@ +import { HttpTemplate } from "@deablabs/cannoli-core"; +import { App, Modal, Setting } from "obsidian"; + +export class HttpTemplateEditorModal extends Modal { + template: HttpTemplate; + onSave: (template: HttpTemplate) => void; + onCancel: () => void; + + constructor( + app: App, + template: HttpTemplate, + onSave: (template: HttpTemplate) => void, + onCancel: () => void + ) { + super(app); + this.template = template; + this.onSave = onSave; + this.onCancel = onCancel; + } + + onOpen() { + const { contentEl } = this; + + contentEl.addClass("http-template-editor"); + contentEl.createEl("h1", { text: "Edit action node template" }); + + // Add some space between the header and the description + contentEl.createEl("div", { cls: "spacer" }); + + // Insert a spacer element + contentEl.createEl("div", { cls: "spacer", attr: { style: "height: 20px;" } }); + + const createDescription = (text: string) => { + const p = contentEl.createEl("p", { + cls: "http-template-description", + }); + // Allow newlines in the description + p.innerHTML = text.replace(/\n/g, "
"); + return p; + }; + + // Brief description of what this modal does + createDescription( + `This modal allows you to edit the template for an action node. You can use this template to predefine the structure of http requests.\n\nUse {{variableName}} syntax to insert variables anywhere in the request. If there's only one variable, it will be replaced with whatever is written to the action node. If there are multiple variables, the action node will look for the variables in the available named arrows.` + ); + + const createInputGroup = ( + labelText: string, + inputElement: HTMLElement, + id: string + ) => { + const div = contentEl.createEl("div", { + cls: "http-template-group", + }); + const label = div.createEl("label", { text: labelText }); + label.htmlFor = id; + inputElement.setAttribute("id", id); + div.appendChild(inputElement); + }; + + + + const nameInput = contentEl.createEl("input", { + type: "text", + value: this.template.name || "", + }) as HTMLInputElement; + nameInput.setAttribute("id", "name-input"); + createInputGroup("Name:", nameInput, "name-input"); + + const urlInput = contentEl.createEl("input", { + type: "text", + value: this.template.url || "", + }) as HTMLInputElement; + urlInput.setAttribute("id", "url-input"); + urlInput.setAttribute("placeholder", "https://example.com/{{path}}"); + createInputGroup("URL:", urlInput, "url-input"); + + // Create a select element for HTTP methods + const methodSelect = contentEl.createEl("select") as HTMLSelectElement; + const methods = ["GET", "POST", "PUT", "DELETE"]; + methods.forEach((method) => { + const option = methodSelect.createEl("option", { + text: method, + value: method, + }); + // If the current template's method matches, select this option + if (this.template.method === method) { + option.selected = true; + } + }); + createInputGroup("Method:", methodSelect, "method-select"); + + const headersValue = + this.template.headers && + this.template.headers.length > 0 + ? this.template.headers + : `{ "Content-Type": "application/json" }`; + + const headersInput = contentEl.createEl("textarea") as HTMLTextAreaElement; + + headersInput.value = headersValue; + headersInput.setAttribute("rows", "3"); + headersInput.setAttribute("placeholder", `{ "Content-Type": "application/json" }`); + + createInputGroup("Headers: (optional)", headersInput, "headers-input"); + + // Body template input + const bodyInput = contentEl.createEl("textarea", { + placeholder: + "Enter body. Use {{variableName}} for variables.", + }) as HTMLTextAreaElement; + + const bodyValue = this.template.body ?? this.template.bodyTemplate ?? ''; + + const formattedBody = this.formatBody(bodyValue); + bodyInput.value = formattedBody; + bodyInput.setAttribute("rows", "3"); + bodyInput.setAttribute( + "placeholder", + "Enter body template. Use {{variableName}} for variables." + ); + createInputGroup( + "Body: (optional)", + bodyInput, + "body-input" + ); + + const panel = new Setting(contentEl); + + panel.addButton((btn) => + btn.setButtonText("Cancel").onClick(() => { + this.close(); + this.onCancel(); + }) + ); + + panel.addButton((btn) => + btn + .setButtonText("Save") + .setCta() + .onClick(() => { + if (!urlInput.value) { + alert("URL is required"); + return; + } + + try { + JSON.parse(headersInput.value || "{}"); + } catch (error) { + alert( + "Invalid JSON format for headers. Please correct and try again." + ); + return; + } + + // Updating template object + this.template.name = nameInput.value; + this.template.url = urlInput.value; + this.template.headers = headersInput.value; + this.template.method = methodSelect.value; + this.template.body = bodyInput.value; + + // Delete deprecated bodyTemplate + delete this.template.bodyTemplate; + + this.close(); + this.onSave(this.template); + }) + ); + } + + onClose() { + const { contentEl } = this; + contentEl.empty(); + } + + formatBody(body: string): string { + try { + // Try to parse the body as JSON + const parsedBody = JSON.parse(body); + + // If successful, stringify it with proper formatting + return JSON.stringify(parsedBody, null, 2); + } catch (error) { + // If parsing failed, return the body as-is + return body; + } + } +} \ No newline at end of file diff --git a/packages/cannoli-plugin/src/modals/runPriceAlert.ts b/packages/cannoli-plugin/src/modals/runPriceAlert.ts new file mode 100644 index 0000000..5fa9ff1 --- /dev/null +++ b/packages/cannoli-plugin/src/modals/runPriceAlert.ts @@ -0,0 +1,87 @@ +import { ModelUsage } from "@deablabs/cannoli-core"; +import { App, Modal, Setting } from "obsidian"; + +export class RunPriceAlertModal extends Modal { + usage: Record; + onContinue: () => void; + onCancel: () => void; + requestThreshold: number; + + constructor( + app: App, + usage: Record, + requestThreshold: number, + onContinue: () => void, + onCancel: () => void, + ) { + super(app); + this.usage = usage; + this.onContinue = onContinue; + this.onCancel = onCancel; + this.requestThreshold = requestThreshold; + } + + onOpen() { + const { contentEl } = this; + + let totalCalls = 0; + let totalPromptTokens = 0; + + for (const usage of Object.values(this.usage)) { + totalCalls += usage.numberOfCalls; + totalPromptTokens += usage.promptTokens ?? 0; + } + + contentEl.createEl("h1", { text: "Run usage alert" }); + contentEl.createEl("p", { + text: `This run exceeds the AI requests threshold defined in your settings: ${this.requestThreshold}`, + }); + + // Convert usage object to array + for (const [model, usage] of Object.entries(this.usage)) { + contentEl.createEl("h2", { text: `Model: ${model}` }); + contentEl + .createEl("p", { + text: `\t\tEstimated prompt tokens: ${usage.promptTokens}`, + }) + .addClass("whitespace"); + contentEl + .createEl("p", { + text: `\t\tNumber of AI requests: ${usage.numberOfCalls}`, + }) + .addClass("whitespace"); + } + + contentEl.createEl("h2", { + text: `Total AI requests: ${totalCalls}`, + }); + + contentEl.createEl("h2", { + text: `Total estimated prompt tokens: ${totalPromptTokens}`, + }); + + const panel = new Setting(contentEl); + + panel.addButton((btn) => + btn.setButtonText("Cancel").onClick(() => { + this.close(); + this.onCancel(); + }) + ); + + panel.addButton((btn) => + btn + .setButtonText("Run anyway") + .setCta() + .onClick(() => { + this.close(); + this.onContinue(); + }) + ); + } + + onClose() { + const { contentEl } = this; + contentEl.empty(); + } +} \ No newline at end of file diff --git a/packages/cannoli-plugin/src/modals/versionTwoModal.ts b/packages/cannoli-plugin/src/modals/versionTwoModal.ts new file mode 100644 index 0000000..b4246d7 --- /dev/null +++ b/packages/cannoli-plugin/src/modals/versionTwoModal.ts @@ -0,0 +1,16 @@ +import { App, Modal } from "obsidian"; + +export class Version2Modal extends Modal { + paragraph: HTMLParagraphElement; + + constructor(app: App, paragraph: HTMLParagraphElement) { + super(app); + this.paragraph = paragraph; + } + + onOpen() { + const { contentEl } = this; + contentEl.createEl("h1", { text: "Cannoli 2.0" }); + contentEl.appendChild(this.paragraph); + } +} \ No newline at end of file diff --git a/packages/cannoli-plugin/src/modals/viewVals.ts b/packages/cannoli-plugin/src/modals/viewVals.ts new file mode 100644 index 0000000..6d48e38 --- /dev/null +++ b/packages/cannoli-plugin/src/modals/viewVals.ts @@ -0,0 +1,123 @@ +import { CannoliFunctionInfo } from "@deablabs/cannoli-core"; +import { App, Modal, Notice } from "obsidian"; + +export class ValTownModal extends Modal { + cannoliFunctions: Array<{ link: string; moduleUrl: string; httpEndpointUrl: string; cannoliFunctionInfo: CannoliFunctionInfo, identicalToLocal: boolean, localExists: boolean }>; + openCanvas: (canvasName: string) => boolean; + valtownApiKey: string; + bakeToValTown: (canvasName: string) => Promise; + getCannoliFunctions: () => Promise>; + createCanvas: (name: string, canvas: string) => void; + + constructor(app: App, cannoliFunctions: Array<{ link: string; moduleUrl: string; httpEndpointUrl: string; cannoliFunctionInfo: CannoliFunctionInfo, identicalToLocal: boolean, localExists: boolean }>, getCannoliFunctions: () => Promise>, openCanvas: (canvasName: string) => boolean, valtownApiKey: string, bakeToValTown: (canvasName: string) => Promise, createCanvas: (name: string, canvas: string) => void) { + super(app); + this.cannoliFunctions = cannoliFunctions; + this.openCanvas = openCanvas; + this.valtownApiKey = valtownApiKey; + this.bakeToValTown = bakeToValTown; + this.getCannoliFunctions = getCannoliFunctions; + this.createCanvas = createCanvas; + } + + onOpen() { + const { contentEl } = this; + contentEl.createEl("h1", { text: "Cannoli Vals" }); + + // Add CSS styles for table borders + const style = document.createElement('style'); + style.textContent = ` + .cannoli-table, .cannoli-table th, .cannoli-table td { + border: 1px solid grey; + border-collapse: collapse; + } + .cannoli-table th, .cannoli-table td { + padding: 8px; + } + .synced { + color: green; + } + `; + document.head.appendChild(style); + + const table = contentEl.createEl("table", { cls: "cannoli-table" }); + const tbody = table.createEl("tbody"); + + this.cannoliFunctions.forEach((func) => { + const { canvasName } = func.cannoliFunctionInfo; + const displayName = canvasName.replace(/\.canvas|\.cno/g, ""); + + const row = tbody.createEl("tr"); + + row.createEl("td", { text: displayName }); + + const canvasCell = row.createEl("td"); + const openCanvasButton = canvasCell.createEl("button", { text: "Canvas" }); + openCanvasButton.addEventListener("click", () => { + const found = this.openCanvas(canvasName); + if (found) { + this.close(); + } + }); + + const valCell = row.createEl("td"); + const openValButton = valCell.createEl("button", { text: "Val" }); + openValButton.addEventListener("click", () => { + window.open(func.link, "_blank"); + }); + + const copyUrlCell = row.createEl("td"); + const copyButton = copyUrlCell.createEl("button", { text: "📋 URL" }); + copyButton.addEventListener("click", () => { + navigator.clipboard.writeText(func.httpEndpointUrl).then(() => { + new Notice("HTTP Endpoint URL copied to clipboard"); + }).catch((err) => { + console.error("Failed to copy text: ", err); + }); + }); + + const copyCurlCell = row.createEl("td"); + const copyCurlButton = copyCurlCell.createEl("button", { text: "📋 cURL" }); + copyCurlButton.addEventListener("click", () => { + const curlCommand = `curl -X POST ${func.httpEndpointUrl} \\ +-H "Authorization: Bearer ${this.valtownApiKey}" \\ +-H "Content-Type: application/json" \\ +${Object.keys(func.cannoliFunctionInfo.params).length > 0 ? `-d '${JSON.stringify(Object.fromEntries(Object.keys(func.cannoliFunctionInfo.params).map(param => [param, "value"])), null, 2)}'` : ''}`; + navigator.clipboard.writeText(curlCommand).then(() => { + new Notice("cURL command copied to clipboard"); + }).catch((err) => { + console.error("Failed to copy text: ", err); + }); + }); + + const syncStatusCell = row.createEl("td"); + + if (!func.localExists) { + const syncButton = syncStatusCell.createEl("button", { text: "Download" }); + syncButton.addEventListener("click", async () => { + this.createCanvas(func.cannoliFunctionInfo.canvasName, JSON.stringify(func.cannoliFunctionInfo.cannoli)); + new Notice(`Copied ${func.cannoliFunctionInfo.canvasName} to vault`); + const newCannoliFunctions = await this.getCannoliFunctions(); + const newModal = new ValTownModal(this.app, newCannoliFunctions, this.getCannoliFunctions, this.openCanvas, this.valtownApiKey, this.bakeToValTown, this.createCanvas); + this.close(); + newModal.open(); + }); + } else if (func.identicalToLocal) { + syncStatusCell.createEl("span", { text: "Synced", cls: "synced" }); + } else { + const syncButton = syncStatusCell.createEl("button", { text: "Upload" }); + syncButton.addEventListener("click", async () => { + await this.bakeToValTown(canvasName); + const newCannoliFunctions = await this.getCannoliFunctions(); + const newModal = new ValTownModal(this.app, newCannoliFunctions, this.getCannoliFunctions, this.openCanvas, this.valtownApiKey, this.bakeToValTown, this.createCanvas); + this.close(); + newModal.open(); + }); + } + }); + } + + onClose() { + const { contentEl } = this; + contentEl.empty(); + } +} \ No newline at end of file diff --git a/packages/cannoli-plugin/src/settings/sections/actionSettings.ts b/packages/cannoli-plugin/src/settings/sections/actionSettings.ts new file mode 100644 index 0000000..164e3b0 --- /dev/null +++ b/packages/cannoli-plugin/src/settings/sections/actionSettings.ts @@ -0,0 +1,73 @@ +import { HttpTemplate } from "@deablabs/cannoli-core"; +import { Setting } from "obsidian"; +import Cannoli from "src/main"; +import { HttpTemplateEditorModal } from "src/modals/httpTemplateEditor"; + +export function createActionSettings(containerEl: HTMLElement, plugin: Cannoli, display: () => void): void { + containerEl.createEl("h1", { text: "Action nodes" }); + + new Setting(containerEl) + .setName("Action node templates") + .setDesc("Manage default HTTP templates for action nodes.") + .addButton((button) => + button.setButtonText("+ Template").onClick(async () => { + // Create a new command object to pass to the modal + const newCommand: HttpTemplate = { + name: "", + url: "", + headers: `{ "Content-Type": "application/json" }`, + id: "", + method: "GET", + }; + + // Open the modal to edit the new template + new HttpTemplateEditorModal( + plugin.app, + newCommand, + async (command) => { + plugin.settings.httpTemplates.push(command); + await plugin.saveSettings(); + // Refresh the settings pane to reflect the changes + display(); + }, + () => { } + ).open(); + }) + ); + + // Iterate through saved templates and display them + for (const template of plugin.settings.httpTemplates) { + new Setting(containerEl) + .setName(template.name) + .addButton((button) => + button.setButtonText("Edit").onClick(() => { + // Open the modal to edit the existing template + new HttpTemplateEditorModal( + plugin.app, + template, + async (updatedTemplate) => { + Object.assign(template, updatedTemplate); + await plugin.saveSettings(); + // Refresh the settings pane to reflect the changes + display(); + }, + () => { } + ).open(); + }) + ) + .addButton((button) => + button.setButtonText("Delete").onClick(async () => { + const index = + plugin.settings.httpTemplates.indexOf( + template + ); + if (index > -1) { + plugin.settings.httpTemplates.splice(index, 1); + await plugin.saveSettings(); + // Refresh the settings pane to reflect the changes + display(); + } + }) + ); + } +} \ No newline at end of file diff --git a/packages/cannoli-plugin/src/settings/sections/bakingSettings.ts b/packages/cannoli-plugin/src/settings/sections/bakingSettings.ts new file mode 100644 index 0000000..7b1917b --- /dev/null +++ b/packages/cannoli-plugin/src/settings/sections/bakingSettings.ts @@ -0,0 +1,57 @@ +import { BakeLanguage, BakeRuntime } from "@deablabs/cannoli-core"; +import { Setting } from "obsidian"; +import Cannoli from "src/main"; + +export function createBakingSettings(containerEl: HTMLElement, plugin: Cannoli): void { + containerEl.createEl("h1", { text: "Baking" }); + + // Filepath for baked cannoli folder + new Setting(containerEl) + .setName("Baked cannoli folder") + .setDesc("The path to the folder where baked cannoli will be saved. There can be subfolders.") + .addText((text) => + text + .setValue(plugin.settings.bakedCannoliFolder) + .onChange(async (value) => { + plugin.settings.bakedCannoliFolder = value; + await plugin.saveSettings(); + }) + ); + + new Setting(containerEl) + .setName("Language") + .addDropdown((dropdown) => { + dropdown.addOption("typescript", "Typescript"); + dropdown.addOption("javascript", "Javascript"); + dropdown.setValue(plugin.settings.bakeLanguage); + dropdown.onChange(async (value) => { + plugin.settings.bakeLanguage = value as BakeLanguage; + await plugin.saveSettings(); + }); + }); + + new Setting(containerEl) + .setName("Runtime") + .addDropdown((dropdown) => { + dropdown.addOption("node", "Node"); + dropdown.addOption("deno", "Deno"); + dropdown.addOption("bun", "Bun"); + dropdown.setValue(plugin.settings.bakeRuntime); + dropdown.onChange(async (value) => { + plugin.settings.bakeRuntime = value as BakeRuntime; + await plugin.saveSettings(); + }); + }); + + new Setting(containerEl) + .setName("Indent") + .addDropdown((dropdown) => { + dropdown.addOption("2", "2"); + dropdown.addOption("4", "4"); + dropdown.setValue(plugin.settings.bakeIndent); + dropdown.onChange(async (value) => { + plugin.settings.bakeIndent = value as "2" | "4"; + await plugin.saveSettings(); + }); + }); +} \ No newline at end of file diff --git a/packages/cannoli-plugin/src/settings/sections/canvasSettings.ts b/packages/cannoli-plugin/src/settings/sections/canvasSettings.ts new file mode 100644 index 0000000..3037136 --- /dev/null +++ b/packages/cannoli-plugin/src/settings/sections/canvasSettings.ts @@ -0,0 +1,25 @@ +import { Setting } from "obsidian"; +import { DEFAULT_SETTINGS } from "../settings"; +import Cannoli from "src/main"; + +export function createCanvasSettings(containerEl: HTMLElement, plugin: Cannoli): void { + containerEl.createEl("h1", { text: "Canvas preferences" }); + + // Add toggle for contentIsColorless + new Setting(containerEl) + .setName("Parse colorless nodes as content nodes") + .setDesc( + "Toggle this if you'd like colorless (grey) nodes to be interpreted as content nodes rather than call nodes. Purple nodes will then be interpreted as call nodes." + ) + .addToggle((toggle) => + toggle + .setValue( + plugin.settings.contentIsColorless ?? + DEFAULT_SETTINGS.contentIsColorless + ) + .onChange(async (value) => { + plugin.settings.contentIsColorless = value; + await plugin.saveSettings(); + }) + ); +} \ No newline at end of file diff --git a/packages/cannoli-plugin/src/settings/sections/chatCannoliSettings.ts b/packages/cannoli-plugin/src/settings/sections/chatCannoliSettings.ts new file mode 100644 index 0000000..82facc1 --- /dev/null +++ b/packages/cannoli-plugin/src/settings/sections/chatCannoliSettings.ts @@ -0,0 +1,50 @@ +import { Setting } from "obsidian"; +import Cannoli from "src/main"; + +export function createChatCannoliSettings(containerEl: HTMLElement, plugin: Cannoli): void { + containerEl.createEl("h1", { text: "Chat cannolis" }); + + // Chat format string setting, error if invalid + new Setting(containerEl) + .setName("Chat format string") + .setDesc( + "This string will be used to format chat messages when using chat arrows. This string must contain the placeholders {{role}} and {{content}}, which will be replaced with the role and content of the message, respectively." + ) + .addTextArea((text) => + text + .setValue(plugin.settings.chatFormatString) + .onChange(async (value) => { + // Check if the format string is valid + const rolePlaceholder = "{{role}}"; + const contentPlaceholder = "{{content}}"; + if ( + !value.includes(rolePlaceholder) || + !value.includes(contentPlaceholder) + ) { + alert( + `Invalid format string. Please include both ${rolePlaceholder} and ${contentPlaceholder}.` + ); + return; + } + + plugin.settings.chatFormatString = value; + await plugin.saveSettings(); + }) + ); + + new Setting(containerEl) + .setName("Auto-scroll with token stream") + .setDesc( + "Move the cursor forward every time a token is streamed in from a chat arrow. This will lock the scroll position to the bottom of the note." + ) + .addToggle((toggle) => + toggle + .setValue( + plugin.settings.autoScrollWithTokenStream || false + ) + .onChange(async (value) => { + plugin.settings.autoScrollWithTokenStream = value; + await plugin.saveSettings(); + }) + ); +} \ No newline at end of file diff --git a/packages/cannoli-plugin/src/settings/sections/llmSettings.ts b/packages/cannoli-plugin/src/settings/sections/llmSettings.ts new file mode 100644 index 0000000..39ad946 --- /dev/null +++ b/packages/cannoli-plugin/src/settings/sections/llmSettings.ts @@ -0,0 +1,78 @@ +import { Setting } from "obsidian"; +import Cannoli from "src/main"; +import { DEFAULT_SETTINGS } from "../settings"; +import { SupportedProviders } from "@deablabs/cannoli-core"; +import { createAnthropicSettings } from "./llmSettings/anthropicSettings"; +import { createAzureOpenAISettings } from "./llmSettings/azureOpenAISettings"; +import { createGeminiSettings } from "./llmSettings/geminiSettings"; +import { createGroqSettings } from "./llmSettings/groqSettings"; +import { createOllamaSettings } from "./llmSettings/ollamaSettings"; +import { createOpenAISettings } from "./llmSettings/openAISettings"; + +export function createLLMSettings(containerEl: HTMLElement, plugin: Cannoli, display: () => void): void { + // Add dropdown for AI provider with options OpenAI and Ollama + new Setting(containerEl) + .setName("AI Provider") + .setDesc( + "Choose which provider settings to edit. This dropdown will also select your default provider, which can be overridden at the node level using config arrows." + ) + .addDropdown((dropdown) => { + dropdown.addOption("openai", "OpenAI"); + dropdown.addOption("azure_openai", "Azure OpenAI"); + dropdown.addOption("ollama", "Ollama"); + dropdown.addOption("gemini", "Gemini"); + dropdown.addOption("anthropic", "Anthropic"); + dropdown.addOption("groq", "Groq"); + dropdown.setValue( + plugin.settings.llmProvider ?? + DEFAULT_SETTINGS.llmProvider + ); + dropdown.onChange(async (value) => { + plugin.settings.llmProvider = value as SupportedProviders; + await plugin.saveSettings(); + display(); + }); + }); + + containerEl.createEl("h1", { text: "LLM" }); + + if (plugin.settings.llmProvider === "openai") { + createOpenAISettings(containerEl, plugin); + } else if (plugin.settings.llmProvider === "azure_openai") { + createAzureOpenAISettings(containerEl, plugin); + } else if (plugin.settings.llmProvider === "ollama") { + createOllamaSettings(containerEl, plugin); + } else if (plugin.settings.llmProvider === "gemini") { + createGeminiSettings(containerEl, plugin); + } else if (plugin.settings.llmProvider === "anthropic") { + createAnthropicSettings(containerEl, plugin, display); + } else if (plugin.settings.llmProvider === "groq") { + createGroqSettings(containerEl, plugin); + } + + new Setting(containerEl) + .setName("LLM call concurrency limit (pLimit)") + .setDesc( + "The maximum number of LLM calls that can be made at once. Decrease this if you are running into rate limiting issues." + ) + .addText((text) => + text + .setValue( + Number.isInteger(plugin.settings.pLimit) + ? plugin.settings.pLimit.toString() + : DEFAULT_SETTINGS.pLimit.toString() + ) + .onChange(async (value) => { + // If it's not empty and it's a positive integer, save it + if (!isNaN(parseInt(value)) && parseInt(value) > 0) { + plugin.settings.pLimit = parseInt(value); + await plugin.saveSettings(); + } else { + // Otherwise, reset it to the default + plugin.settings.pLimit = + DEFAULT_SETTINGS.pLimit; + await plugin.saveSettings(); + } + }) + ); +} \ No newline at end of file diff --git a/packages/cannoli-plugin/src/settings/sections/llmSettings/anthropicSettings.ts b/packages/cannoli-plugin/src/settings/sections/llmSettings/anthropicSettings.ts new file mode 100644 index 0000000..3ef5045 --- /dev/null +++ b/packages/cannoli-plugin/src/settings/sections/llmSettings/anthropicSettings.ts @@ -0,0 +1,97 @@ +import { Setting } from "obsidian"; +import Cannoli from "src/main"; +import { DEFAULT_SETTINGS } from "src/settings/settings"; + +export function createAnthropicSettings(containerEl: HTMLElement, plugin: Cannoli, display: () => void): void { + new Setting(containerEl) + .setName("Create proxy server on Val Town") + .setDesc("Anthropic requests currently require a proxy server. This button will use your Val Town API key to create a new HTTP val on your Val Town account which will handle all Anthropic requests.") + .addButton((button) => + button + .setButtonText("Create proxy server") + .onClick(async () => { + // If they don't have a valtown API key, give a notice + if (!plugin.settings.valTownAPIKey) { + alert("You don't have a Val Town API key. Please enter one in the settings."); + return; + } + + // call the create proxy server function + await plugin.createProxyServer(); + + await plugin.saveSettings(); + display(); + }) + ); + + new Setting(containerEl) + .setName("Anthropic base URL") + .setDesc("This base URL will be used to make all Anthropic LLM calls.") + .addText((text) => + text + .setValue(plugin.settings.anthropicBaseURL) + .setPlaceholder("https://api.anthropic.com/v1/") + .onChange(async (value) => { + plugin.settings.anthropicBaseURL = value; + await plugin.saveSettings(); + }) + ); + + // anthropic api key setting + new Setting(containerEl) + .setName("Anthropic API key") + .setDesc( + "This key will be used to make all Anthropic LLM calls. Be aware that complex cannolis, can be expensive to run." + ) + .addText((text) => + text + .setValue(plugin.settings.anthropicAPIKey) + .setPlaceholder("sk-...") + .onChange(async (value) => { + plugin.settings.anthropicAPIKey = value; + await plugin.saveSettings(); + }).inputEl.setAttribute("type", "password") + ); + // anthropic model setting + new Setting(containerEl) + .setName("Anthropic model") + .setDesc( + "This model will be used for all LLM nodes unless overridden with a config arrow." + ) + .addText((text) => + text + .setValue(plugin.settings.anthropicModel) + .onChange(async (value) => { + plugin.settings.anthropicModel = value; + await plugin.saveSettings(); + }) + ); + // Default LLM temperature setting + new Setting(containerEl) + .setName("Default LLM temperature") + .setDesc( + "This temperature will be used for all LLM nodes unless overridden with a config arrow." + ) + .addText((text) => + text + .setValue( + !isNaN(plugin.settings.anthropicTemperature) && + plugin.settings.anthropicTemperature + ? plugin.settings.anthropicTemperature.toString() + : DEFAULT_SETTINGS.anthropicTemperature.toString() + ) + .onChange(async (value) => { + // If it's not empty and it's a number, save it + if (!isNaN(parseFloat(value))) { + plugin.settings.anthropicTemperature = + parseFloat(value); + await plugin.saveSettings(); + } else { + // Otherwise, reset it to the default + plugin.settings.anthropicTemperature = + DEFAULT_SETTINGS.anthropicTemperature; + await plugin.saveSettings(); + } + }) + ); +} \ No newline at end of file diff --git a/packages/cannoli-plugin/src/settings/sections/llmSettings/azureOpenAISettings.ts b/packages/cannoli-plugin/src/settings/sections/llmSettings/azureOpenAISettings.ts new file mode 100644 index 0000000..133856b --- /dev/null +++ b/packages/cannoli-plugin/src/settings/sections/llmSettings/azureOpenAISettings.ts @@ -0,0 +1,126 @@ +import { Setting } from "obsidian"; +import Cannoli from "src/main"; +import { DEFAULT_SETTINGS } from "src/settings/settings"; + +export function createAzureOpenAISettings(containerEl: HTMLElement, plugin: Cannoli): void { + // azure openai api key setting + new Setting(containerEl) + .setName("Azure OpenAI API key") + .setDesc( + "This key will be used to make all Azure OpenAI LLM calls. Be aware that complex cannolis, can be expensive to run." + ) + .addText((text) => + text + .setValue(plugin.settings.azureAPIKey) + .setPlaceholder("sk-...") + .onChange(async (value) => { + plugin.settings.azureAPIKey = value; + await plugin.saveSettings(); + }).inputEl.setAttribute("type", "password") + ); + // azure openai model setting + new Setting(containerEl) + .setName("Azure OpenAI model") + .setDesc( + "This model will be used for all LLM nodes unless overridden with a config arrow." + ) + .addText((text) => + text + .setValue(plugin.settings.azureModel) + .onChange(async (value) => { + plugin.settings.azureModel = value; + await plugin.saveSettings(); + }) + ); + // Default LLM temperature setting + new Setting(containerEl) + .setName("LLM temperature") + .setDesc( + "This temperature will be used for all LLM nodes unless overridden with a config arrow." + ) + .addText((text) => + text + .setValue( + !isNaN(plugin.settings.azureTemperature) && + plugin.settings.azureTemperature + ? plugin.settings.azureTemperature.toString() + : DEFAULT_SETTINGS.azureTemperature.toString() + ) + .onChange(async (value) => { + // If it's not empty and it's a number, save it + if (!isNaN(parseFloat(value))) { + plugin.settings.azureTemperature = + parseFloat(value); + await plugin.saveSettings(); + } else { + // Otherwise, reset it to the default + plugin.settings.azureTemperature = + DEFAULT_SETTINGS.azureTemperature; + await plugin.saveSettings(); + } + }) + ); + // azure openai api deployment name setting + new Setting(containerEl) + .setName("Azure OpenAI API deployment name") + .setDesc( + "This deployment will be used to make all Azure OpenAI LLM calls." + ) + .addText((text) => + text + .setValue(plugin.settings.azureOpenAIApiDeploymentName) + .setPlaceholder("deployment-name") + .onChange(async (value) => { + plugin.settings.azureOpenAIApiDeploymentName = value; + await plugin.saveSettings(); + }) + ); + + // azure openai api instance name setting + new Setting(containerEl) + .setName("Azure OpenAI API instance name") + .setDesc( + "This instance will be used to make all Azure OpenAI LLM calls." + ) + .addText((text) => + text + .setValue(plugin.settings.azureOpenAIApiInstanceName) + .setPlaceholder("instance-name") + .onChange(async (value) => { + plugin.settings.azureOpenAIApiInstanceName = value; + await plugin.saveSettings(); + }) + ); + + // azure openai api version setting + new Setting(containerEl) + .setName("Azure OpenAI API version") + .setDesc( + "This version will be used to make all Azure OpenAI LLM calls. Be aware that complex cannolis, can be expensive to run." + ) + .addText((text) => + text + .setValue(plugin.settings.azureOpenAIApiVersion) + .setPlaceholder("xxxx-xx-xx") + .onChange(async (value) => { + plugin.settings.azureOpenAIApiVersion = value; + await plugin.saveSettings(); + }) + ); + + // azure base url setting + new Setting(containerEl) + .setName("Azure base url") + .setDesc( + "This url will be used to make azure openai llm calls against a different endpoint. This is useful for switching to an azure enterprise endpoint, or, some other openai compatible service." + ) + .addText((text) => + text + .setValue(plugin.settings.azureBaseURL) + .setPlaceholder("https://api.openai.com/v1/") + .onChange(async (value) => { + plugin.settings.azureBaseURL = value; + await plugin.saveSettings(); + }) + ); +} \ No newline at end of file diff --git a/packages/cannoli-plugin/src/settings/sections/llmSettings/geminiSettings.ts b/packages/cannoli-plugin/src/settings/sections/llmSettings/geminiSettings.ts new file mode 100644 index 0000000..4e2848e --- /dev/null +++ b/packages/cannoli-plugin/src/settings/sections/llmSettings/geminiSettings.ts @@ -0,0 +1,63 @@ +import { Setting } from "obsidian"; +import Cannoli from "src/main"; +import { DEFAULT_SETTINGS } from "src/settings/settings"; + +export function createGeminiSettings(containerEl: HTMLElement, plugin: Cannoli): void { + // gemini api key setting + new Setting(containerEl) + .setName("Gemini API key") + .setDesc( + "This key will be used to make all Gemini LLM calls. Be aware that complex cannolis, can be expensive to run." + ) + .addText((text) => + text + .setValue(plugin.settings.geminiAPIKey) + .setPlaceholder("sk-...") + .onChange(async (value) => { + plugin.settings.geminiAPIKey = value; + await plugin.saveSettings(); + }).inputEl.setAttribute("type", "password") + ); + // gemini model setting + new Setting(containerEl) + .setName("Gemini model") + .setDesc( + "This model will be used for all LLM nodes unless overridden with a config arrow." + ) + .addText((text) => + text + .setValue(plugin.settings.geminiModel) + .onChange(async (value) => { + plugin.settings.geminiModel = value; + await plugin.saveSettings(); + }) + ); + // Default LLM temperature setting + new Setting(containerEl) + .setName("Default LLM temperature") + .setDesc( + "This temperature will be used for all LLM nodes unless overridden with a config arrow." + ) + .addText((text) => + text + .setValue( + !isNaN(plugin.settings.geminiTemperature) && + plugin.settings.geminiTemperature + ? plugin.settings.geminiTemperature.toString() + : DEFAULT_SETTINGS.geminiTemperature.toString() + ) + .onChange(async (value) => { + // If it's not empty and it's a number, save it + if (!isNaN(parseFloat(value))) { + plugin.settings.geminiTemperature = + parseFloat(value); + await plugin.saveSettings(); + } else { + // Otherwise, reset it to the default + plugin.settings.geminiTemperature = + DEFAULT_SETTINGS.geminiTemperature; + await plugin.saveSettings(); + } + }) + ); +} \ No newline at end of file diff --git a/packages/cannoli-plugin/src/settings/sections/llmSettings/groqSettings.ts b/packages/cannoli-plugin/src/settings/sections/llmSettings/groqSettings.ts new file mode 100644 index 0000000..2a1e868 --- /dev/null +++ b/packages/cannoli-plugin/src/settings/sections/llmSettings/groqSettings.ts @@ -0,0 +1,63 @@ +import { Setting } from "obsidian"; +import Cannoli from "src/main"; +import { DEFAULT_SETTINGS } from "src/settings/settings"; + +export function createGroqSettings(containerEl: HTMLElement, plugin: Cannoli): void { + // groq api key setting + new Setting(containerEl) + .setName("Groq API key") + .setDesc( + "This key will be used to make all Groq LLM calls. Be aware that complex cannolis, can be expensive to run." + ) + .addText((text) => + text + .setValue(plugin.settings.groqAPIKey) + .setPlaceholder("sk-...") + .onChange(async (value) => { + plugin.settings.groqAPIKey = value; + await plugin.saveSettings(); + }).inputEl.setAttribute("type", "password") + ); + // groq model setting + new Setting(containerEl) + .setName("Groq model") + .setDesc( + "This model will be used for all LLM nodes unless overridden with a config arrow." + ) + .addText((text) => + text + .setValue(plugin.settings.groqModel) + .onChange(async (value) => { + plugin.settings.groqModel = value; + await plugin.saveSettings(); + }) + ); + // Default LLM temperature setting + new Setting(containerEl) + .setName("Default LLM temperature") + .setDesc( + "This temperature will be used for all LLM nodes unless overridden with a config arrow." + ) + .addText((text) => + text + .setValue( + !isNaN(plugin.settings.groqTemperature) && + plugin.settings.groqTemperature + ? plugin.settings.groqTemperature.toString() + : DEFAULT_SETTINGS.groqTemperature.toString() + ) + .onChange(async (value) => { + // If it's not empty and it's a number, save it + if (!isNaN(parseFloat(value))) { + plugin.settings.groqTemperature = + parseFloat(value); + await plugin.saveSettings(); + } else { + // Otherwise, reset it to the default + plugin.settings.groqTemperature = + DEFAULT_SETTINGS.groqTemperature; + await plugin.saveSettings(); + } + }) + ); +} \ No newline at end of file diff --git a/packages/cannoli-plugin/src/settings/sections/llmSettings/ollamaSettings.ts b/packages/cannoli-plugin/src/settings/sections/llmSettings/ollamaSettings.ts new file mode 100644 index 0000000..6763cb5 --- /dev/null +++ b/packages/cannoli-plugin/src/settings/sections/llmSettings/ollamaSettings.ts @@ -0,0 +1,63 @@ +import { Setting } from "obsidian"; +import Cannoli from "src/main"; +import { DEFAULT_SETTINGS } from "src/settings/settings"; + +export function createOllamaSettings(containerEl: HTMLElement, plugin: Cannoli): void { + // ollama base url setting + new Setting(containerEl) + .setName("Ollama base url") + .setDesc( + "This url will be used to make all ollama LLM calls. Be aware that ollama models have different features and capabilities that may not be compatible with all features of cannoli." + ) + .addText((text) => + text + .setValue(plugin.settings.ollamaBaseUrl) + .setPlaceholder("https://ollama.com") + .onChange(async (value) => { + plugin.settings.ollamaBaseUrl = value; + await plugin.saveSettings(); + }) + ); + // ollama model setting + new Setting(containerEl) + .setName("Ollama model") + .setDesc( + "This model will be used for all LLM nodes unless overridden with a config arrow. (Note that special arrow types rely on function calling, which is not available in all models.)" + ) + .addText((text) => + text + .setValue(plugin.settings.ollamaModel) + .onChange(async (value) => { + plugin.settings.ollamaModel = value; + await plugin.saveSettings(); + }) + ); + // Default LLM temperature setting + new Setting(containerEl) + .setName("Default LLM temperature") + .setDesc( + "This temperature will be used for all LLM nodes unless overridden with a config arrow." + ) + .addText((text) => + text + .setValue( + !isNaN(plugin.settings.ollamaTemperature) && + plugin.settings.ollamaTemperature + ? plugin.settings.ollamaTemperature.toString() + : DEFAULT_SETTINGS.ollamaTemperature.toString() + ) + .onChange(async (value) => { + // If it's not empty and it's a number, save it + if (!isNaN(parseFloat(value))) { + plugin.settings.ollamaTemperature = + parseFloat(value); + await plugin.saveSettings(); + } else { + // Otherwise, reset it to the default + plugin.settings.ollamaTemperature = + DEFAULT_SETTINGS.ollamaTemperature; + await plugin.saveSettings(); + } + }) + ); +} \ No newline at end of file diff --git a/packages/cannoli-plugin/src/settings/sections/llmSettings/openAISettings.ts b/packages/cannoli-plugin/src/settings/sections/llmSettings/openAISettings.ts new file mode 100644 index 0000000..723ac34 --- /dev/null +++ b/packages/cannoli-plugin/src/settings/sections/llmSettings/openAISettings.ts @@ -0,0 +1,105 @@ +import { Setting } from "obsidian"; +import Cannoli from "src/main"; +import { DEFAULT_SETTINGS } from "src/settings/settings"; + +export function createOpenAISettings(containerEl: HTMLElement, plugin: Cannoli): void { + new Setting(containerEl) + .setName("OpenAI API key") + .setDesc( + "This key will be used to make all openai LLM calls. Be aware that complex cannolis, especially those with many GPT-4 calls, can be expensive to run." + ) + .addText((text) => + text + .setValue(plugin.settings.openaiAPIKey) + .setPlaceholder("sk-...") + .onChange(async (value) => { + plugin.settings.openaiAPIKey = value; + await plugin.saveSettings(); + }).inputEl.setAttribute("type", "password") + ); + + // Request threshold setting. This is the number of AI requests at which the user will be alerted before running a Cannoli + new Setting(containerEl) + .setName("AI requests threshold") + .setDesc( + "If the cannoli you are about to run is estimated to make more than this amount of AI requests, you will be alerted before running it." + ) + .addText((text) => + text + .setValue( + Number.isInteger(plugin.settings.requestThreshold) + ? plugin.settings.requestThreshold.toString() + : DEFAULT_SETTINGS.requestThreshold.toString() + ) + .onChange(async (value) => { + // If it's not empty and it's an integer, save it + if (!isNaN(parseInt(value)) && Number.isInteger(parseInt(value))) { + plugin.settings.requestThreshold = parseInt(value); + await plugin.saveSettings(); + } else { + // Otherwise, reset it to the default + plugin.settings.requestThreshold = DEFAULT_SETTINGS.requestThreshold; + await plugin.saveSettings(); + } + }) + ); + + // Default LLM model setting + new Setting(containerEl) + .setName("Default LLM model") + .setDesc( + "This model will be used for all LLM nodes unless overridden with a config arrow. (Note that special arrow types rely on function calling, which is not available in all models.)" + ) + .addText((text) => + text + .setValue(plugin.settings.defaultModel) + .onChange(async (value) => { + plugin.settings.defaultModel = value; + await plugin.saveSettings(); + }) + ); + + // Default LLM temperature setting + new Setting(containerEl) + .setName("Default LLM temperature") + .setDesc( + "This temperature will be used for all LLM nodes unless overridden with a config arrow." + ) + .addText((text) => + text + .setValue( + !isNaN(plugin.settings.defaultTemperature) && + plugin.settings.defaultTemperature + ? plugin.settings.defaultTemperature.toString() + : DEFAULT_SETTINGS.defaultTemperature.toString() + ) + .onChange(async (value) => { + // If it's not empty and it's a number, save it + if (!isNaN(parseFloat(value))) { + plugin.settings.defaultTemperature = + parseFloat(value); + await plugin.saveSettings(); + } else { + // Otherwise, reset it to the default + plugin.settings.defaultTemperature = + DEFAULT_SETTINGS.defaultTemperature; + await plugin.saveSettings(); + } + }) + ); + // openai base url setting + new Setting(containerEl) + .setName("Openai base url") + .setDesc( + "This url will be used to make openai llm calls against a different endpoint. This is useful for switching to an azure enterprise endpoint, or, some other openai compatible service." + ) + .addText((text) => + text + .setValue(plugin.settings.openaiBaseURL) + .setPlaceholder("https://api.openai.com/v1/") + .onChange(async (value) => { + plugin.settings.openaiBaseURL = value; + await plugin.saveSettings(); + }) + ); +} \ No newline at end of file diff --git a/packages/cannoli-plugin/src/settings/sections/noteExtractionSettings.ts b/packages/cannoli-plugin/src/settings/sections/noteExtractionSettings.ts new file mode 100644 index 0000000..dc209f2 --- /dev/null +++ b/packages/cannoli-plugin/src/settings/sections/noteExtractionSettings.ts @@ -0,0 +1,68 @@ +import { Setting } from "obsidian"; +import Cannoli from "src/main"; + +export function createNoteExtractionSettings(containerEl: HTMLElement, plugin: Cannoli): void { + // Put header here + containerEl.createEl("h1", { text: "Note extraction" }); + + // Toggle adding filenames as headers when extracting text from files + new Setting(containerEl) + .setName( + "Include filenames as headers in extracted notes by default" + ) + .setDesc( + `When extracting a note in a cannoli, include the filename as a top-level header. This default can be overridden by adding "#" or "!#" after the note link in a reference like this: {{[[Stuff]]#}} or {{[[Stuff]]!#}}.` + ) + .addToggle((toggle) => + toggle + .setValue( + plugin.settings.includeFilenameAsHeader || false + ) + .onChange(async (value) => { + plugin.settings.includeFilenameAsHeader = value; + await plugin.saveSettings(); + }) + ); + + // Toggle including properties (YAML frontmatter) when extracting text from files + new Setting(containerEl) + .setName( + "Include properties when extracting or editing notes by default" + ) + .setDesc( + `When extracting or editing a note in a cannoli, include the note's properties (YAML frontmatter). This default can be overridden by adding "^" or "!^" after the note link in a reference like this: {{[[Stuff]]^}} or {{[[Stuff]]!^}}.` + ) + .addToggle((toggle) => + toggle + .setValue( + plugin.settings + .includePropertiesInExtractedNotes || false + ) + .onChange(async (value) => { + plugin.settings.includePropertiesInExtractedNotes = + value; + await plugin.saveSettings(); + }) + ); + + // Toggle including markdown links when extracting text from files + new Setting(containerEl) + .setName( + "Include markdown links when extracting or editing notes by default" + ) + .setDesc( + `When extracting or editing a note in a cannoli, include the note's markdown link above the content. This default can be overridden by adding "@" or "!@" after the note link in a reference like this: {{[[Stuff]]@}} or {{[[Stuff]]!@}}.` + ) + .addToggle((toggle) => + toggle + .setValue( + plugin.settings.includeLinkInExtractedNotes || + false + ) + .onChange(async (value) => { + plugin.settings.includeLinkInExtractedNotes = + value; + await plugin.saveSettings(); + }) + ); +} \ No newline at end of file diff --git a/packages/cannoli-plugin/src/settings/sections/secretsSettings.ts b/packages/cannoli-plugin/src/settings/sections/secretsSettings.ts new file mode 100644 index 0000000..a6ef1b1 --- /dev/null +++ b/packages/cannoli-plugin/src/settings/sections/secretsSettings.ts @@ -0,0 +1,74 @@ +import { Notice, Setting } from "obsidian"; +import Cannoli from "src/main"; + +export function createSecretsSettings(containerEl: HTMLElement, plugin: Cannoli, display: () => void): void { + containerEl.createEl("h1", { text: "Secrets" }); + + new Setting(containerEl) + .setName("Secrets") + .setDesc(`These secrets will be available in all of your cannolis, using "{{secret name}}". They are not stored in the canvas, and wil not be included when you bake a cannoli.`) + .addButton((button) => + button.setButtonText("+ Secret").onClick(async () => { + // Create a new secret object + const newSecret = { name: "", value: "", visibility: "password" }; + plugin.settings.secrets.push(newSecret); + await plugin.saveSettings(); + // Refresh the settings pane to reflect the changes + display(); + }) + ); + + // Iterate through saved secrets and display them + for (const secret of plugin.settings.secrets) { + new Setting(containerEl) + .addText((text) => + text + .setValue(secret.name) + .setPlaceholder("Secret Name") + .onChange(async (value) => { + secret.name = value; + await plugin.saveSettings(); + }) + ) + .addText((text) => + text + .setValue(secret.value) + .setPlaceholder("Secret Value") + .onChange(async (value) => { + secret.value = value; + await plugin.saveSettings(); + }) + .inputEl.setAttribute("type", secret.visibility || "password") + ) + .addButton((button) => + button.setButtonText("👁️").onClick(async () => { + secret.visibility = secret.visibility === "password" ? "text" : "password"; + await plugin.saveSettings(); + display(); + }) + ) + .addButton((button) => + button + .setButtonText("📋") + .setTooltip("Copy to clipboard") + .onClick(async () => { + await navigator.clipboard.writeText(secret.value); + new Notice("Secret value copied to clipboard"); + }) + ) + .addButton((button) => + button + .setButtonText("Delete") + .setWarning() + .onClick(async () => { + const index = plugin.settings.secrets.indexOf(secret); + if (index > -1) { + plugin.settings.secrets.splice(index, 1); + await plugin.saveSettings(); + // Refresh the settings pane to reflect the changes + display(); + } + }) + ); + } +} \ No newline at end of file diff --git a/packages/cannoli-plugin/src/settings/sections/transcriptionSettings.ts b/packages/cannoli-plugin/src/settings/sections/transcriptionSettings.ts new file mode 100644 index 0000000..1e0ecf3 --- /dev/null +++ b/packages/cannoli-plugin/src/settings/sections/transcriptionSettings.ts @@ -0,0 +1,69 @@ +import { Setting } from "obsidian"; +import Cannoli from "src/main"; + +export function createTranscriptionSettings(containerEl: HTMLElement, plugin: Cannoli, display: () => void): void { + containerEl.createEl("h1", { text: "Transcription" }); + + // Toggle voice recording triggered cannolis + new Setting(containerEl) + .setName("Enable audio recorder triggered cannolis") + .setDesc( + `Enable cannolis to be triggered by audio recordings. When you make a recording in a note with a cannoli property: (1) The audio file will be transcribed using Whisper. (2) The file reference will be replaced with the transcript. (3) The cannoli defined in the property will run.` + ) + .addToggle((toggle) => + toggle + .setValue( + plugin.settings.enableAudioTriggeredCannolis || + false + ) + .onChange(async (value) => { + plugin.settings.enableAudioTriggeredCannolis = + value; + await plugin.saveSettings(); + display(); + }) + ); + + if (plugin.settings.enableAudioTriggeredCannolis) { + // Transcription prompt + new Setting(containerEl) + .addTextArea((text) => + text + .setPlaceholder( + "Enter prompt to improve transcription accuracy" + ) + .setValue( + plugin.settings.transcriptionPrompt || "" + ) + .onChange(async (value) => { + plugin.settings.transcriptionPrompt = value; + await plugin.saveSettings(); + }) + ) + .setName("Transcription prompt") + .setDesc( + "Use this prompt to guide the style and vocabulary of the transcription. (i.e. the level of punctuation, format and spelling of uncommon words in the prompt will be mimicked in the transcription)" + ); + + // Toggle deleting audio files after starting an audio triggered cannoli + new Setting(containerEl) + .setName("Delete audio files after transcription") + .setDesc( + "After a recording is transcribed, delete the audio file." + ) + .addToggle((toggle) => + toggle + .setValue( + plugin.settings + .deleteAudioFilesAfterAudioTriggeredCannolis || + false + + ) + .onChange(async (value) => { + plugin.settings.deleteAudioFilesAfterAudioTriggeredCannolis = + value; + await plugin.saveSettings(); + }) + ); + } +} \ No newline at end of file diff --git a/packages/cannoli-plugin/src/settings/sections/valtownSettings.ts b/packages/cannoli-plugin/src/settings/sections/valtownSettings.ts new file mode 100644 index 0000000..3b6c236 --- /dev/null +++ b/packages/cannoli-plugin/src/settings/sections/valtownSettings.ts @@ -0,0 +1,39 @@ +import { Notice, Setting } from "obsidian"; +import Cannoli from "src/main"; +import { ValTownModal } from "src/modals/viewVals"; + +export function createValTownSettings(containerEl: HTMLElement, plugin: Cannoli): void { + // ValTown section + containerEl.createEl("h1", { text: "ValTown" }); + + new Setting(containerEl) + .setName("ValTown API key") + .setDesc( + `This key will be used to create Vals on your Val Town account when you run the "Create Val" command.` + ) + .addText((text) => + text + .setValue(plugin.settings.valTownAPIKey) + .setPlaceholder("...") + .onChange(async (value) => { + plugin.settings.valTownAPIKey = value; + await plugin.saveSettings(); + }).inputEl.setAttribute("type", "password") + ); + + new Setting(containerEl) + .setName("View vals") + .setDesc(`View information about your Cannoli Vals. This modal can also be opened using the "View vals" command.`) + .addButton((button) => + button.setButtonText("Open").onClick(async () => { + // new Notice("Fetching all your Cannolis..."); + try { + const modal = new ValTownModal(plugin.app, await plugin.getAllCannoliFunctions(), plugin.getAllCannoliFunctions, plugin.openCanvas, plugin.settings.valTownAPIKey, plugin.bakeToValTown, plugin.createCanvas); + modal.open(); + } catch (error) { + new Notice("Failed to fetch Cannoli functions."); + console.error(error); + } + }) + ); +} \ No newline at end of file diff --git a/packages/cannoli-plugin/src/settings/settings.ts b/packages/cannoli-plugin/src/settings/settings.ts new file mode 100644 index 0000000..9ca2f15 --- /dev/null +++ b/packages/cannoli-plugin/src/settings/settings.ts @@ -0,0 +1,96 @@ +import { BakeLanguage, BakeRuntime, HttpTemplate, SupportedProviders } from "@deablabs/cannoli-core"; + +export interface CannoliSettings { + llmProvider: SupportedProviders; + ollamaBaseUrl: string; + ollamaModel: string; + ollamaTemperature: number; + azureAPIKey: string; + azureModel: string; + azureTemperature: number; + azureOpenAIApiDeploymentName: string; + azureOpenAIApiInstanceName: string; + azureOpenAIApiVersion: string; + azureBaseURL: string; + geminiModel: string; + geminiAPIKey: string; + geminiTemperature: number; + anthropicModel: string; + anthropicAPIKey: string; + anthropicTemperature: number; + anthropicBaseURL: string; + groqModel: string; + groqAPIKey: string; + groqTemperature: number; + openaiAPIKey: string; + openaiBaseURL: string; + requestThreshold: number; + defaultModel: string; + defaultTemperature: number; + httpTemplates: HttpTemplate[]; + includeFilenameAsHeader: boolean; + includePropertiesInExtractedNotes: boolean; + includeLinkInExtractedNotes: boolean; + chatFormatString: string; + enableAudioTriggeredCannolis?: boolean; + deleteAudioFilesAfterAudioTriggeredCannolis?: boolean; + transcriptionPrompt?: string; + autoScrollWithTokenStream: boolean; + pLimit: number; + contentIsColorless: boolean; + valTownAPIKey: string; + exaAPIKey: string; + bakedCannoliFolder: string; + bakeLanguage: BakeLanguage; + bakeRuntime: BakeRuntime; + bakeIndent: "2" | "4"; + seenVersion2Modal: boolean; + secrets: { name: string; value: string; visibility: string }[]; +} + +export const DEFAULT_SETTINGS: CannoliSettings = { + llmProvider: "openai", + ollamaBaseUrl: "http://127.0.0.1:11434", + ollamaModel: "llama2", + ollamaTemperature: 1, + azureModel: "", + azureAPIKey: "", + azureTemperature: 1, + azureOpenAIApiDeploymentName: "", + azureOpenAIApiInstanceName: "", + azureOpenAIApiVersion: "", + azureBaseURL: "", + geminiModel: "gemini-1.0-pro-latest", + geminiAPIKey: "", + geminiTemperature: 1, + anthropicModel: "claude-3-opus-20240229", + anthropicAPIKey: "", + anthropicTemperature: 1, + anthropicBaseURL: "", + groqModel: "llama3-70b-8192", + groqAPIKey: "", + groqTemperature: 1, + openaiAPIKey: "", + openaiBaseURL: "", + requestThreshold: 20, + defaultModel: "gpt-4o", + defaultTemperature: 1, + httpTemplates: [], + includeFilenameAsHeader: false, + includePropertiesInExtractedNotes: false, + includeLinkInExtractedNotes: false, + chatFormatString: `---\n# {{role}}\n\n{{content}}`, + enableAudioTriggeredCannolis: false, + deleteAudioFilesAfterAudioTriggeredCannolis: false, + autoScrollWithTokenStream: false, + pLimit: 50, + contentIsColorless: false, + valTownAPIKey: "", + exaAPIKey: "", + bakedCannoliFolder: "Baked Cannoli", + bakeLanguage: "typescript", + bakeRuntime: "node", + bakeIndent: "2", + seenVersion2Modal: false, + secrets: [], +}; \ No newline at end of file diff --git a/packages/cannoli-plugin/src/settings/settingsTab.ts b/packages/cannoli-plugin/src/settings/settingsTab.ts new file mode 100644 index 0000000..d335005 --- /dev/null +++ b/packages/cannoli-plugin/src/settings/settingsTab.ts @@ -0,0 +1,63 @@ +import { App, PluginSettingTab, Setting } from "obsidian"; +import Cannoli from "src/main"; +import { createLLMSettings } from "./sections/llmSettings"; +import { createCanvasSettings } from "./sections/canvasSettings"; +import { createNoteExtractionSettings } from "./sections/noteExtractionSettings"; +import { createChatCannoliSettings } from "./sections/chatCannoliSettings"; +import { createTranscriptionSettings } from "./sections/transcriptionSettings"; +import { createSecretsSettings } from "./sections/secretsSettings"; +import { createBakingSettings } from "./sections/bakingSettings"; +import { createValTownSettings } from "./sections/valtownSettings"; +import { createActionSettings } from "./sections/actionSettings"; + +export class CannoliSettingTab extends PluginSettingTab { + plugin: Cannoli; + + constructor(app: App, plugin: Cannoli) { + super(app, plugin); + this.plugin = plugin; + + this.display = this.display.bind(this); + } + + display(): void { + const { containerEl } = this; + + containerEl.empty(); + + // Add a header + containerEl.createEl("h1", { text: "Cannoli Settings" }); + + containerEl.appendChild(this.plugin.createVersion2UpdateParagraph()); + + // Add button to add sample folder + new Setting(containerEl) + .setName("Add Cannoli College") + .setDesc( + "Add a folder of sample cannolis to your vault to walk you through the basics of Cannoli. (Delete and re-add this folder to get the latest version after an update.)" + ) + .addButton((button) => + button.setButtonText("Add").onClick(() => { + this.plugin.addSampleFolder(); + }) + ); + + createLLMSettings(containerEl, this.plugin, this.display); + + createCanvasSettings(containerEl, this.plugin); + + createNoteExtractionSettings(containerEl, this.plugin); + + createChatCannoliSettings(containerEl, this.plugin); + + createTranscriptionSettings(containerEl, this.plugin, this.display); + + createSecretsSettings(containerEl, this.plugin, this.display); + + createBakingSettings(containerEl, this.plugin); + + createValTownSettings(containerEl, this.plugin); + + createActionSettings(containerEl, this.plugin, this.display); + } +} diff --git a/packages/cannoli-plugin/src/val_templates.ts b/packages/cannoli-plugin/src/val_templates.ts deleted file mode 100644 index 5abee2b..0000000 --- a/packages/cannoli-plugin/src/val_templates.ts +++ /dev/null @@ -1,178 +0,0 @@ -export const cannoliServerCode = ` -import { Hono } from "npm:hono"; -import { bearerAuth } from "npm:hono/bearer-auth"; - -const app = new Hono(); - -const token = Deno.env.get("cannoli"); - -app.use("/*", bearerAuth({ token })); - -app.all("/:cannoliName", async (c) => { - try { - const { cannoliName } = c.req.param(); - const cannoliFunction = await importCannoliFunction(cannoliName); - - const args = await parseArgs(c); - const results = await callCannoliFunction(cannoliFunction, args); - - return c.json(results); - } catch (error) { - return c.text(\`Error: \${error.message}\`, 500); - } -}); - -async function fetchUserProfile(): Promise<{ username: string }> { - const response = await fetch(\`https://api.val.town/v1/me\`, { - headers: { - Authorization: \`Bearer \${Deno.env.get("valtown")}\`, - }, - }); - - if (!response.ok) { - throw new Error("Failed to fetch val.town profile"); - } - - return await response.json(); -} - -async function importCannoliFunction(cannoliName: string): Promise { - try { - const profile = await fetchUserProfile(); - const username = profile.username; - const importPath = \`https://esm.town/v/\${username}/\${cannoliName}\`; - const module = await import(\${importPath}); - return module.default || module[\${cannoliName}]; - } catch (error) { - throw new Error(\`Error importing cannoli function: \${error.message}\`); - } -} - -async function parseArgs(c: any): Promise | string> { - let args: Record | string = {}; - if (c.req.method === "POST") { - try { - args = await c.req.json(); - } catch { - args = await c.req.text(); - if (!args) { - args = {}; - } - } - } else if (c.req.method === "GET") { - const sp = new URLSearchParams(c.req.url.split("?")[1]); - if (sp.toString().includes("=")) { - args = {}; - sp.forEach((value, key) => { - (args as Record)[key] = value; - }); - } else { - args = sp.toString(); - if (!args) { - args = {}; - } - } - } - return args; -} - -async function callCannoliFunction( - cannoliFunction: Function, - args: Record | string, -): Promise> { - const fnStr = cannoliFunction.toString(); - const paramsStr = fnStr.match(/(([^)]*))/)?.[1] || ""; - const params = paramsStr.split(",").map(param => param.split("=")[0].trim()).filter(param => param !== ""); - - let coercedArgs: unknown[] = []; - - if (typeof args === "string") { - if (params.length === 1) { - coercedArgs = [args]; - } else { - throw new Error("Cannoli expects multiple parameters but received a single string argument."); - } - } else { - const missingParams = params.filter(param => !(param in args)); - const nonStringParams = params.filter(param => typeof args[param] !== "string"); - let errorMessages = []; - if (missingParams.length) errorMessages.push(\`Missing required parameters: \${missingParams.join(", ")}\`); - if (nonStringParams.length) errorMessages.push(\`Parameters expect string values: \${nonStringParams.join(", ")}\`); - if (errorMessages.length) throw new Error(errorMessages.join(". ")); - - coercedArgs = params.map(param => args[param]); - } - - try { - const result = await cannoliFunction(...coercedArgs); - if (result === undefined) return {}; - if (typeof result === "string") return { result }; - if (typeof result === "object" && result !== null) - return Object.fromEntries(Object.entries(result).map(([key, value]) => [key, String(value)])); - return {}; - } catch (error) { - throw new Error(\`Error executing cannoli function: \${error.message}\`); - } -} - -export default app.fetch.bind(app); -` - -const cannoliServerReadmeTemplate = `# Cannoli Server Endpoint - -This HTTP endpoint can be used to execute baked Cannolis stored as scripts in your Val Town account. Cannolis are functions that take one or more string parameters and return one or more string results. You can specify which Cannoli to use via the URL path, and provide parameters through either the query string (GET request) or the request body (POST request). - -## How to use - -1. **Setup Authorization**: - - Set a \`cannoli\` Env variable in your Val Town settings to something only you know. - - Set up any other Env variables used by your cannolis, including LLM provider keys. - - LLM provider API keys look like this by default: \`PROVIDER_API_KEY\`, but you can check the specifics in the \`LLMConfig\` array for a particular cannoli - -2. **Endpoint URL**: - - \`https://{{username}}-cannoliserver.web.val.run/cannoliName\` - - Replace \`cannoliName\` with the name of the Cannoli you want to execute. - -3. **Request Methods**: - - **GET**: Pass parameters in the query string. - - **POST**: Pass parameters in the request body as JSON or plain text. - -## Requests - - The parameters expected by a cannoli are defined by their named input nodes (See the section in the Cannoli College on input and output nodes to learn how to set these up) - - When a cannoli is run with parameters, the content of each corresponding named input node is set to the parameter value before starting the run - -### GET Request - -curl -X GET 'https://{{username}}-cannoliserver.web.val.run/mamaMia?poppa=value1&pia=value2' \\ - -H 'Authorization: Bearer \`cannoli\`' - -### POST Request - -curl -X POST 'https://{{username}}-cannoliserver.web.val.run/mamaMia' \\ - -H 'Authorization: Bearer \`cannoli\`' \\ - -H 'Content-Type: application/json' \\ - -d '{"poppa": "value1", "pia": "value2"}' - -## Responses - - Responses to this endpoint are objects with string properties - - Each property contains the contents of one of the named output nodes after the run has completed (see the Cannoli College section on input and output nodes to learn how to set these up) - -### Example response - -{ \\ - "planet": "Jupiter", \\ - "explanation": "I don't really know tbh." \\ -} - -## Privacy and sharing - -This Val is unlisted by default, meaning only people with the link can see and use it, but any cannoli you call through this Val will use your API keys, so we secure it with the \`cannoli\` Env variable of your Val Town account. - -When you bake a Cannoli from Obsidian, it is private by default, but it can still be imported by this val. Cannolis are just scripts that export a function, so you can make them public if you'd like, and share them so they can be imported anywhere or forked and used by others' Cannoli Server vals. - -All LLM provider keys and other defined Env variables are referenced through the Val environment, but check that you haven't copied an API key into the canvas itself before sharing. -`; - -export function generateCannoliServerReadme(username: string): string { - return cannoliServerReadmeTemplate.replace(/{{username}}/g, username); -} diff --git a/packages/cannoli-plugin/src/vault_interface.ts b/packages/cannoli-plugin/src/vault_interface.ts index 70b3de8..5891325 100644 --- a/packages/cannoli-plugin/src/vault_interface.ts +++ b/packages/cannoli-plugin/src/vault_interface.ts @@ -1,9 +1,9 @@ import Cannoli from "./main"; import { Reference, ReferenceType, FileManager, CannoliNode, ContentNodeType } from "@deablabs/cannoli-core"; -import { App, DropdownComponent, Modal, resolveSubpath, TextAreaComponent, TextComponent, ToggleComponent } from "obsidian"; +import { resolveSubpath } from "obsidian"; import { getAPI } from "obsidian-dataview"; import * as yaml from "js-yaml"; -import moment from 'moment'; +import { CustomModal } from "./modals/customModal"; export class VaultInterface implements FileManager { private cannoli: Cannoli; @@ -997,405 +997,3 @@ export class VaultInterface implements FileManager { } } -interface ModalComponent { - type: 'text' | 'input'; - content: string; - name: string; - fieldType: string; - options?: string[]; - format: string; -} - -interface ModalParagraph { - components: ModalComponent[]; -} - -class CustomModal extends Modal { - layout: string; - callback: (result: Record | Error) => void; - title: string; - paragraphs: ModalParagraph[]; - isSubmitted: boolean; - values: Record; - - constructor(app: App, layout: string, callback: (result: Record | Error) => void, title: string) { - super(app); - this.layout = layout; - this.callback = callback; - this.title = title; - this.paragraphs = this.parseLayout(); - this.isSubmitted = false; - this.values = {}; - } - - parseLayout(): ModalParagraph[] { - const regex = /(\s*)==([\s\S]+?)==|([^=]+)/g; - const paragraphs: ModalParagraph[] = [{ components: [] }]; - let match; - - while ((match = regex.exec(this.layout)) !== null) { - if (match[2]) { // Input component - const trimmedContent = match[2].trim(); - let fieldContent: string | string[]; - - // Check if content is a JSON array - if (trimmedContent.startsWith('[') && trimmedContent.endsWith(']')) { - try { - // Attempt to parse as JSON, handling potential escaping issues - fieldContent = JSON.parse(trimmedContent.replace(/\\n/g, '\n').replace(/\\"/g, '"')); - } catch (e) { - console.error('Failed to parse JSON options:', e); - // If parsing fails, treat it as a comma-separated list - fieldContent = trimmedContent.slice(1, -1).split(',').map(item => - item.trim().replace(/^["']|["']$/g, '') // Remove surrounding quotes if present - ); - } - } else { - fieldContent = trimmedContent; - } - - let fieldName: string, fieldType: string, options: string[] | undefined; - - if (Array.isArray(fieldContent)) { - // If fieldContent is an array, assume the first element is the field name and type - const [nameAndType, ...rest] = fieldContent; - [fieldName, fieldType] = this.parseNameAndType(nameAndType as string); - options = rest.map(String); - } else { - // If fieldContent is a string, use parseField as before - [fieldName, fieldType, options] = this.parseField(fieldContent); - } - - const format = this.parseField(Array.isArray(fieldContent) ? fieldContent[0] : fieldContent)[3] || - (fieldType === 'date' ? 'YYYY-MM-DD' : - fieldType === 'time' ? 'HH:mm' : - fieldType === 'datetime' ? 'YYYY-MM-DDTHH:mm' : ''); - - if (match[1]) { // Preserve leading whitespace - paragraphs[paragraphs.length - 1].components.push({ - type: 'text', - content: match[1], - name: "", - fieldType: "text", - format: format - }); - } - paragraphs[paragraphs.length - 1].components.push({ - type: 'input', - content: fieldName, - name: fieldName, - fieldType: fieldType, - options: options, - format: format - }); - } else if (match[3]) { // Text component - const textParts = match[3].split('\n'); - textParts.forEach((part, index) => { - if (index > 0) { - paragraphs.push({ components: [] }); - } - paragraphs[paragraphs.length - 1].components.push({ - type: 'text', - content: part, - name: "", - fieldType: "text", - format: "" - }); - }); - } - } - - return paragraphs; - } - - parseField(field: string): [string, string, string[] | undefined, string | undefined] { - // Remove double equals signs - const content = field.trim(); - - // Find the index of the first opening parenthesis - const openParenIndex = content.indexOf('('); - - let name: string; - let type: string = 'text'; // Default type - let optionsString: string = ''; - let format: string | undefined; - - if (openParenIndex === -1) { - // No parentheses found, everything is the name - name = content; - } else { - // Find the matching closing parenthesis - const closeParenIndex = content.indexOf(')', openParenIndex); - if (closeParenIndex === -1) { - // Mismatched parentheses, treat everything as name - name = content; - } else { - name = content.slice(0, openParenIndex).trim(); - type = content.slice(openParenIndex + 1, closeParenIndex).trim(); - const remainingContent = content.slice(closeParenIndex + 1).trim(); - - // Check if there's content after the type declaration - if (remainingContent) { - if (type === 'date' || type === 'time' || type === 'datetime') { - format = remainingContent; - } else { - optionsString = remainingContent; - } - } - } - } - - // Parse options if present - let options: string[] | undefined; - if (optionsString.startsWith('[') && optionsString.endsWith(']')) { - try { - // Attempt to parse as JSON, handling potential escaping issues - options = JSON.parse(optionsString.replace(/\\n/g, '\n').replace(/\\"/g, '"')); - } catch (e) { - // If parsing fails, treat it as a comma-separated list - options = optionsString.slice(1, -1).split(',').map(item => - item.trim().replace(/^["']|["']$/g, '') // Remove surrounding quotes if present - ); - } - - // Ensure options is an array of strings - if (Array.isArray(options)) { - options = options.flat().map(String); - } else { - options = [String(options)]; - } - } else { - options = optionsString ? optionsString.split(',').map(o => o.trim()).filter(o => o !== '') : undefined; - } - - return [name, type, options, format]; - } - - parseNameAndType(content: string): [string, string] { - // Remove any leading or trailing whitespace - content = content.trim(); - - // Find the index of the last opening parenthesis - const openParenIndex = content.lastIndexOf('('); - - if (openParenIndex === -1) { - // If there's no parenthesis, assume it's all name and type is default - return [content, 'text']; - } - - // Find the matching closing parenthesis - const closeParenIndex = content.indexOf(')', openParenIndex); - - if (closeParenIndex === -1) { - // If there's no closing parenthesis, assume it's all name - return [content, 'text']; - } - - // Extract the name (everything before the last opening parenthesis) - const name = content.slice(0, openParenIndex).trim(); - - // Extract the type (everything between the parentheses) - const type = content.slice(openParenIndex + 1, closeParenIndex).trim(); - - return [name, type || 'text']; - } - - onOpen() { - const { contentEl } = this; - contentEl.empty(); - - contentEl.createEl("h1", { text: this.title }); - const form = contentEl.createEl("form"); - form.onsubmit = (e) => { - e.preventDefault(); - this.handleSubmit(); - }; - - this.paragraphs.forEach(paragraph => { - const p = form.createEl("p"); - paragraph.components.forEach(component => { - if (component.type === 'text') { - p.appendText(component.content); - } else { - this.createInputComponent(p, component); - } - }); - }); - - form.createEl("button", { text: "Submit", type: "submit" }); - this.addStyle(); - this.modalEl.addClass('cannoli-modal'); - } - - addStyle() { - const style = document.createElement('style'); - style.textContent = ` - .cannoli-modal .modal-content p { - white-space: pre-wrap; - } - .cannoli-modal .obsidian-style-time-input { - background-color: var(--background-modifier-form-field); - border: 1px solid var(--background-modifier-border); - color: var(--text-normal); - padding: var(--size-4-1) var(--size-4-2); - border-radius: var(--radius-s); - font-size: var(--font-ui-small); - line-height: var(--line-height-tight); - width: auto; - font-family: var(--font-interface); - position: relative; - } - .cannoli-modal .obsidian-style-time-input:focus { - outline: none; - box-shadow: 0 0 0 2px var(--background-modifier-border-focus); - } - .cannoli-modal .obsidian-style-time-input:hover { - background-color: var(--background-modifier-form-field-hover); - } - `; - document.head.appendChild(style); - } - - createInputComponent(paragraph: HTMLParagraphElement, component: ModalComponent) { - let settingComponent; - - const updateValue = (value: string) => { - if (component.fieldType === 'date') { - const formattedValue = moment(value, 'YYYY-MM-DD').format(component.format); - this.values[component.name] = formattedValue; - } else if (component.fieldType === 'time') { - const formattedValue = moment(value, 'HH:mm').format(component.format || 'HH:mm'); - this.values[component.name] = formattedValue; - } else if (component.fieldType === 'datetime') { - const formattedValue = moment(value, 'YYYY-MM-DDTHH:mm').format(component.format || 'YYYY-MM-DDTHH:mm'); - this.values[component.name] = formattedValue; - } else { - this.values[component.name] = value; - } - }; - - switch (component.fieldType) { - case 'textarea': - settingComponent = new TextAreaComponent(paragraph) - .setPlaceholder(component.content) - .onChange(updateValue); - updateValue(''); - break; - case 'toggle': { - const toggleContainer = paragraph.createSpan({ cls: 'toggle-container' }); - settingComponent = new ToggleComponent(toggleContainer) - .setValue(false) - .onChange(value => updateValue(value ? "true" : "false")); - updateValue("false"); - break; - } - case 'dropdown': - if (component.options && component.options.length > 0) { - settingComponent = new DropdownComponent(paragraph) - .addOptions(Object.fromEntries(component.options.map(opt => [opt, opt]))) - .onChange(updateValue); - updateValue(component.options[0]); - } - break; - case 'date': { - settingComponent = new TextComponent(paragraph) - .setPlaceholder(component.content) - .onChange((value) => { - updateValue(value); - }); - settingComponent.inputEl.type = 'date'; - const defaultDate = moment().format(component.format); - settingComponent.inputEl.value = moment(defaultDate, component.format).format('YYYY-MM-DD'); - updateValue(settingComponent.inputEl.value); - break; - } - case 'time': { - settingComponent = new TextComponent(paragraph) - .setPlaceholder(component.content) - .onChange((value) => { - updateValue(value); - }); - settingComponent.inputEl.type = 'time'; - const defaultTime = moment().format('HH:mm'); - settingComponent.inputEl.value = defaultTime; - updateValue(settingComponent.inputEl.value); - - // Add custom styling to the time input - settingComponent.inputEl.addClass('obsidian-style-time-input'); - break; - } - case 'datetime': { - settingComponent = new TextComponent(paragraph) - .setPlaceholder(component.content) - .onChange((value) => { - updateValue(value); - }); - settingComponent.inputEl.type = 'datetime-local'; - const defaultDateTime = moment().format(component.format); - settingComponent.inputEl.value = defaultDateTime; - updateValue(settingComponent.inputEl.value); - break; - } - case 'text': - case '': - case undefined: - default: - settingComponent = new TextComponent(paragraph) - .setPlaceholder(component.content) - .onChange(updateValue); - updateValue(''); - break; - - } - - if (settingComponent) { - if (settingComponent instanceof ToggleComponent) { - settingComponent.toggleEl.setAttribute('data-name', component.name); - settingComponent.toggleEl.setAttribute('aria-label', component.content); - } else if ('inputEl' in settingComponent) { - settingComponent.inputEl.name = component.name; - settingComponent.inputEl.setAttribute('aria-label', component.content); - } - } - - // Add custom CSS to align the toggle - const style = document.createElement('style'); - style.textContent = ` - .toggle-container { - display: inline-flex; - align-items: center; - vertical-align: middle; - margin-left: 4px; - } - .toggle-container .checkbox-container { - margin: 0; - } - `; - document.head.appendChild(style); - } - - onClose() { - if (!this.isSubmitted) { - this.callback(new Error("Modal closed without submission")); - } - } - - handleSubmit() { - const result = { ...this.values }; - - this.paragraphs.forEach(paragraph => { - paragraph.components.forEach(component => { - if (component.type === 'input') { - if (!(component.name in result) || - ((component.fieldType === 'text' || component.fieldType === 'textarea') && result[component.name].trim() === '')) { - result[component.name] = "No input"; - } - } - }); - }); - - this.isSubmitted = true; - this.close(); - this.callback(result); - } -} From 52208cbe2a1abc440c5bb50cb615ed2bf2106d3f Mon Sep 17 00:00:00 2001 From: blindmansion Date: Sun, 18 Aug 2024 02:15:08 -0400 Subject: [PATCH 2/2] Break up and organize graph object files. Fix floating node group issue. --- packages/cannoli-core/src/bake.ts | 2 +- packages/cannoli-core/src/factory.ts | 8 +- packages/cannoli-core/src/fileManager.ts | 2 +- .../cannoli-core/src/{models => }/graph.ts | 47 +- .../cannoli-core/src/graph/CannoliObject.ts | 348 ++ .../src/graph/objects/CannoliEdge.ts | 173 + .../src/graph/objects/CannoliVertex.ts | 97 + .../src/graph/objects/FloatingNode.ts | 111 + .../graph/objects/edges/ChatConverterEdge.ts | 150 + .../graph/objects/edges/ChatResponseEdge.ts | 49 + .../src/graph/objects/edges/LoggingEdge.ts | 164 + .../graph/objects/edges/SystemMessageEdge.ts | 21 + .../graph/objects/vertices/CannoliGroup.ts | 382 ++ .../src/graph/objects/vertices/CannoliNode.ts | 1201 ++++++ .../objects/vertices/groups/RepeatGroup.ts | 122 + .../graph/objects/vertices/nodes/CallNode.ts | 263 ++ .../objects/vertices/nodes/ContentNode.ts | 283 ++ .../objects/vertices/nodes/call/ChooseNode.ts | 95 + .../objects/vertices/nodes/call/FormNode.ts | 124 + .../vertices/nodes/content/FormatterNode.ts | 21 + .../vertices/nodes/content/HttpNode.ts | 588 +++ .../vertices/nodes/content/ReferenceNode.ts | 606 +++ .../vertices/nodes/content/SearchNode.ts | 62 + packages/cannoli-core/src/index.ts | 4 +- packages/cannoli-core/src/models/edge.ts | 552 --- packages/cannoli-core/src/models/group.ts | 505 --- packages/cannoli-core/src/models/node.ts | 3340 ----------------- packages/cannoli-core/src/models/object.ts | 442 --- packages/cannoli-core/src/persistor.ts | 2 +- packages/cannoli-core/src/run.ts | 9 +- packages/cannoli-plugin/src/main.ts | 4 +- 31 files changed, 4900 insertions(+), 4877 deletions(-) rename packages/cannoli-core/src/{models => }/graph.ts (87%) create mode 100644 packages/cannoli-core/src/graph/CannoliObject.ts create mode 100644 packages/cannoli-core/src/graph/objects/CannoliEdge.ts create mode 100644 packages/cannoli-core/src/graph/objects/CannoliVertex.ts create mode 100644 packages/cannoli-core/src/graph/objects/FloatingNode.ts create mode 100644 packages/cannoli-core/src/graph/objects/edges/ChatConverterEdge.ts create mode 100644 packages/cannoli-core/src/graph/objects/edges/ChatResponseEdge.ts create mode 100644 packages/cannoli-core/src/graph/objects/edges/LoggingEdge.ts create mode 100644 packages/cannoli-core/src/graph/objects/edges/SystemMessageEdge.ts create mode 100644 packages/cannoli-core/src/graph/objects/vertices/CannoliGroup.ts create mode 100644 packages/cannoli-core/src/graph/objects/vertices/CannoliNode.ts create mode 100644 packages/cannoli-core/src/graph/objects/vertices/groups/RepeatGroup.ts create mode 100644 packages/cannoli-core/src/graph/objects/vertices/nodes/CallNode.ts create mode 100644 packages/cannoli-core/src/graph/objects/vertices/nodes/ContentNode.ts create mode 100644 packages/cannoli-core/src/graph/objects/vertices/nodes/call/ChooseNode.ts create mode 100644 packages/cannoli-core/src/graph/objects/vertices/nodes/call/FormNode.ts create mode 100644 packages/cannoli-core/src/graph/objects/vertices/nodes/content/FormatterNode.ts create mode 100644 packages/cannoli-core/src/graph/objects/vertices/nodes/content/HttpNode.ts create mode 100644 packages/cannoli-core/src/graph/objects/vertices/nodes/content/ReferenceNode.ts create mode 100644 packages/cannoli-core/src/graph/objects/vertices/nodes/content/SearchNode.ts delete mode 100644 packages/cannoli-core/src/models/edge.ts delete mode 100644 packages/cannoli-core/src/models/group.ts delete mode 100644 packages/cannoli-core/src/models/node.ts delete mode 100644 packages/cannoli-core/src/models/object.ts diff --git a/packages/cannoli-core/src/bake.ts b/packages/cannoli-core/src/bake.ts index 14d8614..c8afdcd 100644 --- a/packages/cannoli-core/src/bake.ts +++ b/packages/cannoli-core/src/bake.ts @@ -1,6 +1,6 @@ import { run } from "./cannoli"; import { FileManager } from "./fileManager"; -import { VerifiedCannoliCanvasData } from "./models/graph"; +import { VerifiedCannoliCanvasData } from "./graph"; import { LLMConfig } from "./providers"; import { Action, HttpTemplate } from "./run"; diff --git a/packages/cannoli-core/src/factory.ts b/packages/cannoli-core/src/factory.ts index 442bf84..f7a01e6 100644 --- a/packages/cannoli-core/src/factory.ts +++ b/packages/cannoli-core/src/factory.ts @@ -25,7 +25,7 @@ import { VerifiedCannoliCanvasGroupData, AllVerifiedCannoliCanvasNodeData, VerifiedCannoliCanvasEdgeData, -} from "./models/graph"; +} from "./graph"; export enum IndicatedNodeType { Call = "call", @@ -293,7 +293,7 @@ export class CannoliFactory { node.type === "text" || node.type === "file" ? this.parseNodeReferences(node) : []; - const groups = this.getGroupsForVertex(node); + let groups = this.getGroupsForVertex(node); const dependencies = [] as string[]; const originalObject = null; const status = @@ -305,6 +305,10 @@ export class CannoliFactory { return null; } + if (type === FloatingNodeType.Variable) { + groups = []; + } + return { kind, type, diff --git a/packages/cannoli-core/src/fileManager.ts b/packages/cannoli-core/src/fileManager.ts index 46c5d26..fddd2ce 100644 --- a/packages/cannoli-core/src/fileManager.ts +++ b/packages/cannoli-core/src/fileManager.ts @@ -1,4 +1,4 @@ -import { Reference } from "./models/graph"; +import { Reference } from "./graph"; export interface FileManager { editNote( diff --git a/packages/cannoli-core/src/models/graph.ts b/packages/cannoli-core/src/graph.ts similarity index 87% rename from packages/cannoli-core/src/models/graph.ts rename to packages/cannoli-core/src/graph.ts index c0e6aa6..076a856 100644 --- a/packages/cannoli-core/src/models/graph.ts +++ b/packages/cannoli-core/src/graph.ts @@ -5,31 +5,28 @@ import { CanvasGroupData, CanvasLinkData, CanvasTextData, -} from "../persistor"; - -import { CannoliObject } from "./object"; -import { CannoliGroup, RepeatGroup } from "./group"; -import { - CallNode, - CannoliNode, - ChooseNode, - ContentNode, - FormNode, - FloatingNode, - FormatterNode, - HttpNode, - ReferenceNode, - SearchNode, -} from "./node"; -import { - CannoliEdge, - ChatConverterEdge, - ChatResponseEdge, - LoggingEdge, - SystemMessageEdge, -} from "./edge"; -import { GenericCompletionResponse } from "../providers"; -import { ReceiveInfo } from "../run"; +} from "./persistor"; + +import { GenericCompletionResponse } from "./providers"; +import { ReceiveInfo } from "./run"; +import { CannoliObject } from "./graph/CannoliObject"; +import { CannoliEdge } from "./graph/objects/CannoliEdge"; +import { ChatConverterEdge } from "./graph/objects/edges/ChatConverterEdge"; +import { ChatResponseEdge } from "./graph/objects/edges/ChatResponseEdge"; +import { LoggingEdge } from "./graph/objects/edges/LoggingEdge"; +import { SystemMessageEdge } from "./graph/objects/edges/SystemMessageEdge"; +import { CannoliGroup } from "./graph/objects/vertices/CannoliGroup"; +import { CannoliNode } from "./graph/objects/vertices/CannoliNode"; +import { RepeatGroup } from "./graph/objects/vertices/groups/RepeatGroup"; +import { ChooseNode } from "./graph/objects/vertices/nodes/call/ChooseNode"; +import { FormNode } from "./graph/objects/vertices/nodes/call/FormNode"; +import { CallNode } from "./graph/objects/vertices/nodes/CallNode"; +import { FormatterNode } from "./graph/objects/vertices/nodes/content/FormatterNode"; +import { HttpNode } from "./graph/objects/vertices/nodes/content/HttpNode"; +import { ReferenceNode } from "./graph/objects/vertices/nodes/content/ReferenceNode"; +import { SearchNode } from "./graph/objects/vertices/nodes/content/SearchNode"; +import { ContentNode } from "./graph/objects/vertices/nodes/ContentNode"; +import { FloatingNode } from "./graph/objects/FloatingNode"; export enum CannoliObjectKind { Node = "node", diff --git a/packages/cannoli-core/src/graph/CannoliObject.ts b/packages/cannoli-core/src/graph/CannoliObject.ts new file mode 100644 index 0000000..4ceeef3 --- /dev/null +++ b/packages/cannoli-core/src/graph/CannoliObject.ts @@ -0,0 +1,348 @@ +import type { Run } from "../run"; +import { + AllVerifiedCannoliCanvasNodeData, + CallNodeType, + CannoliGraph, + CannoliObjectKind, + CannoliObjectStatus, + EdgeType, + GroupType, + NodeType, + VerifiedCannoliCanvasData, + VerifiedCannoliCanvasEdgeData, + VerifiedCannoliCanvasGroupData, +} from "../graph"; + +export class CannoliObject extends EventTarget { + run: Run; + id: string; + text: string; + status: CannoliObjectStatus; + dependencies: string[]; + graph: Record; + cannoliGraph: CannoliGraph; + canvasData: VerifiedCannoliCanvasData; + originalObject: string | null; + kind: CannoliObjectKind; + type: EdgeType | NodeType | GroupType; + + constructor( + data: AllVerifiedCannoliCanvasNodeData | VerifiedCannoliCanvasEdgeData, + canvasData: VerifiedCannoliCanvasData + ) { + super(); + this.id = data.id; + this.text = data.cannoliData.text; + this.status = data.cannoliData.status; + this.dependencies = data.cannoliData.dependencies; + this.originalObject = data.cannoliData.originalObject; + this.kind = data.cannoliData.kind; + this.type = data.cannoliData.type; + this.canvasData = canvasData; + } + + setRun(run: Run) { + this.run = run; + } + + setGraph(graph: Record, cannoliGraph: CannoliGraph) { + this.graph = graph; + this.cannoliGraph = cannoliGraph; + } + + setupListeners() { + // For each dependency + for (const dependency of this.dependencies) { + // Set up a listener for the dependency's completion event + this.graph[dependency].addEventListener( + "update", + (event: CustomEvent) => { + // Assuming that 'obj' and 'status' are properties in the detail of the CustomEvent + this.dependencyUpdated( + event.detail.obj, + event.detail.status + ); + } + ); + } + } + + setStatus(status: CannoliObjectStatus) { + this.status = status; + if (this.kind === CannoliObjectKind.Node || this.kind === CannoliObjectKind.Group) { + const data = this.canvasData.nodes.find((node) => node.id === this.id) as AllVerifiedCannoliCanvasNodeData; + data.cannoliData.status = status; + + if (this.type === CallNodeType.StandardCall || this.type === CallNodeType.Choose || this.type === CallNodeType.Form) { + if (status === CannoliObjectStatus.Pending) { + if (this.run.config && this.run.config.contentIsColorless) { + data.color = "6"; + } else { + data.color = undefined; + } + } else if (status === CannoliObjectStatus.Executing) { + data.color = "3"; + } else if (status === CannoliObjectStatus.Complete) { + data.color = "4"; + } + } + } else if (this.kind === CannoliObjectKind.Edge) { + const data = this.canvasData.edges.find((edge) => edge.id === this.id) as VerifiedCannoliCanvasEdgeData; + data.cannoliData.status = status; + } + } + + setText(text: string) { + this.text = text; + if (this.kind === CannoliObjectKind.Node) { + const data = this.canvasData.nodes.find((node) => node.id === this.id) as AllVerifiedCannoliCanvasNodeData; + data.cannoliData.text = text; + data.text = text; + } else if (this.kind === CannoliObjectKind.Group) { + const data = this.canvasData.nodes.find((group) => group.id === this.id) as VerifiedCannoliCanvasGroupData; + data.cannoliData.text = text; + data.label = text; + } + } + + getAllDependencies(): CannoliObject[] { + const dependencies: CannoliObject[] = []; + for (const dependency of this.dependencies) { + dependencies.push(this.graph[dependency]); + } + + return dependencies; + } + + dependencyUpdated(dependency: CannoliObject, status: CannoliObjectStatus) { + switch (status) { + case CannoliObjectStatus.Complete: + this.dependencyCompleted(dependency); + break; + case CannoliObjectStatus.Rejected: + this.dependencyRejected(dependency); + break; + case CannoliObjectStatus.Executing: + this.dependencyExecuting(dependency); + break; + default: + break; + } + } + + allDependenciesComplete(): boolean { + // Get the dependencies as objects + const dependencies = this.getAllDependencies(); + + // For each dependency + for (const dependency of dependencies) { + // New logic for edges with versions property + if (this.cannoliGraph.isEdge(dependency) && dependency.versions !== undefined) { + for (const otherDependency of dependencies) { + if ( + this.cannoliGraph.isEdge(otherDependency) && + otherDependency.text === dependency.text && + (otherDependency.status !== CannoliObjectStatus.Complete && + otherDependency.status !== CannoliObjectStatus.Rejected) + ) { + // If any other edge with the same name is not complete or rejected, return false + return false; + } + } + } + + if (dependency.status !== CannoliObjectStatus.Complete) { + // If the dependency is a non-logging edge + if ( + this.cannoliGraph.isEdge(dependency) && + dependency.type !== EdgeType.Logging + ) { + let redundantComplete = false; + + // Check if there are any other edge dependencies that share the same name and type which are complete + for (const otherDependency of dependencies) { + if ( + this.cannoliGraph.isEdge(otherDependency) && + otherDependency.text === dependency.text && + otherDependency.type === dependency.type && + otherDependency.status === + CannoliObjectStatus.Complete + ) { + // If there are, set redundantComplete to true + redundantComplete = true; + break; + } + } + + // If redundantComplete is false, return false + if (!redundantComplete) { + return false; + } + } else { + // If the dependency is not an edge, return false + return false; + } + } + + + } + return true; + } + + allEdgeDependenciesComplete(): boolean { + // Get the dependencies as objects + const dependencies = this.getAllDependencies(); + + // For each dependency + for (const dependency of dependencies) { + // If the dependency it's not an edge, continue + if (!this.cannoliGraph.isEdge(dependency)) { + continue; + } + + if (dependency.status !== CannoliObjectStatus.Complete) { + // If the dependency is a non-logging edge + if ( + this.cannoliGraph.isEdge(dependency) && + dependency.type !== EdgeType.Logging + ) { + let redundantComplete = false; + + // Check if there are any other edge dependencies that share the same name which are complete + for (const otherDependency of dependencies) { + if ( + this.cannoliGraph.isEdge(otherDependency) && + otherDependency.text === dependency.text && + otherDependency.status === + CannoliObjectStatus.Complete + ) { + // If there are, set redundantComplete to true + redundantComplete = true; + break; + } + } + + // If redundantComplete is false, return false + if (!redundantComplete) { + return false; + } + } else { + // If the dependency is not an edge, return false + return false; + } + } + } + return true; + } + + executing() { + this.setStatus(CannoliObjectStatus.Executing); + const event = new CustomEvent("update", { + detail: { obj: this, status: CannoliObjectStatus.Executing }, + }); + this.dispatchEvent(event); + } + + completed() { + this.setStatus(CannoliObjectStatus.Complete); + const event = new CustomEvent("update", { + detail: { obj: this, status: CannoliObjectStatus.Complete }, + }); + this.dispatchEvent(event); + } + + pending() { + this.setStatus(CannoliObjectStatus.Pending); + const event = new CustomEvent("update", { + detail: { obj: this, status: CannoliObjectStatus.Pending }, + }); + this.dispatchEvent(event); + } + + reject() { + this.setStatus(CannoliObjectStatus.Rejected); + const event = new CustomEvent("update", { + detail: { obj: this, status: CannoliObjectStatus.Rejected }, + }); + this.dispatchEvent(event); + } + + tryReject() { + // Check all dependencies + const shouldReject = this.getAllDependencies().every((dependency) => { + if (dependency.status === CannoliObjectStatus.Rejected) { + // If the dependency is an edge + if (this.cannoliGraph.isEdge(dependency)) { + let redundantNotRejected = false; + + // Check if there are any other edge dependencies that share the same name and are not rejected + for (const otherDependency of this.getAllDependencies()) { + if ( + this.cannoliGraph.isEdge(otherDependency) && + otherDependency.text === dependency.text && + otherDependency.status !== + CannoliObjectStatus.Rejected + ) { + // If there are, set redundantNotRejected to true and break the loop + redundantNotRejected = true; + break; + } + } + + // If redundantNotRejected is true, return true to continue the evaluation + if (redundantNotRejected) { + return true; + } + } + + // If the dependency is not an edge or no redundancy was found, return false to reject + return false; + } + + // If the current dependency is not rejected, continue the evaluation + return true; + }); + + // If the object should be rejected, call the reject method + if (!shouldReject) { + this.reject(); + } + } + + ensureStringLength(str: string, maxLength: number): string { + if (str.length > maxLength) { + return str.substring(0, maxLength - 3) + "..."; + } else { + return str; + } + } + + reset() { + this.setStatus(CannoliObjectStatus.Pending); + const event = new CustomEvent("update", { + detail: { obj: this, status: CannoliObjectStatus.Pending }, + }); + this.dispatchEvent(event); + } + + dependencyRejected(dependency: CannoliObject) { + this.tryReject(); + } + + dependencyCompleted(dependency: CannoliObject) { } + + dependencyExecuting(dependency: CannoliObject) { } + + async execute() { } + + logDetails(): string { + let dependenciesString = ""; + for (const dependency of this.dependencies) { + dependenciesString += `\t"${this.graph[dependency].text}"\n`; + } + + return `Dependencies:\n${dependenciesString}\n`; + } + + validate() { } +} \ No newline at end of file diff --git a/packages/cannoli-core/src/graph/objects/CannoliEdge.ts b/packages/cannoli-core/src/graph/objects/CannoliEdge.ts new file mode 100644 index 0000000..58eb398 --- /dev/null +++ b/packages/cannoli-core/src/graph/objects/CannoliEdge.ts @@ -0,0 +1,173 @@ +import { GenericCompletionResponse, GenericCompletionParams } from "src/providers"; +import { CannoliObject } from "../CannoliObject"; +import { EdgeModifier, VerifiedCannoliCanvasEdgeData, VerifiedCannoliCanvasData, EdgeType, CannoliObjectStatus } from "../../graph"; +import { CannoliVertex } from "./CannoliVertex"; +import { CannoliGroup } from "./vertices/CannoliGroup"; + +export class CannoliEdge extends CannoliObject { + source: string; + target: string; + crossingInGroups: string[]; + crossingOutGroups: string[]; + isReflexive: boolean; + addMessages: boolean; + edgeModifier: EdgeModifier | null; + content: string | Record | null; + messages: GenericCompletionResponse[] | null; + versions: { + header: string | null, + subHeader: string | null, + }[] | null; + + constructor(edgeData: VerifiedCannoliCanvasEdgeData, fullCanvasData: VerifiedCannoliCanvasData) { + super(edgeData, fullCanvasData); + this.source = edgeData.fromNode; + this.target = edgeData.toNode; + this.crossingInGroups = edgeData.cannoliData.crossingInGroups; + this.crossingOutGroups = edgeData.cannoliData.crossingOutGroups; + this.isReflexive = edgeData.cannoliData.isReflexive; + this.addMessages = edgeData.cannoliData.addMessages; + this.edgeModifier = edgeData.cannoliData.edgeModifier + ? edgeData.cannoliData.edgeModifier + : null; + this.content = edgeData.cannoliData.content + ? edgeData.cannoliData.content + : null; + this.messages = edgeData.cannoliData.messages + ? edgeData.cannoliData.messages + : null; + this.versions = edgeData.cannoliData.versions + ? edgeData.cannoliData.versions + : null; + + // Overrwite the addMessages for certain types of edges + if ( + this.type === EdgeType.Chat || + this.type === EdgeType.SystemMessage || + this.type === EdgeType.ChatResponse || + this.type === EdgeType.ChatConverter + ) { + this.addMessages = true; + } + } + + getSource(): CannoliVertex { + return this.graph[this.source] as CannoliVertex; + } + + getTarget(): CannoliVertex { + return this.graph[this.target] as CannoliVertex; + } + + setContent(content: string | Record | undefined) { + this.content = content ?? ""; + const data = this.canvasData.edges.find((edge) => edge.id === this.id) as VerifiedCannoliCanvasEdgeData; + data.cannoliData.content = content ?? ""; + } + + setMessages(messages: GenericCompletionResponse[] | undefined) { + this.messages = messages ?? null; + const data = this.canvasData.edges.find((edge) => edge.id === this.id) as VerifiedCannoliCanvasEdgeData; + data.cannoliData.messages = messages; + } + + setVersionHeaders(index: number, header: string, subheader: string) { + if (this.versions) { + this.versions[index].header = header; + this.versions[index].subHeader = subheader; + + const data = this.canvasData.edges.find((edge) => edge.id === this.id) as VerifiedCannoliCanvasEdgeData; + data.cannoliData.versions = this.versions; + } + } + + load({ + content, + request, + }: { + content?: string | Record; + request?: GenericCompletionParams; + }): void { + // If there is a versions array + if (this.versions) { + let versionCount = 0; + for (const group of this.crossingOutGroups) { + const groupObject = this.graph[group] as CannoliGroup; + // Get the incoming item edge, if there is one + const itemEdge = groupObject.incomingEdges.find((edge) => this.graph[edge].type === EdgeType.Item); + if (itemEdge) { + // Get the item edge object + const itemEdgeObject = this.graph[itemEdge] as CannoliEdge; + + // Set the version header to the name of the list edge + this.setVersionHeaders(versionCount, itemEdgeObject.text, itemEdgeObject.content as string); + + versionCount++; + } + } + + } + + this.setContent(content); + + if (this.addMessages) { + this.setMessages( + request && request.messages ? request.messages : undefined); + } + } + + async execute(): Promise { + this.completed(); + } + + dependencyCompleted(dependency: CannoliObject): void { + if ( + this.allDependenciesComplete() && + this.status === CannoliObjectStatus.Pending + ) { + // console.log( + // `Executing edge with loaded content: ${ + // this.content + // } and messages:\n${JSON.stringify(this.messages, null, 2)}` + // ); + this.execute(); + } + } + + logDetails(): string { + // Build crossing groups string of the text of the crossing groups + let crossingGroupsString = ""; + crossingGroupsString += `Crossing Out Groups: `; + for (const group of this.crossingOutGroups) { + crossingGroupsString += `\n\t-"${this.ensureStringLength( + this.graph[group].text, + 15 + )}"`; + } + crossingGroupsString += `\nCrossing In Groups: `; + for (const group of this.crossingInGroups) { + crossingGroupsString += `\n\t-"${this.ensureStringLength( + this.graph[group].text, + 15 + )}"`; + } + + return ( + `--> Edge ${this.id} Text: "${this.text ?? "undefined string" + }"\n"${this.ensureStringLength( + this.getSource().text ?? "undefined string", + 15 + )}--->"${this.ensureStringLength( + this.getTarget().text ?? "undefined string", + 15 + )}"\n${crossingGroupsString}\nisReflexive: ${this.isReflexive + }\nType: ${this.type}\n` + super.logDetails() + ); + } + + reset() { + if (!this.isReflexive) { + super.reset(); + } + } +} \ No newline at end of file diff --git a/packages/cannoli-core/src/graph/objects/CannoliVertex.ts b/packages/cannoli-core/src/graph/objects/CannoliVertex.ts new file mode 100644 index 0000000..b94eeb3 --- /dev/null +++ b/packages/cannoli-core/src/graph/objects/CannoliVertex.ts @@ -0,0 +1,97 @@ +import { getIncomingEdgesFromData, getOutgoingEdgesFromData } from "src/factory"; +import { CannoliObject } from "../CannoliObject"; +import { AllVerifiedCannoliCanvasNodeData, VerifiedCannoliCanvasData, CannoliObjectStatus } from "../../graph"; +import { CannoliEdge } from "./CannoliEdge"; +import { CannoliGroup } from "./vertices/CannoliGroup"; + +export class CannoliVertex extends CannoliObject { + outgoingEdges: string[]; + incomingEdges: string[]; + groups: string[]; // Sorted from immediate parent to most distant + + constructor(vertexData: AllVerifiedCannoliCanvasNodeData, fullCanvasData: VerifiedCannoliCanvasData) { + super(vertexData, fullCanvasData); + this.outgoingEdges = getOutgoingEdgesFromData(this.id, this.canvasData); + this.incomingEdges = getIncomingEdgesFromData(this.id, this.canvasData); + this.groups = vertexData.cannoliData.groups; + } + + getOutgoingEdges(): CannoliEdge[] { + return this.outgoingEdges.map( + (edge) => this.graph[edge] as CannoliEdge + ); + } + + getIncomingEdges(): CannoliEdge[] { + return this.incomingEdges.map( + (edge) => this.graph[edge] as CannoliEdge + ); + } + + getGroups(): CannoliGroup[] { + return this.groups.map((group) => this.graph[group] as CannoliGroup); + } + + createRectangle(x: number, y: number, width: number, height: number) { + return { + x, + y, + width, + height, + x_right: x + width, + y_bottom: y + height, + }; + } + + encloses( + a: ReturnType, + b: ReturnType + ): boolean { + return ( + a.x <= b.x && + a.y <= b.y && + a.x_right >= b.x_right && + a.y_bottom >= b.y_bottom + ); + } + + overlaps( + a: ReturnType, + b: ReturnType + ): boolean { + const horizontalOverlap = a.x < b.x_right && a.x_right > b.x; + const verticalOverlap = a.y < b.y_bottom && a.y_bottom > b.y; + const overlap = horizontalOverlap && verticalOverlap; + return overlap && !this.encloses(a, b) && !this.encloses(b, a); + } + + error(message: string) { + this.setStatus(CannoliObjectStatus.Error); + const event = new CustomEvent("update", { + detail: { + obj: this, + status: CannoliObjectStatus.Error, + message: message, + }, + }); + this.dispatchEvent(event); + console.error(message); + } + + warning(message: string) { + this.setStatus(CannoliObjectStatus.Warning); + const event = new CustomEvent("update", { + detail: { + obj: this, + status: CannoliObjectStatus.Warning, + message: message, + }, + }); + this.dispatchEvent(event); + console.error(message); // Consider changing this to console.warn(message); + } + + validate() { + super.validate(); + } +} diff --git a/packages/cannoli-core/src/graph/objects/FloatingNode.ts b/packages/cannoli-core/src/graph/objects/FloatingNode.ts new file mode 100644 index 0000000..8280a85 --- /dev/null +++ b/packages/cannoli-core/src/graph/objects/FloatingNode.ts @@ -0,0 +1,111 @@ +import { CannoliObject } from "src/graph/CannoliObject"; +import { VerifiedCannoliCanvasTextData, VerifiedCannoliCanvasLinkData, VerifiedCannoliCanvasFileData, VerifiedCannoliCanvasData, CannoliObjectStatus } from "src/graph"; +import * as yaml from "js-yaml"; + +export class FloatingNode extends CannoliObject { + constructor( + nodeData: + | VerifiedCannoliCanvasTextData + | VerifiedCannoliCanvasLinkData + | VerifiedCannoliCanvasFileData, + fullCanvasData: VerifiedCannoliCanvasData + ) { + super(nodeData, fullCanvasData); + this.setStatus(CannoliObjectStatus.Complete); + } + + dependencyCompleted(dependency: CannoliObject): void { + return; + } + + dependencyRejected(dependency: CannoliObject): void { + return; + } + + async execute() { + this.completed(); + } + + getName(): string { + const firstLine = this.text.split("\n")[0].trim(); + // Take the first and last characters off the first line + return firstLine.substring(1, firstLine.length - 1); + } + + // Content is everything after the first line + getContent(): string { + const firstLine = this.text.split("\n")[0]; + return this.text.substring(firstLine.length + 1); + } + + editContent(newContent: string): void { + const firstLine = this.text.split("\n")[0]; + this.setText(`${firstLine}\n${newContent}`); + + const event = new CustomEvent("update", { + detail: { obj: this, status: this.status }, + }); + this.dispatchEvent(event); + } + + editProperty(propertyName: string, newContent: string): void { + // Find the frontmatter from the content + const frontmatter = this.getContent().split("---")[1]; + + if (!frontmatter) { + return; + } + + const parsedFrontmatter: Record = yaml.load( + frontmatter + ) as Record; + + // If the parsed frontmatter is null, return + if (!parsedFrontmatter) { + return; + } + + // Set the property to the new content + parsedFrontmatter[propertyName] = newContent; + + // Stringify the frontmatter and add it back to the content + const newFrontmatter = yaml.dump(parsedFrontmatter); + + const newProps = `---\n${newFrontmatter}---\n${this.getContent().split("---")[2] + }`; + + this.editContent(newProps); + } + + getProperty(propertyName: string): string { + // If property name is empty, return the entire frontmatter + if (propertyName.length === 0) { + return this.getContent().split("---")[1]; + } + + // Find the frontmatter from the content + const frontmatter = this.getContent().split("---")[1]; + + if (!frontmatter) { + return ""; + } + + const parsedFrontmatter: Record = yaml.load( + frontmatter + ) as Record; + + // If the parsed frontmatter is null, return + if (!parsedFrontmatter) { + return ""; + } + + return parsedFrontmatter[propertyName]; + } + + logDetails(): string { + return ( + super.logDetails() + + `Type: Floating\nName: ${this.getName()}\nContent: ${this.getContent()}\n` + ); + } +} diff --git a/packages/cannoli-core/src/graph/objects/edges/ChatConverterEdge.ts b/packages/cannoli-core/src/graph/objects/edges/ChatConverterEdge.ts new file mode 100644 index 0000000..989b1a4 --- /dev/null +++ b/packages/cannoli-core/src/graph/objects/edges/ChatConverterEdge.ts @@ -0,0 +1,150 @@ +import { GenericCompletionParams, GenericCompletionResponse } from "src/providers"; +import { ChatRole } from "src/run"; +import { CannoliEdge } from "../CannoliEdge"; + +export const chatFormatString = `--- +# {{role}} + +{{content}}` + +export class ChatConverterEdge extends CannoliEdge { + load({ + content, + request, + }: { + content?: string | Record; + request?: GenericCompletionParams; + }): void { + const format = this.run.config?.chatFormatString?.toString() ?? chatFormatString; + const messageString = ""; + let messages: GenericCompletionResponse[] = []; + + if (content && format) { + // Convert content to messages using the format + messages = this.stringToArray(content as string, format); + } else { + throw new Error( + "Chat converter edge was loaded without a content or messages" + ); + } + + this.setContent(messageString); + this.setMessages(messages); + } + + stringToArray(str: string, format: string): GenericCompletionResponse[] { + const rolePattern = format + .replace("{{role}}", "(System|User|Assistant)") + .replace("{{content}}", "") + .trim(); + const regex = new RegExp(rolePattern, "g"); + + let match; + let messages: GenericCompletionResponse[] = []; + let lastIndex = 0; + + let firstMatch = true; + + while ((match = regex.exec(str)) !== null) { + const [, role] = match; + + // If this is the first match and there's text before it, add that text as a 'user' message + if (firstMatch && match.index > 0) { + messages.push({ + role: "user" as const, + content: str.substring(0, match.index).trim(), + }); + } + firstMatch = false; + + const start = regex.lastIndex; + let end; + const nextMatch = regex.exec(str); + if (nextMatch) { + end = nextMatch.index; + } else { + end = str.length; + } + regex.lastIndex = start; + + const content = str.substring(start, end).trim(); + const uncapRole = role.charAt(0).toLowerCase() + role.slice(1); + + messages.push({ + role: uncapRole as ChatRole, + content, + }); + + lastIndex = end; + } + + if (messages.length === 0) { + messages.push({ + role: "user" as ChatRole, + content: str.trim(), + }); + return messages; + } + + if (lastIndex < str.length - 1) { + messages.push({ + role: "user" as ChatRole, + content: str.substring(lastIndex).trim(), + }); + } + + if (this.text.length > 0) { + messages = this.limitMessages(messages); + } + + return messages; + } + + limitMessages( + messages: GenericCompletionResponse[] + ): GenericCompletionResponse[] { + let isTokenBased = false; + let originalText = this.text; + + if (originalText.startsWith("#")) { + isTokenBased = true; + originalText = originalText.substring(1); + } + + const limitValue = Number(originalText); + + if (isNaN(limitValue) || limitValue < 0) { + return messages; + } + + let outputMessages: GenericCompletionResponse[]; + + if (isTokenBased) { + const maxCharacters = limitValue * 4; + let totalCharacters = 0; + let index = 0; + + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i]; + if (message.content) { + totalCharacters += message.content.length; + } + + if (totalCharacters > maxCharacters) { + index = i + 1; + break; + } + } + outputMessages = messages.slice(index); + } else { + outputMessages = messages.slice(-Math.max(limitValue, 1)); + } + + // Safeguard to always include at least one message + if (outputMessages.length === 0 && messages.length > 0) { + outputMessages = [messages[messages.length - 1]]; + } + + return outputMessages; + } +} \ No newline at end of file diff --git a/packages/cannoli-core/src/graph/objects/edges/ChatResponseEdge.ts b/packages/cannoli-core/src/graph/objects/edges/ChatResponseEdge.ts new file mode 100644 index 0000000..06ee17d --- /dev/null +++ b/packages/cannoli-core/src/graph/objects/edges/ChatResponseEdge.ts @@ -0,0 +1,49 @@ +import { GenericCompletionParams } from "src/providers"; +import { CannoliEdge } from "../CannoliEdge"; +import { chatFormatString } from "./ChatConverterEdge"; + +export class ChatResponseEdge extends CannoliEdge { + beginningOfStream = true; + + load({ + content, + request, + }: { + content?: string | Record; + request?: GenericCompletionParams; + }): void { + const format = this.run.config?.chatFormatString?.toString() ?? chatFormatString; + + if (!format) { + throw new Error( + "Chat response edge was loaded without a format string" + ); + } + + if (content && typeof content === "string") { + if (!this.beginningOfStream) { + // If the content is the string "END OF STREAM" + if (content === "END OF STREAM") { + // Create a user template for the next message + const userTemplate = format + .replace("{{role}}", "User") + .replace("{{content}}", ""); + + this.setContent("\n\n" + userTemplate); + } else { + this.setContent(content); + } + } else { + const assistantTemplate = format + .replace("{{role}}", "Assistant") + .replace("{{content}}", content); + + this.setContent("\n\n" + assistantTemplate); + + this.beginningOfStream = false; + } + + this.execute(); + } + } +} \ No newline at end of file diff --git a/packages/cannoli-core/src/graph/objects/edges/LoggingEdge.ts b/packages/cannoli-core/src/graph/objects/edges/LoggingEdge.ts new file mode 100644 index 0000000..969f47b --- /dev/null +++ b/packages/cannoli-core/src/graph/objects/edges/LoggingEdge.ts @@ -0,0 +1,164 @@ +import { CannoliObject } from "src/graph/CannoliObject"; +import { CannoliObjectStatus } from "src/graph"; +import { GenericCompletionParams, GenericCompletionResponse } from "src/providers"; +import { CannoliEdge } from "../CannoliEdge"; +import { CannoliGroup } from "../vertices/CannoliGroup"; +import { RepeatGroup } from "../vertices/groups/RepeatGroup"; + +export class LoggingEdge extends CannoliEdge { + load({ + content, + request, + }: { + content?: string | Record; + request?: GenericCompletionParams; + }): void { + // If content exists, save it as the configString + let configString = null; + + let messages: GenericCompletionResponse[] = []; + + if (request) { + configString = this.getConfigString(request); + messages = request.messages ? request.messages : []; + } else { + throw new Error( + "Logging edge was loaded without a request, this should never happen" + ); + } + + let logs = ""; + + // Get the current loop number of any repeat type groups that the edge is crossing out of + const repeatLoopNumbers = this.getLoopNumbers(); + + const loopHeader = this.formatLoopHeader(repeatLoopNumbers); + + // Get the version header + const forEachVersionNumbers = this.getForEachVersionNumbers(); + + const versionHeader = this.formatVersionHeader(forEachVersionNumbers); + + if (repeatLoopNumbers.length > 0) { + logs = `${loopHeader}\n`; + } + + if (forEachVersionNumbers.length > 0) { + logs = `${logs}${versionHeader}\n`; + } + + if (messages !== undefined) { + logs = `${logs}${this.formatInteractionHeaders(messages)}`; + } + + // If there is a configString, add it to the logs + if (configString !== null) { + logs = `${logs}\n#### Config\n${configString}\n`; + } + + // Append the logs to the content + if (this.content !== null) { + this.setContent(`${this.content}\n${logs}`); + } else { + this.setContent(logs); + } + } + + getConfigString(request: GenericCompletionParams) { + let configString = ""; + + // Extract imageReferences separately + const imageReferences = request.imageReferences; + + // Loop through all the properties of the request except for messages and imageReferences + for (const key in request) { + if (key !== "messages" && key !== "imageReferences" && request[key as keyof typeof request]) { + // If its apiKey, don't log the value + if (key === "apiKey") { + continue; + } + + configString += `${key}: ${request[key as keyof typeof request]}\n`; + } + } + + // Check for imageReferences and log the size of the array if it has elements + if (Array.isArray(imageReferences) && imageReferences.length > 0) { + configString += `images: ${imageReferences.length}\n`; + } + + return configString; + } + + getLoopNumbers(): number[] { + // Get the current loop number of any repeat type groups that the edge is crossing out of + const repeatLoopNumbers: number[] = []; + + this.crossingOutGroups.forEach((group) => { + const groupObject = this.graph[group]; + if (groupObject instanceof RepeatGroup) { + repeatLoopNumbers.push(groupObject.currentLoop); + } + }); + + // Reverse the array + repeatLoopNumbers.reverse(); + + return repeatLoopNumbers; + } + + getForEachVersionNumbers(): number[] { + // Get the current loop number of any repeat type groups that the edge is crossing out of + const forEachVersionNumbers: number[] = []; + + this.crossingOutGroups.forEach((group) => { + const groupObject = this.graph[group] as CannoliGroup; + if (groupObject.originalObject) { + forEachVersionNumbers.push(groupObject.currentLoop); + } + }); + + // Reverse the array + forEachVersionNumbers.reverse(); + + return forEachVersionNumbers; + } + + formatInteractionHeaders(messages: GenericCompletionResponse[]): string { + let formattedString = ""; + messages.forEach((message) => { + const role = message.role || "user"; + let content = message.content; + if ("function_call" in message && message.function_call) { + content = `Function Call: **${message.function_call.name}**\nArguments:\n\`\`\`json\n${message.function_call.arguments}\n\`\`\``; + } + formattedString += `#### ${role.charAt(0).toUpperCase() + role.slice(1) + }:\n${content}\n`; + }); + return formattedString.trim(); + } + + formatLoopHeader(loopNumbers: number[]): string { + let loopString = "# Loop "; + loopNumbers.forEach((loopNumber) => { + loopString += `${loopNumber + 1}.`; + }); + return loopString.slice(0, -1); + } + + formatVersionHeader(versionNumbers: number[]): string { + let versionString = "# Version "; + versionNumbers.forEach((versionNumber) => { + versionString += `${versionNumber}.`; + }); + return versionString.slice(0, -1); + } + + dependencyCompleted(dependency: CannoliObject): void { + if ( + this.getSource().status === CannoliObjectStatus.Complete + ) { + this.execute(); + } + } +} diff --git a/packages/cannoli-core/src/graph/objects/edges/SystemMessageEdge.ts b/packages/cannoli-core/src/graph/objects/edges/SystemMessageEdge.ts new file mode 100644 index 0000000..ff7f7e1 --- /dev/null +++ b/packages/cannoli-core/src/graph/objects/edges/SystemMessageEdge.ts @@ -0,0 +1,21 @@ +import { GenericCompletionParams } from "src/providers"; +import { CannoliEdge } from "../CannoliEdge"; + +export class SystemMessageEdge extends CannoliEdge { + load({ + content, + request, + }: { + content?: string | Record; + request?: GenericCompletionParams; + }): void { + if (content) { + this.setMessages([ + { + role: "system", + content: content as string, + }, + ]); + } + } +} \ No newline at end of file diff --git a/packages/cannoli-core/src/graph/objects/vertices/CannoliGroup.ts b/packages/cannoli-core/src/graph/objects/vertices/CannoliGroup.ts new file mode 100644 index 0000000..6380423 --- /dev/null +++ b/packages/cannoli-core/src/graph/objects/vertices/CannoliGroup.ts @@ -0,0 +1,382 @@ + +import { getGroupMembersFromData } from "src/factory"; +import { CannoliObject } from "src/graph/CannoliObject"; +import { VerifiedCannoliCanvasGroupData, VerifiedCannoliCanvasData, CannoliObjectStatus, AllVerifiedCannoliCanvasNodeData, EdgeType } from "src/graph"; +import { CannoliEdge } from "../CannoliEdge"; +import { CannoliVertex } from "../CannoliVertex"; + +export class CannoliGroup extends CannoliVertex { + members: string[]; + maxLoops: number; + currentLoop: number; + fromForEach: boolean; + + constructor( + groupData: VerifiedCannoliCanvasGroupData, + fullCanvasData: VerifiedCannoliCanvasData + ) { + super(groupData, fullCanvasData); + this.members = getGroupMembersFromData( + groupData.id, + fullCanvasData + ); + this.maxLoops = groupData.cannoliData.maxLoops ?? 1; + this.currentLoop = groupData.cannoliData.currentLoop ?? 0; + this.fromForEach = groupData.cannoliData.fromForEach ?? false; + this.originalObject = groupData.cannoliData.originalObject ?? null; + } + + setCurrentLoop(currentLoop: number) { + this.currentLoop = currentLoop; + + const data = this.canvasData.nodes.find((node) => node.id === this.id) as VerifiedCannoliCanvasGroupData; + data.cannoliData.currentLoop = currentLoop; + } + + getMembers(): CannoliVertex[] { + return this.members.map( + (member) => this.graph[member] as CannoliVertex + ); + } + + getCrossingAndInternalEdges(): { + crossingInEdges: CannoliEdge[]; + crossingOutEdges: CannoliEdge[]; + internalEdges: CannoliEdge[]; + } { + // Initialize the lists + const crossingInEdges: CannoliEdge[] = []; + const crossingOutEdges: CannoliEdge[] = []; + const internalEdges: CannoliEdge[] = []; + + // For each member + for (const member of this.members) { + const memberObject = this.graph[member]; + // If it's a vertex + if ( + this.cannoliGraph.isNode(memberObject) || + this.cannoliGraph.isGroup(memberObject) + ) { + // For each incoming edge + for (const edge of memberObject.incomingEdges) { + const edgeObject = this.graph[edge]; + if (this.cannoliGraph.isEdge(edgeObject)) { + // If it's crossing in + if (edgeObject.crossingInGroups.includes(this.id)) { + // Add it to the list + crossingInEdges.push(edgeObject); + } else { + // Otherwise, it's internal + internalEdges.push(edgeObject); + } + } + } + // For each outgoing edge + for (const edge of memberObject.outgoingEdges) { + const edgeObject = this.graph[edge]; + if (this.cannoliGraph.isEdge(edgeObject)) { + // If it's crossing out + if (edgeObject.crossingOutGroups.includes(this.id)) { + // Add it to the list + crossingOutEdges.push(edgeObject); + } else { + // Otherwise, it's internal + internalEdges.push(edgeObject); + } + } + } + } + } + + return { + crossingInEdges, + crossingOutEdges, + internalEdges, + }; + } + + allMembersCompleteOrRejected(): boolean { + // For each member + for (const member of this.members) { + // If it's not complete, return false + if ( + this.graph[member].status !== CannoliObjectStatus.Complete && + this.graph[member].status !== CannoliObjectStatus.Rejected + ) { + return false; + } + } + return true; + } + + allDependenciesCompleteOrRejected(): boolean { + // For each dependency + for (const dependency of this.dependencies) { + // If it's not complete, return false + if ( + this.graph[dependency].status !== + CannoliObjectStatus.Complete && + this.graph[dependency].status !== CannoliObjectStatus.Rejected + ) { + return false; + } + } + return true; + } + + async execute(): Promise { + this.setStatus(CannoliObjectStatus.Complete); + const event = new CustomEvent("update", { + detail: { obj: this, status: CannoliObjectStatus.Complete }, + }); + this.dispatchEvent(event); + } + + membersFinished() { } + + dependencyCompleted(dependency: CannoliObject): void { + if (this.status === CannoliObjectStatus.Executing) { + // If all dependencies are complete or rejected, call membersFinished + if (this.allDependenciesCompleteOrRejected()) { + this.membersFinished(); + } + } else if (this.status === CannoliObjectStatus.Complete) { + if (this.fromForEach && this.allDependenciesCompleteOrRejected()) { + const event = new CustomEvent("update", { + detail: { obj: this, status: CannoliObjectStatus.VersionComplete }, + }); + this.dispatchEvent(event); + } + } + } + + dependencyExecuting(dependency: CannoliObject): void { + if (this.status === CannoliObjectStatus.Pending) { + this.execute(); + } + } + + dependencyRejected(dependency: CannoliObject) { + if (this.noEdgeDependenciesRejected()) { + if (this.allDependenciesCompleteOrRejected()) { + this.reject(); + } + return; + } else { + this.reject(); + } + } + + noEdgeDependenciesRejected(): boolean { + // For each dependency + for (const dependency of this.dependencies) { + // If its an edge + if (this.graph[dependency].kind === "edge") { + if ( + this.graph[dependency].status === + CannoliObjectStatus.Rejected + ) { + return false; + } + } + } + return true; + } + + anyReflexiveEdgesComplete(): boolean { + // For each incoming edge + for (const edge of this.incomingEdges) { + const edgeObject = this.graph[edge] as CannoliEdge; + // If it's reflexive and complete, return true + if ( + edgeObject.isReflexive && + edgeObject.status === CannoliObjectStatus.Complete + ) { + return true; + } + } + return false; + } + + logDetails(): string { + let groupsString = ""; + groupsString += `Groups: `; + for (const group of this.groups) { + groupsString += `\n\t-"${this.ensureStringLength( + this.graph[group].text, + 15 + )}"`; + } + + let membersString = ""; + membersString += `Members: `; + for (const member of this.members) { + membersString += `\n\t-"${this.ensureStringLength( + this.graph[member].text, + 15 + )}"`; + } + + let incomingEdgesString = ""; + incomingEdgesString += `Incoming Edges: `; + for (const edge of this.incomingEdges) { + incomingEdgesString += `\n\t-"${this.ensureStringLength( + this.graph[edge].text, + 15 + )}"`; + } + + let outgoingEdgesString = ""; + outgoingEdgesString += `Outgoing Edges: `; + for (const edge of this.outgoingEdges) { + outgoingEdgesString += `\n\t-"${this.ensureStringLength( + this.graph[edge].text, + 15 + )}"`; + } + + return ( + `[::] Group ${this.id} Text: "${this.text}"\n${incomingEdgesString}\n${outgoingEdgesString}\n${groupsString}\n${membersString}\n` + + super.logDetails() + ); + } + + checkOverlap(): void { + const data = this.canvasData.nodes.find((node) => node.id === this.id) as VerifiedCannoliCanvasGroupData; + + const currentGroupRectangle = this.createRectangle( + data.x, + data.y, + data.width, + data.height + ); + + // Iterate through all objects in the graph + for (const objectKey in this.graph) { + const object = this.graph[objectKey]; + + if (object instanceof CannoliVertex) { + // Skip the current group to avoid self-comparison + if (object === this) continue; + + const objectData = this.canvasData.nodes.find((node) => node.id === object.id) as AllVerifiedCannoliCanvasNodeData; + + const objectRectangle = this.createRectangle( + objectData.x, + objectData.y, + objectData.width, + objectData.height + ); + + // Check if the object overlaps with the current group + if (this.overlaps(objectRectangle, currentGroupRectangle)) { + this.error( + `This group overlaps with another object. Please ensure objects fully enclose their members.` + ); + return; // Exit the method after the first error is found + } + } + } + } + + validateExitingAndReenteringPaths(): void { + const visited = new Set(); + + const dfs = (vertex: CannoliVertex, hasLeftGroup: boolean) => { + visited.add(vertex); + for (const edge of vertex.getOutgoingEdges()) { + const targetVertex = edge.getTarget(); + const isTargetInsideGroup = targetVertex + .getGroups() + .includes(this); + + if (hasLeftGroup && isTargetInsideGroup) { + this.error( + `A path leaving this group and re-enters it, this would cause deadlock.` + ); + return; + } + + if (!visited.has(targetVertex)) { + dfs(targetVertex, hasLeftGroup || !isTargetInsideGroup); + } + } + }; + + const members = this.getMembers(); + + for (const member of members) { + if (!visited.has(member)) { + dfs(member, false); + } + } + } + + validate() { + super.validate(); + + // Check for exiting and re-entering paths + this.validateExitingAndReenteringPaths(); + + // Check overlap + this.checkOverlap(); + + // If the group is fromForEach + if (this.fromForEach) { + const { crossingInEdges, crossingOutEdges } = this.getCrossingAndInternalEdges(); + + + // Check that there are no item edges crossing into it + const crossingInItemEdges = crossingInEdges.filter( + (edge) => this.graph[edge.id].type === EdgeType.Item + ); + + if (crossingInItemEdges.length > 0) { + this.error(`List edges can't cross into parallel groups. Try putting the node it's coming from inside the parallel group or using a non-list edge and an intermediary node.`); + return; + } + + // Check that there are no item edges crossing out of it and crossing into a different fromForEach group + const crossingOutItemOrListEdges = crossingOutEdges.filter( + (edge) => this.graph[edge.id].type === EdgeType.Item || this.graph[edge.id].type === EdgeType.List + ); + + if (crossingOutItemOrListEdges.length > 0) { + for (const edge of crossingOutItemOrListEdges) { + const edgeObject = this.graph[edge.id] as CannoliEdge; + + const crossingInGroups = edgeObject.crossingInGroups.map((group) => this.graph[group] as CannoliGroup); + + const crossingInParallelGroups = crossingInGroups.filter((group) => group.fromForEach); + + if (crossingInParallelGroups.length > 1) { + this.error(`List edges can't cross between parallel groups.`); + return; + } + } + } + + // Check that it has one and only one incoming edge of type item + const itemOrListEdges = this.incomingEdges.filter( + (edge) => this.graph[edge].type === EdgeType.Item || this.graph[edge].type === EdgeType.List + ); + if (itemOrListEdges.length < 1) { + this.error(`Parallel groups must have at least one incoming list arrow (cyan, labeled).`); + return; + } else if (itemOrListEdges.length > 1) { + // Check if one of the edges crosses a fromForEach group + const itemEdges = itemOrListEdges.filter( + (edge) => (this.graph[edge] as CannoliEdge).crossingOutGroups.some((group) => (this.graph[group] as CannoliGroup).fromForEach) + ); + + if (itemEdges.length > 0) { + this.error(`List edges can't cross between parallel groups.`); + return; + } + + this.error(`Parallel groups can't have more than one incoming list arrow.`); + return; + } + } + } +} \ No newline at end of file diff --git a/packages/cannoli-core/src/graph/objects/vertices/CannoliNode.ts b/packages/cannoli-core/src/graph/objects/vertices/CannoliNode.ts new file mode 100644 index 0000000..a420809 --- /dev/null +++ b/packages/cannoli-core/src/graph/objects/vertices/CannoliNode.ts @@ -0,0 +1,1201 @@ +import { pathOr, stringToPath } from "remeda"; +import { CannoliObject } from "src/graph/CannoliObject"; +import { Reference, VerifiedCannoliCanvasFileData, VerifiedCannoliCanvasLinkData, VerifiedCannoliCanvasTextData, VerifiedCannoliCanvasData, ReferenceType, GroupType, CannoliObjectStatus, ContentNodeType, EdgeModifier, EdgeType } from "src/graph"; +import { GenericCompletionParams, GenericCompletionResponse } from "src/providers"; +import { ZodSchema, z } from "zod"; +import { CannoliEdge } from "../CannoliEdge"; +import { CannoliVertex } from "../CannoliVertex"; +import { ChatResponseEdge } from "../edges/ChatResponseEdge"; +import { CannoliGroup } from "./CannoliGroup"; +import { FloatingNode } from "../FloatingNode"; + +export type VariableValue = { name: string; content: string; edgeId: string | null }; + +export type VersionedContent = { + content: string; + versionArray: { + header: string | null; + subHeader: string | null; + }[]; +}; + +export type TreeNode = { + header: string | null; + subHeader: string | null; + content?: string; + children?: TreeNode[]; +}; + +export class CannoliNode extends CannoliVertex { + references: Reference[] = []; + renderFunction: ( + variables: { name: string; content: string }[] + ) => Promise; + + constructor( + nodeData: + | VerifiedCannoliCanvasFileData + | VerifiedCannoliCanvasLinkData + | VerifiedCannoliCanvasTextData, + fullCanvasData: VerifiedCannoliCanvasData + ) { + super(nodeData, fullCanvasData); + this.references = nodeData.cannoliData.references || []; + this.renderFunction = this.buildRenderFunction(); + } + + buildRenderFunction() { + // Replace references with placeholders using an index-based system + let textCopy = this.text.slice(); + + let index = 0; + // Updated regex pattern to avoid matching newlines inside the double braces + textCopy = textCopy.replace(/\{\{[^{}\n]+\}\}/g, () => `{{${index++}}}`); + + // Define and return the render function + const renderFunction = async ( + variables: { name: string; content: string }[] + ) => { + // Process embedded notes + let processedText = await this.processEmbeds(textCopy); + + // Create a map to look up variable content by name + const varMap = new Map(variables.map((v) => [v.name, v.content])); + // Replace the indexed placeholders with the content from the variables + processedText = processedText.replace(/\{\{(\d+)\}\}/g, (match, index) => { + // Retrieve the reference by index + const reference = this.references[Number(index)]; + // Retrieve the content from the varMap using the reference's name + return varMap.get(reference.name) ?? "{{invalid}}"; + }); + + // Run replacer functions + for (const replacer of this.run.replacers) { + processedText = await replacer(processedText, this.run.isMock, this); + } + + return processedText; + }; + + + return renderFunction; + } + + + async processEmbeds(content: string): Promise { + // Check for embedded notes (e.g. ![[Note Name]]), and replace them with the note content + const embeddedNotes = content.match(/!\[\[[\s\S]*?\]\]/g); + + if (embeddedNotes) { + for (const embeddedNote of embeddedNotes) { + let noteName = embeddedNote + .replace("![[", "") + .replace("]]", ""); + + let subpath; + + // Image extensions + const imageExtensions = [".jpg", ".png", ".jpeg", ".gif", ".bmp", ".tiff", ".webp", ".svg", ".ico", ".jfif", ".avif"]; + if (imageExtensions.some(ext => noteName.endsWith(ext))) { + continue; + } + + // If there's a pipe, split and use the first part as the note name + if (noteName.includes("|")) { + noteName = noteName.split("|")[0]; + } + + // If there's a "#", split and use the first part as the note name, and the second part as the heading + if (noteName.includes("#")) { + const split = noteName.split("#"); + noteName = split[0]; + subpath = split[1]; + } + + // If there's no fileSystemInterface, throw an error + if (!this.run.fileManager) { + throw new Error("No fileManager found"); + } + + const noteContent = await this.run.fileManager.getNote({ + name: noteName, + type: ReferenceType.Note, + shouldExtract: true, + includeName: true, + subpath: subpath, + }, this.run.isMock); + + + + if (noteContent) { + const blockquotedNoteContent = + "> " + noteContent.replace(/\n/g, "\n> "); + content = content.replace( + embeddedNote, + blockquotedNoteContent + ); + } + } + } + + return content; + } + + async getContentFromNote(reference: Reference): Promise { + // If there's no fileSystemInterface, throw an error + if (!this.run.fileManager) { + throw new Error("No fileManager found"); + } + + const note = await this.run.fileManager.getNote(reference, this.run.isMock); + + if (note === null) { + return null; + } + + return note; + } + + getContentFromFloatingNode(name: string): string | null { + for (const object of Object.values(this.graph)) { + if (object instanceof FloatingNode && object.getName() === name) { + return object.getContent(); + } + } + return null; + } + + async processReferences(additionalVariableValues?: VariableValue[], cleanForJson?: boolean) { + const variableValues = this.getVariableValues(true); + + if (additionalVariableValues) { + variableValues.push(...additionalVariableValues); + } + + const resolvedReferences = await Promise.all( + this.references.map(async (reference) => { + let content = "{{invalid reference}}"; + const { name } = reference; + + if ( + (reference.type === ReferenceType.Variable || + reference.type === ReferenceType.Selection) && + !reference.shouldExtract + ) { + // First, try to get the content from variable values + const variable = variableValues.find( + (variable: { name: string }) => + variable.name === reference.name + ); + + if (variable) { + content = variable.content; + } else { + // If variable content is null, fall back to floating node + const floatingContent = this.getContentFromFloatingNode(reference.name); + if (floatingContent !== null) { + content = floatingContent; + } + // If the reference name contains only "#" symbols, replace it with the loop index + else if (reference.name.match(/^#+$/)) { + // Depth is the number of hash symbols minus 1 + const depth = reference.name.length - 1; + const loopIndex = this.getLoopIndex(depth); + if (loopIndex !== null) { + content = loopIndex.toString(); + } else { + content = `{{${reference.name}}}`; + } + } else if (this.run.secrets && this.run.secrets[reference.name]) { + // Check in this.run.secrets + content = this.run.secrets[reference.name]; + } else { + // this.warning(`Variable "${reference.name}" not found`); + content = `{{${reference.name}}}`; + } + } + } else if ( + (reference.type === ReferenceType.Variable || + reference.type === ReferenceType.Selection) && + reference.shouldExtract + ) { + // First, try to get the content from variable values + const variable = variableValues.find( + (variable) => variable.name === reference.name + ); + if (variable && variable.content) { + content = variable.content; + } else { + // If variable content is null, fall back to floating node + const floatingContent = this.getContentFromFloatingNode(reference.name); + if (floatingContent !== null) { + content = floatingContent; + } + } + + if (content !== "{{invalid reference}}") { + // Save original variable name + const originalName = reference.name; + + // Set reference name to the content of the variable + reference.name = content; + + // Get the content from the note + const noteContent = await this.getContentFromNote( + reference + ); + + // Restore original variable name + reference.name = originalName; + if (noteContent !== null) { + content = noteContent; + } else { + this.warning( + `Note "${content}" not found` + ); + content = `{{@${reference.name}}}`; + } + } else { + //this.warning(`Variable "${reference.name}" not found`); + content = `{{@${reference.name}}}`; + } + } else if (reference.type === ReferenceType.Note) { + if (reference.shouldExtract) { + const noteContent = await this.getContentFromNote( + reference + ); + if (noteContent !== null) { + content = noteContent; + } else { + this.warning(`Note "${reference.name}" not found`); + content = `{{[[${reference.name}]]}}`; + } + } else { + content = reference.name; + } + } else if (reference.type === ReferenceType.Floating) { + if (reference.shouldExtract) { + const floatingContent = this.getContentFromFloatingNode( + reference.name + ); + if (floatingContent !== null) { + content = floatingContent; + } else { + this.warning(`Floating node "${name}" not found`); + content = `{{[${reference.name}]}}`; + } + } + } + + if (cleanForJson) { + content = content.replace(/\\/g, '\\\\') + .replace(/\n/g, "\\n") + .replace(/"/g, '\\"') + .replace(/\t/g, '\\t'); + } + + return { name, content }; + }) + ); + + return this.renderFunction(resolvedReferences); + } + + getLoopIndex(depth: number): number | null { + const groups = this.groups.map((group) => this.graph[group] as CannoliGroup); + + // Filter to only repeat or forEach groups + const repeatOrForEachGroups = groups.filter((group) => group.type === GroupType.Repeat || group.fromForEach); + + // Get the group at the specified depth (0 is the most immediate group) + const group = repeatOrForEachGroups[depth]; + + // If group is not there, return null + if (!group) { + return null; + } + + // If group is not a CannoliGroup, return null + if (!(group instanceof CannoliGroup)) { + return null; + } + + // Get the loop index from the group + const loopIndex = group.currentLoop + 1; + + return loopIndex; + } + + getVariableValues(includeGroupEdges: boolean): VariableValue[] { + const variableValues: VariableValue[] = []; + + // Get all available provide edges + let availableEdges = this.getAllAvailableProvideEdges(); + + // If includeGroupEdges is not true, filter for only incoming edges of this node + if (!includeGroupEdges) { + availableEdges = availableEdges.filter((edge) => + this.incomingEdges.includes(edge.id) + ); + } + + for (const edge of availableEdges) { + const edgeObject = this.graph[edge.id]; + if (!(edgeObject instanceof CannoliEdge)) { + throw new Error( + `Error on object ${edgeObject.id}: object is not a provide edge.` + ); + } + + // If the edge isn't complete, check its status + if (!(edgeObject.status === CannoliObjectStatus.Complete)) { + // If the edge is reflexive and not rejected, set its content to an empty string and keep going + if (edgeObject.isReflexive && edgeObject.status !== CannoliObjectStatus.Rejected) { + edgeObject.setContent(""); + } else if ( + // If the edge is not rejected, not reflexive, or its content is null, skip it + !(edgeObject.status === CannoliObjectStatus.Rejected) || + !edgeObject.isReflexive || + edgeObject.content === null + ) { + continue; + } + } + + let content: string; + + if (edgeObject.content === null) { + continue; + } + + if (typeof edgeObject.content === "string" && edgeObject.text) { + // if the edge has a versions array + if (edgeObject.versions && edgeObject.versions.length > 0) { + const allVersions: VersionedContent[] = [{ + content: edgeObject.content, + versionArray: edgeObject.versions.map((version) => ({ + header: version.header, + subHeader: version.subHeader + })) + }]; + + // Find all edges with the same name and add them to the allVersions array + const edgesWithSameName = this.getAllAvailableProvideEdges().filter((edge) => edge.text === edgeObject.text); + for (const otherVersion of edgesWithSameName) { + + if (otherVersion.id !== edgeObject.id && + otherVersion.versions?.length === edgeObject.versions?.length && + otherVersion.content !== null) { + allVersions.push( + { + content: otherVersion.content as string, + versionArray: otherVersion.versions + } + ) + } + } + + const modifier = edgeObject.edgeModifier; + + let fromFormatterNode = false; + + if (this.graph[edgeObject.source].type === ContentNodeType.Formatter) { + fromFormatterNode = true; + } + + content = this.renderMergedContent(allVersions, modifier, fromFormatterNode, edgeObject.text); + } else { + content = edgeObject.content; + } + + const variableValue = { + name: edgeObject.text, + content: content, + edgeId: edgeObject.id, + }; + + variableValues.push(variableValue); + } else if ( + typeof edgeObject.content === "object" && + !Array.isArray(edgeObject.content) + ) { + const multipleVariableValues = []; + + for (const name in edgeObject.content) { + const variableValue = { + name: name, + content: edgeObject.content[name], + edgeId: edgeObject.id, + }; + + multipleVariableValues.push(variableValue); + } + + variableValues.push(...multipleVariableValues); + } else { + continue; + } + } + + // Add the default "NOTE" variable + if (this.run.currentNote && includeGroupEdges) { + const currentNoteVariableValue = { + name: "NOTE", + content: this.run.currentNote, + edgeId: "", + }; + + variableValues.push(currentNoteVariableValue); + } + + // Add the default "SELECTION" variable + if (this.run.selection && includeGroupEdges) { + const currentSelectionVariableValue = { + name: "SELECTION", + content: this.run.selection, + edgeId: "", + }; + + variableValues.push(currentSelectionVariableValue); + } + + // Resolve variable conflicts + const resolvedVariableValues = + this.resolveVariableConflicts(variableValues); + + return resolvedVariableValues; + } + + renderMergedContent(allVersions: VersionedContent[], modifier: EdgeModifier | null, fromFormatterNode: boolean, edgeName: string): string { + const tree = this.transformToTree(allVersions); + if (modifier === EdgeModifier.Table) { + return this.renderAsMarkdownTable(tree, edgeName); + } else if (modifier === EdgeModifier.List) { + return this.renderAsMarkdownList(tree); + } else if (modifier === EdgeModifier.Headers) { + return this.renderAsMarkdownHeaders(tree); + } else { + return this.renderAsParagraphs(tree, fromFormatterNode); + } + } + + transformToTree(allVersions: VersionedContent[]): TreeNode { + const root: TreeNode = { header: null, subHeader: null, children: [] }; + + allVersions.forEach(item => { + let currentNode = root; + + for (let i = item.versionArray.length - 1; i >= 0; i--) { + const version = item.versionArray[i]; + if (!currentNode.children) { + currentNode.children = []; + } + + let nextNode = currentNode.children.find(child => child.subHeader === version.subHeader); + + if (!nextNode) { + nextNode = { + header: version.header, + subHeader: version.subHeader, + children: [] + }; + currentNode.children.push(nextNode); + } + + currentNode = nextNode; + + if (i === 0) { + currentNode.content = item.content; + } + } + }); + + return root; + } + + renderAsParagraphs(tree: TreeNode, fromFormatterNode: boolean): string { + let result = ''; + + if (tree.content) { + if (fromFormatterNode) { + result += `${tree.content}`; + } else { + result += `${tree.content}\n\n`; + } + } + + if (tree.children) { + tree.children.forEach(child => { + result += this.renderAsParagraphs(child, fromFormatterNode); + }); + } + + return result; + } + + renderAsMarkdownHeaders(tree: TreeNode, level: number = 0): string { + let result = ''; + + if (level !== 0) { + result += `${'#'.repeat(level)} ${tree.subHeader}\n\n`; + } + + if (tree.content) { + result += `${tree.content}\n\n`; + } + + if (tree.children) { + tree.children.forEach(child => { + result += this.renderAsMarkdownHeaders(child, level + 1); + }); + } + + return result; + } + + renderAsMarkdownList(tree: TreeNode, indent: string = ''): string { + let result = ''; + + if (tree.subHeader) { + result += `${indent}- ${tree.subHeader}\n`; + } + + if (tree.content) { + const indentedContent = tree.content.split('\n').map(line => `${indent} ${line}`).join('\n'); + result += `${indentedContent}\n`; + } + + if (tree.children) { + tree.children.forEach(child => { + result += this.renderAsMarkdownList(child, indent + ' '); + }); + } + + return result; + } + + renderAsMarkdownTable(tree: TreeNode, edgeName: string): string { + let table = ''; + + if (!tree.children) { + return table; + } + + // Helper function to replace newlines with
+ const replaceNewlines = (text: string | null | undefined): string => { + return (text ?? '').replace(/\n/g, '
'); + }; + + // Check if there's only one level + const isSingleLevel = !tree.children.some(child => child.children && child.children.length > 0); + + if (isSingleLevel) { + table = `| ${replaceNewlines(tree.children[0].header)} | ${edgeName} |\n| --- | --- |\n` + + // Create the table rows + tree.children.forEach(child => { + table += '| ' + replaceNewlines(child.subHeader) + ' | ' + replaceNewlines(child.content) + ' |\n'; + }); + } else { + // Extract the headers from the first child + const headers = tree.children[0].children?.map(child => replaceNewlines(child.subHeader)) ?? []; + + // Create the table header with an empty cell for the main header + table += '| |' + headers.join(' | ') + ' |\n'; + table += '| --- |' + headers.map(() => ' --- ').join(' | ') + ' |\n'; + + // Create the table rows + tree.children.forEach(child => { + table += '| ' + replaceNewlines(child.subHeader) + ' |'; + child.children?.forEach(subChild => { + const content = replaceNewlines(subChild.content); + table += ` ${content} |`; + }); + table += '\n'; + }); + } + + return table; + } + + resolveVariableConflicts(variableValues: VariableValue[]): VariableValue[] { + const finalVariables: VariableValue[] = []; + const groupedByName: Record = {}; + + // Group the variable values by name + for (const variable of variableValues) { + if (!groupedByName[variable.name]) { + groupedByName[variable.name] = []; + } + groupedByName[variable.name].push(variable); + } + + // Iterate through the grouped names + for (const name in groupedByName) { + // Get all the variable values for this name + const variables = groupedByName[name]; + + let selectedVariable = variables[0]; // Start with the first variable + let foundNonEmptyReflexive = false; + + // Iterate through the variables, preferring the reflexive edge if found + for (const variable of variables) { + if (!variable.edgeId) { + // If the variable has no edgeId, it's a special variable given by the node, it always has priority + selectedVariable = variable; + break; + } + + const edgeObject = this.graph[variable.edgeId]; + + // Check if edgeObject is an instance of CannoliEdge and if it's reflexive + if ( + edgeObject instanceof CannoliEdge && + edgeObject.isReflexive + ) { + if (edgeObject.content !== "") { + selectedVariable = variable; + foundNonEmptyReflexive = true; + break; // Exit the loop once a reflexive edge with non-empty content is found + } else if (!foundNonEmptyReflexive) { + // If no non-empty reflexive edge has been found yet, prefer the first reflexive edge + selectedVariable = variable; + } + } + } + + // If no non-empty reflexive edge was found, prefer the first non-reflexive edge + if (!foundNonEmptyReflexive && selectedVariable.content === "") { + for (const variable of variables) { + if (!variable.edgeId) { + this.error(`Variable ${name} has no edgeId`); + continue; + } + + const edgeObject = this.graph[variable.edgeId]; + if ( + !(edgeObject instanceof CannoliEdge) || + !edgeObject.isReflexive + ) { + selectedVariable = variable; + break; + } + } + } + + // Add the selected variable to the final array + finalVariables.push(selectedVariable); + } + + return finalVariables; + } + + private parseContent(content: string, path: never): string { + let contentObject; + + // Try to parse the content as JSON + try { + contentObject = JSON.parse(content as string); + } catch (e) { + // If parsing fails, return the original content + return content; + } + + // If we parsed the content as JSON and it's not an array, use the parsed object + if (contentObject && !Array.isArray(contentObject)) { + // Get the value from the parsed text + const value = pathOr(contentObject, path, content); + + // If the value is a string, return it + if (typeof value === "string") { + return value; + } else { + // Otherwise, return the stringified value + return JSON.stringify(value, null, 2); + } + } + + // If we didn't parse the content as JSON, return the original content + return content; + } + + loadOutgoingEdges(content: string, request?: GenericCompletionParams) { + let itemIndex = 0; + let listItems: string[] = []; + + if (this.outgoingEdges.some(edge => this.graph[edge].type === EdgeType.Item)) { + if (this.type === ContentNodeType.Http) { + // Parse the text of the edge with remeda + const path = stringToPath(this.graph[this.outgoingEdges.find(edge => this.graph[edge].type === EdgeType.Item)!].text); + listItems = this.getListArrayFromContent(this.parseContent(content, path)); + } else { + listItems = this.getListArrayFromContent(content); + } + } + + for (const edge of this.outgoingEdges) { + const edgeObject = this.graph[edge]; + let contentToLoad = content; + + // If it's coming from an httpnode + if (this.type === ContentNodeType.Http) { + // Parse the text of the edge with remeda + const path = stringToPath(edgeObject.text); + contentToLoad = this.parseContent(content, path); + } + + if (edgeObject instanceof CannoliEdge && !(edgeObject instanceof ChatResponseEdge) && edgeObject.type !== EdgeType.Item) { + edgeObject.load({ + content: contentToLoad, + request: request, + }); + } else if (edgeObject instanceof CannoliEdge && edgeObject.type === EdgeType.Item) { + const item = listItems[itemIndex]; + + // If we exceed the list items, reject the edge + if (!item) { + edgeObject.reject(); + continue; + } + + edgeObject.load({ + content: item, + request: request, + }); + itemIndex++; + } + } + } + + getListArrayFromContent(content: string): string[] { + // Attempt to parse the content as JSON + try { + const jsonArray = JSON.parse(content); + if (Array.isArray(jsonArray)) { + return jsonArray.map(item => typeof item === 'string' ? item : JSON.stringify(item)); + } + } catch (e) { + // If parsing fails, continue with markdown list parsing + } + + // First pass: look for markdown list items, and return the item at the index + const lines = content.split("\n"); + + // Filter out the lines that don't start with "- " or a number followed by ". " + const listItems = lines.filter((line) => line.startsWith("- ") || /^\d+\. /.test(line)); + + // Return the list items without the "- " or the number and ". " + return listItems.map((item) => item.startsWith("- ") ? item.substring(2) : item.replace(/^\d+\. /, "")); + } + + dependencyCompleted(dependency: CannoliObject): void { + if ( + this.allDependenciesComplete() && + this.status === CannoliObjectStatus.Pending + ) { + this.execute(); + } + } + + getNoteOrFloatingReference(): Reference | null { + const notePattern = /^{{\[\[([^\]]+)\]\]([\W]*)}}$/; + const floatingPattern = /^{{\[([^\]]+)\]}}$/; + const currentNotePattern = /^{{NOTE([\W]*)}}$/; + + const strippedText = this.text.trim(); + + let match = notePattern.exec(strippedText); + if (match) { + const reference: Reference = { + name: match[1], + type: ReferenceType.Note, + shouldExtract: false, + }; + + const modifiers = match[2]; + if (modifiers) { + if (modifiers.includes("!#")) { + reference.includeName = false; + } else if (modifiers.includes("#")) { + reference.includeName = true; + } + + if (modifiers.includes("!$")) { + reference.includeProperties = false; + } else if (modifiers.includes("$")) { + reference.includeProperties = true; + } + + if (modifiers.includes("!@")) { + reference.includeLink = false; + } else if (modifiers.includes("@")) { + reference.includeLink = true; + } + } + return reference; + } + + match = floatingPattern.exec(strippedText); + if (match) { + const reference = { + name: match[1], + type: ReferenceType.Floating, + shouldExtract: false, + }; + return reference; + } + + match = currentNotePattern.exec(strippedText); + if (match && this.run.currentNote) { + const reference: Reference = { + name: this.run.currentNote, + type: ReferenceType.Note, + shouldExtract: false, + }; + + const modifiers = match[1]; + if (modifiers) { + if (modifiers.includes("!#")) { + reference.includeName = false; + } else if (modifiers.includes("#")) { + reference.includeName = true; + } + + if (modifiers.includes("!$")) { + reference.includeProperties = false; + } else if (modifiers.includes("$")) { + reference.includeProperties = true; + } + + if (modifiers.includes("!@")) { + reference.includeLink = false; + } else if (modifiers.includes("@")) { + reference.includeLink = true; + } + } + return reference; + } + + return null; + } + + logDetails(): string { + let groupsString = ""; + groupsString += `Groups: `; + for (const group of this.groups) { + groupsString += `\n\t-"${this.ensureStringLength( + this.graph[group].text, + 15 + )}"`; + } + + let incomingEdgesString = ""; + incomingEdgesString += `Incoming Edges: `; + for (const edge of this.incomingEdges) { + incomingEdgesString += `\n\t-"${this.ensureStringLength( + this.graph[edge].text, + 15 + )}"`; + } + + let outgoingEdgesString = ""; + outgoingEdgesString += `Outgoing Edges: `; + for (const edge of this.outgoingEdges) { + outgoingEdgesString += `\n\t-"${this.ensureStringLength( + this.graph[edge].text, + 15 + )}"`; + } + + return ( + `[] Node ${this.id} Text: "${this.text}"\n${incomingEdgesString}\n${outgoingEdgesString}\n${groupsString}\n` + + super.logDetails() + ); + } + + validate(): void { + super.validate(); + + // Nodes can't have any incoming list edges + if (this.incomingEdges.filter((edge) => this.graph[edge].type === EdgeType.List).length > 0) { + this.error(`Nodes can't have any incoming list edges.`); + } + } + + getSpecialOutgoingEdges(): CannoliEdge[] { + // Get all special outgoing edges + const specialOutgoingEdges = this.getOutgoingEdges().filter((edge) => { + return ( + edge.type === EdgeType.Field || + edge.type === EdgeType.Choice || + edge.type === EdgeType.List || + edge.type === EdgeType.Variable + ); + }); + + return specialOutgoingEdges; + } + + specialOutgoingEdgesAreHomogeneous(): boolean { + const specialOutgoingEdges = this.getSpecialOutgoingEdges(); + + if (specialOutgoingEdges.length === 0) { + return true; + } + + const firstEdgeType = specialOutgoingEdges[0].type; + + for (const edge of specialOutgoingEdges) { + if (edge.type !== firstEdgeType) { + return false; + } + } + + return true; + } + + getAllAvailableProvideEdges(): CannoliEdge[] { + const availableEdges: CannoliEdge[] = []; + + // Get the incoming edges of all groups + for (const group of this.groups) { + const groupObject = this.graph[group]; + if (!(groupObject instanceof CannoliVertex)) { + throw new Error( + `Error on node ${this.id}: group is not a vertex.` + ); + } + + const groupIncomingEdges = groupObject.getIncomingEdges(); + + availableEdges.push(...groupIncomingEdges); + } + + // Get the incoming edges of this node + const nodeIncomingEdges = this.getIncomingEdges(); + + availableEdges.push(...nodeIncomingEdges); + + // Filter out all logging, and write edges + const filteredEdges = availableEdges.filter( + (edge) => + edge.type !== EdgeType.Logging && + edge.type !== EdgeType.Write && + edge.type !== EdgeType.Config + ); + + return filteredEdges as CannoliEdge[]; + } + + private updateConfigWithValue( + runConfig: Record, + content: string | Record | null, + schema: ZodSchema, + setting?: string | null, + ): void { + // Ensure the schema is a ZodObject to access its shape + if (!(schema instanceof z.ZodObject)) { + this.error("Provided schema is not a ZodObject."); + return; + } + + if (typeof content === "string") { + if (setting) { + runConfig[setting] = content; + } + } else if (typeof content === "object") { + for (const key in content) { + runConfig[key] = content[key]; + } + } + + try { + // Validate and transform the final runConfig against the schema + const parsedConfig = schema.parse(runConfig); + Object.assign(runConfig, parsedConfig); // Update runConfig with the transformed values + } catch (error) { + this.error(`Error setting config: ${error.errors[0].message}`); + } + } + + private processSingleEdge( + runConfig: Record, + edgeObject: CannoliEdge, + schema: ZodSchema + ): void { + if ( + typeof edgeObject.content === "string" || + typeof edgeObject.content === "object" + ) { + this.updateConfigWithValue( + runConfig, + edgeObject.content, + schema, + edgeObject.text, + ); + } else { + this.error(`Config edge has invalid content.`); + } + } + + private processEdges( + runConfig: Record, + edges: CannoliEdge[], + schema: ZodSchema + ): void { + for (const edgeObject of edges) { + if (!(edgeObject instanceof CannoliEdge)) { + throw new Error( + `Error processing config edges: object is not an edge.` + ); + } + this.processSingleEdge(runConfig, edgeObject, schema); + } + } + + private processGroups(runConfig: Record, schema: ZodSchema): void { + for (let i = this.groups.length - 1; i >= 0; i--) { + const group = this.graph[this.groups[i]]; + if (group instanceof CannoliGroup) { + const configEdges = group + .getIncomingEdges() + .filter((edge) => edge.type === EdgeType.Config); + this.processEdges(runConfig, configEdges, schema); + } + } + } + + private processNodes(runConfig: Record, schema: ZodSchema): void { + const configEdges = this.getIncomingEdges().filter( + (edge) => edge.type === EdgeType.Config + ); + this.processEdges(runConfig, configEdges, schema); + } + + getConfig(schema: ZodSchema): Record { + const runConfig = {}; + + this.processGroups(runConfig, schema); + this.processNodes(runConfig, schema); + + return runConfig; + } + + getPrependedMessages(): GenericCompletionResponse[] { + const messages: GenericCompletionResponse[] = []; + const systemMessages: GenericCompletionResponse[] = []; + + // Get all available provide edges + const availableEdges = this.getAllAvailableProvideEdges(); + + // filter for only incoming edges of this node + const directEdges = availableEdges.filter((edge) => + this.incomingEdges.includes(edge.id) + ); + + + // Filter for indirect edges (not incoming edges of this node) + const indirectEdges = availableEdges.filter( + (edge) => !this.incomingEdges.includes(edge.id) + ); + + + for (const edge of directEdges) { + const edgeObject = this.graph[edge.id]; + if (!(edgeObject instanceof CannoliEdge)) { + throw new Error( + `Error on object ${edgeObject.id}: object is not a provide edge.` + ); + } + + const edgeMessages = edgeObject.messages; + + if (!edgeMessages || edgeMessages.length < 1) { + continue; + } + + // If the edge is crossing a group, check if there are any indirect edges pointing to that group + for (const group of edgeObject.crossingInGroups) { + const indirectEdgesToGroup = indirectEdges.filter( + (edge) => edge.target === group + ); + + // Filter for those indirect edges that have addMessages = true and are of the same type + const indirectEdgesToAdd = indirectEdgesToGroup.filter( + (edge) => + this.graph[edge.id] instanceof CannoliEdge && + (this.graph[edge.id] as CannoliEdge).addMessages && + (this.graph[edge.id] as CannoliEdge).type === edgeObject.type + ); + + // For each indirect edge, add its messages without overwriting + for (const indirectEdge of indirectEdgesToAdd) { + const indirectEdgeObject = this.graph[indirectEdge.id]; + if (!(indirectEdgeObject instanceof CannoliEdge)) { + throw new Error( + `Error on object ${indirectEdgeObject.id}: object is not a provide edge.` + ); + } + + const indirectEdgeMessages = indirectEdgeObject.messages; + + if (!indirectEdgeMessages || indirectEdgeMessages.length < 1) { + continue; + } + + edgeMessages.push(...indirectEdgeMessages); + } + } + + // Separate system messages from other messages + if (edge.type === EdgeType.SystemMessage) { + for (const msg of edgeMessages) { + if (!systemMessages.some((m) => m.content === msg.content) && !messages.some((m) => m.content === msg.content)) { + systemMessages.push(msg); + } + } + } else { + messages.push(...edgeMessages); + } + } + + // If messages is empty and there are no incoming edges with addMessages = true, try it with indirect edges + if (messages.length === 0) { + for (const edge of indirectEdges) { + const edgeObject = this.graph[edge.id]; + if (!(edgeObject instanceof CannoliEdge)) { + throw new Error( + `Error on object ${edgeObject.id}: object is not a provide edge.` + ); + } + + const edgeMessages = edgeObject.messages; + + if (!edgeMessages || edgeMessages.length < 1) { + continue; + } + + // Separate system messages from other messages + if (edge.type === EdgeType.SystemMessage) { + for (const msg of edgeMessages) { + if (!systemMessages.some((m) => m.content === msg.content) && !messages.some((m) => m.content === msg.content)) { + systemMessages.push(msg); + } + } + } else { + messages.push(...edgeMessages); + } + } + } + + // Combine system messages and other messages + const combinedMessages = [...systemMessages, ...messages]; + + // Remove duplicate system messages from the combined message stack + const uniqueMessages = combinedMessages.filter((msg, index, self) => + msg.role !== "system" || self.findIndex((m) => m.content === msg.content) === index + ); + + return uniqueMessages; + } +} \ No newline at end of file diff --git a/packages/cannoli-core/src/graph/objects/vertices/groups/RepeatGroup.ts b/packages/cannoli-core/src/graph/objects/vertices/groups/RepeatGroup.ts new file mode 100644 index 0000000..98cf3a9 --- /dev/null +++ b/packages/cannoli-core/src/graph/objects/vertices/groups/RepeatGroup.ts @@ -0,0 +1,122 @@ +import { VerifiedCannoliCanvasGroupData, VerifiedCannoliCanvasData, CannoliObjectStatus, EdgeType } from "src/graph"; +import { CannoliEdge } from "../../CannoliEdge"; +import { CannoliGroup } from "../CannoliGroup"; + +export class RepeatGroup extends CannoliGroup { + constructor( + groupData: VerifiedCannoliCanvasGroupData, + fullCanvasData: VerifiedCannoliCanvasData + ) { + super(groupData, fullCanvasData); + + this.currentLoop = groupData.cannoliData.currentLoop ?? 0; + this.maxLoops = groupData.cannoliData.maxLoops ?? 1; + } + + async execute(): Promise { + this.setStatus(CannoliObjectStatus.Executing); + const event = new CustomEvent("update", { + detail: { obj: this, status: CannoliObjectStatus.Executing }, + }); + this.dispatchEvent(event); + } + + resetMembers() { + // For each member + for (const member of this.getMembers()) { + // Reset the member + member.reset(); + // Reset the member's outgoing edges whose target isn't this group + for (const edge of member.outgoingEdges) { + const edgeObject = this.graph[edge] as CannoliEdge; + + if (edgeObject.getTarget() !== this) { + edgeObject.reset(); + } + } + } + } + + membersFinished(): void { + this.setCurrentLoop(this.currentLoop + 1); + this.setText(`${this.currentLoop}/${this.maxLoops}`); + + if ( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.currentLoop < this.maxLoops! && + this.allEdgeDependenciesComplete() + ) { + + if (!this.run.isMock) { + // Sleep for 20ms to allow complete color to render + setTimeout(() => { + this.resetMembers(); + + const event = new CustomEvent("update", { + detail: { obj: this, status: CannoliObjectStatus.VersionComplete, message: this.currentLoop.toString() }, + }); + + this.dispatchEvent(event); + + this.executeMembers(); + }, 20); + } else { + this.resetMembers(); + this.executeMembers(); + } + } else { + this.setStatus(CannoliObjectStatus.Complete); + const event = new CustomEvent("update", { + detail: { obj: this, status: CannoliObjectStatus.Complete }, + }); + this.dispatchEvent(event); + } + } + + executeMembers(): void { + // For each member + for (const member of this.getMembers()) { + member.dependencyCompleted(this); + } + } + + reset(): void { + super.reset(); + this.setCurrentLoop(0); + this.setText(`0/${this.maxLoops}`); + } + + logDetails(): string { + return ( + super.logDetails() + `Type: Repeat\nMax Loops: ${this.maxLoops}\n` + ); + } + + validate(): void { + super.validate(); + + // Repeat groups must have a valid label number + if (this.maxLoops === null) { + this.error( + `Repeat groups loops must have a valid number in their label. Please ensure the label is a positive integer.` + ); + } + + // Repeat groups can't have incoming edges of type list + const listEdges = this.incomingEdges.filter( + (edge) => + this.graph[edge].type === EdgeType.List + ); + + if (listEdges.length !== 0) { + this.error( + `Repeat groups can't have incoming edges of type list.` + ); + } + + // Repeat groups can't have any outgoing edges + if (this.outgoingEdges.length !== 0) { + this.error(`Repeat groups can't have any outgoing edges.`); + } + } +} diff --git a/packages/cannoli-core/src/graph/objects/vertices/nodes/CallNode.ts b/packages/cannoli-core/src/graph/objects/vertices/nodes/CallNode.ts new file mode 100644 index 0000000..61bae08 --- /dev/null +++ b/packages/cannoli-core/src/graph/objects/vertices/nodes/CallNode.ts @@ -0,0 +1,263 @@ +import { EdgeType, EdgeModifier } from "src/graph"; +import { GenericCompletionResponse, GenericCompletionParams, ImageReference, GenericModelConfigSchema, GenericModelConfig, SupportedProviders, GenericFunctionCall } from "src/providers"; +import { ChatRole } from "src/run"; +import invariant from "tiny-invariant"; +import { CannoliNode } from "../CannoliNode"; + +export class CallNode extends CannoliNode { + async getNewMessage( + role?: string + ): Promise { + const content = await this.processReferences(); + + // If there is no content, return null + if (!content) { + return null; + } + + return { + role: (role as ChatRole) || "user", + content: content, + }; + } + + findNoteReferencesInMessages( + messages: GenericCompletionResponse[] + ): string[] { + const references: string[] = []; + const noteRegex = /\[\[(.+?)\]\]/g; + + // Get the contents of each double bracket + for (const message of messages) { + const matches = + typeof message.content === "string" && + message.content?.matchAll(noteRegex); + + if (!matches) { + continue; + } + + for (const match of matches) { + references.push(match[1]); + } + } + + return references; + } + + async execute() { + this.executing(); + + let request: GenericCompletionParams; + try { + request = await this.createLLMRequest(); + } catch (error) { + this.error(`Error creating LLM request: ${error}`); + return; + } + + // If the message array is empty, error + if (request.messages.length === 0) { + this.error( + `No messages to send to LLM. Empty call nodes only send the message history they've been passed.` + ); + return; + } + + // If the node has an outgoing chatResponse edge, call with streaming + const chatResponseEdges = this.getOutgoingEdges().filter( + (edge) => edge.type === EdgeType.ChatResponse + ); + + if (chatResponseEdges.length > 0) { + const stream = await this.run.callLLMStream(request); + + if (stream instanceof Error) { + this.error(`Error calling LLM:\n${stream.message}`); + return; + } + + if (!stream) { + this.error(`Error calling LLM: no stream returned.`); + return; + } + + if (typeof stream === "string") { + this.loadOutgoingEdges(stream, request); + this.completed(); + return; + } + + // Create message content string + let messageContent = ""; + // Process the stream. For each part, add the message to the request, and load the outgoing edges + for await (const part of stream) { + if (!part || typeof part !== "string") { + // deltas might be empty, that's okay, just get the next one + continue; + } + + // Add the part to the message content + messageContent += part; + + // Load outgoing chatResponse edges with the part + for (const edge of chatResponseEdges) { + edge.load({ + content: part ?? "", + request: request, + }); + } + } + + // Load outgoing chatResponse edges with the message "END OF STREAM" + for (const edge of chatResponseEdges) { + edge.load({ + content: "END OF STREAM", + request: request, + }); + } + + // Add an assistant message to the messages array of the request + request.messages.push({ + role: "assistant", + content: messageContent, + }); + + // After the stream is done, load the outgoing edges + this.loadOutgoingEdges(messageContent, request); + } else { + const message = await this.run.callLLM(request); + + if (message instanceof Error) { + this.error(`Error calling LLM:\n${message.message}`); + return; + } + + if (!message) { + this.error(`Error calling LLM: no message returned.`); + return; + } + + request.messages.push(message); + + if (message.function_call?.arguments) { + if (message.function_call.name === "note_select") { + const args = JSON.parse(message.function_call.arguments); + + // Put double brackets around the note name + args.note = `[[${args.note}]]`; + + this.loadOutgoingEdges(args.note, request); + } else { + this.loadOutgoingEdges(message.content ?? "", request); + } + } else { + this.loadOutgoingEdges(message.content ?? "", request); + } + } + + this.completed(); + } + + async extractImages(message: GenericCompletionResponse, index: number): Promise { + const imageReferences: ImageReference[] = []; + const markdownImageRegex = /!\[.*?\]\((.*?)\)/g; + let match; + + while ((match = markdownImageRegex.exec(message.content)) !== null) { + imageReferences.push({ + url: match[1], + messageIndex: index, + }); + } + + if (this.run.fileManager) { + const imageExtensions = [".jpg", ".png", ".jpeg", ".gif", ".bmp", ".tiff", ".webp", ".svg", ".ico", ".jfif", ".avif"]; + // should match instances like ![[image.jpg]] + const fileImageRegex = new RegExp(`!\\[\\[([^\\]]+(${imageExtensions.join("|")}))\\]\\]`, "g"); + while ((match = fileImageRegex.exec(message.content)) !== null) { + // "image.jpg" + const fileName = match[1]; + + // get file somehow from the filename + const file = await this.run.fileManager.getFile(fileName, this.run.isMock); + + if (!file) { + continue; + } + + // turn file into base64 + let base64 = Buffer.from(file).toString('base64'); + base64 = `data:image/${fileName.split('.').pop()};base64,${base64}`; + + imageReferences.push({ + url: base64, + messageIndex: index, + }); + } + } + + return imageReferences; + } + + async createLLMRequest(): Promise { + const overrides = this.getConfig(GenericModelConfigSchema) as GenericModelConfig; + const config = this.run.llm?.getMergedConfig({ + configOverrides: overrides, + provider: (overrides.provider as SupportedProviders) ?? undefined + }); + invariant(config, "Config is undefined"); + + const messages = this.getPrependedMessages(); + + const newMessage = await this.getNewMessage(config.role); + + // Remove the role from the config + delete config.role; + + if (newMessage) { + messages.push(newMessage); + } + + const imageReferences = await Promise.all(messages.map(async (message, index) => { + return await this.extractImages(message, index); + })).then(ir => ir.flat()); + + const functions = this.getFunctions(messages); + + const function_call = + functions && functions.length > 0 + ? { name: functions[0].name } + : undefined; + + return { + messages: messages, + imageReferences: imageReferences, + ...config, + functions: + functions && functions.length > 0 ? functions : undefined, + function_call: function_call ? function_call : undefined, + }; + } + + getFunctions(messages: GenericCompletionResponse[]): GenericFunctionCall[] { + if ( + this.getOutgoingEdges().some( + (edge) => edge.edgeModifier === EdgeModifier.Note + ) + ) { + const noteNames = this.findNoteReferencesInMessages(messages); + return [this.run.createNoteNameFunction(noteNames)]; + } else { + return []; + } + } + + logDetails(): string { + return super.logDetails() + `Type: Call\n`; + } + + validate() { + super.validate(); + } +} \ No newline at end of file diff --git a/packages/cannoli-core/src/graph/objects/vertices/nodes/ContentNode.ts b/packages/cannoli-core/src/graph/objects/vertices/nodes/ContentNode.ts new file mode 100644 index 0000000..0bf1be3 --- /dev/null +++ b/packages/cannoli-core/src/graph/objects/vertices/nodes/ContentNode.ts @@ -0,0 +1,283 @@ +import { CannoliObject } from "src/graph/CannoliObject"; +import { ContentNodeType, GroupType, CannoliObjectStatus, EdgeType, EdgeModifier } from "src/graph"; +import { CannoliEdge } from "../../CannoliEdge"; +import { ChatResponseEdge } from "../../edges/ChatResponseEdge"; +import { LoggingEdge } from "../../edges/LoggingEdge"; +import { CannoliGroup } from "../CannoliGroup"; +import { CannoliNode, VersionedContent } from "../CannoliNode"; + +export class ContentNode extends CannoliNode { + reset(): void { + // If it's a standard content node or output node, reset the text and then call the super + if (this.type === ContentNodeType.StandardContent || this.type === ContentNodeType.Output) { + const name = this.getName(); + if (name !== null && this.type !== ContentNodeType.StandardContent) { + // Clear everything except the first line + this.setText(this.text.split("\n")[0]); + } else { + // Clear everything + this.setText(""); + } + } + + super.reset(); + } + + getName(content?: string): string | null { + let contentToCheck = content; + + if (!contentToCheck) { + contentToCheck = this.text; + } + + const firstLine = contentToCheck.split("\n")[0].trim(); + if ( + firstLine.startsWith("[") && + firstLine.endsWith("]") && + this.type !== ContentNodeType.StandardContent + ) { + try { + // Check if the first line is a valid JSON array + JSON.parse(firstLine); + return null; // If it's a valid JSON array, return null + } catch (e) { + // If it's not a valid JSON array, proceed to extract the name + return firstLine.substring(1, firstLine.length - 1); + } + } + return null; + } + + // Content is everything after the first line + getContentCheckName(content?: string): string { + let contentToCheck = content; + + if (!contentToCheck) { + contentToCheck = this.text; + } + + const name = this.getName(contentToCheck); + if (name !== null) { + const firstLine = contentToCheck.split("\n")[0]; + return contentToCheck.substring(firstLine.length + 1); + } + return this.text; + } + + editContentCheckName(newContent: string): void { + const name = this.getName(); + const firstLine = this.text.split("\n")[0]; + if (name !== null) { + const newFirstLine = newContent.split("\n")[0].trim(); + // Check if the first line of the new content matches the current name line + if (newFirstLine === firstLine.trim()) { + // Discard the first line of the new content + newContent = newContent.substring(newFirstLine.length).trim(); + } + this.setText(`${firstLine}\n${newContent}`); + } else { + this.setText(newContent); + } + } + + filterName(content: string): string { + const name = this.getName(content); + if (name !== null) { + const firstLine = content.split("\n")[0]; + return content.substring(firstLine.length + 1).trim(); + } + return content; + } + + async execute(): Promise { + this.executing(); + + let content = this.getWriteOrLoggingContent(); + + if (content === null) { + const variableValues = this.getVariableValues(false); + + // Get first variable value + if (variableValues.length > 0) { + content = variableValues[0].content || ""; + } + } + + if (content === null || content === undefined) { + content = await this.processReferences(); + } + + content = this.filterName(content); + + if (this.type === ContentNodeType.Output || this.type === ContentNodeType.Formatter || this.type === ContentNodeType.StandardContent) { + this.editContentCheckName(content); + this.loadOutgoingEdges(content); + } else { + this.loadOutgoingEdges(content); + } + + this.completed(); + } + + dependencyCompleted(dependency: CannoliObject): void { + // If the dependency is a logging edge not crossing out of a forEach group or a chatResponse edge, execute regardless of this node's status + if ( + (dependency instanceof LoggingEdge && + !dependency.crossingOutGroups.some((group) => { + const groupObject = this.graph[group]; + if (!(groupObject instanceof CannoliGroup)) { + throw new Error( + `Error on object ${groupObject.id}: object is not a group.` + ); + } + return groupObject.type === GroupType.ForEach; + })) || + dependency instanceof ChatResponseEdge + ) { + this.execute(); + } else if ( + this.allDependenciesComplete() && + this.status === CannoliObjectStatus.Pending + ) { + this.execute(); + } + } + + logDetails(): string { + return super.logDetails() + `Type: Content\n`; + } + + getWriteOrLoggingContent(): string | null { + // Get all incoming edges + const incomingEdges = this.getIncomingEdges(); + + // If there are multiple logging edges + if ( + incomingEdges.filter((edge) => edge.type === EdgeType.Logging) + .length > 1 + ) { + // Append the content of all logging edges + let content = ""; + for (const edge of incomingEdges) { + const edgeObject = this.graph[edge.id]; + if (edgeObject instanceof LoggingEdge) { + if (edgeObject.content !== null) { + content += edgeObject.content; + } + } + } + + return content; + } + + // Filter for incoming complete edges of type write, logging, or chatResponse, as well as edges with no text + let filteredEdges = incomingEdges.filter( + (edge) => + (edge.type === EdgeType.Write || + edge.type === EdgeType.Logging || + edge.type === EdgeType.ChatResponse || + edge.text.length === 0) && + this.graph[edge.id].status === CannoliObjectStatus.Complete + ); + + // Remove all edges with a vault modifier of type folder or property + filteredEdges = filteredEdges.filter( + (edge) => + edge.edgeModifier !== EdgeModifier.Folder && + edge.edgeModifier !== EdgeModifier.Property + ); + + if (filteredEdges.length === 0) { + return null; + } + + // Check for edges with versions + const edgesWithVersions = filteredEdges.filter( + (edge) => { + const edgeObject = this.graph[edge.id]; + return edgeObject instanceof CannoliEdge && edgeObject.versions && edgeObject.versions.length > 0; + } + ); + + if (edgesWithVersions.length > 0) { + const allVersions: VersionedContent[] = []; + for (const edge of edgesWithVersions) { + const edgeObject = this.graph[edge.id] as CannoliEdge; + if (edgeObject.content !== null) { + allVersions.push({ + content: edgeObject.content as string, + versionArray: edgeObject.versions as { header: string | null; subHeader: string | null; }[] + }); + } + } + + const modifier = edgesWithVersions[0].edgeModifier; + + let fromFormatterNode = false; + + if (this.graph[edgesWithVersions[0].source].type === ContentNodeType.Formatter) { + fromFormatterNode = true; + } + + const mergedContent = this.renderMergedContent(allVersions, modifier, fromFormatterNode, edgesWithVersions[0].text); + + if (mergedContent) { + return mergedContent; + } + } + + // If there are write or chatResponse edges, return the content of the first one + const firstEdge = filteredEdges[0]; + const firstEdgeObject = this.graph[firstEdge.id]; + if (firstEdgeObject instanceof CannoliEdge) { + if ( + firstEdgeObject.content !== null && + typeof firstEdgeObject.content === "string" + ) { + return firstEdgeObject.content; + } + } else { + throw new Error( + `Error on object ${firstEdgeObject.id}: object is not an edge.` + ); + } + + return null; + } + + isValidVariableName(name: string): boolean { + // Regular expression to match valid JavaScript variable names + const validNamePattern = /^[a-zA-Z_$][a-zA-Z_$0-9]*$/; + // Check if the name matches the pattern + return validNamePattern.test(name) + } + + isReservedKeyword(name: string): boolean { + const reservedKeywords = [ + "break", "case", "catch", "class", "const", "continue", "debugger", "default", "delete", "do", "else", "enum", "export", "extends", "false", "finally", "for", "function", "if", "import", "in", "instanceof", "new", "null", "return", "super", "switch", "this", "throw", "true", "try", "typeof", "var", "void", "while", "with", "yield", "let", "static", "implements", "interface", "package", "private", "protected", "public" + ]; + return reservedKeywords.includes(name); + } + + validate(): void { + super.validate(); + + if (this.type === ContentNodeType.Input || this.type === ContentNodeType.Output) { + const name = this.getName(); + if (name !== null) { + if (!this.isValidVariableName(name)) { + this.error(`"${name}" is not a valid variable name. Input and output node names must start with a letter, underscore, or dollar sign, and can only contain letters, numbers, underscores, or dollar signs.`); + } + if (this.isReservedKeyword(name)) { + this.error(`"${name}" is a reserved keyword, and cannot be used as an input or output node name.`); + } + + if (this.type === ContentNodeType.Output) { + if (this.getGroups().some((group) => group.fromForEach)) { + this.error(`Named output nodes cannot be inside of parallel groups.`); + } + } + } + } + } +} \ No newline at end of file diff --git a/packages/cannoli-core/src/graph/objects/vertices/nodes/call/ChooseNode.ts b/packages/cannoli-core/src/graph/objects/vertices/nodes/call/ChooseNode.ts new file mode 100644 index 0000000..a29acbf --- /dev/null +++ b/packages/cannoli-core/src/graph/objects/vertices/nodes/call/ChooseNode.ts @@ -0,0 +1,95 @@ +import { EdgeType } from "src/graph"; +import { CannoliEdge } from "src/graph/objects/CannoliEdge"; +import { GenericCompletionResponse, GenericFunctionCall, GenericCompletionParams } from "src/providers"; +import { CallNode } from "../CallNode"; + +export class ChooseNode extends CallNode { + getFunctions(messages: GenericCompletionResponse[]): GenericFunctionCall[] { + const choices = this.getBranchChoices(); + + // Create choice function + const choiceFunc = this.run.createChoiceFunction(choices); + + return [choiceFunc]; + } + + loadOutgoingEdges(content: string, request: GenericCompletionParams): void { + const messages = request.messages; + + // Get the chosen variable from the last message + const lastMessage = messages[messages.length - 1]; + const choiceFunctionArgs = + "function_call" in lastMessage && + lastMessage.function_call?.arguments; + + if (!choiceFunctionArgs) { + this.error(`Choice function call has no arguments.`); + return; + } + + const parsedVariable = JSON.parse(choiceFunctionArgs); + + // Reject all unselected options + this.rejectUnselectedOptions(parsedVariable.choice); + + super.loadOutgoingEdges(choiceFunctionArgs, request); + } + + rejectUnselectedOptions(choice: string) { + // Call reject on any outgoing edges that aren't the selected one + for (const edge of this.outgoingEdges) { + const edgeObject = this.graph[edge]; + if (edgeObject.type === EdgeType.Choice) { + const branchEdge = edgeObject as CannoliEdge; + if (branchEdge.text !== choice) { + branchEdge.reject(); + } + } + } + } + + getBranchChoices(): string[] { + // Get the unique names of all outgoing choice edges + const outgoingChoiceEdges = this.getOutgoingEdges().filter((edge) => { + return edge.type === EdgeType.Choice; + }); + + const uniqueNames = new Set(); + + for (const edge of outgoingChoiceEdges) { + const edgeObject = this.graph[edge.id]; + if (!(edgeObject instanceof CannoliEdge)) { + throw new Error( + `Error on object ${edgeObject.id}: object is not a branch edge.` + ); + } + + const name = edgeObject.text; + + if (name) { + uniqueNames.add(name); + } + } + + return Array.from(uniqueNames); + } + + logDetails(): string { + return super.logDetails() + `Subtype: Choice\n`; + } + + validate() { + super.validate(); + + // If there are no branch edges, error + if ( + !this.getOutgoingEdges().some( + (edge) => edge.type === EdgeType.Choice + ) + ) { + this.error( + `Choice nodes must have at least one outgoing choice edge.` + ); + } + } +} \ No newline at end of file diff --git a/packages/cannoli-core/src/graph/objects/vertices/nodes/call/FormNode.ts b/packages/cannoli-core/src/graph/objects/vertices/nodes/call/FormNode.ts new file mode 100644 index 0000000..c55d764 --- /dev/null +++ b/packages/cannoli-core/src/graph/objects/vertices/nodes/call/FormNode.ts @@ -0,0 +1,124 @@ +import { EdgeModifier, EdgeType } from "src/graph"; +import { CannoliEdge } from "src/graph/objects/CannoliEdge"; +import { GenericCompletionResponse, GenericFunctionCall, GenericCompletionParams } from "src/providers"; +import { CallNode } from "../CallNode"; + +export class FormNode extends CallNode { + getFunctions( + messages: GenericCompletionResponse[] + ): GenericFunctionCall[] { + // Get the names of the fields + const fields = this.getFields(); + + const fieldsWithNotes: { name: string; noteNames?: string[] }[] = []; + + // If one of the outgoing edges has a vault modifier of type "note", get the note names and pass it into that field + const noteEdges = this.getOutgoingEdges().filter( + (edge) => edge.edgeModifier === EdgeModifier.Note + ); + + for (const item of fields) { + // If the item matches the name of one of the note edges + if (noteEdges.find((edge) => edge.text === item)) { + // Get the note names + const noteNames = this.findNoteReferencesInMessages(messages); + + fieldsWithNotes.push({ name: item, noteNames: noteNames }); + } else { + fieldsWithNotes.push({ name: item }); + } + } + + // Generate the form function + const formFunc = this.run.createFormFunction(fieldsWithNotes); + + return [formFunc]; + } + + getFields(): string[] { + // Get the unique names of all outgoing field edges + const outgoingFieldEdges = this.getOutgoingEdges().filter((edge) => { + return edge.type === EdgeType.Field; + }); + + const uniqueNames = new Set(); + + for (const edge of outgoingFieldEdges) { + const edgeObject = this.graph[edge.id]; + if (!(edgeObject instanceof CannoliEdge)) { + throw new Error( + `Error on object ${edgeObject.id}: object is not a field edge.` + ); + } + + const name = edgeObject.text; + + if (name) { + uniqueNames.add(name); + } + } + + return Array.from(uniqueNames); + } + + loadOutgoingEdges(content: string, request: GenericCompletionParams): void { + const messages = request.messages; + + // Get the fields from the last message + const lastMessage = messages[messages.length - 1]; + const formFunctionArgs = + "function_call" in lastMessage && + lastMessage.function_call?.arguments; + + if (!formFunctionArgs) { + this.error(`Form function call has no arguments.`); + return; + } + + // Parse the fields from the arguments + const fields = JSON.parse(formFunctionArgs); + + for (const edge of this.outgoingEdges) { + const edgeObject = this.graph[edge]; + if (edgeObject instanceof CannoliEdge) { + // If the edge is a field edge, load it with the content of the corresponding field + if ( + edgeObject instanceof CannoliEdge && + edgeObject.type === EdgeType.Field + ) { + const name = edgeObject.text; + + if (name) { + const fieldContent = fields[name]; + + if (fieldContent) { + // If it has a note modifier, add double brackets around the note name + if ( + edgeObject.edgeModifier === EdgeModifier.Note + ) { + edgeObject.load({ + content: `[[${fieldContent}]]`, + request: request, + }); + } else { + edgeObject.load({ + content: fieldContent, + request: request, + }); + } + } + } + } else { + edgeObject.load({ + content: formFunctionArgs, + request: request, + }); + } + } + } + } + + logDetails(): string { + return super.logDetails() + `Subtype: Form\n`; + } +} \ No newline at end of file diff --git a/packages/cannoli-core/src/graph/objects/vertices/nodes/content/FormatterNode.ts b/packages/cannoli-core/src/graph/objects/vertices/nodes/content/FormatterNode.ts new file mode 100644 index 0000000..287e5ce --- /dev/null +++ b/packages/cannoli-core/src/graph/objects/vertices/nodes/content/FormatterNode.ts @@ -0,0 +1,21 @@ +import { ContentNode } from "../ContentNode"; + +export class FormatterNode extends ContentNode { + logDetails(): string { + return super.logDetails() + `Subtype: Formatter\n`; + } + + async execute(): Promise { + this.executing(); + + const content = await this.processReferences(); + + // Take off the first 2 and last 2 characters (the double double quotes) + const processedContent = content.slice(2, -2); + + // Load all outgoing edges + this.loadOutgoingEdges(processedContent); + + this.completed(); + } +} \ No newline at end of file diff --git a/packages/cannoli-core/src/graph/objects/vertices/nodes/content/HttpNode.ts b/packages/cannoli-core/src/graph/objects/vertices/nodes/content/HttpNode.ts new file mode 100644 index 0000000..01e79c5 --- /dev/null +++ b/packages/cannoli-core/src/graph/objects/vertices/nodes/content/HttpNode.ts @@ -0,0 +1,588 @@ +import { VerifiedCannoliCanvasFileData, VerifiedCannoliCanvasLinkData, VerifiedCannoliCanvasTextData, VerifiedCannoliCanvasData, AllVerifiedCannoliCanvasNodeData } from "src/graph"; +import { ReceiveInfo, Action, ActionResponse, ActionArgs, HttpRequest, HttpTemplate } from "src/run"; +import { z } from "zod"; +import { ContentNode } from "../ContentNode"; +import { FloatingNode } from "../../../FloatingNode"; + +export const HTTPConfigSchema = z.object({ + url: z.string().optional(), + method: z.string().optional(), + headers: z.string().optional(), + catch: z.coerce.boolean().optional(), + timeout: z.coerce.number().optional(), +}).passthrough(); + +export type HttpConfig = z.infer; + +// Default http config +export const defaultHttpConfig: HttpConfig = { + catch: true, + timeout: 30000, +}; + +export class HttpNode extends ContentNode { + receiveInfo: ReceiveInfo | undefined; + + constructor( + nodeData: + | VerifiedCannoliCanvasFileData + | VerifiedCannoliCanvasLinkData + | VerifiedCannoliCanvasTextData, + fullCanvasData: VerifiedCannoliCanvasData + ) { + super(nodeData, fullCanvasData); + this.receiveInfo = nodeData.cannoliData.receiveInfo + } + + setReceiveInfo(info: ReceiveInfo) { + this.receiveInfo = info; + const data = this.canvasData.nodes.find((node) => node.id === this.id) as AllVerifiedCannoliCanvasNodeData; + data.cannoliData.receiveInfo = info; + } + + logDetails(): string { + return super.logDetails() + `Subtype: Http\n`; + } + + private async prepareAndExecuteAction( + action: Action, + namedActionContent: string | null, + config: HttpConfig, + isLongAction: boolean = false + ): Promise { + const { args: argNames, optionalArgs } = this.getActionArgs(action); + + const variableValues = this.getVariableValues(true); + + let isFirstArg = true; + + // Create an object to hold the argument values + const args: ActionArgs = {}; + + // Get the value for each arg name from the variables, and error if any arg is missing + for (const argName of argNames) { + // If the arg has argInfo and its category is extra, skip + if (action.argInfo && action.argInfo[argName] && action.argInfo[argName].category === "extra") { + continue; + } + + // If the arg has an argInfo and its category is files, give it the filesystem interface + if (action.argInfo && action.argInfo[argName] && action.argInfo[argName].category === "fileManager") { + // If the filesystemInterface is null, error + if (!this.run.fileManager) { + return new Error(`The action "${action.name}" requires a file interface, but there isn't one in this run.`); + } + args[argName] = this.run.fileManager; + continue; + } + + // If the arg has an argInfo and its category is fetcher, give it the responseTextFetcher + if (action.argInfo && action.argInfo[argName] && action.argInfo[argName].category === "fetcher") { + args[argName] = this.run.fetcher; + continue; + } + + // If the argName is in the configKeys, get the value from the config + if (action.argInfo && action.argInfo[argName] && action.argInfo[argName].category === "config") { + // Error if the config is not set + if (!config[argName] && !optionalArgs[argName]) { + return new Error( + `Missing value for config parameter "${argName}" in available config. This action "${action.name}" accepts the following config keys:\n${Object.keys(action.argInfo) + .filter((arg) => action.argInfo?.[arg].category === "config") + .map((arg) => ` - ${arg} ${optionalArgs[arg] ? '(optional)' : ''}`) + .join('\n')}` + ); + } + + if (config[argName]) { + if (action.argInfo[argName].type === "number") { + args[argName] = this.coerceValue(config[argName] as string, argName, action.argInfo[argName].type); + } else { + args[argName] = config[argName] as string; + } + } + continue; + } + + // If the argName is in the secretKeys, get the value from the config + if (action.argInfo && action.argInfo[argName] && action.argInfo[argName].category === "secret") { + // Error if the secret is not set + if (!config[argName] && !optionalArgs[argName]) { + return new Error( + `Missing value for secret parameter "${argName}" in the cannoli environment.\n\nYou can set these in the "Secrets" section of the Cannoli settings. LLM provider and Val Town keys are currently pulled from their respective settings.\n\nThis action "${action.name}" accepts the following secret keys:\n${Object.keys(action.argInfo) + .filter((arg) => action.argInfo?.[arg].category === "secret") + .map((arg) => ` - ${arg} ${optionalArgs[arg] ? '(optional)' : ''}`) + .join('\n')}` + ); + } + + if (config[argName]) { + if (action.argInfo[argName].type === "number") { + args[argName] = this.coerceValue(config[argName] as string, argName, action.argInfo[argName].type); + } else { + args[argName] = config[argName] as string; + } + } + continue; + } + + if (isFirstArg && namedActionContent) { + isFirstArg = false; + + if (action.argInfo && action.argInfo[argName] && action.argInfo[argName].type) { + args[argName] = this.coerceValue(namedActionContent, argName, action.argInfo[argName].type); + } else { + args[argName] = namedActionContent; + } + continue; + } + + const variableValue = variableValues.find((variableValue) => variableValue.name === argName); + if (!variableValue && !optionalArgs[argName]) { + return new Error( + `Missing value for variable "${argName}" in available arrows. This action "${action.name}" accepts the following variables:\n${argNames + .map((arg) => ` - ${arg} ${optionalArgs[arg] ? '(optional)' : ''}`) + .join('\n')}` + ); + } + + if (variableValue) { + if (action.argInfo && action.argInfo[argName] && action.argInfo[argName].type) { + args[argName] = this.coerceValue(variableValue.content || "", argName, action.argInfo[argName].type); + } else { + args[argName] = variableValue.content || ""; + } + } + } + + const extraArgs: Record = {}; + + // Collect extra arguments + for (const variableValue of variableValues) { + if (!argNames.includes(variableValue.name)) { + extraArgs[variableValue.name] = variableValue.content || ""; + } + } + + // If the action has an "extra" category, add the extraArgs to the args + if (action.argInfo) { + for (const [argName, argInfo] of Object.entries(action.argInfo)) { + if (argInfo.category === "extra") { + args[argName] = extraArgs; + } + } + } + + if (this.run.isMock) { + if (isLongAction) { + return { content: "This is a mock response" }; + } + + if (action.resultKeys) { + // Make an object with the keys and values + const result = action.resultKeys.reduce>((acc, key) => { + acc[key] = "This is a mock response"; + return acc; + }, {}); + return result; + } + + return "This is a mock response"; + } + + return await action.function(args); + } + + private coerceValue(value: string, argName: string, type: "number" | "boolean" | "string" | string[] | undefined): number | boolean | string { + if (type === "number") { + const numberValue = parseFloat(value); + if (isNaN(numberValue)) { + this.error(`Invalid number value: "${value}" for variable: "${argName}"`); + } + return numberValue; + } else if (type === "boolean") { + if (value !== "true" && value !== "false") { + this.error(`Invalid boolean value: "${value}" for variable: "${argName}"`); + } + return value === "true"; + } else if (Array.isArray(type)) { + if (!type.includes(value)) { + this.error(`Invalid value: "${value}" for variable: "${argName}". Expected one of:\n${type.map((t) => ` - ${t}`).join("\n")}`); + } + return value; + } else { + return value; + } + } + + coerceActionResponseToString(result: ActionResponse): string | Error { + if (result === undefined || result === null) { + return ""; + } + if (result instanceof Error) { + return result; + } + else if (typeof result === "string") { + return result; + } + else if (Array.isArray(result)) { + return JSON.stringify(result); + } + else if (typeof result === "object") { + const objectKeys = Object.keys(result); + + // Check if there are any outgoing edges whose text isn't a key in the object + const outgoingEdgeNames = this.outgoingEdges.map((edge) => this.graph[edge].text); + const keysNotInObject = outgoingEdgeNames.filter((name) => !objectKeys.includes(name)); + + // If there are, error + if (keysNotInObject.length > 0) { + return new Error(`This action returns multiple variables, but there are outgoing arrows that don't match any names of the variables. The variables are: ${objectKeys.join(", ")}. The incorrect outgoing arrows are: ${keysNotInObject.join(", ")}.`); + } + + return JSON.stringify(result); + } + + return new Error(`Action returned an unknown type: ${typeof result}.`); + } + + async execute(): Promise { + const overrides = this.getConfig(HTTPConfigSchema) as HttpConfig; + if (overrides instanceof Error) { + this.error(overrides.message); + return; + } + + const config = { ...this.run.config, ...this.run.secrets, ...defaultHttpConfig, ...overrides }; + + this.executing(); + + const content = await this.processReferences([], true); + + let maybeActionName = this.getName(content); + let namedActionContent = null; + + if (maybeActionName !== null) { + maybeActionName = maybeActionName.toLowerCase().trim(); + namedActionContent = this.getContentCheckName(content); + } else { + maybeActionName = content.toLowerCase().trim(); + } + + if (this.run.actions !== undefined && this.run.actions.length > 0) { + const action = this.run.actions.find((action) => action.name.toLowerCase().trim() === maybeActionName); + + if (action) { + let actionResponse: ActionResponse; + + if (action.receive && this.receiveInfo) { + actionResponse = await this.handleReceiveFunction(action); + } else { + actionResponse = await this.prepareAndExecuteAction(action, namedActionContent, config); + + if (actionResponse instanceof Error) { + if (config.catch) { + this.error(actionResponse.message); + return; + } else { + actionResponse = actionResponse.message; + } + } + + if (action.receive) { + this.setReceiveInfo(actionResponse ?? {}); + actionResponse = await this.handleReceiveFunction(action); + } else { + actionResponse = this.coerceActionResponseToString(actionResponse); + } + } + + if (actionResponse instanceof Error) { + this.error(actionResponse.message); + return; + } + + this.loadOutgoingEdges(actionResponse); + this.completed(); + return; + } + } + + const request = this.parseContentToRequest(content, config); + if (request instanceof Error) { + this.error(request.message); + return; + } + + let response = await this.run.executeHttpRequest(request, config.timeout as number); + + if (response instanceof Error) { + if (config.catch) { + this.error(response.message); + return; + } + response = response.message; + } + + this.loadOutgoingEdges(response); + this.completed(); + } + + private async handleReceiveFunction(action: Action): Promise { + let receiveResponse: string | Error; + + if (this.run.isMock) { + if (action.resultKeys) { + const result = action.resultKeys.reduce>((acc, key) => { + acc[key] = "This is a mock response"; + return acc; + }, {}); + receiveResponse = this.coerceActionResponseToString(result); + } else { + receiveResponse = "This is a mock response"; + } + } else { + const result = await action.receive!(this.receiveInfo!); + receiveResponse = this.coerceActionResponseToString(result); + } + + return receiveResponse; + } + + + getActionArgs(action: Action): { args: string[], optionalArgs: Record } { + const stringifiedFn = action.function.toString(); + // Match the function body to find the destructured object keys + const argsMatch = stringifiedFn.match(/\(\s*{([^}]*)}\s*\)\s*=>/); + const args = argsMatch ? argsMatch[1] : ""; + + const requiredArgs = args ? args.split(',').filter(arg => !arg.includes('=')).map((arg: string) => arg.trim()) : []; + const optionalArgs = args ? args.split(',').filter(arg => arg.includes('=')).map((arg: string) => arg.trim().split("=")[0].trim()) : []; + const optionalArgsObject: Record = {}; + optionalArgs.forEach(arg => optionalArgsObject[arg] = true); + + return { args: [...requiredArgs, ...optionalArgs], optionalArgs: optionalArgsObject }; + } + + private parseContentToRequest(content: string, config: HttpConfig): HttpRequest | Error { + // If the url config is set, look for the method and headers, and interpret the content as the body + if (config.url) { + const request: HttpRequest = { + url: config.url, + method: config.method || "POST", + headers: config.headers, + body: content, + }; + return request; + } + + // If the content is wrapped in triple backticks with or without a language identifier, remove them + content = content.replace(/^```[^\n]*\n([\s\S]*?)\n```$/, '$1').trim(); + + if (typeof content === "string" && (content.startsWith("http://") || content.startsWith("https://"))) { + return { url: content, method: "GET" }; + } + + try { + const request = JSON.parse(content); + + // Evaluate the request + try { + // Check that the template has a url and method + if (!request.url || !request.method) { + return new Error(`Request is missing a URL or method.`); + } + + if (request.headers && typeof request.headers !== "string") { + request.headers = JSON.stringify(request.headers); + } + + if (request.body && typeof request.body !== "string") { + request.body = JSON.stringify(request.body); + } + + return request; + } catch (e) { + return new Error(`Action node does not have a valid HTTP request.`); + } + + + } catch (e) { + // Continue to next parsing method + } + + const variables = this.getVariables(); + const template = this.getTemplate(content); + if (template instanceof Error) { + return template; + } + + const request = this.convertTemplateToRequest(template, variables); + if (request instanceof Error) { + return request; + } + + return request; + } + + private getVariables(): string | Record | null { + let variables: string | Record | null = null; + + const variableValues = this.getVariableValues(false); + if (variableValues.length > 0) { + variables = {}; + for (const variableValue of variableValues) { + variables[variableValue.name] = variableValue.content || ""; + } + + } + + return variables; + } + + private getTemplate(name: string): HttpTemplate | Error { + for (const objectId in this.graph) { + const object = this.graph[objectId]; + if (object instanceof FloatingNode && object.getName() === name) { + // If the text is wrapped in triple backticks with or without a language identifier, remove them + const text = object.getContent().replace(/^```[^\n]*\n([\s\S]*?)\n```$/, '$1').trim(); + + try { + const template = JSON.parse(text) as HttpTemplate; + + // Check that the template has a url and method + if (!template.url || !template.method) { + return new Error(`Floating node "${name}" does not have a valid HTTP template.`); + } + + if (template.headers && typeof template.headers !== "string") { + template.headers = JSON.stringify(template.headers); + } + + const bodyValue = template.body ?? template.bodyTemplate; + + if (bodyValue && typeof bodyValue !== "string") { + template.body = JSON.stringify(bodyValue); + } + + return template; + } catch (e) { + return new Error(`Floating node "${name}" could not be parsed as an HTTP template.`); + } + + } + } + + const settingsTemplate = this.run.httpTemplates.find((template) => template.name === name); + if (!settingsTemplate) { + return new Error(`Could not get HTTP template with name "${name}" from floating nodes or pre-set templates.`); + } + + return settingsTemplate; + } + + private convertTemplateToRequest( + template: HttpTemplate, + variables: string | Record | null + ): HttpRequest | Error { + const url = this.replaceVariables(template.url, variables); + if (url instanceof Error) return url; + + const method = this.replaceVariables(template.method, variables); + if (method instanceof Error) return method; + + let headers: string | Error | undefined; + if (template.headers) { + headers = this.replaceVariables(template.headers, variables); + if (headers instanceof Error) return headers; + } + + const bodyTemplate = template.body ?? template.bodyTemplate; + let body: string | Error = ""; + + if (bodyTemplate) { + body = this.parseBodyTemplate(bodyTemplate, variables || ""); + if (body instanceof Error) { + return body; + } + } + + return { + url, + method, + headers: headers ? headers as string : undefined, + body: method.toLowerCase() !== "get" ? body : undefined, + }; + } + + private replaceVariables(template: string, variables: string | Record | null): string | Error { + template = String(template); + + const variablesInTemplate = (template.match(/\{\{.*?\}\}/g) || []).map( + (v) => v.slice(2, -2) + ); + + if (typeof variables === "string") { + return template.replace(/{{.*?}}/g, variables); + } + + if (variables && typeof variables === "object") { + for (const variable of variablesInTemplate) { + if (!(variable in variables)) { + return new Error( + `Missing value for variable "${variable}" in available arrows. This part of the template requires the following variables:\n${variablesInTemplate + .map((v) => ` - ${v}`) + .join("\n")}` + ); + } + template = template.replace(new RegExp(`{{${variable}}}`, "g"), variables[variable]); + } + } + + return template; + } + + private parseBodyTemplate( + template: string, + body: string | Record + ): string | Error { + template = String(template); + + const variablesInTemplate = (template.match(/\{\{.*?\}\}/g) || []).map( + (v) => v.slice(2, -2) + ); + + let parsedTemplate = template; + + if (typeof body === "object") { + for (const variable of variablesInTemplate) { + if (!(variable in body)) { + return new Error( + `Missing value for variable "${variable}" in available arrows. This body template requires the following variables:\n${variablesInTemplate + .map((v) => ` - ${v}`) + .join("\n")}` + ); + } + parsedTemplate = parsedTemplate.replace( + new RegExp(`{{${variable}}}`, "g"), + body[variable].replace(/\\/g, '\\\\') + .replace(/\n/g, "\\n") + .replace(/"/g, '\\"') + .replace(/\t/g, '\\t') + ); + } + } else { + for (const variable of variablesInTemplate) { + parsedTemplate = parsedTemplate.replace( + new RegExp(`{{${variable}}}`, "g"), + body.replace(/\\/g, '\\\\') + .replace(/\n/g, "\\n") + .replace(/"/g, '\\"') + .replace(/\t/g, '\\t') + ); + } + } + + return parsedTemplate; + } +} \ No newline at end of file diff --git a/packages/cannoli-core/src/graph/objects/vertices/nodes/content/ReferenceNode.ts b/packages/cannoli-core/src/graph/objects/vertices/nodes/content/ReferenceNode.ts new file mode 100644 index 0000000..f29ca11 --- /dev/null +++ b/packages/cannoli-core/src/graph/objects/vertices/nodes/content/ReferenceNode.ts @@ -0,0 +1,606 @@ +import { Reference, VerifiedCannoliCanvasTextData, VerifiedCannoliCanvasLinkData, VerifiedCannoliCanvasFileData, VerifiedCannoliCanvasData, EdgeModifier, EdgeType, ReferenceType } from "src/graph"; +import { CannoliEdge } from "src/graph/objects/CannoliEdge"; +import { ChatResponseEdge } from "src/graph/objects/edges/ChatResponseEdge"; +import { GenericCompletionParams } from "src/providers"; +import { ContentNode } from "../ContentNode"; +import { FloatingNode } from "../../../FloatingNode"; + +export class ReferenceNode extends ContentNode { + reference: Reference; + + constructor( + nodeData: + | VerifiedCannoliCanvasTextData + | VerifiedCannoliCanvasLinkData + | VerifiedCannoliCanvasFileData, + fullCanvasData: VerifiedCannoliCanvasData + ) { + super(nodeData, fullCanvasData); + + if (this.references.length !== 1) { + this.error(`Could not find reference.`); + } else { + this.reference = this.references[0]; + } + } + + async execute(): Promise { + this.executing(); + + let content: string | null = null; + + const writeOrLoggingContent = this.getWriteOrLoggingContent(); + + const variableValues = this.getVariableValues(false); + + if (variableValues.length > 0) { + // First, get the edges of the variable values + const variableValueEdges = variableValues.map((variableValue) => { + return this.graph[variableValue.edgeId ?? ""] as CannoliEdge; + }); + + // Then, filter out the edges that have the same name as the reference, or are of type folder or property + const filteredVariableValueEdges = variableValueEdges.filter( + (variableValueEdge) => { + return ( + variableValueEdge.text !== this.reference.name && + variableValueEdge.edgeModifier !== + EdgeModifier.Folder && + variableValueEdge.edgeModifier !== + EdgeModifier.Property + ); + } + ); + + // Then, filter the variable values by the filtered edges + const filteredVariableValues = variableValues.filter( + (variableValue) => { + return filteredVariableValueEdges.some( + (filteredVariableValueEdge) => { + return ( + filteredVariableValueEdge.id === + variableValue.edgeId + ); + } + ); + } + ); + + if (filteredVariableValues.length > 0) { + // Then, get the content of the first variable value + content = filteredVariableValues[0].content; + } else if (writeOrLoggingContent !== null) { + content = writeOrLoggingContent; + } + } else if (writeOrLoggingContent !== null) { + content = writeOrLoggingContent; + } + + // Get the property edges + const propertyEdges = this.getIncomingEdges().filter( + (edge) => + edge.edgeModifier === EdgeModifier.Property && + edge.text !== this.reference.name + ); + + if (content !== null) { + // Append is dependent on if there is an incoming edge of type ChatResponse + const append = this.getIncomingEdges().some( + (edge) => edge.type === EdgeType.ChatResponse + ); + + if ( + this.reference.type === ReferenceType.CreateNote || + (this.reference.type === ReferenceType.Variable && + this.reference.shouldExtract) + ) { + await this.processDynamicReference(content); + } else { + await this.editContent(content, append); + + // If there are property edges, edit the properties + if (propertyEdges.length > 0) { + for (const edge of propertyEdges) { + if ( + edge.content === null || + edge.content === undefined || + typeof edge.content !== "string" + ) { + this.error(`Property arrow has invalid content.`); + return; + } + + await this.editProperty(edge.text, edge.content); + } + } + } + + // Load all outgoing edges + await this.loadOutgoingEdges(content); + } else { + if ( + this.reference.type === ReferenceType.CreateNote || + (this.reference.type === ReferenceType.Variable && + this.reference.shouldExtract) + ) { + await this.processDynamicReference(""); + + const fetchedContent = await this.getContent(); + await this.loadOutgoingEdges(fetchedContent); + } else { + const fetchedContent = await this.getContent(); + await this.loadOutgoingEdges(fetchedContent); + } + + // If there are property edges, edit the properties + if (propertyEdges.length > 0) { + for (const edge of propertyEdges) { + if ( + edge.content === null || + edge.content === undefined || + typeof edge.content !== "string" + ) { + this.error(`Property arrow has invalid content.`); + return; + } + + await this.editProperty(edge.text, edge.content); + } + } + } + + // Load all outgoing edges + this.completed(); + } + + async getContent(): Promise { + if (this.run.isMock) { + return `Mock content`; + } + + if (this.reference) { + if (this.reference.type === ReferenceType.Note) { + const content = await this.getContentFromNote(this.reference); + if (content !== null && content !== undefined) { + return content; + } else { + this.error( + `Invalid reference. Could not find note "${this.reference.name}".` + ); + } + } else if (this.reference.type === ReferenceType.Selection) { + const content = this.run.selection; + + if (content !== null && content !== undefined) { + return content; + } else { + this.error(`Invalid reference. Could not find selection.`); + } + } else if (this.reference.type === ReferenceType.Floating) { + const content = this.getContentFromFloatingNode( + this.reference.name + ); + if (content !== null) { + return content; + } else { + this.error( + `Invalid reference. Could not find floating node "${this.reference.name}".\n\nIf you want this node to inject a variable, turn it into a formatter node by wrapping the whole node in ""two sets of double quotes"".` + ); + } + } else if (this.reference.type === ReferenceType.Variable) { + const content = this.getContentFromFloatingNode( + this.reference.name + ); + if (content !== null) { + return content; + } else { + this.error( + `Invalid reference. Could not find floating node "${this.reference.name}".\n\nIf you want this node to inject a variable, turn it into a formatter node by wrapping the whole node in ""two sets of double quotes"".` + ); + } + } else if ( + this.reference.type === ReferenceType.CreateNote + ) { + this.error(`Dynamic reference did not process correctly.`); + } + } + + return `Could not find reference.`; + } + + async processDynamicReference(content: string) { + if (this.run.isMock) { + return; + } + + const incomingEdges = this.getIncomingEdges(); + + // Find the incoming edge with the same name as the reference name + const referenceNameEdge = incomingEdges.find( + (edge) => edge.text === this.reference.name + ); + + if (!referenceNameEdge) { + this.error(`Could not find arrow containing note name.`); + return; + } + + if ( + referenceNameEdge.content === null || + referenceNameEdge.content === undefined || + typeof referenceNameEdge.content !== "string" + ) { + this.error(`Note name arrow has invalid content.`); + return; + } + + // Look for an incoming edge with a vault modifier of type folder + const folderEdge = incomingEdges.find( + (edge) => edge.edgeModifier === EdgeModifier.Folder + ); + + let path = ""; + + if (folderEdge) { + if ( + folderEdge.content === null || + folderEdge.content === undefined || + typeof folderEdge.content !== "string" + ) { + this.error(`Folder arrow has invalid content.`); + return; + } + + path = folderEdge.content; + } + + // Look for incoming edges with a vault modifier of type property + const propertyEdges = incomingEdges.filter( + (edge) => + edge.edgeModifier === EdgeModifier.Property && + edge.text !== this.reference.name + ); + + // If this reference is a create note type, create the note + if (this.reference.type === ReferenceType.CreateNote) { + let noteName; + + // If there are properties edges, create a yaml frontmatter section, and fill it with the properties, where the key is the edge.text and the value is the edge.content + if (propertyEdges.length > 0) { + let yamlFrontmatter = "---\n"; + + for (const edge of propertyEdges) { + if ( + edge.content === null || + edge.content === undefined || + typeof edge.content !== "string" + ) { + this.error(`Property arrow has invalid content.`); + return; + } + + // If the edge.content is a list (starts with a dash), add a newline and two spaces, and replace all newlines with newlines and two spaces + if (edge.content.startsWith("-")) { + yamlFrontmatter += `${edge.text}: \n ${edge.content + .replace(/\n/g, "\n ") + .trim()}\n`; + } else { + yamlFrontmatter += `${edge.text}: "${edge.content}"\n`; + } + } + + yamlFrontmatter += "---\n"; + + content = yamlFrontmatter + content; + } + + try { + // If there's no fileSystemInterface, throw an error + if (!this.run.fileManager) { + throw new Error("No fileManager found"); + } + + noteName = await this.run.fileManager.createNoteAtExistingPath( + referenceNameEdge.content, + path, + content + ); + } catch (e) { + this.error(`Could not create note: ${e.message}`); + return; + } + + if (!noteName) { + this.error(`"${referenceNameEdge.content}" already exists.`); + } else { + this.reference.name = noteName; + this.reference.type = ReferenceType.Note; + } + } else { + // Transform the reference + this.reference.name = referenceNameEdge.content; + this.reference.type = ReferenceType.Note; + + // If content is not null, edit the note + if (content !== null) { + await this.editContent(content, false); + } + + // If there are property edges, edit the properties + if (propertyEdges.length > 0) { + for (const edge of propertyEdges) { + if ( + edge.content === null || + edge.content === undefined || + typeof edge.content !== "string" + ) { + this.error(`Property arrow has invalid content.`); + return; + } + + await this.editProperty(edge.text, edge.content); + } + } + } + } + + async editContent(newContent: string, append?: boolean): Promise { + if (this.run.isMock) { + return; + } + + if (this.reference) { + if (this.reference.type === ReferenceType.Note) { + // If there's no fileSystemInterface, throw an error + if (!this.run.fileManager) { + throw new Error("No fileManager found"); + } + + const edit = await this.run.fileManager.editNote( + this.reference, + newContent, + this.run.isMock, + append ?? false + ); + + if (edit !== null) { + return; + } else { + this.error( + `Invalid reference. Could not edit note ${this.reference.name}` + ); + } + } else if (this.reference.type === ReferenceType.Selection) { + // If there's no fileSystemInterface, throw an error + if (!this.run.fileManager) { + throw new Error("No fileManager found"); + } + + this.run.fileManager.editSelection(newContent, this.run.isMock); + return; + } else if (this.reference.type === ReferenceType.Floating) { + // Search through all nodes for a floating node with the correct name + for (const objectId in this.graph) { + const object = this.graph[objectId]; + if ( + object instanceof FloatingNode && + object.getName() === this.reference.name + ) { + object.editContent(newContent); + return; + } + } + + this.error( + `Invalid reference. Could not find floating node ${this.reference.name}.\n\nIf you want this node to inject a variable, turn it into a formatter node by wrapping it in ""two sets of double quotes"".` + ); + } else if ( + this.reference.type === ReferenceType.Variable) { + // Search through all nodes for a floating node with the correct name + for (const objectId in this.graph) { + const object = this.graph[objectId]; + if ( + object instanceof FloatingNode && + object.getName() === this.reference.name + ) { + object.editContent(newContent); + return; + } + } + + this.error( + `Invalid reference. Could not find floating node ${this.reference.name}.\n\nIf you want this node to inject a variable, turn it into a formatter node by wrapping it in ""two sets of double quotes"".` + ); + } else if ( + this.reference.type === ReferenceType.CreateNote + ) { + this.error(`Dynamic reference did not process correctly.`); + } + } + } + + async editProperty( + propertyName: string, + newContent: string + ): Promise { + if (this.run.isMock) { + return; + } + + if (this.reference) { + if (this.reference.type === ReferenceType.Note) { + // If there's no fileSystemInterface, throw an error + if (!this.run.fileManager) { + throw new Error("No fileManager found"); + } + + const edit = await this.run.fileManager.editPropertyOfNote( + this.reference.name, + propertyName, + newContent.trim() + ); + + if (edit !== null) { + return; + } else { + this.error( + `Invalid reference. Could not edit property ${propertyName} of note ${this.reference.name}` + ); + } + } else if (this.reference.type === ReferenceType.Floating) { + // Search through all nodes for a floating node with the correct name + + for (const objectId in this.graph) { + const object = this.graph[objectId]; + if ( + object instanceof FloatingNode && + object.getName() === this.reference.name + ) { + object.editProperty(propertyName, newContent.trim()); + return; + } + } + } else if ( + this.reference.type === ReferenceType.Variable || + this.reference.type === ReferenceType.CreateNote + ) { + this.error(`Dynamic reference did not process correctly.`); + } + } + } + + async loadOutgoingEdges( + content: string, + request?: GenericCompletionParams | undefined + ) { + // If this is a floating node, load all outgoing edges with the content + if (this.reference.type === ReferenceType.Floating) { + this.loadOutgoingEdgesFloating(content, request); + return; + } + + for (const edge of this.outgoingEdges) { + const edgeObject = this.graph[edge]; + if (!(edgeObject instanceof CannoliEdge)) { + continue; + } + + if (edgeObject.edgeModifier === EdgeModifier.Property) { + let value; + + if (edgeObject.text.length === 0) { + // If there's no fileSystemInterface, throw an error + if (!this.run.fileManager) { + throw new Error("No fileManager found"); + } + + value = await this.run.fileManager.getAllPropertiesOfNote( + this.reference.name, + true + ); + } else { + // If there's no fileSystemInterface, throw an error + if (!this.run.fileManager) { + throw new Error("No fileManager found"); + } + + // Get value of the property with the same name as the edge + value = await this.run.fileManager.getPropertyOfNote( + this.reference.name, + edgeObject.text, + true + ); + } + + if (value) { + edgeObject.load({ + content: value ?? "", + request: request, + }); + } + } else if (edgeObject.edgeModifier === EdgeModifier.Note) { + // Load the edge with the name of the note + edgeObject.load({ + content: `${this.reference.name}`, + request: request, + }); + } else if (edgeObject.edgeModifier === EdgeModifier.Folder) { + // If there's no fileSystemInterface, throw an error + if (!this.run.fileManager) { + throw new Error("No fileManager found"); + } + + const path = await this.run.fileManager.getNotePath( + this.reference.name + ); + + if (path) { + edgeObject.load({ + content: path, + request: request, + }); + } + } else if ( + edgeObject instanceof CannoliEdge && + !(edgeObject instanceof ChatResponseEdge) + ) { + edgeObject.load({ + content: content, + request: request, + }); + } + } + } + + loadOutgoingEdgesFloating( + content: string, + request?: GenericCompletionParams | undefined + ) { + for (const edge of this.outgoingEdges) { + const edgeObject = this.graph[edge]; + if (!(edgeObject instanceof CannoliEdge)) { + continue; + } + + // If the edge has a note modifier, load it with the name of the floating node + if (edgeObject.edgeModifier === EdgeModifier.Note) { + edgeObject.load({ + content: `${this.reference.name}`, + request: request, + }); + } else if (edgeObject.edgeModifier === EdgeModifier.Property) { + // Find the floating node with the same name as this reference + let propertyContent = ""; + + for (const objectId in this.graph) { + const object = this.graph[objectId]; + if ( + object instanceof FloatingNode && + object.getName() === this.reference.name + ) { + propertyContent = object.getProperty(edgeObject.text); + } + } + + if (propertyContent) { + edgeObject.load({ + content: propertyContent, + request: request, + }); + } + } else if ( + edgeObject instanceof CannoliEdge && + !(edgeObject instanceof ChatResponseEdge) + ) { + edgeObject.load({ + content: content, + request: request, + }); + } + } + } + + logDetails(): string { + return super.logDetails() + `Subtype: Reference\n`; + } +} \ No newline at end of file diff --git a/packages/cannoli-core/src/graph/objects/vertices/nodes/content/SearchNode.ts b/packages/cannoli-core/src/graph/objects/vertices/nodes/content/SearchNode.ts new file mode 100644 index 0000000..c673ff4 --- /dev/null +++ b/packages/cannoli-core/src/graph/objects/vertices/nodes/content/SearchNode.ts @@ -0,0 +1,62 @@ +import { EdgeType } from "src/graph"; +import { z } from "zod"; +import { ContentNode } from "../ContentNode"; +import { defaultHttpConfig, HttpConfig, HTTPConfigSchema } from "./HttpNode"; + +const SearchConfigSchema = z.object({ + limit: z.coerce.number().optional(), +}).passthrough(); + +export type SearchConfig = z.infer; + +export class SearchNode extends ContentNode { + logDetails(): string { + return super.logDetails() + `Subtype: Search\n`; + } + + async execute(): Promise { + const overrides = this.getConfig(HTTPConfigSchema) as HttpConfig; + if (overrides instanceof Error) { + this.error(overrides.message); + return; + } + + const config = { ...defaultHttpConfig, ...overrides }; + + this.executing(); + + const content = await this.processReferences([], true); + + if (this.run.isMock) { + this.loadOutgoingEdges("[Mock response]"); + this.completed(); + return; + } + + let output: string; + + const results = await this.search(content, config); + + if (results instanceof Error) { + if (config.catch) { + this.error(results.message); + return; + } + output = results.message; + } else { + // If there are any outgoing edges of type Item from this node, output should be a stringified json array + if (this.outgoingEdges.some((edge) => this.graph[edge].type === EdgeType.Item)) { + output = JSON.stringify(results); + } else { + output = results.join("\n\n"); + } + } + + this.loadOutgoingEdges(output); + this.completed(); + } + + async search(query: string, config: SearchConfig): Promise { + return new Error("Search nodes not implemented."); + } +} \ No newline at end of file diff --git a/packages/cannoli-core/src/index.ts b/packages/cannoli-core/src/index.ts index 1625207..2b78961 100644 --- a/packages/cannoli-core/src/index.ts +++ b/packages/cannoli-core/src/index.ts @@ -1,9 +1,9 @@ -export * from "./models/graph" +export * from "./graph" export * from "./persistor" export * from "./fileManager" export * from "./providers" export * from "./run" -export * from "./models/node" +export * from "./graph/objects/vertices/CannoliNode" export * from "./cannoli" export { bake, callCannoliFunction, parseCannoliFunctionInfo } from "./bake" export type { BakeLanguage, BakeRuntime, CannoliFunctionInfo, CannoliParamType, CannoliReturnType, BakeResult } from "./bake" diff --git a/packages/cannoli-core/src/models/edge.ts b/packages/cannoli-core/src/models/edge.ts deleted file mode 100644 index 247e119..0000000 --- a/packages/cannoli-core/src/models/edge.ts +++ /dev/null @@ -1,552 +0,0 @@ -import { CannoliObject, CannoliVertex } from "./object"; -import { CannoliGroup, RepeatGroup } from "./group"; -import { - CannoliObjectStatus, - EdgeModifier, - EdgeType, - VerifiedCannoliCanvasData, - VerifiedCannoliCanvasEdgeData, -} from "./graph"; -import { ChatRole } from "../run"; -import { - GenericCompletionParams, - GenericCompletionResponse, -} from "../providers"; - -const chatFormatString = `--- -# {{role}} - -{{content}}` - -export class CannoliEdge extends CannoliObject { - source: string; - target: string; - crossingInGroups: string[]; - crossingOutGroups: string[]; - isReflexive: boolean; - addMessages: boolean; - edgeModifier: EdgeModifier | null; - content: string | Record | null; - messages: GenericCompletionResponse[] | null; - versions: { - header: string | null, - subHeader: string | null, - }[] | null; - - constructor(edgeData: VerifiedCannoliCanvasEdgeData, fullCanvasData: VerifiedCannoliCanvasData) { - super(edgeData, fullCanvasData); - this.source = edgeData.fromNode; - this.target = edgeData.toNode; - this.crossingInGroups = edgeData.cannoliData.crossingInGroups; - this.crossingOutGroups = edgeData.cannoliData.crossingOutGroups; - this.isReflexive = edgeData.cannoliData.isReflexive; - this.addMessages = edgeData.cannoliData.addMessages; - this.edgeModifier = edgeData.cannoliData.edgeModifier - ? edgeData.cannoliData.edgeModifier - : null; - this.content = edgeData.cannoliData.content - ? edgeData.cannoliData.content - : null; - this.messages = edgeData.cannoliData.messages - ? edgeData.cannoliData.messages - : null; - this.versions = edgeData.cannoliData.versions - ? edgeData.cannoliData.versions - : null; - - // Overrwite the addMessages for certain types of edges - if ( - this.type === EdgeType.Chat || - this.type === EdgeType.SystemMessage || - this.type === EdgeType.ChatResponse || - this.type === EdgeType.ChatConverter - ) { - this.addMessages = true; - } - } - - getSource(): CannoliVertex { - return this.graph[this.source] as CannoliVertex; - } - - getTarget(): CannoliVertex { - return this.graph[this.target] as CannoliVertex; - } - - setContent(content: string | Record | undefined) { - this.content = content ?? ""; - const data = this.canvasData.edges.find((edge) => edge.id === this.id) as VerifiedCannoliCanvasEdgeData; - data.cannoliData.content = content ?? ""; - } - - setMessages(messages: GenericCompletionResponse[] | undefined) { - this.messages = messages ?? null; - const data = this.canvasData.edges.find((edge) => edge.id === this.id) as VerifiedCannoliCanvasEdgeData; - data.cannoliData.messages = messages; - } - - setVersionHeaders(index: number, header: string, subheader: string) { - if (this.versions) { - this.versions[index].header = header; - this.versions[index].subHeader = subheader; - - const data = this.canvasData.edges.find((edge) => edge.id === this.id) as VerifiedCannoliCanvasEdgeData; - data.cannoliData.versions = this.versions; - } - } - - load({ - content, - request, - }: { - content?: string | Record; - request?: GenericCompletionParams; - }): void { - // If there is a versions array - if (this.versions) { - let versionCount = 0; - for (const group of this.crossingOutGroups) { - const groupObject = this.graph[group] as CannoliGroup; - // Get the incoming item edge, if there is one - const itemEdge = groupObject.incomingEdges.find((edge) => this.graph[edge].type === EdgeType.Item); - if (itemEdge) { - // Get the item edge object - const itemEdgeObject = this.graph[itemEdge] as CannoliEdge; - - // Set the version header to the name of the list edge - this.setVersionHeaders(versionCount, itemEdgeObject.text, itemEdgeObject.content as string); - - versionCount++; - } - } - - } - - this.setContent(content); - - if (this.addMessages) { - this.setMessages( - request && request.messages ? request.messages : undefined); - } - } - - async execute(): Promise { - this.completed(); - } - - dependencyCompleted(dependency: CannoliObject): void { - if ( - this.allDependenciesComplete() && - this.status === CannoliObjectStatus.Pending - ) { - // console.log( - // `Executing edge with loaded content: ${ - // this.content - // } and messages:\n${JSON.stringify(this.messages, null, 2)}` - // ); - this.execute(); - } - } - - logDetails(): string { - // Build crossing groups string of the text of the crossing groups - let crossingGroupsString = ""; - crossingGroupsString += `Crossing Out Groups: `; - for (const group of this.crossingOutGroups) { - crossingGroupsString += `\n\t-"${this.ensureStringLength( - this.graph[group].text, - 15 - )}"`; - } - crossingGroupsString += `\nCrossing In Groups: `; - for (const group of this.crossingInGroups) { - crossingGroupsString += `\n\t-"${this.ensureStringLength( - this.graph[group].text, - 15 - )}"`; - } - - return ( - `--> Edge ${this.id} Text: "${this.text ?? "undefined string" - }"\n"${this.ensureStringLength( - this.getSource().text ?? "undefined string", - 15 - )}--->"${this.ensureStringLength( - this.getTarget().text ?? "undefined string", - 15 - )}"\n${crossingGroupsString}\nisReflexive: ${this.isReflexive - }\nType: ${this.type}\n` + super.logDetails() - ); - } - - reset() { - if (!this.isReflexive) { - super.reset(); - } - } -} - -export class ChatConverterEdge extends CannoliEdge { - load({ - content, - request, - }: { - content?: string | Record; - request?: GenericCompletionParams; - }): void { - const format = this.run.config?.chatFormatString?.toString() ?? chatFormatString; - const messageString = ""; - let messages: GenericCompletionResponse[] = []; - - if (content && format) { - // Convert content to messages using the format - messages = this.stringToArray(content as string, format); - } else { - throw new Error( - "Chat converter edge was loaded without a content or messages" - ); - } - - this.setContent(messageString); - this.setMessages(messages); - } - - stringToArray(str: string, format: string): GenericCompletionResponse[] { - const rolePattern = format - .replace("{{role}}", "(System|User|Assistant)") - .replace("{{content}}", "") - .trim(); - const regex = new RegExp(rolePattern, "g"); - - let match; - let messages: GenericCompletionResponse[] = []; - let lastIndex = 0; - - let firstMatch = true; - - while ((match = regex.exec(str)) !== null) { - const [, role] = match; - - // If this is the first match and there's text before it, add that text as a 'user' message - if (firstMatch && match.index > 0) { - messages.push({ - role: "user" as const, - content: str.substring(0, match.index).trim(), - }); - } - firstMatch = false; - - const start = regex.lastIndex; - let end; - const nextMatch = regex.exec(str); - if (nextMatch) { - end = nextMatch.index; - } else { - end = str.length; - } - regex.lastIndex = start; - - const content = str.substring(start, end).trim(); - const uncapRole = role.charAt(0).toLowerCase() + role.slice(1); - - messages.push({ - role: uncapRole as ChatRole, - content, - }); - - lastIndex = end; - } - - if (messages.length === 0) { - messages.push({ - role: "user" as ChatRole, - content: str.trim(), - }); - return messages; - } - - if (lastIndex < str.length - 1) { - messages.push({ - role: "user" as ChatRole, - content: str.substring(lastIndex).trim(), - }); - } - - if (this.text.length > 0) { - messages = this.limitMessages(messages); - } - - return messages; - } - - limitMessages( - messages: GenericCompletionResponse[] - ): GenericCompletionResponse[] { - let isTokenBased = false; - let originalText = this.text; - - if (originalText.startsWith("#")) { - isTokenBased = true; - originalText = originalText.substring(1); - } - - const limitValue = Number(originalText); - - if (isNaN(limitValue) || limitValue < 0) { - return messages; - } - - let outputMessages: GenericCompletionResponse[]; - - if (isTokenBased) { - const maxCharacters = limitValue * 4; - let totalCharacters = 0; - let index = 0; - - for (let i = messages.length - 1; i >= 0; i--) { - const message = messages[i]; - if (message.content) { - totalCharacters += message.content.length; - } - - if (totalCharacters > maxCharacters) { - index = i + 1; - break; - } - } - outputMessages = messages.slice(index); - } else { - outputMessages = messages.slice(-Math.max(limitValue, 1)); - } - - // Safeguard to always include at least one message - if (outputMessages.length === 0 && messages.length > 0) { - outputMessages = [messages[messages.length - 1]]; - } - - return outputMessages; - } -} - -export class ChatResponseEdge extends CannoliEdge { - beginningOfStream = true; - - load({ - content, - request, - }: { - content?: string | Record; - request?: GenericCompletionParams; - }): void { - const format = this.run.config?.chatFormatString?.toString() ?? chatFormatString; - - if (!format) { - throw new Error( - "Chat response edge was loaded without a format string" - ); - } - - if (content && typeof content === "string") { - if (!this.beginningOfStream) { - // If the content is the string "END OF STREAM" - if (content === "END OF STREAM") { - // Create a user template for the next message - const userTemplate = format - .replace("{{role}}", "User") - .replace("{{content}}", ""); - - this.setContent("\n\n" + userTemplate); - } else { - this.setContent(content); - } - } else { - const assistantTemplate = format - .replace("{{role}}", "Assistant") - .replace("{{content}}", content); - - this.setContent("\n\n" + assistantTemplate); - - this.beginningOfStream = false; - } - - this.execute(); - } - } -} - -export class SystemMessageEdge extends CannoliEdge { - load({ - content, - request, - }: { - content?: string | Record; - request?: GenericCompletionParams; - }): void { - if (content) { - this.setMessages([ - { - role: "system", - content: content as string, - }, - ]); - } - } -} - -export class LoggingEdge extends CannoliEdge { - load({ - content, - request, - }: { - content?: string | Record; - request?: GenericCompletionParams; - }): void { - // If content exists, save it as the configString - let configString = null; - - let messages: GenericCompletionResponse[] = []; - - if (request) { - configString = this.getConfigString(request); - messages = request.messages ? request.messages : []; - } else { - throw new Error( - "Logging edge was loaded without a request, this should never happen" - ); - } - - let logs = ""; - - // Get the current loop number of any repeat type groups that the edge is crossing out of - const repeatLoopNumbers = this.getLoopNumbers(); - - const loopHeader = this.formatLoopHeader(repeatLoopNumbers); - - // Get the version header - const forEachVersionNumbers = this.getForEachVersionNumbers(); - - const versionHeader = this.formatVersionHeader(forEachVersionNumbers); - - if (repeatLoopNumbers.length > 0) { - logs = `${loopHeader}\n`; - } - - if (forEachVersionNumbers.length > 0) { - logs = `${logs}${versionHeader}\n`; - } - - if (messages !== undefined) { - logs = `${logs}${this.formatInteractionHeaders(messages)}`; - } - - // If there is a configString, add it to the logs - if (configString !== null) { - logs = `${logs}\n#### Config\n${configString}\n`; - } - - // Append the logs to the content - if (this.content !== null) { - this.setContent(`${this.content}\n${logs}`); - } else { - this.setContent(logs); - } - } - - getConfigString(request: GenericCompletionParams) { - let configString = ""; - - // Extract imageReferences separately - const imageReferences = request.imageReferences; - - // Loop through all the properties of the request except for messages and imageReferences - for (const key in request) { - if (key !== "messages" && key !== "imageReferences" && request[key as keyof typeof request]) { - // If its apiKey, don't log the value - if (key === "apiKey") { - continue; - } - - configString += `${key}: ${request[key as keyof typeof request]}\n`; - } - } - - // Check for imageReferences and log the size of the array if it has elements - if (Array.isArray(imageReferences) && imageReferences.length > 0) { - configString += `images: ${imageReferences.length}\n`; - } - - return configString; - } - - getLoopNumbers(): number[] { - // Get the current loop number of any repeat type groups that the edge is crossing out of - const repeatLoopNumbers: number[] = []; - - this.crossingOutGroups.forEach((group) => { - const groupObject = this.graph[group]; - if (groupObject instanceof RepeatGroup) { - repeatLoopNumbers.push(groupObject.currentLoop); - } - }); - - // Reverse the array - repeatLoopNumbers.reverse(); - - return repeatLoopNumbers; - } - - getForEachVersionNumbers(): number[] { - // Get the current loop number of any repeat type groups that the edge is crossing out of - const forEachVersionNumbers: number[] = []; - - this.crossingOutGroups.forEach((group) => { - const groupObject = this.graph[group] as CannoliGroup; - if (groupObject.originalObject) { - forEachVersionNumbers.push(groupObject.currentLoop); - } - }); - - // Reverse the array - forEachVersionNumbers.reverse(); - - return forEachVersionNumbers; - } - - formatInteractionHeaders(messages: GenericCompletionResponse[]): string { - let formattedString = ""; - messages.forEach((message) => { - const role = message.role || "user"; - let content = message.content; - if ("function_call" in message && message.function_call) { - content = `Function Call: **${message.function_call.name}**\nArguments:\n\`\`\`json\n${message.function_call.arguments}\n\`\`\``; - } - formattedString += `#### ${role.charAt(0).toUpperCase() + role.slice(1) - }:\n${content}\n`; - }); - return formattedString.trim(); - } - - formatLoopHeader(loopNumbers: number[]): string { - let loopString = "# Loop "; - loopNumbers.forEach((loopNumber) => { - loopString += `${loopNumber + 1}.`; - }); - return loopString.slice(0, -1); - } - - formatVersionHeader(versionNumbers: number[]): string { - let versionString = "# Version "; - versionNumbers.forEach((versionNumber) => { - versionString += `${versionNumber}.`; - }); - return versionString.slice(0, -1); - } - - dependencyCompleted(dependency: CannoliObject): void { - if ( - this.getSource().status === CannoliObjectStatus.Complete - ) { - this.execute(); - } - } -} diff --git a/packages/cannoli-core/src/models/group.ts b/packages/cannoli-core/src/models/group.ts deleted file mode 100644 index 923f0a6..0000000 --- a/packages/cannoli-core/src/models/group.ts +++ /dev/null @@ -1,505 +0,0 @@ -import { CannoliObject, CannoliVertex } from "./object"; -import { CannoliEdge } from "./edge"; -import { - AllVerifiedCannoliCanvasNodeData, - CannoliObjectStatus, - EdgeType, - VerifiedCannoliCanvasData, - VerifiedCannoliCanvasGroupData, -} from "./graph"; -import { getGroupMembersFromData } from "src/factory"; - -export class CannoliGroup extends CannoliVertex { - members: string[]; - maxLoops: number; - currentLoop: number; - fromForEach: boolean; - - constructor( - groupData: VerifiedCannoliCanvasGroupData, - fullCanvasData: VerifiedCannoliCanvasData - ) { - super(groupData, fullCanvasData); - this.members = getGroupMembersFromData( - groupData.id, - fullCanvasData - ); - this.maxLoops = groupData.cannoliData.maxLoops ?? 1; - this.currentLoop = groupData.cannoliData.currentLoop ?? 0; - this.fromForEach = groupData.cannoliData.fromForEach ?? false; - this.originalObject = groupData.cannoliData.originalObject ?? null; - } - - setCurrentLoop(currentLoop: number) { - this.currentLoop = currentLoop; - - const data = this.canvasData.nodes.find((node) => node.id === this.id) as VerifiedCannoliCanvasGroupData; - data.cannoliData.currentLoop = currentLoop; - } - - getMembers(): CannoliVertex[] { - return this.members.map( - (member) => this.graph[member] as CannoliVertex - ); - } - - getCrossingAndInternalEdges(): { - crossingInEdges: CannoliEdge[]; - crossingOutEdges: CannoliEdge[]; - internalEdges: CannoliEdge[]; - } { - // Initialize the lists - const crossingInEdges: CannoliEdge[] = []; - const crossingOutEdges: CannoliEdge[] = []; - const internalEdges: CannoliEdge[] = []; - - // For each member - for (const member of this.members) { - const memberObject = this.graph[member]; - // If it's a vertex - if ( - this.cannoliGraph.isNode(memberObject) || - this.cannoliGraph.isGroup(memberObject) - ) { - // For each incoming edge - for (const edge of memberObject.incomingEdges) { - const edgeObject = this.graph[edge]; - if (this.cannoliGraph.isEdge(edgeObject)) { - // If it's crossing in - if (edgeObject.crossingInGroups.includes(this.id)) { - // Add it to the list - crossingInEdges.push(edgeObject); - } else { - // Otherwise, it's internal - internalEdges.push(edgeObject); - } - } - } - // For each outgoing edge - for (const edge of memberObject.outgoingEdges) { - const edgeObject = this.graph[edge]; - if (this.cannoliGraph.isEdge(edgeObject)) { - // If it's crossing out - if (edgeObject.crossingOutGroups.includes(this.id)) { - // Add it to the list - crossingOutEdges.push(edgeObject); - } else { - // Otherwise, it's internal - internalEdges.push(edgeObject); - } - } - } - } - } - - return { - crossingInEdges, - crossingOutEdges, - internalEdges, - }; - } - - allMembersCompleteOrRejected(): boolean { - // For each member - for (const member of this.members) { - // If it's not complete, return false - if ( - this.graph[member].status !== CannoliObjectStatus.Complete && - this.graph[member].status !== CannoliObjectStatus.Rejected - ) { - return false; - } - } - return true; - } - - allDependenciesCompleteOrRejected(): boolean { - // For each dependency - for (const dependency of this.dependencies) { - // If it's not complete, return false - if ( - this.graph[dependency].status !== - CannoliObjectStatus.Complete && - this.graph[dependency].status !== CannoliObjectStatus.Rejected - ) { - return false; - } - } - return true; - } - - async execute(): Promise { - this.setStatus(CannoliObjectStatus.Complete); - const event = new CustomEvent("update", { - detail: { obj: this, status: CannoliObjectStatus.Complete }, - }); - this.dispatchEvent(event); - } - - membersFinished() { } - - dependencyCompleted(dependency: CannoliObject): void { - if (this.status === CannoliObjectStatus.Executing) { - // If all dependencies are complete or rejected, call membersFinished - if (this.allDependenciesCompleteOrRejected()) { - this.membersFinished(); - } - } else if (this.status === CannoliObjectStatus.Complete) { - if (this.fromForEach && this.allDependenciesCompleteOrRejected()) { - const event = new CustomEvent("update", { - detail: { obj: this, status: CannoliObjectStatus.VersionComplete }, - }); - this.dispatchEvent(event); - } - } - } - - dependencyExecuting(dependency: CannoliObject): void { - if (this.status === CannoliObjectStatus.Pending) { - this.execute(); - } - } - - dependencyRejected(dependency: CannoliObject) { - if (this.noEdgeDependenciesRejected()) { - if (this.allDependenciesCompleteOrRejected()) { - this.reject(); - } - return; - } else { - this.reject(); - } - } - - noEdgeDependenciesRejected(): boolean { - // For each dependency - for (const dependency of this.dependencies) { - // If its an edge - if (this.graph[dependency].kind === "edge") { - if ( - this.graph[dependency].status === - CannoliObjectStatus.Rejected - ) { - return false; - } - } - } - return true; - } - - anyReflexiveEdgesComplete(): boolean { - // For each incoming edge - for (const edge of this.incomingEdges) { - const edgeObject = this.graph[edge] as CannoliEdge; - // If it's reflexive and complete, return true - if ( - edgeObject.isReflexive && - edgeObject.status === CannoliObjectStatus.Complete - ) { - return true; - } - } - return false; - } - - logDetails(): string { - let groupsString = ""; - groupsString += `Groups: `; - for (const group of this.groups) { - groupsString += `\n\t-"${this.ensureStringLength( - this.graph[group].text, - 15 - )}"`; - } - - let membersString = ""; - membersString += `Members: `; - for (const member of this.members) { - membersString += `\n\t-"${this.ensureStringLength( - this.graph[member].text, - 15 - )}"`; - } - - let incomingEdgesString = ""; - incomingEdgesString += `Incoming Edges: `; - for (const edge of this.incomingEdges) { - incomingEdgesString += `\n\t-"${this.ensureStringLength( - this.graph[edge].text, - 15 - )}"`; - } - - let outgoingEdgesString = ""; - outgoingEdgesString += `Outgoing Edges: `; - for (const edge of this.outgoingEdges) { - outgoingEdgesString += `\n\t-"${this.ensureStringLength( - this.graph[edge].text, - 15 - )}"`; - } - - return ( - `[::] Group ${this.id} Text: "${this.text}"\n${incomingEdgesString}\n${outgoingEdgesString}\n${groupsString}\n${membersString}\n` + - super.logDetails() - ); - } - - checkOverlap(): void { - const data = this.canvasData.nodes.find((node) => node.id === this.id) as VerifiedCannoliCanvasGroupData; - - const currentGroupRectangle = this.createRectangle( - data.x, - data.y, - data.width, - data.height - ); - - // Iterate through all objects in the graph - for (const objectKey in this.graph) { - const object = this.graph[objectKey]; - - if (object instanceof CannoliVertex) { - // Skip the current group to avoid self-comparison - if (object === this) continue; - - const objectData = this.canvasData.nodes.find((node) => node.id === object.id) as AllVerifiedCannoliCanvasNodeData; - - const objectRectangle = this.createRectangle( - objectData.x, - objectData.y, - objectData.width, - objectData.height - ); - - // Check if the object overlaps with the current group - if (this.overlaps(objectRectangle, currentGroupRectangle)) { - this.error( - `This group overlaps with another object. Please ensure objects fully enclose their members.` - ); - return; // Exit the method after the first error is found - } - } - } - } - - validateExitingAndReenteringPaths(): void { - const visited = new Set(); - - const dfs = (vertex: CannoliVertex, hasLeftGroup: boolean) => { - visited.add(vertex); - for (const edge of vertex.getOutgoingEdges()) { - const targetVertex = edge.getTarget(); - const isTargetInsideGroup = targetVertex - .getGroups() - .includes(this); - - if (hasLeftGroup && isTargetInsideGroup) { - this.error( - `A path leaving this group and re-enters it, this would cause deadlock.` - ); - return; - } - - if (!visited.has(targetVertex)) { - dfs(targetVertex, hasLeftGroup || !isTargetInsideGroup); - } - } - }; - - const members = this.getMembers(); - - for (const member of members) { - if (!visited.has(member)) { - dfs(member, false); - } - } - } - - validate() { - super.validate(); - - // Check for exiting and re-entering paths - this.validateExitingAndReenteringPaths(); - - // Check overlap - this.checkOverlap(); - - // If the group is fromForEach - if (this.fromForEach) { - const { crossingInEdges, crossingOutEdges } = this.getCrossingAndInternalEdges(); - - - // Check that there are no item edges crossing into it - const crossingInItemEdges = crossingInEdges.filter( - (edge) => this.graph[edge.id].type === EdgeType.Item - ); - - if (crossingInItemEdges.length > 0) { - this.error(`List edges can't cross into parallel groups. Try putting the node it's coming from inside the parallel group or using a non-list edge and an intermediary node.`); - return; - } - - // Check that there are no item edges crossing out of it and crossing into a different fromForEach group - const crossingOutItemOrListEdges = crossingOutEdges.filter( - (edge) => this.graph[edge.id].type === EdgeType.Item || this.graph[edge.id].type === EdgeType.List - ); - - if (crossingOutItemOrListEdges.length > 0) { - for (const edge of crossingOutItemOrListEdges) { - const edgeObject = this.graph[edge.id] as CannoliEdge; - - const crossingInGroups = edgeObject.crossingInGroups.map((group) => this.graph[group] as CannoliGroup); - - const crossingInParallelGroups = crossingInGroups.filter((group) => group.fromForEach); - - if (crossingInParallelGroups.length > 1) { - this.error(`List edges can't cross between parallel groups.`); - return; - } - } - } - - // Check that it has one and only one incoming edge of type item - const itemOrListEdges = this.incomingEdges.filter( - (edge) => this.graph[edge].type === EdgeType.Item || this.graph[edge].type === EdgeType.List - ); - if (itemOrListEdges.length < 1) { - this.error(`Parallel groups must have at least one incoming list arrow (cyan, labeled).`); - return; - } else if (itemOrListEdges.length > 1) { - // Check if one of the edges crosses a fromForEach group - const itemEdges = itemOrListEdges.filter( - (edge) => (this.graph[edge] as CannoliEdge).crossingOutGroups.some((group) => (this.graph[group] as CannoliGroup).fromForEach) - ); - - if (itemEdges.length > 0) { - this.error(`List edges can't cross between parallel groups.`); - return; - } - - this.error(`Parallel groups can't have more than one incoming list arrow.`); - return; - } - } - } -} - -export class RepeatGroup extends CannoliGroup { - constructor( - groupData: VerifiedCannoliCanvasGroupData, - fullCanvasData: VerifiedCannoliCanvasData - ) { - super(groupData, fullCanvasData); - - this.currentLoop = groupData.cannoliData.currentLoop ?? 0; - this.maxLoops = groupData.cannoliData.maxLoops ?? 1; - } - - async execute(): Promise { - this.setStatus(CannoliObjectStatus.Executing); - const event = new CustomEvent("update", { - detail: { obj: this, status: CannoliObjectStatus.Executing }, - }); - this.dispatchEvent(event); - } - - resetMembers() { - // For each member - for (const member of this.getMembers()) { - // Reset the member - member.reset(); - // Reset the member's outgoing edges whose target isn't this group - for (const edge of member.outgoingEdges) { - const edgeObject = this.graph[edge] as CannoliEdge; - - if (edgeObject.getTarget() !== this) { - edgeObject.reset(); - } - } - } - } - - membersFinished(): void { - this.setCurrentLoop(this.currentLoop + 1); - this.setText(`${this.currentLoop}/${this.maxLoops}`); - - if ( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - this.currentLoop < this.maxLoops! && - this.allEdgeDependenciesComplete() - ) { - - if (!this.run.isMock) { - // Sleep for 20ms to allow complete color to render - setTimeout(() => { - this.resetMembers(); - - const event = new CustomEvent("update", { - detail: { obj: this, status: CannoliObjectStatus.VersionComplete, message: this.currentLoop.toString() }, - }); - - this.dispatchEvent(event); - - this.executeMembers(); - }, 20); - } else { - this.resetMembers(); - this.executeMembers(); - } - } else { - this.setStatus(CannoliObjectStatus.Complete); - const event = new CustomEvent("update", { - detail: { obj: this, status: CannoliObjectStatus.Complete }, - }); - this.dispatchEvent(event); - } - } - - executeMembers(): void { - // For each member - for (const member of this.getMembers()) { - member.dependencyCompleted(this); - } - } - - reset(): void { - super.reset(); - this.setCurrentLoop(0); - this.setText(`0/${this.maxLoops}`); - } - - logDetails(): string { - return ( - super.logDetails() + `Type: Repeat\nMax Loops: ${this.maxLoops}\n` - ); - } - - validate(): void { - super.validate(); - - // Repeat groups must have a valid label number - if (this.maxLoops === null) { - this.error( - `Repeat groups loops must have a valid number in their label. Please ensure the label is a positive integer.` - ); - } - - // Repeat groups can't have incoming edges of type list - const listEdges = this.incomingEdges.filter( - (edge) => - this.graph[edge].type === EdgeType.List - ); - - if (listEdges.length !== 0) { - this.error( - `Repeat groups can't have incoming edges of type list.` - ); - } - - // Repeat groups can't have any outgoing edges - if (this.outgoingEdges.length !== 0) { - this.error(`Repeat groups can't have any outgoing edges.`); - } - } -} diff --git a/packages/cannoli-core/src/models/node.ts b/packages/cannoli-core/src/models/node.ts deleted file mode 100644 index e4a49da..0000000 --- a/packages/cannoli-core/src/models/node.ts +++ /dev/null @@ -1,3340 +0,0 @@ -import { CannoliObject, CannoliVertex } from "./object"; -import { ChatRole, HttpRequest, HttpTemplate } from "../run"; -import { CannoliEdge, ChatResponseEdge, LoggingEdge } from "./edge"; -import { CannoliGroup } from "./group"; -import { - AllVerifiedCannoliCanvasNodeData, - CannoliObjectStatus, - ContentNodeType, - EdgeType, - GroupType, - Reference, - ReferenceType, - EdgeModifier, - VerifiedCannoliCanvasData, - VerifiedCannoliCanvasFileData, - VerifiedCannoliCanvasLinkData, - VerifiedCannoliCanvasTextData, -} from "./graph"; -import * as yaml from "js-yaml"; -import { - GenericCompletionParams, - GenericCompletionResponse, - GenericFunctionCall, - GenericModelConfig, - GenericModelConfigSchema, - ImageReference, - SupportedProviders, -} from "../providers"; -import invariant from "tiny-invariant"; -import { pathOr, stringToPath } from "remeda"; -import { ZodSchema, z } from "zod"; -import { Action, ActionArgs, ActionResponse, ReceiveInfo } from "../run"; - -type VariableValue = { name: string; content: string; edgeId: string | null }; - -type VersionedContent = { - content: string; - versionArray: { - header: string | null; - subHeader: string | null; - }[]; -}; - -type TreeNode = { - header: string | null; - subHeader: string | null; - content?: string; - children?: TreeNode[]; -}; - -export class CannoliNode extends CannoliVertex { - references: Reference[] = []; - renderFunction: ( - variables: { name: string; content: string }[] - ) => Promise; - - constructor( - nodeData: - | VerifiedCannoliCanvasFileData - | VerifiedCannoliCanvasLinkData - | VerifiedCannoliCanvasTextData, - fullCanvasData: VerifiedCannoliCanvasData - ) { - super(nodeData, fullCanvasData); - this.references = nodeData.cannoliData.references || []; - this.renderFunction = this.buildRenderFunction(); - } - - buildRenderFunction() { - // Replace references with placeholders using an index-based system - let textCopy = this.text.slice(); - - let index = 0; - // Updated regex pattern to avoid matching newlines inside the double braces - textCopy = textCopy.replace(/\{\{[^{}\n]+\}\}/g, () => `{{${index++}}}`); - - // Define and return the render function - const renderFunction = async ( - variables: { name: string; content: string }[] - ) => { - // Process embedded notes - let processedText = await this.processEmbeds(textCopy); - - // Create a map to look up variable content by name - const varMap = new Map(variables.map((v) => [v.name, v.content])); - // Replace the indexed placeholders with the content from the variables - processedText = processedText.replace(/\{\{(\d+)\}\}/g, (match, index) => { - // Retrieve the reference by index - const reference = this.references[Number(index)]; - // Retrieve the content from the varMap using the reference's name - return varMap.get(reference.name) ?? "{{invalid}}"; - }); - - // Run replacer functions - for (const replacer of this.run.replacers) { - processedText = await replacer(processedText, this.run.isMock, this); - } - - return processedText; - }; - - - return renderFunction; - } - - - async processEmbeds(content: string): Promise { - // Check for embedded notes (e.g. ![[Note Name]]), and replace them with the note content - const embeddedNotes = content.match(/!\[\[[\s\S]*?\]\]/g); - - if (embeddedNotes) { - for (const embeddedNote of embeddedNotes) { - let noteName = embeddedNote - .replace("![[", "") - .replace("]]", ""); - - let subpath; - - // Image extensions - const imageExtensions = [".jpg", ".png", ".jpeg", ".gif", ".bmp", ".tiff", ".webp", ".svg", ".ico", ".jfif", ".avif"]; - if (imageExtensions.some(ext => noteName.endsWith(ext))) { - continue; - } - - // If there's a pipe, split and use the first part as the note name - if (noteName.includes("|")) { - noteName = noteName.split("|")[0]; - } - - // If there's a "#", split and use the first part as the note name, and the second part as the heading - if (noteName.includes("#")) { - const split = noteName.split("#"); - noteName = split[0]; - subpath = split[1]; - } - - // If there's no fileSystemInterface, throw an error - if (!this.run.fileManager) { - throw new Error("No fileManager found"); - } - - const noteContent = await this.run.fileManager.getNote({ - name: noteName, - type: ReferenceType.Note, - shouldExtract: true, - includeName: true, - subpath: subpath, - }, this.run.isMock); - - - - if (noteContent) { - const blockquotedNoteContent = - "> " + noteContent.replace(/\n/g, "\n> "); - content = content.replace( - embeddedNote, - blockquotedNoteContent - ); - } - } - } - - return content; - } - - async getContentFromNote(reference: Reference): Promise { - // If there's no fileSystemInterface, throw an error - if (!this.run.fileManager) { - throw new Error("No fileManager found"); - } - - const note = await this.run.fileManager.getNote(reference, this.run.isMock); - - if (note === null) { - return null; - } - - return note; - } - - getContentFromFloatingNode(name: string): string | null { - for (const object of Object.values(this.graph)) { - if (object instanceof FloatingNode && object.getName() === name) { - return object.getContent(); - } - } - return null; - } - - async processReferences(additionalVariableValues?: VariableValue[], cleanForJson?: boolean) { - const variableValues = this.getVariableValues(true); - - if (additionalVariableValues) { - variableValues.push(...additionalVariableValues); - } - - const resolvedReferences = await Promise.all( - this.references.map(async (reference) => { - let content = "{{invalid reference}}"; - const { name } = reference; - - if ( - (reference.type === ReferenceType.Variable || - reference.type === ReferenceType.Selection) && - !reference.shouldExtract - ) { - // First, try to get the content from variable values - const variable = variableValues.find( - (variable: { name: string }) => - variable.name === reference.name - ); - - if (variable) { - content = variable.content; - } else { - // If variable content is null, fall back to floating node - const floatingContent = this.getContentFromFloatingNode(reference.name); - if (floatingContent !== null) { - content = floatingContent; - } - // If the reference name contains only "#" symbols, replace it with the loop index - else if (reference.name.match(/^#+$/)) { - // Depth is the number of hash symbols minus 1 - const depth = reference.name.length - 1; - const loopIndex = this.getLoopIndex(depth); - if (loopIndex !== null) { - content = loopIndex.toString(); - } else { - content = `{{${reference.name}}}`; - } - } else if (this.run.secrets && this.run.secrets[reference.name]) { - // Check in this.run.secrets - content = this.run.secrets[reference.name]; - } else { - // this.warning(`Variable "${reference.name}" not found`); - content = `{{${reference.name}}}`; - } - } - } else if ( - (reference.type === ReferenceType.Variable || - reference.type === ReferenceType.Selection) && - reference.shouldExtract - ) { - // First, try to get the content from variable values - const variable = variableValues.find( - (variable) => variable.name === reference.name - ); - if (variable && variable.content) { - content = variable.content; - } else { - // If variable content is null, fall back to floating node - const floatingContent = this.getContentFromFloatingNode(reference.name); - if (floatingContent !== null) { - content = floatingContent; - } - } - - if (content !== "{{invalid reference}}") { - // Save original variable name - const originalName = reference.name; - - // Set reference name to the content of the variable - reference.name = content; - - // Get the content from the note - const noteContent = await this.getContentFromNote( - reference - ); - - // Restore original variable name - reference.name = originalName; - if (noteContent !== null) { - content = noteContent; - } else { - this.warning( - `Note "${content}" not found` - ); - content = `{{@${reference.name}}}`; - } - } else { - //this.warning(`Variable "${reference.name}" not found`); - content = `{{@${reference.name}}}`; - } - } else if (reference.type === ReferenceType.Note) { - if (reference.shouldExtract) { - const noteContent = await this.getContentFromNote( - reference - ); - if (noteContent !== null) { - content = noteContent; - } else { - this.warning(`Note "${reference.name}" not found`); - content = `{{[[${reference.name}]]}}`; - } - } else { - content = reference.name; - } - } else if (reference.type === ReferenceType.Floating) { - if (reference.shouldExtract) { - const floatingContent = this.getContentFromFloatingNode( - reference.name - ); - if (floatingContent !== null) { - content = floatingContent; - } else { - this.warning(`Floating node "${name}" not found`); - content = `{{[${reference.name}]}}`; - } - } - } - - if (cleanForJson) { - content = content.replace(/\\/g, '\\\\') - .replace(/\n/g, "\\n") - .replace(/"/g, '\\"') - .replace(/\t/g, '\\t'); - } - - return { name, content }; - }) - ); - - return this.renderFunction(resolvedReferences); - } - - getLoopIndex(depth: number): number | null { - const groups = this.groups.map((group) => this.graph[group] as CannoliGroup); - - // Filter to only repeat or forEach groups - const repeatOrForEachGroups = groups.filter((group) => group.type === GroupType.Repeat || group.fromForEach); - - // Get the group at the specified depth (0 is the most immediate group) - const group = repeatOrForEachGroups[depth]; - - // If group is not there, return null - if (!group) { - return null; - } - - // If group is not a CannoliGroup, return null - if (!(group instanceof CannoliGroup)) { - return null; - } - - // Get the loop index from the group - const loopIndex = group.currentLoop + 1; - - return loopIndex; - } - - getVariableValues(includeGroupEdges: boolean): VariableValue[] { - const variableValues: VariableValue[] = []; - - // Get all available provide edges - let availableEdges = this.getAllAvailableProvideEdges(); - - // If includeGroupEdges is not true, filter for only incoming edges of this node - if (!includeGroupEdges) { - availableEdges = availableEdges.filter((edge) => - this.incomingEdges.includes(edge.id) - ); - } - - for (const edge of availableEdges) { - const edgeObject = this.graph[edge.id]; - if (!(edgeObject instanceof CannoliEdge)) { - throw new Error( - `Error on object ${edgeObject.id}: object is not a provide edge.` - ); - } - - // If the edge isn't complete, check its status - if (!(edgeObject.status === CannoliObjectStatus.Complete)) { - // If the edge is reflexive and not rejected, set its content to an empty string and keep going - if (edgeObject.isReflexive && edgeObject.status !== CannoliObjectStatus.Rejected) { - edgeObject.setContent(""); - } else if ( - // If the edge is not rejected, not reflexive, or its content is null, skip it - !(edgeObject.status === CannoliObjectStatus.Rejected) || - !edgeObject.isReflexive || - edgeObject.content === null - ) { - continue; - } - } - - let content: string; - - if (edgeObject.content === null) { - continue; - } - - if (typeof edgeObject.content === "string" && edgeObject.text) { - // if the edge has a versions array - if (edgeObject.versions && edgeObject.versions.length > 0) { - const allVersions: VersionedContent[] = [{ - content: edgeObject.content, - versionArray: edgeObject.versions.map((version) => ({ - header: version.header, - subHeader: version.subHeader - })) - }]; - - // Find all edges with the same name and add them to the allVersions array - const edgesWithSameName = this.getAllAvailableProvideEdges().filter((edge) => edge.text === edgeObject.text); - for (const otherVersion of edgesWithSameName) { - - if (otherVersion.id !== edgeObject.id && - otherVersion.versions?.length === edgeObject.versions?.length && - otherVersion.content !== null) { - allVersions.push( - { - content: otherVersion.content as string, - versionArray: otherVersion.versions - } - ) - } - } - - const modifier = edgeObject.edgeModifier; - - let fromFormatterNode = false; - - if (this.graph[edgeObject.source].type === ContentNodeType.Formatter) { - fromFormatterNode = true; - } - - content = this.renderMergedContent(allVersions, modifier, fromFormatterNode, edgeObject.text); - } else { - content = edgeObject.content; - } - - const variableValue = { - name: edgeObject.text, - content: content, - edgeId: edgeObject.id, - }; - - variableValues.push(variableValue); - } else if ( - typeof edgeObject.content === "object" && - !Array.isArray(edgeObject.content) - ) { - const multipleVariableValues = []; - - for (const name in edgeObject.content) { - const variableValue = { - name: name, - content: edgeObject.content[name], - edgeId: edgeObject.id, - }; - - multipleVariableValues.push(variableValue); - } - - variableValues.push(...multipleVariableValues); - } else { - continue; - } - } - - // Add the default "NOTE" variable - if (this.run.currentNote && includeGroupEdges) { - const currentNoteVariableValue = { - name: "NOTE", - content: this.run.currentNote, - edgeId: "", - }; - - variableValues.push(currentNoteVariableValue); - } - - // Add the default "SELECTION" variable - if (this.run.selection && includeGroupEdges) { - const currentSelectionVariableValue = { - name: "SELECTION", - content: this.run.selection, - edgeId: "", - }; - - variableValues.push(currentSelectionVariableValue); - } - - // Resolve variable conflicts - const resolvedVariableValues = - this.resolveVariableConflicts(variableValues); - - return resolvedVariableValues; - } - - renderMergedContent(allVersions: VersionedContent[], modifier: EdgeModifier | null, fromFormatterNode: boolean, edgeName: string): string { - const tree = this.transformToTree(allVersions); - if (modifier === EdgeModifier.Table) { - return this.renderAsMarkdownTable(tree, edgeName); - } else if (modifier === EdgeModifier.List) { - return this.renderAsMarkdownList(tree); - } else if (modifier === EdgeModifier.Headers) { - return this.renderAsMarkdownHeaders(tree); - } else { - return this.renderAsParagraphs(tree, fromFormatterNode); - } - } - - transformToTree(allVersions: VersionedContent[]): TreeNode { - const root: TreeNode = { header: null, subHeader: null, children: [] }; - - allVersions.forEach(item => { - let currentNode = root; - - for (let i = item.versionArray.length - 1; i >= 0; i--) { - const version = item.versionArray[i]; - if (!currentNode.children) { - currentNode.children = []; - } - - let nextNode = currentNode.children.find(child => child.subHeader === version.subHeader); - - if (!nextNode) { - nextNode = { - header: version.header, - subHeader: version.subHeader, - children: [] - }; - currentNode.children.push(nextNode); - } - - currentNode = nextNode; - - if (i === 0) { - currentNode.content = item.content; - } - } - }); - - return root; - } - - renderAsParagraphs(tree: TreeNode, fromFormatterNode: boolean): string { - let result = ''; - - if (tree.content) { - if (fromFormatterNode) { - result += `${tree.content}`; - } else { - result += `${tree.content}\n\n`; - } - } - - if (tree.children) { - tree.children.forEach(child => { - result += this.renderAsParagraphs(child, fromFormatterNode); - }); - } - - return result; - } - - renderAsMarkdownHeaders(tree: TreeNode, level: number = 0): string { - let result = ''; - - if (level !== 0) { - result += `${'#'.repeat(level)} ${tree.subHeader}\n\n`; - } - - if (tree.content) { - result += `${tree.content}\n\n`; - } - - if (tree.children) { - tree.children.forEach(child => { - result += this.renderAsMarkdownHeaders(child, level + 1); - }); - } - - return result; - } - - renderAsMarkdownList(tree: TreeNode, indent: string = ''): string { - let result = ''; - - if (tree.subHeader) { - result += `${indent}- ${tree.subHeader}\n`; - } - - if (tree.content) { - const indentedContent = tree.content.split('\n').map(line => `${indent} ${line}`).join('\n'); - result += `${indentedContent}\n`; - } - - if (tree.children) { - tree.children.forEach(child => { - result += this.renderAsMarkdownList(child, indent + ' '); - }); - } - - return result; - } - - renderAsMarkdownTable(tree: TreeNode, edgeName: string): string { - let table = ''; - - if (!tree.children) { - return table; - } - - // Helper function to replace newlines with
- const replaceNewlines = (text: string | null | undefined): string => { - return (text ?? '').replace(/\n/g, '
'); - }; - - // Check if there's only one level - const isSingleLevel = !tree.children.some(child => child.children && child.children.length > 0); - - if (isSingleLevel) { - table = `| ${replaceNewlines(tree.children[0].header)} | ${edgeName} |\n| --- | --- |\n` - - // Create the table rows - tree.children.forEach(child => { - table += '| ' + replaceNewlines(child.subHeader) + ' | ' + replaceNewlines(child.content) + ' |\n'; - }); - } else { - // Extract the headers from the first child - const headers = tree.children[0].children?.map(child => replaceNewlines(child.subHeader)) ?? []; - - // Create the table header with an empty cell for the main header - table += '| |' + headers.join(' | ') + ' |\n'; - table += '| --- |' + headers.map(() => ' --- ').join(' | ') + ' |\n'; - - // Create the table rows - tree.children.forEach(child => { - table += '| ' + replaceNewlines(child.subHeader) + ' |'; - child.children?.forEach(subChild => { - const content = replaceNewlines(subChild.content); - table += ` ${content} |`; - }); - table += '\n'; - }); - } - - return table; - } - - resolveVariableConflicts(variableValues: VariableValue[]): VariableValue[] { - const finalVariables: VariableValue[] = []; - const groupedByName: Record = {}; - - // Group the variable values by name - for (const variable of variableValues) { - if (!groupedByName[variable.name]) { - groupedByName[variable.name] = []; - } - groupedByName[variable.name].push(variable); - } - - // Iterate through the grouped names - for (const name in groupedByName) { - // Get all the variable values for this name - const variables = groupedByName[name]; - - let selectedVariable = variables[0]; // Start with the first variable - let foundNonEmptyReflexive = false; - - // Iterate through the variables, preferring the reflexive edge if found - for (const variable of variables) { - if (!variable.edgeId) { - // If the variable has no edgeId, it's a special variable given by the node, it always has priority - selectedVariable = variable; - break; - } - - const edgeObject = this.graph[variable.edgeId]; - - // Check if edgeObject is an instance of CannoliEdge and if it's reflexive - if ( - edgeObject instanceof CannoliEdge && - edgeObject.isReflexive - ) { - if (edgeObject.content !== "") { - selectedVariable = variable; - foundNonEmptyReflexive = true; - break; // Exit the loop once a reflexive edge with non-empty content is found - } else if (!foundNonEmptyReflexive) { - // If no non-empty reflexive edge has been found yet, prefer the first reflexive edge - selectedVariable = variable; - } - } - } - - // If no non-empty reflexive edge was found, prefer the first non-reflexive edge - if (!foundNonEmptyReflexive && selectedVariable.content === "") { - for (const variable of variables) { - if (!variable.edgeId) { - this.error(`Variable ${name} has no edgeId`); - continue; - } - - const edgeObject = this.graph[variable.edgeId]; - if ( - !(edgeObject instanceof CannoliEdge) || - !edgeObject.isReflexive - ) { - selectedVariable = variable; - break; - } - } - } - - // Add the selected variable to the final array - finalVariables.push(selectedVariable); - } - - return finalVariables; - } - - private parseContent(content: string, path: never): string { - let contentObject; - - // Try to parse the content as JSON - try { - contentObject = JSON.parse(content as string); - } catch (e) { - // If parsing fails, return the original content - return content; - } - - // If we parsed the content as JSON and it's not an array, use the parsed object - if (contentObject && !Array.isArray(contentObject)) { - // Get the value from the parsed text - const value = pathOr(contentObject, path, content); - - // If the value is a string, return it - if (typeof value === "string") { - return value; - } else { - // Otherwise, return the stringified value - return JSON.stringify(value, null, 2); - } - } - - // If we didn't parse the content as JSON, return the original content - return content; - } - - loadOutgoingEdges(content: string, request?: GenericCompletionParams) { - let itemIndex = 0; - let listItems: string[] = []; - - if (this.outgoingEdges.some(edge => this.graph[edge].type === EdgeType.Item)) { - if (this.type === ContentNodeType.Http) { - // Parse the text of the edge with remeda - const path = stringToPath(this.graph[this.outgoingEdges.find(edge => this.graph[edge].type === EdgeType.Item)!].text); - listItems = this.getListArrayFromContent(this.parseContent(content, path)); - } else { - listItems = this.getListArrayFromContent(content); - } - } - - for (const edge of this.outgoingEdges) { - const edgeObject = this.graph[edge]; - let contentToLoad = content; - - // If it's coming from an httpnode - if (this.type === ContentNodeType.Http) { - // Parse the text of the edge with remeda - const path = stringToPath(edgeObject.text); - contentToLoad = this.parseContent(content, path); - } - - if (edgeObject instanceof CannoliEdge && !(edgeObject instanceof ChatResponseEdge) && edgeObject.type !== EdgeType.Item) { - edgeObject.load({ - content: contentToLoad, - request: request, - }); - } else if (edgeObject instanceof CannoliEdge && edgeObject.type === EdgeType.Item) { - const item = listItems[itemIndex]; - - // If we exceed the list items, reject the edge - if (!item) { - edgeObject.reject(); - continue; - } - - edgeObject.load({ - content: item, - request: request, - }); - itemIndex++; - } - } - } - - getListArrayFromContent(content: string): string[] { - // Attempt to parse the content as JSON - try { - const jsonArray = JSON.parse(content); - if (Array.isArray(jsonArray)) { - return jsonArray.map(item => typeof item === 'string' ? item : JSON.stringify(item)); - } - } catch (e) { - // If parsing fails, continue with markdown list parsing - } - - // First pass: look for markdown list items, and return the item at the index - const lines = content.split("\n"); - - // Filter out the lines that don't start with "- " or a number followed by ". " - const listItems = lines.filter((line) => line.startsWith("- ") || /^\d+\. /.test(line)); - - // Return the list items without the "- " or the number and ". " - return listItems.map((item) => item.startsWith("- ") ? item.substring(2) : item.replace(/^\d+\. /, "")); - } - - dependencyCompleted(dependency: CannoliObject): void { - if ( - this.allDependenciesComplete() && - this.status === CannoliObjectStatus.Pending - ) { - this.execute(); - } - } - - getNoteOrFloatingReference(): Reference | null { - const notePattern = /^{{\[\[([^\]]+)\]\]([\W]*)}}$/; - const floatingPattern = /^{{\[([^\]]+)\]}}$/; - const currentNotePattern = /^{{NOTE([\W]*)}}$/; - - const strippedText = this.text.trim(); - - let match = notePattern.exec(strippedText); - if (match) { - const reference: Reference = { - name: match[1], - type: ReferenceType.Note, - shouldExtract: false, - }; - - const modifiers = match[2]; - if (modifiers) { - if (modifiers.includes("!#")) { - reference.includeName = false; - } else if (modifiers.includes("#")) { - reference.includeName = true; - } - - if (modifiers.includes("!$")) { - reference.includeProperties = false; - } else if (modifiers.includes("$")) { - reference.includeProperties = true; - } - - if (modifiers.includes("!@")) { - reference.includeLink = false; - } else if (modifiers.includes("@")) { - reference.includeLink = true; - } - } - return reference; - } - - match = floatingPattern.exec(strippedText); - if (match) { - const reference = { - name: match[1], - type: ReferenceType.Floating, - shouldExtract: false, - }; - return reference; - } - - match = currentNotePattern.exec(strippedText); - if (match && this.run.currentNote) { - const reference: Reference = { - name: this.run.currentNote, - type: ReferenceType.Note, - shouldExtract: false, - }; - - const modifiers = match[1]; - if (modifiers) { - if (modifiers.includes("!#")) { - reference.includeName = false; - } else if (modifiers.includes("#")) { - reference.includeName = true; - } - - if (modifiers.includes("!$")) { - reference.includeProperties = false; - } else if (modifiers.includes("$")) { - reference.includeProperties = true; - } - - if (modifiers.includes("!@")) { - reference.includeLink = false; - } else if (modifiers.includes("@")) { - reference.includeLink = true; - } - } - return reference; - } - - return null; - } - - logDetails(): string { - let groupsString = ""; - groupsString += `Groups: `; - for (const group of this.groups) { - groupsString += `\n\t-"${this.ensureStringLength( - this.graph[group].text, - 15 - )}"`; - } - - let incomingEdgesString = ""; - incomingEdgesString += `Incoming Edges: `; - for (const edge of this.incomingEdges) { - incomingEdgesString += `\n\t-"${this.ensureStringLength( - this.graph[edge].text, - 15 - )}"`; - } - - let outgoingEdgesString = ""; - outgoingEdgesString += `Outgoing Edges: `; - for (const edge of this.outgoingEdges) { - outgoingEdgesString += `\n\t-"${this.ensureStringLength( - this.graph[edge].text, - 15 - )}"`; - } - - return ( - `[] Node ${this.id} Text: "${this.text}"\n${incomingEdgesString}\n${outgoingEdgesString}\n${groupsString}\n` + - super.logDetails() - ); - } - - validate(): void { - super.validate(); - - // Nodes can't have any incoming list edges - if (this.incomingEdges.filter((edge) => this.graph[edge].type === EdgeType.List).length > 0) { - this.error(`Nodes can't have any incoming list edges.`); - } - } - - getSpecialOutgoingEdges(): CannoliEdge[] { - // Get all special outgoing edges - const specialOutgoingEdges = this.getOutgoingEdges().filter((edge) => { - return ( - edge.type === EdgeType.Field || - edge.type === EdgeType.Choice || - edge.type === EdgeType.List || - edge.type === EdgeType.Variable - ); - }); - - return specialOutgoingEdges; - } - - specialOutgoingEdgesAreHomogeneous(): boolean { - const specialOutgoingEdges = this.getSpecialOutgoingEdges(); - - if (specialOutgoingEdges.length === 0) { - return true; - } - - const firstEdgeType = specialOutgoingEdges[0].type; - - for (const edge of specialOutgoingEdges) { - if (edge.type !== firstEdgeType) { - return false; - } - } - - return true; - } - - getAllAvailableProvideEdges(): CannoliEdge[] { - const availableEdges: CannoliEdge[] = []; - - // Get the incoming edges of all groups - for (const group of this.groups) { - const groupObject = this.graph[group]; - if (!(groupObject instanceof CannoliVertex)) { - throw new Error( - `Error on node ${this.id}: group is not a vertex.` - ); - } - - const groupIncomingEdges = groupObject.getIncomingEdges(); - - availableEdges.push(...groupIncomingEdges); - } - - // Get the incoming edges of this node - const nodeIncomingEdges = this.getIncomingEdges(); - - availableEdges.push(...nodeIncomingEdges); - - // Filter out all logging, and write edges - const filteredEdges = availableEdges.filter( - (edge) => - edge.type !== EdgeType.Logging && - edge.type !== EdgeType.Write && - edge.type !== EdgeType.Config - ); - - return filteredEdges as CannoliEdge[]; - } - - private updateConfigWithValue( - runConfig: Record, - content: string | Record | null, - schema: ZodSchema, - setting?: string | null, - ): void { - // Ensure the schema is a ZodObject to access its shape - if (!(schema instanceof z.ZodObject)) { - this.error("Provided schema is not a ZodObject."); - return; - } - - if (typeof content === "string") { - if (setting) { - runConfig[setting] = content; - } - } else if (typeof content === "object") { - for (const key in content) { - runConfig[key] = content[key]; - } - } - - try { - // Validate and transform the final runConfig against the schema - const parsedConfig = schema.parse(runConfig); - Object.assign(runConfig, parsedConfig); // Update runConfig with the transformed values - } catch (error) { - this.error(`Error setting config: ${error.errors[0].message}`); - } - } - - private processSingleEdge( - runConfig: Record, - edgeObject: CannoliEdge, - schema: ZodSchema - ): void { - if ( - typeof edgeObject.content === "string" || - typeof edgeObject.content === "object" - ) { - this.updateConfigWithValue( - runConfig, - edgeObject.content, - schema, - edgeObject.text, - ); - } else { - this.error(`Config edge has invalid content.`); - } - } - - private processEdges( - runConfig: Record, - edges: CannoliEdge[], - schema: ZodSchema - ): void { - for (const edgeObject of edges) { - if (!(edgeObject instanceof CannoliEdge)) { - throw new Error( - `Error processing config edges: object is not an edge.` - ); - } - this.processSingleEdge(runConfig, edgeObject, schema); - } - } - - private processGroups(runConfig: Record, schema: ZodSchema): void { - for (let i = this.groups.length - 1; i >= 0; i--) { - const group = this.graph[this.groups[i]]; - if (group instanceof CannoliGroup) { - const configEdges = group - .getIncomingEdges() - .filter((edge) => edge.type === EdgeType.Config); - this.processEdges(runConfig, configEdges, schema); - } - } - } - - private processNodes(runConfig: Record, schema: ZodSchema): void { - const configEdges = this.getIncomingEdges().filter( - (edge) => edge.type === EdgeType.Config - ); - this.processEdges(runConfig, configEdges, schema); - } - - getConfig(schema: ZodSchema): Record { - const runConfig = {}; - - this.processGroups(runConfig, schema); - this.processNodes(runConfig, schema); - - return runConfig; - } - - getPrependedMessages(): GenericCompletionResponse[] { - const messages: GenericCompletionResponse[] = []; - const systemMessages: GenericCompletionResponse[] = []; - - // Get all available provide edges - const availableEdges = this.getAllAvailableProvideEdges(); - - // filter for only incoming edges of this node - const directEdges = availableEdges.filter((edge) => - this.incomingEdges.includes(edge.id) - ); - - - // Filter for indirect edges (not incoming edges of this node) - const indirectEdges = availableEdges.filter( - (edge) => !this.incomingEdges.includes(edge.id) - ); - - - for (const edge of directEdges) { - const edgeObject = this.graph[edge.id]; - if (!(edgeObject instanceof CannoliEdge)) { - throw new Error( - `Error on object ${edgeObject.id}: object is not a provide edge.` - ); - } - - const edgeMessages = edgeObject.messages; - - if (!edgeMessages || edgeMessages.length < 1) { - continue; - } - - // If the edge is crossing a group, check if there are any indirect edges pointing to that group - for (const group of edgeObject.crossingInGroups) { - const indirectEdgesToGroup = indirectEdges.filter( - (edge) => edge.target === group - ); - - // Filter for those indirect edges that have addMessages = true and are of the same type - const indirectEdgesToAdd = indirectEdgesToGroup.filter( - (edge) => - this.graph[edge.id] instanceof CannoliEdge && - (this.graph[edge.id] as CannoliEdge).addMessages && - (this.graph[edge.id] as CannoliEdge).type === edgeObject.type - ); - - // For each indirect edge, add its messages without overwriting - for (const indirectEdge of indirectEdgesToAdd) { - const indirectEdgeObject = this.graph[indirectEdge.id]; - if (!(indirectEdgeObject instanceof CannoliEdge)) { - throw new Error( - `Error on object ${indirectEdgeObject.id}: object is not a provide edge.` - ); - } - - const indirectEdgeMessages = indirectEdgeObject.messages; - - if (!indirectEdgeMessages || indirectEdgeMessages.length < 1) { - continue; - } - - edgeMessages.push(...indirectEdgeMessages); - } - } - - // Separate system messages from other messages - if (edge.type === EdgeType.SystemMessage) { - for (const msg of edgeMessages) { - if (!systemMessages.some((m) => m.content === msg.content) && !messages.some((m) => m.content === msg.content)) { - systemMessages.push(msg); - } - } - } else { - messages.push(...edgeMessages); - } - } - - // If messages is empty and there are no incoming edges with addMessages = true, try it with indirect edges - if (messages.length === 0) { - for (const edge of indirectEdges) { - const edgeObject = this.graph[edge.id]; - if (!(edgeObject instanceof CannoliEdge)) { - throw new Error( - `Error on object ${edgeObject.id}: object is not a provide edge.` - ); - } - - const edgeMessages = edgeObject.messages; - - if (!edgeMessages || edgeMessages.length < 1) { - continue; - } - - // Separate system messages from other messages - if (edge.type === EdgeType.SystemMessage) { - for (const msg of edgeMessages) { - if (!systemMessages.some((m) => m.content === msg.content) && !messages.some((m) => m.content === msg.content)) { - systemMessages.push(msg); - } - } - } else { - messages.push(...edgeMessages); - } - } - } - - // Combine system messages and other messages - const combinedMessages = [...systemMessages, ...messages]; - - // Remove duplicate system messages from the combined message stack - const uniqueMessages = combinedMessages.filter((msg, index, self) => - msg.role !== "system" || self.findIndex((m) => m.content === msg.content) === index - ); - - return uniqueMessages; - } -} - - - - -export class CallNode extends CannoliNode { - async getNewMessage( - role?: string - ): Promise { - const content = await this.processReferences(); - - // If there is no content, return null - if (!content) { - return null; - } - - return { - role: (role as ChatRole) || "user", - content: content, - }; - } - - findNoteReferencesInMessages( - messages: GenericCompletionResponse[] - ): string[] { - const references: string[] = []; - const noteRegex = /\[\[(.+?)\]\]/g; - - // Get the contents of each double bracket - for (const message of messages) { - const matches = - typeof message.content === "string" && - message.content?.matchAll(noteRegex); - - if (!matches) { - continue; - } - - for (const match of matches) { - references.push(match[1]); - } - } - - return references; - } - - async execute() { - this.executing(); - - let request: GenericCompletionParams; - try { - request = await this.createLLMRequest(); - } catch (error) { - this.error(`Error creating LLM request: ${error}`); - return; - } - - // If the message array is empty, error - if (request.messages.length === 0) { - this.error( - `No messages to send to LLM. Empty call nodes only send the message history they've been passed.` - ); - return; - } - - // If the node has an outgoing chatResponse edge, call with streaming - const chatResponseEdges = this.getOutgoingEdges().filter( - (edge) => edge.type === EdgeType.ChatResponse - ); - - if (chatResponseEdges.length > 0) { - const stream = await this.run.callLLMStream(request); - - if (stream instanceof Error) { - this.error(`Error calling LLM:\n${stream.message}`); - return; - } - - if (!stream) { - this.error(`Error calling LLM: no stream returned.`); - return; - } - - if (typeof stream === "string") { - this.loadOutgoingEdges(stream, request); - this.completed(); - return; - } - - // Create message content string - let messageContent = ""; - // Process the stream. For each part, add the message to the request, and load the outgoing edges - for await (const part of stream) { - if (!part || typeof part !== "string") { - // deltas might be empty, that's okay, just get the next one - continue; - } - - // Add the part to the message content - messageContent += part; - - // Load outgoing chatResponse edges with the part - for (const edge of chatResponseEdges) { - edge.load({ - content: part ?? "", - request: request, - }); - } - } - - // Load outgoing chatResponse edges with the message "END OF STREAM" - for (const edge of chatResponseEdges) { - edge.load({ - content: "END OF STREAM", - request: request, - }); - } - - // Add an assistant message to the messages array of the request - request.messages.push({ - role: "assistant", - content: messageContent, - }); - - // After the stream is done, load the outgoing edges - this.loadOutgoingEdges(messageContent, request); - } else { - const message = await this.run.callLLM(request); - - if (message instanceof Error) { - this.error(`Error calling LLM:\n${message.message}`); - return; - } - - if (!message) { - this.error(`Error calling LLM: no message returned.`); - return; - } - - request.messages.push(message); - - if (message.function_call?.arguments) { - if (message.function_call.name === "note_select") { - const args = JSON.parse(message.function_call.arguments); - - // Put double brackets around the note name - args.note = `[[${args.note}]]`; - - this.loadOutgoingEdges(args.note, request); - } else { - this.loadOutgoingEdges(message.content ?? "", request); - } - } else { - this.loadOutgoingEdges(message.content ?? "", request); - } - } - - this.completed(); - } - - async extractImages(message: GenericCompletionResponse, index: number): Promise { - const imageReferences: ImageReference[] = []; - const markdownImageRegex = /!\[.*?\]\((.*?)\)/g; - let match; - - while ((match = markdownImageRegex.exec(message.content)) !== null) { - imageReferences.push({ - url: match[1], - messageIndex: index, - }); - } - - if (this.run.fileManager) { - const imageExtensions = [".jpg", ".png", ".jpeg", ".gif", ".bmp", ".tiff", ".webp", ".svg", ".ico", ".jfif", ".avif"]; - // should match instances like ![[image.jpg]] - const fileImageRegex = new RegExp(`!\\[\\[([^\\]]+(${imageExtensions.join("|")}))\\]\\]`, "g"); - while ((match = fileImageRegex.exec(message.content)) !== null) { - // "image.jpg" - const fileName = match[1]; - - // get file somehow from the filename - const file = await this.run.fileManager.getFile(fileName, this.run.isMock); - - if (!file) { - continue; - } - - // turn file into base64 - let base64 = Buffer.from(file).toString('base64'); - base64 = `data:image/${fileName.split('.').pop()};base64,${base64}`; - - imageReferences.push({ - url: base64, - messageIndex: index, - }); - } - } - - return imageReferences; - } - - async createLLMRequest(): Promise { - const overrides = this.getConfig(GenericModelConfigSchema) as GenericModelConfig; - const config = this.run.llm?.getMergedConfig({ - configOverrides: overrides, - provider: (overrides.provider as SupportedProviders) ?? undefined - }); - invariant(config, "Config is undefined"); - - const messages = this.getPrependedMessages(); - - const newMessage = await this.getNewMessage(config.role); - - // Remove the role from the config - delete config.role; - - if (newMessage) { - messages.push(newMessage); - } - - const imageReferences = await Promise.all(messages.map(async (message, index) => { - return await this.extractImages(message, index); - })).then(ir => ir.flat()); - - const functions = this.getFunctions(messages); - - const function_call = - functions && functions.length > 0 - ? { name: functions[0].name } - : undefined; - - return { - messages: messages, - imageReferences: imageReferences, - ...config, - functions: - functions && functions.length > 0 ? functions : undefined, - function_call: function_call ? function_call : undefined, - }; - } - - getFunctions(messages: GenericCompletionResponse[]): GenericFunctionCall[] { - if ( - this.getOutgoingEdges().some( - (edge) => edge.edgeModifier === EdgeModifier.Note - ) - ) { - const noteNames = this.findNoteReferencesInMessages(messages); - return [this.run.createNoteNameFunction(noteNames)]; - } else { - return []; - } - } - - logDetails(): string { - return super.logDetails() + `Type: Call\n`; - } - - validate() { - super.validate(); - } -} - -export class FormNode extends CallNode { - getFunctions( - messages: GenericCompletionResponse[] - ): GenericFunctionCall[] { - // Get the names of the fields - const fields = this.getFields(); - - const fieldsWithNotes: { name: string; noteNames?: string[] }[] = []; - - // If one of the outgoing edges has a vault modifier of type "note", get the note names and pass it into that field - const noteEdges = this.getOutgoingEdges().filter( - (edge) => edge.edgeModifier === EdgeModifier.Note - ); - - for (const item of fields) { - // If the item matches the name of one of the note edges - if (noteEdges.find((edge) => edge.text === item)) { - // Get the note names - const noteNames = this.findNoteReferencesInMessages(messages); - - fieldsWithNotes.push({ name: item, noteNames: noteNames }); - } else { - fieldsWithNotes.push({ name: item }); - } - } - - // Generate the form function - const formFunc = this.run.createFormFunction(fieldsWithNotes); - - return [formFunc]; - } - - getFields(): string[] { - // Get the unique names of all outgoing field edges - const outgoingFieldEdges = this.getOutgoingEdges().filter((edge) => { - return edge.type === EdgeType.Field; - }); - - const uniqueNames = new Set(); - - for (const edge of outgoingFieldEdges) { - const edgeObject = this.graph[edge.id]; - if (!(edgeObject instanceof CannoliEdge)) { - throw new Error( - `Error on object ${edgeObject.id}: object is not a field edge.` - ); - } - - const name = edgeObject.text; - - if (name) { - uniqueNames.add(name); - } - } - - return Array.from(uniqueNames); - } - - loadOutgoingEdges(content: string, request: GenericCompletionParams): void { - const messages = request.messages; - - // Get the fields from the last message - const lastMessage = messages[messages.length - 1]; - const formFunctionArgs = - "function_call" in lastMessage && - lastMessage.function_call?.arguments; - - if (!formFunctionArgs) { - this.error(`Form function call has no arguments.`); - return; - } - - // Parse the fields from the arguments - const fields = JSON.parse(formFunctionArgs); - - for (const edge of this.outgoingEdges) { - const edgeObject = this.graph[edge]; - if (edgeObject instanceof CannoliEdge) { - // If the edge is a field edge, load it with the content of the corresponding field - if ( - edgeObject instanceof CannoliEdge && - edgeObject.type === EdgeType.Field - ) { - const name = edgeObject.text; - - if (name) { - const fieldContent = fields[name]; - - if (fieldContent) { - // If it has a note modifier, add double brackets around the note name - if ( - edgeObject.edgeModifier === EdgeModifier.Note - ) { - edgeObject.load({ - content: `[[${fieldContent}]]`, - request: request, - }); - } else { - edgeObject.load({ - content: fieldContent, - request: request, - }); - } - } - } - } else { - edgeObject.load({ - content: formFunctionArgs, - request: request, - }); - } - } - } - } - - logDetails(): string { - return super.logDetails() + `Subtype: Form\n`; - } -} - -export class ChooseNode extends CallNode { - getFunctions(messages: GenericCompletionResponse[]): GenericFunctionCall[] { - const choices = this.getBranchChoices(); - - // Create choice function - const choiceFunc = this.run.createChoiceFunction(choices); - - return [choiceFunc]; - } - - loadOutgoingEdges(content: string, request: GenericCompletionParams): void { - const messages = request.messages; - - // Get the chosen variable from the last message - const lastMessage = messages[messages.length - 1]; - const choiceFunctionArgs = - "function_call" in lastMessage && - lastMessage.function_call?.arguments; - - if (!choiceFunctionArgs) { - this.error(`Choice function call has no arguments.`); - return; - } - - const parsedVariable = JSON.parse(choiceFunctionArgs); - - // Reject all unselected options - this.rejectUnselectedOptions(parsedVariable.choice); - - super.loadOutgoingEdges(choiceFunctionArgs, request); - } - - rejectUnselectedOptions(choice: string) { - // Call reject on any outgoing edges that aren't the selected one - for (const edge of this.outgoingEdges) { - const edgeObject = this.graph[edge]; - if (edgeObject.type === EdgeType.Choice) { - const branchEdge = edgeObject as CannoliEdge; - if (branchEdge.text !== choice) { - branchEdge.reject(); - } - } - } - } - - getBranchChoices(): string[] { - // Get the unique names of all outgoing choice edges - const outgoingChoiceEdges = this.getOutgoingEdges().filter((edge) => { - return edge.type === EdgeType.Choice; - }); - - const uniqueNames = new Set(); - - for (const edge of outgoingChoiceEdges) { - const edgeObject = this.graph[edge.id]; - if (!(edgeObject instanceof CannoliEdge)) { - throw new Error( - `Error on object ${edgeObject.id}: object is not a branch edge.` - ); - } - - const name = edgeObject.text; - - if (name) { - uniqueNames.add(name); - } - } - - return Array.from(uniqueNames); - } - - logDetails(): string { - return super.logDetails() + `Subtype: Choice\n`; - } - - validate() { - super.validate(); - - // If there are no branch edges, error - if ( - !this.getOutgoingEdges().some( - (edge) => edge.type === EdgeType.Choice - ) - ) { - this.error( - `Choice nodes must have at least one outgoing choice edge.` - ); - } - } -} - -export class ContentNode extends CannoliNode { - reset(): void { - // If it's a standard content node or output node, reset the text and then call the super - if (this.type === ContentNodeType.StandardContent || this.type === ContentNodeType.Output) { - const name = this.getName(); - if (name !== null && this.type !== ContentNodeType.StandardContent) { - // Clear everything except the first line - this.setText(this.text.split("\n")[0]); - } else { - // Clear everything - this.setText(""); - } - } - - super.reset(); - } - - getName(content?: string): string | null { - let contentToCheck = content; - - if (!contentToCheck) { - contentToCheck = this.text; - } - - const firstLine = contentToCheck.split("\n")[0].trim(); - if ( - firstLine.startsWith("[") && - firstLine.endsWith("]") && - this.type !== ContentNodeType.StandardContent - ) { - try { - // Check if the first line is a valid JSON array - JSON.parse(firstLine); - return null; // If it's a valid JSON array, return null - } catch (e) { - // If it's not a valid JSON array, proceed to extract the name - return firstLine.substring(1, firstLine.length - 1); - } - } - return null; - } - - // Content is everything after the first line - getContentCheckName(content?: string): string { - let contentToCheck = content; - - if (!contentToCheck) { - contentToCheck = this.text; - } - - const name = this.getName(contentToCheck); - if (name !== null) { - const firstLine = contentToCheck.split("\n")[0]; - return contentToCheck.substring(firstLine.length + 1); - } - return this.text; - } - - editContentCheckName(newContent: string): void { - const name = this.getName(); - const firstLine = this.text.split("\n")[0]; - if (name !== null) { - const newFirstLine = newContent.split("\n")[0].trim(); - // Check if the first line of the new content matches the current name line - if (newFirstLine === firstLine.trim()) { - // Discard the first line of the new content - newContent = newContent.substring(newFirstLine.length).trim(); - } - this.setText(`${firstLine}\n${newContent}`); - } else { - this.setText(newContent); - } - } - - filterName(content: string): string { - const name = this.getName(content); - if (name !== null) { - const firstLine = content.split("\n")[0]; - return content.substring(firstLine.length + 1).trim(); - } - return content; - } - - async execute(): Promise { - this.executing(); - - let content = this.getWriteOrLoggingContent(); - - if (content === null) { - const variableValues = this.getVariableValues(false); - - // Get first variable value - if (variableValues.length > 0) { - content = variableValues[0].content || ""; - } - } - - if (content === null || content === undefined) { - content = await this.processReferences(); - } - - content = this.filterName(content); - - if (this.type === ContentNodeType.Output || this.type === ContentNodeType.Formatter || this.type === ContentNodeType.StandardContent) { - this.editContentCheckName(content); - this.loadOutgoingEdges(content); - } else { - this.loadOutgoingEdges(content); - } - - this.completed(); - } - - dependencyCompleted(dependency: CannoliObject): void { - // If the dependency is a logging edge not crossing out of a forEach group or a chatResponse edge, execute regardless of this node's status - if ( - (dependency instanceof LoggingEdge && - !dependency.crossingOutGroups.some((group) => { - const groupObject = this.graph[group]; - if (!(groupObject instanceof CannoliGroup)) { - throw new Error( - `Error on object ${groupObject.id}: object is not a group.` - ); - } - return groupObject.type === GroupType.ForEach; - })) || - dependency instanceof ChatResponseEdge - ) { - this.execute(); - } else if ( - this.allDependenciesComplete() && - this.status === CannoliObjectStatus.Pending - ) { - this.execute(); - } - } - - logDetails(): string { - return super.logDetails() + `Type: Content\n`; - } - - getWriteOrLoggingContent(): string | null { - // Get all incoming edges - const incomingEdges = this.getIncomingEdges(); - - // If there are multiple logging edges - if ( - incomingEdges.filter((edge) => edge.type === EdgeType.Logging) - .length > 1 - ) { - // Append the content of all logging edges - let content = ""; - for (const edge of incomingEdges) { - const edgeObject = this.graph[edge.id]; - if (edgeObject instanceof LoggingEdge) { - if (edgeObject.content !== null) { - content += edgeObject.content; - } - } - } - - return content; - } - - // Filter for incoming complete edges of type write, logging, or chatResponse, as well as edges with no text - let filteredEdges = incomingEdges.filter( - (edge) => - (edge.type === EdgeType.Write || - edge.type === EdgeType.Logging || - edge.type === EdgeType.ChatResponse || - edge.text.length === 0) && - this.graph[edge.id].status === CannoliObjectStatus.Complete - ); - - // Remove all edges with a vault modifier of type folder or property - filteredEdges = filteredEdges.filter( - (edge) => - edge.edgeModifier !== EdgeModifier.Folder && - edge.edgeModifier !== EdgeModifier.Property - ); - - if (filteredEdges.length === 0) { - return null; - } - - // Check for edges with versions - const edgesWithVersions = filteredEdges.filter( - (edge) => { - const edgeObject = this.graph[edge.id]; - return edgeObject instanceof CannoliEdge && edgeObject.versions && edgeObject.versions.length > 0; - } - ); - - if (edgesWithVersions.length > 0) { - const allVersions: VersionedContent[] = []; - for (const edge of edgesWithVersions) { - const edgeObject = this.graph[edge.id] as CannoliEdge; - if (edgeObject.content !== null) { - allVersions.push({ - content: edgeObject.content as string, - versionArray: edgeObject.versions as { header: string | null; subHeader: string | null; }[] - }); - } - } - - const modifier = edgesWithVersions[0].edgeModifier; - - let fromFormatterNode = false; - - if (this.graph[edgesWithVersions[0].source].type === ContentNodeType.Formatter) { - fromFormatterNode = true; - } - - const mergedContent = this.renderMergedContent(allVersions, modifier, fromFormatterNode, edgesWithVersions[0].text); - - if (mergedContent) { - return mergedContent; - } - } - - // If there are write or chatResponse edges, return the content of the first one - const firstEdge = filteredEdges[0]; - const firstEdgeObject = this.graph[firstEdge.id]; - if (firstEdgeObject instanceof CannoliEdge) { - if ( - firstEdgeObject.content !== null && - typeof firstEdgeObject.content === "string" - ) { - return firstEdgeObject.content; - } - } else { - throw new Error( - `Error on object ${firstEdgeObject.id}: object is not an edge.` - ); - } - - return null; - } - - isValidVariableName(name: string): boolean { - // Regular expression to match valid JavaScript variable names - const validNamePattern = /^[a-zA-Z_$][a-zA-Z_$0-9]*$/; - // Check if the name matches the pattern - return validNamePattern.test(name) - } - - isReservedKeyword(name: string): boolean { - const reservedKeywords = [ - "break", "case", "catch", "class", "const", "continue", "debugger", "default", "delete", "do", "else", "enum", "export", "extends", "false", "finally", "for", "function", "if", "import", "in", "instanceof", "new", "null", "return", "super", "switch", "this", "throw", "true", "try", "typeof", "var", "void", "while", "with", "yield", "let", "static", "implements", "interface", "package", "private", "protected", "public" - ]; - return reservedKeywords.includes(name); - } - - validate(): void { - super.validate(); - - if (this.type === ContentNodeType.Input || this.type === ContentNodeType.Output) { - const name = this.getName(); - if (name !== null) { - if (!this.isValidVariableName(name)) { - this.error(`"${name}" is not a valid variable name. Input and output node names must start with a letter, underscore, or dollar sign, and can only contain letters, numbers, underscores, or dollar signs.`); - } - if (this.isReservedKeyword(name)) { - this.error(`"${name}" is a reserved keyword, and cannot be used as an input or output node name.`); - } - - if (this.type === ContentNodeType.Output) { - if (this.getGroups().some((group) => group.fromForEach)) { - this.error(`Named output nodes cannot be inside of parallel groups.`); - } - } - } - } - } -} - -export class ReferenceNode extends ContentNode { - reference: Reference; - - constructor( - nodeData: - | VerifiedCannoliCanvasTextData - | VerifiedCannoliCanvasLinkData - | VerifiedCannoliCanvasFileData, - fullCanvasData: VerifiedCannoliCanvasData - ) { - super(nodeData, fullCanvasData); - - if (this.references.length !== 1) { - this.error(`Could not find reference.`); - } else { - this.reference = this.references[0]; - } - } - - async execute(): Promise { - this.executing(); - - let content: string | null = null; - - const writeOrLoggingContent = this.getWriteOrLoggingContent(); - - const variableValues = this.getVariableValues(false); - - if (variableValues.length > 0) { - // First, get the edges of the variable values - const variableValueEdges = variableValues.map((variableValue) => { - return this.graph[variableValue.edgeId ?? ""] as CannoliEdge; - }); - - // Then, filter out the edges that have the same name as the reference, or are of type folder or property - const filteredVariableValueEdges = variableValueEdges.filter( - (variableValueEdge) => { - return ( - variableValueEdge.text !== this.reference.name && - variableValueEdge.edgeModifier !== - EdgeModifier.Folder && - variableValueEdge.edgeModifier !== - EdgeModifier.Property - ); - } - ); - - // Then, filter the variable values by the filtered edges - const filteredVariableValues = variableValues.filter( - (variableValue) => { - return filteredVariableValueEdges.some( - (filteredVariableValueEdge) => { - return ( - filteredVariableValueEdge.id === - variableValue.edgeId - ); - } - ); - } - ); - - if (filteredVariableValues.length > 0) { - // Then, get the content of the first variable value - content = filteredVariableValues[0].content; - } else if (writeOrLoggingContent !== null) { - content = writeOrLoggingContent; - } - } else if (writeOrLoggingContent !== null) { - content = writeOrLoggingContent; - } - - // Get the property edges - const propertyEdges = this.getIncomingEdges().filter( - (edge) => - edge.edgeModifier === EdgeModifier.Property && - edge.text !== this.reference.name - ); - - if (content !== null) { - // Append is dependent on if there is an incoming edge of type ChatResponse - const append = this.getIncomingEdges().some( - (edge) => edge.type === EdgeType.ChatResponse - ); - - if ( - this.reference.type === ReferenceType.CreateNote || - (this.reference.type === ReferenceType.Variable && - this.reference.shouldExtract) - ) { - await this.processDynamicReference(content); - } else { - await this.editContent(content, append); - - // If there are property edges, edit the properties - if (propertyEdges.length > 0) { - for (const edge of propertyEdges) { - if ( - edge.content === null || - edge.content === undefined || - typeof edge.content !== "string" - ) { - this.error(`Property arrow has invalid content.`); - return; - } - - await this.editProperty(edge.text, edge.content); - } - } - } - - // Load all outgoing edges - await this.loadOutgoingEdges(content); - } else { - if ( - this.reference.type === ReferenceType.CreateNote || - (this.reference.type === ReferenceType.Variable && - this.reference.shouldExtract) - ) { - await this.processDynamicReference(""); - - const fetchedContent = await this.getContent(); - await this.loadOutgoingEdges(fetchedContent); - } else { - const fetchedContent = await this.getContent(); - await this.loadOutgoingEdges(fetchedContent); - } - - // If there are property edges, edit the properties - if (propertyEdges.length > 0) { - for (const edge of propertyEdges) { - if ( - edge.content === null || - edge.content === undefined || - typeof edge.content !== "string" - ) { - this.error(`Property arrow has invalid content.`); - return; - } - - await this.editProperty(edge.text, edge.content); - } - } - } - - // Load all outgoing edges - this.completed(); - } - - async getContent(): Promise { - if (this.run.isMock) { - return `Mock content`; - } - - if (this.reference) { - if (this.reference.type === ReferenceType.Note) { - const content = await this.getContentFromNote(this.reference); - if (content !== null && content !== undefined) { - return content; - } else { - this.error( - `Invalid reference. Could not find note "${this.reference.name}".` - ); - } - } else if (this.reference.type === ReferenceType.Selection) { - const content = this.run.selection; - - if (content !== null && content !== undefined) { - return content; - } else { - this.error(`Invalid reference. Could not find selection.`); - } - } else if (this.reference.type === ReferenceType.Floating) { - const content = this.getContentFromFloatingNode( - this.reference.name - ); - if (content !== null) { - return content; - } else { - this.error( - `Invalid reference. Could not find floating node "${this.reference.name}".\n\nIf you want this node to inject a variable, turn it into a formatter node by wrapping the whole node in ""two sets of double quotes"".` - ); - } - } else if (this.reference.type === ReferenceType.Variable) { - const content = this.getContentFromFloatingNode( - this.reference.name - ); - if (content !== null) { - return content; - } else { - this.error( - `Invalid reference. Could not find floating node "${this.reference.name}".\n\nIf you want this node to inject a variable, turn it into a formatter node by wrapping the whole node in ""two sets of double quotes"".` - ); - } - } else if ( - this.reference.type === ReferenceType.CreateNote - ) { - this.error(`Dynamic reference did not process correctly.`); - } - } - - return `Could not find reference.`; - } - - async processDynamicReference(content: string) { - if (this.run.isMock) { - return; - } - - const incomingEdges = this.getIncomingEdges(); - - // Find the incoming edge with the same name as the reference name - const referenceNameEdge = incomingEdges.find( - (edge) => edge.text === this.reference.name - ); - - if (!referenceNameEdge) { - this.error(`Could not find arrow containing note name.`); - return; - } - - if ( - referenceNameEdge.content === null || - referenceNameEdge.content === undefined || - typeof referenceNameEdge.content !== "string" - ) { - this.error(`Note name arrow has invalid content.`); - return; - } - - // Look for an incoming edge with a vault modifier of type folder - const folderEdge = incomingEdges.find( - (edge) => edge.edgeModifier === EdgeModifier.Folder - ); - - let path = ""; - - if (folderEdge) { - if ( - folderEdge.content === null || - folderEdge.content === undefined || - typeof folderEdge.content !== "string" - ) { - this.error(`Folder arrow has invalid content.`); - return; - } - - path = folderEdge.content; - } - - // Look for incoming edges with a vault modifier of type property - const propertyEdges = incomingEdges.filter( - (edge) => - edge.edgeModifier === EdgeModifier.Property && - edge.text !== this.reference.name - ); - - // If this reference is a create note type, create the note - if (this.reference.type === ReferenceType.CreateNote) { - let noteName; - - // If there are properties edges, create a yaml frontmatter section, and fill it with the properties, where the key is the edge.text and the value is the edge.content - if (propertyEdges.length > 0) { - let yamlFrontmatter = "---\n"; - - for (const edge of propertyEdges) { - if ( - edge.content === null || - edge.content === undefined || - typeof edge.content !== "string" - ) { - this.error(`Property arrow has invalid content.`); - return; - } - - // If the edge.content is a list (starts with a dash), add a newline and two spaces, and replace all newlines with newlines and two spaces - if (edge.content.startsWith("-")) { - yamlFrontmatter += `${edge.text}: \n ${edge.content - .replace(/\n/g, "\n ") - .trim()}\n`; - } else { - yamlFrontmatter += `${edge.text}: "${edge.content}"\n`; - } - } - - yamlFrontmatter += "---\n"; - - content = yamlFrontmatter + content; - } - - try { - // If there's no fileSystemInterface, throw an error - if (!this.run.fileManager) { - throw new Error("No fileManager found"); - } - - noteName = await this.run.fileManager.createNoteAtExistingPath( - referenceNameEdge.content, - path, - content - ); - } catch (e) { - this.error(`Could not create note: ${e.message}`); - return; - } - - if (!noteName) { - this.error(`"${referenceNameEdge.content}" already exists.`); - } else { - this.reference.name = noteName; - this.reference.type = ReferenceType.Note; - } - } else { - // Transform the reference - this.reference.name = referenceNameEdge.content; - this.reference.type = ReferenceType.Note; - - // If content is not null, edit the note - if (content !== null) { - await this.editContent(content, false); - } - - // If there are property edges, edit the properties - if (propertyEdges.length > 0) { - for (const edge of propertyEdges) { - if ( - edge.content === null || - edge.content === undefined || - typeof edge.content !== "string" - ) { - this.error(`Property arrow has invalid content.`); - return; - } - - await this.editProperty(edge.text, edge.content); - } - } - } - } - - async editContent(newContent: string, append?: boolean): Promise { - if (this.run.isMock) { - return; - } - - if (this.reference) { - if (this.reference.type === ReferenceType.Note) { - // If there's no fileSystemInterface, throw an error - if (!this.run.fileManager) { - throw new Error("No fileManager found"); - } - - const edit = await this.run.fileManager.editNote( - this.reference, - newContent, - this.run.isMock, - append ?? false - ); - - if (edit !== null) { - return; - } else { - this.error( - `Invalid reference. Could not edit note ${this.reference.name}` - ); - } - } else if (this.reference.type === ReferenceType.Selection) { - // If there's no fileSystemInterface, throw an error - if (!this.run.fileManager) { - throw new Error("No fileManager found"); - } - - this.run.fileManager.editSelection(newContent, this.run.isMock); - return; - } else if (this.reference.type === ReferenceType.Floating) { - // Search through all nodes for a floating node with the correct name - for (const objectId in this.graph) { - const object = this.graph[objectId]; - if ( - object instanceof FloatingNode && - object.getName() === this.reference.name - ) { - object.editContent(newContent); - return; - } - } - - this.error( - `Invalid reference. Could not find floating node ${this.reference.name}.\n\nIf you want this node to inject a variable, turn it into a formatter node by wrapping it in ""two sets of double quotes"".` - ); - } else if ( - this.reference.type === ReferenceType.Variable) { - // Search through all nodes for a floating node with the correct name - for (const objectId in this.graph) { - const object = this.graph[objectId]; - if ( - object instanceof FloatingNode && - object.getName() === this.reference.name - ) { - object.editContent(newContent); - return; - } - } - - this.error( - `Invalid reference. Could not find floating node ${this.reference.name}.\n\nIf you want this node to inject a variable, turn it into a formatter node by wrapping it in ""two sets of double quotes"".` - ); - } else if ( - this.reference.type === ReferenceType.CreateNote - ) { - this.error(`Dynamic reference did not process correctly.`); - } - } - } - - async editProperty( - propertyName: string, - newContent: string - ): Promise { - if (this.run.isMock) { - return; - } - - if (this.reference) { - if (this.reference.type === ReferenceType.Note) { - // If there's no fileSystemInterface, throw an error - if (!this.run.fileManager) { - throw new Error("No fileManager found"); - } - - const edit = await this.run.fileManager.editPropertyOfNote( - this.reference.name, - propertyName, - newContent.trim() - ); - - if (edit !== null) { - return; - } else { - this.error( - `Invalid reference. Could not edit property ${propertyName} of note ${this.reference.name}` - ); - } - } else if (this.reference.type === ReferenceType.Floating) { - // Search through all nodes for a floating node with the correct name - - for (const objectId in this.graph) { - const object = this.graph[objectId]; - if ( - object instanceof FloatingNode && - object.getName() === this.reference.name - ) { - object.editProperty(propertyName, newContent.trim()); - return; - } - } - } else if ( - this.reference.type === ReferenceType.Variable || - this.reference.type === ReferenceType.CreateNote - ) { - this.error(`Dynamic reference did not process correctly.`); - } - } - } - - async loadOutgoingEdges( - content: string, - request?: GenericCompletionParams | undefined - ) { - // If this is a floating node, load all outgoing edges with the content - if (this.reference.type === ReferenceType.Floating) { - this.loadOutgoingEdgesFloating(content, request); - return; - } - - for (const edge of this.outgoingEdges) { - const edgeObject = this.graph[edge]; - if (!(edgeObject instanceof CannoliEdge)) { - continue; - } - - if (edgeObject.edgeModifier === EdgeModifier.Property) { - let value; - - if (edgeObject.text.length === 0) { - // If there's no fileSystemInterface, throw an error - if (!this.run.fileManager) { - throw new Error("No fileManager found"); - } - - value = await this.run.fileManager.getAllPropertiesOfNote( - this.reference.name, - true - ); - } else { - // If there's no fileSystemInterface, throw an error - if (!this.run.fileManager) { - throw new Error("No fileManager found"); - } - - // Get value of the property with the same name as the edge - value = await this.run.fileManager.getPropertyOfNote( - this.reference.name, - edgeObject.text, - true - ); - } - - if (value) { - edgeObject.load({ - content: value ?? "", - request: request, - }); - } - } else if (edgeObject.edgeModifier === EdgeModifier.Note) { - // Load the edge with the name of the note - edgeObject.load({ - content: `${this.reference.name}`, - request: request, - }); - } else if (edgeObject.edgeModifier === EdgeModifier.Folder) { - // If there's no fileSystemInterface, throw an error - if (!this.run.fileManager) { - throw new Error("No fileManager found"); - } - - const path = await this.run.fileManager.getNotePath( - this.reference.name - ); - - if (path) { - edgeObject.load({ - content: path, - request: request, - }); - } - } else if ( - edgeObject instanceof CannoliEdge && - !(edgeObject instanceof ChatResponseEdge) - ) { - edgeObject.load({ - content: content, - request: request, - }); - } - } - } - - loadOutgoingEdgesFloating( - content: string, - request?: GenericCompletionParams | undefined - ) { - for (const edge of this.outgoingEdges) { - const edgeObject = this.graph[edge]; - if (!(edgeObject instanceof CannoliEdge)) { - continue; - } - - // If the edge has a note modifier, load it with the name of the floating node - if (edgeObject.edgeModifier === EdgeModifier.Note) { - edgeObject.load({ - content: `${this.reference.name}`, - request: request, - }); - } else if (edgeObject.edgeModifier === EdgeModifier.Property) { - // Find the floating node with the same name as this reference - let propertyContent = ""; - - for (const objectId in this.graph) { - const object = this.graph[objectId]; - if ( - object instanceof FloatingNode && - object.getName() === this.reference.name - ) { - propertyContent = object.getProperty(edgeObject.text); - } - } - - if (propertyContent) { - edgeObject.load({ - content: propertyContent, - request: request, - }); - } - } else if ( - edgeObject instanceof CannoliEdge && - !(edgeObject instanceof ChatResponseEdge) - ) { - edgeObject.load({ - content: content, - request: request, - }); - } - } - } - - logDetails(): string { - return super.logDetails() + `Subtype: Reference\n`; - } -} - -const HTTPConfigSchema = z.object({ - url: z.string().optional(), - method: z.string().optional(), - headers: z.string().optional(), - catch: z.coerce.boolean().optional(), - timeout: z.coerce.number().optional(), -}).passthrough(); - -export type HttpConfig = z.infer; - -// Default http config -const defaultHttpConfig: HttpConfig = { - catch: true, - timeout: 30000, -}; - -export class HttpNode extends ContentNode { - receiveInfo: ReceiveInfo | undefined; - - constructor( - nodeData: - | VerifiedCannoliCanvasFileData - | VerifiedCannoliCanvasLinkData - | VerifiedCannoliCanvasTextData, - fullCanvasData: VerifiedCannoliCanvasData - ) { - super(nodeData, fullCanvasData); - this.receiveInfo = nodeData.cannoliData.receiveInfo - } - - setReceiveInfo(info: ReceiveInfo) { - this.receiveInfo = info; - const data = this.canvasData.nodes.find((node) => node.id === this.id) as AllVerifiedCannoliCanvasNodeData; - data.cannoliData.receiveInfo = info; - } - - logDetails(): string { - return super.logDetails() + `Subtype: Http\n`; - } - - private async prepareAndExecuteAction( - action: Action, - namedActionContent: string | null, - config: HttpConfig, - isLongAction: boolean = false - ): Promise { - const { args: argNames, optionalArgs } = this.getActionArgs(action); - - const variableValues = this.getVariableValues(true); - - let isFirstArg = true; - - // Create an object to hold the argument values - const args: ActionArgs = {}; - - // Get the value for each arg name from the variables, and error if any arg is missing - for (const argName of argNames) { - // If the arg has argInfo and its category is extra, skip - if (action.argInfo && action.argInfo[argName] && action.argInfo[argName].category === "extra") { - continue; - } - - // If the arg has an argInfo and its category is files, give it the filesystem interface - if (action.argInfo && action.argInfo[argName] && action.argInfo[argName].category === "fileManager") { - // If the filesystemInterface is null, error - if (!this.run.fileManager) { - return new Error(`The action "${action.name}" requires a file interface, but there isn't one in this run.`); - } - args[argName] = this.run.fileManager; - continue; - } - - // If the arg has an argInfo and its category is fetcher, give it the responseTextFetcher - if (action.argInfo && action.argInfo[argName] && action.argInfo[argName].category === "fetcher") { - args[argName] = this.run.fetcher; - continue; - } - - // If the argName is in the configKeys, get the value from the config - if (action.argInfo && action.argInfo[argName] && action.argInfo[argName].category === "config") { - // Error if the config is not set - if (!config[argName] && !optionalArgs[argName]) { - return new Error( - `Missing value for config parameter "${argName}" in available config. This action "${action.name}" accepts the following config keys:\n${Object.keys(action.argInfo) - .filter((arg) => action.argInfo?.[arg].category === "config") - .map((arg) => ` - ${arg} ${optionalArgs[arg] ? '(optional)' : ''}`) - .join('\n')}` - ); - } - - if (config[argName]) { - if (action.argInfo[argName].type === "number") { - args[argName] = this.coerceValue(config[argName] as string, argName, action.argInfo[argName].type); - } else { - args[argName] = config[argName] as string; - } - } - continue; - } - - // If the argName is in the secretKeys, get the value from the config - if (action.argInfo && action.argInfo[argName] && action.argInfo[argName].category === "secret") { - // Error if the secret is not set - if (!config[argName] && !optionalArgs[argName]) { - return new Error( - `Missing value for secret parameter "${argName}" in the cannoli environment.\n\nYou can set these in the "Secrets" section of the Cannoli settings. LLM provider and Val Town keys are currently pulled from their respective settings.\n\nThis action "${action.name}" accepts the following secret keys:\n${Object.keys(action.argInfo) - .filter((arg) => action.argInfo?.[arg].category === "secret") - .map((arg) => ` - ${arg} ${optionalArgs[arg] ? '(optional)' : ''}`) - .join('\n')}` - ); - } - - if (config[argName]) { - if (action.argInfo[argName].type === "number") { - args[argName] = this.coerceValue(config[argName] as string, argName, action.argInfo[argName].type); - } else { - args[argName] = config[argName] as string; - } - } - continue; - } - - if (isFirstArg && namedActionContent) { - isFirstArg = false; - - if (action.argInfo && action.argInfo[argName] && action.argInfo[argName].type) { - args[argName] = this.coerceValue(namedActionContent, argName, action.argInfo[argName].type); - } else { - args[argName] = namedActionContent; - } - continue; - } - - const variableValue = variableValues.find((variableValue) => variableValue.name === argName); - if (!variableValue && !optionalArgs[argName]) { - return new Error( - `Missing value for variable "${argName}" in available arrows. This action "${action.name}" accepts the following variables:\n${argNames - .map((arg) => ` - ${arg} ${optionalArgs[arg] ? '(optional)' : ''}`) - .join('\n')}` - ); - } - - if (variableValue) { - if (action.argInfo && action.argInfo[argName] && action.argInfo[argName].type) { - args[argName] = this.coerceValue(variableValue.content || "", argName, action.argInfo[argName].type); - } else { - args[argName] = variableValue.content || ""; - } - } - } - - const extraArgs: Record = {}; - - // Collect extra arguments - for (const variableValue of variableValues) { - if (!argNames.includes(variableValue.name)) { - extraArgs[variableValue.name] = variableValue.content || ""; - } - } - - // If the action has an "extra" category, add the extraArgs to the args - if (action.argInfo) { - for (const [argName, argInfo] of Object.entries(action.argInfo)) { - if (argInfo.category === "extra") { - args[argName] = extraArgs; - } - } - } - - if (this.run.isMock) { - if (isLongAction) { - return { content: "This is a mock response" }; - } - - if (action.resultKeys) { - // Make an object with the keys and values - const result = action.resultKeys.reduce>((acc, key) => { - acc[key] = "This is a mock response"; - return acc; - }, {}); - return result; - } - - return "This is a mock response"; - } - - return await action.function(args); - } - - private coerceValue(value: string, argName: string, type: "number" | "boolean" | "string" | string[] | undefined): number | boolean | string { - if (type === "number") { - const numberValue = parseFloat(value); - if (isNaN(numberValue)) { - this.error(`Invalid number value: "${value}" for variable: "${argName}"`); - } - return numberValue; - } else if (type === "boolean") { - if (value !== "true" && value !== "false") { - this.error(`Invalid boolean value: "${value}" for variable: "${argName}"`); - } - return value === "true"; - } else if (Array.isArray(type)) { - if (!type.includes(value)) { - this.error(`Invalid value: "${value}" for variable: "${argName}". Expected one of:\n${type.map((t) => ` - ${t}`).join("\n")}`); - } - return value; - } else { - return value; - } - } - - coerceActionResponseToString(result: ActionResponse): string | Error { - if (result === undefined || result === null) { - return ""; - } - if (result instanceof Error) { - return result; - } - else if (typeof result === "string") { - return result; - } - else if (Array.isArray(result)) { - return JSON.stringify(result); - } - else if (typeof result === "object") { - const objectKeys = Object.keys(result); - - // Check if there are any outgoing edges whose text isn't a key in the object - const outgoingEdgeNames = this.outgoingEdges.map((edge) => this.graph[edge].text); - const keysNotInObject = outgoingEdgeNames.filter((name) => !objectKeys.includes(name)); - - // If there are, error - if (keysNotInObject.length > 0) { - return new Error(`This action returns multiple variables, but there are outgoing arrows that don't match any names of the variables. The variables are: ${objectKeys.join(", ")}. The incorrect outgoing arrows are: ${keysNotInObject.join(", ")}.`); - } - - return JSON.stringify(result); - } - - return new Error(`Action returned an unknown type: ${typeof result}.`); - } - - async execute(): Promise { - const overrides = this.getConfig(HTTPConfigSchema) as HttpConfig; - if (overrides instanceof Error) { - this.error(overrides.message); - return; - } - - const config = { ...this.run.config, ...this.run.secrets, ...defaultHttpConfig, ...overrides }; - - this.executing(); - - const content = await this.processReferences([], true); - - let maybeActionName = this.getName(content); - let namedActionContent = null; - - if (maybeActionName !== null) { - maybeActionName = maybeActionName.toLowerCase().trim(); - namedActionContent = this.getContentCheckName(content); - } else { - maybeActionName = content.toLowerCase().trim(); - } - - if (this.run.actions !== undefined && this.run.actions.length > 0) { - const action = this.run.actions.find((action) => action.name.toLowerCase().trim() === maybeActionName); - - if (action) { - let actionResponse: ActionResponse; - - if (action.receive && this.receiveInfo) { - actionResponse = await this.handleReceiveFunction(action); - } else { - actionResponse = await this.prepareAndExecuteAction(action, namedActionContent, config); - - if (actionResponse instanceof Error) { - if (config.catch) { - this.error(actionResponse.message); - return; - } else { - actionResponse = actionResponse.message; - } - } - - if (action.receive) { - this.setReceiveInfo(actionResponse ?? {}); - actionResponse = await this.handleReceiveFunction(action); - } else { - actionResponse = this.coerceActionResponseToString(actionResponse); - } - } - - if (actionResponse instanceof Error) { - this.error(actionResponse.message); - return; - } - - this.loadOutgoingEdges(actionResponse); - this.completed(); - return; - } - } - - const request = this.parseContentToRequest(content, config); - if (request instanceof Error) { - this.error(request.message); - return; - } - - let response = await this.run.executeHttpRequest(request, config.timeout as number); - - if (response instanceof Error) { - if (config.catch) { - this.error(response.message); - return; - } - response = response.message; - } - - this.loadOutgoingEdges(response); - this.completed(); - } - - private async handleReceiveFunction(action: Action): Promise { - let receiveResponse: string | Error; - - if (this.run.isMock) { - if (action.resultKeys) { - const result = action.resultKeys.reduce>((acc, key) => { - acc[key] = "This is a mock response"; - return acc; - }, {}); - receiveResponse = this.coerceActionResponseToString(result); - } else { - receiveResponse = "This is a mock response"; - } - } else { - const result = await action.receive!(this.receiveInfo!); - receiveResponse = this.coerceActionResponseToString(result); - } - - return receiveResponse; - } - - - getActionArgs(action: Action): { args: string[], optionalArgs: Record } { - const stringifiedFn = action.function.toString(); - // Match the function body to find the destructured object keys - const argsMatch = stringifiedFn.match(/\(\s*{([^}]*)}\s*\)\s*=>/); - const args = argsMatch ? argsMatch[1] : ""; - - const requiredArgs = args ? args.split(',').filter(arg => !arg.includes('=')).map((arg: string) => arg.trim()) : []; - const optionalArgs = args ? args.split(',').filter(arg => arg.includes('=')).map((arg: string) => arg.trim().split("=")[0].trim()) : []; - const optionalArgsObject: Record = {}; - optionalArgs.forEach(arg => optionalArgsObject[arg] = true); - - return { args: [...requiredArgs, ...optionalArgs], optionalArgs: optionalArgsObject }; - } - - private parseContentToRequest(content: string, config: HttpConfig): HttpRequest | Error { - // If the url config is set, look for the method and headers, and interpret the content as the body - if (config.url) { - const request: HttpRequest = { - url: config.url, - method: config.method || "POST", - headers: config.headers, - body: content, - }; - return request; - } - - // If the content is wrapped in triple backticks with or without a language identifier, remove them - content = content.replace(/^```[^\n]*\n([\s\S]*?)\n```$/, '$1').trim(); - - if (typeof content === "string" && (content.startsWith("http://") || content.startsWith("https://"))) { - return { url: content, method: "GET" }; - } - - try { - const request = JSON.parse(content); - - // Evaluate the request - try { - // Check that the template has a url and method - if (!request.url || !request.method) { - return new Error(`Request is missing a URL or method.`); - } - - if (request.headers && typeof request.headers !== "string") { - request.headers = JSON.stringify(request.headers); - } - - if (request.body && typeof request.body !== "string") { - request.body = JSON.stringify(request.body); - } - - return request; - } catch (e) { - return new Error(`Action node does not have a valid HTTP request.`); - } - - - } catch (e) { - // Continue to next parsing method - } - - const variables = this.getVariables(); - const template = this.getTemplate(content); - if (template instanceof Error) { - return template; - } - - const request = this.convertTemplateToRequest(template, variables); - if (request instanceof Error) { - return request; - } - - return request; - } - - private getVariables(): string | Record | null { - let variables: string | Record | null = null; - - const variableValues = this.getVariableValues(false); - if (variableValues.length > 0) { - variables = {}; - for (const variableValue of variableValues) { - variables[variableValue.name] = variableValue.content || ""; - } - - } - - return variables; - } - - private getTemplate(name: string): HttpTemplate | Error { - for (const objectId in this.graph) { - const object = this.graph[objectId]; - if (object instanceof FloatingNode && object.getName() === name) { - // If the text is wrapped in triple backticks with or without a language identifier, remove them - const text = object.getContent().replace(/^```[^\n]*\n([\s\S]*?)\n```$/, '$1').trim(); - - try { - const template = JSON.parse(text) as HttpTemplate; - - // Check that the template has a url and method - if (!template.url || !template.method) { - return new Error(`Floating node "${name}" does not have a valid HTTP template.`); - } - - if (template.headers && typeof template.headers !== "string") { - template.headers = JSON.stringify(template.headers); - } - - const bodyValue = template.body ?? template.bodyTemplate; - - if (bodyValue && typeof bodyValue !== "string") { - template.body = JSON.stringify(bodyValue); - } - - return template; - } catch (e) { - return new Error(`Floating node "${name}" could not be parsed as an HTTP template.`); - } - - } - } - - const settingsTemplate = this.run.httpTemplates.find((template) => template.name === name); - if (!settingsTemplate) { - return new Error(`Could not get HTTP template with name "${name}" from floating nodes or pre-set templates.`); - } - - return settingsTemplate; - } - - private convertTemplateToRequest( - template: HttpTemplate, - variables: string | Record | null - ): HttpRequest | Error { - const url = this.replaceVariables(template.url, variables); - if (url instanceof Error) return url; - - const method = this.replaceVariables(template.method, variables); - if (method instanceof Error) return method; - - let headers: string | Error | undefined; - if (template.headers) { - headers = this.replaceVariables(template.headers, variables); - if (headers instanceof Error) return headers; - } - - const bodyTemplate = template.body ?? template.bodyTemplate; - let body: string | Error = ""; - - if (bodyTemplate) { - body = this.parseBodyTemplate(bodyTemplate, variables || ""); - if (body instanceof Error) { - return body; - } - } - - return { - url, - method, - headers: headers ? headers as string : undefined, - body: method.toLowerCase() !== "get" ? body : undefined, - }; - } - - private replaceVariables(template: string, variables: string | Record | null): string | Error { - template = String(template); - - const variablesInTemplate = (template.match(/\{\{.*?\}\}/g) || []).map( - (v) => v.slice(2, -2) - ); - - if (typeof variables === "string") { - return template.replace(/{{.*?}}/g, variables); - } - - if (variables && typeof variables === "object") { - for (const variable of variablesInTemplate) { - if (!(variable in variables)) { - return new Error( - `Missing value for variable "${variable}" in available arrows. This part of the template requires the following variables:\n${variablesInTemplate - .map((v) => ` - ${v}`) - .join("\n")}` - ); - } - template = template.replace(new RegExp(`{{${variable}}}`, "g"), variables[variable]); - } - } - - return template; - } - - private parseBodyTemplate( - template: string, - body: string | Record - ): string | Error { - template = String(template); - - const variablesInTemplate = (template.match(/\{\{.*?\}\}/g) || []).map( - (v) => v.slice(2, -2) - ); - - let parsedTemplate = template; - - if (typeof body === "object") { - for (const variable of variablesInTemplate) { - if (!(variable in body)) { - return new Error( - `Missing value for variable "${variable}" in available arrows. This body template requires the following variables:\n${variablesInTemplate - .map((v) => ` - ${v}`) - .join("\n")}` - ); - } - parsedTemplate = parsedTemplate.replace( - new RegExp(`{{${variable}}}`, "g"), - body[variable].replace(/\\/g, '\\\\') - .replace(/\n/g, "\\n") - .replace(/"/g, '\\"') - .replace(/\t/g, '\\t') - ); - } - } else { - for (const variable of variablesInTemplate) { - parsedTemplate = parsedTemplate.replace( - new RegExp(`{{${variable}}}`, "g"), - body.replace(/\\/g, '\\\\') - .replace(/\n/g, "\\n") - .replace(/"/g, '\\"') - .replace(/\t/g, '\\t') - ); - } - } - - return parsedTemplate; - } -} - -const SearchConfigSchema = z.object({ - limit: z.coerce.number().optional(), -}).passthrough(); - -export type SearchConfig = z.infer; - -export class SearchNode extends ContentNode { - logDetails(): string { - return super.logDetails() + `Subtype: Search\n`; - } - - async execute(): Promise { - const overrides = this.getConfig(HTTPConfigSchema) as HttpConfig; - if (overrides instanceof Error) { - this.error(overrides.message); - return; - } - - const config = { ...defaultHttpConfig, ...overrides }; - - this.executing(); - - const content = await this.processReferences([], true); - - if (this.run.isMock) { - this.loadOutgoingEdges("[Mock response]"); - this.completed(); - return; - } - - let output: string; - - const results = await this.search(content, config); - - if (results instanceof Error) { - if (config.catch) { - this.error(results.message); - return; - } - output = results.message; - } else { - // If there are any outgoing edges of type Item from this node, output should be a stringified json array - if (this.outgoingEdges.some((edge) => this.graph[edge].type === EdgeType.Item)) { - output = JSON.stringify(results); - } else { - output = results.join("\n\n"); - } - } - - this.loadOutgoingEdges(output); - this.completed(); - } - - async search(query: string, config: SearchConfig): Promise { - return new Error("Search nodes not implemented."); - } -} - -export class FormatterNode extends ContentNode { - logDetails(): string { - return super.logDetails() + `Subtype: Formatter\n`; - } - - async execute(): Promise { - this.executing(); - - const content = await this.processReferences(); - - // Take off the first 2 and last 2 characters (the double double quotes) - const processedContent = content.slice(2, -2); - - // Load all outgoing edges - this.loadOutgoingEdges(processedContent); - - this.completed(); - } -} - -export class FloatingNode extends CannoliNode { - constructor( - nodeData: - | VerifiedCannoliCanvasTextData - | VerifiedCannoliCanvasLinkData - | VerifiedCannoliCanvasFileData, - fullCanvasData: VerifiedCannoliCanvasData - ) { - super(nodeData, fullCanvasData); - this.setStatus(CannoliObjectStatus.Complete); - } - - dependencyCompleted(dependency: CannoliObject): void { - return; - } - - dependencyRejected(dependency: CannoliObject): void { - return; - } - - async execute() { - this.completed(); - } - - getName(): string { - const firstLine = this.text.split("\n")[0].trim(); - // Take the first and last characters off the first line - return firstLine.substring(1, firstLine.length - 1); - } - - // Content is everything after the first line - getContent(): string { - const firstLine = this.text.split("\n")[0]; - return this.text.substring(firstLine.length + 1); - } - - editContent(newContent: string): void { - const firstLine = this.text.split("\n")[0]; - this.setText(`${firstLine}\n${newContent}`); - - const event = new CustomEvent("update", { - detail: { obj: this, status: this.status }, - }); - this.dispatchEvent(event); - } - - editProperty(propertyName: string, newContent: string): void { - // Find the frontmatter from the content - const frontmatter = this.getContent().split("---")[1]; - - if (!frontmatter) { - return; - } - - const parsedFrontmatter: Record = yaml.load( - frontmatter - ) as Record; - - // If the parsed frontmatter is null, return - if (!parsedFrontmatter) { - return; - } - - // Set the property to the new content - parsedFrontmatter[propertyName] = newContent; - - // Stringify the frontmatter and add it back to the content - const newFrontmatter = yaml.dump(parsedFrontmatter); - - const newProps = `---\n${newFrontmatter}---\n${this.getContent().split("---")[2] - }`; - - this.editContent(newProps); - } - - getProperty(propertyName: string): string { - // If property name is empty, return the entire frontmatter - if (propertyName.length === 0) { - return this.getContent().split("---")[1]; - } - - // Find the frontmatter from the content - const frontmatter = this.getContent().split("---")[1]; - - if (!frontmatter) { - return ""; - } - - const parsedFrontmatter: Record = yaml.load( - frontmatter - ) as Record; - - // If the parsed frontmatter is null, return - if (!parsedFrontmatter) { - return ""; - } - - return parsedFrontmatter[propertyName]; - } - - logDetails(): string { - return ( - super.logDetails() + - `Type: Floating\nName: ${this.getName()}\nContent: ${this.getContent()}\n` - ); - } -} diff --git a/packages/cannoli-core/src/models/object.ts b/packages/cannoli-core/src/models/object.ts deleted file mode 100644 index de25040..0000000 --- a/packages/cannoli-core/src/models/object.ts +++ /dev/null @@ -1,442 +0,0 @@ -import type { CannoliEdge } from "./edge"; -import type { CannoliGroup } from "./group"; -import type { Run } from "../run"; -import { - AllVerifiedCannoliCanvasNodeData, - CallNodeType, - CannoliGraph, - CannoliObjectKind, - CannoliObjectStatus, - EdgeType, - GroupType, - NodeType, - VerifiedCannoliCanvasData, - VerifiedCannoliCanvasEdgeData, - VerifiedCannoliCanvasGroupData, -} from "./graph"; -import { getIncomingEdgesFromData, getOutgoingEdgesFromData } from "src/factory"; -export class CannoliObject extends EventTarget { - run: Run; - id: string; - text: string; - status: CannoliObjectStatus; - dependencies: string[]; - graph: Record; - cannoliGraph: CannoliGraph; - canvasData: VerifiedCannoliCanvasData; - originalObject: string | null; - kind: CannoliObjectKind; - type: EdgeType | NodeType | GroupType; - - constructor( - data: AllVerifiedCannoliCanvasNodeData | VerifiedCannoliCanvasEdgeData, - canvasData: VerifiedCannoliCanvasData - ) { - super(); - this.id = data.id; - this.text = data.cannoliData.text; - this.status = data.cannoliData.status; - this.dependencies = data.cannoliData.dependencies; - this.originalObject = data.cannoliData.originalObject; - this.kind = data.cannoliData.kind; - this.type = data.cannoliData.type; - this.canvasData = canvasData; - } - - setRun(run: Run) { - this.run = run; - } - - setGraph(graph: Record, cannoliGraph: CannoliGraph) { - this.graph = graph; - this.cannoliGraph = cannoliGraph; - } - - setupListeners() { - // For each dependency - for (const dependency of this.dependencies) { - // Set up a listener for the dependency's completion event - this.graph[dependency].addEventListener( - "update", - (event: CustomEvent) => { - // Assuming that 'obj' and 'status' are properties in the detail of the CustomEvent - this.dependencyUpdated( - event.detail.obj, - event.detail.status - ); - } - ); - } - } - - setStatus(status: CannoliObjectStatus) { - this.status = status; - if (this.kind === CannoliObjectKind.Node || this.kind === CannoliObjectKind.Group) { - const data = this.canvasData.nodes.find((node) => node.id === this.id) as AllVerifiedCannoliCanvasNodeData; - data.cannoliData.status = status; - - if (this.type === CallNodeType.StandardCall || this.type === CallNodeType.Choose || this.type === CallNodeType.Form) { - if (status === CannoliObjectStatus.Pending) { - if (this.run.config && this.run.config.contentIsColorless) { - data.color = "6"; - } else { - data.color = undefined; - } - } else if (status === CannoliObjectStatus.Executing) { - data.color = "3"; - } else if (status === CannoliObjectStatus.Complete) { - data.color = "4"; - } - } - } else if (this.kind === CannoliObjectKind.Edge) { - const data = this.canvasData.edges.find((edge) => edge.id === this.id) as VerifiedCannoliCanvasEdgeData; - data.cannoliData.status = status; - } - } - - setText(text: string) { - this.text = text; - if (this.kind === CannoliObjectKind.Node) { - const data = this.canvasData.nodes.find((node) => node.id === this.id) as AllVerifiedCannoliCanvasNodeData; - data.cannoliData.text = text; - data.text = text; - } else if (this.kind === CannoliObjectKind.Group) { - const data = this.canvasData.nodes.find((group) => group.id === this.id) as VerifiedCannoliCanvasGroupData; - data.cannoliData.text = text; - data.label = text; - } - } - - getAllDependencies(): CannoliObject[] { - const dependencies: CannoliObject[] = []; - for (const dependency of this.dependencies) { - dependencies.push(this.graph[dependency]); - } - - return dependencies; - } - - dependencyUpdated(dependency: CannoliObject, status: CannoliObjectStatus) { - switch (status) { - case CannoliObjectStatus.Complete: - this.dependencyCompleted(dependency); - break; - case CannoliObjectStatus.Rejected: - this.dependencyRejected(dependency); - break; - case CannoliObjectStatus.Executing: - this.dependencyExecuting(dependency); - break; - default: - break; - } - } - - allDependenciesComplete(): boolean { - // Get the dependencies as objects - const dependencies = this.getAllDependencies(); - - // For each dependency - for (const dependency of dependencies) { - // New logic for edges with versions property - if (this.cannoliGraph.isEdge(dependency) && dependency.versions !== undefined) { - for (const otherDependency of dependencies) { - if ( - this.cannoliGraph.isEdge(otherDependency) && - otherDependency.text === dependency.text && - (otherDependency.status !== CannoliObjectStatus.Complete && - otherDependency.status !== CannoliObjectStatus.Rejected) - ) { - // If any other edge with the same name is not complete or rejected, return false - return false; - } - } - } - - if (dependency.status !== CannoliObjectStatus.Complete) { - // If the dependency is a non-logging edge - if ( - this.cannoliGraph.isEdge(dependency) && - dependency.type !== EdgeType.Logging - ) { - let redundantComplete = false; - - // Check if there are any other edge dependencies that share the same name and type which are complete - for (const otherDependency of dependencies) { - if ( - this.cannoliGraph.isEdge(otherDependency) && - otherDependency.text === dependency.text && - otherDependency.type === dependency.type && - otherDependency.status === - CannoliObjectStatus.Complete - ) { - // If there are, set redundantComplete to true - redundantComplete = true; - break; - } - } - - // If redundantComplete is false, return false - if (!redundantComplete) { - return false; - } - } else { - // If the dependency is not an edge, return false - return false; - } - } - - - } - return true; - } - - allEdgeDependenciesComplete(): boolean { - // Get the dependencies as objects - const dependencies = this.getAllDependencies(); - - // For each dependency - for (const dependency of dependencies) { - // If the dependency it's not an edge, continue - if (!this.cannoliGraph.isEdge(dependency)) { - continue; - } - - if (dependency.status !== CannoliObjectStatus.Complete) { - // If the dependency is a non-logging edge - if ( - this.cannoliGraph.isEdge(dependency) && - dependency.type !== EdgeType.Logging - ) { - let redundantComplete = false; - - // Check if there are any other edge dependencies that share the same name which are complete - for (const otherDependency of dependencies) { - if ( - this.cannoliGraph.isEdge(otherDependency) && - otherDependency.text === dependency.text && - otherDependency.status === - CannoliObjectStatus.Complete - ) { - // If there are, set redundantComplete to true - redundantComplete = true; - break; - } - } - - // If redundantComplete is false, return false - if (!redundantComplete) { - return false; - } - } else { - // If the dependency is not an edge, return false - return false; - } - } - } - return true; - } - - executing() { - this.setStatus(CannoliObjectStatus.Executing); - const event = new CustomEvent("update", { - detail: { obj: this, status: CannoliObjectStatus.Executing }, - }); - this.dispatchEvent(event); - } - - completed() { - this.setStatus(CannoliObjectStatus.Complete); - const event = new CustomEvent("update", { - detail: { obj: this, status: CannoliObjectStatus.Complete }, - }); - this.dispatchEvent(event); - } - - pending() { - this.setStatus(CannoliObjectStatus.Pending); - const event = new CustomEvent("update", { - detail: { obj: this, status: CannoliObjectStatus.Pending }, - }); - this.dispatchEvent(event); - } - - reject() { - this.setStatus(CannoliObjectStatus.Rejected); - const event = new CustomEvent("update", { - detail: { obj: this, status: CannoliObjectStatus.Rejected }, - }); - this.dispatchEvent(event); - } - - tryReject() { - // Check all dependencies - const shouldReject = this.getAllDependencies().every((dependency) => { - if (dependency.status === CannoliObjectStatus.Rejected) { - // If the dependency is an edge - if (this.cannoliGraph.isEdge(dependency)) { - let redundantNotRejected = false; - - // Check if there are any other edge dependencies that share the same name and are not rejected - for (const otherDependency of this.getAllDependencies()) { - if ( - this.cannoliGraph.isEdge(otherDependency) && - otherDependency.text === dependency.text && - otherDependency.status !== - CannoliObjectStatus.Rejected - ) { - // If there are, set redundantNotRejected to true and break the loop - redundantNotRejected = true; - break; - } - } - - // If redundantNotRejected is true, return true to continue the evaluation - if (redundantNotRejected) { - return true; - } - } - - // If the dependency is not an edge or no redundancy was found, return false to reject - return false; - } - - // If the current dependency is not rejected, continue the evaluation - return true; - }); - - // If the object should be rejected, call the reject method - if (!shouldReject) { - this.reject(); - } - } - - ensureStringLength(str: string, maxLength: number): string { - if (str.length > maxLength) { - return str.substring(0, maxLength - 3) + "..."; - } else { - return str; - } - } - - reset() { - this.setStatus(CannoliObjectStatus.Pending); - const event = new CustomEvent("update", { - detail: { obj: this, status: CannoliObjectStatus.Pending }, - }); - this.dispatchEvent(event); - } - - dependencyRejected(dependency: CannoliObject) { - this.tryReject(); - } - - dependencyCompleted(dependency: CannoliObject) { } - - dependencyExecuting(dependency: CannoliObject) { } - - async execute() { } - - logDetails(): string { - let dependenciesString = ""; - for (const dependency of this.dependencies) { - dependenciesString += `\t"${this.graph[dependency].text}"\n`; - } - - return `Dependencies:\n${dependenciesString}\n`; - } - - validate() { } -} - -export class CannoliVertex extends CannoliObject { - outgoingEdges: string[]; - incomingEdges: string[]; - groups: string[]; // Sorted from immediate parent to most distant - - constructor(vertexData: AllVerifiedCannoliCanvasNodeData, fullCanvasData: VerifiedCannoliCanvasData) { - super(vertexData, fullCanvasData); - this.outgoingEdges = getOutgoingEdgesFromData(this.id, this.canvasData); - this.incomingEdges = getIncomingEdgesFromData(this.id, this.canvasData); - this.groups = vertexData.cannoliData.groups; - } - - getOutgoingEdges(): CannoliEdge[] { - return this.outgoingEdges.map( - (edge) => this.graph[edge] as CannoliEdge - ); - } - - getIncomingEdges(): CannoliEdge[] { - return this.incomingEdges.map( - (edge) => this.graph[edge] as CannoliEdge - ); - } - - getGroups(): CannoliGroup[] { - return this.groups.map((group) => this.graph[group] as CannoliGroup); - } - - createRectangle(x: number, y: number, width: number, height: number) { - return { - x, - y, - width, - height, - x_right: x + width, - y_bottom: y + height, - }; - } - - encloses( - a: ReturnType, - b: ReturnType - ): boolean { - return ( - a.x <= b.x && - a.y <= b.y && - a.x_right >= b.x_right && - a.y_bottom >= b.y_bottom - ); - } - - overlaps( - a: ReturnType, - b: ReturnType - ): boolean { - const horizontalOverlap = a.x < b.x_right && a.x_right > b.x; - const verticalOverlap = a.y < b.y_bottom && a.y_bottom > b.y; - const overlap = horizontalOverlap && verticalOverlap; - return overlap && !this.encloses(a, b) && !this.encloses(b, a); - } - - error(message: string) { - this.setStatus(CannoliObjectStatus.Error); - const event = new CustomEvent("update", { - detail: { - obj: this, - status: CannoliObjectStatus.Error, - message: message, - }, - }); - this.dispatchEvent(event); - console.error(message); - } - - warning(message: string) { - this.setStatus(CannoliObjectStatus.Warning); - const event = new CustomEvent("update", { - detail: { - obj: this, - status: CannoliObjectStatus.Warning, - message: message, - }, - }); - this.dispatchEvent(event); - console.error(message); // Consider changing this to console.warn(message); - } - - validate() { - super.validate(); - } -} diff --git a/packages/cannoli-core/src/persistor.ts b/packages/cannoli-core/src/persistor.ts index 23a9701..5315246 100644 --- a/packages/cannoli-core/src/persistor.ts +++ b/packages/cannoli-core/src/persistor.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { z } from "zod"; -import { AllVerifiedCannoliCanvasNodeData, VerifiedCannoliCanvasData, VerifiedCannoliCanvasEdgeData } from "./models/graph"; +import { AllVerifiedCannoliCanvasNodeData, VerifiedCannoliCanvasData, VerifiedCannoliCanvasEdgeData } from "./graph"; export const hexColorSchema = z.custom<`#${string}`>(v => { return /^#[0-9a-fA-F]{6}$/.test(v); diff --git a/packages/cannoli-core/src/run.ts b/packages/cannoli-core/src/run.ts index 52f46db..1b9b104 100644 --- a/packages/cannoli-core/src/run.ts +++ b/packages/cannoli-core/src/run.ts @@ -1,6 +1,5 @@ -import { CannoliObject, CannoliVertex } from "./models/object"; import pLimit from "p-limit"; -import { AllVerifiedCannoliCanvasNodeData, CallNodeType, CannoliGraph, CannoliObjectKind, CannoliObjectStatus, ContentNodeType, EdgeType, VerifiedCannoliCanvasData, VerifiedCannoliCanvasEdgeData } from "./models/graph"; +import { AllVerifiedCannoliCanvasNodeData, CallNodeType, CannoliGraph, CannoliObjectKind, CannoliObjectStatus, ContentNodeType, EdgeType, VerifiedCannoliCanvasData, VerifiedCannoliCanvasEdgeData } from "./graph"; import { GenericCompletionParams, GenericCompletionResponse, @@ -14,8 +13,10 @@ import invariant from "tiny-invariant"; import { CannoliFactory } from "./factory"; import { FileManager } from "./fileManager"; import { CanvasData, Persistor, canvasDataSchema } from "./persistor"; -import { CannoliGroup } from "./models/group"; -import { CannoliNode } from "./models/node"; +import { CannoliObject } from "./graph/CannoliObject"; +import { CannoliVertex } from "./graph/objects/CannoliVertex"; +import { CannoliGroup } from "./graph/objects/vertices/CannoliGroup"; +import { CannoliNode } from "./graph/objects/vertices/CannoliNode"; export interface HttpTemplate { id: string; diff --git a/packages/cannoli-plugin/src/main.ts b/packages/cannoli-plugin/src/main.ts index 67c7ab2..2ae8b30 100644 --- a/packages/cannoli-plugin/src/main.ts +++ b/packages/cannoli-plugin/src/main.ts @@ -23,14 +23,14 @@ import { run, bake, CannoliFunctionInfo, - parseCannoliFunctionInfo + parseCannoliFunctionInfo, + BakeResult } from "@deablabs/cannoli-core"; import { cannoliCollege } from "../assets/cannoliCollege"; import { cannoliIcon } from "../assets/cannoliIcon"; import { VaultInterface } from "./vault_interface"; import { CanvasPersistor } from "./canvas"; import { dataviewQuery, smartConnectionsQuery, modalMaker } from "./actions"; -import { BakeResult } from "@deablabs/cannoli-core"; import { Version2Modal } from "./modals/versionTwoModal"; import { ValTownModal } from "./modals/viewVals"; import { EditValModal } from "./modals/editVal";