Skip to content
Draft
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
113 changes: 108 additions & 5 deletions components/Dataservices/DescribeDataservice.vue
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,49 @@
>
{{ getFirstWarning("description") }}
</SimpleBanner>
<div class="flex items-center gap-4 mt-2 mb-3">
<Tooltip v-if="!canGenerateDescription">
<BrandedButton
type="button"
color="primary"
:disabled="true"
>
<div class="flex items-center space-x-2">
<RiSparklingLine
class="size-4"
aria-hidden="true"
/>
<span>{{ $t('Suggérer une description') }}</span>
</div>
</BrandedButton>
<template #tooltip>
{{ $t('Remplissez le champ "Lien vers la documentation technique de l\'API" pour utiliser cette fonctionnalité.') }}
</template>
</Tooltip>
<BrandedButton
v-else
type="button"
color="primary"
:icon="RiSparklingLine"
:loading="isGeneratingDescription"
@click="handleAutoCompleteDescription"
>
<template v-if="isGeneratingDescription">
{{ $t('Suggestion en cours...') }}
</template>
<template v-else>
{{ $t('Suggérer une description') }}
</template>
</BrandedButton>
<CdataLink
v-if="config.public.generateDescriptionFeedbackUrl"
:to="config.public.generateDescriptionFeedbackUrl"
target="_blank"
class="text-sm text-gray-medium"
>
{{ $t('Comment avez-vous trouvé cette suggestion ?') }}
</CdataLink>
</div>
</LinkedToAccordion>
<LinkedToAccordion
class="fr-fieldset__element"
Expand Down Expand Up @@ -506,15 +549,16 @@
</template>

<script setup lang="ts">
import { BrandedButton, SimpleBanner, TranslationT, type Owned } from '@datagouv/components-next'
import { RiAddLine } from '@remixicon/vue'
import { computed } from 'vue'
import ModalClient from '../Modal/Modal.client.vue'
import { BrandedButton, SimpleBanner, Tooltip, TranslationT, type Owned } from '@datagouv/components-next'
import { RiAddLine, RiSparklingLine } from '@remixicon/vue'
import { computed, nextTick } from 'vue'
import Accordion from '~/components/Accordion/Accordion.global.vue'
import AccordionGroup from '~/components/Accordion/AccordionGroup.global.vue'
import ToggleSwitch from '~/components/Form/ToggleSwitch.vue'
import CdataLink from '~/components/CdataLink.vue'
import ContactPointSelect from '~/components/ContactPointSelect.vue'
import ProducerSelect from '~/components/ProducerSelect.vue'
import ToggleSwitch from '~/components/Form/ToggleSwitch.vue'
import ModalClient from '../Modal/Modal.client.vue'
import type { DataserviceForm } from '~/types/types'

