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() {
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(10, 0).should('contain.text', '9');
logs.clickOpenNdvAtRow(2);
ndv.setInputDisplayMode('Table');
ndv.getInputSelect().should('have.value', 'Code ');
ndv.getInputTableRows().should('have.length', 11);
ndv.getInputTbodyCell(1, 0).should('contain.text', '0');

View File

@@ -107,3 +107,8 @@ Object.defineProperty(window, 'DataTransfer', {
writable: true,
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 v-bind="virtualList.wrapperProps.value" role="tree">
<LogsOverviewRow
v-for="{ data } of virtualList.list.value"
:key="data.id"
v-for="{ data, index } of virtualList.list.value"
:key="index"
:data="data"
:is-read-only="isReadOnly"
:is-selected="

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,8 +13,14 @@ import { useWorkflowsStore } from '@/stores/workflows.store';
import { waitingNodeTooltip } from '@/utils/executionUtils';
import { uniqBy } from 'lodash-es';
import { N8nIcon, N8nRadioButtons, N8nText, N8nTooltip } from '@n8n/design-system';
import type { INodeInputConfiguration, INodeOutputConfiguration, Workflow } from 'n8n-workflow';
import { type NodeConnectionType, NodeConnectionTypes, NodeHelpers } from 'n8n-workflow';
import {
type INodeInputConfiguration,
type INodeOutputConfiguration,
type Workflow,
type NodeConnectionType,
NodeConnectionTypes,
NodeHelpers,
} from 'n8n-workflow';
import { storeToRefs } from 'pinia';
import { computed, ref, watch } from 'vue';
import { useNDVStore } from '../stores/ndv.store';
@@ -22,6 +28,7 @@ import InputNodeSelect from './InputNodeSelect.vue';
import NodeExecuteButton from './NodeExecuteButton.vue';
import RunData from './RunData.vue';
import WireMeUp from './WireMeUp.vue';
import { type IRunDataDisplayMode } from '@/Interface';
type MappingMode = 'debugging' | 'mapping';
@@ -35,6 +42,7 @@ export type Props = {
readOnly?: boolean;
isProductionExecutionPreview?: boolean;
isPaneActive?: boolean;
displayMode: IRunDataDisplayMode;
};
const props = withDefaults(defineProps<Props>(), {
@@ -64,6 +72,7 @@ const emit = defineEmits<{
changeInputNode: [nodeName: string, index: number];
execute: [];
activatePane: [];
displayModeChange: [IRunDataDisplayMode];
}>();
const i18n = useI18n();
@@ -369,8 +378,10 @@ function activatePane() {
:distance-from-active="currentNodeDepth"
:is-production-execution-preview="isProductionExecutionPreview"
:is-pane-active="isPaneActive"
:display-mode="displayMode"
pane-type="input"
data-test-id="ndv-input-panel"
:disable-ai-content="true"
@activate-pane="activatePane"
@item-hover="onItemHover"
@link-run="onLinkRun"
@@ -378,6 +389,7 @@ function activatePane() {
@run-change="onRunIndexChange"
@table-mounted="onTableMounted"
@search="onSearch"
@display-mode-change="emit('displayModeChange', $event)"
>
<template #header>
<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 type { IRunData, Workflow, NodeConnectionType, IConnectedNode } 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 NDVDraggablePanels from './NDVDraggablePanels.vue';
@@ -350,6 +355,10 @@ const foreignCredentials = computed(() => {
const hasForeignCredential = computed(() => foreignCredentials.value.length > 0);
const inputPanelDisplayMode = computed(() => ndvStore.inputPanelDisplayMode);
const outputPanelDisplayMode = computed(() => ndvStore.outputPanelDisplayMode);
//methods
const setIsTooltipVisible = ({ isTooltipVisible }: DataPinningDiscoveryEvent) => {
@@ -619,6 +628,10 @@ const setSelectedInput = (value: string | undefined) => {
selectedInput.value = value;
};
const handleChangeDisplayMode = (pane: NodePanelType, mode: IRunDataDisplayMode) => {
ndvStore.setPanelDisplayMode({ pane, mode });
};
//watchers
watch(
@@ -664,8 +677,8 @@ watch(
parameters_pane_position: mainPanelPosition.value,
input_first_connector_runs: maxInputRun.value,
output_first_connector_runs: maxOutputRun.value,
selected_view_inputs: isTriggerNode.value ? 'trigger' : ndvStore.inputPanelDisplayMode,
selected_view_outputs: ndvStore.outputPanelDisplayMode,
selected_view_inputs: isTriggerNode.value ? 'trigger' : inputPanelDisplayMode.value,
selected_view_outputs: outputPanelDisplayMode.value,
input_connectors: parentNodes.value.length,
output_connectors: outgoingConnections?.main?.length,
input_displayed_run_index: inputRun.value,
@@ -790,6 +803,7 @@ onBeforeUnmount(() => {
:read-only="readOnly || hasForeignCredential"
:is-production-execution-preview="isProductionExecutionPreview"
:is-pane-active="isInputPaneActive"
:display-mode="inputPanelDisplayMode"
@activate-pane="activateInputPane"
@link-run="onLinkRunToInput"
@unlink-run="() => onUnlinkRun('input')"
@@ -800,6 +814,7 @@ onBeforeUnmount(() => {
@table-mounted="onInputTableMounted"
@item-hover="onInputItemHover"
@search="onSearch"
@display-mode-change="handleChangeDisplayMode('input', $event)"
/>
</template>
<template #output>
@@ -814,6 +829,7 @@ onBeforeUnmount(() => {
:block-u-i="blockUi && isTriggerNode && !isExecutableTriggerNode"
:is-production-execution-preview="isProductionExecutionPreview"
:is-pane-active="isOutputPaneActive"
:display-mode="outputPanelDisplayMode"
@activate-pane="activateOutputPane"
@link-run="onLinkRunToOutput"
@unlink-run="() => onUnlinkRun('output')"
@@ -822,6 +838,7 @@ onBeforeUnmount(() => {
@table-mounted="onOutputTableMounted"
@item-hover="onOutputItemHover"
@search="onSearch"
@display-mode-change="handleChangeDisplayMode('output', $event)"
/>
</template>
<template #main>

View File

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

View File

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

View File

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

View File

@@ -1,32 +1,27 @@
<script lang="ts" setup>
import type { IAiDataContent } from '@/Interface';
import { capitalize } from 'lodash-es';
import { ref, onMounted } 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 { computed, ref } from 'vue';
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<{
runData: IAiDataContent;
error?: NodeError;
}>();
const i18n = useI18n();
const clipboard = useClipboard();
const { showMessage } = useToast();
const contentParsers = useAiContentParsers();
// eslint-disable-next-line @typescript-eslint/no-use-before-define
const isExpanded = ref(getInitialExpandedState());
const renderType = ref<'rendered' | 'json'>('rendered');
const contentParsed = ref(false);
const parsedRun = ref(undefined as ParsedAiContent | undefined);
const parsedRun = computed(() => parseAiContent(props.runData.data ?? [], props.runData.type));
const contentParsed = computed(() =>
parsedRun.value.some((item) => item.parsedContent?.parsed === true),
);
function getInitialExpandedState() {
const collapsedTypes = {
input: [
@@ -44,119 +39,23 @@ function getInitialExpandedState() {
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() {
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') {
renderType.value = value;
}
onMounted(() => {
parsedRun.value = parseAiRunData(props.runData);
if (parsedRun.value) {
setContentParsed(parsedRun.value);
}
});
</script>
<template>
<div :class="$style.block">
<header :class="$style.blockHeader" @click="onBlockHeaderClick">
<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>
<p :class="$style.blockTitle">{{ capitalize(runData.inOut) }}</p>
<n8n-radio-buttons
<N8nRadioButtons
v-if="contentParsed && !error && isExpanded"
size="small"
:model-value="renderType"
@@ -174,104 +73,26 @@ onMounted(() => {
[$style.blockContentExpanded]: isExpanded,
}"
>
<NodeErrorView v-if="error" :error="error" :class="$style.error" />
<div
v-for="({ parsedContent, raw }, index) in parsedRun"
<NodeErrorView v-if="error" :error="error" :class="$style.error" show-details />
<RunDataAi
v-else
:key="index"
:class="$style.contentText"
:data-content-type="parsedContent?.type"
>
<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>
:data="runData.data"
:type="runData.type"
:content="parsedRun"
:render-type="renderType"
/>
</main>
</div>
</template>
<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 {
padding: var(--spacing-s) 0 var(--spacing-2xs) var(--spacing-2xs);
border: 1px solid var(--color-foreground-light);
margin-top: var(--spacing-s);
border-radius: var(--border-radius-base);
}
:root .blockContent {
height: 0;
overflow: hidden;
@@ -280,10 +101,7 @@ onMounted(() => {
height: auto;
}
}
.runText {
line-height: var(--font-line-height-xloose);
white-space: pre-line;
}
.rawSwitch {
opacity: 0;
height: fit-content;
@@ -294,6 +112,7 @@ onMounted(() => {
opacity: 1;
}
}
.blockHeader {
display: flex;
gap: var(--spacing-xs);
@@ -307,12 +126,14 @@ onMounted(() => {
user-select: none;
}
}
.blockTitle {
font-size: var(--font-size-s);
color: var(--color-text-dark);
margin: 0;
padding-bottom: var(--spacing-4xs);
}
.blockToggle {
border: none;
background: none;
@@ -320,6 +141,7 @@ onMounted(() => {
color: var(--color-text-base);
margin-top: calc(-1 * var(--spacing-3xs));
}
.error {
padding: var(--spacing-s) 0;
}

View File

@@ -631,7 +631,5 @@ describe(deepToRaw, () => {
expect(isReactive(raw.foo)).toBe(false);
expect(isReactive(raw.foo.bar)).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 { type NodePanelType, type IRunDataDisplayMode } from '@/Interface';
import { N8nIcon, N8nRadioButtons } from '@n8n/design-system';
import { computed } from 'vue';
import { computed, watch } from 'vue';
const { compact, value, hasBinaryData, paneType, nodeGeneratesHtml } = defineProps<{
compact: boolean;
value: IRunDataDisplayMode;
hasBinaryData: boolean;
paneType: NodePanelType;
nodeGeneratesHtml: boolean;
}>();
const { compact, value, hasBinaryData, paneType, nodeGeneratesHtml, hasRenderableData } =
defineProps<{
compact: boolean;
value: IRunDataDisplayMode;
hasBinaryData: boolean;
paneType: NodePanelType;
nodeGeneratesHtml: boolean;
hasRenderableData: boolean;
}>();
const emit = defineEmits<{ change: [IRunDataDisplayMode] }>();
@@ -30,8 +32,23 @@ const options = computed(() => {
defaults.unshift({ label: 'HTML', value: 'html' });
}
if (hasRenderableData) {
defaults.unshift({ label: i18n.baseText('runData.rendered'), value: 'ai' });
}
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>
<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 === 'schema'" icon="schema" 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>
</template>
</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.items": "Items",
"runData.json": "JSON",
"runData.rendered": "Rendered",
"runData.schema": "Schema",
"runData.mimeType": "Mime Type",
"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;
}
let message = content.kwargs.content;
let message = String(content.kwargs.content);
if (Array.isArray(message)) {
message = (message as MessageContent[])
.map((item) => {
@@ -199,6 +199,7 @@ const outputTypeParsers: {
};
},
};
export type ParsedAiContent = Array<{
raw: IDataObject | IDataObject[];
parsedContent: {
@@ -208,40 +209,33 @@ export type ParsedAiContent = Array<{
} | null;
}>;
export const useAiContentParsers = () => {
const parseAiRunData = (
executionData: INodeExecutionData[],
endpointType: NodeConnectionType,
): ParsedAiContent => {
if (
([NodeConnectionTypes.AiChain, NodeConnectionTypes.Main] as NodeConnectionType[]).includes(
endpointType,
)
) {
return executionData.map((data) => ({ raw: data.json, parsedContent: null }));
}
export function parseAiContent(
executionData: INodeExecutionData[],
endpointType: NodeConnectionType,
) {
if (
([NodeConnectionTypes.AiChain, NodeConnectionTypes.Main] as NodeConnectionType[]).includes(
endpointType,
)
) {
return executionData.map((data) => ({ raw: data.json, parsedContent: null }));
}
const contentJson = executionData.map((node) => {
const hasBinaryData = !isObjectEmpty(node.binary);
return hasBinaryData ? node.binary : node.json;
});
const contentJson = executionData.map((node) => {
const hasBinaryData = !isObjectEmpty(node.binary);
return hasBinaryData ? node.binary : node.json;
});
const parser = outputTypeParsers[endpointType as AllowedEndpointType];
if (!parser)
return [
{
raw: contentJson.filter((item): item is IDataObject => item !== undefined),
parsedContent: null,
},
];
const parser = outputTypeParsers[endpointType as AllowedEndpointType];
if (!parser)
return [
{
raw: contentJson.filter((item): item is IDataObject => item !== undefined),
parsedContent: null,
},
];
const parsedOutput = contentJson
.filter((c): c is IDataObject => c !== undefined)
.map((c) => ({ raw: c, parsedContent: parser(c) }));
return parsedOutput;
};
return {
parseAiRunData,
};
};
return contentJson
.filter((c): c is IDataObject => c !== undefined)
.map((c) => ({ raw: c, parsedContent: parser(c) }));
}