feat(AI): add ai manager admin and create powerpoint with ai
This commit is contained in:
25
app/Enums/AiProviderDriver.php
Normal file
25
app/Enums/AiProviderDriver.php
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
enum AiProviderDriver: string
|
||||||
|
{
|
||||||
|
case OpenAi = 'openai';
|
||||||
|
case GapGpt = 'gapgpt';
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::OpenAi => '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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
54
app/Filament/Resources/AiProviders/AiProviderResource.php
Normal file
54
app/Filament/Resources/AiProviders/AiProviderResource.php
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\AiProviders;
|
||||||
|
|
||||||
|
use App\Filament\Resources\AiProviders\Pages\CreateAiProvider;
|
||||||
|
use App\Filament\Resources\AiProviders\Pages\EditAiProvider;
|
||||||
|
use App\Filament\Resources\AiProviders\Pages\ListAiProviders;
|
||||||
|
use App\Filament\Resources\AiProviders\Schemas\AiProviderForm;
|
||||||
|
use App\Filament\Resources\AiProviders\Tables\AiProvidersTable;
|
||||||
|
use App\Models\AiProvider;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Support\Icons\Heroicon;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
class AiProviderResource extends Resource
|
||||||
|
{
|
||||||
|
protected static ?string $model = AiProvider::class;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedSparkles;
|
||||||
|
|
||||||
|
protected static string|UnitEnum|null $navigationGroup = 'هوش مصنوعی';
|
||||||
|
|
||||||
|
protected static ?string $recordTitleAttribute = 'name';
|
||||||
|
|
||||||
|
protected static ?string $modelLabel = 'سرویس هوش مصنوعی';
|
||||||
|
|
||||||
|
protected static ?string $pluralModelLabel = 'سرویسهای هوش مصنوعی';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'سرویسهای AI';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 30;
|
||||||
|
|
||||||
|
public static function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return AiProviderForm::configure($schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return AiProvidersTable::configure($table);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => ListAiProviders::route('/'),
|
||||||
|
'create' => CreateAiProvider::route('/create'),
|
||||||
|
'edit' => EditAiProvider::route('/{record}/edit'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\AiProviders\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\AiProviders\AiProviderResource;
|
||||||
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
|
||||||
|
class CreateAiProvider extends CreateRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = AiProviderResource::class;
|
||||||
|
}
|
||||||
19
app/Filament/Resources/AiProviders/Pages/EditAiProvider.php
Normal file
19
app/Filament/Resources/AiProviders/Pages/EditAiProvider.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\AiProviders\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\AiProviders\AiProviderResource;
|
||||||
|
use Filament\Actions\DeleteAction;
|
||||||
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
|
class EditAiProvider extends EditRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = AiProviderResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
DeleteAction::make(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
19
app/Filament/Resources/AiProviders/Pages/ListAiProviders.php
Normal file
19
app/Filament/Resources/AiProviders/Pages/ListAiProviders.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\AiProviders\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\AiProviders\AiProviderResource;
|
||||||
|
use Filament\Actions\CreateAction;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListAiProviders extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = AiProviderResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
CreateAction::make(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\AiProviders\Schemas;
|
||||||
|
|
||||||
|
use App\Enums\AiProviderDriver;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Forms\Components\Toggle;
|
||||||
|
use Filament\Schemas\Components\Utilities\Get;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
|
||||||
|
class AiProviderForm
|
||||||
|
{
|
||||||
|
public static function configure(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->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('هنگام تولید محتوا در صورت وجود چند سرویس فعال، این سرویس اولویت دارد.'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\AiProviders\Tables;
|
||||||
|
|
||||||
|
use App\Enums\AiProviderDriver;
|
||||||
|
use Filament\Actions\BulkActionGroup;
|
||||||
|
use Filament\Actions\DeleteBulkAction;
|
||||||
|
use Filament\Actions\EditAction;
|
||||||
|
use Filament\Tables\Columns\IconColumn;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Filters\TernaryFilter;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
|
||||||
|
class AiProvidersTable
|
||||||
|
{
|
||||||
|
public static function configure(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->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(),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\TemplateGenerations\Actions;
|
||||||
|
|
||||||
|
use App\Enums\TemplateGenerationStatus;
|
||||||
|
use App\Models\TemplateGeneration;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Support\Icons\Heroicon;
|
||||||
|
|
||||||
|
class DownloadGenerationAction
|
||||||
|
{
|
||||||
|
public static function make(): Action
|
||||||
|
{
|
||||||
|
return Action::make('download')
|
||||||
|
->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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Filament\Resources\TemplateGenerations\Tables;
|
namespace App\Filament\Resources\TemplateGenerations\Tables;
|
||||||
|
|
||||||
use App\Enums\TemplateGenerationStatus;
|
use App\Enums\TemplateGenerationStatus;
|
||||||
|
use App\Filament\Resources\TemplateGenerations\Actions\DownloadGenerationAction;
|
||||||
use Filament\Actions\ViewAction;
|
use Filament\Actions\ViewAction;
|
||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
use Filament\Tables\Filters\SelectFilter;
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
@@ -58,6 +59,7 @@ class TemplateGenerationsTable
|
|||||||
])
|
])
|
||||||
->recordActions([
|
->recordActions([
|
||||||
ViewAction::make(),
|
ViewAction::make(),
|
||||||
|
DownloadGenerationAction::make(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Templates\Actions;
|
||||||
|
|
||||||
|
use App\Enums\TemplateGenerationStatus;
|
||||||
|
use App\Enums\TemplateKeyType;
|
||||||
|
use App\Jobs\GenerateTemplateJob;
|
||||||
|
use App\Models\AiProvider;
|
||||||
|
use App\Models\Template;
|
||||||
|
use App\Models\TemplateGeneration;
|
||||||
|
use App\Services\Ai\OpenAiCompatibleTextGenerator;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Forms\Components\FileUpload;
|
||||||
|
use Filament\Forms\Components\Textarea;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Support\Icons\Heroicon;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class GenerateWithAiAction
|
||||||
|
{
|
||||||
|
public static function make(): Action
|
||||||
|
{
|
||||||
|
return Action::make('generateWithAi')
|
||||||
|
->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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources\Templates\Pages;
|
namespace App\Filament\Resources\Templates\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\Templates\Actions\GenerateWithAiAction;
|
||||||
use App\Filament\Resources\Templates\TemplateResource;
|
use App\Filament\Resources\Templates\TemplateResource;
|
||||||
use Filament\Actions\EditAction;
|
use Filament\Actions\EditAction;
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
@@ -13,6 +14,7 @@ class ViewTemplate extends ViewRecord
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
GenerateWithAiAction::make(),
|
||||||
EditAction::make(),
|
EditAction::make(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Filament\Resources\Templates\RelationManagers;
|
namespace App\Filament\Resources\Templates\RelationManagers;
|
||||||
|
|
||||||
use App\Enums\TemplateGenerationStatus;
|
use App\Enums\TemplateGenerationStatus;
|
||||||
|
use App\Filament\Resources\TemplateGenerations\Actions\DownloadGenerationAction;
|
||||||
use Filament\Resources\RelationManagers\RelationManager;
|
use Filament\Resources\RelationManagers\RelationManager;
|
||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
use Filament\Tables\Filters\SelectFilter;
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
@@ -60,7 +61,9 @@ class GenerationsRelationManager extends RelationManager
|
|||||||
TemplateGenerationStatus::Failed->value => TemplateGenerationStatus::Failed->label(),
|
TemplateGenerationStatus::Failed->value => TemplateGenerationStatus::Failed->label(),
|
||||||
]),
|
]),
|
||||||
])
|
])
|
||||||
->recordActions([])
|
->recordActions([
|
||||||
|
DownloadGenerationAction::make(),
|
||||||
|
])
|
||||||
->toolbarActions([]);
|
->toolbarActions([]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ class KeysRelationManager extends RelationManager
|
|||||||
->components([
|
->components([
|
||||||
TextInput::make('key')
|
TextInput::make('key')
|
||||||
->label('کلید')
|
->label('کلید')
|
||||||
->helperText('در فایل پاورپوینت برای متن بهصورت {{key}} و برای عکس/چارت بهصورت نام شکل (shape name) استفاده میشود.')
|
->helperText('فقط نام کلید را وارد کنید، بدون آکولاد. مثلاً اگر داخل پاورپوینت نوشتهاید {Main Title}، اینجا فقط Main Title را وارد کنید؛ آکولاد را خود سیستم موقع جایگزینی اضافه میکند. برای عکس/چارت بهجای این، نام شکل (shape name) را در فیلد پایین وارد کنید.')
|
||||||
->required()
|
->required()
|
||||||
->maxLength(255),
|
->maxLength(255),
|
||||||
TextInput::make('label')
|
TextInput::make('label')
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources\Templates\Tables;
|
namespace App\Filament\Resources\Templates\Tables;
|
||||||
|
|
||||||
|
use App\Filament\Resources\Templates\Actions\GenerateWithAiAction;
|
||||||
use Filament\Actions\BulkActionGroup;
|
use Filament\Actions\BulkActionGroup;
|
||||||
use Filament\Actions\DeleteBulkAction;
|
use Filament\Actions\DeleteBulkAction;
|
||||||
use Filament\Actions\EditAction;
|
use Filament\Actions\EditAction;
|
||||||
@@ -51,6 +52,7 @@ class TemplatesTable
|
|||||||
->recordActions([
|
->recordActions([
|
||||||
ViewAction::make(),
|
ViewAction::make(),
|
||||||
EditAction::make(),
|
EditAction::make(),
|
||||||
|
GenerateWithAiAction::make(),
|
||||||
])
|
])
|
||||||
->toolbarActions([
|
->toolbarActions([
|
||||||
BulkActionGroup::make([
|
BulkActionGroup::make([
|
||||||
|
|||||||
23
app/Http/Controllers/Admin/GenerationDownloadController.php
Normal file
23
app/Http/Controllers/Admin/GenerationDownloadController.php
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Enums\TemplateGenerationStatus;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\TemplateGeneration;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||||
|
|
||||||
|
class GenerationDownloadController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(Request $request, TemplateGeneration $generation): StreamedResponse
|
||||||
|
{
|
||||||
|
$user = $request->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
31
app/Models/AiProvider.php
Normal file
31
app/Models/AiProvider.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Enums\AiProviderDriver;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
#[Fillable(['name', 'driver', 'model', 'base_url', 'api_key', 'is_active', 'is_default'])]
|
||||||
|
#[Hidden(['api_key'])]
|
||||||
|
class AiProvider extends Model
|
||||||
|
{
|
||||||
|
public function resolvedBaseUrl(): string
|
||||||
|
{
|
||||||
|
return $this->base_url ?: $this->driver->defaultBaseUrl();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'driver' => AiProviderDriver::class,
|
||||||
|
'api_key' => 'encrypted',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
'is_default' => 'boolean',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ namespace App\Models;
|
|||||||
|
|
||||||
use App\Enums\TemplateKeyType;
|
use App\Enums\TemplateKeyType;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
@@ -27,6 +28,17 @@ class TemplateKey extends Model
|
|||||||
return $this->belongsTo(Template::class);
|
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<string, string>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
|
|||||||
39
app/Policies/AiProviderPolicy.php
Normal file
39
app/Policies/AiProviderPolicy.php
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Models\AiProvider;
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
class AiProviderPolicy
|
||||||
|
{
|
||||||
|
public function viewAny(User $user): bool
|
||||||
|
{
|
||||||
|
return $this->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
81
app/Services/Ai/OpenAiCompatibleTextGenerator.php
Normal file
81
app/Services/Ai/OpenAiCompatibleTextGenerator.php
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Ai;
|
||||||
|
|
||||||
|
use App\Enums\TemplateKeyType;
|
||||||
|
use App\Models\AiProvider;
|
||||||
|
use App\Models\Template;
|
||||||
|
use App\Models\TemplateKey;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class OpenAiCompatibleTextGenerator
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
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 = <<<PROMPT
|
||||||
|
شما دستیار تولید محتوای پاورپوینت هستید. بر اساس موضوعی که کاربر میدهد، برای هرکدام از کلیدهای زیر یک متن مناسب فارسی تولید کن.
|
||||||
|
فقط یک JSON معتبر برگردان که کلیدهای آن دقیقاً و بدون هیچ تغییری (بدون تغییر حروف بزرگ/کوچک، بدون ترجمه، بدون حذف فاصله) برابر با نام کلیدهای زیر باشد. هیچ توضیح اضافهای برنگردان.
|
||||||
|
|
||||||
|
کلیدها:
|
||||||
|
{$keysDescription}
|
||||||
|
PROMPT;
|
||||||
|
|
||||||
|
$response = Http::withToken($provider->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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,11 @@ use PhpOffice\PhpPresentation\IOFactory;
|
|||||||
use PhpOffice\PhpPresentation\PhpPresentation;
|
use PhpOffice\PhpPresentation\PhpPresentation;
|
||||||
use PhpOffice\PhpPresentation\Shape\Drawing\File as FileDrawing;
|
use PhpOffice\PhpPresentation\Shape\Drawing\File as FileDrawing;
|
||||||
use PhpOffice\PhpPresentation\Shape\Drawing\Gd as GdDrawing;
|
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;
|
||||||
|
use PhpOffice\PhpPresentation\Shape\RichText\BreakElement;
|
||||||
|
use PhpOffice\PhpPresentation\Shape\RichText\Paragraph;
|
||||||
|
use PhpOffice\PhpPresentation\Shape\Table as TableShape;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
|
|
||||||
class TemplateFillService
|
class TemplateFillService
|
||||||
@@ -23,18 +27,26 @@ class TemplateFillService
|
|||||||
|
|
||||||
$values = $generation->values ?? [];
|
$values = $generation->values ?? [];
|
||||||
|
|
||||||
|
$textValues = [];
|
||||||
|
|
||||||
foreach ($template->keys as $key) {
|
foreach ($template->keys as $key) {
|
||||||
if (! array_key_exists($key->key, $values)) {
|
if (! array_key_exists($key->key, $values)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
match ($key->type) {
|
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::Image => $this->fillImage($presentation, $key->shape_name, Storage::disk('local')->path($values[$key->key])),
|
||||||
TemplateKeyType::Chart => throw new RuntimeException('پرکردن کلید از نوع چارت هنوز پشتیبانی نمیشود.'),
|
TemplateKeyType::Chart => throw new RuntimeException('پرکردن کلید از نوع چارت هنوز پشتیبانی نمیشود.'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($textValues !== []) {
|
||||||
|
foreach ($presentation->getAllSlides() as $slide) {
|
||||||
|
$this->replaceInShapes($slide->getShapeCollection(), $textValues);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$outputRelativePath = 'templates/generations/'.$generation->uuid.'.pptx';
|
$outputRelativePath = 'templates/generations/'.$generation->uuid.'.pptx';
|
||||||
$outputAbsolutePath = Storage::disk('local')->path($outputRelativePath);
|
$outputAbsolutePath = Storage::disk('local')->path($outputRelativePath);
|
||||||
|
|
||||||
@@ -46,27 +58,103 @@ class TemplateFillService
|
|||||||
return $outputRelativePath;
|
return $outputRelativePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function fillText(PhpPresentation $presentation, string $key, string $value): void
|
/**
|
||||||
|
* @param iterable<mixed> $shapes
|
||||||
|
* @param array<string, string> $textValues
|
||||||
|
*/
|
||||||
|
private function replaceInShapes(iterable $shapes, array $textValues): void
|
||||||
{
|
{
|
||||||
$placeholder = '{{'.$key.'}}';
|
foreach ($shapes as $shape) {
|
||||||
|
if ($shape instanceof RichText) {
|
||||||
foreach ($presentation->getAllSlides() as $slide) {
|
foreach ($shape->getParagraphs() as $paragraph) {
|
||||||
foreach ($slide->getShapeCollection() as $shape) {
|
$this->replaceInParagraph($paragraph, $textValues);
|
||||||
if (! $shape instanceof RichText) {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($shape->getParagraphs() as $paragraph) {
|
continue;
|
||||||
foreach ($paragraph->getRichTextElements() as $element) {
|
}
|
||||||
if (str_contains($element->getText(), $placeholder)) {
|
|
||||||
$element->setText(str_replace($placeholder, $value, $element->getText()));
|
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<string, string> $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
|
private function fillImage(PhpPresentation $presentation, ?string $shapeName, string $imagePath): void
|
||||||
{
|
{
|
||||||
if ($shapeName === null || $shapeName === '') {
|
if ($shapeName === null || $shapeName === '') {
|
||||||
@@ -74,16 +162,30 @@ class TemplateFillService
|
|||||||
}
|
}
|
||||||
|
|
||||||
foreach ($presentation->getAllSlides() as $slide) {
|
foreach ($presentation->getAllSlides() as $slide) {
|
||||||
foreach ($slide->getShapeCollection() as $shape) {
|
$this->replaceImageInShapes($slide->getShapeCollection(), $shapeName, $imagePath);
|
||||||
if ($shape->getName() !== $shapeName) {
|
}
|
||||||
continue;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if ($shape instanceof GdDrawing) {
|
/**
|
||||||
$this->replaceGdImage($shape, $imagePath);
|
* @param iterable<mixed> $shapes
|
||||||
} elseif ($shape instanceof FileDrawing) {
|
*/
|
||||||
$shape->setPath($imagePath);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('ai_providers', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('ai_providers', function (Blueprint $table) {
|
||||||
|
$table->string('base_url')->nullable()->after('model');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('ai_providers', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('base_url');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\Admin\GenerationDownloadController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::get('/', function () {
|
Route::get('/', function () {
|
||||||
return view('welcome');
|
return view('welcome');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Route::middleware('auth')->get('admin/generations/{generation:uuid}/download', GenerationDownloadController::class)
|
||||||
|
->name('admin.generations.download');
|
||||||
|
|||||||
Reference in New Issue
Block a user