Files
InvoiceShelf/resources/scripts/api/services/ai.service.ts
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

97 lines
2.7 KiB
TypeScript

import { client } from '../client'
import { API } from '../endpoints'
import type {
AiChatSendResponse,
AiConfig,
AiConversationDetail,
AiConversationSummary,
AiDriversResponse,
AiGenerateRequest,
AiGenerateResponse,
AiTestPayload,
AiTestResponse,
CompanyAiConfig,
} from '@/scripts/types/ai-config'
export const aiService = {
// Driver catalog — same shape across admin, company, installer contexts.
async getDrivers(): Promise<AiDriversResponse> {
const { data } = await client.get(API.AI_DRIVERS)
return data
},
// --- Global (admin) ---
async getGlobalConfig(): Promise<AiConfig> {
const { data } = await client.get(API.AI_CONFIG)
return data
},
async saveGlobalConfig(payload: AiConfig): Promise<{ success?: string; error?: string }> {
const { data } = await client.post(API.AI_CONFIG, payload)
return data
},
async testGlobalConnection(payload: AiTestPayload): Promise<AiTestResponse> {
const { data } = await client.post(API.AI_TEST, payload)
return data
},
// --- Per-company ---
async getCompanyConfig(): Promise<CompanyAiConfig> {
const { data } = await client.get(API.COMPANY_AI_CONFIG)
return data
},
async saveCompanyConfig(payload: CompanyAiConfig): Promise<{ success?: boolean; error?: string }> {
const { data } = await client.post(API.COMPANY_AI_CONFIG, payload)
return data
},
async testCompanyConnection(payload: AiTestPayload): Promise<AiTestResponse> {
const { data } = await client.post(API.COMPANY_AI_TEST, payload)
return data
},
// --- Phase 2: chat ---
async sendChatMessage(
conversationId: number | null,
message: string,
): Promise<AiChatSendResponse> {
const { data } = await client.post(API.AI_CHAT, {
conversation_id: conversationId,
message,
})
return data
},
async listConversations(): Promise<{ conversations: AiConversationSummary[] }> {
const { data } = await client.get(API.AI_CONVERSATIONS)
return data
},
async getConversation(id: number): Promise<AiConversationDetail> {
const { data } = await client.get(`${API.AI_CONVERSATIONS}/${id}`)
return data
},
async renameConversation(id: number, title: string): Promise<{ success: boolean }> {
const { data } = await client.patch(`${API.AI_CONVERSATIONS}/${id}`, { title })
return data
},
async deleteConversation(id: number): Promise<{ success: boolean }> {
const { data } = await client.delete(`${API.AI_CONVERSATIONS}/${id}`)
return data
},
// --- Phase 3: text generation ---
async generateText(payload: AiGenerateRequest): Promise<AiGenerateResponse> {
const { data } = await client.post(API.AI_GENERATE, payload)
return data
},
}