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

@@ -88,7 +88,7 @@ watch(
justify-content: center;
gap: var(--spacing-xs);
padding-inline: var(--spacing-m);
padding-bottom: 1.5em;
padding-bottom: var(--spacing-l);
overflow: hidden;
}

View File

@@ -49,7 +49,7 @@
"@fortawesome/fontawesome-svg-core": "^1.2.36",
"@fortawesome/free-solid-svg-icons": "^5.15.4",
"@fortawesome/vue-fontawesome": "^3.0.3",
"element-plus": "2.4.3",
"element-plus": "catalog:frontend",
"is-emoji-supported": "^0.0.5",
"markdown-it": "^13.0.2",
"markdown-it-emoji": "^2.0.2",

View File

@@ -59,6 +59,7 @@
"core-js": "^3.40.0",
"curlconverter": "^4.12.0",
"dateformat": "^3.0.3",
"element-plus": "catalog:frontend",
"email-providers": "^2.0.1",
"esprima-next": "5.8.4",
"fast-json-stable-stringify": "^2.1.0",

View File

@@ -445,9 +445,9 @@ export interface IExecutionBase {
status: ExecutionStatus;
retryOf?: string;
retrySuccessId?: string;
startedAt: Date;
createdAt: Date;
stoppedAt?: Date;
startedAt: Date | string;
createdAt: Date | string;
stoppedAt?: Date | string;
workflowId?: string; // To be able to filter executions easily //
}
@@ -1583,3 +1583,10 @@ export type MainPanelDimensions = Record<
relativeWidth: number;
}
>;
export interface LlmTokenUsageData {
completionTokens: number;
promptTokens: number;
totalTokens: number;
isEstimate: boolean;
}

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();
}

View File

@@ -240,10 +240,19 @@ export function usePushConnection({ router }: { router: ReturnType<typeof useRou
let showedSuccessToast = false;
let executionData: Pick<IExecutionResponse, 'workflowId' | 'data' | 'status'>;
let executionData: Pick<
IExecutionResponse,
'workflowId' | 'data' | 'status' | 'startedAt' | 'stoppedAt'
>;
if (receivedData.type === 'executionFinished' && receivedData.data.rawData) {
const { workflowId, status, rawData } = receivedData.data;
executionData = { workflowId, data: parse(rawData), status };
executionData = {
workflowId,
data: parse(rawData),
status,
startedAt: workflowsStore.workflowExecutionData?.startedAt ?? new Date(),
stoppedAt: new Date(),
};
} else {
uiStore.setProcessingExecutionResults(true);
@@ -278,6 +287,8 @@ export function usePushConnection({ router }: { router: ReturnType<typeof useRou
workflowId: execution.workflowId,
data: parse(execution.data as unknown as string),
status: execution.status,
startedAt: workflowsStore.workflowExecutionData?.startedAt as Date,
stoppedAt: receivedData.type === 'executionFinished' ? new Date() : undefined,
};
} catch {
uiStore.setProcessingExecutionResults(false);

View File

@@ -14,7 +14,7 @@ import {
} from 'n8n-workflow';
import { useRunWorkflow } from '@/composables/useRunWorkflow';
import type { IStartRunData, IWorkflowData } from '@/Interface';
import type { IExecutionResponse, IStartRunData, IWorkflowData } from '@/Interface';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useUIStore } from '@/stores/ui.store';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
@@ -23,7 +23,8 @@ import { useI18n } from '@/composables/useI18n';
import { captor, mock } from 'vitest-mock-extended';
import { useSettingsStore } from '@/stores/settings.store';
import { usePushConnectionStore } from '@/stores/pushConnection.store';
import { createTestNode } from '@/__tests__/mocks';
import { createTestNode, createTestWorkflow } from '@/__tests__/mocks';
import { waitFor } from '@testing-library/vue';
vi.mock('@/stores/workflows.store', () => ({
useWorkflowsStore: vi.fn().mockReturnValue({
@@ -45,6 +46,7 @@ vi.mock('@/stores/workflows.store', () => ({
getPinnedDataLastRemovedAt: vi.fn(),
incomingConnectionsByNodeName: vi.fn(),
outgoingConnectionsByNodeName: vi.fn(),
markExecutionAsStopped: vi.fn(),
}),
}));
@@ -671,4 +673,41 @@ describe('useRunWorkflow({ router })', () => {
});
});
});
describe('stopCurrentExecution()', () => {
it('should not prematurely call markExecutionAsStopped() while execution status is still "running"', async () => {
const runWorkflowComposable = useRunWorkflow({ router });
const executionData: IExecutionResponse = {
id: 'test-exec-id',
workflowData: createTestWorkflow({ id: 'test-wf-id' }),
finished: false,
mode: 'manual',
status: 'running',
startedAt: new Date('2025-04-01T00:00:00.000Z'),
createdAt: new Date('2025-04-01T00:00:00.000Z'),
};
const markStoppedSpy = vi.spyOn(workflowsStore, 'markExecutionAsStopped');
workflowsStore.workflowExecutionData = executionData;
workflowsStore.activeWorkflows = ['test-wf-id'];
workflowsStore.activeExecutionId = 'test-exec-id';
// Exercise - don't wait for returned promise to resolve
void runWorkflowComposable.stopCurrentExecution();
// Assert that markExecutionAsStopped() isn't called yet after a simulated delay
await new Promise((resolve) => setTimeout(resolve, 10));
expect(markStoppedSpy).not.toHaveBeenCalled();
// Simulated executionFinished event
workflowsStore.workflowExecutionData = {
...executionData,
status: 'canceled',
stoppedAt: new Date('2025-04-01T00:00:99.000Z'),
};
// Assert that markExecutionAsStopped() is called eventually
await waitFor(() => expect(markStoppedSpy).toHaveBeenCalled());
});
});
});

