From 39781fa17747c8aac6f006751339bc5977790784 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20N=C3=A9meth?= Date: Wed, 18 Feb 2026 21:52:56 +0100 Subject: [PATCH] ## [1.2.0] - 2026-02-18 - AI provider configuration page for supervisors to manage API credentials, model selection, and generation parameters - AI usage log for supervisors showing request history, token consumption, and estimated costs - Secure credential storage and connection testing for AI service integration --- CHANGELOG.md | 50 +--- app/Filament/Pages/AiSettings.php | 273 ++++++++++++++++++ app/Filament/Resources/AiLogResource.php | 273 ++++++++++++++++++ .../AiLogResource/Pages/ListAiLogs.php | 19 ++ app/Models/AiLog.php | 112 +++++++ app/Models/AiSetting.php | 134 +++++++++ app/Providers/AppServiceProvider.php | 12 +- app/Services/Ai/AiProviderInterface.php | 35 +++ app/Services/Ai/AiProxyService.php | 189 ++++++++++++ app/Services/Ai/AiResponse.php | 143 +++++++++ .../Ai/Providers/AnthropicProvider.php | 170 +++++++++++ app/Services/Ai/Providers/OpenAiProvider.php | 153 ++++++++++ config/ai.php | 103 +++++++ config/ai_prompts.php | 84 ++++++ ..._02_18_000001_create_ai_settings_table.php | 37 +++ ...2026_02_18_000002_create_ai_logs_table.php | 44 +++ database/seeders/AiSettingSeeder.php | 84 ++++++ database/seeders/DatabaseSeeder.php | 1 + .../filament/pages/ai-settings.blade.php | 5 + 19 files changed, 1881 insertions(+), 40 deletions(-) create mode 100644 app/Filament/Pages/AiSettings.php create mode 100644 app/Filament/Resources/AiLogResource.php create mode 100644 app/Filament/Resources/AiLogResource/Pages/ListAiLogs.php create mode 100644 app/Models/AiLog.php create mode 100644 app/Models/AiSetting.php create mode 100644 app/Services/Ai/AiProviderInterface.php create mode 100644 app/Services/Ai/AiProxyService.php create mode 100644 app/Services/Ai/AiResponse.php create mode 100644 app/Services/Ai/Providers/AnthropicProvider.php create mode 100644 app/Services/Ai/Providers/OpenAiProvider.php create mode 100644 config/ai.php create mode 100644 config/ai_prompts.php create mode 100644 database/migrations/2026_02_18_000001_create_ai_settings_table.php create mode 100644 database/migrations/2026_02_18_000002_create_ai_logs_table.php create mode 100644 database/seeders/AiSettingSeeder.php create mode 100644 resources/views/filament/pages/ai-settings.blade.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 39b7571..4c3971c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,47 +2,19 @@ All notable changes to this project will be documented in this file. -## [1.1.0] - 2025-12-24 +## [1.2.0] - 2026-02-18 ### Added -- Public changelog page at /changelog displaying CHANGELOG.md with homepage design -- ChangelogController for parsing and displaying changelog entries -- config/app_config.php for centralized version management -- Changelog navigation link in main site header -- Version badge showing current version and release date -- Color-coded changelog sections (Added, Changed, Fixed, Removed) -- Card-based changelog design with card suit symbols +- AI provider configuration page for supervisors to manage API credentials, model selection, and generation parameters +- AI usage log for supervisors showing request history, token consumption, and estimated costs +- Secure credential storage and connection testing for AI service integration -### Changed -- Email templates now use Laravel mail layout with Cards Forge custom theme -- Email Templates and Scheduled Emails moved to System Settings navigation group -- Preview modal now displays emails with actual site header and footer styling -- Renamed "Run Now" to "Force Run" action with enhanced feedback showing sent/skipped counts -- Force Run action now explicitly shows scheduled time being bypassed in confirmation modal +## [1.1.0] - 2025-12-24 ### Added -- Custom Email Template System for managing email templates with Markdown support -- EmailTemplate model and database table for storing reusable email templates -- EmailTemplateResource in Filament admin panel (supervisor-only access) -- Markdown editor with live preview functionality for email content -- Variable injection system supporting {{ variable }} placeholders in subject and body -- TemplateEmail mailable class for sending template-based emails with custom HTML layout -- Preview modal for templates showing rendered content -- Available variables cheat sheet in template form -- Custom HTML email template with responsive design and inline CSS -- Feature documentation in dps/features/custom-email-templates.md -- Advanced Scheduled Email Dispatcher for automated email campaigns -- ScheduledEmail model with cron expression scheduling and multi-source data support -- EmailDispatchLog model for deduplication tracking -- Cron expression validation with human-friendly display -- Dynamic recipient targeting (all users, specific roles, individual selection) -- Order status filtering with configurable look-back windows -- ScheduledEmailResource with reactive UI based on data source -- ProcessScheduledEmails command for email dispatch with deduplication -- Automatic scheduling via Laravel task scheduler (runs every minute) -- Force Run action with detailed execution feedback (sent/skipped counts) -- Bulk Force Run action to execute multiple campaigns simultaneously -- Clear Dispatch Logs header action for resetting deduplication during testing -- Comprehensive execution statistics tracking -- Enhanced testing capabilities with immediate execution bypassing schedules -- Feature documentation in dps/features/advanced-scheduled-email-dispatcher.md +- Custom email template system with variable injection and live preview for supervisors +- Scheduled email dispatcher with flexible recipient targeting and automated delivery +- Public changelog page accessible from the site navigation + +### Changed +- Email notifications updated to use the Cards Forge branded layout diff --git a/app/Filament/Pages/AiSettings.php b/app/Filament/Pages/AiSettings.php new file mode 100644 index 0000000..1f3a724 --- /dev/null +++ b/app/Filament/Pages/AiSettings.php @@ -0,0 +1,273 @@ +check() && auth()->user()->isSupervisor(); + } + + /** + * Load current settings into the form. + */ + public function mount(): void + { + $this->form->fill([ + 'ai_provider' => AiSetting::get('ai_provider', 'openai'), + 'ai_api_key' => AiSetting::get('ai_api_key', ''), + 'ai_model' => AiSetting::get('ai_model', 'gpt-4o'), + 'ai_max_tokens' => AiSetting::get('ai_max_tokens', 2048), + 'ai_temperature' => AiSetting::get('ai_temperature', 0.7), + 'ai_system_prompt' => AiSetting::get('ai_system_prompt', config('ai_prompts.card_generation.system', '')), + ]); + } + + /** + * Define the settings form. + */ + public function form(Form $form): Form + { + return $form + ->schema([ + Section::make('Provider Configuration') + ->description('Configure the AI service provider and authentication.') + ->icon('heroicon-o-cloud') + ->schema([ + Select::make('ai_provider') + ->label('AI Provider') + ->options([ + 'openai' => 'OpenAI', + 'anthropic' => 'Anthropic (Claude)', + ]) + ->required() + ->live() + ->afterStateUpdated(function ($state, $set) { + // Reset model when provider changes + $defaultModels = [ + 'openai' => 'gpt-4o', + 'anthropic' => 'claude-sonnet-4-6', + ]; + $set('ai_model', $defaultModels[$state] ?? 'gpt-4o'); + }), + + TextInput::make('ai_api_key') + ->label('API Key') + ->password() + ->revealable() + ->placeholder('sk-... or sk-ant-...') + ->helperText('Stored encrypted in the database. Leave blank to keep existing key.') + ->maxLength(500), + + Select::make('ai_model') + ->label('Model') + ->options(function ($get) { + $provider = $get('ai_provider') ?? 'openai'; + return config("ai.providers.{$provider}.models", []); + }) + ->required() + ->searchable(), + ]) + ->columns(2), + + Section::make('Generation Parameters') + ->description('Control the behavior of AI-generated responses.') + ->icon('heroicon-o-adjustments-horizontal') + ->schema([ + TextInput::make('ai_max_tokens') + ->label('Max Tokens') + ->numeric() + ->minValue(100) + ->maxValue(8192) + ->default(2048) + ->helperText('Maximum number of tokens in the AI response (100-8192).'), + + TextInput::make('ai_temperature') + ->label('Temperature') + ->numeric() + ->minValue(0) + ->maxValue(2) + ->step(0.1) + ->default(0.7) + ->helperText('Controls randomness: 0 = deterministic, 2 = very random. Recommended: 0.7'), + ]) + ->columns(2), + + Section::make('System Prompt') + ->description('The default system prompt for card generation. Defines the AI\'s role and output format.') + ->icon('heroicon-o-document-text') + ->schema([ + Textarea::make('ai_system_prompt') + ->label('Card Generation System Prompt') + ->rows(15) + ->helperText('This prompt is sent with every card generation request. It defines the AI\'s domain knowledge and required JSON output format.') + ->columnSpanFull(), + ]), + ]) + ->statePath('data'); + } + + /** + * Save the settings to the database. + */ + public function save(): void + { + $data = $this->form->getState(); + + // Only update API key if a new one was provided + if (!empty($data['ai_api_key'])) { + $this->saveEncryptedSetting('ai_api_key', $data['ai_api_key']); + } + + $this->saveSetting('ai_provider', $data['ai_provider'], 'text', 'provider', 'AI Provider', 1); + $this->saveSetting('ai_model', $data['ai_model'], 'text', 'provider', 'AI Model', 2); + $this->saveSetting('ai_max_tokens', $data['ai_max_tokens'], 'number', 'parameters', 'Max Tokens', 1); + $this->saveSetting('ai_temperature', $data['ai_temperature'], 'number', 'parameters', 'Temperature', 2); + $this->saveSetting('ai_system_prompt', $data['ai_system_prompt'], 'textarea', 'prompts', 'System Prompt', 1); + + Notification::make() + ->title('Settings saved successfully.') + ->success() + ->send(); + } + + /** + * Test the current AI provider connection. + */ + public function testConnection(): void + { + // Save current form state first before testing + $data = $this->form->getState(); + if (!empty($data['ai_api_key'])) { + $this->saveEncryptedSetting('ai_api_key', $data['ai_api_key']); + } + $this->saveSetting('ai_provider', $data['ai_provider'], 'text', 'provider', 'AI Provider', 1); + $this->saveSetting('ai_model', $data['ai_model'], 'text', 'provider', 'AI Model', 2); + + try { + $service = new AiProxyService(); + $result = $service->testConnection(); + + if ($result['success']) { + Notification::make() + ->title('Connection successful!') + ->body('The AI provider responded correctly.') + ->success() + ->send(); + } elseif ($result['status'] === 'rate_limited') { + Notification::make() + ->title('API key is valid (rate limited)') + ->body($result['message']) + ->warning() + ->send(); + } else { + Notification::make() + ->title('Connection failed') + ->body($result['message']) + ->danger() + ->send(); + } + } catch (\Exception $e) { + Notification::make() + ->title('Connection error') + ->body($e->getMessage()) + ->danger() + ->send(); + } + } + + /** + * Get the header actions (Save + Test Connection buttons). + */ + protected function getHeaderActions(): array + { + return [ + Action::make('testConnection') + ->label('Test Connection') + ->icon('heroicon-o-signal') + ->color('gray') + ->action('testConnection'), + + Action::make('save') + ->label('Save Settings') + ->icon('heroicon-o-check') + ->color('primary') + ->action('save'), + ]; + } + + /** + * Save a plain-text setting. + */ + private function saveSetting(string $key, mixed $value, string $type, string $group, string $label, int $order): void + { + AiSetting::updateOrCreate( + ['key' => $key], + [ + 'value' => $value, + 'type' => $type, + 'group' => $group, + 'label' => $label, + 'order' => $order, + ] + ); + } + + /** + * Save an encrypted setting. + */ + private function saveEncryptedSetting(string $key, string $value): void + { + $existing = AiSetting::where('key', $key)->first(); + + if ($existing) { + // Directly update encrypted value using the mutator + $existing->type = 'encrypted'; + $existing->value = $value; + $existing->save(); + } else { + AiSetting::create([ + 'key' => $key, + 'value' => $value, + 'type' => 'encrypted', + 'group' => 'provider', + 'label' => 'API Key', + 'order' => 3, + ]); + } + } +} diff --git a/app/Filament/Resources/AiLogResource.php b/app/Filament/Resources/AiLogResource.php new file mode 100644 index 0000000..9ab37c2 --- /dev/null +++ b/app/Filament/Resources/AiLogResource.php @@ -0,0 +1,273 @@ +check() && auth()->user()->isSupervisor(); + } + + public static function canCreate(): bool + { + return false; + } + + public static function canEdit($record): bool + { + return false; + } + + public static function canDelete($record): bool + { + return false; + } + + /** + * Navigation badge showing total log count. + */ + public static function getNavigationBadge(): ?string + { + return static::getModel()::count(); + } + + public static function form(Form $form): Form + { + return $form->schema([]); + } + + public static function infolist(Infolist $infolist): Infolist + { + return $infolist + ->schema([ + Section::make('Request Details') + ->schema([ + TextEntry::make('user.name') + ->label('User') + ->default('System'), + TextEntry::make('provider') + ->label('Provider') + ->badge() + ->color(fn (string $state): string => match ($state) { + 'openai' => 'success', + 'anthropic' => 'info', + default => 'gray', + }), + TextEntry::make('model') + ->label('Model'), + TextEntry::make('status') + ->label('Status') + ->badge() + ->color(fn (string $state): string => match ($state) { + 'success' => 'success', + 'error' => 'danger', + 'rate_limited' => 'warning', + default => 'gray', + }), + TextEntry::make('created_at') + ->label('Timestamp') + ->dateTime(), + ]) + ->columns(3), + + Section::make('Token Usage & Performance') + ->schema([ + TextEntry::make('prompt_tokens') + ->label('Prompt Tokens') + ->numeric(), + TextEntry::make('completion_tokens') + ->label('Completion Tokens') + ->numeric(), + TextEntry::make('total_tokens') + ->label('Total Tokens') + ->numeric(), + TextEntry::make('duration_ms') + ->label('Duration (ms)') + ->numeric(), + TextEntry::make('cost_estimate') + ->label('Est. Cost (USD)') + ->money('USD') + ->default('N/A'), + ]) + ->columns(3), + + Section::make('Prompts') + ->schema([ + TextEntry::make('system_prompt') + ->label('System Prompt') + ->prose() + ->columnSpanFull(), + TextEntry::make('user_prompt') + ->label('User Prompt') + ->prose() + ->columnSpanFull(), + ]), + + Section::make('Response') + ->schema([ + TextEntry::make('response') + ->label('AI Response') + ->prose() + ->columnSpanFull() + ->default('No response (error occurred)'), + TextEntry::make('error_message') + ->label('Error Message') + ->visible(fn ($record) => !empty($record->error_message)) + ->color('danger') + ->columnSpanFull(), + ]), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('id') + ->label('#') + ->sortable(), + + Tables\Columns\TextColumn::make('user.name') + ->label('User') + ->searchable() + ->sortable() + ->default('System'), + + Tables\Columns\TextColumn::make('provider') + ->label('Provider') + ->badge() + ->color(fn (string $state): string => match ($state) { + 'openai' => 'success', + 'anthropic' => 'info', + default => 'gray', + }) + ->sortable(), + + Tables\Columns\TextColumn::make('model') + ->label('Model') + ->searchable() + ->sortable(), + + Tables\Columns\TextColumn::make('status') + ->label('Status') + ->badge() + ->color(fn (string $state): string => match ($state) { + 'success' => 'success', + 'error' => 'danger', + 'rate_limited' => 'warning', + default => 'gray', + }) + ->sortable(), + + Tables\Columns\TextColumn::make('total_tokens') + ->label('Tokens') + ->numeric() + ->sortable(), + + Tables\Columns\TextColumn::make('cost_estimate') + ->label('Est. Cost') + ->money('USD') + ->sortable() + ->default('—'), + + Tables\Columns\TextColumn::make('duration_ms') + ->label('Duration') + ->formatStateUsing(fn ($state) => $state < 1000 ? "{$state}ms" : round($state / 1000, 2) . 's') + ->sortable(), + + Tables\Columns\TextColumn::make('created_at') + ->label('Date & Time') + ->dateTime() + ->sortable(), + ]) + ->filters([ + Tables\Filters\SelectFilter::make('status') + ->options([ + 'success' => 'Success', + 'error' => 'Error', + 'rate_limited' => 'Rate Limited', + ]), + + Tables\Filters\SelectFilter::make('provider') + ->options([ + 'openai' => 'OpenAI', + 'anthropic' => 'Anthropic', + ]), + + Tables\Filters\SelectFilter::make('user') + ->relationship('user', 'name') + ->label('User'), + + Tables\Filters\Filter::make('created_at') + ->form([ + Forms\Components\DatePicker::make('created_from') + ->label('From'), + Forms\Components\DatePicker::make('created_until') + ->label('Until'), + ]) + ->query(function (Builder $query, array $data): Builder { + return $query + ->when( + $data['created_from'], + fn (Builder $query, $date): Builder => $query->whereDate('created_at', '>=', $date), + ) + ->when( + $data['created_until'], + fn (Builder $query, $date): Builder => $query->whereDate('created_at', '<=', $date), + ); + }), + ]) + ->actions([ + Tables\Actions\ViewAction::make() + ->slideOver() + ->modalWidth('4xl'), + ]) + ->defaultSort('created_at', 'desc') + ->poll('60s'); + } + + public static function getRelations(): array + { + return []; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListAiLogs::route('/'), + ]; + } +} diff --git a/app/Filament/Resources/AiLogResource/Pages/ListAiLogs.php b/app/Filament/Resources/AiLogResource/Pages/ListAiLogs.php new file mode 100644 index 0000000..519da42 --- /dev/null +++ b/app/Filament/Resources/AiLogResource/Pages/ListAiLogs.php @@ -0,0 +1,19 @@ + 'integer', + 'completion_tokens' => 'integer', + 'total_tokens' => 'integer', + 'duration_ms' => 'integer', + 'cost_estimate' => 'decimal:6', + 'metadata' => 'array', + ]; + + /** + * Relationship: belongs to a user + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * Scope: successful requests + */ + public function scopeSuccessful(Builder $query): Builder + { + return $query->where('status', 'success'); + } + + /** + * Scope: failed requests + */ + public function scopeFailed(Builder $query): Builder + { + return $query->where('status', 'error'); + } + + /** + * Scope: rate-limited requests + */ + public function scopeRateLimited(Builder $query): Builder + { + return $query->where('status', 'rate_limited'); + } + + /** + * Scope: filter by provider + */ + public function scopeByProvider(Builder $query, string $provider): Builder + { + return $query->where('provider', $provider); + } + + /** + * Scope: filter by model + */ + public function scopeByModel(Builder $query, string $model): Builder + { + return $query->where('model', $model); + } + + /** + * Accessor: duration in seconds formatted + */ + public function getDurationFormattedAttribute(): string + { + if ($this->duration_ms < 1000) { + return $this->duration_ms . 'ms'; + } + + return round($this->duration_ms / 1000, 2) . 's'; + } + + /** + * Accessor: status color for Filament badge + */ + public function getStatusColorAttribute(): string + { + return match ($this->status) { + 'success' => 'success', + 'error' => 'danger', + 'rate_limited' => 'warning', + default => 'gray', + }; + } +} diff --git a/app/Models/AiSetting.php b/app/Models/AiSetting.php new file mode 100644 index 0000000..dcde3b5 --- /dev/null +++ b/app/Models/AiSetting.php @@ -0,0 +1,134 @@ + 'integer', + ]; + + /** + * Get the value attribute, handling type-specific conversions + */ + public function getValueAttribute($value): mixed + { + if (empty($value)) { + return $value; + } + + $type = $this->attributes['type'] ?? 'text'; + + if ($type === 'encrypted') { + try { + return Crypt::decryptString($value); + } catch (\Exception) { + return $value; + } + } + + if ($type === 'number') { + return is_numeric($value) ? (float) $value : $value; + } + + return $value; + } + + /** + * Set the value attribute, handling encryption + */ + public function setValueAttribute($value): void + { + $type = $this->attributes['type'] ?? 'text'; + + if ($type === 'encrypted' && !empty($value)) { + $this->attributes['value'] = Crypt::encryptString($value); + } else { + $this->attributes['value'] = $value; + } + } + + /** + * Boot the model + */ + protected static function booted(): void + { + static::saved(function () { + Cache::forget('ai_settings'); + }); + + static::deleted(function () { + Cache::forget('ai_settings'); + }); + } + + /** + * Get a setting value by key (reads raw, non-decrypted value from cache) + */ + public static function get(string $key, mixed $default = null): mixed + { + $instance = static::where('key', $key)->first(); + + if (!$instance) { + return $default; + } + + // Use the model accessor which handles decryption + return $instance->value ?? $default; + } + + /** + * Set a setting value + */ + public static function set(string $key, mixed $value): void + { + $existing = static::where('key', $key)->first(); + + if ($existing) { + // Update using the mutator + $existing->value = $value; + $existing->save(); + } else { + static::create(['key' => $key, 'value' => $value]); + } + + Cache::forget('ai_settings'); + } + + /** + * Get all settings as key-value array (raw values, not decrypted) + */ + public static function getAllGrouped(): array + { + return static::orderBy('group') + ->orderBy('order') + ->get() + ->groupBy('group') + ->toArray(); + } + + /** + * Check if a setting exists + */ + public static function has(string $key): bool + { + return static::where('key', $key)->exists(); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 1ba1d6b..436eb16 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -4,6 +4,8 @@ use App\Models\PhysicalCard; use App\Observers\PhysicalCardObserver; +use App\Services\Ai\AiProxyService; +use App\Services\Ai\AiProviderInterface; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -13,7 +15,15 @@ class AppServiceProvider extends ServiceProvider */ public function register(): void { - // + // Register the AI Proxy Service as a singleton + $this->app->singleton(AiProxyService::class, function ($app) { + return new AiProxyService(); + }); + + // Bind the interface to the resolved provider via the proxy service + $this->app->bind(AiProviderInterface::class, function ($app) { + return $app->make(AiProxyService::class)->getProvider(); + }); } /** diff --git a/app/Services/Ai/AiProviderInterface.php b/app/Services/Ai/AiProviderInterface.php new file mode 100644 index 0000000..0d3f53f --- /dev/null +++ b/app/Services/Ai/AiProviderInterface.php @@ -0,0 +1,35 @@ + key-value: model_id => model_label + */ + public function getAvailableModels(): array; + + /** + * Get the provider identifier string. + */ + public function getProviderName(): string; +} diff --git a/app/Services/Ai/AiProxyService.php b/app/Services/Ai/AiProxyService.php new file mode 100644 index 0000000..c76bad2 --- /dev/null +++ b/app/Services/Ai/AiProxyService.php @@ -0,0 +1,189 @@ +retryAttempts = (int) config('ai.retry_attempts', 3); + $this->retryDelayMs = (int) config('ai.retry_delay_ms', 1000); + $this->provider = $this->resolveProvider(); + } + + /** + * Send a chat request through the configured AI provider. + * Handles retry logic, logging, and error normalization. + */ + public function chat(string $systemPrompt, string $userPrompt, array $options = []): AiResponse + { + $attempt = 0; + $lastResponse = null; + + while ($attempt < $this->retryAttempts) { + $attempt++; + + $response = $this->provider->chat($systemPrompt, $userPrompt, $options); + $lastResponse = $response; + + // Log the request + $this->logRequest($systemPrompt, $userPrompt, $response); + + if ($response->isSuccess) { + return $response; + } + + // Retry on rate limit with exponential backoff + if ($response->status === 'rate_limited' && $attempt < $this->retryAttempts) { + $delay = $this->retryDelayMs * (2 ** ($attempt - 1)); // exponential: 1s, 2s, 4s + Log::warning("AI rate limited, retrying in {$delay}ms (attempt {$attempt}/{$this->retryAttempts})"); + usleep($delay * 1000); + continue; + } + + // Don't retry on other errors + break; + } + + return $lastResponse; + } + + /** + * Test the current provider's connection. + * Returns a result array with 'success' bool and 'message' string. + * + * @return array{success: bool, message: string, status: string} + */ + public function testConnection(): array + { + $testPrompts = config('ai_prompts.connection_test'); + + $response = $this->chat( + systemPrompt: $testPrompts['system'], + userPrompt: $testPrompts['user'], + options: ['max_tokens' => 50, 'json_mode' => false], + ); + + if ($response->isSuccess) { + return ['success' => true, 'message' => 'Connection successful.', 'status' => 'success']; + } + + if ($response->status === 'rate_limited') { + return ['success' => false, 'message' => 'API key is valid but rate limit reached. Try again in a moment.', 'status' => 'rate_limited']; + } + + return ['success' => false, 'message' => $response->errorMessage ?? 'Unknown error.', 'status' => 'error']; + } + + /** + * Get the current provider instance. + */ + public function getProvider(): AiProviderInterface + { + return $this->provider; + } + + /** + * Get available models for the current provider. + */ + public function getAvailableModels(): array + { + return $this->provider->getAvailableModels(); + } + + /** + * Resolve the AI provider from settings or config. + */ + private function resolveProvider(): AiProviderInterface + { + $providerName = AiSetting::get('ai_provider') ?? config('ai.default_provider', 'openai'); + $apiKey = AiSetting::get('ai_api_key') ?? ''; + $model = AiSetting::get('ai_model') ?? null; + $maxTokens = (int) (AiSetting::get('ai_max_tokens') ?? config('ai.default_max_tokens', 2048)); + $temperature = (float) (AiSetting::get('ai_temperature') ?? config('ai.default_temperature', 0.7)); + + return match ($providerName) { + 'anthropic' => new AnthropicProvider( + apiKey: $apiKey, + model: $model ?? config('ai.providers.anthropic.default_model', 'claude-sonnet-4-6'), + maxTokens: $maxTokens, + temperature: $temperature, + ), + default => new OpenAiProvider( + apiKey: $apiKey, + model: $model ?? config('ai.providers.openai.default_model', 'gpt-4o'), + maxTokens: $maxTokens, + temperature: $temperature, + ), + }; + } + + /** + * Log the AI request to the database. + */ + private function logRequest(string $systemPrompt, string $userPrompt, AiResponse $response): void + { + if (!config('ai.log_requests', true)) { + return; + } + + try { + $costEstimate = $this->calculateCost( + provider: $response->provider, + model: $response->model, + promptTokens: $response->promptTokens, + completionTokens: $response->completionTokens, + ); + + AiLog::create([ + 'user_id' => Auth::id(), + 'provider' => $response->provider, + 'model' => $response->model, + 'prompt_tokens' => $response->promptTokens, + 'completion_tokens' => $response->completionTokens, + 'total_tokens' => $response->totalTokens, + 'system_prompt' => $systemPrompt, + 'user_prompt' => $userPrompt, + 'response' => $response->isSuccess ? $response->content : null, + 'status' => $response->status, + 'error_message' => $response->errorMessage, + 'duration_ms' => $response->durationMs, + 'cost_estimate' => $costEstimate, + ]); + } catch (\Exception $e) { + Log::error('Failed to log AI request', ['error' => $e->getMessage()]); + } + } + + /** + * Calculate estimated cost based on token usage. + */ + private function calculateCost(string $provider, string $model, int $promptTokens, int $completionTokens): ?float + { + $inputCosts = config("ai.providers.{$provider}.cost_per_1k_input", []); + $outputCosts = config("ai.providers.{$provider}.cost_per_1k_output", []); + + if (!isset($inputCosts[$model]) || !isset($outputCosts[$model])) { + return null; + } + + $inputCost = ($promptTokens / 1000) * $inputCosts[$model]; + $outputCost = ($completionTokens / 1000) * $outputCosts[$model]; + + return round($inputCost + $outputCost, 6); + } +} diff --git a/app/Services/Ai/AiResponse.php b/app/Services/Ai/AiResponse.php new file mode 100644 index 0000000..60fdc61 --- /dev/null +++ b/app/Services/Ai/AiResponse.php @@ -0,0 +1,143 @@ + $this->content, + 'prompt_tokens' => $this->promptTokens, + 'completion_tokens' => $this->completionTokens, + 'total_tokens' => $this->totalTokens, + 'model' => $this->model, + 'provider' => $this->provider, + 'duration_ms' => $this->durationMs, + 'is_success' => $this->isSuccess, + 'error_message' => $this->errorMessage, + 'status' => $this->status, + ]; + } + + /** + * Parse content as JSON and return as array. + * Returns null if content is not valid JSON. + */ + public function toJsonArray(): ?array + { + if (empty($this->content)) { + return null; + } + + $decoded = json_decode($this->content, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + return null; + } + + return $decoded; + } + + /** + * Parse content as card data (for card generation responses). + * Returns structured card data or null if parsing fails. + */ + public function toCardData(): ?array + { + $data = $this->toJsonArray(); + + if (!$data) { + return null; + } + + // Validate expected card fields + $required = ['name', 'description', 'rarity']; + foreach ($required as $field) { + if (!isset($data[$field])) { + return null; + } + } + + return $data; + } + + /** + * Get a masked/truncated version of content for display + */ + public function getContentPreview(int $length = 200): string + { + if (strlen($this->content) <= $length) { + return $this->content; + } + + return substr($this->content, 0, $length) . '...'; + } +} diff --git a/app/Services/Ai/Providers/AnthropicProvider.php b/app/Services/Ai/Providers/AnthropicProvider.php new file mode 100644 index 0000000..9d7a944 --- /dev/null +++ b/app/Services/Ai/Providers/AnthropicProvider.php @@ -0,0 +1,170 @@ +apiKey = $apiKey; + $this->model = $model; + $this->baseUrl = config('ai.providers.anthropic.base_url', 'https://api.anthropic.com/v1'); + $this->maxTokens = $maxTokens; + $this->temperature = $temperature; + $this->timeoutSeconds = config('ai.timeout_seconds', 60); + } + + /** + * Send a chat message to Anthropic Claude + */ + public function chat(string $systemPrompt, string $userPrompt, array $options = []): AiResponse + { + $startTime = microtime(true); + $model = $options['model'] ?? $this->model; + $maxTokens = $options['max_tokens'] ?? $this->maxTokens; + $temperature = $options['temperature'] ?? $this->temperature; + $jsonMode = $options['json_mode'] ?? true; + + // Anthropic uses system prompt at top level, not in messages array + // For JSON mode, append instruction to system prompt + $finalSystemPrompt = $systemPrompt; + if ($jsonMode) { + $finalSystemPrompt .= "\n\nIMPORTANT: Your response must be ONLY a valid JSON object. No markdown, no code blocks, no explanations outside the JSON."; + } + + $payload = [ + 'model' => $model, + 'max_tokens' => $maxTokens, + 'temperature' => $temperature, + 'system' => $finalSystemPrompt, + 'messages' => [ + ['role' => 'user', 'content' => $userPrompt], + ], + ]; + + try { + $client = HttpClient::create(); + $response = $client->request('POST', $this->baseUrl . '/messages', [ + 'headers' => [ + 'x-api-key' => $this->apiKey, + 'anthropic-version' => self::API_VERSION, + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + 'timeout' => $this->timeoutSeconds, + ]); + + $statusCode = $response->getStatusCode(); + $durationMs = (int) ((microtime(true) - $startTime) * 1000); + + if ($statusCode === 429) { + return AiResponse::error( + errorMessage: 'Rate limit exceeded. Please try again later.', + model: $model, + provider: $this->getProviderName(), + durationMs: $durationMs, + status: 'rate_limited', + ); + } + + if ($statusCode !== 200) { + $body = $response->toArray(throw: false); + $errorMsg = $body['error']['message'] ?? "HTTP {$statusCode} error"; + + return AiResponse::error( + errorMessage: $errorMsg, + model: $model, + provider: $this->getProviderName(), + durationMs: $durationMs, + ); + } + + $body = $response->toArray(); + + // Anthropic response structure differs from OpenAI + $content = ''; + if (isset($body['content']) && is_array($body['content'])) { + foreach ($body['content'] as $block) { + if ($block['type'] === 'text') { + $content .= $block['text']; + } + } + } + + $usage = $body['usage'] ?? []; + + return AiResponse::success( + content: trim($content), + promptTokens: $usage['input_tokens'] ?? 0, + completionTokens: $usage['output_tokens'] ?? 0, + model: $model, + provider: $this->getProviderName(), + durationMs: $durationMs, + ); + + } catch (\Exception $e) { + $durationMs = (int) ((microtime(true) - $startTime) * 1000); + Log::error('Anthropic API error', ['error' => $e->getMessage()]); + + return AiResponse::error( + errorMessage: $e->getMessage(), + model: $model, + provider: $this->getProviderName(), + durationMs: $durationMs, + ); + } + } + + /** + * Test the connection with a minimal request + */ + public function testConnection(): bool + { + $testPrompts = config('ai_prompts.connection_test'); + $response = $this->chat( + systemPrompt: $testPrompts['system'], + userPrompt: $testPrompts['user'], + options: ['max_tokens' => 50, 'json_mode' => false], + ); + + return $response->isSuccess; + } + + /** + * Get available models + */ + public function getAvailableModels(): array + { + return config('ai.providers.anthropic.models', []); + } + + /** + * Get provider name + */ + public function getProviderName(): string + { + return 'anthropic'; + } +} diff --git a/app/Services/Ai/Providers/OpenAiProvider.php b/app/Services/Ai/Providers/OpenAiProvider.php new file mode 100644 index 0000000..9e80d1b --- /dev/null +++ b/app/Services/Ai/Providers/OpenAiProvider.php @@ -0,0 +1,153 @@ +apiKey = $apiKey; + $this->model = $model; + $this->baseUrl = config('ai.providers.openai.base_url', 'https://api.openai.com/v1'); + $this->maxTokens = $maxTokens; + $this->temperature = $temperature; + $this->timeoutSeconds = config('ai.timeout_seconds', 60); + } + + /** + * Send a chat message to OpenAI + */ + public function chat(string $systemPrompt, string $userPrompt, array $options = []): AiResponse + { + $startTime = microtime(true); + $model = $options['model'] ?? $this->model; + $maxTokens = $options['max_tokens'] ?? $this->maxTokens; + $temperature = $options['temperature'] ?? $this->temperature; + $jsonMode = $options['json_mode'] ?? true; + + $payload = [ + 'model' => $model, + 'messages' => [ + ['role' => 'system', 'content' => $systemPrompt], + ['role' => 'user', 'content' => $userPrompt], + ], + 'max_tokens' => $maxTokens, + 'temperature' => $temperature, + ]; + + if ($jsonMode) { + $payload['response_format'] = ['type' => 'json_object']; + } + + try { + $client = HttpClient::create(); + $response = $client->request('POST', $this->baseUrl . '/chat/completions', [ + 'headers' => [ + 'Authorization' => 'Bearer ' . $this->apiKey, + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + 'timeout' => $this->timeoutSeconds, + ]); + + $statusCode = $response->getStatusCode(); + $durationMs = (int) ((microtime(true) - $startTime) * 1000); + + if ($statusCode === 429) { + return AiResponse::error( + errorMessage: 'Rate limit exceeded. Please try again later.', + model: $model, + provider: $this->getProviderName(), + durationMs: $durationMs, + status: 'rate_limited', + ); + } + + if ($statusCode !== 200) { + $body = $response->toArray(throw: false); + $errorMsg = $body['error']['message'] ?? "HTTP {$statusCode} error"; + + return AiResponse::error( + errorMessage: $errorMsg, + model: $model, + provider: $this->getProviderName(), + durationMs: $durationMs, + ); + } + + $body = $response->toArray(); + $content = $body['choices'][0]['message']['content'] ?? ''; + $usage = $body['usage'] ?? []; + + return AiResponse::success( + content: $content, + promptTokens: $usage['prompt_tokens'] ?? 0, + completionTokens: $usage['completion_tokens'] ?? 0, + model: $model, + provider: $this->getProviderName(), + durationMs: $durationMs, + ); + + } catch (\Exception $e) { + $durationMs = (int) ((microtime(true) - $startTime) * 1000); + Log::error('OpenAI API error', ['error' => $e->getMessage()]); + + return AiResponse::error( + errorMessage: $e->getMessage(), + model: $model, + provider: $this->getProviderName(), + durationMs: $durationMs, + ); + } + } + + /** + * Test the connection with a minimal request + */ + public function testConnection(): bool + { + $testPrompts = config('ai_prompts.connection_test'); + $response = $this->chat( + systemPrompt: $testPrompts['system'], + userPrompt: $testPrompts['user'], + options: ['max_tokens' => 50, 'json_mode' => false], + ); + + return $response->isSuccess; + } + + /** + * Get available models + */ + public function getAvailableModels(): array + { + return config('ai.providers.openai.models', []); + } + + /** + * Get provider name + */ + public function getProviderName(): string + { + return 'openai'; + } +} diff --git a/config/ai.php b/config/ai.php new file mode 100644 index 0000000..0251611 --- /dev/null +++ b/config/ai.php @@ -0,0 +1,103 @@ + AI Management > AI Settings + */ + +return [ + + /* + |-------------------------------------------------------------------------- + | Default AI Provider + |-------------------------------------------------------------------------- + | + | The default AI provider to use. Can be overridden via AiSetting model. + | Supported: "openai", "anthropic" + | + */ + 'default_provider' => env('AI_PROVIDER', 'openai'), + + /* + |-------------------------------------------------------------------------- + | Request Settings + |-------------------------------------------------------------------------- + */ + 'retry_attempts' => env('AI_RETRY_ATTEMPTS', 3), + 'retry_delay_ms' => env('AI_RETRY_DELAY_MS', 1000), + 'timeout_seconds' => env('AI_TIMEOUT_SECONDS', 60), + + /* + |-------------------------------------------------------------------------- + | Default Parameters + |-------------------------------------------------------------------------- + */ + 'default_max_tokens' => env('AI_MAX_TOKENS', 2048), + 'default_temperature' => env('AI_TEMPERATURE', 0.7), + + /* + |-------------------------------------------------------------------------- + | Providers Configuration + |-------------------------------------------------------------------------- + */ + 'providers' => [ + + 'openai' => [ + 'api_key_env' => 'OPENAI_API_KEY', + 'base_url' => 'https://api.openai.com/v1', + 'default_model' => 'gpt-4o', + 'models' => [ + 'gpt-4o' => 'GPT-4o', + 'gpt-4o-mini' => 'GPT-4o Mini', + 'gpt-4-turbo' => 'GPT-4 Turbo', + 'gpt-3.5-turbo' => 'GPT-3.5 Turbo', + ], + // Cost per 1k tokens in USD (approximate) + 'cost_per_1k_input' => [ + 'gpt-4o' => 0.0025, + 'gpt-4o-mini' => 0.00015, + 'gpt-4-turbo' => 0.01, + 'gpt-3.5-turbo' => 0.0005, + ], + 'cost_per_1k_output' => [ + 'gpt-4o' => 0.01, + 'gpt-4o-mini' => 0.0006, + 'gpt-4-turbo' => 0.03, + 'gpt-3.5-turbo' => 0.0015, + ], + ], + + 'anthropic' => [ + 'api_key_env' => 'ANTHROPIC_API_KEY', + 'base_url' => 'https://api.anthropic.com/v1', + 'default_model' => 'claude-opus-4-6', + 'models' => [ + 'claude-opus-4-6' => 'Claude Opus 4.6', + 'claude-sonnet-4-6' => 'Claude Sonnet 4.6', + 'claude-haiku-4-5-20251001' => 'Claude Haiku 4.5', + ], + // Cost per 1k tokens in USD (approximate) + 'cost_per_1k_input' => [ + 'claude-opus-4-6' => 0.015, + 'claude-sonnet-4-6' => 0.003, + 'claude-haiku-4-5-20251001' => 0.00025, + ], + 'cost_per_1k_output' => [ + 'claude-opus-4-6' => 0.075, + 'claude-sonnet-4-6' => 0.015, + 'claude-haiku-4-5-20251001' => 0.00125, + ], + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Logging + |-------------------------------------------------------------------------- + */ + 'log_requests' => env('AI_LOG_REQUESTS', true), + 'log_cleanup_days' => env('AI_LOG_CLEANUP_DAYS', 30), + +]; diff --git a/config/ai_prompts.php b/config/ai_prompts.php new file mode 100644 index 0000000..09f92f1 --- /dev/null +++ b/config/ai_prompts.php @@ -0,0 +1,84 @@ + [ + 'version' => '1.0.0', + + 'system' => <<<'PROMPT' +Te egy senior TCG (Trading Card Game) tervező és matematikai egyensúly-szakértő vagy. A feladatod új elemek tervezése a Cards Forge ökoszisztémába. Ismered a kártyajátékok mechanikáit (pl. Magic: The Gathering, Hearthstone), de szigorúan a megadott kártyatípusok keretein belül maradsz. + +## Cards Forge Domain Knowledge + +**Game**: A fő keretrendszer, amely meghatározza a világot és az alapszabályokat. +**Card Type**: Meghatározza a kártya szerepét (pl. Spell, Creature, Artifact). +**Hexas**: Hatlapú mezők, amelyek a játéktér topológiáját és speciális területi hatásokat kezelik. +**Figures**: Fizikai egységek a játéktéren, amelyek pozícióval és mozgási statisztikával rendelkeznek. + +## Generálási Szabályok + +**Konzisztencia**: Ha a játék "Dark Fantasy" stílusú, ne generálj "Sci-fi" képességeket. +**Egyensúly**: Az értékek (Power, Toughness, Mana Cost) legyenek arányosak. Egy alacsony költségű kártya ne legyen túl erős. +**Nyelv**: Mindig a kért nyelven válaszolj (alapértelmezett: Magyar). +**Stílus**: A "Lore Text" legyen hangulatos, rövid és dőlt betűs stílusú. + +## Kötelező JSON Kimenet + +A válaszod kizárólag egy valid JSON objektum lehet, markdown kódblokkok nélkül, az alábbi struktúrában: + +{ + "name": "Kártya neve", + "description": "Képesség leírása szakmai nyelven", + "lore_text": "Rövid hangulati szöveg", + "stats": { + "cost": 0, + "power": 0, + "toughness": 0, + "movement": 0 + }, + "rarity": "Common/Uncommon/Rare/Legendary", + "ai_logic_note": "Rövid magyarázat a tervezői döntésről (csak adminnak)" +} +PROMPT, + + // User prompt template - replace placeholders at runtime + 'user_template' => <<<'TEMPLATE' +Generálj egy új kártyát a következő paraméterekkel: + +**Játék**: {game_name} +**Típus**: {card_type} +**Cél**: {generation_goal} +**Stílus/Téma**: {game_style} +{existing_cards_context} +TEMPLATE, + ], + + /* + |-------------------------------------------------------------------------- + | Connection Test Prompt + |-------------------------------------------------------------------------- + | + | Minimal prompt used to verify API connection is working + | + */ + 'connection_test' => [ + 'system' => 'You are a helpful assistant. Respond only with valid JSON.', + 'user' => 'Respond with exactly this JSON: {"status": "ok", "message": "Connection successful"}', + ], + +]; diff --git a/database/migrations/2026_02_18_000001_create_ai_settings_table.php b/database/migrations/2026_02_18_000001_create_ai_settings_table.php new file mode 100644 index 0000000..901028f --- /dev/null +++ b/database/migrations/2026_02_18_000001_create_ai_settings_table.php @@ -0,0 +1,37 @@ +id(); + $table->string('key')->unique(); + $table->text('value')->nullable(); + $table->string('type')->default('text'); // text, encrypted, number, select, textarea + $table->string('group')->default('general'); // general, provider, parameters, prompts + $table->string('label'); + $table->text('description')->nullable(); + $table->integer('order')->default(0); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('ai_settings'); + } +}; diff --git a/database/migrations/2026_02_18_000002_create_ai_logs_table.php b/database/migrations/2026_02_18_000002_create_ai_logs_table.php new file mode 100644 index 0000000..b9d61ae --- /dev/null +++ b/database/migrations/2026_02_18_000002_create_ai_logs_table.php @@ -0,0 +1,44 @@ +id(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->string('provider')->default('openai'); // openai, anthropic + $table->string('model'); + $table->integer('prompt_tokens')->default(0); + $table->integer('completion_tokens')->default(0); + $table->integer('total_tokens')->default(0); + $table->text('system_prompt')->nullable(); + $table->text('user_prompt'); + $table->text('response')->nullable(); + $table->string('status')->default('success'); // success, error, rate_limited + $table->text('error_message')->nullable(); + $table->integer('duration_ms')->default(0); + $table->decimal('cost_estimate', 10, 6)->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('ai_logs'); + } +}; diff --git a/database/seeders/AiSettingSeeder.php b/database/seeders/AiSettingSeeder.php new file mode 100644 index 0000000..ba07377 --- /dev/null +++ b/database/seeders/AiSettingSeeder.php @@ -0,0 +1,84 @@ + 'ai_provider', + 'value' => 'openai', + 'type' => 'text', + 'group' => 'provider', + 'label' => 'AI Provider', + 'description' => 'The AI service provider to use (openai or anthropic).', + 'order' => 1, + ], + [ + 'key' => 'ai_model', + 'value' => 'gpt-4o', + 'type' => 'text', + 'group' => 'provider', + 'label' => 'AI Model', + 'description' => 'The model to use for AI requests.', + 'order' => 2, + ], + [ + 'key' => 'ai_api_key', + 'value' => '', + 'type' => 'encrypted', + 'group' => 'provider', + 'label' => 'API Key', + 'description' => 'The API key for the configured AI provider. Stored encrypted.', + 'order' => 3, + ], + [ + 'key' => 'ai_max_tokens', + 'value' => '2048', + 'type' => 'number', + 'group' => 'parameters', + 'label' => 'Max Tokens', + 'description' => 'Maximum number of tokens in the AI response.', + 'order' => 1, + ], + [ + 'key' => 'ai_temperature', + 'value' => '0.7', + 'type' => 'number', + 'group' => 'parameters', + 'label' => 'Temperature', + 'description' => 'Controls randomness of AI output (0.0 - 2.0).', + 'order' => 2, + ], + [ + 'key' => 'ai_system_prompt', + 'value' => config('ai_prompts.card_generation.system', ''), + 'type' => 'textarea', + 'group' => 'prompts', + 'label' => 'Card Generation System Prompt', + 'description' => 'The system prompt sent with every card generation AI request.', + 'order' => 1, + ], + ]; + + foreach ($defaults as $setting) { + AiSetting::updateOrCreate( + ['key' => $setting['key']], + $setting + ); + } + + $this->command->info('AI settings seeded successfully.'); + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index e4114a2..1781e81 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -17,6 +17,7 @@ public function run(): void $this->call([ SystemUserSeeder::class, WebsiteSettingSeeder::class, + AiSettingSeeder::class, ]); // User::factory(10)->create(); diff --git a/resources/views/filament/pages/ai-settings.blade.php b/resources/views/filament/pages/ai-settings.blade.php new file mode 100644 index 0000000..d45ede8 --- /dev/null +++ b/resources/views/filament/pages/ai-settings.blade.php @@ -0,0 +1,5 @@ + + + {{ $this->form }} + +