Files
InvoiceShelf/resources/scripts/features/shared/ai/AiTextGenerationModal.vue
Darko Gjorgjijoski b761ea9931 feat(ai): Phase 3 — text generation popup on WYSIWYG editors
Third and final phase of the AI feature. A SparklesIcon button is added to every Tiptap WYSIWYG editor (invoice notes, email body compose, note templates — ~6 places where RichEditor is used) that opens a modal with a prompt input, optional 'use current content as context' toggle, preview area, and Insert / Replace / Regenerate actions.

**Backend (thin)** — AiTextGenerationService is stateless: resolve config → check text_generation_enabled → instantiate driver → call textCompletion() with a system-prompt-wrapped user instruction. The system prompt is terse and opinionated: 'Return only the requested text. No preamble, no explanation, no markdown code fences.' When context is provided, it's included as a separate framed block ('Context (current content the user is working with):') so the model knows it's operating on existing copy.

**GenerationController** — POST /api/v1/ai/generate with {prompt, context?}. Validates prompt required (max 4000 chars) and context optional (max 20000 chars). Rate-limited via the same 'ai' RateLimiter from Phase 2 (30/min per user/company). Gated by 'use ai' Bouncer ability + AiConfigurationService resolution. Returns {text} on success or {error, message} with 422 on any AiException.

**Frontend modal (AiTextGenerationModal.vue)** — mounted globally in CompanyLayout when bootstrap reports ai.enabled && text_generation_enabled. Uses the existing modalStore pattern: self-registers on componentName='AiTextGenerationModal'. Modal state includes prompt, useContext toggle, generatedText preview. Callers (currently RichEditor) pass onInsert/onReplace callbacks via modalStore.data; the modal invokes them with the final text and closes — it knows nothing about tiptap or ProseMirror.

**RichEditor integration** — the Sparkles toolbar button is pushed onto the existing editorButtons ref at setup time, gated on globalStore.ai.enabled && text_generation_enabled. The button opens the modal with the editor's current getHTML() as context and callbacks that use the tiptap chain API: insertContent for Insert, selectAll().deleteSelection().insertContent for Replace. No reactivity on the flag check — it's set once at bootstrap and doesn't change during a session.

**Tests** (7 new) — AiGenerationTest with a dedicated TextGenDriver test double that tracks the exact prompt passed to textCompletion(). Covers: happy path, context inclusion/omission, AI globally disabled rejection, text_generation role disabled rejection, prompt/context length validation, response whitespace trimming.

395 tests pass (was 388, +7 new). Pint clean. npm run build clean. The AI feature is now complete end-to-end: provider configuration (Phase 1), chat assistant with DB tool-calling (Phase 2), and text generation popup (Phase 3).
2026-04-12 11:00:00 +02:00

195 lines
5.5 KiB
Vue

