mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +00:00
feat(editor): Logs overview panel (#14045)
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user