Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

All notable changes to this project will be documented in this file.

## [1.7.5] - 2026-03-08

### Added
- Per-feature enable/disable toggles for all four AI features (Card Suggestion, Ability Re-roll, Deck Analytics, Card Swap)
- Per-feature token caps configurable per AI feature to control cost and response length
- Per-user rate limiting with configurable hourly and daily request quotas
- System-wide daily and monthly cost budget enforcement with live spend stats in AI Settings

## [1.7.0] - 2026-03-07

### Added
Expand Down
16 changes: 16 additions & 0 deletions app/Exceptions/AiLimitExceededException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php
/**
* Webtech-solutions 2025, All rights reserved.
*/

namespace App\Exceptions;

use Exception;

class AiLimitExceededException extends Exception
{
public function __construct(string $message = 'AI limit exceeded', int $code = 0, ?\Throwable $previous = null)
{
parent::__construct($message, $code, $previous);
}
}
161 changes: 161 additions & 0 deletions app/Filament/Pages/AiSettings.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,13 @@
use App\Models\AiSetting;
use App\Services\Ai\AiProxyService;
use Filament\Actions\Action;
use App\Services\Ai\AiBudgetGuard;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Forms\Form;
Expand Down Expand Up @@ -54,6 +57,24 @@ public function mount(): void
'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', '')),
// Features
'ai_feature_card_suggest' => filter_var(AiSetting::get('ai_feature_card_suggest', 'true'), FILTER_VALIDATE_BOOLEAN),
'ai_feature_ability_reroll' => filter_var(AiSetting::get('ai_feature_ability_reroll', 'true'), FILTER_VALIDATE_BOOLEAN),
'ai_feature_deck_analytics' => filter_var(AiSetting::get('ai_feature_deck_analytics', 'true'), FILTER_VALIDATE_BOOLEAN),
'ai_feature_card_swap' => filter_var(AiSetting::get('ai_feature_card_swap', 'true'), FILTER_VALIDATE_BOOLEAN),
// Token caps
'ai_tokens_card_suggest' => AiSetting::get('ai_tokens_card_suggest', 1024),
'ai_tokens_ability_reroll' => AiSetting::get('ai_tokens_ability_reroll', 512),
'ai_tokens_deck_analytics' => AiSetting::get('ai_tokens_deck_analytics', 2048),
'ai_tokens_card_swap' => AiSetting::get('ai_tokens_card_swap', 1024),
// Rate limiting
'ai_rate_limit_enabled' => filter_var(AiSetting::get('ai_rate_limit_enabled', 'true'), FILTER_VALIDATE_BOOLEAN),
'ai_rate_limit_hourly' => AiSetting::get('ai_rate_limit_hourly', 20),
'ai_rate_limit_daily' => AiSetting::get('ai_rate_limit_daily', 100),
// Budget
'ai_budget_enabled' => filter_var(AiSetting::get('ai_budget_enabled', 'true'), FILTER_VALIDATE_BOOLEAN),
'ai_budget_daily_usd' => AiSetting::get('ai_budget_daily_usd', 5.00),
'ai_budget_monthly_usd' => AiSetting::get('ai_budget_monthly_usd', 50.00),
]);
}

Expand Down Expand Up @@ -137,6 +158,124 @@ public function form(Form $form): Form
->helperText('This prompt is sent with every card generation request. It defines the AI\'s domain knowledge and required JSON output format.')
->columnSpanFull(),
]),

Section::make('Feature Toggles')
->description('Enable or disable individual AI features. Disabled features are hidden from the UI.')
->icon('heroicon-o-adjustments-vertical')
->schema([
Toggle::make('ai_feature_card_suggest')
->label('AI Card Suggestion')
->helperText('Allow supervisors to generate card data using AI on the Card Create/Edit pages.'),

Toggle::make('ai_feature_ability_reroll')
->label('Ability Re-roll')
->helperText('Allow supervisors to re-generate card abilities with a chosen tone.'),

Toggle::make('ai_feature_deck_analytics')
->label('Deck Analytics')
->helperText('Allow supervisors to run AI synergy and curve analysis on decks.'),

Toggle::make('ai_feature_card_swap')
->label('AI Card Swap')
->helperText('Allow supervisors to get AI-powered card swap suggestions for decks.'),
])
->columns(2),

