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:
committed by
GitHub
parent
04dfcd73be
commit
00a4b8b0c6
@@ -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>
|
||||
360
packages/editor-ui/src/components/RunDataAi/RunDataAi.vue
Normal file
360
packages/editor-ui/src/components/RunDataAi/RunDataAi.vue
Normal 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>
|
||||
199
packages/editor-ui/src/components/RunDataAi/RunDataAiContent.vue
Normal file
199
packages/editor-ui/src/components/RunDataAi/RunDataAiContent.vue
Normal 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>
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user