Skip to content

Commit

Permalink
Merge pull request #517 from ditrit/feature/create_diagram_from_ia
Browse files Browse the repository at this point in the history
Feature: create diagram from ia
  • Loading branch information
Zorin95670 authored Sep 4, 2024
2 parents f7c82a2 + ffdb724 commit eee143a
Show file tree
Hide file tree
Showing 13 changed files with 613 additions and 1 deletion.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
42 changes: 41 additions & 1 deletion src/boot/axios.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import axios from 'axios';
import { useCsrfStore } from 'src/stores/CsrfTokenStore';

const templateLibraryApiClient = axios.create({
baseURL: '/template-library/',
Expand All @@ -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) => {
Expand All @@ -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<object>} 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 };
16 changes: 16 additions & 0 deletions src/components/card/DiagramsCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,21 @@
>
{{ $t('actions.models.create.button.template.name') }}
</q-item>
<template v-if="HAS_BACKEND">
<q-separator />
<q-item
clickable
:label="$t('actions.models.create.button.ai.label')"
:title="$t('actions.models.create.button.ai.title')"
data-cy="create-diagram-from-ia-button"
@click="DialogEvent.next({
type: 'open',
key: 'CreateAIModel',
})"
>
{{ $t('actions.models.create.button.ai.name') }}
</q-item>
</template>
</q-list>
</q-menu>
</q-btn>
Expand Down Expand Up @@ -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;
Expand Down
31 changes: 31 additions & 0 deletions src/components/dialog/CreateAIModelDialog.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<template>
<default-dialog
dialog-key="CreateAIModel"
data-cy="create-ai-model-dialog"
>
<template #title>
<q-icon
color="primary"
name="fa-solid fa-scroll"
/>
{{ $t(`actions.models.create.dialog.name`) }}
</template>
<template #default>
<create-a-i-model-form
:project-name="projectName"
/>
</template>
</default-dialog>
</template>

<script setup>
import DefaultDialog from 'components/dialog/DefaultDialog.vue';
import CreateAIModelForm from 'components/form/CreateAIModelForm.vue';
defineProps({
projectName: {
type: String,
required: true,
},
});
</script>
233 changes: 233 additions & 0 deletions src/components/form/CreateAIModelForm.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
<template>
<q-form
class="q-gutter-md create-model-form"
data-cy="create-ai-model-form"
@submit="onSubmit"
>
<q-select
v-model="modelPlugin"
filled
:label="$t('actions.models.create.form.plugin')"
:options="plugins.map(({ data }) => data.name)"
:rules="[
(value) => notEmpty($t, value),
]"
data-cy="plugin-select"
@update:model-value="onPluginChange"
>
<template #option="{ selected, opt, toggleOption }">
<q-item
:active="selected"
clickable
@click="toggleOption(opt)"
>
<q-item-section :data-cy="`item_${opt}`">
{{ opt }}
</q-item-section>
</q-item>
</template>
</q-select>
<q-input
v-model="modelPath"
filled
:label="$t('actions.models.create.form.name')"
lazy-rules
:rules="[
(value) => canCreateRootModel ? null : notEmpty($t, value),
(value) => isUniqueModel(
$t,
modelPlugin,
models.filter(({ plugin }) => plugin === modelPlugin).map(({ path }) => path),
modelLocation,
'errors.models.duplicate',
),
() => isValidDiagramPath()
]"
data-cy="name-input"
/>
<q-input
v-model="modelPath"
:model-value="modelLocation"
outlined
disable
:label="$t('actions.models.create.form.location')"
data-cy="location-input"
/>
<q-input
v-model="modelDescription"
filled
type="textarea"
:label="$t('actions.models.create.form.description')"
lazy-rules
:rules="[(value) => notEmpty($t, value)]"
bottom-slots
:error="modelDescriptionError"
:error-message="modelDescriptionErrorMessage"
data-cy="description-input"
/>
<div class="flex row items-center justify-center">
<q-btn
icon="fa-solid fa-brain"
:label="$t('actions.default.create')"
type="submit"
:loading="submitting"
color="positive"
data-cy="submit-button"
>
<template #loading>
<q-spinner-bars class="q-mx-md" />
</template>
</q-btn>
</div>
</q-form>
</template>