Section::make('Per-Feature Token Caps')
->description('Limit the maximum tokens each AI feature can use per request. Lower values reduce cost but may produce shorter responses.')
->icon('heroicon-o-calculator')
->schema([
TextInput::make('ai_tokens_card_suggest')
->label('Card Suggestion (tokens)')
->numeric()
->minValue(100)
->maxValue(8192)
->default(1024),

TextInput::make('ai_tokens_ability_reroll')
->label('Ability Re-roll (tokens)')
->numeric()
->minValue(100)
->maxValue(8192)
->default(512),

TextInput::make('ai_tokens_deck_analytics')
->label('Deck Analytics (tokens)')
->numeric()
->minValue(100)
->maxValue(8192)
->default(2048),

TextInput::make('ai_tokens_card_swap')
->label('Card Swap (tokens)')
->numeric()
->minValue(100)
->maxValue(8192)
->default(1024),
])
->columns(2),

Section::make('Rate Limiting')
->description('Limit how many AI requests each user can make per rolling time window.')
->icon('heroicon-o-clock')
->schema([
Toggle::make('ai_rate_limit_enabled')
->label('Enable Rate Limiting')
->columnSpanFull(),

TextInput::make('ai_rate_limit_hourly')
->label('Max Requests per Hour (per user)')
->numeric()
->minValue(1)
->maxValue(1000)
->default(20)
->helperText('Number of AI requests a single user can make within any 60-minute window.'),

TextInput::make('ai_rate_limit_daily')
->label('Max Requests per Day (per user)')
->numeric()
->minValue(1)
->maxValue(10000)
->default(100)
->helperText('Number of AI requests a single user can make per calendar day.'),
])
->columns(2),

Section::make('Cost Budget')
->description('Block all AI calls when estimated spend exceeds the configured limit.')
->icon('heroicon-o-currency-dollar')
->schema([
Toggle::make('ai_budget_enabled')
->label('Enable Budget Limiting')
->columnSpanFull(),

TextInput::make('ai_budget_daily_usd')
->label('Daily Budget (USD)')
->numeric()
->minValue(0.01)
->step(0.01)
->default(5.00)
->prefix('$')
->helperText('System-wide daily spend cap. Resets at midnight.'),

TextInput::make('ai_budget_monthly_usd')
->label('Monthly Budget (USD)')
->numeric()
->minValue(0.01)
->step(0.01)
->default(50.00)
->prefix('$')
->helperText('System-wide monthly spend cap. Resets on the 1st of each month.'),

Placeholder::make('daily_spend_stat')
->label('Today\'s Spend')
->content(fn () => '$' . number_format(app(AiBudgetGuard::class)->getDailySpend(), 4)),

Placeholder::make('monthly_spend_stat')
->label('This Month\'s Spend')
->content(fn () => '$' . number_format(app(AiBudgetGuard::class)->getMonthlySpend(), 4)),
])
->columns(2),
])
->statePath('data');
}
Expand All @@ -159,6 +298,28 @@ public function save(): void
$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);

// Feature toggles
$this->saveSetting('ai_feature_card_suggest', $data['ai_feature_card_suggest'] ? 'true' : 'false', 'text', 'features', 'AI Card Suggestion', 1);
$this->saveSetting('ai_feature_ability_reroll', $data['ai_feature_ability_reroll'] ? 'true' : 'false', 'text', 'features', 'Ability Re-roll', 2);
$this->saveSetting('ai_feature_deck_analytics', $data['ai_feature_deck_analytics'] ? 'true' : 'false', 'text', 'features', 'Deck Analytics', 3);
$this->saveSetting('ai_feature_card_swap', $data['ai_feature_card_swap'] ? 'true' : 'false', 'text', 'features', 'AI Card Swap', 4);

// Token caps
$this->saveSetting('ai_tokens_card_suggest', $data['ai_tokens_card_suggest'], 'number', 'token_caps', 'Card Suggestion Tokens', 1);
$this->saveSetting('ai_tokens_ability_reroll', $data['ai_tokens_ability_reroll'], 'number', 'token_caps', 'Ability Re-roll Tokens', 2);
$this->saveSetting('ai_tokens_deck_analytics', $data['ai_tokens_deck_analytics'], 'number', 'token_caps', 'Deck Analytics Tokens', 3);
$this->saveSetting('ai_tokens_card_swap', $data['ai_tokens_card_swap'], 'number', 'token_caps', 'Card Swap Tokens', 4);

