style updates

This commit is contained in:
Mark Gregory
2026-07-24 14:59:44 +01:00
parent 24ab771cd3
commit dd23205d0b
482 changed files with 7173 additions and 1435 deletions
+1
View File
@@ -1,4 +1,5 @@
<?php
return [
'default_provider' => env('AI_DEFAULT_PROVIDER'),
'timeout' => (int) env('AI_TIMEOUT', 60),
+15 -2
View File
@@ -1,9 +1,22 @@
<?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; }
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;
}
}
+14 -2
View File
@@ -1,9 +1,21 @@
<?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; }
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;
}
}
+5 -1
View File
@@ -1,9 +1,13 @@
<?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 execute(AIPrompt|string $prompt, array $variables = [], array $options = []): AIExecution;
public function testProvider(int $providerId): array;
}
+5 -1
View File
@@ -1,8 +1,12 @@
<?php
namespace Modules\AI\Contracts;
use Modules\AI\Models\AIProvider;
interface AIProviderDriver
{
public function generate(AIProvider $provider, array $messages, array $options=[]): array;
public function generate(AIProvider $provider, array $messages, array $options = []): array;
public function test(AIProvider $provider): array;
}
@@ -1,46 +1,90 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
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();
$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();
$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();
$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();
$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']);
$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');
Schema::dropIfExists('ai_knowledge_chunks');
Schema::dropIfExists('ai_knowledge_sources');
Schema::dropIfExists('ai_executions');
Schema::dropIfExists('ai_prompts');
Schema::dropIfExists('ai_providers');
}
};
+18 -9
View File
@@ -1,27 +1,36 @@
<?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));
$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
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];
$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.'];
$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.'];
}
}
@@ -1,16 +1,20 @@
<?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
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);
$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);
}
}
+38 -15
View File
@@ -1,5 +1,7 @@
<?php
namespace Modules\AI\Http\Controllers;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
@@ -11,36 +13,57 @@ 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()]);
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.');
$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
public function testProvider(AIProvider $provider, AIManager $manager): RedirectResponse
{
$result=$manager->testProvider($provider->id); return back()->with($result['ok']?'status':'error',$result['message']);
$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.');
$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
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);
$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
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.');
$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.');
}
}
+6 -2
View File
@@ -1,8 +1,12 @@
<?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'];
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'];
}
+6 -2
View File
@@ -1,8 +1,12 @@
<?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'];
protected $fillable = ['ai_knowledge_source_id', 'position', 'content', 'metadata'];
protected $casts = ['metadata' => 'array'];
}
+16 -4
View File
@@ -1,12 +1,24 @@
<?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); }
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);
}
}
+8 -3
View File
@@ -1,12 +1,17 @@
<?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'];
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;
return preg_replace_callback('/\{\{\s*([\w.]+)\s*\}\}/', fn ($m) => (string) data_get($variables, $m[1], ''), $this->user_template) ?? $this->user_template;
}
}
+8 -3
View File
@@ -1,9 +1,14 @@
<?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'];
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'];
}
+7 -1
View File
@@ -1,10 +1,13 @@
<?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
@@ -13,10 +16,13 @@ final class AIServiceProvider extends ServiceProvider
$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]);
if ($this->app->runningInConsole()) {
$this->commands([TestAIProvider::class, PruneAIExecutions::class]);
}
}
}
@@ -1,7 +1,10 @@
<?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
+3 -1
View File
@@ -1,4 +1,6 @@
<?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');
Route::post('/ai/prompts/{prompt:slug}/execute', [AIApiController::class, 'execute'])->middleware(['auth.api', 'ability:ai.execute'])->name('api.ai.execute');
+9 -7
View File
@@ -1,11 +1,13 @@
<?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');
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');
});
+38 -15
View File
@@ -1,5 +1,7 @@
<?php
namespace Modules\AI\Services;
use Illuminate\Support\Str;
use Modules\AI\Contracts\AIManager as AIManagerContract;
use Modules\AI\Contracts\AIProviderDriver;
@@ -7,36 +9,57 @@ 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
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);
$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()]);
$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()]);
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()]; }
$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();
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}].") };
return match ($provider->driver) {
'openai_compatible','openai','lm_studio','ollama' => app(OpenAICompatibleDriver::class), default => throw new \InvalidArgumentException("Unsupported AI driver [{$provider->driver}].")
};
}
}
+28 -9
View File
@@ -1,20 +1,39 @@
<?php
namespace Modules\AI\Services;
use Modules\AI\Models\AIKnowledgeSource;
final class KnowledgeIndexer
{
public function index(AIKnowledgeSource $source,int $chunkSize=1800): AIKnowledgeSource
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()]);
$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
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;
$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;
}
}
+7 -2
View File
@@ -1,11 +1,16 @@
<?php
namespace Modules\AI\Tests\Feature;
use Modules\AI\Contracts\AIManager;
use Modules\AI\Providers\AIServiceProvider;
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));
$this->assertTrue(class_exists(AIServiceProvider::class));
$this->assertTrue(app()->bound(AIManager::class));
}
}