diff --git a/Modules/AI/Config/config.php b/Modules/AI/Config/config.php new file mode 100644 index 0000000..cbd8a62 --- /dev/null +++ b/Modules/AI/Config/config.php @@ -0,0 +1,8 @@ + 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), +]; diff --git a/Modules/AI/Console/PruneAIExecutions.php b/Modules/AI/Console/PruneAIExecutions.php new file mode 100644 index 0000000..80b1cf9 --- /dev/null +++ b/Modules/AI/Console/PruneAIExecutions.php @@ -0,0 +1,9 @@ +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; } +} diff --git a/Modules/AI/Console/TestAIProvider.php b/Modules/AI/Console/TestAIProvider.php new file mode 100644 index 0000000..97c8745 --- /dev/null +++ b/Modules/AI/Console/TestAIProvider.php @@ -0,0 +1,9 @@ +testProvider((int)$this->argument('provider')); $this->{$result['ok']?'info':'error'}($result['message']); return $result['ok']?self::SUCCESS:self::FAILURE; } +} diff --git a/Modules/AI/Contracts/AIManager.php b/Modules/AI/Contracts/AIManager.php new file mode 100644 index 0000000..55dda71 --- /dev/null +++ b/Modules/AI/Contracts/AIManager.php @@ -0,0 +1,9 @@ +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'); + } +}; diff --git a/Modules/AI/Drivers/OpenAICompatibleDriver.php b/Modules/AI/Drivers/OpenAICompatibleDriver.php new file mode 100644 index 0000000..2e24a21 --- /dev/null +++ b/Modules/AI/Drivers/OpenAICompatibleDriver.php @@ -0,0 +1,27 @@ +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.']; + } +} diff --git a/Modules/AI/Http/Controllers/AIApiController.php b/Modules/AI/Http/Controllers/AIApiController.php new file mode 100644 index 0000000..779facc --- /dev/null +++ b/Modules/AI/Http/Controllers/AIApiController.php @@ -0,0 +1,16 @@ +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); + } +} diff --git a/Modules/AI/Http/Controllers/AIController.php b/Modules/AI/Http/Controllers/AIController.php new file mode 100644 index 0000000..e9dd69c --- /dev/null +++ b/Modules/AI/Http/Controllers/AIController.php @@ -0,0 +1,46 @@ +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.'); + } +} diff --git a/Modules/AI/Models/AIExecution.php b/Modules/AI/Models/AIExecution.php new file mode 100644 index 0000000..65a571f --- /dev/null +++ b/Modules/AI/Models/AIExecution.php @@ -0,0 +1,8 @@ +'array','usage'=>'array','completed_at'=>'datetime']; +} diff --git a/Modules/AI/Models/AIKnowledgeChunk.php b/Modules/AI/Models/AIKnowledgeChunk.php new file mode 100644 index 0000000..93ac068 --- /dev/null +++ b/Modules/AI/Models/AIKnowledgeChunk.php @@ -0,0 +1,8 @@ +'array']; +} diff --git a/Modules/AI/Models/AIKnowledgeSource.php b/Modules/AI/Models/AIKnowledgeSource.php new file mode 100644 index 0000000..18fb99e --- /dev/null +++ b/Modules/AI/Models/AIKnowledgeSource.php @@ -0,0 +1,12 @@ +'array','indexed_at'=>'datetime']; + public function source(): MorphTo { return $this->morphTo(); } + public function chunks(): HasMany { return $this->hasMany(AIKnowledgeChunk::class); } +} diff --git a/Modules/AI/Models/AIPrompt.php b/Modules/AI/Models/AIPrompt.php new file mode 100644 index 0000000..3dbed94 --- /dev/null +++ b/Modules/AI/Models/AIPrompt.php @@ -0,0 +1,12 @@ +'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; + } +} diff --git a/Modules/AI/Models/AIProvider.php b/Modules/AI/Models/AIProvider.php new file mode 100644 index 0000000..7450926 --- /dev/null +++ b/Modules/AI/Models/AIProvider.php @@ -0,0 +1,9 @@ +'encrypted','models'=>'array','options'=>'array','is_active'=>'boolean','is_default'=>'boolean','last_tested_at'=>'datetime']; + protected $hidden=['api_key']; +} diff --git a/Modules/AI/Providers/AIServiceProvider.php b/Modules/AI/Providers/AIServiceProvider.php new file mode 100644 index 0000000..929b215 --- /dev/null +++ b/Modules/AI/Providers/AIServiceProvider.php @@ -0,0 +1,22 @@ +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]); + } +} diff --git a/Modules/AI/Providers/RouteServiceProvider.php b/Modules/AI/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..7978a18 --- /dev/null +++ b/Modules/AI/Providers/RouteServiceProvider.php @@ -0,0 +1,12 @@ +group(module_path('AI', 'Routes/web.php')); + Route::middleware('api')->prefix('api/v1')->group(module_path('AI', 'Routes/api.php')); + } +} diff --git a/Modules/AI/Resources/views/admin/index.blade.php b/Modules/AI/Resources/views/admin/index.blade.php new file mode 100644 index 0000000..3948fa0 --- /dev/null +++ b/Modules/AI/Resources/views/admin/index.blade.php @@ -0,0 +1,21 @@ + +
+
AI PlatformConfigure hosted or local models, reusable prompts, execution history and knowledge sources.
+@if(session('status')){{ session('status') }}@endif +@if(session('error')){{ session('error') }}@endif +
+Providers{{ $providers->count() }} +Prompts{{ $prompts->total() }} +Executions{{ \Modules\AI\Models\AIExecution::count() }} +Knowledge sources{{ \Modules\AI\Models\AIKnowledgeSource::count() }} +
+
+Provider
@csrf + +Add provider +
@foreach($providers as $provider)
{{ $provider->name }}
{{ $provider->driver }} · {{ $provider->default_model }}
@csrfTest
@endforeach
+Reusable prompt
@csrf +Add prompt
+Knowledge source
@csrfIndex source
@foreach($sources as $source)
{{ $source->name }}
{{ $source->status }} · {{ $source->chunk_count }} chunks
@endforeach
+Recent executions
@forelse($executions as $execution)
{{ $execution->purpose ?: 'Prompt execution' }}{{ $execution->status }}
{{ $execution->model }} · {{ $execution->duration_ms }} ms · {{ $execution->created_at?->diffForHumans() }}
@emptyNo executions yet.@endforelse
+
diff --git a/Modules/AI/Routes/api.php b/Modules/AI/Routes/api.php new file mode 100644 index 0000000..835cf14 --- /dev/null +++ b/Modules/AI/Routes/api.php @@ -0,0 +1,4 @@ +middleware(['auth.api','ability:ai.execute'])->name('api.ai.execute'); diff --git a/Modules/AI/Routes/web.php b/Modules/AI/Routes/web.php new file mode 100644 index 0000000..2d28220 --- /dev/null +++ b/Modules/AI/Routes/web.php @@ -0,0 +1,11 @@ +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'); +}); diff --git a/Modules/AI/Services/AIManager.php b/Modules/AI/Services/AIManager.php new file mode 100644 index 0000000..d74fd7f --- /dev/null +++ b/Modules/AI/Services/AIManager.php @@ -0,0 +1,42 @@ +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}].") }; + } +} diff --git a/Modules/AI/Services/KnowledgeIndexer.php b/Modules/AI/Services/KnowledgeIndexer.php new file mode 100644 index 0000000..c9eb0ff --- /dev/null +++ b/Modules/AI/Services/KnowledgeIndexer.php @@ -0,0 +1,20 @@ +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; + } +} diff --git a/Modules/AI/Tests/Feature/ModuleBootTest.php b/Modules/AI/Tests/Feature/ModuleBootTest.php new file mode 100644 index 0000000..39704ff --- /dev/null +++ b/Modules/AI/Tests/Feature/ModuleBootTest.php @@ -0,0 +1,11 @@ +assertTrue(class_exists(\Modules\AI\Providers\AIServiceProvider::class)); + $this->assertTrue(app()->bound(\Modules\AI\Contracts\AIManager::class)); + } +} diff --git a/Modules/AI/module.json b/Modules/AI/module.json new file mode 100644 index 0000000..dda2c66 --- /dev/null +++ b/Modules/AI/module.json @@ -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"]} diff --git a/Modules/Roles/Database/Seeders/RolePermissionSeeder.php b/Modules/Roles/Database/Seeders/RolePermissionSeeder.php index 3942bc8..6195898 100644 --- a/Modules/Roles/Database/Seeders/RolePermissionSeeder.php +++ b/Modules/Roles/Database/Seeders/RolePermissionSeeder.php @@ -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) { diff --git a/modules_statuses.json b/modules_statuses.json index d79fc6d..11b3f32 100644 --- a/modules_statuses.json +++ b/modules_statuses.json @@ -39,5 +39,6 @@ "Enterprise": true, "Commerce": true, "Security": true, - "Automation": true + "Automation": true, + "AI": true } diff --git a/resources/themes/common/views/components/layouts/app/sidebar.blade.php b/resources/themes/common/views/components/layouts/app/sidebar.blade.php index 665661c..d29a7af 100644 --- a/resources/themes/common/views/components/layouts/app/sidebar.blade.php +++ b/resources/themes/common/views/components/layouts/app/sidebar.blade.php @@ -37,6 +37,7 @@ {{ __('Enterprise Services') }}{{ __('Sites') }}{{ __('Dashboard') }}{{ __('Administration') }} {{ __('API & Webhooks') }}{{ __('Developer Tools') }} +{{ __('AI Platform') }} {{ __('Automation') }} {{ __('Review Queue') }} {{ __('Users') }}{{ __('Roles & Permissions') }}{{ __('Media') }}{{ __('Search') }}