// Rate limiting
$this->saveSetting('ai_rate_limit_enabled', $data['ai_rate_limit_enabled'] ? 'true' : 'false', 'text', 'rate_limit', 'Rate Limiting Enabled', 1);
$this->saveSetting('ai_rate_limit_hourly', $data['ai_rate_limit_hourly'], 'number', 'rate_limit', 'Hourly Limit', 2);
$this->saveSetting('ai_rate_limit_daily', $data['ai_rate_limit_daily'], 'number', 'rate_limit', 'Daily Limit', 3);

// Budget
$this->saveSetting('ai_budget_enabled', $data['ai_budget_enabled'] ? 'true' : 'false', 'text', 'budget', 'Budget Enabled', 1);
$this->saveSetting('ai_budget_daily_usd', $data['ai_budget_daily_usd'], 'number', 'budget', 'Daily Budget (USD)', 2);
$this->saveSetting('ai_budget_monthly_usd', $data['ai_budget_monthly_usd'], 'number', 'budget', 'Monthly Budget (USD)', 3);

Notification::make()
->title('Settings saved successfully.')
->success()
Expand Down
6 changes: 4 additions & 2 deletions app/Filament/Resources/CardResource/Pages/CreateCard.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@

namespace App\Filament\Resources\CardResource\Pages;

