feat(editor): Logs overview panel (#14045)

This commit is contained in:
Suguru Inoue
2025-03-31 13:19:54 +02:00
committed by GitHub
parent 68d9460f2a
commit d1710a1da3
22 changed files with 912 additions and 123 deletions

View File

@@ -10,7 +10,7 @@ import ChatInput from '@n8n/chat/components/Input.vue';
import { computed, ref } from 'vue';
import { useClipboard } from '@/composables/useClipboard';
import { useToast } from '@/composables/useToast';
import PanelHeader from '@/components/CanvasChat/components/PanelHeader.vue';
import PanelHeader from '@/components/CanvasChat/future/components/PanelHeader.vue';
import { N8nButton, N8nIconButton, N8nTooltip } from '@n8n/design-system';
interface Props {
@@ -357,7 +357,7 @@ async function copySessionId() {
height: 100%;
width: 100%;
overflow: auto;
padding-top: 1.5em;
padding-top: var(--spacing-l);
&:not(:last-child) {
margin-right: 1em;

View File

@@ -32,10 +32,8 @@ const telemetry = useTelemetry();
const { rootStyles, height, chatWidth, onWindowResize, onResizeDebounced, onResizeChatDebounced } =
useResize(container);
const { currentSessionId, messages, sendMessage, refreshSession, displayExecution } = useChatState(
ref(false),
onWindowResize,
);
const { currentSessionId, messages, connectedNode, sendMessage, refreshSession, displayExecution } =
useChatState(ref(false), onWindowResize);
const appStyles = useStyles();
const tooltipZIndex = computed(() => appStyles.APP_Z_INDEXES.ASK_ASSISTANT_FLOATING_BUTTON + 100);
@@ -95,7 +93,7 @@ watch([panelState, height], ([state, h]) => {
:class="[$style.resizeWrapper, panelState === 'closed' ? '' : $style.isOpen]"
@resize="onResizeDebounced"
>
<div ref="container" :class="$style.container">
<div ref="container" :class="[$style.container, 'ignore-key-press-canvas']" tabindex="0">
<N8nResizeWrapper
v-if="hasChat"
:supported-directions="['right']"
@@ -120,7 +118,11 @@ watch([panelState, height], ([state, h]) => {
@click-header="handleClickHeader"
/>
</N8nResizeWrapper>
<LogsOverviewPanel :is-open="panelState !== 'closed'" @click-header="handleClickHeader">
<LogsOverviewPanel
:is-open="panelState !== 'closed'"
:node="connectedNode"
@click-header="handleClickHeader"
>
<template #actions>
<N8nTooltip
v-if="canPopOut && !isPoppedOut"
@@ -178,14 +180,11 @@ watch([panelState, height], ([state, h]) => {
}
.resizeWrapper {
height: auto;
height: 100%;
min-height: 0;
flex-basis: 0;
border-top: 1px solid var(--color-foreground-base);
border-top: var(--border-base);
background-color: var(--color-background-light);
display: flex;
align-items: stretch;
justify-content: stretch;
&.isOpen {
height: var(--panel-height);
@@ -196,11 +195,12 @@ watch([panelState, height], ([state, h]) => {
}
.container {
height: 100%;
display: flex;
flex-grow: 1;
& > *:not(:last-child) {
border-right: 1px solid var(--color-foreground-base);
border-right: var(--border-base);
}
}

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
import { formatTokenUsageCount } from '@/components/RunDataAi/utils';
import { useI18n } from '@/composables/useI18n';
import { type LlmTokenUsageData } from '@/Interface';
import { N8nTooltip } from '@n8n/design-system';
const { consumedTokens } = defineProps<{ consumedTokens: LlmTokenUsageData }>();
const locale = useI18n();
</script>
<template>
<N8nTooltip v-if="consumedTokens !== undefined" :enterable="false">
<span>{{
locale.baseText('runData.aiContentBlock.tokens', {
interpolate: {
count: formatTokenUsageCount(consumedTokens, 'total'),
},
})
}}</span>
<template #content>
<ConsumedTokensDetails :consumed-tokens="consumedTokens" />
</template>
</N8nTooltip>
</template>

View File

@@ -0,0 +1,148 @@
import { renderComponent } from '@/__tests__/render';
import LogsOverviewPanel from './LogsOverviewPanel.vue';
import { createTestNode, createTestWorkflow } from '@/__tests__/mocks';
import { setActivePinia } from 'pinia';
import { createTestingPinia, type TestingPinia } from '@pinia/testing';
import { mockedStore } from '@/__tests__/utils';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { createRouter, createWebHistory } from 'vue-router';
import { h, type ExtractPropTypes } from 'vue';
import { fireEvent, waitFor, within } from '@testing-library/vue';
describe('LogsOverviewPanel', () => {
let pinia: TestingPinia;
let workflowsStore: ReturnType<typeof mockedStore<typeof useWorkflowsStore>>;
const triggerNode = createTestNode({ name: 'Chat' });
const aiAgentNode = createTestNode({ name: 'AI Agent' });
const aiModelNode = createTestNode({ name: 'AI Model' });
const workflow = createTestWorkflow({
nodes: [triggerNode, aiAgentNode, aiModelNode],
connections: {
Chat: {
main: [[{ node: 'AI Agent', index: 0, type: 'main' }]],
},
'AI Model': {
ai_languageModel: [[{ node: 'AI Agent', index: 0, type: 'ai_languageModel' }]],
},
},
});
function render(props: ExtractPropTypes<typeof LogsOverviewPanel>) {
return renderComponent(LogsOverviewPanel, {
props,
global: {
plugins: [
createRouter({
history: createWebHistory(),
routes: [{ path: '/', component: () => h('div') }],
}),
pinia,
],
},
});
}
beforeEach(() => {
pinia = createTestingPinia({ stubActions: false, fakeApp: true });
setActivePinia(pinia);
workflowsStore = mockedStore(useWorkflowsStore);
workflowsStore.setWorkflow(workflow);
workflowsStore.setWorkflowExecutionData(null);
});
it('should not render body if the panel is not open', () => {
const rendered = render({ isOpen: false, node: null });
expect(
rendered.queryByText('Nothing to display yet', { exact: false }),
).not.toBeInTheDocument();
});
it('should render empty text if there is no execution', () => {
const rendered = render({ isOpen: true, node: null });
expect(rendered.queryByText('Nothing to display yet', { exact: false })).toBeInTheDocument();
});
it('should render summary text and executed nodes if there is an execution', async () => {
workflowsStore.setWorkflowExecutionData({
id: 'test-exec-id',
finished: true,
mode: 'manual',
status: 'success',
data: {
resultData: {
runData: {
'AI Agent': [
{
executionStatus: 'success',
startTime: +new Date('2025-03-26T00:00:00.002Z'),
executionTime: 1778,
source: [],
data: {},
},
],
'AI Model': [
{
executionStatus: 'success',
startTime: +new Date('2025-03-26T00:00:00.003Z'),
executionTime: 1777,
source: [],
data: {
ai_languageModel: [
[
{
json: {
tokenUsage: {
completionTokens: 222,
promptTokens: 333,
totalTokens: 555,
},
},
},
],
],
},
},
],
},
},
},
workflowData: workflow,
createdAt: new Date('2025-03-26T00:00:00.000Z'),
startedAt: new Date('2025-03-26T00:00:00.001Z'),
stoppedAt: new Date('2025-03-26T00:00:02.000Z'),
});
const rendered = render({ isOpen: true, node: aiAgentNode });
const summary = within(rendered.container.querySelector('.summary')!);
expect(summary.queryByText('Success in 1.999s')).toBeInTheDocument();
expect(summary.queryByText('555 Tokens')).toBeInTheDocument();
const tree = within(rendered.getByRole('tree'));
expect(tree.queryAllByRole('treeitem')).toHaveLength(2);
const row1 = within(tree.queryAllByRole('treeitem')[0]);
expect(row1.queryByText('AI Agent')).toBeInTheDocument();
expect(row1.queryByText('Success in 1.778s')).toBeInTheDocument();
expect(row1.queryByText('Started 2025-03-26T00:00:00.002Z')).toBeInTheDocument();
expect(row1.queryByText('555 Tokens')).toBeInTheDocument();
const row2 = within(tree.queryAllByRole('treeitem')[1]);
expect(row2.queryByText('AI Model')).toBeInTheDocument();
expect(row2.queryByText('Success in 1.777s')).toBeInTheDocument();
expect(row2.queryByText('Started 2025-03-26T00:00:00.003Z')).toBeInTheDocument();
expect(row2.queryByText('555 Tokens')).toBeInTheDocument();
// collapse tree
await fireEvent.click(row1.getByRole('button'));
await waitFor(() => expect(tree.queryAllByRole('treeitem')).toHaveLength(1));
});
});

View File

@@ -1,25 +1,100 @@
<script setup lang="ts">
import PanelHeader from '@/components/CanvasChat/components/PanelHeader.vue';
import PanelHeader from '@/components/CanvasChat/future/components/PanelHeader.vue';
import { useClearExecutionButtonVisible } from '@/composables/useClearExecutionButtonVisible';
import { useI18n } from '@/composables/useI18n';
import { useNodeHelpers } from '@/composables/useNodeHelpers';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { N8nButton, N8nText, N8nTooltip } from '@n8n/design-system';
import { N8nButton, N8nRadioButtons, N8nText, N8nTooltip } from '@n8n/design-system';
import { computed, ref } from 'vue';
import { ElTree, type TreeNode as ElTreeNode } from 'element-plus';
import {
createAiData,
getSubtreeTotalConsumedTokens,
getTotalConsumedTokens,
getTreeNodeData,
type TreeNode,
} from '@/components/RunDataAi/utils';
import { type INodeUi } from '@/Interface';
import { upperFirst } from 'lodash-es';
import { useTelemetry } from '@/composables/useTelemetry';
import ConsumedTokenCountText from '@/components/CanvasChat/future/components/ConsumedTokenCountText.vue';
defineProps<{ isOpen: boolean }>();
const { node, isOpen } = defineProps<{ isOpen: boolean; node: INodeUi | null }>();
const emit = defineEmits<{ clickHeader: [] }>();
defineSlots<{ actions: {} }>();
const locale = useI18n();
const telemetry = useTelemetry();
const workflowsStore = useWorkflowsStore();
const nodeHelpers = useNodeHelpers();
const isClearExecutionButtonVisible = useClearExecutionButtonVisible();
const workflow = computed(() => workflowsStore.getCurrentWorkflow());
const executionTree = computed<TreeNode[]>(() =>
node
? getTreeNodeData(
node.name,
workflow.value,
createAiData(node.name, workflow.value, workflowsStore.getWorkflowResultDataByNodeName),
)
: [],
);
const isEmpty = computed(() => workflowsStore.workflowExecutionData === null);
const switchViewOptions = computed<Array<{ label: string; value: string }>>(() => [
{ label: locale.baseText('logs.overview.header.switch.details'), value: 'details' },
{ label: locale.baseText('logs.overview.header.switch.overview'), value: 'overview' },
]);
const executionStatusText = computed(() => {
const execution = workflowsStore.workflowExecutionData;
defineSlots<{ actions: {} }>();
if (!execution) {
return undefined;
}
if (execution.startedAt && execution.stoppedAt) {
return locale.baseText('logs.overview.body.summaryText', {
interpolate: {
status: upperFirst(execution.status),
time: locale.displayTimer(
+new Date(execution.stoppedAt) - +new Date(execution.startedAt),
true,
),
},
});
}
return upperFirst(execution.status);
});
const consumedTokens = computed(() =>
getTotalConsumedTokens(...executionTree.value.map(getSubtreeTotalConsumedTokens)),
);
const selectedRun = ref<{ node: string; runIndex: number } | undefined>(undefined);
function onClearExecutionData() {
workflowsStore.setWorkflowExecutionData(null);
nodeHelpers.updateNodesExecutionIssues();
}
function handleClickNode(clicked: TreeNode) {
if (selectedRun.value?.node === clicked.node && selectedRun.value.runIndex === clicked.runIndex) {
selectedRun.value = undefined;
return;
}
selectedRun.value = { node: clicked.node, runIndex: clicked.runIndex };
telemetry.track('User selected node in log view', {
node_type: workflowsStore.nodesByName[clicked.node].type,
node_id: workflowsStore.nodesByName[clicked.node].id,
execution_id: workflowsStore.workflowExecutionData?.id,
workflow_id: workflow.value.id,
});
}
function handleToggleExpanded(treeNode: ElTreeNode) {
treeNode.expanded = !treeNode.expanded;
}
</script>
<template>
@@ -45,10 +120,52 @@ function onClearExecutionData() {
<slot name="actions" />
</template>
</PanelHeader>
<div v-if="isOpen" :class="[$style.content, $style.empty]">
<N8nText tag="p" size="medium" color="text-base" :class="$style.emptyText">
<div v-if="isOpen" :class="[$style.content, isEmpty ? $style.empty : '']">
<N8nText v-if="isEmpty" tag="p" size="medium" color="text-base" :class="$style.emptyText">
{{ locale.baseText('logs.overview.body.empty.message') }}
</N8nText>
<div v-else :class="$style.scrollable">
<N8nText
v-if="executionStatusText !== undefined"
tag="div"
color="text-light"
size="small"
:class="$style.summary"
>
<span>{{ executionStatusText }}</span>
<ConsumedTokenCountText
v-if="consumedTokens.totalTokens > 0"
:consumed-tokens="consumedTokens"
/>
</N8nText>
<ElTree
v-if="executionTree.length > 0"
:class="$style.tree"
:indent="0"
:data="executionTree"
:expand-on-click-node="false"
:default-expand-all="true"
@node-click="handleClickNode"
>
<template #default="{ node: elTreeNode, data }">
<LogsOverviewRow
:data="data"
:node="elTreeNode"
:is-selected="
data.node === selectedRun?.node && data.runIndex === selectedRun?.runIndex
"
:should-show-consumed-tokens="consumedTokens.totalTokens > 0"
@toggle-expanded="handleToggleExpanded"
/>
</template>
</ElTree>
<N8nRadioButtons
size="medium"
:class="$style.switchViewButtons"
:model-value="selectedRun ? 'details' : 'overview'"
:options="switchViewOptions"
/>
</div>
</div>
</div>
</template>
@@ -60,14 +177,19 @@ function onClearExecutionData() {
display: flex;
flex-direction: column;
align-items: stretch;
overflow: hidden;
}
.content {
padding: var(--spacing-2xs);
position: relative;
flex-grow: 1;
overflow: auto;
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: stretch;
&.empty {
display: flex;
align-items: center;
justify-content: center;
}
@@ -77,4 +199,40 @@ function onClearExecutionData() {
max-width: 20em;
text-align: center;
}
.scrollable {
padding: var(--spacing-2xs);
flex-grow: 1;
flex-shrink: 1;
overflow: auto;
}
.summary {
display: flex;
align-items: center;
padding-block: var(--spacing-2xs);
& > * {
padding-inline: var(--spacing-2xs);
}
& > *:not(:last-child) {
border-right: var(--border-base);
}
}
.tree {
margin-top: var(--spacing-2xs);
& :global(.el-icon) {
display: none;
}
}
.switchViewButtons {
position: absolute;
right: 0;
top: 0;
margin: var(--spacing-2xs);
}
</style>

View File

@@ -0,0 +1,239 @@
<script setup lang="ts">
import { type TreeNode as ElTreeNode } from 'element-plus';
import { getSubtreeTotalConsumedTokens, type TreeNode } from '@/components/RunDataAi/utils';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { computed } from 'vue';
import { type INodeUi } from '@/Interface';
import { type ExecutionStatus, type ITaskData } from 'n8n-workflow';
import { N8nIconButton, N8nText } from '@n8n/design-system';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { upperFirst } from 'lodash-es';
import { useI18n } from '@/composables/useI18n';
import ConsumedTokenCountText from '@/components/CanvasChat/future/components/ConsumedTokenCountText.vue';
const props = defineProps<{
data: TreeNode;
node: ElTreeNode;
isSelected: boolean;
shouldShowConsumedTokens: boolean;
}>();
const emit = defineEmits<{ toggleExpanded: [node: ElTreeNode] }>();
const locale = useI18n();
const workflowsStore = useWorkflowsStore();
const nodeTypeStore = useNodeTypesStore();
const node = computed<INodeUi | undefined>(() => workflowsStore.nodesByName[props.data.node]);
const runData = computed<ITaskData | undefined>(() =>
node.value
? workflowsStore.workflowExecutionData?.data?.resultData.runData[node.value.name]?.[
props.data.runIndex
]
: undefined,
);
const type = computed(() => (node.value ? nodeTypeStore.getNodeType(node.value.type) : undefined));
const depth = computed(() => (props.node.level ?? 1) - 1);
const timeTookText = computed(() => {
const finalStatuses: ExecutionStatus[] = ['crashed', 'error', 'success'];
const status = runData.value?.executionStatus;
if (!status) {
return '';
}
const statusText = upperFirst(status);
return finalStatuses.includes(status)
? locale.baseText('logs.overview.body.summaryText', {
interpolate: {
status: statusText,
time: locale.displayTimer(runData.value.executionTime, true),
},
})
: statusText;
});
const startedAtText = computed(() =>
locale.baseText('logs.overview.body.started', {
interpolate: {
time: new Date(runData.value?.startTime ?? 0).toISOString(), // TODO: confirm date format
},
}),
);
const subtreeConsumedTokens = computed(() =>
props.shouldShowConsumedTokens ? getSubtreeTotalConsumedTokens(props.data) : undefined,
);
function isLastChild(level: number) {
let parent = props.data.parent;
let data: TreeNode | undefined = props.data;
for (let i = 0; i < depth.value - level; i++) {
data = parent;
parent = parent?.parent;
}
const siblings = parent?.children ?? [];
return data === siblings[siblings.length - 1];
}
</script>
<template>
<div
v-if="node !== undefined"
:class="{ [$style.container]: true, [$style.selected]: props.isSelected }"
>
<template v-for="level in depth" :key="level">
<div
:class="{
[$style.indent]: true,
[$style.connectorCurved]: level === depth,
[$style.connectorStraight]: !isLastChild(level),
}"
/>
</template>
<NodeIcon :node-type="type" :size="16" :class="$style.icon" />
<N8nText tag="div" :bold="true" size="small" :class="$style.name">{{ node.name }}</N8nText>
<N8nText tag="div" color="text-light" size="small" :class="$style.timeTook">{{
timeTookText
}}</N8nText>
<N8nText tag="div" color="text-light" size="small" :class="$style.startedAt">{{
startedAtText
}}</N8nText>
<N8nText
v-if="subtreeConsumedTokens !== undefined"
tag="div"
color="text-light"
size="small"
:class="$style.consumedTokens"
>
<ConsumedTokenCountText
v-if="
subtreeConsumedTokens.totalTokens > 0 &&
(props.data.children.length === 0 || !props.node.expanded)
"
:consumed-tokens="subtreeConsumedTokens"
/>
</N8nText>
<div>
<N8nIconButton
type="secondary"
size="medium"
:icon="props.node.expanded ? 'chevron-down' : 'chevron-up'"
:style="{
visibility: props.data.children.length === 0 ? 'hidden' : '',
color: 'var(--color-text-base)', // give higher specificity than the style from the component itself
}"
:class="$style.toggleButton"
@click.stop="emit('toggleExpanded', props.node)"
/>
</div>
</div>
</template>
<style lang="scss" module>
.container {
display: flex;
align-items: stretch;
justify-content: stretch;
overflow: hidden;
& > * {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: var(--spacing-2xs);
}
& > :has(.toggleButton) {
flex-shrink: 0;
display: flex;
align-items: center;
padding: 0;
}
& > .icon {
border-top-left-radius: var(--border-radius-base);
border-bottom-left-radius: var(--border-radius-base);
}
& > :last-of-type {
border-top-right-radius: var(--border-radius-base);
border-bottom-right-radius: var(--border-radius-base);
}
&.selected > :not(.indent),
&:hover > :not(.indent) {
background-color: var(--color-foreground-base);
}
}
.indent {
flex-grow: 0;
flex-shrink: 0;
width: var(--spacing-xl);
align-self: stretch;
position: relative;
overflow: hidden;
&.connectorCurved:before {
content: '';
position: absolute;
left: var(--spacing-s);
bottom: var(--spacing-s);
border: 2px solid var(--color-canvas-dot);
width: var(--spacing-l);
height: var(--spacing-l);
border-radius: var(--border-radius-large);
}
&.connectorStraight:after {
content: '';
position: absolute;
left: var(--spacing-s);
top: 0;
border-left: 2px solid var(--color-canvas-dot);
height: 100%;
}
}
.icon {
flex-grow: 0;
flex-shrink: 0;
}
.name {
flex-grow: 1;
padding-inline-start: 0;
}
.timeTook {
flex-grow: 0;
flex-shrink: 0;
width: 20%;
}
.startedAt {
flex-grow: 0;
flex-shrink: 0;
width: 30%;
}
.consumedTokens {
flex-grow: 0;
flex-shrink: 0;
width: 10%;
text-align: right;
}
.toggleButton {
border: none;
background: transparent;
margin-inline-end: var(--spacing-5xs);
&:hover {
background: transparent;
}
}
</style>

View File

@@ -1,4 +1,6 @@
<script setup lang="ts">
import { N8nText } from '@n8n/design-system';
defineProps<{ title: string }>();
defineSlots<{ actions: {} }>();
@@ -8,7 +10,7 @@ const emit = defineEmits<{ click: [] }>();
<template>
<header :class="$style.container" @click="emit('click')">
<span :class="$style.title">{{ title }}</span>
<N8nText :class="$style.title" :bold="true" size="small">{{ title }}</N8nText>
<div :class="$style.actions">
<slot name="actions" />
</div>
@@ -18,10 +20,9 @@ const emit = defineEmits<{ click: [] }>();
<style lang="scss" module>
.container {
font-size: var(--font-size-2xs);
font-weight: 400;
line-height: 18px;
text-align: left;
padding-inline: var(--spacing-s);
padding-inline-start: var(--spacing-s);
padding-inline-end: var(--spacing-xs);
padding-block: var(--spacing-2xs);
background-color: var(--color-foreground-xlight);
display: flex;
@@ -36,12 +37,11 @@ const emit = defineEmits<{ click: [] }>();
&:not(:last-child) {
/** Panel open */
border-bottom: 1px solid var(--color-foreground-base);
border-bottom: var(--border-base);
}
}
.title {
font-weight: 600;
flex-grow: 1;
flex-shrink: 1;
}

View File

@@ -0,0 +1,35 @@
<script setup lang="ts">
import { formatTokenUsageCount } from '@/components/RunDataAi/utils';
import { useI18n } from '@/composables/useI18n';
import { type LlmTokenUsageData } from '@/Interface';
import { N8nText } from '@n8n/design-system';
const { consumedTokens } = defineProps<{ consumedTokens: LlmTokenUsageData }>();
const i18n = useI18n();
</script>
<template>
<div>
<N8nText :bold="true" size="small">
{{ i18n.baseText('runData.aiContentBlock.tokens.prompt') }}
{{
i18n.baseText('runData.aiContentBlock.tokens', {
interpolate: {
count: formatTokenUsageCount(consumedTokens, 'prompt'),
},
})
}}
</N8nText>
<br />
<N8nText :bold="true" size="small">
{{ i18n.baseText('runData.aiContentBlock.tokens.completion') }}
{{
i18n.baseText('runData.aiContentBlock.tokens', {
interpolate: {
count: formatTokenUsageCount(consumedTokens, 'completion'),
},
})
}}
</N8nText>
</div>
</template>

View File

@@ -2,17 +2,14 @@
import type { IAiData, IAiDataContent } from '@/Interface';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import type {
INodeExecutionData,
INodeTypeDescription,
NodeConnectionType,
NodeError,
} from 'n8n-workflow';
import type { INodeTypeDescription, NodeConnectionType, NodeError } from 'n8n-workflow';
import { computed } from 'vue';
import NodeIcon from '@/components/NodeIcon.vue';
import AiRunContentBlock from './AiRunContentBlock.vue';
import { useExecutionHelpers } from '@/composables/useExecutionHelpers';
import { useI18n } from '@/composables/useI18n';
import { formatTokenUsageCount, getConsumedTokens } from '@/components/RunDataAi/utils';
import ConsumedTokensDetails from '@/components/ConsumedTokensDetails.vue';
interface RunMeta {
startTimeMs: number;
@@ -36,44 +33,11 @@ const workflowsStore = useWorkflowsStore();
const { trackOpeningRelatedExecution, resolveRelatedExecutionUrl } = useExecutionHelpers();
const i18n = useI18n();
type TokenUsageData = {
completionTokens: number;
promptTokens: number;
totalTokens: number;
};
const consumedTokensSum = computed(() => {
// eslint-disable-next-line @typescript-eslint/no-use-before-define
const tokenUsage = outputRun.value?.data?.reduce(
(acc: TokenUsageData, curr: INodeExecutionData) => {
const tokenUsageData = (curr.json?.tokenUsage ??
curr.json?.tokenUsageEstimate) 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 tokenUsage;
return getConsumedTokens(outputRun.value);
});
const usingTokensEstimates = computed(() => {
return outputRun.value?.data?.some((d) => d.json?.tokenUsageEstimate);
});
function formatTokenUsageCount(count: number) {
return usingTokensEstimates.value ? `~${count}` : count.toString();
}
function extractRunMeta(run: IAiDataContent) {
const uiNode = workflowsStore.getNodeByName(props.inputData.node);
const nodeType = nodeTypesStore.getNodeType(uiNode?.type ?? '');
@@ -155,34 +119,12 @@ const outputError = computed(() => {
{{
i18n.baseText('runData.aiContentBlock.tokens', {
interpolate: {
count: formatTokenUsageCount(consumedTokensSum?.totalTokens ?? 0),
count: formatTokenUsageCount(consumedTokensSum, 'total'),
},
})
}}
<n8n-info-tip type="tooltip" theme="info-light" tooltip-placement="right">
<div>
<n8n-text :bold="true" size="small">
{{ i18n.baseText('runData.aiContentBlock.tokens.prompt') }}
{{
i18n.baseText('runData.aiContentBlock.tokens', {
interpolate: {
count: formatTokenUsageCount(consumedTokensSum?.promptTokens ?? 0),
},
})
}}
</n8n-text>
<br />
<n8n-text :bold="true" size="small">
{{ i18n.baseText('runData.aiContentBlock.tokens.completion') }}
{{
i18n.baseText('runData.aiContentBlock.tokens', {
interpolate: {
count: formatTokenUsageCount(consumedTokensSum?.completionTokens ?? 0),
},
})
}}
</n8n-text>
</div>
<ConsumedTokensDetails :consumed-tokens="consumedTokensSum" />
</n8n-info-tip>
</li>
</ul>

View File

@@ -31,11 +31,62 @@ describe(getTreeNodeData, () => {
const taskDataByNodeName: Record<string, ITaskData[]> = {
A: [createTaskData({ startTime: +new Date('2025-02-26T00:00:00.000Z') })],
B: [
createTaskData({ startTime: +new Date('2025-02-26T00:00:01.000Z') }),
createTaskData({ startTime: +new Date('2025-02-26T00:00:03.000Z') }),
createTaskData({
startTime: +new Date('2025-02-26T00:00:01.000Z'),
data: {
main: [
[
{
json: {
tokenUsage: {
completionTokens: 1,
promptTokens: 2,
totalTokens: 3,
},
},
},
],
],
},
}),
createTaskData({
startTime: +new Date('2025-02-26T00:00:03.000Z'),
data: {
main: [
[
{
json: {
tokenUsage: {
completionTokens: 4,
promptTokens: 5,
totalTokens: 6,
},
},
},
],
],
},
}),
],
C: [
createTaskData({ startTime: +new Date('2025-02-26T00:00:02.000Z') }),
createTaskData({
startTime: +new Date('2025-02-26T00:00:02.000Z'),
data: {
main: [
[
{
json: {
tokenUsageEstimate: {
completionTokens: 7,
promptTokens: 8,
totalTokens: 9,
},
},
},
],
],
},
}),
createTaskData({ startTime: +new Date('2025-02-26T00:00:04.000Z') }),
],
};
@@ -53,6 +104,13 @@ describe(getTreeNodeData, () => {
node: 'A',
runIndex: 0,
startTime: 0,
parent: undefined,
consumedTokens: {
completionTokens: 0,
promptTokens: 0,
totalTokens: 0,
isEstimate: false,
},
children: [
{
depth: 1,
@@ -60,6 +118,13 @@ describe(getTreeNodeData, () => {
node: 'B',
runIndex: 0,
startTime: +new Date('2025-02-26T00:00:01.000Z'),
parent: expect.objectContaining({ node: 'A' }),
consumedTokens: {
completionTokens: 1,
promptTokens: 2,
totalTokens: 3,
isEstimate: false,
},
children: [
{
children: [],
@@ -68,6 +133,13 @@ describe(getTreeNodeData, () => {
node: 'C',
runIndex: 0,
startTime: +new Date('2025-02-26T00:00:02.000Z'),
parent: expect.objectContaining({ node: 'B' }),
consumedTokens: {
completionTokens: 7,
promptTokens: 8,
totalTokens: 9,
isEstimate: true,
},
},
],
},
@@ -77,6 +149,13 @@ describe(getTreeNodeData, () => {
node: 'B',
runIndex: 1,
startTime: +new Date('2025-02-26T00:00:03.000Z'),
parent: expect.objectContaining({ node: 'A' }),
consumedTokens: {
completionTokens: 4,
promptTokens: 5,
totalTokens: 6,
isEstimate: false,
},
children: [
{
children: [],
@@ -85,6 +164,13 @@ describe(getTreeNodeData, () => {
node: 'C',
runIndex: 1,
startTime: +new Date('2025-02-26T00:00:04.000Z'),
parent: expect.objectContaining({ node: 'B' }),
consumedTokens: {
completionTokens: 0,
promptTokens: 0,
totalTokens: 0,
isEstimate: false,
},
},
],
},

View File

@@ -1,5 +1,6 @@
import { type IAiDataContent } from '@/Interface';
import { type LlmTokenUsageData, type IAiDataContent } from '@/Interface';
import {
type INodeExecutionData,
type ITaskData,
type ITaskDataConnections,
type NodeConnectionType,
@@ -13,27 +14,32 @@ export interface AIResult {
}
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,
r?: AIResult,
children: TreeNode[] = [],
): TreeNode {
return {
parent,
node: nodeName,
id: nodeName,
depth: currentDepth,
startTime: r?.data?.metadata?.startTime ?? 0,
runIndex: r?.runIndex ?? 0,
children,
consumedTokens: getConsumedTokens(r?.data),
};
}
@@ -42,10 +48,11 @@ export function getTreeNodeData(
workflow: Workflow,
aiData: AIResult[] | undefined,
): TreeNode[] {
return getTreeNodeDataRec(nodeName, 0, workflow, aiData, undefined);
return getTreeNodeDataRec(undefined, nodeName, 0, workflow, aiData, undefined);
}
function getTreeNodeDataRec(
parent: TreeNode | undefined,
nodeName: string,
currentDepth: number,
workflow: Workflow,
@@ -59,12 +66,13 @@ function getTreeNodeDataRec(
) ?? [];
if (!connections) {
return resultData.map((d) => createNode(nodeName, currentDepth, d));
return resultData.map((d) => createNode(parent, nodeName, currentDepth, d));
}
// Get the first level of children
const connectedSubNodes = workflow.getParentNodes(nodeName, 'ALL_NON_MAIN', 1);
const treeNode = createNode(parent, nodeName, currentDepth);
const children = connectedSubNodes.flatMap((name) => {
// Only include sub-nodes which have data
return (
@@ -73,18 +81,20 @@ function getTreeNodeDataRec(
(data) => data.node === name && (runIndex === undefined || data.runIndex === runIndex),
)
.flatMap((data) =>
getTreeNodeDataRec(name, currentDepth + 1, workflow, aiData, data.runIndex),
getTreeNodeDataRec(treeNode, name, currentDepth + 1, workflow, aiData, data.runIndex),
) ?? []
);
});
children.sort((a, b) => a.startTime - b.startTime);
treeNode.children = children;
if (resultData.length) {
return resultData.map((r) => createNode(nodeName, currentDepth, r, children));
return resultData.map((r) => createNode(parent, nodeName, currentDepth, r, children));
}
return [createNode(nodeName, currentDepth, undefined, children)];
return [treeNode];
}
export function createAiData(
@@ -158,3 +168,66 @@ export function getReferencedData(
return returnData;
}
const emptyTokenUsageData: LlmTokenUsageData = {
completionTokens: 0,
promptTokens: 0,
totalTokens: 0,
isEstimate: false,
};
function addTokenUsageData(one: LlmTokenUsageData, another: LlmTokenUsageData): LlmTokenUsageData {
return {
completionTokens: one.completionTokens + another.completionTokens,
promptTokens: one.promptTokens + another.promptTokens,
totalTokens: one.totalTokens + another.totalTokens,
isEstimate: one.isEstimate || another.isEstimate,
};
}
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 getTotalConsumedTokens(...usage: LlmTokenUsageData[]): LlmTokenUsageData {
return usage.reduce(addTokenUsageData, emptyTokenUsageData);
}
export function getSubtreeTotalConsumedTokens(treeNode: TreeNode): LlmTokenUsageData {
return getTotalConsumedTokens(
treeNode.consumedTokens,
...treeNode.children.map(getSubtreeTotalConsumedTokens),
);
}
export function formatTokenUsageCount(
usage: LlmTokenUsageData,
field: 'total' | 'prompt' | 'completion',
) {
const count =
field === 'total'
? usage.totalTokens
: field === 'completion'
? usage.completionTokens
: usage.promptTokens;
return usage.isEstimate ? `~${count}` : count.toLocaleString();
}