diff --git a/README.md b/README.md index ec3d04ea0..824a2a74c 100644 --- a/README.md +++ b/README.md @@ -234,6 +234,7 @@ To get the authentication setup, `backendUrl`is mandatory. **_NOTE_**: If the previous configuration is not present in the configuration file, Leto-Modelizer will be launched with the backend mode deactivated. **_NOTE_**: For now, there is no UI associated to the backend, but the UI for the admin is coming soon ! +**_NOTE_**: The AI tools are only available with the backend mode and it needs to be authenticated with Leto-Modelizer-Api. ## How to build this app diff --git a/changelog.md b/changelog.md index e1bdb09ff..b74f5a47e 100644 --- a/changelog.md +++ b/changelog.md @@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Improve dockerfile with version of plugins as argument. * Export diagram as svg. * Error management on monaco editor and error footer. +* Generate diagrams from AI proxy. ### Changed diff --git a/src/boot/axios.js b/src/boot/axios.js index e9205f6ad..09dbdea8d 100644 --- a/src/boot/axios.js +++ b/src/boot/axios.js @@ -1,4 +1,5 @@ import axios from 'axios'; +import { useCsrfStore } from 'src/stores/CsrfTokenStore'; const templateLibraryApiClient = axios.create({ baseURL: '/template-library/', @@ -15,6 +16,22 @@ templateLibraryApiClient.interceptors.response.use( (error) => Promise.reject(error), ); +api.interceptors.request.use( + async (config) => { + if (['post', 'put', 'delete'].includes(config.method)) { + const { + token, + headerName, + } = useCsrfStore(); + + config.headers[headerName] = token; + } + + return config; + }, + (error) => Promise.reject(error), +); + api.interceptors.response.use( ({ data }) => Promise.resolve(data), (error) => { @@ -25,4 +42,27 @@ api.interceptors.response.use( }, ); -export { api, templateLibraryApiClient }; +/** + * Asynchronously prepares a request by ensuring the availability of a valid CSRF token. + * + * This function uses a CSRF token to check if token is valid. + * If not, it fetches a new CSRF token from the server using the provided API. + * The retrieved CSRF token is then stored in the CSRF token store for future use. + * @returns {Promise} The API instance with an updated CSRF token. + */ +async function prepareApiRequest() { + const csrfStore = useCsrfStore(); + const currentTime = new Date().getTime(); + + if (!csrfStore.expirationDate || csrfStore.expirationDate < currentTime) { + const csrf = await api.get('/csrf'); + + csrfStore.headerName = csrf.headerName; + csrfStore.token = csrf.token; + csrfStore.expirationDate = csrf.expirationDate; + } + + return api; +} + +export { api, prepareApiRequest, templateLibraryApiClient }; diff --git a/src/components/card/DiagramsCard.vue b/src/components/card/DiagramsCard.vue index 4d421aefe..c5321354f 100644 --- a/src/components/card/DiagramsCard.vue +++ b/src/components/card/DiagramsCard.vue @@ -51,6 +51,21 @@ > {{ $t('actions.models.create.button.template.name') }} + @@ -141,6 +156,7 @@ const selectedTags = ref([]); const categoryTags = ref(getAllTagsByType('category')); const isDiagramGrid = ref(getUserSetting('displayType') === 'grid'); const viewType = computed(() => route.params.viewType); +const HAS_BACKEND = computed(() => process.env.HAS_BACKEND); let updateModelSubscription; diff --git a/src/components/dialog/CreateAIModelDialog.vue b/src/components/dialog/CreateAIModelDialog.vue new file mode 100644 index 000000000..da3a4059b --- /dev/null +++ b/src/components/dialog/CreateAIModelDialog.vue @@ -0,0 +1,31 @@ + + + diff --git a/src/components/form/CreateAIModelForm.vue b/src/components/form/CreateAIModelForm.vue new file mode 100644 index 000000000..7ffd9dd5f --- /dev/null +++ b/src/components/form/CreateAIModelForm.vue @@ -0,0 +1,233 @@ + + + + + diff --git a/src/i18n/en-US/index.js b/src/i18n/en-US/index.js index cf627668f..5f4e06baf 100644 --- a/src/i18n/en-US/index.js +++ b/src/i18n/en-US/index.js @@ -50,6 +50,12 @@ export default { label: 'Create a diagram from a template', title: 'Open a popup to create a diagram from a template', }, + ai: { + name: 'From AI', + label: 'Create a diagram from AI', + title: 'Open a popup to create a diagram from AI', + error: 'Error during diagram creation: retry or change input', + }, }, dialog: { name: 'Create new model', @@ -58,6 +64,7 @@ export default { name: 'Model path', plugin: 'Model plugin', location: 'Model location', + description: 'Describe your model for IA here', }, notify: { success: 'Model has been created 🥳!', diff --git a/src/pages/ModelsPage.vue b/src/pages/ModelsPage.vue index 6c7aa3154..0842dacd8 100644 --- a/src/pages/ModelsPage.vue +++ b/src/pages/ModelsPage.vue @@ -5,6 +5,7 @@ /> + @@ -16,6 +17,7 @@ import { computed } from 'vue'; import { useRoute } from 'vue-router'; import ModelsView from 'src/components/ModelsView.vue'; import CreateModelDialog from 'components/dialog/CreateModelDialog.vue'; +import CreateAIModelDialog from 'components/dialog/CreateAIModelDialog.vue'; import DeleteModelDialog from 'components/dialog/DeleteModelDialog.vue'; import RenameModelDialog from 'components/dialog/RenameModelDialog.vue'; import ImportModelTemplateDialog from 'components/dialog/ImportModelTemplateDialog.vue'; diff --git a/src/services/AIService.js b/src/services/AIService.js new file mode 100644 index 000000000..1cccefa72 --- /dev/null +++ b/src/services/AIService.js @@ -0,0 +1,20 @@ +import { prepareApiRequest } from 'src/boot/axios'; + +/** + * Generate diagrams using Leto AI proxy. + * It posts a request to the proxy and returns a json response composed of an + * array with each item being a diagram (a name and a content). + * @param {string} plugin - name of the plugin + * @param {string} description - description of the diagram + * @returns {Promise} Promise with the diagram information on success + * otherwise an error. + */ +export async function generateDiagram(plugin, description) { + const api = await prepareApiRequest(); + + return api.post('/ai/generate', { + plugin, + description, + type: 'diagram', + }, { timeout: 300000 }); +} diff --git a/src/stores/CsrfTokenStore.js b/src/stores/CsrfTokenStore.js new file mode 100644 index 000000000..066912938 --- /dev/null +++ b/src/stores/CsrfTokenStore.js @@ -0,0 +1,9 @@ +import { defineStore } from 'pinia'; + +export const useCsrfStore = defineStore('crsf', { + state: () => ({ + headerName: '', + token: '', + expirationDate: 0, + }), +}); diff --git a/tests/unit/components/dialog/CreateAIModelDialog.spec.js b/tests/unit/components/dialog/CreateAIModelDialog.spec.js new file mode 100644 index 000000000..647c4c9e6 --- /dev/null +++ b/tests/unit/components/dialog/CreateAIModelDialog.spec.js @@ -0,0 +1,25 @@ +import { installQuasarPlugin } from '@quasar/quasar-app-extension-testing-unit-jest'; +import { shallowMount } from '@vue/test-utils'; +import CreateAIModelDialog from 'src/components/dialog/CreateAIModelDialog'; + +installQuasarPlugin(); + +describe('Test component: CreateAIModelDialog', () => { + let wrapper; + + beforeEach(() => { + wrapper = shallowMount(CreateAIModelDialog, { + props: { + projectName: 'projectName', + }, + }); + }); + + describe('Test variables initialization', () => { + describe('Test props: projectName', () => { + it('should match "projectName"', () => { + expect(wrapper.vm.projectName).toEqual('projectName'); + }); + }); + }); +}); diff --git a/tests/unit/components/form/CreateAIModelForm.spec.js b/tests/unit/components/form/CreateAIModelForm.spec.js new file mode 100644 index 000000000..949533477 --- /dev/null +++ b/tests/unit/components/form/CreateAIModelForm.spec.js @@ -0,0 +1,204 @@ +import { installQuasarPlugin } from '@quasar/quasar-app-extension-testing-unit-jest'; +import { shallowMount } from '@vue/test-utils'; +import CreateAIModelForm from 'src/components/form/CreateAIModelForm'; +import { Notify } from 'quasar'; +import { generateDiagram } from 'src/services/AIService'; +import { useRouter } from 'vue-router'; +import PluginManager from 'src/composables/PluginManager'; + +installQuasarPlugin({ + plugins: [Notify], +}); + +jest.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (t) => t, + }), +})); + +jest.mock('vue-router', () => ({ + useRouter: jest.fn(), +})); + +jest.mock('src/composables/Project', () => ({ + appendProjectFile: jest.fn(() => Promise.resolve()), + getAllModels: jest.fn(() => Promise.resolve([])), +})); + +jest.mock('src/composables/PluginManager', () => ({ + getPlugins: jest.fn(() => [{ + data: { name: 'pluginName' }, + }]), + getPluginByName: jest.fn(() => ({ + data: { name: 'pluginName' }, + configuration: { + defaultFileName: 'file.md', + restrictiveFolder: 'folder/', + isFolderTypeDiagram: false, + }, + isParsable: jest.fn(() => true), + })), + getModelPath: jest.fn(() => 'modelPath'), +})); + +jest.mock('src/services/AIService', () => ({ + generateDiagram: jest.fn(), +})); + +describe('Test component: CreateAIModelForm', () => { + let wrapper; + let useRouterPush; + + beforeEach(() => { + useRouterPush = jest.fn(); + + useRouter.mockImplementation(() => ({ + push: useRouterPush, + })); + + wrapper = shallowMount(CreateAIModelForm, { + props: { + projectName: 'projectName', + }, + }); + }); + describe('Test variables initialization', () => { + describe('Test computed: modelLocation', () => { + it('should be "folder/modelPath" when plugin.isFolderTypeDiagram is false', () => { + wrapper.vm.modelPath = 'modelPath'; + + expect(wrapper.vm.modelLocation).toEqual('folder/modelPath'); + }); + + it('should be "folder/modelPath/file.md" when plugin.isFolderTypeDiagram is true', () => { + wrapper.vm.modelPath = 'modelPath'; + + PluginManager.getPluginByName.mockImplementation(() => ({ + data: { name: 'pluginName' }, + configuration: { + defaultFileName: 'file.md', + restrictiveFolder: 'folder/', + isFolderTypeDiagram: true, + }, + isParsable: jest.fn(() => true), + })); + + expect(wrapper.vm.modelLocation).toEqual('folder/modelPath/file.md'); + }); + + it('should be "folder/file.md" when plugin.isFolderTypeDiagram is true and restrictiveFolder is defined', () => { + PluginManager.getPluginByName.mockImplementation(() => ({ + data: { name: 'pluginName' }, + configuration: { + defaultFileName: 'file.md', + restrictiveFolder: 'folder/', + isFolderTypeDiagram: true, + }, + isParsable: jest.fn(() => true), + })); + + expect(wrapper.vm.modelLocation).toEqual('folder/file.md'); + }); + }); + + describe('Test computed: fileName', () => { + it('should be "file.md"', () => { + expect(wrapper.vm.fileName).toEqual('file.md'); + }); + + it('should be empty string when plugin.defaultFileName is null', () => { + PluginManager.getPluginByName.mockImplementation(() => ({ + data: { name: 'pluginName' }, + configuration: { + defaultFileName: null, + restrictiveFolder: null, + isFolderTypeDiagram: true, + }, + isParsable: jest.fn(() => true), + })); + + expect(wrapper.vm.fileName).toEqual(''); + }); + }); + }); + + describe('Test props: projectName', () => { + it('should match "projectName"', () => { + expect(wrapper.vm.projectName).toEqual('projectName'); + }); + }); + + describe('Test funtion: onPluginChange', () => { + it('should set modelPath to empty string if plugin.isFolderTypeDiagram is true', () => { + wrapper.vm.modelPath = null; + + wrapper.vm.onPluginChange(); + + expect(wrapper.vm.modelPath).toEqual(''); + }); + + it('should set modelPath equal to defaultFileName if plugin.isFolderTypeDiagram is false', () => { + wrapper.vm.modelPath = null; + + PluginManager.getPluginByName.mockImplementation(() => ({ + data: { name: 'pluginName' }, + configuration: { + defaultFileName: 'file.md', + restrictiveFolder: '', + isFolderTypeDiagram: false, + }, + isParsable: jest.fn(() => true), + })); + + wrapper.vm.onPluginChange(); + + expect(wrapper.vm.modelPath).toEqual('file.md'); + }); + }); + + describe('Test funtion: isValidDiagramPath', () => { + it('should return null if diagram path is parsable and therefore valid', () => { + const result = wrapper.vm.isValidDiagramPath(); + + expect(result).toEqual(null); + }); + + it('should return string error message if diagram path is not parsable and therefore not valid', () => { + PluginManager.getPluginByName.mockImplementation(() => ({ + data: { name: 'pluginName' }, + configuration: { + defaultFileName: 'file.md', + restrictiveFolder: '', + isFolderTypeDiagram: false, + }, + isParsable: jest.fn(() => false), + })); + + const result = wrapper.vm.isValidDiagramPath(); + + expect(result).toEqual('errors.models.notParsable'); + }); + }); + + describe('Test function: onSubmit', () => { + it('should emit an event and a positive notification on success', async () => { + Notify.create = jest.fn(); + + generateDiagram.mockImplementation(() => Promise.resolve([{ name: 'deployment.yaml', content: 'apiVersion: Deployment' }])); + + await wrapper.vm.onSubmit(); + + expect(Notify.create).toHaveBeenCalledWith(expect.objectContaining({ type: 'positive' })); + expect(useRouterPush).toHaveBeenCalledTimes(1); + }); + + it('should display model description error when API proxy return an error', async () => { + generateDiagram.mockImplementation(() => Promise.reject()); + + await wrapper.vm.onSubmit(); + + expect(wrapper.vm.modelDescriptionError).toEqual(true); + expect(wrapper.vm.modelDescriptionErrorMessage).not.toEqual(''); + }); + }); +}); diff --git a/tests/unit/services/AIService.spec.js b/tests/unit/services/AIService.spec.js new file mode 100644 index 000000000..9cca498d6 --- /dev/null +++ b/tests/unit/services/AIService.spec.js @@ -0,0 +1,23 @@ +import { api, prepareApiRequest } from 'src/boot/axios'; +import * as AIService from 'src/services/AIService'; + +jest.mock('src/boot/axios', () => ({ + prepareApiRequest: jest.fn(), + api: { + post: jest.fn(), + }, +})); + +describe('AI Service tests', () => { + describe('Test function: generateDiagram', () => { + it('should return a diagram based on the description', async () => { + const resultPostDiagram = '[{"name": "deployment.yaml", "content": "apiVersion: apps/v1\\nkind: Deployment"}]'; + api.post.mockImplementation(() => Promise.resolve(resultPostDiagram)); + prepareApiRequest.mockImplementation(() => Promise.resolve(api)); + + const res = await AIService.generateDiagram('@ditrit/kubernator-plugin', 'give me a sample of code'); + + expect(res).toEqual(resultPostDiagram); + }); + }); +});