View File

@@ -447,6 +447,15 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof u
toast.showError(error, i18n.baseText('nodeView.showError.stopExecution.title'));
}
} finally {
// Wait for websocket event to update the execution status to 'canceled'
for (let i = 0; i < 100; i++) {
if (workflowsStore.workflowExecutionData?.status !== 'running') {
break;
}
await new Promise(requestAnimationFrame);
}
workflowsStore.markExecutionAsStopped();
}
}

View File

@@ -93,6 +93,10 @@ export class I18nClass {
return `${Math.floor(msPassed / 1000)}${this.baseText('genericHelpers.secShort')}`;
}
if (msPassed > 0 && msPassed < 1000) {
return `${msPassed}${this.baseText('genericHelpers.millis')}`;
}
return `${msPassed / 1000}${this.baseText('genericHelpers.secShort')}`;
}

View File

@@ -964,6 +964,7 @@
"genericHelpers.minShort": "m",
"genericHelpers.sec": "sec",
"genericHelpers.secShort": "s",
"genericHelpers.millis": "ms",
"readOnly.showMessage.executions.message": "Executions are read-only. Make changes from the <b>Workflow</b> tab.",
"readOnly.showMessage.executions.title": "Cannot edit execution",
"readOnlyEnv.showMessage.executions.message": "Executions are read-only.",
@@ -980,8 +981,12 @@
"logs.overview.header.title": "Logs",
"logs.overview.header.actions.clearExecution": "Clear execution",
"logs.overview.header.actions.clearExecution.tooltip": "Clear execution data",
"logs.overview.header.switch.details": "Details",
"logs.overview.header.switch.overview": "Overview",
"logs.overview.body.empty.message": "Nothing to display yet. Execute the workflow to see execution logs.",
"logs.overview.body.empty.action": "Execute the workflow",
"logs.overview.body.summaryText": "{status} in {time}",
"logs.overview.body.started": "Started {time}",
"mainSidebar.aboutN8n": "About n8n",
"mainSidebar.confirmMessage.workflowDelete.cancelButtonText": "",
"mainSidebar.confirmMessage.workflowDelete.confirmButtonText": "Yes, delete",

33
pnpm-lock.yaml generated
View File