<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useModalStore } from '@/scripts/stores/modal.store'
import { useNotificationStore } from '@/scripts/stores/notification.store'
import { aiService } from '@/scripts/api/services/ai.service'
/**
* One-shot text generation popup for WYSIWYG editors.
*
* Usage pattern — a caller opens this modal via modalStore and passes data with:
* - currentContent: string // the editor's current HTML (used as optional context)
* - onInsert: (text: string) => void // invoked when the user accepts "Insert"
* - onReplace: (text: string) => void // invoked when the user accepts "Replace"
*
* See RichEditor.vue for the canonical caller. The modal doesn't know
* anything about tiptap or ProseMirror — it just hands back the text it got
* from the backend and lets the caller decide how to splice it into the editor.
*/
interface ModalData {
currentContent?: string
onInsert?: (text: string) => void
onReplace?: (text: string) => void
}
const { t } = useI18n()
const modalStore = useModalStore()
const notificationStore = useNotificationStore()
const modalActive = computed<boolean>(
() => modalStore.active && modalStore.componentName === 'AiTextGenerationModal',
)
const data = computed<ModalData>(() => (modalStore.data as ModalData) ?? {})
const prompt = ref<string>('')
const useContext = ref<boolean>(false)
const generatedText = ref<string>('')
const isGenerating = ref<boolean>(false)
const canInsert = computed<boolean>(() => generatedText.value.trim() !== '')
async function generate(): Promise<void> {
if (!prompt.value.trim() || isGenerating.value) return
isGenerating.value = true
generatedText.value = ''
try {
const response = await aiService.generateText({
prompt: prompt.value,
context: useContext.value ? data.value.currentContent : undefined,
})
if (response.text !== undefined) {
generatedText.value = response.text
} else if (response.error) {
notificationStore.showNotification({
type: 'error',
message: t('settings.ai.errors.' + response.error, { error: response.message ?? '' }),
})
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Unknown error'
notificationStore.showNotification({ type: 'error', message })
} finally {
isGenerating.value = false
}
}
function insert(): void {
data.value.onInsert?.(generatedText.value)
close()
}
function replace(): void {
data.value.onReplace?.(generatedText.value)
close()
}
function close(): void {
modalStore.closeModal()
setTimeout(() => {
prompt.value = ''
generatedText.value = ''
useContext.value = false
}, 200)
}
</script>
<template>
<BaseModal :show="modalActive" @close="close">
<template #header>
<div class="flex items-center justify-between w-full">
<div class="flex items-center gap-2">
<BaseIcon name="SparklesIcon" class="w-5 h-5 text-primary-500" />
<span>{{ $t('ai.generate.title') }}</span>
</div>
<BaseIcon
name="XMarkIcon"
class="w-6 h-6 text-muted cursor-pointer"
@click="close"
/>
</div>
</template>
<div class="p-6 space-y-4">
<BaseInputGroup
:label="$t('ai.generate.prompt_label')"
:content-loading="false"
required
>
<BaseTextarea
v-model="prompt"
rows="3"
:placeholder="$t('ai.generate.prompt_placeholder')"
:disabled="isGenerating"
/>
</BaseInputGroup>
<div v-if="data.currentContent">
<BaseSwitch
v-model="useContext"
class="flex"
:label-right="$t('ai.generate.use_current_as_context')"
/>
<p class="mt-1 text-xs text-muted">
{{ $t('ai.generate.use_context_help') }}
</p>
</div>
<div v-if="generatedText" class="border border-line-default rounded-md p-3 bg-surface-secondary">
<p class="text-xs text-muted mb-2">{{ $t('ai.generate.preview') }}</p>
<p class="text-sm text-body whitespace-pre-wrap">{{ generatedText }}</p>
</div>
</div>
<div class="flex justify-end gap-2 p-4 border-t border-line-default">
<BaseButton
variant="primary-outline"
type="button"
:disabled="isGenerating"
@click="close"
>
{{ $t('general.cancel') }}
</BaseButton>
<BaseButton
v-if="canInsert"
variant="primary-outline"
type="button"
:disabled="isGenerating"
@click="replace"
>
{{ $t('ai.generate.replace') }}
</BaseButton>
<BaseButton
v-if="canInsert"
variant="primary-outline"
type="button"
:disabled="isGenerating"
@click="generate"
>
{{ $t('ai.generate.regenerate') }}
</BaseButton>
<BaseButton
v-if="canInsert"
variant="primary"
type="button"
:disabled="isGenerating"
@click="insert"
>
{{ $t('ai.generate.insert') }}
</BaseButton>
<BaseButton
v-else
variant="primary"
type="button"
:loading="isGenerating"
:disabled="!prompt.trim() || isGenerating"
@click="generate"
>
<template #left="slotProps">
<BaseIcon v-if="!isGenerating" name="SparklesIcon" :class="slotProps.class" />
</template>
{{ $t('ai.generate.generate') }}
</BaseButton>
</div>
</BaseModal>
</template>