Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: create diagram from ia #517

Merged
merged 6 commits into from
Sep 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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() {
Zorin95670 marked this conversation as resolved.
Show resolved Hide resolved
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
Loading