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 @@