const props = defineProps<{
Expand Down Expand Up @@ -554,6 +598,15 @@ const ownedOptions = computed<Array<Owned>>(() => {
const machineDocumentationUrlWarningMessage = t(`Il est fortement recommandé d'ajouter une documentation OpenAPI ou Swagger à votre API.`)
const openConfirmModal = ref(false)

// Track description generation state
const isGeneratingDescription = ref(false)

const hasTechnicalDocumentationUrl = computed(() => form.value.technical_documentation_url && form.value.technical_documentation_url.trim().length > 0)

const canGenerateDescription = computed(() => {
return hasTechnicalDocumentationUrl.value
})

const { form, touch, getFirstError, getFirstWarning, validate } = useForm(dataserviceForm, {
featured: [],
owned: [required()],
Expand Down Expand Up @@ -582,6 +635,56 @@ const accordionState = (key: keyof typeof form.value) => {
return 'default'
}

async function handleAutoCompleteDescription() {
if (!form.value.technical_documentation_url) {
return
}

try {
isGeneratingDescription.value = true

const requestBody: {
technicalDocumentationUrl: string
machineDocumentationUrl?: string
title?: string
} = {
technicalDocumentationUrl: form.value.technical_documentation_url,
}

// Include machine documentation URL only if it's provided
if (form.value.machine_documentation_url && form.value.machine_documentation_url.trim().length > 0) {
requestBody.machineDocumentationUrl = form.value.machine_documentation_url
}

// Include title only if it's provided
if (form.value.title && form.value.title.trim().length > 0) {
requestBody.title = form.value.title
}

// We call our server-side API route instead of Albert API directly to avoid CORS issues.
// The Albert API doesn't allow direct requests from browser-side JavaScript.
// Our server acts as a proxy, keeping the API key secure on the server side.
const response = await $fetch<{ description?: string }>('/nuxt-api/albert/generate-dataservice-description', {
method: 'POST',
body: requestBody,
})

if (response.description) {
form.value.description = response.description
await nextTick()
}
}
catch (error) {
console.error('[Albert API] Failed to generate description:', error)
if (error && typeof error === 'object' && 'data' in error) {
console.error('[Albert API] Error details:', error.data)
}
}
finally {
isGeneratingDescription.value = false
}
}

async function submit() {
if (await validate()) {
if (dataserviceForm.value.machine_documentation_url || openConfirmModal.value) {
Expand Down
18 changes: 18 additions & 0 deletions components/MarkdownEditor/InternalEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@
insertImageCommand,
linkAttr,
paragraphAttr,
replaceAllCommand,

Check failure on line 126 in components/MarkdownEditor/InternalEditor.vue

View workflow job for this annotation

GitHub Actions / quality

Module '"@milkdown/preset-commonmark"' has no exported member 'replaceAllCommand'.
toggleEmphasisCommand,
toggleStrongCommand,
wrapInBlockquoteCommand,
Expand All @@ -140,6 +141,7 @@
import { Milkdown, useEditor } from '@milkdown/vue'
import { usePluginViewFactory, useWidgetViewFactory } from '@prosemirror-adapter/vue'
import { useDebounceFn } from '@vueuse/core'
import { watch } from 'vue'
import { clipboard } from '@milkdown/kit/plugin/clipboard'
import type { ImageModalForm } from '~/components/MarkdownEditor/ImageModal/ImageModalButton.vue'
import ImageModalButton from '~/components/MarkdownEditor/ImageModal/ImageModalButton.vue'
Expand Down Expand Up @@ -249,4 +251,20 @@
function call<T>(command: CmdKey<T>, payload?: T) {
return editor.get()?.action(callCommand(command, payload))
}

// Watch for external value changes and update the editor
watch(() => props.value, (newValue, oldValue) => {
if (newValue !== oldValue && editor.get()) {
const editorInstance = editor.get()
if (editorInstance) {
// Only update if the value actually changed and is different from current content
try {
editorInstance.action(callCommand(replaceAllCommand.key, newValue || ''))
}
catch (error) {
console.warn('[MarkdownEditor] Failed to update editor content:', error)
}
}
}
}, { immediate: false })
</script>
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { createChatCompletion, useAlbertConfig, type ChatResponse } from '~/server/utils/albert-api-client'

export default defineEventHandler(async (event) => {
const body = await readBody(event)
const { title, technicalDocumentationUrl, machineDocumentationUrl } = body

if (!technicalDocumentationUrl) {
console.error('[Albert API] Missing required field: technicalDocumentationUrl')
throw createError({
statusCode: 400,
statusMessage: 'Technical documentation URL is required',
})
}

const runtimeConfig = useRuntimeConfig()

if (!runtimeConfig.albertApiKey) {
console.error('[Albert API] API key not configured')
throw createError({
statusCode: 400,
statusMessage: 'Albert API is not configured',
})
}

try {
const albertConfig = useAlbertConfig()

const messages = [
{
role: 'system',
content: `You are an assistant integrated into data.gouv.fr, the French open data platform.\n`
+ `Your purpose is to help API producers write clear, comprehensive, and factual descriptions of APIs.\n`
+ `\n`
+ `Guidelines:\n`
+ `- Always respond in French.\n`
+ `- Your tone is factual, neutral, and accessible to non-experts.\n`
+ `- Use plain language and clear sentences, avoiding unnecessary technical jargon.\n`
+ `- Do not make assumptions or add information that is not present in the input.\n`
+ `- Focus on what the API does, what data it provides, and how it can be used.\n`
+ `- Always start with a capital letter and end with a period.\n`
+ `- The goal is to produce informative descriptions that help users understand the API's purpose and capabilities.\n`
+ `- IMPORTANT: Return ONLY the description text, without quotes or additional punctuation.`,
},
{
role: 'user',
content: `You are asked to generate a description for an API on data.gouv.fr.\n`
+ `\n`
+ `Goal:\n`
+ `→ Write a comprehensive and accessible description of the API.\n`
+ `→ Focus on what the API does, what data it provides, and its main capabilities.\n`
+ `→ Mention key endpoints, data types, and use cases if available.\n`
+ `→ Explain the API's purpose and how it can be used.\n`
+ `\n`
+ `Here is the API information:\n`
+ (title ? `Title: ${title}\n` : '')
+ `Technical documentation URL: ${technicalDocumentationUrl}\n`
+ (machineDocumentationUrl ? `Machine documentation URL (OpenAPI/Swagger): ${machineDocumentationUrl}\n` : '')
+ `\n`
+ `Output:\n`
+ `→ A comprehensive description in French (no markdown, no introduction, no labels, no emojis).\n`
+ `→ The description should be detailed enough to help users understand the API's purpose and capabilities.`,
},
]

// Models available for text generation:
// - openweight-small (replaces albert-small)
// - openweight-medium (replaces albert-large)
// - openweight-large
const response = await createChatCompletion(messages, 'openweight-small', albertConfig) as ChatResponse
const generatedDescription = response.choices?.[0]?.message?.content || ''

return { description: generatedDescription }
}
catch (error) {
console.error('[Albert API] Error calling Albert API:', error)
if (error && typeof error === 'object') {
console.error('[Albert API] Error details:', {
message: (error as Error).message,
stack: (error as Error).stack,
...error,
})
}
throw createError({
statusCode: 500,
statusMessage: (error as Error).message || 'Failed to call Albert API',
})
}
})
Loading