<script setup>
import { Notify } from 'quasar';
import { getPluginByName, getPlugins, getModelPath } from 'src/composables/PluginManager';
import {
computed,
onMounted,
reactive,
ref,
} from 'vue';
import { isUniqueModel, notEmpty } from 'src/composables/QuasarFieldRule';
import { useI18n } from 'vue-i18n';
import {
appendProjectFile,
getAllModels,
} from 'src/composables/Project';
import { useRouter } from 'vue-router';
import {
FileInput,
FileInformation,
} from '@ditrit/leto-modelizer-plugin-core';
import { generateDiagram } from 'src/services/AIService';
const { t } = useI18n();
const router = useRouter();
const props = defineProps({
projectName: {
type: String,
required: true,
},
});
const plugins = reactive(getPlugins());
const modelPath = ref();
const modelPlugin = ref(plugins[0]?.data.name);
const modelDescription = ref('');
const modelDescriptionError = ref(false);
const modelDescriptionErrorMessage = ref('');
const submitting = ref(false);
const models = ref([]);
const pluginConfiguration = computed(() => getPluginByName(modelPlugin.value).configuration);
const fileName = computed(() => pluginConfiguration.value.defaultFileName || '');
const baseFolder = computed(() => pluginConfiguration.value.restrictiveFolder || '');
const canCreateRootModel = computed(() => pluginConfiguration.value.restrictiveFolder === null);
const modelLocation = computed(() => {
if (pluginConfiguration.value.isFolderTypeDiagram) {
if (modelPath.value?.length > 0) {
return `${baseFolder.value}${modelPath.value}/${fileName.value}`;
}
return `${baseFolder.value}${fileName.value}`;
}
return `${baseFolder.value}${modelPath.value}`;
});
/**
* Check if new diagram to create has a valid path.
* @returns {null | string} Return true if the value is a valid diagram path,
* otherwise the translated error message.
*/
function isValidDiagramPath() {
return getPluginByName(modelPlugin.value)
.isParsable(new FileInformation({ path: modelLocation.value }))
? null : t('errors.models.notParsable');
}
/**
* Create a new files with its parent folders if necessary, from the AI Proxy response.
* The response contains the name and content of the new files.
* They are then appended to the project.
* @param {Array} files - List of files from AI Proxy response.
* @returns {Promise} Promise with nothing on success otherwise an error.
*/
async function createFilesFromAIResponse(files) {
const model = getModelPath(modelPlugin.value, modelLocation.value);
return Promise.allSettled(files.map((file) => {
const path = (modelPath.value?.length > 0)
? `${props.projectName}/${baseFolder.value}${modelPath.value}/${file.name}`
: `${props.projectName}/${baseFolder.value}${file.name}`;
return appendProjectFile(new FileInput({
path,
content: file.content,
}));
}))
.then(() => {
Notify.create({
type: 'positive',
message: t('actions.models.create.notify.success'),
html: true,
});
return router.push({
name: 'Draw',
params: {
projectName: props.projectName,
},
query: {
plugin: modelPlugin.value,
path: model,
},
});
});
}
/**
* Create a new model folder and its parent folders if necessary.
* Emit a positive notification on success and redirect to model page.
* Otherwise, emit a negative notification.
* @returns {Promise<void>} Promise with nothing on success or error.
*/
async function onSubmit() {
submitting.value = true;
modelDescriptionError.value = false;
modelDescriptionErrorMessage.value = '';
return generateDiagram(modelPlugin.value, modelDescription.value)
.then(createFilesFromAIResponse)
.catch(() => {
modelDescriptionError.value = true;
modelDescriptionErrorMessage.value = t('actions.models.create.button.ai.error');
})
.finally(() => {
submitting.value = false;
});
}
/**
* Set model path on plugin name change.
*/
function onPluginChange() {
const { defaultFileName = '', isFolderTypeDiagram } = pluginConfiguration.value;
modelPath.value = isFolderTypeDiagram ? '' : defaultFileName;
}
onMounted(async () => {
getAllModels(props.projectName).then((array) => {
models.value = array;
});
});
</script>
<style lang="scss" scoped>
.create-model-form {
min-width: 300px;
}
</style>
7 changes: 7 additions & 0 deletions src/i18n/en-US/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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 &#129395;!',
Expand Down
Loading

0 comments on commit eee143a

Please sign in to comment.