@@ -103,6 +103,9 @@ catalogs:
'@vueuse/core':
specifier: ^10.11.0
version: 10.11.0
element-plus:
specifier: 2.4.3
version: 2.4.3
highlight.js:
specifier: ^11.8.0
version: 11.9.0
@@ -494,7 +497,7 @@ importers:
version: 3.666.0(@aws-sdk/client-sts@3.666.0)
'@getzep/zep-cloud':
specifier: 1.0.12
version: 1.0.12(@langchain/core@0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)))(encoding@0.1.13)(langchain@0.3.11(fd386e1130022c8548c06dd951c5cbf0))
version: 1.0.12(@langchain/core@0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)))(encoding@0.1.13)(langchain@0.3.11(d1e86e144e3517fab3dbb7a92ab7f45a))
'@getzep/zep-js':
specifier: 0.9.0
version: 0.9.0
@@ -521,7 +524,7 @@ importers:
version: 0.3.2(@aws-sdk/client-sso-oidc@3.666.0(@aws-sdk/client-sts@3.666.0))(@langchain/core@0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)))(encoding@0.1.13)
'@langchain/community':
specifier: 0.3.24
version: 0.3.24(1725dd003b6ba0539bce135b7f30abed)
version: 0.3.24(14647e509198b6d5542cb42df21485e1)
'@langchain/core':
specifier: 'catalog:'
version: 0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1))
@@ -614,7 +617,7 @@ importers:
version: 23.0.1
langchain:
specifier: 0.3.11
version: 0.3.11(fd386e1130022c8548c06dd951c5cbf0)
version: 0.3.11(d1e86e144e3517fab3dbb7a92ab7f45a)
lodash:
specifier: 'catalog:'
version: 4.17.21
@@ -1439,7 +1442,7 @@ importers:
specifier: '*'
version: 10.11.0(vue@3.5.13(typescript@5.8.2))
element-plus:
specifier: 2.4.3
specifier: catalog:frontend
version: 2.4.3(vue@3.5.13(typescript@5.8.2))
is-emoji-supported:
specifier: ^0.0.5
@@ -1682,6 +1685,9 @@ importers:
dateformat:
specifier: ^3.0.3
version: 3.0.3
element-plus:
specifier: catalog:frontend
version: 2.4.3(vue@3.5.13(typescript@5.8.2))
email-providers:
specifier: ^2.0.1
version: 2.0.1
@@ -8896,6 +8902,7 @@ packages:
gm@1.25.0:
resolution: {integrity: sha512-4kKdWXTtgQ4biIo7hZA396HT062nDVVHPjQcurNZ3o/voYN+o5FUC5kOwuORbpExp3XbTJ3SU7iRipiIhQtovw==}
engines: {node: '>=14'}
deprecated: The gm module has been sunset. Please migrate to an alternative. https://github.com/aheckmann/gm?tab=readme-ov-file#2025-02-24-this-project-is-not-maintained
google-auth-library@8.9.0:
resolution: {integrity: sha512-f7aQCJODJFmYWN6PeNKzgvy9LI2tYmXnzpNDHEjG5sDNPgGb2FXQyTBnXeSH+PAtpKESFD+LmHw3Ox3mN7e1Fg==}
@@ -15973,7 +15980,7 @@ snapshots:
'@gar/promisify@1.1.3':
optional: true
'@getzep/zep-cloud@1.0.12(@langchain/core@0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)))(encoding@0.1.13)(langchain@0.3.11(fd386e1130022c8548c06dd951c5cbf0))':
'@getzep/zep-cloud@1.0.12(@langchain/core@0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)))(encoding@0.1.13)(langchain@0.3.11(d1e86e144e3517fab3dbb7a92ab7f45a))':
dependencies:
form-data: 4.0.0
node-fetch: 2.7.0(encoding@0.1.13)
@@ -15982,7 +15989,7 @@ snapshots:
zod: 3.24.1
optionalDependencies:
'@langchain/core': 0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1))
langchain: 0.3.11(fd386e1130022c8548c06dd951c5cbf0)
langchain: 0.3.11(d1e86e144e3517fab3dbb7a92ab7f45a)
transitivePeerDependencies:
- encoding
@@ -16490,7 +16497,7 @@ snapshots:
- aws-crt
- encoding
'@langchain/community@0.3.24(1725dd003b6ba0539bce135b7f30abed)':
'@langchain/community@0.3.24(14647e509198b6d5542cb42df21485e1)':
dependencies:
'@browserbasehq/stagehand': 1.9.0(@playwright/test@1.49.1)(deepmerge@4.3.1)(dotenv@16.4.5)(encoding@0.1.13)(openai@4.78.1(encoding@0.1.13)(zod@3.24.1))(zod@3.24.1)
'@ibm-cloud/watsonx-ai': 1.1.2
@@ -16501,7 +16508,7 @@ snapshots:
flat: 5.0.2
ibm-cloud-sdk-core: 5.1.0
js-yaml: 4.1.0
langchain: 0.3.11(fd386e1130022c8548c06dd951c5cbf0)
langchain: 0.3.11(d1e86e144e3517fab3dbb7a92ab7f45a)
langsmith: 0.2.15(openai@4.78.1(encoding@0.1.13)(zod@3.24.1))
openai: 4.78.1(encoding@0.1.13)(zod@3.24.1)
uuid: 10.0.0
@@ -16516,7 +16523,7 @@ snapshots:
'@aws-sdk/credential-provider-node': 3.666.0(@aws-sdk/client-sso-oidc@3.666.0(@aws-sdk/client-sts@3.666.0))(@aws-sdk/client-sts@3.666.0)
'@azure/storage-blob': 12.18.0(encoding@0.1.13)
'@browserbasehq/sdk': 2.0.0(encoding@0.1.13)
'@getzep/zep-cloud': 1.0.12(@langchain/core@0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)))(encoding@0.1.13)(langchain@0.3.11(fd386e1130022c8548c06dd951c5cbf0))
'@getzep/zep-cloud': 1.0.12(@langchain/core@0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)))(encoding@0.1.13)(langchain@0.3.11(d1e86e144e3517fab3dbb7a92ab7f45a))
'@getzep/zep-js': 0.9.0
'@google-ai/generativelanguage': 2.6.0(encoding@0.1.13)
'@google-cloud/storage': 7.12.1(encoding@0.1.13)
@@ -22668,7 +22675,7 @@ snapshots:
'@types/debug': 4.1.12
'@types/node': 18.16.16
'@types/tough-cookie': 4.0.2
axios: 1.8.2(debug@4.4.0)
axios: 1.8.2
camelcase: 6.3.0
debug: 4.4.0(supports-color@8.1.1)
dotenv: 16.4.5
@@ -22678,7 +22685,7 @@ snapshots:
isstream: 0.1.2
jsonwebtoken: 9.0.2
mime-types: 2.1.35
retry-axios: 2.6.0(axios@1.8.2)
retry-axios: 2.6.0(axios@1.8.2(debug@4.4.0))
tough-cookie: 4.1.3
transitivePeerDependencies:
- supports-color
@@ -23661,7 +23668,7 @@ snapshots:
kuler@2.0.0: {}
langchain@0.3.11(fd386e1130022c8548c06dd951c5cbf0):
langchain@0.3.11(d1e86e144e3517fab3dbb7a92ab7f45a):
dependencies:
'@langchain/core': 0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1))
'@langchain/openai': 0.3.17(@langchain/core@0.3.30(openai@4.78.1(encoding@0.1.13)(zod@3.24.1)))(encoding@0.1.13)
@@ -26066,7 +26073,7 @@ snapshots:
ret@0.1.15: {}
retry-axios@2.6.0(axios@1.8.2):
retry-axios@2.6.0(axios@1.8.2(debug@4.4.0)):
dependencies:
axios: 1.8.2

View File

@@ -50,3 +50,4 @@ catalogs:
vue-tsc: ^2.2.8
vue-markdown-render: ^2.2.1
highlight.js: ^11.8.0
'element-plus': 2.4.3