feat(editor): Add "Rendered" display mode to the logs view (#14994)

This commit is contained in:
Suguru Inoue
2025-04-30 11:36:28 +02:00
committed by GitHub
parent 1de95ead0d
commit c0b54832b3
22 changed files with 587 additions and 294 deletions

View File

@@ -310,3 +310,7 @@ export function verifyOutputHoverState(expectedText: string) {
export function resetHoverState() { export function resetHoverState() {
getBackToCanvasButton().realHover(); getBackToCanvasButton().realHover();
} }
export function setInputDisplayMode(mode: 'Schema' | 'Table' | 'JSON' | 'Binary') {
getInputPanel().findChildByTestId('ndv-run-data-display-mode').contains(mode).click();
}

View File

@@ -22,6 +22,7 @@ describe('Logs', () => {
logs.getInputTbodyCell(1, 0).should('contain.text', '0'); logs.getInputTbodyCell(1, 0).should('contain.text', '0');
logs.getInputTbodyCell(10, 0).should('contain.text', '9'); logs.getInputTbodyCell(10, 0).should('contain.text', '9');
logs.clickOpenNdvAtRow(2); logs.clickOpenNdvAtRow(2);
ndv.setInputDisplayMode('Table');
ndv.getInputSelect().should('have.value', 'Code '); ndv.getInputSelect().should('have.value', 'Code ');
ndv.getInputTableRows().should('have.length', 11); ndv.getInputTableRows().should('have.length', 11);
ndv.getInputTbodyCell(1, 0).should('contain.text', '0'); ndv.getInputTbodyCell(1, 0).should('contain.text', '0');

View File

@@ -107,3 +107,8 @@ Object.defineProperty(window, 'DataTransfer', {
writable: true, writable: true,
value: DataTransfer, value: DataTransfer,
}); });
Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {
writable: true,
value: vi.fn(),
});

View File

@@ -179,8 +179,8 @@ watch(
<div :class="$style.tree" v-bind="virtualList.containerProps"> <div :class="$style.tree" v-bind="virtualList.containerProps">
<div v-bind="virtualList.wrapperProps.value" role="tree"> <div v-bind="virtualList.wrapperProps.value" role="tree">
<LogsOverviewRow <LogsOverviewRow
v-for="{ data } of virtualList.list.value" v-for="{ data, index } of virtualList.list.value"
:key="data.id" :key="index"
:data="data" :data="data"
:is-read-only="isReadOnly" :is-read-only="isReadOnly"
:is-selected=" :is-selected="

View File

@@ -328,11 +328,14 @@ function isLastChild(level: number) {
flex-shrink: 0; flex-shrink: 0;
border: none; border: none;
background: transparent; background: transparent;
margin-inline-end: var(--spacing-5xs);
color: var(--color-text-base); color: var(--color-text-base);
align-items: center; align-items: center;
justify-content: center; justify-content: center;
&:last-child {
margin-inline-end: var(--spacing-5xs);
}
&:hover { &:hover {
background: transparent; background: transparent;
} }

View File

@@ -2,11 +2,11 @@
import RunData from '@/components/RunData.vue'; import RunData from '@/components/RunData.vue';
import { type LogEntry } from '@/components/RunDataAi/utils'; import { type LogEntry } from '@/components/RunDataAi/utils';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { type IExecutionResponse, type NodePanelType } from '@/Interface'; import { type IRunDataDisplayMode, type IExecutionResponse, type NodePanelType } from '@/Interface';
import { useNDVStore } from '@/stores/ndv.store'; import { useNDVStore } from '@/stores/ndv.store';
import { N8nLink, N8nText } from '@n8n/design-system'; import { N8nLink, N8nText } from '@n8n/design-system';
import { type Workflow } from 'n8n-workflow'; import { type Workflow } from 'n8n-workflow';
import { computed } from 'vue'; import { computed, ref } from 'vue';
import { I18nT } from 'vue-i18n'; import { I18nT } from 'vue-i18n';
const { title, logEntry, paneType, workflow, execution } = defineProps<{ const { title, logEntry, paneType, workflow, execution } = defineProps<{
@@ -19,6 +19,8 @@ const { title, logEntry, paneType, workflow, execution } = defineProps<{
const locale = useI18n(); const locale = useI18n();
const ndvStore = useNDVStore(); const ndvStore = useNDVStore();
const displayMode = ref<IRunDataDisplayMode>(paneType === 'input' ? 'schema' : 'table');
const isMultipleInput = computed(() => paneType === 'input' && logEntry.runData.source.length > 1); const isMultipleInput = computed(() => paneType === 'input' && logEntry.runData.source.length > 1);
const runDataProps = computed< const runDataProps = computed<
Pick<InstanceType<typeof RunData>['$props'], 'node' | 'runIndex' | 'overrideOutputs'> | undefined Pick<InstanceType<typeof RunData>['$props'], 'node' | 'runIndex' | 'overrideOutputs'> | undefined
@@ -44,6 +46,10 @@ const runDataProps = computed<
function handleClickOpenNdv() { function handleClickOpenNdv() {
ndvStore.setActiveNodeName(logEntry.node.name); ndvStore.setActiveNodeName(logEntry.node.name);
} }
function handleChangeDisplayMode(value: IRunDataDisplayMode) {
displayMode.value = value;
}
</script> </script>
<template> <template>
@@ -61,7 +67,10 @@ function handleClickOpenNdv() {
:disable-pin="true" :disable-pin="true"
:disable-edit="true" :disable-edit="true"
:disable-hover-highlight="true" :disable-hover-highlight="true"
:display-mode="displayMode"
:disable-ai-content="logEntry.depth === 0"
table-header-bg-color="light" table-header-bg-color="light"
@display-mode-change="handleChangeDisplayMode"
> >
<template #header> <template #header>
<N8nText :class="$style.title" :bold="true" color="text-light" size="small"> <N8nText :class="$style.title" :bold="true" color="text-light" size="small">

View File

@@ -132,9 +132,9 @@ describe('NodeErrorView.vue', () => {
); );
}); });
it('renders stack trace', () => { it('renders stack trace if showDetails is set to true', () => {
const { getByText } = renderComponent({ const { getByText } = renderComponent({
props: { error }, props: { error, showDetails: true },
}); });
expect(getByText('Test stack trace')).toBeTruthy(); expect(getByText('Test stack trace')).toBeTruthy();
}); });

View File

@@ -29,6 +29,7 @@ import { N8nIconButton } from '@n8n/design-system';
type Props = { type Props = {
// TODO: .node can be undefined // TODO: .node can be undefined
error: NodeError | NodeApiError | NodeOperationError; error: NodeError | NodeApiError | NodeOperationError;
showDetails?: boolean;
compact?: boolean; compact?: boolean;
}; };
@@ -416,7 +417,7 @@ async function onAskAssistantClick() {
</script> </script>
<template> <template>
<div class="node-error-view"> <div :class="['node-error-view', props.compact ? 'node-error-view_compact' : '']">
<div class="node-error-view__header"> <div class="node-error-view__header">
<div class="node-error-view__header-message" data-test-id="node-error-message"> <div class="node-error-view__header-message" data-test-id="node-error-message">
<div> <div>
@@ -449,7 +450,7 @@ async function onAskAssistantClick() {
</div> </div>
</div> </div>
<div v-if="!compact" class="node-error-view__info"> <div v-if="showDetails" class="node-error-view__info">
<div class="node-error-view__info-header"> <div class="node-error-view__info-header">
<p class="node-error-view__info-title"> <p class="node-error-view__info-title">
{{ i18n.baseText('nodeErrorView.details.title') }} {{ i18n.baseText('nodeErrorView.details.title') }}
@@ -660,6 +661,11 @@ async function onAskAssistantClick() {
background-color: var(--color-background-xlight); background-color: var(--color-background-xlight);
border: 1px solid var(--color-foreground-base); border: 1px solid var(--color-foreground-base);
border-radius: var(--border-radius-large); border-radius: var(--border-radius-large);
.node-error-view_compact & {
margin: 0 auto var(--spacing-2xs) auto;
border-radius: var(--border-radius-base);
}
} }
&__header-title { &__header-title {
@@ -670,6 +676,10 @@ async function onAskAssistantClick() {
background-color: var(--color-danger-tint-2); background-color: var(--color-danger-tint-2);
border-radius: var(--border-radius-large) var(--border-radius-large) 0 0; border-radius: var(--border-radius-large) var(--border-radius-large) 0 0;
color: var(--color-danger); color: var(--color-danger);
.node-error-view_compact & {
border-radius: var(--border-radius-base);
}
} }
&__header-message { &__header-message {
@@ -758,6 +768,10 @@ async function onAskAssistantClick() {
margin: 0 auto; margin: 0 auto;
border: 1px solid var(--color-foreground-base); border: 1px solid var(--color-foreground-base);
border-radius: var(--border-radius-large); border-radius: var(--border-radius-large);
.node-error-view_compact & {
border-radius: var(--border-radius-base);
}
} }
&__info-header { &__info-header {

View File

@@ -98,6 +98,7 @@ const render = (props: Partial<Props> = {}, pinData?: INodeExecutionData[], runD
runIndex: 0, runIndex: 0,
currentNodeName: nodes[1].name, currentNodeName: nodes[1].name,
workflow: workflowObject, workflow: workflowObject,
displayMode: 'schema',
}, },
global: { global: {
stubs: { stubs: {

View File

@@ -13,8 +13,14 @@ import { useWorkflowsStore } from '@/stores/workflows.store';
import { waitingNodeTooltip } from '@/utils/executionUtils'; import { waitingNodeTooltip } from '@/utils/executionUtils';
import { uniqBy } from 'lodash-es'; import { uniqBy } from 'lodash-es';
import { N8nIcon, N8nRadioButtons, N8nText, N8nTooltip } from '@n8n/design-system'; import { N8nIcon, N8nRadioButtons, N8nText, N8nTooltip } from '@n8n/design-system';
import type { INodeInputConfiguration, INodeOutputConfiguration, Workflow } from 'n8n-workflow'; import {
import { type NodeConnectionType, NodeConnectionTypes, NodeHelpers } from 'n8n-workflow'; type INodeInputConfiguration,
type INodeOutputConfiguration,
type Workflow,
type NodeConnectionType,
NodeConnectionTypes,
NodeHelpers,
} from 'n8n-workflow';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { useNDVStore } from '../stores/ndv.store'; import { useNDVStore } from '../stores/ndv.store';
@@ -22,6 +28,7 @@ import InputNodeSelect from './InputNodeSelect.vue';
import NodeExecuteButton from './NodeExecuteButton.vue'; import NodeExecuteButton from './NodeExecuteButton.vue';
import RunData from './RunData.vue'; import RunData from './RunData.vue';
import WireMeUp from './WireMeUp.vue'; import WireMeUp from './WireMeUp.vue';
import { type IRunDataDisplayMode } from '@/Interface';
type MappingMode = 'debugging' | 'mapping'; type MappingMode = 'debugging' | 'mapping';
@@ -35,6 +42,7 @@ export type Props = {
readOnly?: boolean; readOnly?: boolean;
isProductionExecutionPreview?: boolean; isProductionExecutionPreview?: boolean;
isPaneActive?: boolean; isPaneActive?: boolean;
displayMode: IRunDataDisplayMode;
}; };
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
@@ -64,6 +72,7 @@ const emit = defineEmits<{
changeInputNode: [nodeName: string, index: number]; changeInputNode: [nodeName: string, index: number];
execute: []; execute: [];
activatePane: []; activatePane: [];
displayModeChange: [IRunDataDisplayMode];
}>(); }>();
const i18n = useI18n(); const i18n = useI18n();
@@ -369,8 +378,10 @@ function activatePane() {
:distance-from-active="currentNodeDepth" :distance-from-active="currentNodeDepth"
:is-production-execution-preview="isProductionExecutionPreview" :is-production-execution-preview="isProductionExecutionPreview"
:is-pane-active="isPaneActive" :is-pane-active="isPaneActive"
:display-mode="displayMode"
pane-type="input" pane-type="input"
data-test-id="ndv-input-panel" data-test-id="ndv-input-panel"
:disable-ai-content="true"
@activate-pane="activatePane" @activate-pane="activatePane"
@item-hover="onItemHover" @item-hover="onItemHover"
@link-run="onLinkRun" @link-run="onLinkRun"
@@ -378,6 +389,7 @@ function activatePane() {
@run-change="onRunIndexChange" @run-change="onRunIndexChange"
@table-mounted="onTableMounted" @table-mounted="onTableMounted"
@search="onSearch" @search="onSearch"
@display-mode-change="emit('displayModeChange', $event)"
> >
<template #header> <template #header>
<div :class="$style.titleSection"> <div :class="$style.titleSection">

View File

@@ -3,7 +3,12 @@ import { ref, onMounted, onBeforeUnmount, computed, watch } from 'vue';
import { createEventBus } from '@n8n/utils/event-bus'; import { createEventBus } from '@n8n/utils/event-bus';
import type { IRunData, Workflow, NodeConnectionType, IConnectedNode } from 'n8n-workflow'; import type { IRunData, Workflow, NodeConnectionType, IConnectedNode } from 'n8n-workflow';
import { jsonParse, NodeHelpers, NodeConnectionTypes } from 'n8n-workflow'; import { jsonParse, NodeHelpers, NodeConnectionTypes } from 'n8n-workflow';
import type { IUpdateInformation, TargetItem } from '@/Interface'; import type {
IRunDataDisplayMode,
IUpdateInformation,
NodePanelType,
TargetItem,
} from '@/Interface';
import NodeSettings from '@/components/NodeSettings.vue'; import NodeSettings from '@/components/NodeSettings.vue';
import NDVDraggablePanels from './NDVDraggablePanels.vue'; import NDVDraggablePanels from './NDVDraggablePanels.vue';
@@ -350,6 +355,10 @@ const foreignCredentials = computed(() => {
const hasForeignCredential = computed(() => foreignCredentials.value.length > 0); const hasForeignCredential = computed(() => foreignCredentials.value.length > 0);
const inputPanelDisplayMode = computed(() => ndvStore.inputPanelDisplayMode);
const outputPanelDisplayMode = computed(() => ndvStore.outputPanelDisplayMode);
//methods //methods
const setIsTooltipVisible = ({ isTooltipVisible }: DataPinningDiscoveryEvent) => { const setIsTooltipVisible = ({ isTooltipVisible }: DataPinningDiscoveryEvent) => {
@@ -619,6 +628,10 @@ const setSelectedInput = (value: string | undefined) => {
selectedInput.value = value; selectedInput.value = value;
}; };
const handleChangeDisplayMode = (pane: NodePanelType, mode: IRunDataDisplayMode) => {
ndvStore.setPanelDisplayMode({ pane, mode });
};
//watchers //watchers
watch( watch(
@@ -664,8 +677,8 @@ watch(
parameters_pane_position: mainPanelPosition.value, parameters_pane_position: mainPanelPosition.value,
input_first_connector_runs: maxInputRun.value, input_first_connector_runs: maxInputRun.value,
output_first_connector_runs: maxOutputRun.value, output_first_connector_runs: maxOutputRun.value,
selected_view_inputs: isTriggerNode.value ? 'trigger' : ndvStore.inputPanelDisplayMode, selected_view_inputs: isTriggerNode.value ? 'trigger' : inputPanelDisplayMode.value,
selected_view_outputs: ndvStore.outputPanelDisplayMode, selected_view_outputs: outputPanelDisplayMode.value,
input_connectors: parentNodes.value.length, input_connectors: parentNodes.value.length,
output_connectors: outgoingConnections?.main?.length, output_connectors: outgoingConnections?.main?.length,
input_displayed_run_index: inputRun.value, input_displayed_run_index: inputRun.value,
@@ -790,6 +803,7 @@ onBeforeUnmount(() => {
:read-only="readOnly || hasForeignCredential" :read-only="readOnly || hasForeignCredential"
:is-production-execution-preview="isProductionExecutionPreview" :is-production-execution-preview="isProductionExecutionPreview"
:is-pane-active="isInputPaneActive" :is-pane-active="isInputPaneActive"
:display-mode="inputPanelDisplayMode"
@activate-pane="activateInputPane" @activate-pane="activateInputPane"
@link-run="onLinkRunToInput" @link-run="onLinkRunToInput"
@unlink-run="() => onUnlinkRun('input')" @unlink-run="() => onUnlinkRun('input')"
@@ -800,6 +814,7 @@ onBeforeUnmount(() => {
@table-mounted="onInputTableMounted" @table-mounted="onInputTableMounted"
@item-hover="onInputItemHover" @item-hover="onInputItemHover"
@search="onSearch" @search="onSearch"
@display-mode-change="handleChangeDisplayMode('input', $event)"
/> />
</template> </template>
<template #output> <template #output>
@@ -814,6 +829,7 @@ onBeforeUnmount(() => {
:block-u-i="blockUi && isTriggerNode && !isExecutableTriggerNode" :block-u-i="blockUi && isTriggerNode && !isExecutableTriggerNode"
:is-production-execution-preview="isProductionExecutionPreview" :is-production-execution-preview="isProductionExecutionPreview"
:is-pane-active="isOutputPaneActive" :is-pane-active="isOutputPaneActive"
:display-mode="outputPanelDisplayMode"
@activate-pane="activateOutputPane" @activate-pane="activateOutputPane"
@link-run="onLinkRunToOutput" @link-run="onLinkRunToOutput"
@unlink-run="() => onUnlinkRun('output')" @unlink-run="() => onUnlinkRun('output')"
@@ -822,6 +838,7 @@ onBeforeUnmount(() => {
@table-mounted="onOutputTableMounted" @table-mounted="onOutputTableMounted"
@item-hover="onOutputItemHover" @item-hover="onOutputItemHover"
@search="onSearch" @search="onSearch"
@display-mode-change="handleChangeDisplayMode('output', $event)"
/> />
</template> </template>
<template #main> <template #main>

View File

@@ -24,6 +24,7 @@ import { N8nRadioButtons, N8nText } from '@n8n/design-system';
import { useSettingsStore } from '@/stores/settings.store'; import { useSettingsStore } from '@/stores/settings.store';
import { useNodeDirtiness } from '@/composables/useNodeDirtiness'; import { useNodeDirtiness } from '@/composables/useNodeDirtiness';
import { CanvasNodeDirtiness } from '@/types'; import { CanvasNodeDirtiness } from '@/types';
import { type IRunDataDisplayMode } from '@/Interface';
// Types // Types
@@ -48,6 +49,7 @@ type Props = {
blockUI?: boolean; blockUI?: boolean;
isProductionExecutionPreview?: boolean; isProductionExecutionPreview?: boolean;
isPaneActive?: boolean; isPaneActive?: boolean;
displayMode: IRunDataDisplayMode;
}; };
// Props and emits // Props and emits
@@ -67,6 +69,7 @@ const emit = defineEmits<{
itemHover: [item: { itemIndex: number; outputIndex: number } | null]; itemHover: [item: { itemIndex: number; outputIndex: number } | null];
search: [string]; search: [string];
openSettings: []; openSettings: [];
displayModeChange: [IRunDataDisplayMode];
}>(); }>();
// Stores // Stores
@@ -88,7 +91,7 @@ const { isSubNodeType } = useNodeType({
}); });
const pinnedData = usePinnedData(activeNode, { const pinnedData = usePinnedData(activeNode, {
runIndex: props.runIndex, runIndex: props.runIndex,
displayMode: ndvStore.outputPanelDisplayMode, displayMode: props.displayMode,
}); });
// Data // Data
@@ -341,6 +344,8 @@ const activatePane = () => {
pane-type="output" pane-type="output"
:data-output-type="outputMode" :data-output-type="outputMode"
:callout-message="allToolsWereUnusedNotice" :callout-message="allToolsWereUnusedNotice"
:display-mode="displayMode"
:disable-ai-content="true"
@activate-pane="activatePane" @activate-pane="activatePane"
@run-change="onRunIndexChange" @run-change="onRunIndexChange"
@link-run="onLinkRun" @link-run="onLinkRun"
@@ -348,6 +353,7 @@ const activatePane = () => {
@table-mounted="emit('tableMounted', $event)" @table-mounted="emit('tableMounted', $event)"
@item-hover="emit('itemHover', $event)" @item-hover="emit('itemHover', $event)"
@search="emit('search', $event)" @search="emit('search', $event)"
@display-mode-change="emit('displayModeChange', $event)"
> >
<template #header> <template #header>
<div :class="$style.titleSection"> <div :class="$style.titleSection">

View File

@@ -641,7 +641,6 @@ describe('RunData', () => {
initialState: { initialState: {
[STORES.SETTINGS]: SETTINGS_STORE_DEFAULT_STATE, [STORES.SETTINGS]: SETTINGS_STORE_DEFAULT_STATE,
[STORES.NDV]: { [STORES.NDV]: {
outputPanelDisplayMode: displayMode,
activeNodeName: 'Test Node', activeNodeName: 'Test Node',
}, },
[STORES.WORKFLOWS]: { [STORES.WORKFLOWS]: {
@@ -696,6 +695,7 @@ describe('RunData', () => {
// @ts-expect-error allow missing properties in test // @ts-expect-error allow missing properties in test
workflowNodes, workflowNodes,
}), }),
displayMode,
}, },
global: { global: {
stubs: { stubs: {

View File

@@ -95,6 +95,7 @@ import ViewSubExecution from './ViewSubExecution.vue';
import RunDataItemCount from '@/components/RunDataItemCount.vue'; import RunDataItemCount from '@/components/RunDataItemCount.vue';
import RunDataDisplayModeSelect from '@/components/RunDataDisplayModeSelect.vue'; import RunDataDisplayModeSelect from '@/components/RunDataDisplayModeSelect.vue';
import RunDataPaginationBar from '@/components/RunDataPaginationBar.vue'; import RunDataPaginationBar from '@/components/RunDataPaginationBar.vue';
import { parseAiContent } from '@/utils/aiUtils';
const LazyRunDataTable = defineAsyncComponent( const LazyRunDataTable = defineAsyncComponent(
async () => await import('@/components/RunDataTable.vue'), async () => await import('@/components/RunDataTable.vue'),
@@ -109,6 +110,9 @@ const LazyRunDataSchema = defineAsyncComponent(
const LazyRunDataHtml = defineAsyncComponent( const LazyRunDataHtml = defineAsyncComponent(
async () => await import('@/components/RunDataHtml.vue'), async () => await import('@/components/RunDataHtml.vue'),
); );
const LazyRunDataAi = defineAsyncComponent(
async () => await import('@/components/RunDataParsedAiContent.vue'),
);
const LazyRunDataSearch = defineAsyncComponent( const LazyRunDataSearch = defineAsyncComponent(
async () => await import('@/components/RunDataSearch.vue'), async () => await import('@/components/RunDataSearch.vue'),
); );
@@ -125,6 +129,7 @@ type Props = {
executingMessage: string; executingMessage: string;
pushRef?: string; pushRef?: string;
paneType: NodePanelType; paneType: NodePanelType;
displayMode: IRunDataDisplayMode;
noDataInBranchMessage: string; noDataInBranchMessage: string;
node?: INodeUi | null; node?: INodeUi | null;
nodes?: IConnectedNode[]; nodes?: IConnectedNode[];
@@ -145,6 +150,7 @@ type Props = {
compact?: boolean; compact?: boolean;
tableHeaderBgColor?: 'base' | 'light'; tableHeaderBgColor?: 'base' | 'light';
disableHoverHighlight?: boolean; disableHoverHighlight?: boolean;
disableAiContent?: boolean;
}; };
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
@@ -166,6 +172,7 @@ const props = withDefaults(defineProps<Props>(), {
compact: false, compact: false,
tableHeaderBgColor: 'base', tableHeaderBgColor: 'base',
workflowExecution: undefined, workflowExecution: undefined,
disableAiContent: false,
}); });
defineSlots<{ defineSlots<{
@@ -198,6 +205,7 @@ const emit = defineEmits<{
avgRowHeight: number; avgRowHeight: number;
}, },
]; ];
displayModeChange: [IRunDataDisplayMode];
}>(); }>();
const connectionType = ref<NodeConnectionType>(NodeConnectionTypes.Main); const connectionType = ref<NodeConnectionType>(NodeConnectionTypes.Main);
@@ -236,17 +244,12 @@ const node = toRef(props, 'node');
const pinnedData = usePinnedData(node, { const pinnedData = usePinnedData(node, {
runIndex: props.runIndex, runIndex: props.runIndex,
displayMode: displayMode: props.displayMode,
props.paneType === 'input' ? ndvStore.inputPanelDisplayMode : ndvStore.outputPanelDisplayMode,
}); });
const { isSubNodeType } = useNodeType({ const { isSubNodeType } = useNodeType({
node, node,
}); });
const displayMode = computed(() =>
props.paneType === 'input' ? ndvStore.inputPanelDisplayMode : ndvStore.outputPanelDisplayMode,
);
const isReadOnlyRoute = computed(() => route.meta.readOnlyCanvas === true); const isReadOnlyRoute = computed(() => route.meta.readOnlyCanvas === true);
const isWaitNodeWaiting = computed(() => { const isWaitNodeWaiting = computed(() => {
return ( return (
@@ -263,7 +266,7 @@ const nodeType = computed(() => {
return nodeTypesStore.getNodeType(node.value.type, node.value.typeVersion); return nodeTypesStore.getNodeType(node.value.type, node.value.typeVersion);
}); });
const isSchemaView = computed(() => displayMode.value === 'schema'); const isSchemaView = computed(() => props.displayMode === 'schema');
const isSearchInSchemaView = computed(() => isSchemaView.value && !!search.value); const isSearchInSchemaView = computed(() => isSchemaView.value && !!search.value);
const hasMultipleInputNodes = computed(() => props.paneType === 'input' && props.nodes.length > 0); const hasMultipleInputNodes = computed(() => props.paneType === 'input' && props.nodes.length > 0);
const displaysMultipleNodes = computed(() => isSchemaView.value && hasMultipleInputNodes.value); const displaysMultipleNodes = computed(() => isSchemaView.value && hasMultipleInputNodes.value);
@@ -617,6 +620,14 @@ const itemsCountProps = computed<InstanceType<typeof RunDataItemCount>['$props']
subExecutionsCount: activeTaskMetadata.value?.subExecutionsCount, subExecutionsCount: activeTaskMetadata.value?.subExecutionsCount,
})); }));
const parsedAiContent = computed(() =>
props.disableAiContent ? [] : parseAiContent(rawInputData.value, connectionType.value),
);
const hasParsedAiContent = computed(() =>
parsedAiContent.value.some((prr) => prr.parsedContent?.parsed),
);
function setInputBranchIndex(value: number) { function setInputBranchIndex(value: number) {
if (props.paneType === 'input') { if (props.paneType === 'input') {
outputIndex.value = value; outputIndex.value = value;
@@ -659,9 +670,9 @@ watch(jsonData, (data: IDataObject[], prevData: IDataObject[]) => {
}); });
watch(binaryData, (newData, prevData) => { watch(binaryData, (newData, prevData) => {
if (newData.length && !prevData.length && displayMode.value !== 'binary') { if (newData.length && !prevData.length && props.displayMode !== 'binary') {
switchToBinary(); switchToBinary();
} else if (!newData.length && displayMode.value === 'binary') { } else if (!newData.length && props.displayMode === 'binary') {
onDisplayModeChange('table'); onDisplayModeChange('table');
} }
}); });
@@ -677,6 +688,17 @@ watch(search, (newSearch) => {
emit('search', newSearch); emit('search', newSearch);
}); });
// Switch to AI display mode if it's most suitable
watch(
hasParsedAiContent,
(hasAiContent) => {
if (hasAiContent && props.displayMode !== 'ai') {
emit('displayModeChange', 'ai');
}
},
{ immediate: true },
);
onMounted(() => { onMounted(() => {
init(); init();
@@ -869,7 +891,7 @@ function enterEditMode({ origin }: EnterEditModeArgs) {
push_ref: props.pushRef, push_ref: props.pushRef,
run_index: props.runIndex, run_index: props.runIndex,
is_output_present: hasNodeRun.value || pinnedData.hasData.value, is_output_present: hasNodeRun.value || pinnedData.hasData.value,
view: !hasNodeRun.value && !pinnedData.hasData.value ? 'undefined' : displayMode.value, view: !hasNodeRun.value && !pinnedData.hasData.value ? 'undefined' : props.displayMode,
is_data_pinned: pinnedData.hasData.value, is_data_pinned: pinnedData.hasData.value,
}); });
} }
@@ -912,7 +934,7 @@ function onExitEditMode({ type }: { type: 'save' | 'cancel' }) {
node_type: activeNode.value?.type, node_type: activeNode.value?.type,
push_ref: props.pushRef, push_ref: props.pushRef,
run_index: props.runIndex, run_index: props.runIndex,
view: displayMode.value, view: props.displayMode,
type, type,
}); });
} }
@@ -927,7 +949,7 @@ async function onTogglePinData({ source }: { source: PinDataSource | UnpinDataSo
node_type: activeNode.value?.type, node_type: activeNode.value?.type,
push_ref: props.pushRef, push_ref: props.pushRef,
run_index: props.runIndex, run_index: props.runIndex,
view: !hasNodeRun.value && !pinnedData.hasData.value ? 'none' : displayMode.value, view: !hasNodeRun.value && !pinnedData.hasData.value ? 'none' : props.displayMode,
}; };
void externalHooks.run('runData.onTogglePinData', telemetryPayload); void externalHooks.run('runData.onTogglePinData', telemetryPayload);
@@ -1046,8 +1068,8 @@ function onPageSizeChange(newPageSize: number) {
} }
function onDisplayModeChange(newDisplayMode: IRunDataDisplayMode) { function onDisplayModeChange(newDisplayMode: IRunDataDisplayMode) {
const previous = displayMode.value; const previous = props.displayMode;
ndvStore.setPanelDisplayMode({ pane: props.paneType, mode: newDisplayMode }); emit('displayModeChange', newDisplayMode);
if (!userEnabledShowData.value) updateShowData(); if (!userEnabledShowData.value) updateShowData();
@@ -1193,15 +1215,9 @@ function init() {
} }
connectionType.value = outputTypes.length === 0 ? NodeConnectionTypes.Main : outputTypes[0]; connectionType.value = outputTypes.length === 0 ? NodeConnectionTypes.Main : outputTypes[0];
if (binaryData.value.length > 0) { if (binaryData.value.length > 0) {
ndvStore.setPanelDisplayMode({ emit('displayModeChange', 'binary');
pane: props.paneType, } else if (props.displayMode === 'binary') {
mode: 'binary', emit('displayModeChange', 'schema');
});
} else if (displayMode.value === 'binary') {
ndvStore.setPanelDisplayMode({
pane: props.paneType,
mode: 'schema',
});
} }
} }
@@ -1317,10 +1333,7 @@ function setDisplayMode() {
activeNode.value.parameters.operation === 'generateHtmlTemplate'; activeNode.value.parameters.operation === 'generateHtmlTemplate';
if (shouldDisplayHtml) { if (shouldDisplayHtml) {
ndvStore.setPanelDisplayMode({ emit('displayModeChange', 'html');
pane: 'output',
mode: 'html',
});
} }
} }
@@ -1426,6 +1439,7 @@ defineExpose({ enterEditMode });
activeNode?.type === HTML_NODE_TYPE && activeNode?.type === HTML_NODE_TYPE &&
activeNode.parameters.operation === 'generateHtmlTemplate' activeNode.parameters.operation === 'generateHtmlTemplate'
" "
:has-renderable-data="hasParsedAiContent"
@change="onDisplayModeChange" @change="onDisplayModeChange"
/> />
@@ -1625,7 +1639,12 @@ defineExpose({ enterEditMode });
" "
:class="$style.stretchVertically" :class="$style.stretchVertically"
> >
<NodeErrorView :error="subworkflowExecutionError" :class="$style.errorDisplay" /> <NodeErrorView
:compact="compact"
:error="subworkflowExecutionError"
:class="$style.errorDisplay"
show-details
/>
</div> </div>
<div v-else-if="isWaitNodeWaiting" :class="$style.center"> <div v-else-if="isWaitNodeWaiting" :class="$style.center">
@@ -1687,7 +1706,7 @@ defineExpose({ enterEditMode });
v-if="workflowRunErrorAsNodeError" v-if="workflowRunErrorAsNodeError"
:error="workflowRunErrorAsNodeError" :error="workflowRunErrorAsNodeError"
:class="$style.inlineError" :class="$style.inlineError"
compact :compact="compact"
/> />
<slot name="content"></slot> <slot name="content"></slot>
</div> </div>
@@ -1695,6 +1714,8 @@ defineExpose({ enterEditMode });
v-else-if="workflowRunErrorAsNodeError" v-else-if="workflowRunErrorAsNodeError"
:error="workflowRunErrorAsNodeError" :error="workflowRunErrorAsNodeError"
:class="$style.dataDisplay" :class="$style.dataDisplay"
:compact="compact"
show-details
/> />
</div> </div>
@@ -1835,6 +1856,10 @@ defineExpose({ enterEditMode });
<LazyRunDataHtml :input-html="inputHtml" /> <LazyRunDataHtml :input-html="inputHtml" />
</Suspense> </Suspense>
<Suspense v-else-if="hasNodeRun && displayMode === 'ai'">
<LazyRunDataAi render-type="rendered" :compact="compact" :content="parsedAiContent" />
</Suspense>
<Suspense v-else-if="(hasNodeRun || hasPreviewSchema) && isSchemaView"> <Suspense v-else-if="(hasNodeRun || hasPreviewSchema) && isSchemaView">
<LazyRunDataSchema <LazyRunDataSchema
:nodes="nodes" :nodes="nodes"

View File

@@ -1,32 +1,27 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { IAiDataContent } from '@/Interface'; import type { IAiDataContent } from '@/Interface';
import { capitalize } from 'lodash-es'; import { capitalize } from 'lodash-es';
import { ref, onMounted } from 'vue'; import { computed, ref } from 'vue';
import type { ParsedAiContent } from './useAiContentParsers';
import { useAiContentParsers } from './useAiContentParsers';
import VueMarkdown from 'vue-markdown-render';
import hljs from 'highlight.js/lib/core';
import { useClipboard } from '@/composables/useClipboard';
import { useI18n } from '@/composables/useI18n';
import { useToast } from '@/composables/useToast';
import { NodeConnectionTypes } from 'n8n-workflow'; import { NodeConnectionTypes } from 'n8n-workflow';
import type { NodeConnectionType, NodeError, IDataObject } from 'n8n-workflow'; import type { NodeConnectionType, NodeError } from 'n8n-workflow';
import RunDataAi from '@/components/RunDataParsedAiContent.vue';
import { parseAiContent } from '@/utils/aiUtils';
import { N8nRadioButtons } from '@n8n/design-system';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
const props = defineProps<{ const props = defineProps<{
runData: IAiDataContent; runData: IAiDataContent;
error?: NodeError; error?: NodeError;
}>(); }>();
const i18n = useI18n();
const clipboard = useClipboard();
const { showMessage } = useToast();
const contentParsers = useAiContentParsers();
// eslint-disable-next-line @typescript-eslint/no-use-before-define // eslint-disable-next-line @typescript-eslint/no-use-before-define
const isExpanded = ref(getInitialExpandedState()); const isExpanded = ref(getInitialExpandedState());
const renderType = ref<'rendered' | 'json'>('rendered'); const renderType = ref<'rendered' | 'json'>('rendered');
const contentParsed = ref(false); const parsedRun = computed(() => parseAiContent(props.runData.data ?? [], props.runData.type));
const parsedRun = ref(undefined as ParsedAiContent | undefined); const contentParsed = computed(() =>
parsedRun.value.some((item) => item.parsedContent?.parsed === true),
);
function getInitialExpandedState() { function getInitialExpandedState() {
const collapsedTypes = { const collapsedTypes = {
input: [ input: [
@@ -44,119 +39,23 @@ function getInitialExpandedState() {
return !collapsedTypes[props.runData.inOut].includes(props.runData.type); return !collapsedTypes[props.runData.inOut].includes(props.runData.type);
} }
function isJsonString(text: string) {
try {
JSON.parse(text);
return true;
} catch (e) {
return false;
}
}
const markdownOptions = {
highlight(str: string, lang: string) {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(str, { language: lang }).value;
} catch {}
}
return ''; // use external default escaping
},
};
function parseAiRunData(run: IAiDataContent) {
if (!run.data) {
return;
}
const parsedData = contentParsers.parseAiRunData(run.data, run.type);
return parsedData;
}
function isMarkdown(content: JsonMarkdown): boolean {
if (typeof content !== 'string') return false;
const markdownPatterns = [
/^# .+/gm, // headers
/\*{1,2}.+\*{1,2}/g, // emphasis and strong
/\[.+\]\(.+\)/g, // links
/```[\s\S]+```/g, // code blocks
];
return markdownPatterns.some((pattern) => pattern.test(content));
}
function formatToJsonMarkdown(data: string): string {
return '```json\n' + data + '\n```';
}
type JsonMarkdown = string | object | Array<string | object>;
function jsonToMarkdown(data: JsonMarkdown): string {
if (isMarkdown(data)) return data as string;
if (Array.isArray(data) && data.length && typeof data[0] !== 'number') {
const markdownArray = data.map((item: JsonMarkdown) => jsonToMarkdown(item));
return markdownArray.join('\n\n').trim();
}
if (typeof data === 'string') {
// If data is a valid JSON string format it as JSON markdown
if (isJsonString(data)) {
return formatToJsonMarkdown(data);
}
// Return original string otherwise
return data;
}
return formatToJsonMarkdown(JSON.stringify(data, null, 2));
}
function setContentParsed(content: ParsedAiContent): void {
contentParsed.value = !!content.find((item) => {
if (item.parsedContent?.parsed === true) {
return true;
}
return false;
});
}
function onBlockHeaderClick() { function onBlockHeaderClick() {
isExpanded.value = !isExpanded.value; isExpanded.value = !isExpanded.value;
} }
function onCopyToClipboard(content: IDataObject | IDataObject[]) {
try {
void clipboard.copy(JSON.stringify(content, undefined, 2));
showMessage({
title: i18n.baseText('generic.copiedToClipboard'),
type: 'success',
});
} catch (err) {}
}
function onRenderTypeChange(value: 'rendered' | 'json') { function onRenderTypeChange(value: 'rendered' | 'json') {
renderType.value = value; renderType.value = value;
} }
onMounted(() => {
parsedRun.value = parseAiRunData(props.runData);
if (parsedRun.value) {
setContentParsed(parsedRun.value);
}
});
</script> </script>
<template> <template>
<div :class="$style.block"> <div :class="$style.block">
<header :class="$style.blockHeader" @click="onBlockHeaderClick"> <header :class="$style.blockHeader" @click="onBlockHeaderClick">
<button :class="$style.blockToggle"> <button :class="$style.blockToggle">
<font-awesome-icon :icon="isExpanded ? 'angle-down' : 'angle-right'" size="lg" /> <FontAwesomeIcon :icon="isExpanded ? 'angle-down' : 'angle-right'" size="lg" />
</button> </button>
<p :class="$style.blockTitle">{{ capitalize(runData.inOut) }}</p> <p :class="$style.blockTitle">{{ capitalize(runData.inOut) }}</p>
<n8n-radio-buttons <N8nRadioButtons
v-if="contentParsed && !error && isExpanded" v-if="contentParsed && !error && isExpanded"
size="small" size="small"
:model-value="renderType" :model-value="renderType"
@@ -174,104 +73,26 @@ onMounted(() => {
[$style.blockContentExpanded]: isExpanded, [$style.blockContentExpanded]: isExpanded,
}" }"
> >
<NodeErrorView v-if="error" :error="error" :class="$style.error" /> <NodeErrorView v-if="error" :error="error" :class="$style.error" show-details />
<div <RunDataAi
v-for="({ parsedContent, raw }, index) in parsedRun"
v-else v-else
:key="index" :data="runData.data"
:class="$style.contentText" :type="runData.type"
:data-content-type="parsedContent?.type" :content="parsedRun"
> :render-type="renderType"
<template v-if="parsedContent && renderType === 'rendered'"> />
<template v-if="parsedContent.type === 'json'">
<VueMarkdown
:source="jsonToMarkdown(parsedContent.data as JsonMarkdown)"
:class="$style.markdown"
:options="markdownOptions"
/>
</template>
<template v-if="parsedContent.type === 'markdown'">
<VueMarkdown
:source="parsedContent.data"
:class="$style.markdown"
:options="markdownOptions"
/>
</template>
<p
v-if="parsedContent.type === 'text'"
:class="$style.runText"
v-text="parsedContent.data"
/>
</template>
<!-- We weren't able to parse text or raw switch -->
<template v-else>
<div :class="$style.rawContent">
<n8n-icon-button
size="small"
:class="$style.copyToClipboard"
type="secondary"
:title="i18n.baseText('nodeErrorView.copyToClipboard')"
icon="copy"
@click="onCopyToClipboard(raw)"
/>
<VueMarkdown :source="jsonToMarkdown(raw as JsonMarkdown)" :class="$style.markdown" />
</div>
</template>
</div>
</main> </main>
</div> </div>
</template> </template>
<style lang="scss" module> <style lang="scss" module>
.copyToClipboard {
position: absolute;
right: var(--spacing-s);
top: var(--spacing-s);
}
.rawContent {
position: relative;
}
.markdown {
& {
white-space: pre-wrap;
h1 {
font-size: var(--font-size-l);
line-height: var(--font-line-height-xloose);
}
h2 {
font-size: var(--font-size-m);
line-height: var(--font-line-height-loose);
}
h3 {
font-size: var(--font-size-s);
line-height: var(--font-line-height-regular);
}
pre {
background: var(--chat--message--pre--background);
border-radius: var(--border-radius-base);
line-height: var(--font-line-height-xloose);
padding: var(--spacing-s);
font-size: var(--font-size-s);
white-space: pre-wrap;
}
}
}
.contentText {
padding-top: var(--spacing-s);
padding-left: var(--spacing-m);
padding-right: var(--spacing-m);
font-size: var(--font-size-s);
}
.block { .block {
padding: var(--spacing-s) 0 var(--spacing-2xs) var(--spacing-2xs); padding: var(--spacing-s) 0 var(--spacing-2xs) var(--spacing-2xs);
border: 1px solid var(--color-foreground-light); border: 1px solid var(--color-foreground-light);
margin-top: var(--spacing-s); margin-top: var(--spacing-s);
border-radius: var(--border-radius-base); border-radius: var(--border-radius-base);
} }
:root .blockContent { :root .blockContent {
height: 0; height: 0;
overflow: hidden; overflow: hidden;
@@ -280,10 +101,7 @@ onMounted(() => {
height: auto; height: auto;
} }
} }
.runText {
line-height: var(--font-line-height-xloose);
white-space: pre-line;
}
.rawSwitch { .rawSwitch {
opacity: 0; opacity: 0;
height: fit-content; height: fit-content;
@@ -294,6 +112,7 @@ onMounted(() => {
opacity: 1; opacity: 1;
} }
} }
.blockHeader { .blockHeader {
display: flex; display: flex;
gap: var(--spacing-xs); gap: var(--spacing-xs);
@@ -307,12 +126,14 @@ onMounted(() => {
user-select: none; user-select: none;
} }
} }
.blockTitle { .blockTitle {
font-size: var(--font-size-s); font-size: var(--font-size-s);
color: var(--color-text-dark); color: var(--color-text-dark);
margin: 0; margin: 0;
padding-bottom: var(--spacing-4xs); padding-bottom: var(--spacing-4xs);
} }
.blockToggle { .blockToggle {
border: none; border: none;
background: none; background: none;
@@ -320,6 +141,7 @@ onMounted(() => {
color: var(--color-text-base); color: var(--color-text-base);
margin-top: calc(-1 * var(--spacing-3xs)); margin-top: calc(-1 * var(--spacing-3xs));
} }
.error { .error {
padding: var(--spacing-s) 0; padding: var(--spacing-s) 0;
} }

View File

@@ -631,7 +631,5 @@ describe(deepToRaw, () => {
expect(isReactive(raw.foo)).toBe(false); expect(isReactive(raw.foo)).toBe(false);
expect(isReactive(raw.foo.bar)).toBe(false); expect(isReactive(raw.foo.bar)).toBe(false);
expect(isReactive(raw.bazz)).toBe(false); expect(isReactive(raw.bazz)).toBe(false);
console.log(raw.foo.bar);
}); });
}); });

View File

@@ -2,15 +2,17 @@
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { type NodePanelType, type IRunDataDisplayMode } from '@/Interface'; import { type NodePanelType, type IRunDataDisplayMode } from '@/Interface';
import { N8nIcon, N8nRadioButtons } from '@n8n/design-system'; import { N8nIcon, N8nRadioButtons } from '@n8n/design-system';
import { computed } from 'vue'; import { computed, watch } from 'vue';
const { compact, value, hasBinaryData, paneType, nodeGeneratesHtml } = defineProps<{ const { compact, value, hasBinaryData, paneType, nodeGeneratesHtml, hasRenderableData } =
compact: boolean; defineProps<{
value: IRunDataDisplayMode; compact: boolean;
hasBinaryData: boolean; value: IRunDataDisplayMode;
paneType: NodePanelType; hasBinaryData: boolean;
nodeGeneratesHtml: boolean; paneType: NodePanelType;
}>(); nodeGeneratesHtml: boolean;
hasRenderableData: boolean;
}>();
const emit = defineEmits<{ change: [IRunDataDisplayMode] }>(); const emit = defineEmits<{ change: [IRunDataDisplayMode] }>();
@@ -30,8 +32,23 @@ const options = computed(() => {
defaults.unshift({ label: 'HTML', value: 'html' }); defaults.unshift({ label: 'HTML', value: 'html' });
} }
if (hasRenderableData) {
defaults.unshift({ label: i18n.baseText('runData.rendered'), value: 'ai' });
}
return defaults; return defaults;
}); });
// If selected display mode isn't included in the options, rest to the first item
watch(
[() => value, options],
([val, opts]) => {
if (opts.length > 0 && opts.every((opt) => opt.value !== val)) {
emit('change', opts[0].value);
}
},
{ immediate: true },
);
</script> </script>
<template> <template>
@@ -49,6 +66,7 @@ const options = computed(() => {
<N8nIcon v-else-if="option.value === 'binary'" icon="binary" size="small" /> <N8nIcon v-else-if="option.value === 'binary'" icon="binary" size="small" />
<N8nIcon v-else-if="option.value === 'schema'" icon="schema" size="small" /> <N8nIcon v-else-if="option.value === 'schema'" icon="schema" size="small" />
<N8nIcon v-else-if="option.value === 'html'" icon="html" size="small" /> <N8nIcon v-else-if="option.value === 'html'" icon="html" size="small" />
<N8nIcon v-else-if="option.value === 'ai'" icon="text" size="small" />
<span v-else>{{ option.label }}</span> <span v-else>{{ option.label }}</span>
</template> </template>
</N8nRadioButtons> </N8nRadioButtons>

View File

@@ -0,0 +1,74 @@
import { createComponentRenderer } from '@/__tests__/render';
import RunDataParsedAiContent from '@/components/RunDataParsedAiContent.vue';
import { createTestingPinia } from '@pinia/testing';
import { h } from 'vue';
import { createRouter, createWebHistory } from 'vue-router';
type Props = InstanceType<typeof RunDataParsedAiContent>['$props'];
function renderComponent(props: Props) {
return createComponentRenderer(RunDataParsedAiContent, {
global: {
plugins: [
createTestingPinia({ stubActions: false }),
createRouter({
history: createWebHistory(),
routes: [{ path: '/', component: () => h('div') }],
}),
],
},
props,
})();
}
describe('RunDataParsedAiContent', () => {
it('renders AI content parsed as text', () => {
const rendered = renderComponent({
renderType: 'rendered',
content: [{ raw: {}, parsedContent: { type: 'text', data: 'hello!', parsed: true } }],
});
expect(rendered.container).toHaveTextContent('hello!');
});
it('renders AI content parsed as markdown', () => {
const rendered = renderComponent({
renderType: 'rendered',
content: [
{
raw: {},
parsedContent: { type: 'markdown', data: '# hi!\n\nthis is *markdown*', parsed: true },
},
],
});
expect(rendered.getByText('hi!', { selector: 'h1' })).toBeInTheDocument();
expect(rendered.getByText('markdown', { selector: 'em' })).toBeInTheDocument();
});
it('renders AI content parsed as JSON', () => {
const rendered = renderComponent({
renderType: 'rendered',
content: [
{
raw: {},
parsedContent: { type: 'json', data: ['hi!', '{"key": "value"}'], parsed: true },
},
],
});
expect(rendered.getByText('hi!', { selector: 'p' })).toBeInTheDocument();
expect(rendered.getByText('{"key": "value"}', { selector: 'code' })).toBeInTheDocument();
});
it("renders AI content that wasn't parsed", () => {
const rendered = renderComponent({
renderType: 'rendered',
content: [{ raw: { key: 'value' }, parsedContent: null }],
});
expect(
rendered.getByText('{ "key": "value" }', { selector: 'code', exact: false }),
).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,208 @@
<script setup lang="ts">
import { useClipboard } from '@/composables/useClipboard';
import { useI18n } from '@/composables/useI18n';
import { useToast } from '@/composables/useToast';
import { type ParsedAiContent } from '@/utils/aiUtils';
import { N8nIconButton } from '@n8n/design-system';
import { type IDataObject } from 'n8n-workflow';
import VueMarkdown from 'vue-markdown-render';
import hljs from 'highlight.js/lib/core';
const {
content,
compact = false,
renderType,
} = defineProps<{
content: ParsedAiContent;
compact?: boolean;
renderType: 'rendered' | 'json';
}>();
const i18n = useI18n();
const clipboard = useClipboard();
const { showMessage } = useToast();
function isJsonString(text: string) {
try {
JSON.parse(text);
return true;
} catch (e) {
return false;
}
}
const markdownOptions = {
highlight(str: string, lang: string) {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(str, { language: lang }).value;
} catch {}
}
return ''; // use external default escaping
},
};
function isMarkdown(jsonMarkdown: JsonMarkdown): boolean {
if (typeof jsonMarkdown !== 'string') return false;
const markdownPatterns = [
/^# .+/gm, // headers
/\*{1,2}.+\*{1,2}/g, // emphasis and strong
/\[.+\]\(.+\)/g, // links
/```[\s\S]+```/g, // code blocks
];
return markdownPatterns.some((pattern) => pattern.test(jsonMarkdown));
}
function formatToJsonMarkdown(data: string): string {
return '```json\n' + data + '\n```';
}
type JsonMarkdown = string | object | Array<string | object>;
function jsonToMarkdown(data: JsonMarkdown): string {
if (isMarkdown(data)) return data as string;
if (Array.isArray(data) && data.length && typeof data[0] !== 'number') {
const markdownArray = data.map((item: JsonMarkdown) => jsonToMarkdown(item));
return markdownArray.join('\n\n').trim();
}
if (typeof data === 'string') {
// If data is a valid JSON string format it as JSON markdown
if (isJsonString(data)) {
return formatToJsonMarkdown(data);
}
// Return original string otherwise
return data;
}
return formatToJsonMarkdown(JSON.stringify(data, null, 2));
}
function onCopyToClipboard(object: IDataObject | IDataObject[]) {
try {
void clipboard.copy(JSON.stringify(object, undefined, 2));
showMessage({
title: i18n.baseText('generic.copiedToClipboard'),
type: 'success',
});
} catch {}
}
</script>
<template>
<div :class="[$style.component, compact ? $style.compact : '']">
<div
v-for="({ parsedContent, raw }, index) in content"
:key="index"
:class="$style.contentText"
:data-content-type="parsedContent?.type"
>
<template v-if="parsedContent && renderType === 'rendered'">
<VueMarkdown
v-if="parsedContent.type === 'json'"
:source="jsonToMarkdown(parsedContent.data as JsonMarkdown)"
:class="$style.markdown"
:options="markdownOptions"
/>
<VueMarkdown
v-else-if="parsedContent.type === 'markdown'"
:source="parsedContent.data"
:class="$style.markdown"
:options="markdownOptions"
/>
<p
v-else-if="parsedContent.type === 'text'"
:class="$style.runText"
v-text="parsedContent.data"
/>
</template>
<!-- We weren't able to parse text or raw switch -->
<div v-else :class="$style.rawContent">
<N8nIconButton
size="small"
:class="$style.copyToClipboard"
type="secondary"
:title="i18n.baseText('nodeErrorView.copyToClipboard')"
icon="copy"
@click="onCopyToClipboard(raw)"
/>
<VueMarkdown :source="jsonToMarkdown(raw as JsonMarkdown)" :class="$style.markdown" />
</div>
</div>
</div>
</template>
<style lang="scss" module>
.runText {
line-height: var(--font-line-height-xloose);
white-space: pre-line;
}
.markdown {
& {
white-space: pre-wrap;
h1 {
font-size: var(--font-size-l);
line-height: var(--font-line-height-xloose);
}
h2 {
font-size: var(--font-size-m);
line-height: var(--font-line-height-loose);
}
h3 {
font-size: var(--font-size-s);
line-height: var(--font-line-height-regular);
}
pre {
background: var(--chat--message--pre--background);
border-radius: var(--border-radius-base);
line-height: var(--font-line-height-xloose);
padding: var(--spacing-s);
font-size: var(--font-size-s);
white-space: pre-wrap;
.compact & {
padding: var(--spacing-3xs);
font-size: var(--font-size-xs);
}
}
}
}
.copyToClipboard {
position: absolute;
right: var(--spacing-s);
top: var(--spacing-s);
.compact & {
right: var(--spacing-2xs);
top: var(--spacing-2xs);
}
}
.rawContent {
position: relative;
}
.contentText {
padding-top: var(--spacing-s);
padding-left: var(--spacing-m);
padding-right: var(--spacing-m);
font-size: var(--font-size-s);
.compact & {
padding-top: 0;
padding-inline: var(--spacing-2xs);
font-size: var(--font-size-xs);
}
}
</style>

View File

@@ -1776,6 +1776,7 @@
"runData.invalidPinnedData": "Invalid pinned data", "runData.invalidPinnedData": "Invalid pinned data",
"runData.items": "Items", "runData.items": "Items",
"runData.json": "JSON", "runData.json": "JSON",
"runData.rendered": "Rendered",
"runData.schema": "Schema", "runData.schema": "Schema",
"runData.mimeType": "Mime Type", "runData.mimeType": "Mime Type",
"runData.fileSize": "File Size", "runData.fileSize": "File Size",

View File

@@ -0,0 +1,81 @@
import { parseAiContent } from '@/utils/aiUtils';
import { NodeConnectionTypes } from 'n8n-workflow';
describe(parseAiContent, () => {
it('should parse inputOverride data', () => {
const executionData = [
{
json: {
messages: ['System: You are a helpful assistant\nHuman: test'],
estimatedTokens: 11,
options: {
openai_api_key: {
lc: 1,
type: 'secret',
id: ['OPENAI_API_KEY'],
},
model: 'gpt-4o-mini',
timeout: 60000,
max_retries: 2,
configuration: {
baseURL: 'https://api.openai.com/v1',
},
model_kwargs: {},
},
},
},
];
expect(parseAiContent(executionData, NodeConnectionTypes.AiLanguageModel)).toEqual([
{
parsedContent: {
data: 'System: You are a helpful assistant\nHuman: test',
parsed: true,
type: 'text',
},
raw: expect.any(Object),
},
]);
});
it('should parse response from AI model node', () => {
const executionData = [
{
json: {
response: {
generations: [
[
{
text: "It seems like you're testing the interface. How can I assist you today?",
generationInfo: {
prompt: 0,
completion: 0,
finish_reason: 'stop',
system_fingerprint: 'fp_b376dfbbd5',
model_name: 'gpt-4o-mini-2024-07-18',
},
},
],
],
},
tokenUsage: {
completionTokens: 16,
promptTokens: 17,
totalTokens: 33,
},
},
},
];
expect(parseAiContent(executionData, NodeConnectionTypes.AiLanguageModel)).toEqual([
{
parsedContent: {
data: ["It seems like you're testing the interface. How can I assist you today?"],
parsed: true,
type: 'json',
},
raw: expect.any(Object),
},
]);
});
});

View File

@@ -106,7 +106,7 @@ const outputTypeParsers: {
} }
| string; | string;
} }
let message = content.kwargs.content; let message = String(content.kwargs.content);
if (Array.isArray(message)) { if (Array.isArray(message)) {
message = (message as MessageContent[]) message = (message as MessageContent[])
.map((item) => { .map((item) => {
@@ -199,6 +199,7 @@ const outputTypeParsers: {
}; };
}, },
}; };
export type ParsedAiContent = Array<{ export type ParsedAiContent = Array<{
raw: IDataObject | IDataObject[]; raw: IDataObject | IDataObject[];
parsedContent: { parsedContent: {
@@ -208,40 +209,33 @@ export type ParsedAiContent = Array<{
} | null; } | null;
}>; }>;
export const useAiContentParsers = () => { export function parseAiContent(
const parseAiRunData = ( executionData: INodeExecutionData[],
executionData: INodeExecutionData[], endpointType: NodeConnectionType,
endpointType: NodeConnectionType, ) {
): ParsedAiContent => { if (
if ( ([NodeConnectionTypes.AiChain, NodeConnectionTypes.Main] as NodeConnectionType[]).includes(
([NodeConnectionTypes.AiChain, NodeConnectionTypes.Main] as NodeConnectionType[]).includes( endpointType,
endpointType, )
) ) {
) { return executionData.map((data) => ({ raw: data.json, parsedContent: null }));
return executionData.map((data) => ({ raw: data.json, parsedContent: null })); }
}
const contentJson = executionData.map((node) => { const contentJson = executionData.map((node) => {
const hasBinaryData = !isObjectEmpty(node.binary); const hasBinaryData = !isObjectEmpty(node.binary);
return hasBinaryData ? node.binary : node.json; return hasBinaryData ? node.binary : node.json;
}); });
const parser = outputTypeParsers[endpointType as AllowedEndpointType]; const parser = outputTypeParsers[endpointType as AllowedEndpointType];
if (!parser) if (!parser)
return [ return [
{ {
raw: contentJson.filter((item): item is IDataObject => item !== undefined), raw: contentJson.filter((item): item is IDataObject => item !== undefined),
parsedContent: null, parsedContent: null,
}, },
]; ];
const parsedOutput = contentJson return contentJson
.filter((c): c is IDataObject => c !== undefined) .filter((c): c is IDataObject => c !== undefined)
.map((c) => ({ raw: c, parsedContent: parser(c) })); .map((c) => ({ raw: c, parsedContent: parser(c) }));
return parsedOutput; }
};
return {
parseAiRunData,
};
};