Files
InvoiceShelf/app/Services/Ai/AiTextGenerationService.php
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

89 lines
3.6 KiB
PHP

<?php
namespace App\Services\Ai;
use App\Services\AiConfigurationService;
use App\Support\Ai\AiException;
/**
* Stateless one-shot text generation for the WYSIWYG popup.
*
* Much simpler than the chat assistant — no conversation state, no tool calls,
* no history. Takes a user-authored instruction plus optional surrounding
* context (e.g. "here's the current editor content") and returns a single
* generated text blob the frontend can insert into the editor.
*
* The text-generation role is distinct from chat in two places:
* - AiConfigurationService.text_generation_enabled gates availability
* - ai_text_generation_model picks which model to use
*
* That means an instance can use a cheap fast model for one-shot writing
* (openai/gpt-4o-mini) while pointing chat at a smarter model
* (openai/gpt-4o) without config gymnastics.
*/
class AiTextGenerationService
{
public function __construct(
private readonly AiConfigurationService $aiConfiguration,
) {}
/**
* Generate text from a user instruction, optionally grounded in a context blob.
*
* @param int $companyId Current company — resolves config and model selection
* @param string $prompt User's instruction (e.g. "write a polite late-payment reminder")
* @param string|null $context Optional surrounding content — usually the editor's
* current HTML/text, passed when the user wants the AI
* to work from existing copy
*
* @throws AiException When AI is disabled, text generation is off, or the driver call fails
*/
public function generate(int $companyId, string $prompt, ?string $context = null): string
{
$driver = $this->aiConfiguration->makeDriver($companyId);
if ($driver === null) {
throw new AiException('AI is not enabled for this company', 'ai_disabled');
}
$resolved = $this->aiConfiguration->resolveForCompany($companyId);
if (empty($resolved['text_generation_enabled'])) {
throw new AiException('Text generation is not enabled for this company', 'text_generation_disabled');
}
$model = (string) ($resolved['ai_text_generation_model'] ?? '');
if ($model === '') {
throw new AiException('No text generation model configured', 'missing_model');
}
$fullPrompt = $this->buildPrompt($prompt, $context);
return trim($driver->textCompletion($fullPrompt, $model));
}
/**
* Compose the final prompt sent to the model.
*
* Keep the framing terse — text-generation output should not be padded
* with "Here is the text you requested:" preambles. The instruction is
* always placed last so the model gives it the most weight.
*/
protected function buildPrompt(string $prompt, ?string $context): string
{
$system = "You are a helpful writing assistant embedded in an invoicing application. Generate text based on the user's instruction. Rules:\n"
.'- Return only the requested text. No preamble, no explanation, no markdown code fences.'."\n"
.'- Match the tone and language implied by the instruction.'."\n"
.'- Be concise unless explicitly asked for length.';
if ($context !== null && trim($context) !== '') {
return $system."\n\n"
."Context (current content the user is working with):\n"
.trim($context)
."\n\n"
."Instruction: {$prompt}";
}
return $system."\n\nInstruction: {$prompt}";
}
}