move into sonardesk
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
<?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),
|
||||
];
|
||||
@@ -0,0 +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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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 testProvider(int $providerId): array;
|
||||
}
|
||||
@@ -0,0 +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 test(AIProvider $provider): array;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?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,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));
|
||||
|
||||
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,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
|
||||
{
|
||||
$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,69 @@
|
||||
<?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.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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'];
|
||||
}
|
||||
@@ -0,0 +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'];
|
||||
}
|
||||
@@ -0,0 +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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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'];
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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'];
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?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,15 @@
|
||||
<?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>
|
||||
@@ -0,0 +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');
|
||||
@@ -0,0 +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');
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
<?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}].")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?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,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(AIServiceProvider::class));
|
||||
$this->assertTrue(app()->bound(AIManager::class));
|
||||
}
|
||||
}
|
||||
@@ -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"]}
|
||||
Reference in New Issue
Block a user