mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-07-19 07:15:20 +00:00
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).
This commit is contained in:
194
resources/scripts/features/shared/ai/AiTextGenerationModal.vue
Normal file
194
resources/scripts/features/shared/ai/AiTextGenerationModal.vue
Normal file
@@ -0,0 +1,194 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user