mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-07-16 22:05:20 +00:00
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).
195 lines
5.5 KiB
Vue
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>
|