feat: Add Ask AI to HTTP Request Node (#8917)

This commit is contained in:
Alex Grozav
2024-05-02 13:52:15 +03:00
committed by GitHub
parent 7ff24f134b
commit cd9bc44bdd
40 changed files with 3945 additions and 371 deletions

View File

@@ -6,6 +6,20 @@ export interface DebugErrorPayload {
error: Error;
}
export interface DebugErrorResponse {
message: string;
}
export interface GenerateCurlPayload {
service: string;
request: string;
}
export interface GenerateCurlResponse {
curl: string;
metadata: object;
}
export async function generateCodeForPrompt(
ctx: IRestApiContext,
{
@@ -36,7 +50,7 @@ export async function generateCodeForPrompt(
export const debugError = async (
context: IRestApiContext,
payload: DebugErrorPayload,
): Promise<{ message: string }> => {
): Promise<DebugErrorResponse> => {
return await makeRestApiRequest(
context,
'POST',
@@ -44,3 +58,15 @@ export const debugError = async (
payload as unknown as IDataObject,
);
};
export const generateCurl = async (
context: IRestApiContext,
payload: GenerateCurlPayload,
): Promise<GenerateCurlResponse> => {
return await makeRestApiRequest(
context,
'POST',
'/ai/generate-curl',
payload as unknown as IDataObject,
);
};

View File

@@ -0,0 +1,216 @@
<template>
<Modal
width="700px"
:title="i18n.baseText('generateCurlModal.title')"
:event-bus="modalBus"
:name="GENERATE_CURL_MODAL_KEY"
:center="true"
>
<template #content>
<div :class="$style.container">
<N8nFormInputs
:inputs="formInputs"
:event-bus="formBus"
column-view
@update="onUpdate"
@submit="onSubmit"
/>
</div>
</template>
<template #footer>
<div :class="$style.modalFooter">
<N8nNotice
:class="$style.notice"
:content="i18n.baseText('generateCurlModal.notice.content')"
/>
<div>
<N8nButton
float="right"
:loading="loading"
:label="i18n.baseText('generateCurlModal.button.label')"
@click="onGenerate"
/>
</div>
</div>
</template>
</Modal>
</template>
<script lang="ts" setup>
import Modal from '@/components/Modal.vue';
import { GENERATE_CURL_MODAL_KEY } from '@/constants';
import { ref } from 'vue';
import { createEventBus } from 'n8n-design-system/utils';
import { useTelemetry } from '@/composables/useTelemetry';
import { useI18n } from '@/composables/useI18n';
import { useAIStore } from '@/stores/ai.store';
import type { IFormInput } from 'n8n-design-system';
import { useToast } from '@/composables/useToast';
import { useImportCurlCommand } from '@/composables/useImportCurlCommand';
import { useUIStore } from '@/stores/ui.store';
import { useNDVStore } from '@/stores/ndv.store';
const telemetry = useTelemetry();
const i18n = useI18n();
const toast = useToast();
const uiStore = useUIStore();
const aiStore = useAIStore();
const ndvStore = useNDVStore();
const modalBus = createEventBus();
const formBus = createEventBus();
const initialServiceValue = uiStore.getModalData(GENERATE_CURL_MODAL_KEY)?.service as string;
const initialRequestValue = uiStore.getModalData(GENERATE_CURL_MODAL_KEY)?.request as string;
const formInputs: IFormInput[] = [
{
name: 'service',
initialValue: initialServiceValue,
properties: {
label: i18n.baseText('generateCurlModal.service.label'),
placeholder: i18n.baseText('generateCurlModal.service.placeholder'),
type: 'text',
required: true,
capitalize: true,
},
},
{
name: 'request',
initialValue: initialRequestValue,
properties: {
label: i18n.baseText('generateCurlModal.request.label'),
placeholder: i18n.baseText('generateCurlModal.request.placeholder'),
type: 'text',
required: true,
capitalize: true,
},
},
];
const formValues = ref<{ service: string; request: string }>({
service: initialServiceValue ?? '',
request: initialRequestValue ?? '',
});
const loading = ref(false);
const { importCurlCommand } = useImportCurlCommand({
onImportSuccess,
onImportFailure,
onAfterImport,
i18n: {
invalidCurCommand: {
title: 'generateCurlModal.invalidCurlCommand.title',
message: 'generateCurlModal.invalidCurlCommand.message',
},
},
});
function closeDialog(): void {
modalBus.emit('close');
}
function onImportSuccess() {
sendImportCurlTelemetry();
toast.showMessage({
title: i18n.baseText('generateCurlModal.success.title'),
message: i18n.baseText('generateCurlModal.success.message'),
type: 'success',
});
closeDialog();
}
function onImportFailure(data: { invalidProtocol: boolean; protocol?: string }) {
sendImportCurlTelemetry({ valid: false, ...data });
}
function onAfterImport() {
uiStore.setModalData({
name: GENERATE_CURL_MODAL_KEY,
data: {
service: formValues.value.service,
request: formValues.value.request,
},
});
}
function sendImportCurlTelemetry(
data: { valid: boolean; invalidProtocol: boolean; protocol?: string } = {
valid: true,
invalidProtocol: false,
protocol: '',
},
): void {
const service = formValues.value.service;
const request = formValues.value.request;
telemetry.track(
'User generated curl command using AI',
{
request,
request_service_name: service,
valid_curl_response: data.valid,
api_docs_returned: false,
invalidProtocol: data.invalidProtocol,
protocol: data.protocol,
node_type: ndvStore.activeNode?.type,
node_name: ndvStore.activeNode?.name,
},
{ withPostHog: true },
);
}
async function onUpdate(field: { name: string; value: string }) {
formValues.value = {
...formValues.value,
[field.name]: field.value,
};
}
async function onGenerate() {
formBus.emit('submit');
}
async function onSubmit() {
const service = formValues.value.service;
const request = formValues.value.request;
try {
loading.value = true;
const data = await aiStore.generateCurl({
service,
request,
});
await importCurlCommand(data.curl);
} catch (error) {
toast.showError(error, i18n.baseText('error'));
} finally {
loading.value = false;
}
}
</script>
<style module lang="scss">
.modalFooter {
justify-content: space-between;
display: flex;
flex-direction: row;
}
.notice {
margin: 0;
}
.container > * {
margin-bottom: var(--spacing-s);
&:last-child {
margin-bottom: 0;
}
}
</style>

View File

@@ -1,37 +1,37 @@
<template>
<Modal
width="700px"
:title="$locale.baseText('importCurlModal.title')"
:title="i18n.baseText('importCurlModal.title')"
:event-bus="modalBus"
:name="IMPORT_CURL_MODAL_KEY"
:center="true"
>
<template #content>
<div :class="$style.container">
<n8n-input-label :label="$locale.baseText('importCurlModal.input.label')" color="text-dark">
<n8n-input
ref="input"
<N8nInputLabel :label="i18n.baseText('importCurlModal.input.label')" color="text-dark">
<N8nInput
ref="inputRef"
:model-value="curlCommand"
type="textarea"
:rows="5"
:placeholder="$locale.baseText('importCurlModal.input.placeholder')"
:placeholder="i18n.baseText('importCurlModal.input.placeholder')"
@update:model-value="onInput"
@focus="$event.target.select()"
/>
</n8n-input-label>
</N8nInputLabel>
</div>
</template>
<template #footer>
<div :class="$style.modalFooter">
<n8n-notice
<N8nNotice
:class="$style.notice"
:content="$locale.baseText('ImportCurlModal.notice.content')"
:content="i18n.baseText('ImportCurlModal.notice.content')"
/>
<div>
<n8n-button
<N8nButton
float="right"
:label="$locale.baseText('importCurlModal.button.label')"
@click="importCurlCommand"
:label="i18n.baseText('importCurlModal.button.label')"
@click="onImport"
/>
</div>
</div>
@@ -39,150 +39,81 @@
</Modal>
</template>
<script lang="ts">
<script lang="ts" setup>
import Modal from '@/components/Modal.vue';
import {
IMPORT_CURL_MODAL_KEY,
CURL_IMPORT_NOT_SUPPORTED_PROTOCOLS,
CURL_IMPORT_NODES_PROTOCOLS,
} from '@/constants';
import { useToast } from '@/composables/useToast';
import { defineComponent } from 'vue';
import type { INodeUi } from '@/Interface';
import { mapStores } from 'pinia';
import { IMPORT_CURL_MODAL_KEY } from '@/constants';
import { onMounted, ref } from 'vue';
import { useUIStore } from '@/stores/ui.store';
import { useNDVStore } from '@/stores/ndv.store';
import { createEventBus } from 'n8n-design-system/utils';
import { useTelemetry } from '@/composables/useTelemetry';
import { useI18n } from '@/composables/useI18n';
import { useImportCurlCommand } from '@/composables/useImportCurlCommand';
export default defineComponent({
name: 'ImportCurlModal',
components: {
Modal,
},
setup() {
return {
...useToast(),
};
},
data() {
return {
IMPORT_CURL_MODAL_KEY,
curlCommand: '',
modalBus: createEventBus(),
};
},
computed: {
...mapStores(useNDVStore, useUIStore),
node(): INodeUi | null {
return this.ndvStore.activeNode;
},
},
mounted() {
this.curlCommand = this.uiStore.getCurlCommand || '';
setTimeout(() => {
(this.$refs.input as HTMLTextAreaElement).focus();
});
},
methods: {
closeDialog(): void {
this.modalBus.emit('close');
},
onInput(value: string): void {
this.curlCommand = value;
},
async importCurlCommand(): Promise<void> {
const curlCommand = this.curlCommand;
if (curlCommand === '') return;
const telemetry = useTelemetry();
const i18n = useI18n();
try {
const parameters = await this.uiStore.getCurlToJson(curlCommand);
const url = parameters['parameters.url'];
const uiStore = useUIStore();
const invalidProtocol = CURL_IMPORT_NOT_SUPPORTED_PROTOCOLS.find((p) =>
url.includes(`${p}://`),
);
const curlCommand = ref('');
const modalBus = createEventBus();
if (!invalidProtocol) {
this.uiStore.setHttpNodeParameters({
name: IMPORT_CURL_MODAL_KEY,
parameters: JSON.stringify(parameters),
});
const inputRef = ref<HTMLTextAreaElement | null>(null);
this.closeDialog();
this.sendTelemetry();
return;
// if we have a node that supports the invalid protocol
// suggest that one
} else if (CURL_IMPORT_NODES_PROTOCOLS[invalidProtocol]) {
const useNode = CURL_IMPORT_NODES_PROTOCOLS[invalidProtocol];
this.showProtocolErrorWithSupportedNode(invalidProtocol, useNode);
// we do not have a node that supports the use protocol
} else {
this.showProtocolError(invalidProtocol);
}
this.sendTelemetry({ success: false, invalidProtocol: true, protocol: invalidProtocol });
} catch (e) {
this.showInvalidcURLCommandError();
this.sendTelemetry({ success: false, invalidProtocol: false });
} finally {
this.uiStore.setCurlCommand({ name: IMPORT_CURL_MODAL_KEY, command: this.curlCommand });
}
},
showProtocolErrorWithSupportedNode(protocol: string, node: string): void {
this.showToast({
title: this.$locale.baseText('importParameter.showError.invalidProtocol1.title', {
interpolate: {
node,
},
}),
message: this.$locale.baseText('importParameter.showError.invalidProtocol.message', {
interpolate: {
protocol: protocol.toUpperCase(),
},
}),
type: 'error',
duration: 0,
});
},
showProtocolError(protocol: string): void {
this.showToast({
title: this.$locale.baseText('importParameter.showError.invalidProtocol2.title'),
message: this.$locale.baseText('importParameter.showError.invalidProtocol.message', {
interpolate: {
protocol,
},
}),
type: 'error',
duration: 0,
});
},
showInvalidcURLCommandError(): void {
this.showToast({
title: this.$locale.baseText('importParameter.showError.invalidCurlCommand.title'),
message: this.$locale.baseText('importParameter.showError.invalidCurlCommand.message'),
type: 'error',
duration: 0,
});
},
sendTelemetry(
data: { success: boolean; invalidProtocol: boolean; protocol?: string } = {
success: true,
invalidProtocol: false,
protocol: '',
},
): void {
this.$telemetry.track('User imported curl command', {
success: data.success,
invalidProtocol: data.invalidProtocol,
protocol: data.protocol,
});
},
},
const { importCurlCommand } = useImportCurlCommand({
onImportSuccess,
onImportFailure,
onAfterImport,
});
onMounted(() => {
curlCommand.value = (uiStore.getModalData(IMPORT_CURL_MODAL_KEY)?.curlCommand as string) ?? '';
setTimeout(() => {
inputRef.value?.focus();
});
});
function onInput(value: string): void {
curlCommand.value = value;
}
function closeDialog(): void {
modalBus.emit('close');
}
function onImportSuccess() {
sendTelemetry();
closeDialog();
}
function onImportFailure(data: { invalidProtocol: boolean; protocol?: string }) {
sendTelemetry({ success: false, ...data });
}
function onAfterImport() {
uiStore.setModalData({
name: IMPORT_CURL_MODAL_KEY,
data: { curlCommand: curlCommand.value },
});
}
function sendTelemetry(
data: { success: boolean; invalidProtocol: boolean; protocol?: string } = {
success: true,
invalidProtocol: false,
protocol: '',
},
): void {
telemetry.track('User imported curl command', {
success: data.success,
invalidProtocol: data.invalidProtocol,
protocol: data.protocol,
});
}
async function onImport() {
await importCurlCommand(curlCommand);
}
</script>
<style module lang="scss">

View File

@@ -0,0 +1,53 @@
<script lang="ts" setup>
import { GENERATE_CURL_MODAL_KEY, IMPORT_CURL_MODAL_KEY } from '@/constants';
import { useUIStore } from '@/stores/ui.store';
import { useAIStore } from '@/stores/ai.store';
defineProps({
isReadOnly: {
type: Boolean,
default: false,
},
});
const uiStore = useUIStore();
const aiStore = useAIStore();
function onImportCurlClicked() {
uiStore.openModal(IMPORT_CURL_MODAL_KEY);
}
function onGenerateCurlClicked() {
uiStore.openModal(GENERATE_CURL_MODAL_KEY);
}
</script>
<template>
<div :class="$style.importSection">
<n8n-button
type="secondary"
:label="$locale.baseText('importCurlParameter.label')"
:disabled="isReadOnly"
size="mini"
@click="onImportCurlClicked"
/>
<n8n-button
v-if="aiStore.isGenerateCurlEnabled"
class="mr-2xs"
type="secondary"
:label="$locale.baseText('generateCurlParameter.label')"
:disabled="isReadOnly"
size="mini"
@click="onGenerateCurlClicked"
/>
</div>
</template>
<style module lang="scss">
.importSection {
display: flex;
flex-direction: row-reverse;
margin-top: var(--spacing-xs);
}
</style>

View File

@@ -1,44 +0,0 @@
<template>
<div :class="$style.importSection">
<n8n-button
type="secondary"
:label="$locale.baseText('importParameter.label')"
:disabled="isReadOnly"
size="mini"
@click="onImportCurlClicked"
/>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { IMPORT_CURL_MODAL_KEY } from '@/constants';
import { useUIStore } from '@/stores/ui.store';
import { mapStores } from 'pinia';
export default defineComponent({
name: 'ImportParameter',
props: {
isReadOnly: {
type: Boolean,
default: false,
},
},
computed: {
...mapStores(useUIStore),
},
methods: {
onImportCurlClicked() {
this.uiStore.openModal(IMPORT_CURL_MODAL_KEY);
},
},
});
</script>
<style module lang="scss">
.importSection {
display: flex;
flex-direction: row-reverse;
margin-top: 10px;
}
</style>

View File

@@ -101,11 +101,31 @@ export default defineComponent({
return extensions;
},
},
watch: {
modelValue(newValue: string) {
const editorValue = this.editor?.state?.doc.toString();
// If model value changes from outside the component
if (editorValue && editorValue.length !== newValue.length && editorValue !== newValue) {
this.destroyEditor();
this.createEditor();
}
},
},
mounted() {
const state = EditorState.create({ doc: this.modelValue, extensions: this.extensions });
const parent = this.$refs.jsonEditor as HTMLDivElement;
this.editor = new EditorView({ parent, state });
this.editorState = this.editor.state;
this.createEditor();
},
methods: {
createEditor() {
const state = EditorState.create({ doc: this.modelValue, extensions: this.extensions });
const parent = this.$refs.jsonEditor as HTMLDivElement;
this.editor = new EditorView({ parent, state });
this.editorState = this.editor.state;
},
destroyEditor() {
this.editor?.destroy();
},
},
});
</script>

View File

@@ -95,6 +95,10 @@
<ImportCurlModal />
</ModalRoot>
<ModalRoot :name="GENERATE_CURL_MODAL_KEY">
<GenerateCurlModal />
</ModalRoot>
<ModalRoot :name="COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY">
<template #default="{ modalName, activeId, mode }">
<CommunityPackageManageConfirmModal
@@ -208,6 +212,7 @@ import {
WORKFLOW_HISTORY_VERSION_RESTORE,
SUGGESTED_TEMPLATES_PREVIEW_MODAL_KEY,
SETUP_CREDENTIALS_MODAL_KEY,
GENERATE_CURL_MODAL_KEY,
} from '@/constants';
import AboutModal from './AboutModal.vue';
@@ -231,6 +236,7 @@ import WorkflowSettings from './WorkflowSettings.vue';
import DeleteUserModal from './DeleteUserModal.vue';
import ActivationModal from './ActivationModal.vue';
import ImportCurlModal from './ImportCurlModal.vue';
import GenerateCurlModal from './GenerateCurlModal.vue';
import MfaSetupModal from './MfaSetupModal.vue';
import WorkflowShareModal from './WorkflowShareModal.ee.vue';
import EventDestinationSettingsModal from '@/components/SettingsLogStreaming/EventDestinationSettingsModal.ee.vue';
@@ -267,6 +273,7 @@ export default defineComponent({
WorkflowSettings,
WorkflowShareModal,
ImportCurlModal,
GenerateCurlModal,
EventDestinationSettingsModal,
SourceControlPushModal,
SourceControlPullModal,
@@ -299,6 +306,7 @@ export default defineComponent({
VALUE_SURVEY_MODAL_KEY,
WORKFLOW_ACTIVE_MODAL_KEY,
IMPORT_CURL_MODAL_KEY,
GENERATE_CURL_MODAL_KEY,
LOG_STREAM_MODAL_KEY,
SOURCE_CONTROL_PUSH_MODAL_KEY,
SOURCE_CONTROL_PULL_MODAL_KEY,

View File

@@ -207,7 +207,6 @@ import {
COMMUNITY_NODES_INSTALLATION_DOCS_URL,
CUSTOM_NODES_DOCS_URL,
MAIN_NODE_PANEL_WIDTH,
IMPORT_CURL_MODAL_KEY,
SHOULD_CLEAR_NODE_OUTPUTS,
} from '@/constants';
@@ -232,6 +231,7 @@ import { useCredentialsStore } from '@/stores/credentials.store';
import type { EventBus } from 'n8n-design-system';
import { useExternalHooks } from '@/composables/useExternalHooks';
import { useNodeHelpers } from '@/composables/useNodeHelpers';
import { importCurlEventBus } from '@/event-bus';
import { useToast } from '@/composables/useToast';
export default defineComponent({
@@ -266,9 +266,6 @@ export default defineComponent({
useWorkflowsStore,
useWorkflowsEEStore,
),
isCurlImportModalOpen(): boolean {
return this.uiStore.isModalOpen(IMPORT_CURL_MODAL_KEY);
},
isReadOnly(): boolean {
return this.readOnly || this.hasForeignCredential;
},
@@ -454,28 +451,6 @@ export default defineComponent({
node(newNode, oldNode) {
this.setNodeValues();
},
isCurlImportModalOpen(newValue, oldValue) {
if (newValue === false) {
let parameters = this.uiStore.getHttpNodeParameters || '';
if (!parameters) return;
try {
parameters = JSON.parse(parameters) as {
[key: string]: unknown;
};
//@ts-ignore
this.valueChanged({
node: this.node.name,
name: 'parameters',
value: parameters,
});
this.uiStore.setHttpNodeParameters({ name: IMPORT_CURL_MODAL_KEY, parameters: '' });
} catch {}
}
},
},
mounted() {
this.populateHiddenIssuesSet();
@@ -484,11 +459,22 @@ export default defineComponent({
this.eventBus?.on('openSettings', this.openSettings);
this.nodeHelpers.updateNodeParameterIssues(this.node as INodeUi, this.nodeType);
importCurlEventBus.on('setHttpNodeParameters', this.setHttpNodeParameters);
},
beforeUnmount() {
this.eventBus?.off('openSettings', this.openSettings);
importCurlEventBus.off('setHttpNodeParameters', this.setHttpNodeParameters);
},
methods: {
setHttpNodeParameters(parameters: Record<string, unknown>) {
try {
this.valueChanged({
node: this.node.name,
name: 'parameters',
value: parameters,
});
} catch {}
},
onSwitchSelectedNode(node: string) {
this.$emit('switchSelectedNode', node);
},

View File

@@ -22,7 +22,7 @@
/>
</div>
<ImportParameter
<ImportCurlParameter
v-else-if="parameter.type === 'curlImport'"
:is-read-only="isReadOnly"
@value-changed="valueChanged"
@@ -175,7 +175,7 @@ import { defineAsyncComponent, defineComponent, onErrorCaptured, ref } from 'vue
import type { INodeUi, IUpdateInformation } from '@/Interface';
import ImportParameter from '@/components/ImportParameter.vue';
import ImportCurlParameter from '@/components/ImportCurlParameter.vue';
import MultipleParameter from '@/components/MultipleParameter.vue';
import ParameterInputFull from '@/components/ParameterInputFull.vue';
import ResourceMapper from '@/components/ResourceMapper/ResourceMapper.vue';
@@ -208,7 +208,7 @@ export default defineComponent({
ParameterInputFull,
FixedCollectionParameter,
CollectionParameter,
ImportParameter,
ImportCurlParameter,
ResourceMapper,
FilterConditions,
AssignmentCollection,

View File

@@ -0,0 +1,119 @@
import type { MaybeRef } from 'vue';
import { unref } from 'vue';
import { CURL_IMPORT_NODES_PROTOCOLS, CURL_IMPORT_NOT_SUPPORTED_PROTOCOLS } from '@/constants';
import { useToast } from '@/composables/useToast';
import { useUIStore } from '@/stores/ui.store';
import { useI18n } from '@/composables/useI18n';
import { importCurlEventBus } from '@/event-bus';
import type { BaseTextKey } from '@/plugins/i18n';
export function useImportCurlCommand(options?: {
onImportSuccess?: () => void;
onImportFailure?: (data: { invalidProtocol: boolean; protocol?: string }) => void;
onAfterImport?: () => void;
i18n?: {
invalidCurCommand: {
title: string;
message: string;
};
};
}) {
const uiStore = useUIStore();
const toast = useToast();
const i18n = useI18n();
const translationStrings = {
invalidCurCommand: {
title: 'importCurlParameter.showError.invalidCurlCommand.title',
message: 'importCurlParameter.showError.invalidCurlCommand.message',
},
...options?.i18n,
};
async function importCurlCommand(curlCommandRef: MaybeRef<string>): Promise<void> {
const curlCommand = unref(curlCommandRef);
if (curlCommand === '') return;
try {
const parameters = await uiStore.getCurlToJson(curlCommand);
const url = parameters['parameters.url'];
const invalidProtocol = CURL_IMPORT_NOT_SUPPORTED_PROTOCOLS.find((p) =>
url.includes(`${p}://`),
);
if (!invalidProtocol) {
importCurlEventBus.emit('setHttpNodeParameters', parameters);
options?.onImportSuccess?.();
return;
// if we have a node that supports the invalid protocol
// suggest that one
} else if (CURL_IMPORT_NODES_PROTOCOLS[invalidProtocol]) {
const useNode = CURL_IMPORT_NODES_PROTOCOLS[invalidProtocol];
showProtocolErrorWithSupportedNode(invalidProtocol, useNode);
// we do not have a node that supports the use protocol
} else {
showProtocolError(invalidProtocol);
}
options?.onImportFailure?.({
invalidProtocol: true,
protocol: invalidProtocol,
});
} catch (e) {
showInvalidcURLCommandError();
options?.onImportFailure?.({
invalidProtocol: false,
});
} finally {
options?.onAfterImport?.();
}
}
function showProtocolErrorWithSupportedNode(protocol: string, node: string): void {
toast.showToast({
title: i18n.baseText('importCurlParameter.showError.invalidProtocol1.title', {
interpolate: {
node,
},
}),
message: i18n.baseText('importCurlParameter.showError.invalidProtocol.message', {
interpolate: {
protocol: protocol.toUpperCase(),
},
}),
type: 'error',
duration: 0,
});
}
function showProtocolError(protocol: string): void {
toast.showToast({
title: i18n.baseText('importCurlParameter.showError.invalidProtocol2.title'),
message: i18n.baseText('importCurlParameter.showError.invalidProtocol.message', {
interpolate: {
protocol,
},
}),
type: 'error',
duration: 0,
});
}
function showInvalidcURLCommandError(): void {
toast.showToast({
title: i18n.baseText(translationStrings.invalidCurCommand.title as BaseTextKey),
message: i18n.baseText(translationStrings.invalidCurCommand.message as BaseTextKey),
type: 'error',
duration: 0,
});
}
return {
importCurlCommand,
};
}

View File

@@ -50,6 +50,7 @@ export const ONBOARDING_CALL_SIGNUP_MODAL_KEY = 'onboardingCallSignup';
export const COMMUNITY_PACKAGE_INSTALL_MODAL_KEY = 'communityPackageInstall';
export const COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY = 'communityPackageManageConfirm';
export const IMPORT_CURL_MODAL_KEY = 'importCurl';
export const GENERATE_CURL_MODAL_KEY = 'generateCurl';
export const LOG_STREAM_MODAL_KEY = 'settingsLogStream';
export const SOURCE_CONTROL_PUSH_MODAL_KEY = 'sourceControlPush';
export const SOURCE_CONTROL_PULL_MODAL_KEY = 'sourceControlPull';

View File

@@ -0,0 +1,3 @@
import { createEventBus } from 'n8n-design-system/utils';
export const importCurlEventBus = createEventBus();

View File

@@ -2,6 +2,7 @@ export * from './code-node-editor';
export * from './data-pinning';
export * from './link-actions';
export * from './html-editor';
export * from './import-curl';
export * from './node-view';
export * from './mfa';
export * from './ndv';

View File

@@ -2128,14 +2128,26 @@
"importCurlModal.title": "Import cURL command",
"importCurlModal.input.label": "cURL Command",
"importCurlModal.input.placeholder": "Paste the cURL command here",
"ImportCurlModal.notice.content": "This will overwrite any changes you have already made",
"ImportCurlModal.notice.content": "This will overwrite any changes you have already made to the current node",
"importCurlModal.button.label": "Import",
"importParameter.label": "Import cURL",
"importParameter.showError.invalidCurlCommand.title": "Couldnt import cURL command",
"importParameter.showError.invalidCurlCommand.message": "This command is in an unsupported format",
"importParameter.showError.invalidProtocol1.title": "Use the {node} node",
"importParameter.showError.invalidProtocol2.title": "Invalid Protocol",
"importParameter.showError.invalidProtocol.message": "The HTTP node doesnt support {protocol} requests",
"importCurlParameter.label": "Import cURL",
"importCurlParameter.showError.invalidCurlCommand.title": "Couldnt import cURL command",
"importCurlParameter.showError.invalidCurlCommand.message": "This command is in an unsupported format",
"importCurlParameter.showError.invalidProtocol1.title": "Use the {node} node",
"importCurlParameter.showError.invalidProtocol2.title": "Invalid Protocol",
"importCurlParameter.showError.invalidProtocol.message": "The HTTP node doesnt support {protocol} requests",
"generateCurlParameter.label": "Ask AI ✨",
"generateCurlModal.title": "Generate HTTP Request",
"generateCurlModal.notice.content": "This will overwrite any changes you have already made to the current node",
"generateCurlModal.button.label": "Generate",
"generateCurlModal.service.label": "Service",
"generateCurlModal.service.placeholder": "Enter the name of the service",
"generateCurlModal.request.label": "Request",
"generateCurlModal.request.placeholder": "Describe the request you want to make",
"generateCurlModal.invalidCurlCommand.title": "Generation failed",
"generateCurlModal.invalidCurlCommand.message": "The AI couldn't process your request",
"generateCurlModal.success.title": "HTTP Request filled out",
"generateCurlModal.success.message": "Please check carefully as AI content can be inaccurate",
"variables.heading": "Variables",
"variables.add": "Add variable",
"variables.add.unavailable": "Upgrade plan to keep using variables",

View File

@@ -4,6 +4,7 @@ import * as aiApi from '@/api/ai';
vi.mock('@/api/ai', () => ({
debugError: vi.fn(),
generateCurl: vi.fn(),
}));
vi.mock('@/stores/n8nRoot.store', () => ({
@@ -18,7 +19,10 @@ vi.mock('@/stores/settings.store', () => ({
useSettingsStore: () => ({
settings: {
ai: {
errorDebugging: false, // Default mock value
features: {
errorDebugging: false,
generateCurl: false,
},
},
},
}),
@@ -52,4 +56,22 @@ describe('useAIStore', () => {
expect(result).toEqual(mockResult);
});
});
describe('debugError()', () => {
it('calls aiApi.debugError with correct parameters and returns expected result', async () => {
const mockResult = { curl: 'curl -X GET https://n8n.io', metadata: {} };
const aiStore = useAIStore();
const payload = {
service: 'OpenAI',
request: 'Create user message saying "Hello World"',
};
vi.mocked(aiApi.generateCurl).mockResolvedValue(mockResult);
const result = await aiStore.generateCurl(payload);
expect(aiApi.generateCurl).toHaveBeenCalledWith({}, payload);
expect(result).toEqual(mockResult);
});
});
});

View File

@@ -1,6 +1,6 @@
import { defineStore } from 'pinia';
import * as aiApi from '@/api/ai';
import type { DebugErrorPayload } from '@/api/ai';
import type { DebugErrorPayload, GenerateCurlPayload } from '@/api/ai';
import { useRootStore } from '@/stores/n8nRoot.store';
import { useSettingsStore } from '@/stores/settings.store';
import { computed } from 'vue';
@@ -9,11 +9,16 @@ export const useAIStore = defineStore('ai', () => {
const rootStore = useRootStore();
const settingsStore = useSettingsStore();
const isErrorDebuggingEnabled = computed(() => settingsStore.settings.ai.errorDebugging);
const isErrorDebuggingEnabled = computed(() => settingsStore.settings.ai.features.errorDebugging);
const isGenerateCurlEnabled = computed(() => settingsStore.settings.ai.features.generateCurl);
async function debugError(payload: DebugErrorPayload) {
return await aiApi.debugError(rootStore.getRestApiContext, payload);
}
return { isErrorDebuggingEnabled, debugError };
async function generateCurl(payload: GenerateCurlPayload) {
return await aiApi.generateCurl(rootStore.getRestApiContext, payload);
}
return { isErrorDebuggingEnabled, isGenerateCurlEnabled, debugError, generateCurl };
});

View File

@@ -39,6 +39,7 @@ import {
WORKFLOW_HISTORY_VERSION_RESTORE,
SUGGESTED_TEMPLATES_PREVIEW_MODAL_KEY,
SETUP_CREDENTIALS_MODAL_KEY,
GENERATE_CURL_MODAL_KEY,
} from '@/constants';
import type {
CloudUpdateLinkSourceType,
@@ -133,8 +134,16 @@ export const useUIStore = defineStore(STORES.UI, {
},
[IMPORT_CURL_MODAL_KEY]: {
open: false,
curlCommand: '',
httpNodeParameters: '',
data: {
curlCommand: '',
},
},
[GENERATE_CURL_MODAL_KEY]: {
open: false,
data: {
service: '',
request: '',
},
},
[LOG_STREAM_MODAL_KEY]: {
open: false,
@@ -265,12 +274,6 @@ export const useUIStore = defineStore(STORES.UI, {
}
return null;
},
getCurlCommand(): string | undefined {
return this.modals[IMPORT_CURL_MODAL_KEY].curlCommand;
},
getHttpNodeParameters(): string | undefined {
return this.modals[IMPORT_CURL_MODAL_KEY].httpNodeParameters;
},
areExpressionsDisabled(): boolean {
return this.currentView === VIEWS.DEMO;
},
@@ -542,18 +545,21 @@ export const useUIStore = defineStore(STORES.UI, {
curlCommand: payload.command,
};
},
setHttpNodeParameters(payload: { name: string; parameters: string }): void {
this.modals[payload.name] = {
...this.modals[payload.name],
httpNodeParameters: payload.parameters,
};
},
toggleSidebarMenuCollapse(): void {
this.sidebarMenuCollapsed = !this.sidebarMenuCollapsed;
},
async getCurlToJson(curlCommand: string): Promise<CurlToJSONResponse> {
const rootStore = useRootStore();
return await getCurlToJson(rootStore.getRestApiContext, curlCommand);
const parameters = await getCurlToJson(rootStore.getRestApiContext, curlCommand);
// Normalize placeholder values
if (parameters['parameters.url']) {
parameters['parameters.url'] = parameters['parameters.url']
.replaceAll('%7B', '{')
.replaceAll('%7D', '}');
}
return parameters;
},
async goToUpgrade(
source: CloudUpdateLinkSourceType,