From 090ec2c44b313535d8f97f5e7f532c9f32409191 Mon Sep 17 00:00:00 2001 From: soeren Date: Wed, 3 Dec 2025 14:48:45 +0100 Subject: [PATCH] Runware/ComfyUI fixes, dashboard links, import action, Leonardo plugin, widget, added status field and test connection button --- app/Api/Plugins/ComfyUi.php | 81 ++++-- app/Api/Plugins/LeonardoAi.php | 270 +++++++++++++++++ app/Api/Plugins/RunwareAi.php | 134 +++++---- .../Resources/AiModels/AiModelResource.php | 13 + .../ApiProviders/ApiProviderResource.php | 7 + .../ApiProviders/Pages/CreateApiProvider.php | 9 +- .../ApiProviders/Pages/EditApiProvider.php | 33 ++- .../Resources/Styles/StyleResource.php | 93 ++++++ app/Filament/Widgets/ApiProviderHealth.php | 275 ++++++++++++++++++ app/Models/ApiProvider.php | 36 ++- app/Providers/Filament/AdminPanelProvider.php | 1 + composer.json | 1 + composer.lock | 63 +++- ...d_status_fields_to_api_providers_table.php | 36 +++ resources/js/Components/ImageContextMenu.vue | 57 +--- resources/js/Pages/Home.vue | 52 +++- 16 files changed, 1019 insertions(+), 142 deletions(-) create mode 100644 app/Api/Plugins/LeonardoAi.php create mode 100644 app/Filament/Widgets/ApiProviderHealth.php create mode 100644 database/migrations/2025_12_03_090609_add_status_fields_to_api_providers_table.php diff --git a/app/Api/Plugins/ComfyUi.php b/app/Api/Plugins/ComfyUi.php index 30b6b9a..6aaae6e 100644 --- a/app/Api/Plugins/ComfyUi.php +++ b/app/Api/Plugins/ComfyUi.php @@ -4,6 +4,7 @@ namespace App\Api\Plugins; use App\Models\ApiProvider; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Str; class ComfyUi implements ApiPluginInterface { @@ -41,6 +42,7 @@ class ComfyUi implements ApiPluginInterface } else { $this->logError('Failed to enable ComfyUi plugin.', ['provider_name' => $this->apiProvider->name]); } + return $result; } @@ -53,12 +55,14 @@ class ComfyUi implements ApiPluginInterface } else { $this->logError('Failed to disable ComfyUi plugin.', ['provider_name' => $this->apiProvider->name]); } + return $result; } public function getStatus(string $imageUUID): array { $this->logDebug('Getting status for image.', ['image_uuid' => $imageUUID]); + // Implement ComfyUI specific status check return ['status' => 'unknown']; } @@ -66,6 +70,7 @@ class ComfyUi implements ApiPluginInterface public function getProgress(string $imageUUID): array { $this->logDebug('Progress updates are handled via WebSocket.', ['image_uuid' => $imageUUID]); + return ['progress' => 0]; // Progress is now handled by WebSocket } @@ -74,11 +79,12 @@ class ComfyUi implements ApiPluginInterface // This method is no longer used for progress polling, but might be used for final result retrieval $apiUrl = rtrim($this->apiProvider->api_url, '/'); $timeout = 60; // seconds - $this->logDebug('ComfyUI History API URL:', ['url' => $apiUrl . '/history/' . $promptId, 'timeout' => $timeout]); - $response = Http::timeout($timeout)->get($apiUrl . '/history/' . $promptId); + $this->logDebug('ComfyUI History API URL:', ['url' => $apiUrl.'/history/'.$promptId, 'timeout' => $timeout]); + $response = Http::timeout($timeout)->get($apiUrl.'/history/'.$promptId); if ($response->failed()) { throw new \Exception('Failed to get history from ComfyUI'); } + return $response->json(); } @@ -87,7 +93,7 @@ class ComfyUi implements ApiPluginInterface $this->logInfo('Starting ComfyUI style change process.', ['image_id' => $image->id, 'style_id' => $style->id]); // 1. Upload image to ComfyUI - $uploadResponse = $this->uploadImage(public_path('storage/' . $image->path)); + $uploadResponse = $this->uploadImage(public_path('storage/'.$image->path)); $filename = $uploadResponse['name']; // 2. Construct the prompt @@ -106,7 +112,7 @@ class ComfyUi implements ApiPluginInterface $this->logInfo('Uploading image to ComfyUI.', ['image_path' => $imagePath]); $response = Http::attach( 'image', file_get_contents($imagePath), basename($imagePath) - )->timeout(60)->post(rtrim($this->apiProvider->api_url, '/') . '/upload/image', [ + )->timeout(60)->post(rtrim($this->apiProvider->api_url, '/').'/upload/image', [ 'type' => 'input', 'overwrite' => 'false', ]); @@ -131,7 +137,7 @@ class ComfyUi implements ApiPluginInterface $modelParams = []; } } - + if (is_string($styleParams)) { $styleParams = json_decode($styleParams, true); if (json_last_error() !== JSON_ERROR_NONE) { @@ -161,9 +167,9 @@ class ComfyUi implements ApiPluginInterface if (json_last_error() !== JSON_ERROR_NONE) { $this->logError('Failed to decode workflow JSON after placeholder replacement.', [ 'json_error' => json_last_error_msg(), - 'workflow_string' => $workflow + 'workflow_string' => $workflow, ]); - throw new \Exception('Failed to construct valid ComfyUI workflow JSON: ' . json_last_error_msg()); + throw new \Exception('Failed to construct valid ComfyUI workflow JSON: '.json_last_error_msg()); } return $decodedWorkflow; @@ -172,7 +178,7 @@ class ComfyUi implements ApiPluginInterface private function queuePrompt(array $promptData): array { $this->logInfo('Queueing prompt in ComfyUI.'); - $response = Http::timeout(60)->post(rtrim($this->apiProvider->api_url, '/') . '/prompt', ['prompt' => $promptData]); + $response = Http::timeout(60)->post(rtrim($this->apiProvider->api_url, '/').'/prompt', ['prompt' => $promptData]); if ($response->failed()) { $this->logError('Failed to queue prompt in ComfyUI.', ['response' => $response->body()]); @@ -196,7 +202,7 @@ class ComfyUi implements ApiPluginInterface } try { - $response = Http::timeout(60)->get(rtrim($this->apiProvider->api_url, '/') . '/history/' . $promptId); + $response = Http::timeout(60)->get(rtrim($this->apiProvider->api_url, '/').'/history/'.$promptId); $this->logDebug('waitForResult: History API response status.', ['status' => $response->status(), 'prompt_id' => $promptId]); if ($response->failed()) { @@ -219,7 +225,7 @@ class ComfyUi implements ApiPluginInterface $output['images'][0]['subfolder'] ); $this->logInfo('waitForResult: Constructed image URL.', ['imageUrl' => $imageUrl, 'prompt_id' => $promptId]); - + $imageResponse = Http::timeout(60)->get($imageUrl); $this->logDebug('waitForResult: Image fetch response status.', ['status' => $imageResponse->status(), 'imageUrl' => $imageUrl]); @@ -228,6 +234,7 @@ class ComfyUi implements ApiPluginInterface throw new \Exception('Failed to retrieve image data from ComfyUI'); } $this->logInfo('waitForResult: Successfully retrieved image data.', ['prompt_id' => $promptId]); + return base64_encode($imageResponse->body()); } } @@ -245,13 +252,31 @@ class ComfyUi implements ApiPluginInterface public function testConnection(array $data): bool { - $apiUrl = rtrim($data['api_url'], '/'); + $apiUrl = rtrim($data['api_url'] ?? '', '/'); + + if (! $apiUrl) { + throw new \RuntimeException('ComfyUI API-URL fehlt.'); + } + try { - $response = Http::timeout(5)->get($apiUrl . '/queue'); - return $response->successful(); + $response = Http::timeout(5)->get($apiUrl.'/queue'); + + if ($response->successful()) { + return true; + } + + $body = $response->body(); + $snippet = $body ? Str::limit($body, 200) : null; + + $this->logError('ComfyUI connection test failed: HTTP error.', [ + 'status' => $response->status(), + 'body' => $snippet, + ]); + + throw new \RuntimeException('ComfyUI HTTP '.$response->status().($snippet ? ': '.$snippet : '')); } catch (\Exception $e) { $this->logError('ComfyUI connection test failed.', ['error' => $e->getMessage()]); - return false; + throw $e; } } @@ -259,52 +284,57 @@ class ComfyUi implements ApiPluginInterface { $this->logInfo('Checking ComfyUI availability.'); - if (!$this->apiProvider->enabled) { + if (! $this->apiProvider->enabled) { $this->logDebug('ComfyUI provider is disabled.'); + return [ 'available' => false, 'reason' => 'Provider is disabled', 'provider_id' => $this->apiProvider->id, - 'provider_name' => $this->apiProvider->name + 'provider_name' => $this->apiProvider->name, ]; } if (empty($this->apiProvider->api_url)) { $this->logDebug('ComfyUI API URL is not configured.'); + return [ 'available' => false, 'reason' => 'API URL not configured', 'provider_id' => $this->apiProvider->id, - 'provider_name' => $this->apiProvider->name + 'provider_name' => $this->apiProvider->name, ]; } try { - $response = Http::timeout(5)->get(rtrim($this->apiProvider->api_url, '/') . '/queue'); + $response = Http::timeout(5)->get(rtrim($this->apiProvider->api_url, '/').'/queue'); if ($response->successful()) { $this->logInfo('ComfyUI is available.'); + return [ 'available' => true, 'reason' => 'Connection successful', 'provider_id' => $this->apiProvider->id, - 'provider_name' => $this->apiProvider->name + 'provider_name' => $this->apiProvider->name, ]; } else { $this->logError('ComfyUI connection failed.', ['status' => $response->status()]); + return [ 'available' => false, - 'reason' => 'Connection failed: ' . $response->status(), + 'reason' => 'Connection failed: '.$response->status(), 'provider_id' => $this->apiProvider->id, - 'provider_name' => $this->apiProvider->name + 'provider_name' => $this->apiProvider->name, ]; } } catch (\Exception $e) { $this->logError('ComfyUI availability check failed.', ['error' => $e->getMessage()]); + return [ 'available' => false, - 'reason' => 'Connection error: ' . $e->getMessage(), + 'reason' => 'Connection error: '.$e->getMessage(), 'provider_id' => $this->apiProvider->id, - 'provider_name' => $this->apiProvider->name + 'provider_name' => $this->apiProvider->name, ]; } } @@ -312,11 +342,12 @@ class ComfyUi implements ApiPluginInterface public function searchModels(string $searchTerm): array { $this->logInfo('ComfyUI does not support model search. Returning empty list.', ['searchTerm' => $searchTerm]); + return []; } - + public function getStyledImage(string $promptId): string { return $this->waitForResult($promptId); } -} \ No newline at end of file +} diff --git a/app/Api/Plugins/LeonardoAi.php b/app/Api/Plugins/LeonardoAi.php new file mode 100644 index 0000000..fdc6a92 --- /dev/null +++ b/app/Api/Plugins/LeonardoAi.php @@ -0,0 +1,270 @@ +apiProvider->enabled; + } + + public function enable(): bool + { + $this->apiProvider->enabled = true; + + return $this->apiProvider->save(); + } + + public function disable(): bool + { + $this->apiProvider->enabled = false; + + return $this->apiProvider->save(); + } + + public function getStatus(string $imageUUID): array + { + // Not supported directly; handled via getStyledImage polling. + return ['status' => 'unknown']; + } + + public function getProgress(string $imageUUID): array + { + return ['progress' => 0]; + } + + public function processImageStyleChange(Image $image, Style $style): array + { + $this->assertApiConfig(); + + $modelId = $style->aiModel?->model_id; + if (! $modelId || ! in_array($modelId, self::SUPPORTED_MODELS, true)) { + throw new RuntimeException('Unsupported Leonardo model. Please choose a v2 model (Nano Banana Pro, FLUX.2 Pro, Seedream 4.0).'); + } + + $referenceId = $this->uploadReferenceImage($image); + + $width = $style->parameters['width'] ?? 1024; + $height = $style->parameters['height'] ?? 1024; + $styleIds = $style->parameters['style_ids'] ?? []; + $promptEnhance = $style->parameters['prompt_enhance'] ?? 'OFF'; + + $payload = [ + 'model' => $modelId, + 'parameters' => [ + 'width' => $width, + 'height' => $height, + 'prompt' => $style->prompt, + 'quantity' => 1, + 'guidances' => [ + 'image_reference' => [[ + 'image' => [ + 'id' => $referenceId, + 'type' => 'UPLOADED', + ], + 'strength' => 'MID', + ]], + ], + 'style_ids' => $styleIds, + 'prompt_enhance' => $promptEnhance, + ], + 'public' => false, + ]; + + $response = $this->http()->post(self::BASE_URL.'/generations', $payload); + $data = $response->json(); + + $this->throwOnApiError($response, $data); + + $generationId = $data['generationId'] ?? $data['id'] ?? $data['data'][0]['generationId'] ?? $data['data'][0]['id'] ?? null; + if (! $generationId) { + $this->logError('Leonardo generation response missing generationId', ['response' => $data]); + throw new RuntimeException('Leonardo generation did not return an id.'); + } + + // Return identifier to allow polling + return [ + 'prompt_id' => $generationId, + 'plugin' => 'LeonardoAi', + ]; + } + + public function getStyledImage(string $promptId): string + { + $this->assertApiConfig(); + + $response = $this->http()->get(self::BASE_URL.'/generations/'.$promptId); + $data = $response->json(); + + $this->throwOnApiError($response, $data); + + $images = $data['images'] ?? $data['data'][0]['images'] ?? null; + if (! $images || ! isset($images[0]['url'])) { + throw new RuntimeException('Leonardo generation not ready yet or no image URL returned.'); + } + + return $images[0]['url']; + } + + public function testConnection(array $data): bool + { + $apiKey = $data['token'] ?? null; + if (! $apiKey) { + throw new RuntimeException('API key fehlt.'); + } + + $payload = [ + 'model' => self::SUPPORTED_MODELS[0], + 'parameters' => [ + 'prompt' => 'health check', + 'width' => 512, + 'height' => 512, + 'quantity' => 1, + ], + 'public' => false, + ]; + + $response = $this->http($apiKey)->post(self::BASE_URL.'/generations', $payload); + $json = $response->json(); + + $this->throwOnApiError($response, $json); + + return true; + } + + public function searchModels(string $searchTerm): array + { + $models = [ + ['name' => 'Nano Banana Pro', 'id' => 'gemini-image-2'], + ['name' => 'FLUX.2 Pro', 'id' => 'flux-pro-2.0'], + ['name' => 'Seedream 4.0', 'id' => 'seedream-4.0'], + ]; + + return array_values(array_filter($models, function ($model) use ($searchTerm) { + return stripos($model['name'], $searchTerm) !== false || stripos($model['id'], $searchTerm) !== false; + })); + } + + public function checkAvailability(): array + { + try { + $this->testConnection($this->apiProvider->toArray()); + + return [ + 'available' => true, + 'reason' => 'Connection successful', + 'provider_id' => $this->apiProvider->id, + 'provider_name' => $this->apiProvider->name, + ]; + } catch (\Exception $e) { + return [ + 'available' => false, + 'reason' => $e->getMessage(), + 'provider_id' => $this->apiProvider->id, + 'provider_name' => $this->apiProvider->name, + ]; + } + } + + private function uploadReferenceImage(Image $image): string + { + $path = public_path('storage/'.$image->path); + + if (! file_exists($path)) { + throw new RuntimeException('Bild nicht gefunden: '.$path); + } + + $response = $this->http()->attach('image', file_get_contents($path), basename($path)) + ->post(self::BASE_URL.'/init-image', [ + 'extension' => pathinfo($path, PATHINFO_EXTENSION), + ]); + + $json = $response->json(); + $this->throwOnApiError($response, $json); + + $id = $json['id'] ?? $json['imageId'] ?? $json['uploadId'] ?? $json['data'][0]['id'] ?? null; + + if (! $id) { + $this->logError('Leonardo upload response missing id', ['response' => $json]); + throw new RuntimeException('Upload to Leonardo failed (no id returned).'); + } + + return $id; + } + + private function http(?string $apiKey = null) + { + $key = $apiKey ?? $this->apiProvider->token; + + if (! $key) { + throw new RuntimeException('Leonardo API key ist nicht gesetzt.'); + } + + return Http::withHeaders([ + 'Authorization' => 'Bearer '.$key, + 'Accept' => 'application/json', + ]); + } + + private function throwOnApiError($response, ?array $data): void + { + if ($response->successful() && empty($data['errors'])) { + return; + } + + $error = $data['errors'][0] ?? null; + + if ($error) { + $code = $error['code'] ?? 'error'; + $message = $error['message'] ?? 'API error'; + $task = $error['taskUUID'] ?? null; + $detail = trim(($code ? $code.': ' : '').$message.' '.($task ? "(task {$task})" : '')); + throw new RuntimeException($detail); + } + + $status = $response->status(); + $body = $response->body(); + throw new RuntimeException('Leonardo API Fehler '.$status.($body ? ': '.Str::limit($body, 200) : '')); + } + + private function assertApiConfig(): void + { + if (! $this->apiProvider->enabled) { + throw new RuntimeException('API Provider ist deaktiviert.'); + } + + if (! $this->apiProvider->token) { + throw new RuntimeException('Leonardo API key fehlt.'); + } + } +} diff --git a/app/Api/Plugins/RunwareAi.php b/app/Api/Plugins/RunwareAi.php index cc32c97..77d3182 100644 --- a/app/Api/Plugins/RunwareAi.php +++ b/app/Api/Plugins/RunwareAi.php @@ -42,6 +42,7 @@ class RunwareAi implements ApiPluginInterface } else { $this->logError('Failed to enable RunwareAi plugin.', ['provider_name' => $this->apiProvider->name]); } + return $result; } @@ -54,9 +55,10 @@ class RunwareAi implements ApiPluginInterface } else { $this->logError('Failed to disable RunwareAi plugin.', ['provider_name' => $this->apiProvider->name]); } + return $result; } - + public function getStyledImage(string $promptId): string { throw new \Exception('RunwareAi does not support fetching styled images by prompt ID.'); @@ -65,6 +67,7 @@ class RunwareAi implements ApiPluginInterface public function getStatus(string $imageUUID): array { $this->logDebug('Getting status for image.', ['image_uuid' => $imageUUID]); + // Implement RunwareAI specific status check return ['status' => 'unknown']; } @@ -72,6 +75,7 @@ class RunwareAi implements ApiPluginInterface public function getProgress(string $imageUUID): array { $this->logDebug('Getting progress for image.', ['image_uuid' => $imageUUID]); + // Implement RunwareAI specific progress check return ['progress' => 0]; } @@ -79,9 +83,9 @@ class RunwareAi implements ApiPluginInterface public function processImageStyleChange(\App\Models\Image $image, \App\Models\Style $style): array { // Step 1: Upload the original image - $uploadResult = $this->upload(public_path('storage/' . $image->path)); + $uploadResult = $this->upload(public_path('storage/'.$image->path)); - if (!isset($uploadResult['data'][0]['imageUUID'])) { + if (! isset($uploadResult['data'][0]['imageUUID'])) { throw new \Exception('Image upload to AI service failed or returned no UUID.'); } $seedImageUUID = $uploadResult['data'][0]['imageUUID']; @@ -89,7 +93,7 @@ class RunwareAi implements ApiPluginInterface // Step 2: Request style change using the uploaded image's UUID $result = $this->styleChangeRequest($style, $seedImageUUID); - if (!isset($result['base64Data'])) { + if (! isset($result['base64Data'])) { throw new \Exception('AI service did not return base64 image data.'); } @@ -101,41 +105,60 @@ class RunwareAi implements ApiPluginInterface $apiUrl = rtrim($data['api_url'], '/'); $token = $data['token'] ?? null; - if (!$apiUrl || !$token) { + if (! $apiUrl || ! $token) { $this->logError('RunwareAI connection test failed: API URL or Token missing.'); - return false; + throw new \RuntimeException('API URL oder Token fehlt.'); } try { - $response = Http::withHeaders([ - 'Content-Type' => 'application/json', - 'Accept' => 'application/json', - ])->timeout(5)->post($apiUrl, [ + $payload = [[ 'taskType' => 'authentication', 'apiKey' => $token, 'taskUUID' => (string) Str::uuid(), - ]); + ]]; + + $response = Http::withHeaders([ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + ])->timeout(5)->post($apiUrl, $payload); $responseData = $response->json(); - if ($response->successful() && isset($responseData['data']) && !isset($responseData['error'])) { - $this->logInfo('RunwareAI connection test successful: Authentication successful.', [ + // Official Runware format: errors array with code/message. + if (isset($responseData['errors'][0])) { + $error = $responseData['errors'][0]; + $messageParts = [ + $error['code'] ?? 'error', + $error['message'] ?? null, + ]; + $detail = implode(': ', array_filter($messageParts)); + + $this->logError('RunwareAI connection test failed: API returned error.', [ 'status' => $response->status(), - 'response' => $responseData, + 'error' => $error, ]); - return true; - } else { - $errorMessage = $responseData['error'] ?? 'Unknown error'; - $this->logError('RunwareAI connection test failed: Authentication failed.', [ - 'status' => $response->status(), - 'response' => $responseData, - 'error_message' => $errorMessage, - ]); - return false; + + throw new \RuntimeException($detail ?: 'Runware API Fehler.'); } + + if ($response->failed()) { + $this->logError('RunwareAI connection test failed: HTTP error.', [ + 'status' => $response->status(), + 'body' => $response->body(), + ]); + + throw new \RuntimeException('HTTP '.$response->status().' beim Test der Runware-API.'); + } + + $this->logInfo('RunwareAI connection test successful: Authentication successful.', [ + 'status' => $response->status(), + 'response' => $responseData, + ]); + + return true; } catch (\Exception $e) { $this->logError('RunwareAI connection test failed: Exception caught.', ['error' => $e->getMessage()]); - return false; + throw $e; } } @@ -143,33 +166,36 @@ class RunwareAi implements ApiPluginInterface { $this->logInfo('Checking RunwareAI availability.'); - if (!$this->apiProvider->enabled) { + if (! $this->apiProvider->enabled) { $this->logDebug('RunwareAI provider is disabled.'); + return [ 'available' => false, 'reason' => 'Provider is disabled', 'provider_id' => $this->apiProvider->id, - 'provider_name' => $this->apiProvider->name + 'provider_name' => $this->apiProvider->name, ]; } if (empty($this->apiProvider->api_url)) { $this->logDebug('RunwareAI API URL is not configured.'); + return [ 'available' => false, 'reason' => 'API URL not configured', 'provider_id' => $this->apiProvider->id, - 'provider_name' => $this->apiProvider->name + 'provider_name' => $this->apiProvider->name, ]; } if (empty($this->apiProvider->token)) { $this->logDebug('RunwareAI API token is not configured.'); + return [ 'available' => false, 'reason' => 'API token not configured', 'provider_id' => $this->apiProvider->id, - 'provider_name' => $this->apiProvider->name + 'provider_name' => $this->apiProvider->name, ]; } @@ -185,44 +211,47 @@ class RunwareAi implements ApiPluginInterface $responseData = $response->json(); - if ($response->successful() && isset($responseData['data']) && !isset($responseData['error'])) { + if ($response->successful() && isset($responseData['data']) && ! isset($responseData['error'])) { $this->logInfo('RunwareAI is available.'); + return [ 'available' => true, 'reason' => 'Connection successful', 'provider_id' => $this->apiProvider->id, - 'provider_name' => $this->apiProvider->name + 'provider_name' => $this->apiProvider->name, ]; } else { $errorMessage = $responseData['error'] ?? 'Unknown error'; $this->logError('RunwareAI connection failed.', [ 'status' => $response->status(), - 'error_message' => $errorMessage + 'error_message' => $errorMessage, ]); + return [ 'available' => false, - 'reason' => 'Connection failed: ' . $errorMessage, + 'reason' => 'Connection failed: '.$errorMessage, 'provider_id' => $this->apiProvider->id, - 'provider_name' => $this->apiProvider->name + 'provider_name' => $this->apiProvider->name, ]; } } catch (\Exception $e) { $this->logError('RunwareAI availability check failed.', ['error' => $e->getMessage()]); + return [ 'available' => false, - 'reason' => 'Connection error: ' . $e->getMessage(), + 'reason' => 'Connection error: '.$e->getMessage(), 'provider_id' => $this->apiProvider->id, - 'provider_name' => $this->apiProvider->name + 'provider_name' => $this->apiProvider->name, ]; } } - public function searchModels(string $searchTerm): array { $this->logInfo('Attempting model search on RunwareAI.', ['searchTerm' => $searchTerm]); - if (!$this->apiProvider->api_url || !$this->apiProvider->token) { + if (! $this->apiProvider->api_url || ! $this->apiProvider->token) { $this->logError('RunwareAI API URL or Token not configured for model search.', ['provider_name' => $this->apiProvider->name]); + return []; } @@ -231,7 +260,7 @@ class RunwareAi implements ApiPluginInterface try { $response = Http::withHeaders([ - 'Authorization' => 'Bearer ' . $token, + 'Authorization' => 'Bearer '.$token, 'Accept' => 'application/json', 'Content-Type' => 'application/json', ])->timeout(10)->post($apiUrl, [ @@ -242,12 +271,12 @@ class RunwareAi implements ApiPluginInterface 'category' => 'checkpoint', 'limit' => 100, 'taskUUID' => (string) Str::uuid(), - ] + ], ]); $responseData = $response->json(); - if ($response->successful() && isset($responseData['data'][0]['results']) && !isset($responseData['error'])) { + if ($response->successful() && isset($responseData['data'][0]['results']) && ! isset($responseData['error'])) { $models = []; foreach ($responseData['data'][0]['results'] as $model) { $models[] = [ @@ -257,6 +286,7 @@ class RunwareAi implements ApiPluginInterface ]; } $this->logInfo('Model search successful on RunwareAI.', ['searchTerm' => $searchTerm, 'modelsFound' => count($models)]); + return $models; } else { $errorMessage = $responseData['error'] ?? 'Unknown error'; @@ -265,10 +295,12 @@ class RunwareAi implements ApiPluginInterface 'response' => $responseData, 'error_message' => $errorMessage, ]); + return []; } } catch (\Exception $e) { $this->logError('Model search failed on RunwareAI: Exception caught.', ['error' => $e->getMessage()]); + return []; } } @@ -276,7 +308,7 @@ class RunwareAi implements ApiPluginInterface private function upload(string $imagePath): array { $this->logInfo('Attempting to upload image to RunwareAI.', ['image_path' => $imagePath]); - if (!$this->apiProvider->api_url || !$this->apiProvider->token) { + if (! $this->apiProvider->api_url || ! $this->apiProvider->token) { $this->logError('RunwareAI API URL or Token not configured for upload.', ['provider_name' => $this->apiProvider->name]); throw new \Exception('RunwareAI API URL or Token not configured.'); } @@ -285,11 +317,11 @@ class RunwareAi implements ApiPluginInterface $token = $this->apiProvider->token; $taskUUID = (string) Str::uuid(); - $imageData = 'data:image/png;base64,' . base64_encode(file_get_contents($imagePath)); + $imageData = 'data:image/png;base64,'.base64_encode(file_get_contents($imagePath)); try { $response = Http::withHeaders([ - 'Authorization' => 'Bearer ' . $token, + 'Authorization' => 'Bearer '.$token, 'Accept' => 'application/json', 'Content-Type' => 'application/json', ])->post($apiUrl, [ @@ -297,11 +329,12 @@ class RunwareAi implements ApiPluginInterface 'taskType' => 'imageUpload', 'taskUUID' => $taskUUID, 'image' => $imageData, - ] + ], ]); $response->throw(); $this->logInfo('Image uploaded successfully to RunwareAI.', ['task_uuid' => $taskUUID, 'response' => $response->json()]); + return $response->json(); } catch (\Exception $e) { $this->logError('Image upload to RunwareAI failed.', ['error' => $e->getMessage(), 'image_path' => $imagePath]); @@ -312,7 +345,7 @@ class RunwareAi implements ApiPluginInterface private function styleChangeRequest(\App\Models\Style $style, string $seedImageUUID): array { $this->logInfo('Attempting style change request to RunwareAI.', ['style_id' => $style->id, 'seed_image_uuid' => $seedImageUUID]); - if (!$this->apiProvider->api_url || !$this->apiProvider->token) { + if (! $this->apiProvider->api_url || ! $this->apiProvider->token) { $this->logError('RunwareAI API URL or Token not configured for style change.', ['provider_name' => $this->apiProvider->name]); throw new \Exception('RunwareAI API URL or Token not configured.'); } @@ -331,7 +364,7 @@ class RunwareAi implements ApiPluginInterface 'positivePrompt' => $style->prompt, 'seedImage' => $seedImageUUID, 'outputType' => 'base64Data', - 'model' => $style->aiModel->model_id + 'model' => $style->aiModel->model_id, ]; foreach ($mergedParams as $key => $value) { @@ -340,22 +373,23 @@ class RunwareAi implements ApiPluginInterface try { $response = Http::withHeaders([ - 'Authorization' => 'Bearer ' . $token, + 'Authorization' => 'Bearer '.$token, 'Accept' => 'application/json', ])->post($apiUrl, [ - $data + $data, ]); $response->throw(); $responseData = $response->json(); - if (!isset($responseData['data'][0]['imageBase64Data'])) { + if (! isset($responseData['data'][0]['imageBase64Data'])) { throw new \Exception('AI service did not return base64 image data.'); } $base64Image = $responseData['data'][0]['imageBase64Data']; $this->logInfo('Style change request successful to RunwareAI.', ['task_uuid' => $taskUUID, 'response' => $responseData]); + return ['base64Data' => $base64Image]; } catch (\Exception $e) { $errorData = []; @@ -367,4 +401,4 @@ class RunwareAi implements ApiPluginInterface throw $e; } } -} \ No newline at end of file +} diff --git a/app/Filament/Resources/AiModels/AiModelResource.php b/app/Filament/Resources/AiModels/AiModelResource.php index 871c5fb..778740c 100644 --- a/app/Filament/Resources/AiModels/AiModelResource.php +++ b/app/Filament/Resources/AiModels/AiModelResource.php @@ -11,10 +11,12 @@ use Filament\Actions\BulkActionGroup; use Filament\Actions\CreateAction; use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; +use Filament\Forms\Components\Select; use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; use Filament\Resources\Resource; +use Filament\Schemas\Components\Section; use Filament\Schemas\Schema; use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\TextColumn; @@ -38,12 +40,23 @@ class AiModelResource extends Resource { return $schema ->components([ + Section::make('Hinweise zu Model-Parametern') + ->description('Parameter werden je nach Plugin unterschiedlich verwendet: Runware/ComfyUI als JSON für Workflow/Model-Settings, Leonardo v2 akzeptiert z. B. width/height/style_ids/prompt_enhance. Prüfe die Plugin-Doku; Felder, die nicht zum Plugin passen, werden ignoriert.') + ->columns(1) + ->collapsible() + ->schema([]), TextInput::make('name') ->required(), TextInput::make('model_id') ->required(), TextInput::make('model_type') ->nullable(), + Select::make('api_provider_id') + ->label('API Provider') + ->relationship('primaryApiProvider', 'name') + ->searchable() + ->preload() + ->required(), Toggle::make('enabled') ->default(true), Textarea::make('parameters') diff --git a/app/Filament/Resources/ApiProviders/ApiProviderResource.php b/app/Filament/Resources/ApiProviders/ApiProviderResource.php index 6b0890a..e6cadb5 100644 --- a/app/Filament/Resources/ApiProviders/ApiProviderResource.php +++ b/app/Filament/Resources/ApiProviders/ApiProviderResource.php @@ -9,6 +9,7 @@ use Filament\Actions\BulkActionGroup; use Filament\Actions\CreateAction; use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; +use Filament\Forms\Components\Placeholder; use Filament\Forms\Components\Select; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; @@ -38,6 +39,12 @@ class ApiProviderResource extends Resource return $schema ->components([ + Placeholder::make('provider_dashboard') + ->label('Provider Dashboard') + ->content(fn (?ApiProvider $record) => $record?->getDashboardUrl() ? ''.$record->getDashboardUrl().'' : '—') + ->disableLabel(false) + ->columnSpanFull() + ->visible(fn (?ApiProvider $record) => (bool) $record?->getDashboardUrl()), TextInput::make('name') ->required() ->maxLength(255), diff --git a/app/Filament/Resources/ApiProviders/Pages/CreateApiProvider.php b/app/Filament/Resources/ApiProviders/Pages/CreateApiProvider.php index e7dacac..2af85e9 100644 --- a/app/Filament/Resources/ApiProviders/Pages/CreateApiProvider.php +++ b/app/Filament/Resources/ApiProviders/Pages/CreateApiProvider.php @@ -3,7 +3,6 @@ namespace App\Filament\Resources\ApiProviders\Pages; use App\Filament\Resources\ApiProviders\ApiProviderResource; -use Filament\Actions; use Filament\Resources\Pages\CreateRecord; use Livewire\Attributes\On; @@ -19,8 +18,14 @@ class CreateApiProvider extends CreateRecord $this->testResultState = $result; } + protected function getHeaderActions(): array + { + // No record yet, so no dashboard link; keep header clean. + return []; + } + protected function getRedirectUrl(): string { return $this->getResource()::getUrl('index'); } -} \ No newline at end of file +} diff --git a/app/Filament/Resources/ApiProviders/Pages/EditApiProvider.php b/app/Filament/Resources/ApiProviders/Pages/EditApiProvider.php index 94cafbc..ff29d24 100644 --- a/app/Filament/Resources/ApiProviders/Pages/EditApiProvider.php +++ b/app/Filament/Resources/ApiProviders/Pages/EditApiProvider.php @@ -2,8 +2,11 @@ namespace App\Filament\Resources\ApiProviders\Pages; +use App\Api\Plugins\PluginLoader; use App\Filament\Resources\ApiProviders\ApiProviderResource; use Filament\Actions; +use Filament\Actions\Action; +use Filament\Notifications\Notification; use Filament\Resources\Pages\EditRecord; use Livewire\Attributes\On; @@ -22,6 +25,34 @@ class EditApiProvider extends EditRecord protected function getHeaderActions(): array { return [ + Action::make('test_connection') + ->label('Verbindung testen') + ->icon('heroicon-o-bolt') + ->color('gray') + ->action(function (): void { + try { + $provider = $this->record; + $plugin = PluginLoader::getPlugin($provider->plugin, $provider); + $plugin->testConnection($provider->toArray()); + + Notification::make() + ->title('Verbindung erfolgreich') + ->success() + ->send(); + } catch (\Throwable $e) { + Notification::make() + ->title('Verbindung fehlgeschlagen') + ->body($e->getMessage()) + ->danger() + ->send(); + } + }), + Action::make('dashboard') + ->label('Provider Dashboard') + ->icon('heroicon-o-arrow-top-right-on-square') + ->visible(fn () => $this->record?->getDashboardUrl()) + ->url(fn () => $this->record?->getDashboardUrl()) + ->openUrlInNewTab(), Actions\DeleteAction::make(), ]; } @@ -30,4 +61,4 @@ class EditApiProvider extends EditRecord { return $this->getResource()::getUrl('index'); } -} \ No newline at end of file +} diff --git a/app/Filament/Resources/Styles/StyleResource.php b/app/Filament/Resources/Styles/StyleResource.php index e89f870..b9bab28 100644 --- a/app/Filament/Resources/Styles/StyleResource.php +++ b/app/Filament/Resources/Styles/StyleResource.php @@ -5,6 +5,7 @@ namespace App\Filament\Resources\Styles; use App\Models\Style; use BackedEnum; use Filament\Actions\Action; +use Filament\Actions\Action\Step; use Filament\Actions\BulkAction; use Filament\Actions\BulkActionGroup; use Filament\Actions\CreateAction; @@ -25,6 +26,7 @@ use Filament\Tables\Columns\ImageColumn; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Table; +use Spatie\SimpleExcel\SimpleExcelReader; class StyleResource extends Resource { @@ -107,6 +109,79 @@ class StyleResource extends Resource ->relationship('aiModel', 'name'), ]) ->deferFilters(false) + ->headerActions([ + Action::make('import_styles') + ->label('Import Styles (CSV)') + ->icon('heroicon-o-arrow-up-on-square-stack') + ->steps([ + Step::make('Datei') + ->schema([ + FileUpload::make('import_file') + ->label('CSV-Datei (Titel,Prompt,[Beschreibung])') + ->acceptedFileTypes([ + 'text/csv', + 'text/plain', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ]) + ->directory('imports/styles') + ->visibility('private') + ->required(), + ]), + Step::make('Zuordnung') + ->schema([ + Select::make('ai_model_id') + ->label('AI Modell') + ->relationship('aiModel', 'name') + ->required(), + ]), + ]) + ->action(function (array $data): void { + $path = storage_path('app/'.$data['import_file']); + + if (! file_exists($path)) { + throw new \RuntimeException('Import-Datei nicht gefunden.'); + } + + $rows = self::parseImportFile($path); + + if (empty($rows)) { + throw new \RuntimeException('Keine gültigen Zeilen gefunden.'); + } + + $created = 0; + foreach ($rows as $row) { + $title = $row['title'] ?? null; + $prompt = $row['prompt'] ?? null; + $description = $row['description'] ?? null; + + if (! $title || ! $prompt) { + continue; + } + + Style::create([ + 'title' => $title, + 'prompt' => $prompt, + 'description' => $description ?? '', + 'preview_image' => '', // user must set later + 'ai_model_id' => $data['ai_model_id'], + 'enabled' => true, + 'sort_order' => 0, + ]); + $created++; + } + + if ($created === 0) { + throw new \RuntimeException('Es wurden keine Styles importiert.'); + } + + \Filament\Notifications\Notification::make() + ->title('Import abgeschlossen') + ->body($created.' Styles importiert. Bitte Vorschau-Bilder ergänzen.') + ->success() + ->send(); + }), + ]) ->actions([ EditAction::make(), Action::make('duplicate') @@ -149,6 +224,24 @@ class StyleResource extends Resource ]; } + protected static function parseImportFile(string $path): array + { + return SimpleExcelReader::create($path)->getRows()->map(function (array $row) { + $normalized = []; + foreach ($row as $key => $value) { + $normalized[strtolower(trim($key))] = is_string($value) ? trim($value) : $value; + } + + return [ + 'title' => $normalized['title'] ?? $normalized['titel'] ?? null, + 'prompt' => $normalized['prompt'] ?? null, + 'description' => $normalized['description'] ?? $normalized['beschreibung'] ?? null, + ]; + })->filter(function (array $row) { + return ! empty($row['title']) && ! empty($row['prompt']); + })->values()->all(); + } + public static function getPages(): array { return [ diff --git a/app/Filament/Widgets/ApiProviderHealth.php b/app/Filament/Widgets/ApiProviderHealth.php new file mode 100644 index 0000000..cf2bd29 --- /dev/null +++ b/app/Filament/Widgets/ApiProviderHealth.php @@ -0,0 +1,275 @@ +orderByRaw('enabled = 0 desc') + ->orderByRaw("case when coalesce(last_status, '') = 'online' then 1 else 0 end") + ->orderByDesc('updated_at'); + } + + protected function getTableColumns(): array + { + return [ + TextColumn::make('name') + ->label('Provider') + ->searchable() + ->sortable() + ->wrap() + ->url(fn (ApiProvider $record): string => ApiProviderResource::getUrl('edit', ['record' => $record])) + ->openUrlInNewTab(), + TextColumn::make('plugin') + ->label('Plugin') + ->badge() + ->color('gray'), + TextColumn::make('status_label') + ->label('Status') + ->badge() + ->state(fn (ApiProvider $record): string => $this->statusLabel($record)) + ->color(fn (ApiProvider $record): string => $this->statusColor($record)), + TextColumn::make('last_response_time_ms') + ->label('Latency') + ->formatStateUsing(fn (?int $state): string => $state ? $state.' ms' : '—') + ->color('gray'), + TextColumn::make('last_checked_at') + ->label('Geprüft') + ->formatStateUsing(fn ($state): string => $state ? $state->diffForHumans() : 'Noch nie') + ->sortable(), + TextColumn::make('last_error') + ->label('Letzter Fehler') + ->limit(40) + ->tooltip(fn (ApiProvider $record): ?string => $record->last_error), + ]; + } + + protected function getTableActions(): array + { + return [ + Action::make('test') + ->label('Testen') + ->icon('heroicon-o-bolt') + ->color('warning') + ->action(function (ApiProvider $record): void { + $this->testProvider($record); + }), + Action::make('toggle') + ->label(fn (ApiProvider $record): string => $record->enabled ? 'Deaktivieren' : 'Aktivieren') + ->icon('heroicon-o-power') + ->color(fn (ApiProvider $record): string => $record->enabled ? 'gray' : 'success') + ->requiresConfirmation(fn (ApiProvider $record): bool => $record->enabled) + ->action(function (ApiProvider $record): void { + if ($record->enabled) { + $this->disableProvider($record); + + return; + } + + $this->enableProvider($record, true); + }), + ]; + } + + protected function getTableHeaderActions(): array + { + return [ + Action::make('refresh') + ->label('Alle testen') + ->icon('heroicon-o-arrow-path') + ->color('gray') + ->action(function (): void { + $this->testAllProviders(); + }), + ]; + } + + protected function getTableBulkActions(): array + { + return [ + BulkActionGroup::make([ + BulkAction::make('bulkEnable') + ->label('Aktivieren') + ->icon('heroicon-o-power') + ->color('success') + ->action(function (Collection $records): void { + $records->each(function (ApiProvider $record): void { + $this->enableProvider($record, false); + }); + }), + BulkAction::make('bulkDisable') + ->label('Deaktivieren') + ->icon('heroicon-o-stop') + ->color('gray') + ->action(function (Collection $records): void { + $records->each(function (ApiProvider $record): void { + $this->disableProvider($record); + }); + }), + ]), + ]; + } + + private function testProvider(ApiProvider $provider): void + { + $result = $this->probeProvider($provider); + + $notification = Notification::make() + ->title($result['ok'] ? 'Verbindung erfolgreich' : 'Verbindung fehlgeschlagen') + ->body($result['error'] ?? 'Provider antwortet.'); + + $result['ok'] + ? $notification->success() + : $notification->danger(); + + $notification->send(); + } + + private function testAllProviders(): void + { + ApiProvider::query()->get()->each(fn (ApiProvider $provider) => $this->probeProvider($provider)); + + Notification::make() + ->title('Tests gestartet') + ->body('Alle Provider wurden geprüft.') + ->success() + ->send(); + } + + private function enableProvider(ApiProvider $provider, bool $withTest = true): void + { + $ok = true; + $result = null; + + if ($withTest) { + $result = $this->probeProvider($provider); + $ok = $result['ok']; + } + + if ($ok) { + $provider->forceFill(['enabled' => true])->save(); + + Notification::make() + ->title('Provider aktiviert') + ->body($provider->name.' ist aktiviert'.($result ? ' (Test ok).' : '.')) + ->success() + ->send(); + + return; + } + + Notification::make() + ->title('Aktivierung abgebrochen') + ->body($result['error'] ?? 'Verbindung fehlgeschlagen.') + ->danger() + ->send(); + } + + private function disableProvider(ApiProvider $provider): void + { + $provider->disableWithDependencies(); + + Notification::make() + ->title('Provider deaktiviert') + ->body($provider->name.' wurde deaktiviert.') + ->success() + ->send(); + } + + private function probeProvider(ApiProvider $provider): array + { + $startedAt = microtime(true); + $status = $provider->enabled ? 'offline' : 'disabled'; + $error = null; + $duration = null; + $ok = false; + + try { + if (! $provider->plugin) { + $status = 'plugin_missing'; + throw new RuntimeException('Kein Plugin definiert.'); + } + + $plugin = PluginLoader::getPlugin($provider->plugin, $provider); + $success = $plugin->testConnection($provider->toArray()); + $status = $success ? 'online' : 'offline'; + $ok = $success; + } catch (Throwable $exception) { + $status = match (true) { + $status === 'disabled' => 'disabled', + $status === 'plugin_missing' => 'plugin_missing', + default => 'error', + }; + $error = $exception->getMessage(); + } finally { + $duration = (int) round((microtime(true) - $startedAt) * 1000); + } + + $provider->forceFill([ + 'last_checked_at' => now(), + 'last_status' => $status, + 'last_response_time_ms' => $duration, + 'last_error' => $error, + ])->save(); + + return [ + 'ok' => $ok, + 'error' => $error, + 'duration' => $duration, + ]; + } + + private function statusLabel(ApiProvider $provider): string + { + if (! $provider->enabled) { + return 'Disabled'; + } + + return match ($provider->last_status) { + 'online' => 'Online', + 'offline' => 'Offline', + 'error' => 'Error', + 'plugin_missing' => 'Plugin fehlt', + default => 'Unbekannt', + }; + } + + private function statusColor(ApiProvider $provider): string + { + if (! $provider->enabled) { + return 'gray'; + } + + return match ($provider->last_status) { + 'online' => 'success', + 'offline' => 'danger', + 'error' => 'danger', + 'plugin_missing' => 'warning', + default => 'warning', + }; + } +} diff --git a/app/Models/ApiProvider.php b/app/Models/ApiProvider.php index acf7afa..abcb063 100644 --- a/app/Models/ApiProvider.php +++ b/app/Models/ApiProvider.php @@ -17,10 +17,16 @@ class ApiProvider extends Model 'token', 'plugin', 'enabled', + 'last_checked_at', + 'last_status', + 'last_response_time_ms', + 'last_error', ]; protected $casts = [ 'enabled' => 'boolean', + 'last_checked_at' => 'datetime', + 'last_response_time_ms' => 'integer', ]; public function disableWithDependencies() @@ -52,4 +58,32 @@ class ApiProvider extends Model { return $this->hasMany(AiModel::class, 'api_provider_id'); } -} \ No newline at end of file + + public function getDashboardUrl(): ?string + { + return match ($this->plugin) { + 'runwareai' => 'https://my.runware.ai/home', + 'leonardoai' => 'https://app.leonardo.ai/settings/profile', + 'comfyui' => $this->comfyDashboardUrl(), + default => null, + }; + } + + private function comfyDashboardUrl(): ?string + { + if (! $this->api_url) { + return null; + } + + $parts = parse_url($this->api_url); + $scheme = $parts['scheme'] ?? 'http'; + $host = $parts['host'] ?? null; + $port = isset($parts['port']) ? ':'.$parts['port'] : ''; + + if (! $host) { + return null; + } + + return $scheme.'://'.$host.$port.'/'; + } +} diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index 249cb68..d2a3871 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -51,6 +51,7 @@ class AdminPanelProvider extends PanelProvider ->widgets([ Widgets\AccountWidget::class, Widgets\FilamentInfoWidget::class, + \App\Filament\Widgets\ApiProviderHealth::class, \App\Filament\Widgets\AppStatsOverview::class, ]) ->middleware([ diff --git a/composer.json b/composer.json index d09c46a..a5248b8 100644 --- a/composer.json +++ b/composer.json @@ -17,6 +17,7 @@ "laravel/tinker": "^2.8", "predis/predis": "^3.1", "spatie/laravel-settings": "*", + "spatie/simple-excel": "^3.8", "tightenco/ziggy": "^2.0" }, "require-dev": { diff --git a/composer.lock b/composer.lock index 82563f1..b564f9b 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "6815a2ae269daa1ee2b34c951f61968f", + "content-hash": "1a407b8d311c235ec776b18ee56e1223", "packages": [ { "name": "amphp/amp", @@ -7166,6 +7166,67 @@ ], "time": "2025-02-21T14:16:57+00:00" }, + { + "name": "spatie/simple-excel", + "version": "3.8.1", + "source": { + "type": "git", + "url": "https://github.com/spatie/simple-excel.git", + "reference": "80c2fd16090d28e1d0036bfac1afc6bfd8452ea3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/simple-excel/zipball/80c2fd16090d28e1d0036bfac1afc6bfd8452ea3", + "reference": "80c2fd16090d28e1d0036bfac1afc6bfd8452ea3", + "shasum": "" + }, + "require": { + "illuminate/support": "^9.0|^10.0|^11.0|^12.0", + "openspout/openspout": "^4.30", + "php": "^8.3" + }, + "require-dev": { + "pestphp/pest-plugin-laravel": "^1.3|^2.3|^3.0", + "phpunit/phpunit": "^9.4|^10.5|^11.0|^12.0", + "spatie/pest-plugin-snapshots": "^1.1|^2.1", + "spatie/phpunit-snapshot-assertions": "^4.0|^5.1", + "spatie/temporary-directory": "^1.2|^2.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spatie\\SimpleExcel\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "Read and write simple Excel and CSV files", + "homepage": "https://github.com/spatie/simple-excel", + "keywords": [ + "simple-excel", + "spatie" + ], + "support": { + "source": "https://github.com/spatie/simple-excel/tree/3.8.1" + }, + "funding": [ + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2025-09-24T06:40:28+00:00" + }, { "name": "spatie/temporary-directory", "version": "2.3.0", diff --git a/database/migrations/2025_12_03_090609_add_status_fields_to_api_providers_table.php b/database/migrations/2025_12_03_090609_add_status_fields_to_api_providers_table.php new file mode 100644 index 0000000..66a935e --- /dev/null +++ b/database/migrations/2025_12_03_090609_add_status_fields_to_api_providers_table.php @@ -0,0 +1,36 @@ +timestamp('last_checked_at')->nullable()->after('plugin'); + $table->string('last_status', 50)->nullable()->after('last_checked_at'); + $table->unsignedInteger('last_response_time_ms')->nullable()->after('last_status'); + $table->text('last_error')->nullable()->after('last_response_time_ms'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('api_providers', function (Blueprint $table) { + $table->dropColumn([ + 'last_checked_at', + 'last_status', + 'last_response_time_ms', + 'last_error', + ]); + }); + } +}; diff --git a/resources/js/Components/ImageContextMenu.vue b/resources/js/Components/ImageContextMenu.vue index f98cf0d..ea74553 100644 --- a/resources/js/Components/ImageContextMenu.vue +++ b/resources/js/Components/ImageContextMenu.vue @@ -33,47 +33,14 @@ {{ image?.path }}

-
-
-
- - - AI {{ aiAvailable ? 'verfügbar' : 'nicht verfügbar' }} - - -
-
-
-
- API-Provider Status: - - {{ aiAvailable ? 'Online' : 'Offline' }} - -
-
- Einige Funktionen sind derzeit nicht verfügbar -
- -
-
-
- -
+
@@ -200,13 +167,10 @@ const shouldShowDownload = computed(() => { const showStyleSelectorView = ref(false); const aiAvailable = ref(false); -const aiStatus = ref({}); -const showAiStatusDetails = ref(false); const checkAiStatus = async () => { try { const response = await axios.get('/api/ai-status'); - aiStatus.value = response.data; aiAvailable.value = response.data.some(provider => provider.available); } catch (error) { console.error('Error checking AI status:', error); @@ -214,11 +178,6 @@ const checkAiStatus = async () => { } }; -const refreshAiStatus = async () => { - await checkAiStatus(); - showAiStatusDetails.value = false; -}; - onMounted(() => { checkAiStatus(); // Check every 5 minutes diff --git a/resources/js/Pages/Home.vue b/resources/js/Pages/Home.vue index a19edf1..d310f88 100644 --- a/resources/js/Pages/Home.vue +++ b/resources/js/Pages/Home.vue @@ -31,6 +31,13 @@ /> {{ currentTheme === 'light' ? __('api.dark_mode') : __('api.light_mode') }} +
+ + AI offline +
@@ -96,7 +103,7 @@