diff --git a/app/Http/Controllers/Company/Ai/GenerationController.php b/app/Http/Controllers/Company/Ai/GenerationController.php new file mode 100644 index 00000000..0cd49df1 --- /dev/null +++ b/app/Http/Controllers/Company/Ai/GenerationController.php @@ -0,0 +1,53 @@ +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, + ]); + } +} diff --git a/app/Services/Ai/AiTextGenerationService.php b/app/Services/Ai/AiTextGenerationService.php new file mode 100644 index 00000000..0cb441eb --- /dev/null +++ b/app/Services/Ai/AiTextGenerationService.php @@ -0,0 +1,88 @@ +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}"; + } +} diff --git a/lang/en.json b/lang/en.json index 93285158..b051ad13 100644 --- a/lang/en.json +++ b/lang/en.json @@ -964,7 +964,8 @@ "missing_api_key": "An API key is required to test the connection.", "ai_disabled": "AI is not enabled for this company.", "chat_disabled": "The chat assistant is not enabled for this company.", - "missing_model": "No chat model is configured." + "text_generation_disabled": "Text generation is not enabled for this company.", + "missing_model": "No model is configured." } }, "address_information": { @@ -1895,6 +1896,18 @@ "send": "Send", "sending": "Sending…", "input_placeholder": "Ask about your invoices, customers, or payments…" + }, + "generate": { + "title": "AI Text Generation", + "prompt_label": "What should the AI write?", + "prompt_placeholder": "e.g. a polite late-payment reminder for an invoice that's 10 days overdue", + "use_current_as_context": "Use current content as context", + "use_context_help": "When enabled, the AI will see the editor's current text and can rewrite or extend it.", + "preview": "Preview", + "generate": "Generate", + "regenerate": "Regenerate", + "insert": "Insert", + "replace": "Replace" } } } diff --git a/resources/scripts/api/endpoints.ts b/resources/scripts/api/endpoints.ts index b22f0bb3..a9826053 100644 --- a/resources/scripts/api/endpoints.ts +++ b/resources/scripts/api/endpoints.ts @@ -131,6 +131,9 @@ export const API = { AI_CHAT: '/api/v1/ai/chat', AI_CONVERSATIONS: '/api/v1/ai/conversations', + // AI Text Generation (Phase 3) + AI_GENERATE: '/api/v1/ai/generate', + // PDF Configuration PDF_DRIVERS: '/api/v1/pdf/drivers', PDF_CONFIG: '/api/v1/pdf/config', diff --git a/resources/scripts/api/services/ai.service.ts b/resources/scripts/api/services/ai.service.ts index 41029f3d..8ba429e8 100644 --- a/resources/scripts/api/services/ai.service.ts +++ b/resources/scripts/api/services/ai.service.ts @@ -6,6 +6,8 @@ import type { AiConversationDetail, AiConversationSummary, AiDriversResponse, + AiGenerateRequest, + AiGenerateResponse, AiTestPayload, AiTestResponse, CompanyAiConfig, @@ -84,4 +86,11 @@ export const aiService = { const { data } = await client.delete(`${API.AI_CONVERSATIONS}/${id}`) return data }, + + // --- Phase 3: text generation --- + + async generateText(payload: AiGenerateRequest): Promise { + const { data } = await client.post(API.AI_GENERATE, payload) + return data + }, } diff --git a/resources/scripts/components/editor/RichEditor.vue b/resources/scripts/components/editor/RichEditor.vue index 94d031ba..925a5355 100644 --- a/resources/scripts/components/editor/RichEditor.vue +++ b/resources/scripts/components/editor/RichEditor.vue @@ -94,8 +94,11 @@ import { Bars3BottomRightIcon, Bars3Icon, LinkIcon, + SparklesIcon, } from '@heroicons/vue/24/solid' import { ContentPlaceholder, ContentPlaceholderBox } from '../layout' +import { useGlobalStore } from '@/scripts/stores/global.store' +import { useModalStore } from '@/scripts/stores/modal.store' interface EditorButton { name: string @@ -167,6 +170,34 @@ const editorButtons = ref([ }, ]) +// AI text-generation button — shown only when the feature is enabled +// for the current company. The flag is set once at bootstrap time so a +// one-shot push is fine; no reactivity needed. +const globalStore = useGlobalStore() +const modalStore = useModalStore() +if (globalStore.ai?.enabled && globalStore.ai?.text_generation_enabled) { + editorButtons.value.push({ + name: 'aiGenerate', + icon: markRaw(SparklesIcon) as Component, + action: () => { + modalStore.openModal({ + componentName: 'AiTextGenerationModal', + title: 'AI Text Generation', + size: 'md', + data: { + currentContent: editor.value?.getHTML() ?? '', + onInsert: (text: string) => { + editor.value?.chain().focus().insertContent(text).run() + }, + onReplace: (text: string) => { + editor.value?.chain().focus().selectAll().deleteSelection().insertContent(text).run() + }, + }, + }) + }, + }) +} + watch( () => props.modelValue, (newValue: string) => { diff --git a/resources/scripts/features/shared/ai/AiTextGenerationModal.vue b/resources/scripts/features/shared/ai/AiTextGenerationModal.vue new file mode 100644 index 00000000..3bd234df --- /dev/null +++ b/resources/scripts/features/shared/ai/AiTextGenerationModal.vue @@ -0,0 +1,194 @@ + + + diff --git a/resources/scripts/layouts/CompanyLayout.vue b/resources/scripts/layouts/CompanyLayout.vue index 166c8e70..94291c3c 100644 --- a/resources/scripts/layouts/CompanyLayout.vue +++ b/resources/scripts/layouts/CompanyLayout.vue @@ -25,6 +25,9 @@ + + + @@ -43,6 +46,7 @@ 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[] diff --git a/resources/scripts/types/ai-config.ts b/resources/scripts/types/ai-config.ts index efe7b671..3e0aa237 100644 --- a/resources/scripts/types/ai-config.ts +++ b/resources/scripts/types/ai-config.ts @@ -81,3 +81,16 @@ export interface AiConversationDetail { conversation: AiConversationSummary messages: AiChatMessage[] } + +// --- Phase 3: text generation types --- + +export interface AiGenerateRequest { + prompt: string + context?: string +} + +export interface AiGenerateResponse { + text?: string + error?: string + message?: string +} diff --git a/routes/api.php b/routes/api.php index 5644ec26..dbd742ac 100644 --- a/routes/api.php +++ b/routes/api.php @@ -18,6 +18,7 @@ use App\Http\Controllers\Admin\UsersController; use App\Http\Controllers\AppVersionController; use App\Http\Controllers\Company\Ai\ChatController as AiChatController; use App\Http\Controllers\Company\Ai\ConversationController as AiConversationController; +use App\Http\Controllers\Company\Ai\GenerationController as AiGenerationController; use App\Http\Controllers\Company\Auth\AuthController; use App\Http\Controllers\Company\Auth\ForgotPasswordController; use App\Http\Controllers\Company\Auth\InvitationRegistrationController; @@ -435,13 +436,15 @@ Route::prefix('/v1')->group(function () { Route::post('/company/ai/config', [CompanyAiConfigurationController::class, 'saveConfig']); Route::post('/company/ai/test', [CompanyAiConfigurationController::class, 'testConnection']); - // AI Chat — rate-limited via the 'ai' limiter defined in RouteServiceProvider + // AI Chat + text generation — rate-limited via the 'ai' limiter defined in RouteServiceProvider Route::middleware('throttle:ai')->group(function () { Route::post('/ai/chat', AiChatController::class); Route::get('/ai/conversations', [AiConversationController::class, 'index']); Route::get('/ai/conversations/{id}', [AiConversationController::class, 'show']); Route::patch('/ai/conversations/{id}', [AiConversationController::class, 'update']); Route::delete('/ai/conversations/{id}', [AiConversationController::class, 'destroy']); + + Route::post('/ai/generate', AiGenerationController::class); }); // PDF Generation diff --git a/tests/Feature/Ai/AiGenerationTest.php b/tests/Feature/Ai/AiGenerationTest.php new file mode 100644 index 00000000..34b29aeb --- /dev/null +++ b/tests/Feature/Ai/AiGenerationTest.php @@ -0,0 +1,144 @@ + 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'); +});