n8n/packages/editor-ui/src/components/RunDataAi/RunDataAi.vue

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

367 lines
8.6 KiB
Vue
Raw Normal View History

<template>
<div v-if="aiData" :class="$style.container">
<div :class="{ [$style.tree]: true, [$style.slim]: slim }">
<ElTree
:data="executionTree"
:props="{ label: 'node' }"
default-expand-all
:indent="12"
:expand-on-click-node="false"
data-test-id="lm-chat-logs-tree"
@node-click="onItemClick"
>
<template #default="{ node, data }">
<div
:class="{
[$style.treeNode]: true,
[$style.isSelected]: isTreeNodeSelected(data),
}"
:data-tree-depth="data.depth"
:style="{ '--item-depth': data.depth }"
>
<button
v-if="data.children.length"
:class="$style.treeToggle"
@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">
<NodeIcon :node-type="getNodeType(data.node)!" :size="17" />
<span v-if="!slim" v-text="node.label" />
</span>
</n8n-tooltip>
</div>
</template>
</ElTree>
</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`"
data-test-id="lm-chat-logs-entry"
>
<RunDataAiContent :input-data="data" :content-index="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 } from '@/stores/nodeTypes.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
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>