feat(AI): add ai manager admin and create powerpoint with ai

This commit is contained in:
2026-07-22 08:06:48 +03:30
parent ae974c2afa
commit 4a3c109bf3
23 changed files with 743 additions and 23 deletions

View 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',
};
}
}

View 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'),
];
}
}

View File

@@ -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;
}

View 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(),
];
}
}

View 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(),
];
}
}

View File

@@ -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('هنگام تولید محتوا در صورت وجود چند سرویس فعال، این سرویس اولویت دارد.'),
]);
}
}

View File

@@ -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(),
]),
]);
}
}

View File

@@ -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();
}
}

View File

@@ -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(),
]);
}
}

View File

@@ -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();
});
}
}

View File

@@ -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(),
];
}

View File

@@ -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([]);
}
}

View File

@@ -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')

View File

@@ -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([

View 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
View 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',
];
}
}

View File

@@ -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<string, string>
*/

View 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');
}
}

View 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"));
}
}

View File

@@ -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<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 ($shape->getParagraphs() as $paragraph) {
$this->replaceInParagraph($paragraph, $textValues);
}
foreach ($presentation->getAllSlides() as $slide) {
foreach ($slide->getShapeCollection() as $shape) {
if (! $shape instanceof RichText) {
continue;
}
foreach ($shape->getParagraphs() as $paragraph) {
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
{
if ($shapeName === null || $shapeName === '') {
@@ -74,7 +162,22 @@ class TemplateFillService
}
foreach ($presentation->getAllSlides() as $slide) {
foreach ($slide->getShapeCollection() as $shape) {
$this->replaceImageInShapes($slide->getShapeCollection(), $shapeName, $imagePath);
}
}
/**
* @param iterable<mixed> $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;
}
@@ -86,7 +189,6 @@ class TemplateFillService
}
}
}
}
private function replaceGdImage(GdDrawing $shape, string $imagePath): void
{

View File

@@ -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');
}
};

View File

@@ -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');
});
}
};

View File

@@ -1,7 +1,11 @@
<?php
use App\Http\Controllers\Admin\GenerationDownloadController;
use Illuminate\Support\Facades\Route;
Route::get('/', function () {
return view('welcome');
});
Route::middleware('auth')->get('admin/generations/{generation:uuid}/download', GenerationDownloadController::class)
->name('admin.generations.download');