use App\Exceptions\AiLimitExceededException;
use App\Exceptions\AiSuggestionException;
use App\Filament\Resources\CardResource;
use App\Models\CardType;
use App\Models\Game;
use App\Services\Ai\AiFeatureGuard;
use App\Services\Ai\CardSuggestionService;
use Filament\Actions;
use Filament\Forms;
Expand All @@ -32,7 +34,7 @@ protected function getHeaderActions(): array
->label('AI Suggest')
->icon('heroicon-o-sparkles')
->color('warning')
->visible(fn () => auth()->user()?->supervisor === true)
->visible(fn () => AiFeatureGuard::isEnabled('card_suggest'))
->form([
Forms\Components\Select::make('game_id')
->label('Game')
Expand Down Expand Up @@ -72,7 +74,7 @@ protected function getHeaderActions(): array
->title('AI Suggestion Applied')
->body('Review the generated fields and save when ready.')
->send();
} catch (AiSuggestionException $e) {
} catch (AiLimitExceededException | AiSuggestionException $e) {
Notification::make()
->danger()
->title('AI Error')
Expand Down
10 changes: 6 additions & 4 deletions app/Filament/Resources/CardResource/Pages/EditCard.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@

namespace App\Filament\Resources\CardResource\Pages;

use App\Exceptions\AiLimitExceededException;
use App\Exceptions\AiSuggestionException;
use App\Filament\Resources\CardResource;
use App\Models\CardType;
use App\Models\Game;
use App\Services\Ai\AbilityRerollService;
use App\Services\Ai\AiFeatureGuard;
use App\Services\Ai\CardSuggestionService;
use Filament\Actions;
use Filament\Forms;
Expand All @@ -27,7 +29,7 @@ protected function getHeaderActions(): array
->label('AI Suggest')
->icon('heroicon-o-sparkles')
->color('warning')
->visible(fn () => auth()->user()?->supervisor === true)
->visible(fn () => AiFeatureGuard::isEnabled('card_suggest'))
->form([
Forms\Components\Select::make('game_id')
->label('Game')
Expand Down Expand Up @@ -67,7 +69,7 @@ protected function getHeaderActions(): array
->title('AI Suggestion Applied')
->body('Review the generated fields and save when ready.')
->send();
} catch (AiSuggestionException $e) {
} catch (AiLimitExceededException | AiSuggestionException $e) {
Notification::make()
->danger()
->title('AI Error')
Expand All @@ -80,7 +82,7 @@ protected function getHeaderActions(): array
->label('Re-roll Ability')
->icon('heroicon-o-arrow-path')
->color('info')
->visible(fn () => auth()->user()?->supervisor === true)
->visible(fn () => AiFeatureGuard::isEnabled('ability_reroll'))
->form([
Forms\Components\Select::make('tone')
->label('Tone')
Expand Down Expand Up @@ -120,7 +122,7 @@ protected function getHeaderActions(): array
->body('Card ability updated. Save to persist the changes.')
->send();
}
} catch (AiSuggestionException $e) {
} catch (AiLimitExceededException | AiSuggestionException $e) {
Notification::make()
->danger()
->title('AI Error')
Expand Down
10 changes: 6 additions & 4 deletions app/Filament/Resources/DeckResource/Pages/EditDeck.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@

namespace App\Filament\Resources\DeckResource\Pages;

use App\Exceptions\AiLimitExceededException;
use App\Exceptions\AiSuggestionException;
use App\Filament\Resources\DeckResource;
use App\Models\Card;
use App\Models\Deck;
use App\Services\Ai\AiFeatureGuard;
use App\Services\Ai\CardSwapService;
use App\Services\Ai\DeckAnalyticsService;
use Filament\Actions;
Expand All @@ -26,7 +28,7 @@ protected function getHeaderActions(): array
->label('AI Analytics')
->icon('heroicon-o-chart-bar')
->color('success')
->visible(fn () => auth()->user()?->supervisor === true)
->visible(fn () => AiFeatureGuard::isEnabled('deck_analytics'))
->slideOver()
->modalHeading('Deck Synergy Analysis')
->modalSubmitAction(false)
Expand All @@ -35,7 +37,7 @@ protected function getHeaderActions(): array
try {
$report = app(DeckAnalyticsService::class)->analyze($this->record->id);
return view('filament.modals.deck-analytics', ['report' => $report]);
} catch (AiSuggestionException $e) {
} catch (AiLimitExceededException | AiSuggestionException $e) {
Notification::make()
->danger()
->title('AI Error')
Expand All @@ -49,7 +51,7 @@ protected function getHeaderActions(): array
->label('AI Card Swap')
->icon('heroicon-o-arrows-right-left')
->color('warning')
->visible(fn () => auth()->user()?->supervisor === true)
->visible(fn () => AiFeatureGuard::isEnabled('card_swap'))
->slideOver()
->modalHeading('AI Card Swap Suggestions')
->modalSubmitAction(false)
Expand All @@ -61,7 +63,7 @@ protected function getHeaderActions(): array
'suggestions' => $suggestions,
'deckId' => $this->record->id,
]);
} catch (AiSuggestionException $e) {
} catch (AiLimitExceededException | AiSuggestionException $e) {
Notification::make()
->danger()
->title('AI Error')
Expand Down
6 changes: 4 additions & 2 deletions app/Filament/Resources/DeckResource/Pages/ViewDeck.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@

namespace App\Filament\Resources\DeckResource\Pages;

use App\Exceptions\AiLimitExceededException;
use App\Exceptions\AiSuggestionException;
use App\Filament\Resources\DeckResource;
use App\Services\Ai\AiFeatureGuard;
use App\Services\Ai\DeckAnalyticsService;
use Filament\Actions;
use Filament\Notifications\Notification;
Expand All @@ -28,7 +30,7 @@ protected function getHeaderActions(): array
->label('AI Analytics')
->icon('heroicon-o-chart-bar')
->color('success')
->visible(fn () => auth()->user()?->supervisor === true)
->visible(fn () => AiFeatureGuard::isEnabled('deck_analytics'))
->slideOver()
->modalHeading('Deck Synergy Analysis')
->modalSubmitAction(false)
Expand All @@ -37,7 +39,7 @@ protected function getHeaderActions(): array
try {
$report = app(DeckAnalyticsService::class)->analyze($this->record->id);
return view('filament.modals.deck-analytics', ['report' => $report]);
} catch (AiSuggestionException $e) {
} catch (AiLimitExceededException | AiSuggestionException $e) {
Notification::make()
->danger()
->title('AI Error')
Expand Down
2 changes: 1 addition & 1 deletion app/Filament/Resources/PhysicalCardResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ public static function getNavigationBadge(): ?string

public static function canViewAny(): bool
{
return auth()->user()?->isSupervisor() ?? false;
return auth()->check();
}

public static function getEloquentQuery(): Builder
Expand Down
Loading
Loading