mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-07-17 06:15: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).
124 lines
3.6 KiB
Vue
124 lines
3.6 KiB
Vue
<template>
|
|
<div v-if="isAppLoaded" class="h-full">
|
|
<NotificationRoot />
|
|
|
|
<ImpersonationBanner />
|
|
|
|
<SiteHeader />
|
|
|
|
<SiteSidebar v-if="hasCompany" />
|
|
|
|
<main
|
|
:class="[
|
|
'h-screen h-screen-ios overflow-y-auto min-h-0 transition-all duration-300',
|
|
hasCompany
|
|
? globalStore.isSidebarCollapsed
|
|
? 'md:pl-16'
|
|
: 'md:pl-56 xl:pl-64'
|
|
: '',
|
|
]"
|
|
>
|
|
<div class="pt-16 pb-16">
|
|
<router-view />
|
|
</div>
|
|
</main>
|
|
|
|
<!-- AI chat drawer — always mounted, visibility driven by the store -->
|
|
<AiChatDrawer v-if="globalStore.ai?.enabled && globalStore.ai?.chat_enabled" />
|
|
|
|
<!-- AI text generation modal — triggered from any RichEditor's Sparkles button -->
|
|
<AiTextGenerationModal v-if="globalStore.ai?.enabled && globalStore.ai?.text_generation_enabled" />
|
|
</div>
|
|
|
|
<BaseGlobalLoader v-else />
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { useI18n } from 'vue-i18n'
|
|
import { onMounted, computed, watch } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import { useGlobalStore } from '@/scripts/stores/global.store'
|
|
import { useUserStore } from '@/scripts/stores/user.store'
|
|
import { useModalStore } from '@/scripts/stores/modal.store'
|
|
import { useCompanyStore } from '@/scripts/stores/company.store'
|
|
import SiteHeader from './partials/SiteHeader.vue'
|
|
import SiteSidebar from './partials/SiteSidebar.vue'
|
|
import NotificationRoot from '@/scripts/components/notifications/NotificationRoot.vue'
|
|
import ImpersonationBanner from './partials/ImpersonationBanner.vue'
|
|
import AiChatDrawer from '@/scripts/features/company/ai/components/AiChatDrawer.vue'
|
|
import AiTextGenerationModal from '@/scripts/features/shared/ai/AiTextGenerationModal.vue'
|
|
|
|
interface RouteMeta {
|
|
ability?: string | string[]
|
|
isSuperAdmin?: boolean
|
|
isOwner?: boolean
|
|
usesAdminBootstrap?: boolean
|
|
}
|
|
|
|
const globalStore = useGlobalStore()
|
|
const route = useRoute()
|
|
const userStore = useUserStore()
|
|
const router = useRouter()
|
|
const modalStore = useModalStore()
|
|
const { t } = useI18n()
|
|
const companyStore = useCompanyStore()
|
|
|
|
const isAppLoaded = computed<boolean>(() => {
|
|
return globalStore.isAppLoaded
|
|
})
|
|
|
|
const hasCompany = computed<boolean>(() => {
|
|
return !!companyStore.selectedCompany || companyStore.isAdminMode
|
|
})
|
|
|
|
const usesAdminBootstrap = computed<boolean>(() => {
|
|
return route.meta.usesAdminBootstrap === true
|
|
})
|
|
|
|
async function initializeLayout(): Promise<void> {
|
|
const meta = route.meta as RouteMeta
|
|
const res = await globalStore.bootstrap({
|
|
adminMode: meta.usesAdminBootstrap === true,
|
|
})
|
|
|
|
if (res.admin_mode === true) {
|
|
return
|
|
}
|
|
|
|
if (!res.current_company) {
|
|
if (route.name !== 'no.company') {
|
|
router.push({ name: 'no.company' })
|
|
}
|
|
return
|
|
}
|
|
|
|
if (meta.ability && !userStore.hasAbilities(meta.ability as string | string[])) {
|
|
router.push({ name: 'settings.account' })
|
|
} else if (meta.isSuperAdmin && !userStore.currentUser?.is_super_admin) {
|
|
router.push({ name: 'dashboard' })
|
|
} else if (meta.isOwner && !userStore.currentUser?.is_owner) {
|
|
router.push({ name: 'settings.account' })
|
|
}
|
|
|
|
if (
|
|
companyStore.selectedCompanySettings.bulk_exchange_rate_configured === 'NO'
|
|
) {
|
|
modalStore.openModal({
|
|
componentName: 'ExchangeRateBulkUpdateModal',
|
|
title: t('exchange_rates.bulk_update'),
|
|
size: 'sm',
|
|
})
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
void initializeLayout()
|
|
})
|
|
|
|
watch(usesAdminBootstrap, (isAdminBootstrap, previousValue) => {
|
|
if (previousValue !== undefined && isAdminBootstrap !== previousValue) {
|
|
void initializeLayout()
|
|
}
|
|
})
|
|
</script>
|