mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +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() {
|
||||
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(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');
|
||||
|
||||
@@ -107,3 +107,8 @@ Object.defineProperty(window, 'DataTransfer', {
|
||||
writable: true,
|
||||
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 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="
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -98,6 +98,7 @@ const render = (props: Partial<Props> = {}, pinData?: INodeExecutionData[], runD
|
||||
runIndex: 0,
|
||||
currentNodeName: nodes[1].name,
|
||||
workflow: workflowObject,
|
||||
displayMode: 'schema',
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.items": "Items",
|
||||
"runData.json": "JSON",
|
||||
"runData.rendered": "Rendered",
|
||||
"runData.schema": "Schema",
|
||||
"runData.mimeType": "Mime Type",
|
||||
"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;
|
||||
}
|
||||
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) }));
|
||||
}
|
||||
Reference in New Issue
Block a user