mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-07-17 22:35:19 +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).
145 lines
4.7 KiB
PHP
145 lines
4.7 KiB
PHP
<?php
|
|
|
|
use App\Models\User;
|
|
use App\Services\AiConfigurationService;
|
|
use App\Support\Ai\AiChatResponse;
|
|
use App\Support\Ai\AiDriver;
|
|
use App\Support\Ai\AiDriverFactory;
|
|
use Illuminate\Support\Facades\Artisan;
|
|
use Laravel\Sanctum\Sanctum;
|
|
|
|
use function Pest\Laravel\postJson;
|
|
|
|
/**
|
|
* Scripted driver reused from AiChatFlowTest's strategy — tracks which prompt
|
|
* went into textCompletion() and echoes a canned reply.
|
|
*/
|
|
class TextGenDriver extends AiDriver
|
|
{
|
|
public static ?string $lastPrompt = null;
|
|
|
|
public static ?string $lastModel = null;
|
|
|
|
public static string $reply = 'generated text';
|
|
|
|
public static int $callCount = 0;
|
|
|
|
public function chatCompletion(array $messages, string $model, array $tools = [], array $options = []): AiChatResponse
|
|
{
|
|
return new AiChatResponse(message: self::$reply);
|
|
}
|
|
|
|
public function textCompletion(string $prompt, string $model, array $options = []): string
|
|
{
|
|
self::$lastPrompt = $prompt;
|
|
self::$lastModel = $model;
|
|
self::$callCount++;
|
|
|
|
return self::$reply;
|
|
}
|
|
|
|
public function validateConnection(): array
|
|
{
|
|
return ['ok' => true];
|
|
}
|
|
}
|
|
|
|
beforeEach(function () {
|
|
Artisan::call('db:seed', ['--class' => 'DatabaseSeeder', '--force' => true]);
|
|
Artisan::call('db:seed', ['--class' => 'DemoSeeder', '--force' => true]);
|
|
|
|
$this->user = User::find(1);
|
|
$this->companyId = $this->user->companies()->first()->id;
|
|
|
|
$this->withHeaders(['company' => $this->companyId]);
|
|
Sanctum::actingAs($this->user, ['*']);
|
|
|
|
AiDriverFactory::register('textgen', TextGenDriver::class);
|
|
TextGenDriver::$lastPrompt = null;
|
|
TextGenDriver::$lastModel = null;
|
|
TextGenDriver::$callCount = 0;
|
|
TextGenDriver::$reply = 'generated text';
|
|
|
|
app(AiConfigurationService::class)->saveGlobalConfig([
|
|
'ai_enabled' => 'YES',
|
|
'ai_driver' => 'textgen',
|
|
'ai_api_key' => 'test-key',
|
|
'ai_text_generation_enabled' => 'YES',
|
|
'ai_text_generation_model' => 'test-textgen-model',
|
|
]);
|
|
});
|
|
|
|
test('generate endpoint returns generated text for a valid prompt', function () {
|
|
TextGenDriver::$reply = 'Dear customer, your invoice is overdue.';
|
|
|
|
$response = postJson('/api/v1/ai/generate', [
|
|
'prompt' => 'Write a late payment reminder',
|
|
])->assertOk();
|
|
|
|
expect($response->json('text'))->toBe('Dear customer, your invoice is overdue.');
|
|
expect(TextGenDriver::$lastModel)->toBe('test-textgen-model');
|
|
expect(TextGenDriver::$lastPrompt)->toContain('Write a late payment reminder');
|
|
});
|
|
|
|
test('generate endpoint includes context in the prompt when provided', function () {
|
|
postJson('/api/v1/ai/generate', [
|
|
'prompt' => 'Rewrite this in a friendlier tone',
|
|
'context' => 'PAY US NOW OR ELSE',
|
|
])->assertOk();
|
|
|
|
expect(TextGenDriver::$lastPrompt)
|
|
->toContain('Context (current content the user is working with):')
|
|
->toContain('PAY US NOW OR ELSE')
|
|
->toContain('Rewrite this in a friendlier tone');
|
|
});
|
|
|
|
test('generate endpoint omits context section when no context is supplied', function () {
|
|
postJson('/api/v1/ai/generate', [
|
|
'prompt' => 'Hello world',
|
|
])->assertOk();
|
|
|
|
expect(TextGenDriver::$lastPrompt)->not->toContain('Context (current content');
|
|
});
|
|
|
|
test('generate endpoint rejects when AI is globally disabled', function () {
|
|
app(AiConfigurationService::class)->saveGlobalConfig(['ai_enabled' => 'NO']);
|
|
|
|
postJson('/api/v1/ai/generate', ['prompt' => 'Hi'])->assertStatus(422);
|
|
});
|
|
|
|
test('generate endpoint rejects when text_generation role is disabled', function () {
|
|
app(AiConfigurationService::class)->saveGlobalConfig([
|
|
'ai_enabled' => 'YES',
|
|
'ai_driver' => 'textgen',
|
|
'ai_api_key' => 'k',
|
|
'ai_text_generation_enabled' => 'NO', // <— off
|
|
'ai_text_generation_model' => 'test',
|
|
]);
|
|
|
|
postJson('/api/v1/ai/generate', ['prompt' => 'Hi'])->assertStatus(422);
|
|
});
|
|
|
|
test('generate endpoint validates prompt length', function () {
|
|
// Empty prompt → validation error
|
|
postJson('/api/v1/ai/generate', ['prompt' => ''])->assertStatus(422);
|
|
|
|
// Prompt over 4000 chars → validation error
|
|
postJson('/api/v1/ai/generate', ['prompt' => str_repeat('a', 4001)])->assertStatus(422);
|
|
|
|
// Context over 20k chars → validation error
|
|
postJson('/api/v1/ai/generate', [
|
|
'prompt' => 'Hi',
|
|
'context' => str_repeat('x', 20001),
|
|
])->assertStatus(422);
|
|
});
|
|
|
|
test('generate endpoint trims whitespace from the driver response', function () {
|
|
TextGenDriver::$reply = " \n\n spaced out reply \n\n ";
|
|
|
|
$response = postJson('/api/v1/ai/generate', [
|
|
'prompt' => 'anything',
|
|
])->assertOk();
|
|
|
|
expect($response->json('text'))->toBe('spaced out reply');
|
|
});
|