mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 01:56:46 +00:00
feat(editor): Add "Rendered" display mode to the logs view (#14994)
This commit is contained in:
@@ -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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
});
|
||||||
|
|||||||
@@ -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="
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,14 +2,16 @@
|
|||||||
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 } =
|
||||||
|
defineProps<{
|
||||||
compact: boolean;
|
compact: boolean;
|
||||||
value: IRunDataDisplayMode;
|
value: IRunDataDisplayMode;
|
||||||
hasBinaryData: boolean;
|
hasBinaryData: boolean;
|
||||||
paneType: NodePanelType;
|
paneType: NodePanelType;
|
||||||
nodeGeneratesHtml: boolean;
|
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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
@@ -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",
|
||||||
|
|||||||
81
packages/frontend/editor-ui/src/utils/aiUtils.test.ts
Normal file
81
packages/frontend/editor-ui/src/utils/aiUtils.test.ts
Normal 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),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,11 +209,10 @@ 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,
|
||||||
@@ -235,13 +235,7 @@ export const useAiContentParsers = () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user