AI provider management

* OpenAI-compatible provider adapter
* OpenAI support
* LM Studio support
* Ollama OpenAI-compatible support
* Encrypted API-key storage
* Default provider and model selection
* Provider connection testing
* Reusable prompt library
* Prompt variables and templates
* Prompt version field
* AI execution history
* Token-usage storage
* Duration and failure tracking
* Manual knowledge sources
* Knowledge-source chunking
* Knowledge indexing status
* Authenticated AI REST endpoint
* AI administration dashboard
* AI permissions
* Module registration and boot test
This commit is contained in:
Mark Gregory
2026-07-24 13:01:56 +01:00
parent bf37548faa
commit 5dbb04e3ad
26 changed files with 377 additions and 1 deletions
+8
View File
@@ -0,0 +1,8 @@
<?php
return [
'default_provider' => env('AI_DEFAULT_PROVIDER'),
'timeout' => (int) env('AI_TIMEOUT', 60),
'max_output_tokens' => (int) env('AI_MAX_OUTPUT_TOKENS', 2048),
'temperature' => (float) env('AI_TEMPERATURE', 0.3),
'retain_executions_days' => (int) env('AI_RETAIN_EXECUTIONS_DAYS', 90),
];
+9
View File
@@ -0,0 +1,9 @@
<?php
namespace Modules\AI\Console;
use Illuminate\Console\Command;
use Modules\AI\Models\AIExecution;
final class PruneAIExecutions extends Command
{
protected $signature='ai:prune {--days=}'; protected $description='Remove old AI execution records.';
public function handle(): int { $days=(int)($this->option('days')?:config('ai.retain_executions_days',90)); $count=AIExecution::where('created_at','<',now()->subDays($days))->delete(); $this->info("Deleted {$count} AI executions."); return self::SUCCESS; }
}
+9
View File
@@ -0,0 +1,9 @@
<?php
namespace Modules\AI\Console;
use Illuminate\Console\Command;
use Modules\AI\Contracts\AIManager;
final class TestAIProvider extends Command
{
protected $signature='ai:test {provider}'; protected $description='Test an AI provider connection.';
public function handle(AIManager $manager): int { $result=$manager->testProvider((int)$this->argument('provider')); $this->{$result['ok']?'info':'error'}($result['message']); return $result['ok']?self::SUCCESS:self::FAILURE; }
}
+9
View File
@@ -0,0 +1,9 @@
<?php
namespace Modules\AI\Contracts;
use Modules\AI\Models\AIExecution;
use Modules\AI\Models\AIPrompt;
interface AIManager
{
public function execute(AIPrompt|string $prompt, array $variables=[], array $options=[]): AIExecution;
public function testProvider(int $providerId): array;
}
@@ -0,0 +1,8 @@
<?php
namespace Modules\AI\Contracts;
use Modules\AI\Models\AIProvider;
interface AIProviderDriver
{
public function generate(AIProvider $provider, array $messages, array $options=[]): array;
public function test(AIProvider $provider): array;
}
@@ -0,0 +1,46 @@
<?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', 40)->default('openai_compatible');
$table->string('base_url')->nullable(); $table->text('api_key')->nullable(); $table->string('default_model')->nullable();
$table->json('models')->nullable(); $table->json('options')->nullable(); $table->boolean('is_active')->default(true)->index();
$table->boolean('is_default')->default(false)->index(); $table->timestamp('last_tested_at')->nullable();
$table->string('last_test_status', 20)->nullable(); $table->text('last_test_message')->nullable(); $table->timestamps();
});
Schema::create('ai_prompts', function (Blueprint $table) {
$table->id(); $table->string('name'); $table->string('slug')->unique(); $table->string('purpose')->nullable()->index();
$table->text('system_prompt')->nullable(); $table->longText('user_template'); $table->json('variables')->nullable();
$table->unsignedInteger('version')->default(1); $table->boolean('is_active')->default(true)->index();
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); $table->timestamps();
});
Schema::create('ai_executions', function (Blueprint $table) {
$table->id(); $table->uuid('execution_id')->unique(); $table->foreignId('ai_provider_id')->nullable()->constrained('ai_providers')->nullOnDelete();
$table->foreignId('ai_prompt_id')->nullable()->constrained('ai_prompts')->nullOnDelete(); $table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
$table->string('model')->nullable(); $table->string('purpose')->nullable()->index(); $table->longText('input')->nullable();
$table->longText('output')->nullable(); $table->json('context')->nullable(); $table->json('usage')->nullable();
$table->string('status', 20)->default('pending')->index(); $table->text('error')->nullable();
$table->unsignedInteger('duration_ms')->nullable(); $table->timestamp('completed_at')->nullable(); $table->timestamps();
});
Schema::create('ai_knowledge_sources', function (Blueprint $table) {
$table->id(); $table->string('name'); $table->string('source_kind', 40)->default('manual')->index();
$table->nullableMorphs('source'); $table->longText('content')->nullable(); $table->string('checksum', 64)->nullable()->index();
$table->json('metadata')->nullable(); $table->string('status', 20)->default('pending')->index();
$table->unsignedInteger('chunk_count')->default(0); $table->timestamp('indexed_at')->nullable(); $table->timestamps();
});
Schema::create('ai_knowledge_chunks', function (Blueprint $table) {
$table->id(); $table->foreignId('ai_knowledge_source_id')->constrained('ai_knowledge_sources')->cascadeOnDelete();
$table->unsignedInteger('position'); $table->longText('content'); $table->json('metadata')->nullable(); $table->timestamps();
$table->unique(['ai_knowledge_source_id','position']);
});
}
public function down(): void
{
Schema::dropIfExists('ai_knowledge_chunks'); Schema::dropIfExists('ai_knowledge_sources');
Schema::dropIfExists('ai_executions'); Schema::dropIfExists('ai_prompts'); Schema::dropIfExists('ai_providers');
}
};
@@ -0,0 +1,27 @@
<?php
namespace Modules\AI\Drivers;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use Modules\AI\Contracts\AIProviderDriver;
use Modules\AI\Models\AIProvider;
final class OpenAICompatibleDriver implements AIProviderDriver
{
private function client(AIProvider $provider): PendingRequest
{
$client=Http::acceptJson()->timeout((int)config('ai.timeout',60));
return filled($provider->api_key) ? $client->withToken($provider->api_key) : $client;
}
public function generate(AIProvider $provider,array $messages,array $options=[]): array
{
$base=rtrim((string)($provider->base_url ?: 'https://api.openai.com/v1'),'/');
$payload=['model'=>$options['model']??$provider->default_model,'messages'=>$messages,'temperature'=>$options['temperature']??config('ai.temperature',0.3),'max_tokens'=>$options['max_tokens']??config('ai.max_output_tokens',2048)];
$response=$this->client($provider)->post($base.'/chat/completions',array_filter($payload,fn($v)=>$v!==null));
$response->throw(); $json=$response->json();
return ['text'=>(string)data_get($json,'choices.0.message.content',''),'usage'=>(array)data_get($json,'usage',[]),'raw'=>$json];
}
public function test(AIProvider $provider): array
{
$result=$this->generate($provider,[['role'=>'user','content'=>'Reply with OK only.']],['max_tokens'=>8,'temperature'=>0]);
return ['ok'=>true,'message'=>trim($result['text']) ?: 'Provider responded successfully.'];
}
}
@@ -0,0 +1,16 @@
<?php
namespace Modules\AI\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Modules\AI\Contracts\AIManager;
use Modules\AI\Models\AIPrompt;
final class AIApiController extends Controller
{
public function execute(Request $request,AIPrompt $prompt,AIManager $manager): JsonResponse
{
$data=$request->validate(['variables'=>'nullable|array','provider_id'=>'nullable|integer','model'=>'nullable|string|max:150']);
$execution=$manager->execute($prompt,$data['variables']??[],array_filter(['provider_id'=>$data['provider_id']??null,'model'=>$data['model']??null]));
return response()->json(['data'=>['execution_id'=>$execution->execution_id,'status'=>$execution->status,'output'=>$execution->output,'usage'=>$execution->usage,'error'=>$execution->error]],$execution->status==='completed'?200:422);
}
}
@@ -0,0 +1,46 @@
<?php
namespace Modules\AI\Http\Controllers;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Str;
use Illuminate\View\View;
use Modules\AI\Contracts\AIManager;
use Modules\AI\Models\AIExecution;
use Modules\AI\Models\AIKnowledgeSource;
use Modules\AI\Models\AIPrompt;
use Modules\AI\Models\AIProvider;
use Modules\AI\Services\KnowledgeIndexer;
final class AIController extends Controller
{
public function index(): View
{
return view('ai::admin.index',['providers'=>AIProvider::latest()->get(),'prompts'=>AIPrompt::latest()->paginate(15,'*','prompts'),'executions'=>AIExecution::latest()->limit(15)->get(),'sources'=>AIKnowledgeSource::latest()->limit(15)->get()]);
}
public function storeProvider(Request $request): RedirectResponse
{
$data=$request->validate(['name'=>'required|string|max:100','driver'=>'required|in:openai_compatible,openai,lm_studio,ollama','base_url'=>'nullable|url','api_key'=>'nullable|string','default_model'=>'required|string|max:150','is_default'=>'nullable|boolean']);
if($request->boolean('is_default')) AIProvider::query()->update(['is_default'=>false]);
$data['is_default']=$request->boolean('is_default'); AIProvider::create($data); return back()->with('status','AI provider created.');
}
public function testProvider(AIProvider $provider,AIManager $manager): RedirectResponse
{
$result=$manager->testProvider($provider->id); return back()->with($result['ok']?'status':'error',$result['message']);
}
public function storePrompt(Request $request): RedirectResponse
{
$data=$request->validate(['name'=>'required|string|max:120','slug'=>'nullable|alpha_dash|max:140','purpose'=>'nullable|string|max:100','system_prompt'=>'nullable|string','user_template'=>'required|string']);
$data['slug']=$data['slug']?:Str::slug($data['name']); $data['created_by']=auth()->id(); AIPrompt::create($data); return back()->with('status','Prompt created.');
}
public function execute(Request $request,AIPrompt $prompt,AIManager $manager): RedirectResponse
{
$data=$request->validate(['input'=>'required|string|max:50000','provider_id'=>'nullable|exists:ai_providers,id']);
$execution=$manager->execute($prompt,['input'=>$data['input']],['provider_id'=>$data['provider_id']??null]);
return back()->with($execution->status==='completed'?'status':'error',$execution->status==='completed'?'AI execution completed.':$execution->error);
}
public function storeSource(Request $request,KnowledgeIndexer $indexer): RedirectResponse
{
$data=$request->validate(['name'=>'required|string|max:150','content'=>'required|string|max:1000000']);
$source=AIKnowledgeSource::create(['name'=>$data['name'],'source_kind'=>'manual','content'=>$data['content'],'status'=>'pending']); $indexer->index($source); return back()->with('status','Knowledge source indexed.');
}
}
+8
View File
@@ -0,0 +1,8 @@
<?php
namespace Modules\AI\Models;
use Illuminate\Database\Eloquent\Model;
final class AIExecution extends Model
{
protected $fillable=['execution_id','ai_provider_id','ai_prompt_id','user_id','model','purpose','input','output','context','usage','status','error','duration_ms','completed_at'];
protected $casts=['context'=>'array','usage'=>'array','completed_at'=>'datetime'];
}
+8
View File
@@ -0,0 +1,8 @@
<?php
namespace Modules\AI\Models;
use Illuminate\Database\Eloquent\Model;
final class AIKnowledgeChunk extends Model
{
protected $fillable=['ai_knowledge_source_id','position','content','metadata'];
protected $casts=['metadata'=>'array'];
}
+12
View File
@@ -0,0 +1,12 @@
<?php
namespace Modules\AI\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
final class AIKnowledgeSource extends Model
{
protected $fillable=['name','source_kind','source_type','source_id','content','checksum','metadata','status','chunk_count','indexed_at'];
protected $casts=['metadata'=>'array','indexed_at'=>'datetime'];
public function source(): MorphTo { return $this->morphTo(); }
public function chunks(): HasMany { return $this->hasMany(AIKnowledgeChunk::class); }
}
+12
View File
@@ -0,0 +1,12 @@
<?php
namespace Modules\AI\Models;
use Illuminate\Database\Eloquent\Model;
final class AIPrompt extends Model
{
protected $fillable=['name','slug','purpose','system_prompt','user_template','variables','version','is_active','created_by'];
protected $casts=['variables'=>'array','is_active'=>'boolean'];
public function render(array $variables): string
{
return preg_replace_callback('/\{\{\s*([\w.]+)\s*\}\}/', fn($m)=>(string)data_get($variables,$m[1],''), $this->user_template) ?? $this->user_template;
}
}
+9
View File
@@ -0,0 +1,9 @@
<?php
namespace Modules\AI\Models;
use Illuminate\Database\Eloquent\Model;
final class AIProvider extends Model
{
protected $fillable=['name','driver','base_url','api_key','default_model','models','options','is_active','is_default','last_tested_at','last_test_status','last_test_message'];
protected $casts=['api_key'=>'encrypted','models'=>'array','options'=>'array','is_active'=>'boolean','is_default'=>'boolean','last_tested_at'=>'datetime'];
protected $hidden=['api_key'];
}
@@ -0,0 +1,22 @@
<?php
namespace Modules\AI\Providers;
use Illuminate\Support\ServiceProvider;
use Modules\AI\Console\PruneAIExecutions;
use Modules\AI\Console\TestAIProvider;
use Modules\AI\Contracts\AIManager as AIManagerContract;
use Modules\AI\Services\AIManager;
final class AIServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->register(RouteServiceProvider::class);
$this->mergeConfigFrom(module_path('AI', 'Config/config.php'), 'ai');
$this->app->singleton(AIManagerContract::class, AIManager::class);
}
public function boot(): void
{
$this->loadMigrationsFrom(module_path('AI', 'Database/Migrations'));
$this->loadViewsFrom(module_path('AI', 'Resources/views'), 'ai');
if ($this->app->runningInConsole()) $this->commands([TestAIProvider::class, PruneAIExecutions::class]);
}
}
@@ -0,0 +1,12 @@
<?php
namespace Modules\AI\Providers;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Route;
final class RouteServiceProvider extends ServiceProvider
{
public function boot(): void
{
Route::middleware('web')->group(module_path('AI', 'Routes/web.php'));
Route::middleware('api')->prefix('api/v1')->group(module_path('AI', 'Routes/api.php'));
}
}
@@ -0,0 +1,21 @@
<x-layouts.app :title="__('AI Platform')">
<div class="space-y-6">
<div><flux:heading size="xl">AI Platform</flux:heading><flux:text>Configure hosted or local models, reusable prompts, execution history and knowledge sources.</flux:text></div>
@if(session('status'))<flux:callout variant="success">{{ session('status') }}</flux:callout>@endif
@if(session('error'))<flux:callout variant="danger">{{ session('error') }}</flux:callout>@endif
<div class="grid gap-4 md:grid-cols-4">
<flux:card><flux:text>Providers</flux:text><flux:heading size="xl">{{ $providers->count() }}</flux:heading></flux:card>
<flux:card><flux:text>Prompts</flux:text><flux:heading size="xl">{{ $prompts->total() }}</flux:heading></flux:card>
<flux:card><flux:text>Executions</flux:text><flux:heading size="xl">{{ \Modules\AI\Models\AIExecution::count() }}</flux:heading></flux:card>
<flux:card><flux:text>Knowledge sources</flux:text><flux:heading size="xl">{{ \Modules\AI\Models\AIKnowledgeSource::count() }}</flux:heading></flux:card>
</div>
<div class="grid gap-6 xl:grid-cols-2">
<flux:card class="space-y-4"><flux:heading>Provider</flux:heading><form method="POST" action="{{ route('administration.ai.providers.store') }}" class="space-y-3">@csrf
<flux:input name="name" label="Name" required/><flux:select name="driver" label="Driver"><option value="openai_compatible">OpenAI compatible</option><option value="openai">OpenAI</option><option value="lm_studio">LM Studio</option><option value="ollama">Ollama</option></flux:select>
<flux:input name="base_url" label="Base URL" placeholder="http://127.0.0.1:1234/v1"/><flux:input name="api_key" type="password" label="API key"/><flux:input name="default_model" label="Default model" required/><flux:checkbox name="is_default" value="1" label="Default provider"/><flux:button type="submit" variant="primary">Add provider</flux:button></form>
<div class="divide-y">@foreach($providers as $provider)<div class="flex items-center justify-between py-3"><div><strong>{{ $provider->name }}</strong><div class="text-sm opacity-70">{{ $provider->driver }} · {{ $provider->default_model }}</div></div><form method="POST" action="{{ route('administration.ai.providers.test',$provider) }}">@csrf<flux:button size="sm">Test</flux:button></form></div>@endforeach</div></flux:card>
<flux:card class="space-y-4"><flux:heading>Reusable prompt</flux:heading><form method="POST" action="{{ route('administration.ai.prompts.store') }}" class="space-y-3">@csrf
<flux:input name="name" label="Name" required/><flux:input name="slug" label="Slug"/><flux:input name="purpose" label="Purpose" placeholder="summarisation"/><flux:textarea name="system_prompt" label="System prompt"/><flux:textarea name="user_template" label="User template" placeholder="Summarise: [input]" required/><flux:button type="submit" variant="primary">Add prompt</flux:button></form></flux:card>
<flux:card class="space-y-4"><flux:heading>Knowledge source</flux:heading><form method="POST" action="{{ route('administration.ai.knowledge.store') }}" class="space-y-3">@csrf<flux:input name="name" label="Name" required/><flux:textarea name="content" label="Content" rows="10" required/><flux:button type="submit" variant="primary">Index source</flux:button></form><div class="divide-y">@foreach($sources as $source)<div class="py-2"><strong>{{ $source->name }}</strong><div class="text-sm opacity-70">{{ $source->status }} · {{ $source->chunk_count }} chunks</div></div>@endforeach</div></flux:card>
<flux:card class="space-y-4"><flux:heading>Recent executions</flux:heading><div class="divide-y">@forelse($executions as $execution)<div class="py-3"><div class="flex justify-between"><strong>{{ $execution->purpose ?: 'Prompt execution' }}</strong><span>{{ $execution->status }}</span></div><div class="text-sm opacity-70">{{ $execution->model }} · {{ $execution->duration_ms }} ms · {{ $execution->created_at?->diffForHumans() }}</div></div>@empty<flux:text>No executions yet.</flux:text>@endforelse</div></flux:card>
</div></div></x-layouts.app>
+4
View File
@@ -0,0 +1,4 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\AI\Http\Controllers\AIApiController;
Route::post('/ai/prompts/{prompt:slug}/execute',[AIApiController::class,'execute'])->middleware(['auth.api','ability:ai.execute'])->name('api.ai.execute');
+11
View File
@@ -0,0 +1,11 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\AI\Http\Controllers\AIController;
Route::middleware(['auth','permission:ai.view'])->prefix('administration/ai')->name('administration.ai.')->group(function(){
Route::get('/',[AIController::class,'index'])->name('index');
Route::post('/providers',[AIController::class,'storeProvider'])->middleware('permission:ai.manage')->name('providers.store');
Route::post('/providers/{provider}/test',[AIController::class,'testProvider'])->middleware('permission:ai.manage')->name('providers.test');
Route::post('/prompts',[AIController::class,'storePrompt'])->middleware('permission:ai.manage')->name('prompts.store');
Route::post('/prompts/{prompt}/execute',[AIController::class,'execute'])->middleware('permission:ai.execute')->name('prompts.execute');
Route::post('/knowledge',[AIController::class,'storeSource'])->middleware('permission:ai.manage')->name('knowledge.store');
});
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace Modules\AI\Services;
use Illuminate\Support\Str;
use Modules\AI\Contracts\AIManager as AIManagerContract;
use Modules\AI\Contracts\AIProviderDriver;
use Modules\AI\Drivers\OpenAICompatibleDriver;
use Modules\AI\Models\AIExecution;
use Modules\AI\Models\AIPrompt;
use Modules\AI\Models\AIProvider;
final class AIManager implements AIManagerContract
{
public function execute(AIPrompt|string $prompt,array $variables=[],array $options=[]): AIExecution
{
$promptModel=is_string($prompt)?AIPrompt::where('slug',$prompt)->where('is_active',true)->firstOrFail():$prompt;
$provider=$this->provider($options['provider_id']??null); $input=$promptModel->render($variables);
$execution=AIExecution::create(['execution_id'=>(string)Str::uuid(),'ai_provider_id'=>$provider->id,'ai_prompt_id'=>$promptModel->id,'user_id'=>auth()->id(),'model'=>$options['model']??$provider->default_model,'purpose'=>$promptModel->purpose,'input'=>$input,'context'=>$variables,'status'=>'running']);
$started=microtime(true);
try {
$messages=[]; if (filled($promptModel->system_prompt)) $messages[]=['role'=>'system','content'=>$promptModel->system_prompt];
$messages[]=['role'=>'user','content'=>$input]; $result=$this->driver($provider)->generate($provider,$messages,$options);
$execution->update(['output'=>$result['text'],'usage'=>$result['usage'],'status'=>'completed','duration_ms'=>(int)((microtime(true)-$started)*1000),'completed_at'=>now()]);
} catch (\Throwable $e) {
report($e); $execution->update(['status'=>'failed','error'=>$e->getMessage(),'duration_ms'=>(int)((microtime(true)-$started)*1000),'completed_at'=>now()]);
}
return $execution->refresh();
}
public function testProvider(int $providerId): array
{
$provider=AIProvider::findOrFail($providerId);
try { $result=$this->driver($provider)->test($provider); $provider->update(['last_tested_at'=>now(),'last_test_status'=>'passed','last_test_message'=>$result['message']]); return $result; }
catch (\Throwable $e) { $provider->update(['last_tested_at'=>now(),'last_test_status'=>'failed','last_test_message'=>$e->getMessage()]); return ['ok'=>false,'message'=>$e->getMessage()]; }
}
private function provider(?int $id): AIProvider
{
return AIProvider::query()->when($id,fn($q)=>$q->whereKey($id))->when(!$id,fn($q)=>$q->where('is_default',true))->where('is_active',true)->first()
?? AIProvider::where('is_active',true)->firstOrFail();
}
private function driver(AIProvider $provider): AIProviderDriver
{
return match($provider->driver) { 'openai_compatible','openai','lm_studio','ollama'=>app(OpenAICompatibleDriver::class), default=>throw new \InvalidArgumentException("Unsupported AI driver [{$provider->driver}].") };
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace Modules\AI\Services;
use Modules\AI\Models\AIKnowledgeSource;
final class KnowledgeIndexer
{
public function index(AIKnowledgeSource $source,int $chunkSize=1800): AIKnowledgeSource
{
$content=trim((string)$source->content); $source->chunks()->delete();
$chunks=$content===''?[]:$this->split($content,max(500,$chunkSize));
foreach($chunks as $position=>$chunk) $source->chunks()->create(['position'=>$position+1,'content'=>$chunk]);
$source->update(['checksum'=>hash('sha256',$content),'status'=>'indexed','chunk_count'=>count($chunks),'indexed_at'=>now()]);
return $source->refresh();
}
private function split(string $content,int $size): array
{
$paragraphs=preg_split('/\R{2,}/',$content) ?: [$content]; $chunks=[]; $buffer='';
foreach($paragraphs as $paragraph){ if(strlen($buffer)+strlen($paragraph)+2>$size && $buffer!==''){ $chunks[]=trim($buffer); $buffer=''; } $buffer.=$paragraph."\n\n"; }
if(trim($buffer)!=='') $chunks[]=trim($buffer); return $chunks;
}
}
@@ -0,0 +1,11 @@
<?php
namespace Modules\AI\Tests\Feature;
use Tests\TestCase;
final class ModuleBootTest extends TestCase
{
public function test_ai_module_boots(): void
{
$this->assertTrue(class_exists(\Modules\AI\Providers\AIServiceProvider::class));
$this->assertTrue(app()->bound(\Modules\AI\Contracts\AIManager::class));
}
}
+1
View File
@@ -0,0 +1 @@
{"name":"AI","alias":"ai","version":"1.0.0","description":"Provider-neutral artificial intelligence services, reusable prompts, execution history and knowledge sources.","keywords":["ai","llm","prompts","knowledge","rag"],"priority":140,"providers":["Modules\\AI\\Providers\\AIServiceProvider"],"aliases":{},"files":[],"requires":["Core","Administration","Users","Roles","Audit","Media","Documents","Automation"]}
@@ -73,6 +73,9 @@ final class RolePermissionSeeder extends Seeder
'automation.view',
'automation.manage',
'automation.execute',
'ai.view',
'ai.manage',
'ai.execute',
];
foreach ($permissions as $permission) {
+2 -1
View File
@@ -39,5 +39,6 @@
"Enterprise": true,
"Commerce": true,
"Security": true,
"Automation": true
"Automation": true,
"AI": true
}
@@ -37,6 +37,7 @@
</flux:sidebar.group>
<flux:sidebar.group :heading="__('Platform')" class="grid"><flux:sidebar.item icon="server-stack" :href="route('administration.enterprise.index')" :current="request()->routeIs('administration.enterprise.*')" wire:navigate>{{ __('Enterprise Services') }}</flux:sidebar.item><flux:sidebar.item icon="globe-alt" :href="route('administration.sites.index')" :current="request()->routeIs('administration.sites.*')" wire:navigate>{{ __('Sites') }}</flux:sidebar.item><flux:sidebar.item icon="home" :href="route('dashboard')" :current="request()->routeIs('dashboard')" wire:navigate>{{ __('Dashboard') }}</flux:sidebar.item><flux:sidebar.item icon="squares-2x2" :href="route('administration.dashboard')" :current="request()->routeIs('administration.dashboard')" wire:navigate>{{ __('Administration') }}</flux:sidebar.item></flux:sidebar.group>
<flux:sidebar.group :heading="__('Developer')" class="grid"><flux:sidebar.item icon="code-bracket" :href="route('administration.api.index')" :current="request()->routeIs('administration.api.*')" wire:navigate>{{ __('API & Webhooks') }}</flux:sidebar.item><flux:sidebar.item icon="command-line" href="{{ route('administration.developer.index') }}" :current="request()->routeIs('administration.developer.*')" wire:navigate>{{ __('Developer Tools') }}</flux:sidebar.item></flux:sidebar.group>
<flux:sidebar.group :heading="__('Artificial Intelligence')" class="grid"><flux:sidebar.item icon="sparkles" :href="route('administration.ai.index')" :current="request()->routeIs('administration.ai.*')" wire:navigate>{{ __('AI Platform') }}</flux:sidebar.item></flux:sidebar.group>
<flux:sidebar.group :heading="__('Automation')" class="grid"><flux:sidebar.item icon="bolt" :href="route('administration.automation.index')" :current="request()->routeIs('administration.automation.*')" wire:navigate>{{ __('Automation') }}</flux:sidebar.item></flux:sidebar.group>
<flux:sidebar.group :heading="__('Workflow')" class="grid"><flux:sidebar.item icon="arrows-right-left" :href="route('administration.workflows.index')" :current="request()->routeIs('administration.workflows.*')" wire:navigate>{{ __('Review Queue') }}</flux:sidebar.item></flux:sidebar.group>
<flux:sidebar.group :heading="__('Management')" class="grid"><flux:sidebar.item icon="users" :href="route('administration.users.index')" :current="request()->routeIs('administration.users.*')" wire:navigate>{{ __('Users') }}</flux:sidebar.item><flux:sidebar.item icon="shield-check" :href="route('administration.roles.index')" :current="request()->routeIs('administration.roles.*')" wire:navigate>{{ __('Roles & Permissions') }}</flux:sidebar.item><flux:sidebar.item icon="photo" :href="route('administration.media.index')" :current="request()->routeIs('administration.media.*')" wire:navigate>{{ __('Media') }}</flux:sidebar.item><flux:sidebar.item icon="magnifying-glass" :href="route('administration.search.index')" :current="request()->routeIs('administration.search.*')" wire:navigate>{{ __('Search') }}</flux:sidebar.item></flux:sidebar.group>