mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-22 04:10:01 +00:00
271 lines
7.3 KiB
TypeScript
271 lines
7.3 KiB
TypeScript
import { type LlmTokenUsageData, type IAiDataContent } from '@/Interface';
|
|
import { addTokenUsageData, emptyTokenUsageData } from '@/utils/aiUtils';
|
|
import type {
|
|
IConnections,
|
|
INodeExecutionData,
|
|
ITaskData,
|
|
ITaskDataConnections,
|
|
NodeConnectionType,
|
|
} from 'n8n-workflow';
|
|
import { splitTextBySearch } from '@/utils/stringUtils';
|
|
import { escapeHtml } from 'xss';
|
|
import type MarkdownIt from 'markdown-it';
|
|
import { unescapeAll } from 'markdown-it/lib/common/utils';
|
|
import * as workflowUtils from 'n8n-workflow/common';
|
|
|
|
export interface AIResult {
|
|
node: string;
|
|
runIndex: number;
|
|
data: IAiDataContent | undefined;
|
|
}
|
|
|
|
export interface TreeNode {
|
|
parent?: TreeNode;
|
|
node: string;
|
|
id: string;
|
|
children: TreeNode[];
|
|
depth: number;
|
|
startTime: number;
|
|
runIndex: number;
|
|
consumedTokens: LlmTokenUsageData;
|
|
}
|
|
|
|
function createNode(
|
|
parent: TreeNode | undefined,
|
|
nodeName: string,
|
|
currentDepth: number,
|
|
runIndex: number,
|
|
r?: AIResult,
|
|
children: TreeNode[] = [],
|
|
): TreeNode {
|
|
return {
|
|
parent,
|
|
node: nodeName,
|
|
id: `${nodeName}:${runIndex}`,
|
|
depth: currentDepth,
|
|
startTime: r?.data?.metadata?.startTime ?? 0,
|
|
runIndex,
|
|
children,
|
|
consumedTokens: getConsumedTokens(r?.data),
|
|
};
|
|
}
|
|
|
|
export function getTreeNodeData(
|
|
nodeName: string,
|
|
connectionsBySourceNode: IConnections,
|
|
aiData: AIResult[] | undefined,
|
|
runIndex: number,
|
|
): TreeNode[] {
|
|
return getTreeNodeDataRec(undefined, nodeName, 0, connectionsBySourceNode, aiData, runIndex);
|
|
}
|
|
|
|
function getTreeNodeDataRec(
|
|
parent: TreeNode | undefined,
|
|
nodeName: string,
|
|
currentDepth: number,
|
|
connectionsBySourceNode: IConnections,
|
|
aiData: AIResult[] | undefined,
|
|
runIndex: number,
|
|
): TreeNode[] {
|
|
const connectionsByDestinationNode =
|
|
workflowUtils.mapConnectionsByDestination(connectionsBySourceNode);
|
|
const nodeConnections = connectionsByDestinationNode[nodeName];
|
|
const resultData =
|
|
aiData?.filter((data) => data.node === nodeName && runIndex === data.runIndex) ?? [];
|
|
|
|
if (!nodeConnections) {
|
|
return resultData.map((d) => createNode(parent, nodeName, currentDepth, d.runIndex, d));
|
|
}
|
|
|
|
// Filter AI data to only show executions that were triggered by this node
|
|
// This prevents duplicate entries in logs when a sub-node is connected to multiple root nodes
|
|
// Nodes without source info or with empty source arrays are always included
|
|
const filteredAiData = aiData?.filter(({ data }) => {
|
|
if (!data?.source || data.source.every((source) => source === null)) {
|
|
return true;
|
|
}
|
|
|
|
return data.source.some(
|
|
(source) => source?.previousNode === nodeName && source.previousNodeRun === runIndex,
|
|
);
|
|
});
|
|
|
|
// Get the first level of children
|
|
const connectedSubNodes = workflowUtils.getParentNodes(
|
|
connectionsByDestinationNode,
|
|
nodeName,
|
|
'ALL_NON_MAIN',
|
|
1,
|
|
);
|
|
|
|
const treeNode = createNode(parent, nodeName, currentDepth, runIndex);
|
|
|
|
// Only include sub-nodes which have data
|
|
const children = (filteredAiData ?? []).flatMap((data) =>
|
|
connectedSubNodes.includes(data.node)
|
|
? getTreeNodeDataRec(
|
|
treeNode,
|
|
data.node,
|
|
currentDepth + 1,
|
|
connectionsBySourceNode,
|
|
aiData,
|
|
data.runIndex,
|
|
)
|
|
: [],
|
|
);
|
|
|
|
treeNode.children = children;
|
|
|
|
if (resultData.length) {
|
|
return resultData.map((r) =>
|
|
createNode(parent, nodeName, currentDepth, r.runIndex, r, children),
|
|
);
|
|
}
|
|
|
|
return [treeNode];
|
|
}
|
|
|
|
export function createAiData(
|
|
nodeName: string,
|
|
connectionsBySourceNode: IConnections,
|
|
getWorkflowResultDataByNodeName: (nodeName: string) => ITaskData[] | null,
|
|
): AIResult[] {
|
|
const connectionsByDestinationNode =
|
|
workflowUtils.mapConnectionsByDestination(connectionsBySourceNode);
|
|
return workflowUtils
|
|
.getParentNodes(connectionsByDestinationNode, nodeName, 'ALL_NON_MAIN')
|
|
.flatMap((node) =>
|
|
(getWorkflowResultDataByNodeName(node) ?? []).map((task, index) => ({ node, task, index })),
|
|
)
|
|
.sort((a, b) => {
|
|
// Sort the data by execution index or start time
|
|
if (a.task.executionIndex !== undefined && b.task.executionIndex !== undefined) {
|
|
return a.task.executionIndex - b.task.executionIndex;
|
|
}
|
|
|
|
const aTime = a.task.startTime ?? 0;
|
|
const bTime = b.task.startTime ?? 0;
|
|
|
|
return aTime - bTime;
|
|
})
|
|
.map(({ node, task, index }) => ({
|
|
data: getReferencedData(task, false, true)[0],
|
|
node,
|
|
runIndex: index,
|
|
}));
|
|
}
|
|
|
|
export function getReferencedData(
|
|
taskData: ITaskData,
|
|
withInput: boolean,
|
|
withOutput: boolean,
|
|
): IAiDataContent[] {
|
|
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,
|
|
// Include source information in AI content to track which node triggered the execution
|
|
// This enables filtering in the UI to show only relevant executions
|
|
source: taskData.source,
|
|
metadata: {
|
|
executionTime: taskData.executionTime,
|
|
startTime: taskData.startTime,
|
|
subExecution: taskData.metadata?.subExecution,
|
|
},
|
|
});
|
|
});
|
|
}
|
|
|
|
if (withInput) {
|
|
addFunction(taskData.inputOverride, 'input');
|
|
}
|
|
if (withOutput) {
|
|
addFunction(taskData.data, 'output');
|
|
}
|
|
|
|
return returnData;
|
|
}
|
|
|
|
export function getConsumedTokens(outputRun: IAiDataContent | undefined): LlmTokenUsageData {
|
|
if (!outputRun?.data) {
|
|
return emptyTokenUsageData;
|
|
}
|
|
|
|
const tokenUsage = outputRun.data.reduce<LlmTokenUsageData>(
|
|
(acc: LlmTokenUsageData, curr: INodeExecutionData) => {
|
|
const tokenUsageData = curr.json?.tokenUsage ?? curr.json?.tokenUsageEstimate;
|
|
|
|
if (!tokenUsageData) return acc;
|
|
|
|
return addTokenUsageData(acc, {
|
|
...(tokenUsageData as Omit<LlmTokenUsageData, 'isEstimate'>),
|
|
isEstimate: !!curr.json.tokenUsageEstimate,
|
|
});
|
|
},
|
|
emptyTokenUsageData,
|
|
);
|
|
|
|
return tokenUsage;
|
|
}
|
|
|
|
export function createHtmlFragmentWithSearchHighlight(
|
|
text: string,
|
|
search: string | undefined,
|
|
): string {
|
|
const escaped = escapeHtml(text);
|
|
|
|
return search
|
|
? splitTextBySearch(escaped, search)
|
|
.map((part) => (part.isMatched ? `<mark>${part.content}</mark>` : part.content))
|
|
.join('')
|
|
: escaped;
|
|
}
|
|
|
|
export function createSearchHighlightPlugin(search: string | undefined) {
|
|
return (md: MarkdownIt) => {
|
|
md.renderer.rules.text = (tokens, idx) =>
|
|
createHtmlFragmentWithSearchHighlight(tokens[idx].content, search);
|
|
|
|
md.renderer.rules.code_inline = (tokens, idx, _, __, slf) =>
|
|
`<code${slf.renderAttrs(tokens[idx])}>${createHtmlFragmentWithSearchHighlight(tokens[idx].content, search)}</code>`;
|
|
|
|
md.renderer.rules.code_block = (tokens, idx, _, __, slf) =>
|
|
`<pre${slf.renderAttrs(tokens[idx])}><code>${createHtmlFragmentWithSearchHighlight(tokens[idx].content, search)}</code></pre>\n`;
|
|
|
|
md.renderer.rules.fence = (tokens, idx, options, _, slf) => {
|
|
const token = tokens[idx];
|
|
const info = token.info ? unescapeAll(token.info).trim() : '';
|
|
let langName = '';
|
|
let langAttrs = '';
|
|
|
|
if (info) {
|
|
const arr = info.split(/(\s+)/g);
|
|
langName = arr[0];
|
|
langAttrs = arr.slice(2).join('');
|
|
}
|
|
|
|
const highlighted =
|
|
options.highlight?.(token.content, langName, langAttrs) ??
|
|
createHtmlFragmentWithSearchHighlight(token.content, search);
|
|
|
|
if (highlighted.indexOf('<pre') === 0) {
|
|
return highlighted + '\n';
|
|
}
|
|
|
|
return `<pre><code${slf.renderAttrs(token)}>${highlighted}</code></pre>\n`;
|
|
};
|
|
};
|
|
}
|