mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-07-17 22:35:19 +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:
53
app/Http/Controllers/Company/Ai/GenerationController.php
Normal file
53
app/Http/Controllers/Company/Ai/GenerationController.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Company\Ai;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Ai\AiTextGenerationService;
|
||||
use App\Support\Ai\AiException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class GenerationController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AiTextGenerationService $generator,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* One-shot text generation for the WYSIWYG popup.
|
||||
*
|
||||
* Stateless — nothing is persisted. Each call is fully self-contained.
|
||||
* Rate-limited via the shared 'ai' limiter so a stuck client can't
|
||||
* hammer the provider.
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('use ai');
|
||||
|
||||
$validated = $this->validate($request, [
|
||||
'prompt' => 'required|string|max:4000',
|
||||
'context' => 'nullable|string|max:20000',
|
||||
]);
|
||||
|
||||
try {
|
||||
$text = $this->generator->generate(
|
||||
(int) $request->header('company'),
|
||||
$validated['prompt'],
|
||||
$validated['context'] ?? null,
|
||||
);
|
||||
} catch (AiException $e) {
|
||||
return response()->json([
|
||||
'error' => $e->errorKey,
|
||||
'message' => $e->getMessage(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'text' => $text,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user