From 4a3c109bf3cfad819fb5ab114461ec42bcd60d26 Mon Sep 17 00:00:00 2001 From: soheil khaledabadi Date: Wed, 22 Jul 2026 08:06:48 +0330 Subject: [PATCH] feat(AI): add ai manager admin and create powerpoint with ai --- app/Enums/AiProviderDriver.php | 25 +++ .../AiProviders/AiProviderResource.php | 54 +++++++ .../AiProviders/Pages/CreateAiProvider.php | 11 ++ .../AiProviders/Pages/EditAiProvider.php | 19 +++ .../AiProviders/Pages/ListAiProviders.php | 19 +++ .../AiProviders/Schemas/AiProviderForm.php | 58 +++++++ .../AiProviders/Tables/AiProvidersTable.php | 63 ++++++++ .../Actions/DownloadGenerationAction.php | 22 +++ .../Tables/TemplateGenerationsTable.php | 2 + .../Actions/GenerateWithAiAction.php | 99 ++++++++++++ .../Templates/Pages/ViewTemplate.php | 2 + .../GenerationsRelationManager.php | 5 +- .../RelationManagers/KeysRelationManager.php | 2 +- .../Templates/Tables/TemplatesTable.php | 2 + .../Admin/GenerationDownloadController.php | 23 +++ app/Models/AiProvider.php | 31 ++++ app/Models/TemplateKey.php | 12 ++ app/Policies/AiProviderPolicy.php | 39 +++++ .../Ai/OpenAiCompatibleTextGenerator.php | 81 ++++++++++ app/Services/TemplateFillService.php | 144 +++++++++++++++--- ...07_17_181548_create_ai_providers_table.php | 27 ++++ ...905_add_base_url_to_ai_providers_table.php | 22 +++ routes/web.php | 4 + 23 files changed, 743 insertions(+), 23 deletions(-) create mode 100644 app/Enums/AiProviderDriver.php create mode 100644 app/Filament/Resources/AiProviders/AiProviderResource.php create mode 100644 app/Filament/Resources/AiProviders/Pages/CreateAiProvider.php create mode 100644 app/Filament/Resources/AiProviders/Pages/EditAiProvider.php create mode 100644 app/Filament/Resources/AiProviders/Pages/ListAiProviders.php create mode 100644 app/Filament/Resources/AiProviders/Schemas/AiProviderForm.php create mode 100644 app/Filament/Resources/AiProviders/Tables/AiProvidersTable.php create mode 100644 app/Filament/Resources/TemplateGenerations/Actions/DownloadGenerationAction.php create mode 100644 app/Filament/Resources/Templates/Actions/GenerateWithAiAction.php create mode 100644 app/Http/Controllers/Admin/GenerationDownloadController.php create mode 100644 app/Models/AiProvider.php create mode 100644 app/Policies/AiProviderPolicy.php create mode 100644 app/Services/Ai/OpenAiCompatibleTextGenerator.php create mode 100644 database/migrations/2026_07_17_181548_create_ai_providers_table.php create mode 100644 database/migrations/2026_07_20_201905_add_base_url_to_ai_providers_table.php diff --git a/app/Enums/AiProviderDriver.php b/app/Enums/AiProviderDriver.php new file mode 100644 index 0000000..6e57e44 --- /dev/null +++ b/app/Enums/AiProviderDriver.php @@ -0,0 +1,25 @@ + 'OpenAI (GPT)', + self::GapGpt => 'GapGPT', + }; + } + + public function defaultBaseUrl(): string + { + return match ($this) { + self::OpenAi => 'https://api.openai.com/v1', + self::GapGpt => 'https://api.gapgpt.app/v1', + }; + } +} diff --git a/app/Filament/Resources/AiProviders/AiProviderResource.php b/app/Filament/Resources/AiProviders/AiProviderResource.php new file mode 100644 index 0000000..e37a420 --- /dev/null +++ b/app/Filament/Resources/AiProviders/AiProviderResource.php @@ -0,0 +1,54 @@ + ListAiProviders::route('/'), + 'create' => CreateAiProvider::route('/create'), + 'edit' => EditAiProvider::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Resources/AiProviders/Pages/CreateAiProvider.php b/app/Filament/Resources/AiProviders/Pages/CreateAiProvider.php new file mode 100644 index 0000000..bbe0aa4 --- /dev/null +++ b/app/Filament/Resources/AiProviders/Pages/CreateAiProvider.php @@ -0,0 +1,11 @@ +components([ + TextInput::make('name') + ->label('نام') + ->required() + ->maxLength(255), + Select::make('driver') + ->label('سرویس‌دهنده') + ->options([ + AiProviderDriver::OpenAi->value => AiProviderDriver::OpenAi->label(), + AiProviderDriver::GapGpt->value => AiProviderDriver::GapGpt->label(), + ]) + ->default(AiProviderDriver::OpenAi->value) + ->live() + ->required(), + TextInput::make('model') + ->label('مدل') + ->helperText('مثال: gpt-4o-mini') + ->required() + ->maxLength(255), + TextInput::make('base_url') + ->label('آدرس API (Base URL)') + ->url() + ->placeholder(fn (Get $get): string => AiProviderDriver::from($get('driver') ?? AiProviderDriver::OpenAi->value)->defaultBaseUrl()) + ->helperText('در صورت خالی بودن، آدرس پیش‌فرض همان سرویس‌دهنده استفاده می‌شود.') + ->maxLength(255), + TextInput::make('api_key') + ->label('API Key') + ->password() + ->revealable() + ->autocomplete('new-password') + ->required(fn (string $operation): bool => $operation === 'create') + ->saved(fn (?string $state): bool => filled($state)) + ->maxLength(1000), + Toggle::make('is_active') + ->label('فعال') + ->default(true), + Toggle::make('is_default') + ->label('پیش‌فرض') + ->helperText('هنگام تولید محتوا در صورت وجود چند سرویس فعال، این سرویس اولویت دارد.'), + ]); + } +} diff --git a/app/Filament/Resources/AiProviders/Tables/AiProvidersTable.php b/app/Filament/Resources/AiProviders/Tables/AiProvidersTable.php new file mode 100644 index 0000000..bd1ff76 --- /dev/null +++ b/app/Filament/Resources/AiProviders/Tables/AiProvidersTable.php @@ -0,0 +1,63 @@ +columns([ + TextColumn::make('name') + ->label('نام') + ->searchable() + ->sortable(), + TextColumn::make('driver') + ->label('سرویس‌دهنده') + ->badge() + ->formatStateUsing(fn (AiProviderDriver $state): string => $state->label()), + TextColumn::make('model') + ->label('مدل') + ->searchable(), + TextColumn::make('base_url') + ->label('آدرس API') + ->formatStateUsing(fn (?string $state, $record): string => $state ?: $record->driver->defaultBaseUrl()) + ->toggleable(), + IconColumn::make('is_active') + ->label('فعال') + ->boolean(), + IconColumn::make('is_default') + ->label('پیش‌فرض') + ->boolean(), + TextColumn::make('created_at') + ->label('تاریخ ایجاد') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + TernaryFilter::make('is_active') + ->label('وضعیت') + ->placeholder('همه') + ->trueLabel('فعال') + ->falseLabel('غیرفعال'), + ]) + ->recordActions([ + EditAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } +} diff --git a/app/Filament/Resources/TemplateGenerations/Actions/DownloadGenerationAction.php b/app/Filament/Resources/TemplateGenerations/Actions/DownloadGenerationAction.php new file mode 100644 index 0000000..16b59f5 --- /dev/null +++ b/app/Filament/Resources/TemplateGenerations/Actions/DownloadGenerationAction.php @@ -0,0 +1,22 @@ +label('دانلود') + ->icon(Heroicon::OutlinedArrowDownTray) + ->color('success') + ->visible(fn (TemplateGeneration $record): bool => $record->status === TemplateGenerationStatus::Completed && filled($record->output_path)) + ->url(fn (TemplateGeneration $record): string => route('admin.generations.download', $record->uuid)) + ->openUrlInNewTab(); + } +} diff --git a/app/Filament/Resources/TemplateGenerations/Tables/TemplateGenerationsTable.php b/app/Filament/Resources/TemplateGenerations/Tables/TemplateGenerationsTable.php index 0c8c769..bc5d144 100644 --- a/app/Filament/Resources/TemplateGenerations/Tables/TemplateGenerationsTable.php +++ b/app/Filament/Resources/TemplateGenerations/Tables/TemplateGenerationsTable.php @@ -3,6 +3,7 @@ namespace App\Filament\Resources\TemplateGenerations\Tables; use App\Enums\TemplateGenerationStatus; +use App\Filament\Resources\TemplateGenerations\Actions\DownloadGenerationAction; use Filament\Actions\ViewAction; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Filters\SelectFilter; @@ -58,6 +59,7 @@ class TemplateGenerationsTable ]) ->recordActions([ ViewAction::make(), + DownloadGenerationAction::make(), ]); } } diff --git a/app/Filament/Resources/Templates/Actions/GenerateWithAiAction.php b/app/Filament/Resources/Templates/Actions/GenerateWithAiAction.php new file mode 100644 index 0000000..e1fa733 --- /dev/null +++ b/app/Filament/Resources/Templates/Actions/GenerateWithAiAction.php @@ -0,0 +1,99 @@ +label('تولید با هوش مصنوعی') + ->icon(Heroicon::OutlinedSparkles) + ->color('info') + ->schema(function (Template $record): array { + $components = [ + Textarea::make('topic') + ->label('موضوع / محتوای خام') + ->helperText('هوش مصنوعی بر اساس این متن، کلیدهای متنی قالب را تکمیل می‌کند.') + ->required() + ->rows(4), + ]; + + foreach ($record->keys->where('type', TemplateKeyType::Image) as $key) { + $components[] = FileUpload::make("images.{$key->key}") + ->label($key->label) + ->image() + ->disk('local') + ->directory('templates/generations/inputs') + ->required($key->is_required); + } + + return $components; + }) + ->action(function (array $data, Template $record): void { + $provider = AiProvider::query() + ->where('is_active', true) + ->orderByDesc('is_default') + ->first(); + + if (! $provider) { + Notification::make() + ->title('هیچ سرویس هوش مصنوعی فعالی تنظیم نشده است.') + ->body('ابتدا از بخش «سرویس‌های AI» یک سرویس فعال تعریف کنید.') + ->danger() + ->send(); + + return; + } + + try { + $values = app(OpenAiCompatibleTextGenerator::class)->generateForTemplate($record, $data['topic'], $provider); + } catch (Throwable $exception) { + Notification::make() + ->title('خطا در تولید محتوا با هوش مصنوعی') + ->body($exception->getMessage()) + ->danger() + ->send(); + + return; + } + + foreach ($data['images'] ?? [] as $key => $path) { + if ($path) { + $values[$key] = $path; + } + } + + $generation = TemplateGeneration::query()->create([ + 'template_id' => $record->id, + 'uuid' => (string) Str::uuid(), + 'status' => TemplateGenerationStatus::Pending, + 'values' => $values, + 'requested_by' => auth()->id(), + ]); + + GenerateTemplateJob::dispatch($generation); + + Notification::make() + ->title('درخواست تولید ثبت شد') + ->body('نتیجه را می‌توانید در تب «تولیدها» مشاهده کنید.') + ->success() + ->send(); + }); + } +} diff --git a/app/Filament/Resources/Templates/Pages/ViewTemplate.php b/app/Filament/Resources/Templates/Pages/ViewTemplate.php index 323869a..3478946 100644 --- a/app/Filament/Resources/Templates/Pages/ViewTemplate.php +++ b/app/Filament/Resources/Templates/Pages/ViewTemplate.php @@ -2,6 +2,7 @@ namespace App\Filament\Resources\Templates\Pages; +use App\Filament\Resources\Templates\Actions\GenerateWithAiAction; use App\Filament\Resources\Templates\TemplateResource; use Filament\Actions\EditAction; use Filament\Resources\Pages\ViewRecord; @@ -13,6 +14,7 @@ class ViewTemplate extends ViewRecord protected function getHeaderActions(): array { return [ + GenerateWithAiAction::make(), EditAction::make(), ]; } diff --git a/app/Filament/Resources/Templates/RelationManagers/GenerationsRelationManager.php b/app/Filament/Resources/Templates/RelationManagers/GenerationsRelationManager.php index 30f2367..22609ca 100644 --- a/app/Filament/Resources/Templates/RelationManagers/GenerationsRelationManager.php +++ b/app/Filament/Resources/Templates/RelationManagers/GenerationsRelationManager.php @@ -3,6 +3,7 @@ namespace App\Filament\Resources\Templates\RelationManagers; use App\Enums\TemplateGenerationStatus; +use App\Filament\Resources\TemplateGenerations\Actions\DownloadGenerationAction; use Filament\Resources\RelationManagers\RelationManager; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Filters\SelectFilter; @@ -60,7 +61,9 @@ class GenerationsRelationManager extends RelationManager TemplateGenerationStatus::Failed->value => TemplateGenerationStatus::Failed->label(), ]), ]) - ->recordActions([]) + ->recordActions([ + DownloadGenerationAction::make(), + ]) ->toolbarActions([]); } } diff --git a/app/Filament/Resources/Templates/RelationManagers/KeysRelationManager.php b/app/Filament/Resources/Templates/RelationManagers/KeysRelationManager.php index 6d18a62..c914716 100644 --- a/app/Filament/Resources/Templates/RelationManagers/KeysRelationManager.php +++ b/app/Filament/Resources/Templates/RelationManagers/KeysRelationManager.php @@ -32,7 +32,7 @@ class KeysRelationManager extends RelationManager ->components([ TextInput::make('key') ->label('کلید') - ->helperText('در فایل پاورپوینت برای متن به‌صورت {{key}} و برای عکس/چارت به‌صورت نام شکل (shape name) استفاده می‌شود.') + ->helperText('فقط نام کلید را وارد کنید، بدون آکولاد. مثلاً اگر داخل پاورپوینت نوشته‌اید {Main Title}، اینجا فقط Main Title را وارد کنید؛ آکولاد را خود سیستم موقع جایگزینی اضافه می‌کند. برای عکس/چارت به‌جای این، نام شکل (shape name) را در فیلد پایین وارد کنید.') ->required() ->maxLength(255), TextInput::make('label') diff --git a/app/Filament/Resources/Templates/Tables/TemplatesTable.php b/app/Filament/Resources/Templates/Tables/TemplatesTable.php index 03f7a68..1500f87 100644 --- a/app/Filament/Resources/Templates/Tables/TemplatesTable.php +++ b/app/Filament/Resources/Templates/Tables/TemplatesTable.php @@ -2,6 +2,7 @@ namespace App\Filament\Resources\Templates\Tables; +use App\Filament\Resources\Templates\Actions\GenerateWithAiAction; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; @@ -51,6 +52,7 @@ class TemplatesTable ->recordActions([ ViewAction::make(), EditAction::make(), + GenerateWithAiAction::make(), ]) ->toolbarActions([ BulkActionGroup::make([ diff --git a/app/Http/Controllers/Admin/GenerationDownloadController.php b/app/Http/Controllers/Admin/GenerationDownloadController.php new file mode 100644 index 0000000..238dbab --- /dev/null +++ b/app/Http/Controllers/Admin/GenerationDownloadController.php @@ -0,0 +1,23 @@ +user(); + + abort_unless($user && ($user->hasRole('admin') || $user->hasPermission('templates.manage')), 403); + abort_if($generation->status !== TemplateGenerationStatus::Completed || ! $generation->output_path, 404); + + return Storage::disk('local')->download($generation->output_path, $generation->template->slug.'.pptx'); + } +} diff --git a/app/Models/AiProvider.php b/app/Models/AiProvider.php new file mode 100644 index 0000000..e96059e --- /dev/null +++ b/app/Models/AiProvider.php @@ -0,0 +1,31 @@ +base_url ?: $this->driver->defaultBaseUrl(); + } + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'driver' => AiProviderDriver::class, + 'api_key' => 'encrypted', + 'is_active' => 'boolean', + 'is_default' => 'boolean', + ]; + } +} diff --git a/app/Models/TemplateKey.php b/app/Models/TemplateKey.php index 70b2535..ae600c0 100644 --- a/app/Models/TemplateKey.php +++ b/app/Models/TemplateKey.php @@ -4,6 +4,7 @@ namespace App\Models; use App\Enums\TemplateKeyType; use Illuminate\Database\Eloquent\Attributes\Fillable; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -27,6 +28,17 @@ class TemplateKey extends Model return $this->belongsTo(Template::class); } + /** + * Strip any literal curly braces the admin may have copied from the pptx placeholder + * (e.g. "{Main Title}" -> "Main Title"), since braces are added automatically at match time. + */ + protected function key(): Attribute + { + return Attribute::make( + set: fn (string $value) => trim($value, "{} \t\n\r\0\x0B"), + ); + } + /** * @return array */ diff --git a/app/Policies/AiProviderPolicy.php b/app/Policies/AiProviderPolicy.php new file mode 100644 index 0000000..e95cf64 --- /dev/null +++ b/app/Policies/AiProviderPolicy.php @@ -0,0 +1,39 @@ +canManage($user); + } + + public function view(User $user, AiProvider $aiProvider): bool + { + return $this->canManage($user); + } + + public function create(User $user): bool + { + return $this->canManage($user); + } + + public function update(User $user, AiProvider $aiProvider): bool + { + return $this->canManage($user); + } + + public function delete(User $user, AiProvider $aiProvider): bool + { + return $this->canManage($user); + } + + private function canManage(User $user): bool + { + return $user->hasRole('admin') || $user->hasPermission('ai.manage'); + } +} diff --git a/app/Services/Ai/OpenAiCompatibleTextGenerator.php b/app/Services/Ai/OpenAiCompatibleTextGenerator.php new file mode 100644 index 0000000..25b9d95 --- /dev/null +++ b/app/Services/Ai/OpenAiCompatibleTextGenerator.php @@ -0,0 +1,81 @@ + + */ + public function generateForTemplate(Template $template, string $topic, AiProvider $provider): array + { + $textKeys = $template->keys->where('type', TemplateKeyType::Text); + + if ($textKeys->isEmpty()) { + return []; + } + + $keysDescription = $textKeys + ->map(fn (TemplateKey $key): string => sprintf('- %s (%s)%s', $key->key, $key->label, $key->description ? ': '.$key->description : '')) + ->implode("\n"); + + $systemPrompt = <<api_key) + ->timeout(60) + ->post(rtrim($provider->resolvedBaseUrl(), '/').'/chat/completions', [ + 'model' => $provider->model, + 'response_format' => ['type' => 'json_object'], + 'messages' => [ + ['role' => 'system', 'content' => $systemPrompt], + ['role' => 'user', 'content' => $topic], + ], + ]); + + if ($response->failed()) { + throw new RuntimeException('خطا در ارتباط با سرویس هوش مصنوعی: '.$response->body()); + } + + $decoded = json_decode((string) $response->json('choices.0.message.content'), true); + + if (! is_array($decoded)) { + throw new RuntimeException('پاسخ هوش مصنوعی قابل تفسیر نبود.'); + } + + $normalizedDecoded = []; + + foreach ($decoded as $decodedKey => $decodedValue) { + $normalizedDecoded[$this->normalizeKey((string) $decodedKey)] = $decodedValue; + } + + $values = []; + + foreach ($textKeys as $key) { + $normalizedKey = $this->normalizeKey($key->key); + + if (array_key_exists($normalizedKey, $normalizedDecoded)) { + $values[$key->key] = (string) $normalizedDecoded[$normalizedKey]; + } + } + + return $values; + } + + private function normalizeKey(string $key): string + { + return mb_strtolower(trim($key, "{} \t\n\r\0\x0B")); + } +} diff --git a/app/Services/TemplateFillService.php b/app/Services/TemplateFillService.php index 522e4de..4b10121 100644 --- a/app/Services/TemplateFillService.php +++ b/app/Services/TemplateFillService.php @@ -9,7 +9,11 @@ use PhpOffice\PhpPresentation\IOFactory; use PhpOffice\PhpPresentation\PhpPresentation; use PhpOffice\PhpPresentation\Shape\Drawing\File as FileDrawing; use PhpOffice\PhpPresentation\Shape\Drawing\Gd as GdDrawing; +use PhpOffice\PhpPresentation\Shape\Group as GroupShape; use PhpOffice\PhpPresentation\Shape\RichText; +use PhpOffice\PhpPresentation\Shape\RichText\BreakElement; +use PhpOffice\PhpPresentation\Shape\RichText\Paragraph; +use PhpOffice\PhpPresentation\Shape\Table as TableShape; use RuntimeException; class TemplateFillService @@ -23,18 +27,26 @@ class TemplateFillService $values = $generation->values ?? []; + $textValues = []; + foreach ($template->keys as $key) { if (! array_key_exists($key->key, $values)) { continue; } match ($key->type) { - TemplateKeyType::Text => $this->fillText($presentation, $key->key, (string) $values[$key->key]), + TemplateKeyType::Text => $textValues[$key->key] = (string) $values[$key->key], TemplateKeyType::Image => $this->fillImage($presentation, $key->shape_name, Storage::disk('local')->path($values[$key->key])), TemplateKeyType::Chart => throw new RuntimeException('پرکردن کلید از نوع چارت هنوز پشتیبانی نمی‌شود.'), }; } + if ($textValues !== []) { + foreach ($presentation->getAllSlides() as $slide) { + $this->replaceInShapes($slide->getShapeCollection(), $textValues); + } + } + $outputRelativePath = 'templates/generations/'.$generation->uuid.'.pptx'; $outputAbsolutePath = Storage::disk('local')->path($outputRelativePath); @@ -46,27 +58,103 @@ class TemplateFillService return $outputRelativePath; } - private function fillText(PhpPresentation $presentation, string $key, string $value): void + /** + * @param iterable $shapes + * @param array $textValues + */ + private function replaceInShapes(iterable $shapes, array $textValues): void { - $placeholder = '{{'.$key.'}}'; - - foreach ($presentation->getAllSlides() as $slide) { - foreach ($slide->getShapeCollection() as $shape) { - if (! $shape instanceof RichText) { - continue; + foreach ($shapes as $shape) { + if ($shape instanceof RichText) { + foreach ($shape->getParagraphs() as $paragraph) { + $this->replaceInParagraph($paragraph, $textValues); } - foreach ($shape->getParagraphs() as $paragraph) { - foreach ($paragraph->getRichTextElements() as $element) { - if (str_contains($element->getText(), $placeholder)) { - $element->setText(str_replace($placeholder, $value, $element->getText())); + continue; + } + + if ($shape instanceof TableShape) { + foreach ($shape->getRows() as $row) { + foreach ($row->getCells() as $cell) { + foreach ($cell->getParagraphs() as $paragraph) { + $this->replaceInParagraph($paragraph, $textValues); } } } + + continue; + } + + if ($shape instanceof GroupShape) { + $this->replaceInShapes($shape->getShapeCollection(), $textValues); } } } + /** + * @param array $textValues + */ + private function replaceInParagraph(Paragraph $paragraph, array $textValues): void + { + $elements = $paragraph->getRichTextElements(); + + if ($elements === []) { + return; + } + + $originalText = ''; + + foreach ($elements as $element) { + $originalText .= $element->getText(); + } + + if (! str_contains($originalText, '{')) { + return; + } + + $lookup = []; + + foreach ($textValues as $key => $value) { + $lookup[$this->normalizeKey($key)] = $value; + } + + $newText = preg_replace_callback( + '/\{\{?\s*([^{}]+?)\s*\}?\}/u', + function (array $matches) use ($lookup): string { + $normalizedKey = $this->normalizeKey($matches[1]); + + return array_key_exists($normalizedKey, $lookup) ? $lookup[$normalizedKey] : $matches[0]; + }, + $originalText + ); + + if ($newText === null || $newText === $originalText) { + return; + } + + $target = null; + + foreach ($elements as $element) { + if ($element instanceof BreakElement) { + continue; + } + + if ($target === null) { + $target = $element; + $element->setText($newText); + + continue; + } + + $element->setText(''); + } + } + + private function normalizeKey(string $key): string + { + return mb_strtolower(trim($key, "{} \t\n\r\0\x0B")); + } + private function fillImage(PhpPresentation $presentation, ?string $shapeName, string $imagePath): void { if ($shapeName === null || $shapeName === '') { @@ -74,16 +162,30 @@ class TemplateFillService } foreach ($presentation->getAllSlides() as $slide) { - foreach ($slide->getShapeCollection() as $shape) { - if ($shape->getName() !== $shapeName) { - continue; - } + $this->replaceImageInShapes($slide->getShapeCollection(), $shapeName, $imagePath); + } + } - if ($shape instanceof GdDrawing) { - $this->replaceGdImage($shape, $imagePath); - } elseif ($shape instanceof FileDrawing) { - $shape->setPath($imagePath); - } + /** + * @param iterable $shapes + */ + private function replaceImageInShapes(iterable $shapes, string $shapeName, string $imagePath): void + { + foreach ($shapes as $shape) { + if ($shape instanceof GroupShape) { + $this->replaceImageInShapes($shape->getShapeCollection(), $shapeName, $imagePath); + + continue; + } + + if ($shape->getName() !== $shapeName) { + continue; + } + + if ($shape instanceof GdDrawing) { + $this->replaceGdImage($shape, $imagePath); + } elseif ($shape instanceof FileDrawing) { + $shape->setPath($imagePath); } } } diff --git a/database/migrations/2026_07_17_181548_create_ai_providers_table.php b/database/migrations/2026_07_17_181548_create_ai_providers_table.php new file mode 100644 index 0000000..fcdac79 --- /dev/null +++ b/database/migrations/2026_07_17_181548_create_ai_providers_table.php @@ -0,0 +1,27 @@ +id(); + $table->string('name'); + $table->string('driver')->default('openai'); + $table->string('model'); + $table->text('api_key'); + $table->boolean('is_active')->default(true); + $table->boolean('is_default')->default(false); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('ai_providers'); + } +}; diff --git a/database/migrations/2026_07_20_201905_add_base_url_to_ai_providers_table.php b/database/migrations/2026_07_20_201905_add_base_url_to_ai_providers_table.php new file mode 100644 index 0000000..977917b --- /dev/null +++ b/database/migrations/2026_07_20_201905_add_base_url_to_ai_providers_table.php @@ -0,0 +1,22 @@ +string('base_url')->nullable()->after('model'); + }); + } + + public function down(): void + { + Schema::table('ai_providers', function (Blueprint $table) { + $table->dropColumn('base_url'); + }); + } +}; diff --git a/routes/web.php b/routes/web.php index 86a06c5..1a70445 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,7 +1,11 @@ get('admin/generations/{generation:uuid}/download', GenerationDownloadController::class) + ->name('admin.generations.download');