feat(core): Add support for building LLM applications (#7235)

This extracts all core and editor changes from #7246 and #7137, so that
we can get these changes merged first.

ADO-1120

[DB Tests](https://github.com/n8n-io/n8n/actions/runs/6379749011)
[E2E Tests](https://github.com/n8n-io/n8n/actions/runs/6379751480)
[Workflow Tests](https://github.com/n8n-io/n8n/actions/runs/6379752828)

---------

Co-authored-by: Jan Oberhauser <jan.oberhauser@gmail.com>
Co-authored-by: Oleg Ivaniv <me@olegivaniv.com>
Co-authored-by: Alex Grozav <alex@grozav.com>
Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <aditya@netroy.in>
This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™
2023-10-02 17:33:43 +02:00
committed by GitHub
parent 04dfcd73be
commit 00a4b8b0c6
93 changed files with 6209 additions and 728 deletions

View File

@@ -0,0 +1,267 @@
<template>
<div :class="$style.block">
<header :class="$style.blockHeader" @click="onBlockHeaderClick">
<button :class="$style.blockToggle">
<font-awesome-icon :icon="isExpanded ? 'angle-down' : 'angle-up'" size="lg" />
</button>
<p :class="$style.blockTitle">{{ capitalize(runData.inOut) }}</p>
<!-- @click.stop to prevent event from bubbling to blockHeader and toggling expanded state when clicking on rawSwitch -->
<el-switch
v-if="contentParsed"
@click.stop
:class="$style.rawSwitch"
active-text="RAW JSON"
v-model="isShowRaw"
/>
</header>
<main
:class="{
[$style.blockContent]: true,
[$style.blockContentExpanded]: isExpanded,
}"
>
<div
:key="index"
v-for="({ parsedContent, raw }, index) in parsedRun"
:class="$style.contentText"
:data-content-type="parsedContent?.type"
>
<template v-if="parsedContent && !isShowRaw">
<template v-if="parsedContent.type === 'json'">
<vue-markdown
:source="jsonToMarkdown(parsedContent.data as JsonMarkdown)"
:class="$style.markdown"
/>
</template>
<template v-if="parsedContent.type === 'markdown'">
<vue-markdown :source="parsedContent.data" :class="$style.markdown" />
</template>
<p
:class="$style.runText"
v-if="parsedContent.type === 'text'"
v-text="parsedContent.data"
/>
</template>
<!-- We weren't able to parse text or raw switch -->
<template v-else>
<div :class="$style.rawContent">
<n8n-icon-button
size="small"
:class="$style.copyToClipboard"
type="secondary"
@click="copyToClipboard(raw)"
:title="$locale.baseText('nodeErrorView.copyToClipboard')"
icon="copy"
/>
<vue-markdown :source="jsonToMarkdown(raw as JsonMarkdown)" :class="$style.markdown" />
</div>
</template>
</div>
</main>
</div>
</template>
<script lang="ts" setup>
import type { IAiDataContent } from '@/Interface';
import { capitalize } from 'lodash-es';
import { ref, onMounted } from 'vue';
import type { ParsedAiContent } from './useAiContentParsers';
import { useAiContentParsers } from './useAiContentParsers';
import VueMarkdown from 'vue-markdown-render';
import { useCopyToClipboard, useI18n, useToast } from '@/composables';
import { NodeConnectionType, type IDataObject } from 'n8n-workflow';
const props = defineProps<{
runData: IAiDataContent;
}>();
const i18n = useI18n();
const contentParsers = useAiContentParsers();
// eslint-disable-next-line @typescript-eslint/no-use-before-define
const isExpanded = ref(getInitialExpandedState());
const isShowRaw = ref(false);
const contentParsed = ref(false);
const parsedRun = ref(undefined as ParsedAiContent | undefined);
function getInitialExpandedState() {
const collapsedTypes = {
input: [NodeConnectionType.AiDocument, NodeConnectionType.AiTextSplitter],
output: [
NodeConnectionType.AiDocument,
NodeConnectionType.AiEmbedding,
NodeConnectionType.AiTextSplitter,
NodeConnectionType.AiVectorStore,
],
};
return !collapsedTypes[props.runData.inOut].includes(props.runData.type);
}
function parseAiRunData(run: IAiDataContent) {
if (!run.data) {
return;
}
const parsedData = contentParsers.parseAiRunData(run.data, run.type);
return parsedData;
}
function isMarkdown(content: JsonMarkdown): boolean {
if (typeof content !== 'string') return false;
const markdownPatterns = [
/^# .+/gm, // headers
/\*{1,2}.+\*{1,2}/g, // emphasis and strong
/\[.+\]\(.+\)/g, // links
/```[\s\S]+```/g, // code blocks
];
return markdownPatterns.some((pattern) => pattern.test(content));
}
function formatToJsonMarkdown(data: string): string {
return '```json\n' + data + '\n```';
}
type JsonMarkdown = string | object | Array<string | object>;
function jsonToMarkdown(data: JsonMarkdown): string {
if (isMarkdown(data)) return data as string;
if (Array.isArray(data) && data.length && typeof data[0] !== 'number') {
const markdownArray = data.map((item: JsonMarkdown) => jsonToMarkdown(item));
return markdownArray.join('\n\n').trim();
}
if (typeof data === 'string') {
return formatToJsonMarkdown(data);
}
return formatToJsonMarkdown(JSON.stringify(data, null, 2));
}
function setContentParsed(content: ParsedAiContent): void {
contentParsed.value = !!content.find((item) => {
if (item.parsedContent?.parsed === true) {
return true;
}
return false;
});
}
function onBlockHeaderClick() {
isExpanded.value = !isExpanded.value;
}
function copyToClipboard(content: IDataObject | IDataObject[]) {
const copyToClipboardFn = useCopyToClipboard();
const { showMessage } = useToast();
try {
copyToClipboardFn(JSON.stringify(content, undefined, 2));
showMessage({
title: i18n.baseText('generic.copiedToClipboard'),
type: 'success',
});
} catch (err) {}
}
onMounted(() => {
parsedRun.value = parseAiRunData(props.runData);
if (parsedRun.value) {
setContentParsed(parsedRun.value);
}
});
</script>
<style lang="scss" module>
.copyToClipboard {
position: absolute;
right: var(--spacing-s);
top: var(--spacing-s);
}
.rawContent {
position: relative;
}
.markdown {
& {
white-space: pre-wrap;
h1 {
font-size: var(--font-size-xl);
line-height: var(--font-line-height-xloose);
}
h2 {
font-size: var(--font-size-l);
line-height: var(--font-line-height-loose);
}
h3 {
font-size: var(--font-size-m);
line-height: var(--font-line-height-regular);
}
pre {
background-color: var(--color-foreground-light);
border-radius: var(--border-radius-base);
line-height: var(--font-line-height-xloose);
padding: var(--spacing-s);
font-size: var(--font-size-s);
white-space: pre-wrap;
}
}
}
.contentText {
padding-top: var(--spacing-s);
font-size: var(--font-size-xs);
// max-height: 100%;
}
.block {
border: 1px solid var(--color-foreground-base);
background: var(--color-background-xlight);
padding: var(--spacing-xs);
border-radius: 4px;
margin-bottom: var(--spacing-2xs);
}
.blockContent {
height: 0;
overflow: hidden;
&.blockContentExpanded {
height: auto;
}
}
.runText {
line-height: var(--font-line-height-regular);
white-space: pre-line;
}
.rawSwitch {
margin-left: auto;
& * {
font-size: var(--font-size-2xs);
}
}
.blockHeader {
display: flex;
gap: var(--spacing-xs);
cursor: pointer;
/* This hack is needed to make the whole surface of header clickable */
margin: calc(-1 * var(--spacing-xs));
padding: var(--spacing-xs);
& * {
user-select: none;
}
}
.blockTitle {
font-size: var(--font-size-2xs);
color: var(--color-text-dark);
}
.blockToggle {
border: none;
background: none;
padding: 0;
}
</style>

View File

@@ -0,0 +1,360 @@
<template>
<div v-if="aiData" :class="$style.container">
<div :class="{ [$style.tree]: true, [$style.slim]: slim }">
<el-tree
:data="executionTree"
:props="{ label: 'node' }"
default-expand-all
:indent="12"
@node-click="onItemClick"
:expand-on-click-node="false"
>
<template #default="{ node, data }">
<div
:class="{
[$style.treeNode]: true,
[$style.isSelected]: isTreeNodeSelected(data),
}"
:data-tree-depth="data.depth"
:style="{ '--item-depth': data.depth }"
>
<button
:class="$style.treeToggle"
v-if="data.children.length"
@click="toggleTreeItem(node)"
>
<font-awesome-icon :icon="node.expanded ? 'angle-down' : 'angle-up'" />
</button>
<n8n-tooltip :disabled="!slim" placement="right">
<template #content>
{{ node.label }}
</template>
<span :class="$style.leafLabel">
<node-icon :node-type="getNodeType(data.node)!" :size="17" />
<span v-text="node.label" v-if="!slim" />
</span>
</n8n-tooltip>
</div>
</template>
</el-tree>
</div>
<div :class="$style.runData">
<div v-if="selectedRun.length === 0" :class="$style.empty">
<n8n-text size="large">
{{
$locale.baseText('ndv.output.ai.empty', {
interpolate: {
node: props.node.name,
},
})
}}
</n8n-text>
</div>
<div v-for="(data, index) in selectedRun" :key="`${data.node}__${data.runIndex}__index`">
<RunDataAiContent :inputData="data" :contentIndex="index" />
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import type { Ref } from 'vue';
import { computed, ref, watch } from 'vue';
import type { ITaskSubRunMetadata, ITaskDataConnections } from 'n8n-workflow';
import { NodeConnectionType } from 'n8n-workflow';
import type { IAiData, IAiDataContent, INodeUi } from '@/Interface';
import { useNodeTypesStore, useWorkflowsStore } from '@/stores';
import NodeIcon from '@/components/NodeIcon.vue';
import RunDataAiContent from './RunDataAiContent.vue';
import { ElTree } from 'element-plus';
interface AIResult {
node: string;
runIndex: number;
data: IAiDataContent | undefined;
}
interface TreeNode {
node: string;
id: string;
children: TreeNode[];
depth: number;
startTime: number;
runIndex: number;
}
export interface Props {
node: INodeUi;
runIndex: number;
hideTitle?: boolean;
slim?: boolean;
}
const props = withDefaults(defineProps<Props>(), { runIndex: 0 });
const workflowsStore = useWorkflowsStore();
const nodeTypesStore = useNodeTypesStore();
const selectedRun: Ref<IAiData[]> = ref([]);
function isTreeNodeSelected(node: TreeNode) {
return selectedRun.value.some((run) => run.node === node.node && run.runIndex === node.runIndex);
}
function getReferencedData(
reference: ITaskSubRunMetadata,
withInput: boolean,
withOutput: boolean,
): IAiDataContent[] {
const resultData = workflowsStore.getWorkflowResultDataByNodeName(reference.node);
if (!resultData?.[reference.runIndex]) {
return [];
}
const taskData = resultData[reference.runIndex];
if (!taskData) {
return [];
}
const returnData: IAiDataContent[] = [];
function addFunction(data: ITaskDataConnections | undefined, inOut: 'input' | 'output') {
if (!data) {
return;
}
Object.keys(data).map((type) => {
returnData.push({
data: data[type][0],
inOut,
type: type as NodeConnectionType,
metadata: {
executionTime: taskData.executionTime,
startTime: taskData.startTime,
},
});
});
}
if (withInput) {
addFunction(taskData.inputOverride, 'input');
}
if (withOutput) {
addFunction(taskData.data, 'output');
}
return returnData;
}
function toggleTreeItem(node: { expanded: boolean }) {
node.expanded = !node.expanded;
}
function onItemClick(data: TreeNode) {
const matchingRun = aiData.value?.find(
(run) => run.node === data.node && run.runIndex === data.runIndex,
);
if (!matchingRun) {
selectedRun.value = [];
return;
}
selectedRun.value = [
{
node: data.node,
runIndex: data.runIndex,
data: getReferencedData(
{
node: data.node,
runIndex: data.runIndex,
},
true,
true,
),
},
];
}
function getNodeType(nodeName: string) {
const node = workflowsStore.getNodeByName(nodeName);
if (!node) {
return null;
}
const nodeType = nodeTypesStore.getNodeType(node?.type);
return nodeType;
}
function selectFirst() {
if (executionTree.value.length && executionTree.value[0].children.length) {
onItemClick(executionTree.value[0].children[0]);
}
}
const createNode = (
nodeName: string,
currentDepth: number,
r?: AIResult,
children: TreeNode[] = [],
): TreeNode => ({
node: nodeName,
id: nodeName,
depth: currentDepth,
startTime: r?.data?.metadata?.startTime ?? 0,
runIndex: r?.runIndex ?? 0,
children,
});
function getTreeNodeData(nodeName: string, currentDepth: number): TreeNode[] {
const { connectionsByDestinationNode } = workflowsStore.getCurrentWorkflow();
const connections = connectionsByDestinationNode[nodeName];
// eslint-disable-next-line @typescript-eslint/no-use-before-define
const resultData = aiData.value?.filter((data) => data.node === nodeName) ?? [];
if (!connections) {
return resultData.map((d) => createNode(nodeName, currentDepth, d));
}
const nonMainConnectionsKeys = Object.keys(connections).filter(
(key) => key !== NodeConnectionType.Main,
);
const children = nonMainConnectionsKeys.flatMap((key) =>
connections[key][0].flatMap((node) => getTreeNodeData(node.node, currentDepth + 1)),
);
if (resultData.length) {
return resultData.map((r) => createNode(nodeName, currentDepth, r, children));
}
children.sort((a, b) => a.startTime - b.startTime);
return [createNode(nodeName, currentDepth, undefined, children)];
}
const aiData = computed<AIResult[] | undefined>(() => {
const resultData = workflowsStore.getWorkflowResultDataByNodeName(props.node.name);
if (!resultData || !Array.isArray(resultData)) {
return;
}
const subRun = resultData[props.runIndex].metadata?.subRun;
if (!Array.isArray(subRun)) {
return;
}
// Extend the subRun with the data and sort by adding execution time + startTime and comparing them
const subRunWithData = subRun.flatMap((run) =>
getReferencedData(run, false, true).map((data) => ({ ...run, data })),
);
subRunWithData.sort((a, b) => {
const aTime = a.data?.metadata?.startTime || 0;
const bTime = b.data?.metadata?.startTime || 0;
return aTime - bTime;
});
return subRunWithData;
});
const executionTree = computed<TreeNode[]>(() => {
const rootNode = props.node;
const tree = getTreeNodeData(rootNode.name, 0);
return tree || [];
});
watch(() => props.runIndex, selectFirst, { immediate: true });
</script>
<style lang="scss" module>
.treeToggle {
border: none;
background-color: transparent;
padding: 0 var(--spacing-3xs);
margin: 0 calc(-1 * var(--spacing-3xs));
cursor: pointer;
}
.leafLabel {
display: flex;
align-items: center;
gap: var(--spacing-3xs);
}
.empty {
padding: var(--spacing-l);
}
.title {
font-size: var(--font-size-s);
margin-bottom: var(--spacing-xs);
}
.tree {
flex-shrink: 0;
min-width: 12.8rem;
height: 100%;
border-right: 1px solid var(--color-foreground-base);
padding-right: var(--spacing-xs);
padding-left: var(--spacing-2xs);
&.slim {
min-width: auto;
}
}
.runData {
width: 100%;
height: 100%;
overflow: auto;
}
.container {
height: 100%;
padding: 0 var(--spacing-xs);
display: flex;
:global(.el-tree > .el-tree-node) {
position: relative;
&:after {
content: '';
position: absolute;
top: 2rem;
bottom: 1.2rem;
left: 0.75rem;
width: 0.125rem;
background-color: var(--color-foreground-base);
}
}
:global(.el-tree-node__expand-icon) {
display: none;
}
:global(.el-tree) {
margin-left: calc(-1 * var(--spacing-xs));
}
:global(.el-tree-node__content) {
margin-left: var(--spacing-xs);
}
}
.isSelected {
background-color: var(--color-foreground-base);
}
.treeNode {
display: inline-flex;
border-radius: var(--border-radius-base);
align-items: center;
gap: var(--spacing-3xs);
padding: var(--spacing-4xs) var(--spacing-3xs);
font-size: var(--font-size-xs);
color: var(--color-text-dark);
margin-bottom: var(--spacing-3xs);
cursor: pointer;
&:hover {
background-color: var(--color-foreground-base);
}
&[data-tree-depth='0'] {
margin-left: calc(-1 * var(--spacing-2xs));
}
&:after {
content: '';
position: absolute;
margin: auto;
background-color: var(--color-foreground-base);
height: 0.125rem;
left: 0.75rem;
width: calc(var(--item-depth) * 0.625rem);
}
}
</style>

View File

@@ -0,0 +1,199 @@
<template>
<div :class="$style.container">
<header :class="$style.header">
<node-icon
v-if="runMeta?.node"
:class="$style.nodeIcon"
:node-type="runMeta.node"
:size="20"
/>
<div :class="$style.headerWrap">
<p :class="$style.title">
{{ inputData.node }}
</p>
<ul :class="$style.meta">
<li v-if="runMeta?.startTimeMs">{{ runMeta?.executionTimeMs }}ms</li>
<li v-if="runMeta?.startTimeMs">
<n8n-tooltip>
<template #content>
{{ new Date(runMeta?.startTimeMs).toLocaleString() }}
</template>
{{
$locale.baseText('runData.aiContentBlock.startedAt', {
interpolate: {
startTime: new Date(runMeta?.startTimeMs).toLocaleTimeString(),
},
})
}}
</n8n-tooltip>
</li>
<li v-if="(consumedTokensSum?.totalTokens ?? 0) > 0">
{{
$locale.baseText('runData.aiContentBlock.tokens', {
interpolate: {
count: consumedTokensSum?.totalTokens.toString()!,
},
})
}}
<n8n-info-tip type="tooltip" theme="info-light" tooltipPlacement="right">
<div>
<n8n-text :bold="true" size="small">
{{ $locale.baseText('runData.aiContentBlock.tokens.prompt') }}
{{
$locale.baseText('runData.aiContentBlock.tokens', {
interpolate: {
count: consumedTokensSum?.promptTokens.toString()!,
},
})
}}
</n8n-text>
<br />
<n8n-text :bold="true" size="small">
{{ $locale.baseText('runData.aiContentBlock.tokens.completion') }}
{{
$locale.baseText('runData.aiContentBlock.tokens', {
interpolate: {
count: consumedTokensSum?.completionTokens.toString()!,
},
})
}}
</n8n-text>
</div>
</n8n-info-tip>
</li>
</ul>
</div>
</header>
<main :class="$style.content" v-for="(run, index) in props.inputData.data" :key="index">
<AiRunContentBlock :runData="run" />
</main>
</div>
</template>
<script lang="ts" setup>
import type { IAiData, IAiDataContent } from '@/Interface';
import { useNodeTypesStore, useWorkflowsStore } from '@/stores';
import type {
IDataObject,
INodeExecutionData,
INodeTypeDescription,
NodeConnectionType,
} from 'n8n-workflow';
import { computed } from 'vue';
import NodeIcon from '@/components/NodeIcon.vue';
import AiRunContentBlock from './AiRunContentBlock.vue';
interface RunMeta {
startTimeMs: number;
executionTimeMs: number;
node: INodeTypeDescription | null;
type: 'input' | 'output';
connectionType: NodeConnectionType;
}
const props = defineProps<{
inputData: IAiData;
contentIndex: number;
}>();
const nodeTypesStore = useNodeTypesStore();
const workflowsStore = useWorkflowsStore();
type TokenUsageData = {
completionTokens: number;
promptTokens: number;
totalTokens: number;
};
const consumedTokensSum = computed(() => {
// eslint-disable-next-line @typescript-eslint/no-use-before-define
const consumedTokensSum1 = outputRun.value?.data?.reduce(
(acc: TokenUsageData, curr: INodeExecutionData) => {
const response = curr.json?.response as IDataObject;
const tokenUsageData = (response?.llmOutput as IDataObject)?.tokenUsage as TokenUsageData;
if (!tokenUsageData) return acc;
return {
completionTokens: acc.completionTokens + tokenUsageData.completionTokens,
promptTokens: acc.promptTokens + tokenUsageData.promptTokens,
totalTokens: acc.totalTokens + tokenUsageData.totalTokens,
};
},
{
completionTokens: 0,
promptTokens: 0,
totalTokens: 0,
},
);
return consumedTokensSum1;
});
function extractRunMeta(run: IAiDataContent) {
const uiNode = workflowsStore.getNodeByName(props.inputData.node);
const nodeType = nodeTypesStore.getNodeType(uiNode?.type ?? '');
const runMeta: RunMeta = {
startTimeMs: run.metadata.startTime,
executionTimeMs: run.metadata.executionTime,
node: nodeType,
type: run.inOut,
connectionType: run.type,
};
return runMeta;
}
const outputRun = computed(() => {
return props.inputData.data.find((r) => r.inOut === 'output');
});
const runMeta = computed(() => {
if (outputRun.value === undefined) {
return;
}
return extractRunMeta(outputRun.value);
});
</script>
<style type="scss" module>
.container {
padding: 0 var(--spacing-s) var(--spacing-s);
}
.nodeIcon {
margin-top: calc(var(--spacing-3xs) * -1);
}
.header {
display: flex;
align-items: center;
gap: var(--spacing-3xs);
margin-bottom: var(--spacing-s);
}
.headerWrap {
display: flex;
flex-direction: column;
}
.title {
display: flex;
align-items: center;
font-size: var(--font-size-s);
gap: var(--spacing-3xs);
color: var(--color-text-dark);
}
.meta {
list-style: none;
display: flex;
align-items: center;
flex-wrap: wrap;
font-size: var(--font-size-xs);
& > li:not(:last-child) {
border-right: 1px solid var(--color-text-base);
padding-right: var(--spacing-3xs);
}
& > li:not(:first-child) {
padding-left: var(--spacing-3xs);
}
}
</style>

View File

@@ -0,0 +1,201 @@
import type { IDataObject, INodeExecutionData } from 'n8n-workflow';
import { isObjectEmpty, NodeConnectionType } from 'n8n-workflow';
interface MemoryMessage {
lc: number;
type: string;
id: string[];
kwargs: {
content: string;
additional_kwargs: Record<string, unknown>;
};
}
interface LmGeneration {
text: string;
message: MemoryMessage;
}
type ExcludedKeys = NodeConnectionType.Main | NodeConnectionType.AiChain;
type AllowedEndpointType = Exclude<NodeConnectionType, ExcludedKeys>;
const fallbackParser = (execData: IDataObject) => ({
type: 'json' as 'json' | 'text' | 'markdown',
data: execData,
parsed: false,
});
const outputTypeParsers: {
[key in AllowedEndpointType]: (execData: IDataObject) => {
type: 'json' | 'text' | 'markdown';
data: unknown;
parsed: boolean;
};
} = {
[NodeConnectionType.AiLanguageModel](execData: IDataObject) {
const response = (execData.response as IDataObject) ?? execData;
if (!response) throw new Error('No response from Language Model');
// Simple LLM output — single string message item
if (
Array.isArray(response?.messages) &&
response?.messages.length === 1 &&
typeof response?.messages[0] === 'string'
) {
return {
type: 'text',
data: response.messages[0],
parsed: true,
};
}
// Use the memory parser if the response is a memory-like(chat) object
if (response.messages && Array.isArray(response.messages)) {
return outputTypeParsers[NodeConnectionType.AiMemory](execData);
}
if (response.generations) {
const generations = response.generations as LmGeneration[];
const content = generations.map((generation) => {
if (generation?.text) return generation.text;
if (Array.isArray(generation)) {
return generation
.map((item: LmGeneration) => item.text ?? item)
.join('\n\n')
.trim();
}
return generation;
});
return {
type: 'json',
data: content,
parsed: true,
};
}
return {
type: 'json',
data: response,
parsed: true,
};
},
[NodeConnectionType.AiTool]: fallbackParser,
[NodeConnectionType.AiMemory](execData: IDataObject) {
const chatHistory =
execData.chatHistory ?? execData.messages ?? execData?.response?.chat_history;
if (Array.isArray(chatHistory)) {
const responseText = chatHistory
.map((content: MemoryMessage) => {
if (content.type === 'constructor' && content.id?.includes('schema') && content.kwargs) {
let message = content.kwargs.content;
if (Object.keys(content.kwargs.additional_kwargs).length) {
message += ` (${JSON.stringify(content.kwargs.additional_kwargs)})`;
}
if (content.id.includes('HumanMessage')) {
message = `**Human:** ${message.trim()}`;
} else if (content.id.includes('AIMessage')) {
message = `**AI:** ${message}`;
} else if (content.id.includes('SystemMessage')) {
message = `**System Message:** ${message}`;
}
if (execData.action && execData.action !== 'getMessages') {
message = `## Action: ${execData.action}\n\n${message}`;
}
return message;
}
return '';
})
.join('\n\n');
return {
type: 'markdown',
data: responseText,
parsed: true,
};
}
return fallbackParser(execData);
},
[NodeConnectionType.AiOutputParser]: fallbackParser,
[NodeConnectionType.AiRetriever]: fallbackParser,
[NodeConnectionType.AiVectorRetriever]: fallbackParser,
[NodeConnectionType.AiVectorStore](execData: IDataObject) {
if (execData.documents) {
return {
type: 'json',
data: execData.documents,
parsed: true,
};
}
return fallbackParser(execData);
},
[NodeConnectionType.AiEmbedding](execData: IDataObject) {
if (execData.documents) {
return {
type: 'json',
data: execData.documents,
parsed: true,
};
}
return fallbackParser(execData);
},
[NodeConnectionType.AiDocument](execData: IDataObject) {
if (execData.documents) {
return {
type: 'json',
data: execData.documents,
parsed: true,
};
}
return fallbackParser(execData);
},
[NodeConnectionType.AiTextSplitter](execData: IDataObject) {
const arrayData = Array.isArray(execData.response)
? execData.response
: [execData.textSplitter];
return {
type: 'text',
data: arrayData.join('\n\n'),
parsed: true,
};
},
};
export type ParsedAiContent = Array<{
raw: IDataObject | IDataObject[];
parsedContent: {
type: 'json' | 'text' | 'markdown';
data: unknown;
parsed: boolean;
} | null;
}>;
export const useAiContentParsers = () => {
const parseAiRunData = (
executionData: INodeExecutionData[],
endpointType: NodeConnectionType,
): ParsedAiContent => {
if ([NodeConnectionType.AiChain, NodeConnectionType.Main].includes(endpointType)) {
return executionData.map((data) => ({ raw: data.json, parsedContent: null }));
}
const contentJson = executionData.map((node) => {
const hasBinarData = !isObjectEmpty(node.binary);
return hasBinarData ? node.binary : node.json;
});
const parser = outputTypeParsers[endpointType as AllowedEndpointType];
if (!parser) return [{ raw: contentJson, parsedContent: null }];
const parsedOutput = contentJson.map((c) => ({ raw: c, parsedContent: parser(c) }));
return parsedOutput;
};
return {
parseAiRunData,
};
};