New_Micro_Learning/backend/app/Modules/AI/Http/AiConnectionController.php

258 خطوط
12 KiB
PHP

<?php
namespace App\Modules\AI\Http;
use App\Http\Controllers\Controller;
use App\Modules\AI\Infrastructure\AiEndpointPolicy;
use App\Modules\Identity\Application\RolePermissions;
use App\Modules\Identity\Domain\Enums\Permission;
use App\Modules\Tenancy\Application\TenantContext;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
final class AiConnectionController extends Controller
{
public function __construct(
private readonly TenantContext $tenant,
private readonly RolePermissions $permissions,
private readonly AiEndpointPolicy $endpointPolicy,
) {}
public function index(Request $request): JsonResponse
{
$this->author($request);
$items = DB::table('ai_provider_connections')->where('organization_id', $this->tenant->id())->orderByDesc('is_default')->orderBy('name')->get()->map(fn ($row) => $this->payload($row));
return response()->json(['data' => ['items' => $items, 'providers' => $this->providers()]]);
}
public function store(Request $request): JsonResponse
{
$this->author($request);
$data = $this->validated($request);
$id = (string) Str::ulid();
DB::transaction(function () use ($data, $id, $request): void {
$this->lockOrganization();
DB::table('ai_provider_connections')->insert([
'id' => $id, 'organization_id' => $this->tenant->id(), 'created_by' => $request->user()->getKey(),
...$this->columns($data), 'encrypted_api_key' => filled($data['apiKey'] ?? null) ? Crypt::encryptString($data['apiKey']) : null,
'models' => json_encode([]), 'is_default' => false, 'created_at' => now(), 'updated_at' => now(),
]);
$this->normalizeDefault();
}, 3);
return response()->json(['data' => $this->payload($this->connection($id))], 201);
}
public function update(Request $request, string $connection): JsonResponse
{
$this->author($request);
$data = $this->validated($request);
$columns = [...$this->columns($data), 'updated_at' => now()];
if (filled($data['apiKey'] ?? null)) {
$columns['encrypted_api_key'] = Crypt::encryptString($data['apiKey']);
}
if (($data['clearApiKey'] ?? false) === true) {
$columns['encrypted_api_key'] = null;
}
DB::transaction(function () use ($connection, $columns): void {
$this->lockOrganization();
$row = $this->lockedConnection($connection);
DB::table('ai_provider_connections')->where('id', $row->id)->update($columns);
$this->normalizeDefault();
}, 3);
return response()->json(['data' => $this->payload($this->connection($connection))]);
}
public function destroy(Request $request, string $connection): JsonResponse
{
$this->author($request);
DB::transaction(function () use ($connection): void {
$this->lockOrganization();
$row = $this->lockedConnection($connection);
DB::table('ai_provider_connections')->where('id', $row->id)->delete();
$this->normalizeDefault();
}, 3);
return response()->json(status: 204);
}
public function makeDefault(Request $request, string $connection): JsonResponse
{
$this->author($request);
DB::transaction(function () use ($connection): void {
$this->lockOrganization();
$row = $this->lockedConnection($connection);
abort_unless($row->enabled, 422, 'اتصال غیرفعال نمی‌تواند پیش‌فرض باشد.');
$this->normalizeDefault($row->id);
}, 3);
return response()->json(['data' => $this->payload($this->connection($connection))]);
}
public function test(Request $request, string $connection): JsonResponse
{
$this->author($request);
$row = $this->connection($connection);
$result = $this->probe($row);
DB::table('ai_provider_connections')->where('id', $row->id)->update(['last_status' => $result['connected'] ? 'connected' : 'failed', 'last_latency_ms' => $result['latencyMs'], 'last_error' => $result['connected'] ? null : $result['message'], 'last_tested_at' => now(), 'updated_at' => now()]);
return response()->json(['data' => $result]);
}
public function discover(Request $request, string $connection): JsonResponse
{
$this->author($request);
$row = $this->connection($connection);
try {
$response = $this->client($row)->get(rtrim($row->base_url, '/').'/models');
abort_unless($response->successful(), 422, 'فهرست مدل‌ها از ارائه‌دهنده دریافت نشد.');
$models = collect($response->json('data', []))->map(fn ($model) => is_array($model) ? ($model['id'] ?? null) : null)->filter()->unique()->sort()->values()->all();
if ($models === []) {
throw ValidationException::withMessages(['connection' => ['ارائه‌دهنده هیچ مدل قابل استفاده‌ای برنگرداند.']]);
}
DB::table('ai_provider_connections')->where('id', $row->id)->update(['models' => json_encode($models), 'default_model' => in_array($row->default_model, $models, true) ? $row->default_model : $models[0], 'last_status' => 'connected', 'last_error' => null, 'last_tested_at' => now(), 'updated_at' => now()]);
return response()->json(['data' => ['models' => $models]]);
} catch (ValidationException $exception) {
throw $exception;
} catch (\Throwable) {
throw ValidationException::withMessages(['connection' => ['کشف مدل‌ها ناموفق بود؛ Endpoint و کلید دسترسی را بررسی کنید.']]);
}
}
private function validated(Request $request): array
{
$data = $request->validate([
'name' => ['required', 'string', 'max:160'],
'provider' => ['required', Rule::in(array_keys($this->providers()))],
'mode' => ['required', Rule::in(['local', 'online'])],
'baseUrl' => ['required', 'url:http,https', 'max:1000'],
'apiKey' => ['nullable', 'string', 'max:4000'], 'clearApiKey' => ['nullable', 'boolean'],
'defaultModel' => ['nullable', 'string', 'max:255'], 'timeoutSeconds' => ['required', 'integer', 'between:10,600'], 'enabled' => ['required', 'boolean'],
]);
if ($data['mode'] === 'online' && ! str_starts_with($data['baseUrl'], 'https://')) {
throw ValidationException::withMessages(['baseUrl' => ['اتصال آنلاین باید از HTTPS استفاده کند.']]);
}
try {
$this->endpointPolicy->assertAllowed($data['provider'], $data['mode'], $data['baseUrl']);
} catch (InvalidArgumentException $exception) {
throw ValidationException::withMessages(['baseUrl' => [$exception->getMessage()]]);
}
return $data;
}
private function columns(array $data): array
{
return ['name' => trim($data['name']), 'provider' => $data['provider'], 'mode' => $data['mode'], 'base_url' => rtrim($data['baseUrl'], '/'), 'default_model' => $data['defaultModel'] ?: null, 'timeout_seconds' => $data['timeoutSeconds'], 'enabled' => $data['enabled']];
}
private function connection(string $id): object
{
$row = DB::table('ai_provider_connections')->where('organization_id', $this->tenant->id())->where('id', $id)->first();
abort_unless($row, 404);
return $row;
}
private function lockedConnection(string $id): object
{
$row = DB::table('ai_provider_connections')
->where('organization_id', $this->tenant->id())
->where('id', $id)
->lockForUpdate()
->first();
abort_unless($row, 404);
return $row;
}
private function lockOrganization(): void
{
DB::table('organizations')->where('id', $this->tenant->id())->lockForUpdate()->first();
}
private function normalizeDefault(?string $preferredId = null): void
{
$enabled = DB::table('ai_provider_connections')
->where('organization_id', $this->tenant->id())
->where('enabled', true)
->orderByDesc('is_default')
->orderBy('created_at')
->orderBy('id')
->get(['id', 'is_default']);
$selectedId = $preferredId && $enabled->contains('id', $preferredId)
? $preferredId
: $enabled->firstWhere('is_default', true)?->id ?? $enabled->first()?->id;
DB::table('ai_provider_connections')
->where('organization_id', $this->tenant->id())
->where('is_default', true)
->update(['is_default' => false, 'updated_at' => now()]);
if ($selectedId) {
DB::table('ai_provider_connections')
->where('organization_id', $this->tenant->id())
->where('id', $selectedId)
->where('enabled', true)
->update(['is_default' => true, 'updated_at' => now()]);
}
}
private function payload(object $row): array
{
return ['id' => $row->id, 'name' => $row->name, 'provider' => $row->provider, 'mode' => $row->mode, 'baseUrl' => $row->base_url, 'hasApiKey' => filled($row->encrypted_api_key), 'models' => json_decode($row->models ?: '[]', true), 'defaultModel' => $row->default_model, 'timeoutSeconds' => $row->timeout_seconds, 'enabled' => (bool) $row->enabled, 'isDefault' => (bool) $row->is_default, 'lastStatus' => $row->last_status, 'lastLatencyMs' => $row->last_latency_ms, 'lastError' => $row->last_error, 'lastTestedAt' => $row->last_tested_at];
}
private function probe(object $row): array
{
$started = microtime(true);
try {
$response = $this->client($row)->get(rtrim($row->base_url, '/').'/models');
$connected = $response->successful();
return ['connected' => $connected, 'latencyMs' => (int) round((microtime(true) - $started) * 1000), 'message' => $connected ? 'اتصال با موفقیت برقرار شد.' : 'ارائه‌دهنده پاسخ معتبر نداد.'];
} catch (\Throwable) {
return ['connected' => false, 'latencyMs' => (int) round((microtime(true) - $started) * 1000), 'message' => 'اتصال با ارائه‌دهنده برقرار نشد.'];
}
}
private function client(object $row): PendingRequest
{
$client = Http::acceptJson()
->timeout((int) $row->timeout_seconds)
->withOptions($this->endpointPolicy->requestOptions($row->provider, $row->mode, $row->base_url));
if (filled($row->encrypted_api_key)) {
$client = $client->withToken(Crypt::decryptString($row->encrypted_api_key));
}
return $client;
}
private function providers(): array
{
return [
'openai' => ['label' => 'OpenAI', 'mode' => 'online', 'defaultBaseUrl' => 'https://api.openai.com/v1', 'requiresApiKey' => true],
'openai_compatible' => ['label' => 'سرویس آنلاین OpenAI Compatible', 'mode' => 'online', 'defaultBaseUrl' => 'https://', 'requiresApiKey' => true],
'ollama' => ['label' => 'Ollama', 'mode' => 'local', 'defaultBaseUrl' => 'http://127.0.0.1:11434/v1', 'requiresApiKey' => false],
'lm_studio' => ['label' => 'LM Studio', 'mode' => 'local', 'defaultBaseUrl' => 'http://127.0.0.1:1234/v1', 'requiresApiKey' => false],
'vllm' => ['label' => 'vLLM', 'mode' => 'local', 'defaultBaseUrl' => 'http://127.0.0.1:8000/v1', 'requiresApiKey' => false],
'localai' => ['label' => 'LocalAI', 'mode' => 'local', 'defaultBaseUrl' => 'http://127.0.0.1:8080/v1', 'requiresApiKey' => false],
];
}
private function author(Request $request): void
{
abort_unless($this->permissions->allows($request->user(), Permission::CoursesAuthor), 403);
}
}