Runware/ComfyUI fixes, dashboard links, import action, Leonardo plugin, widget, added status field and test connection button
This commit is contained in:
@@ -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',
|
||||
]);
|
||||
@@ -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()) {
|
||||
@@ -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,6 +342,7 @@ class ComfyUi implements ApiPluginInterface
|
||||
public function searchModels(string $searchTerm): array
|
||||
{
|
||||
$this->logInfo('ComfyUI does not support model search. Returning empty list.', ['searchTerm' => $searchTerm]);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
270
app/Api/Plugins/LeonardoAi.php
Normal file
270
app/Api/Plugins/LeonardoAi.php
Normal file
@@ -0,0 +1,270 @@
|
||||
<?php
|
||||
|
||||
namespace App\Api\Plugins;
|
||||
|
||||
use App\Models\ApiProvider;
|
||||
use App\Models\Image;
|
||||
use App\Models\Style;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
|
||||
class LeonardoAi implements ApiPluginInterface
|
||||
{
|
||||
use LoggablePlugin;
|
||||
|
||||
private const BASE_URL = 'https://cloud.leonardo.ai/api/rest/v2';
|
||||
|
||||
private const SUPPORTED_MODELS = [
|
||||
'gemini-image-2', // Nano Banana Pro
|
||||
'flux-pro-2.0', // FLUX.2 Pro
|
||||
'seedream-4.0', // Seedream 4.0
|
||||
];
|
||||
|
||||
public function __construct(private ApiProvider $apiProvider) {}
|
||||
|
||||
public function getIdentifier(): string
|
||||
{
|
||||
return 'leonardoai';
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'Leonardo AI';
|
||||
}
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ class RunwareAi implements ApiPluginInterface
|
||||
} else {
|
||||
$this->logError('Failed to enable RunwareAi plugin.', ['provider_name' => $this->apiProvider->name]);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
@@ -54,6 +55,7 @@ class RunwareAi implements ApiPluginInterface
|
||||
} else {
|
||||
$this->logError('Failed to disable RunwareAi plugin.', ['provider_name' => $this->apiProvider->name]);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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() ? '<a href="'.$record->getDashboardUrl().'" target="_blank" class="text-primary-600 underline">'.$record->getDashboardUrl().'</a>' : '—')
|
||||
->disableLabel(false)
|
||||
->columnSpanFull()
|
||||
->visible(fn (?ApiProvider $record) => (bool) $record?->getDashboardUrl()),
|
||||
TextInput::make('name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
|
||||
@@ -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,6 +18,12 @@ 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');
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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 [
|
||||
|
||||
275
app/Filament/Widgets/ApiProviderHealth.php
Normal file
275
app/Filament/Widgets/ApiProviderHealth.php
Normal file
@@ -0,0 +1,275 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Widgets;
|
||||
|
||||
use App\Api\Plugins\PluginLoader;
|
||||
use App\Filament\Resources\ApiProviders\ApiProviderResource;
|
||||
use App\Models\ApiProvider;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\BulkAction;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Widgets\TableWidget;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class ApiProviderHealth extends TableWidget
|
||||
{
|
||||
protected static ?string $heading = 'API Provider Health';
|
||||
|
||||
protected static ?string $description = 'Status, Tests & Quick enable';
|
||||
|
||||
protected int|string|array $columnSpan = 'full';
|
||||
|
||||
protected static ?int $sort = -5;
|
||||
|
||||
protected function getTableQuery(): Builder
|
||||
{
|
||||
return ApiProvider::query()
|
||||
->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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
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.'/';
|
||||
}
|
||||
}
|
||||
@@ -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([
|
||||
|
||||
@@ -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": {
|
||||
|
||||
63
composer.lock
generated
63
composer.lock
generated
@@ -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",
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('api_providers', function (Blueprint $table) {
|
||||
$table->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',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -33,47 +33,14 @@
|
||||
{{ image?.path }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="relative">
|
||||
<div class="flex items-center gap-2 rounded-full bg-white/80 px-3 py-1.5 text-xs font-medium shadow-sm dark:bg-slate-800/80">
|
||||
<span class="h-2 w-2 rounded-full"
|
||||
:class="aiAvailable ? 'bg-emerald-500' : 'bg-rose-500'"></span>
|
||||
<span class="text-slate-600 dark:text-slate-300">
|
||||
AI {{ aiAvailable ? 'verfügbar' : 'nicht verfügbar' }}
|
||||
</span>
|
||||
<button @click="showAiStatusDetails = !showAiStatusDetails"
|
||||
class="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200">
|
||||
<font-awesome-icon :icon="['fas', 'info-circle']" class="h-3.5 w-3.5"/>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="showAiStatusDetails"
|
||||
class="absolute right-0 mt-2 w-64 rounded-lg bg-white p-3 shadow-lg dark:bg-slate-800">
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="font-medium">API-Provider Status:</span>
|
||||
<span :class="aiAvailable ? 'text-emerald-600' : 'text-rose-600'">
|
||||
{{ aiAvailable ? 'Online' : 'Offline' }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="!aiAvailable" class="text-xs text-slate-500">
|
||||
Einige Funktionen sind derzeit nicht verfügbar
|
||||
</div>
|
||||
<button @click="refreshAiStatus"
|
||||
class="mt-2 w-full rounded-md bg-slate-100 px-2 py-1 text-xs hover:bg-slate-200 dark:bg-slate-700 dark:hover:bg-slate-600">
|
||||
Status aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full border border-white/20 bg-white/10 p-2 text-slate-900 shadow-sm transition hover:border-rose-400 hover:text-rose-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400 dark:text-white"
|
||||
@click="$emit('close')"
|
||||
aria-label="Kontextmenü schließen"
|
||||
>
|
||||
<font-awesome-icon :icon="['fas', 'xmark']" class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full border border-white/20 bg-white/10 p-2 text-slate-900 shadow-sm transition hover:border-rose-400 hover:text-rose-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400 dark:text-white"
|
||||
@click="$emit('close')"
|
||||
aria-label="Kontextmenü schließen"
|
||||
>
|
||||
<font-awesome-icon :icon="['fas', 'xmark']" class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="!showStyleSelectorView" class="space-y-3">
|
||||
@@ -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
|
||||
|
||||
@@ -31,6 +31,13 @@
|
||||
/>
|
||||
<span>{{ currentTheme === 'light' ? __('api.dark_mode') : __('api.light_mode') }}</span>
|
||||
</button>
|
||||
<div
|
||||
v-if="!aiAvailable"
|
||||
class="inline-flex items-center gap-2 rounded-full border border-rose-200 bg-rose-50 px-3 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-rose-700 shadow-sm dark:border-rose-500/40 dark:bg-rose-500/10 dark:text-rose-200"
|
||||
>
|
||||
<span class="h-2 w-2 rounded-full bg-rose-500"></span>
|
||||
<span>AI offline</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex flex-wrap items-center gap-4 text-xs text-slate-500 dark:text-slate-300">
|
||||
@@ -96,7 +103,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Head, router } from '@inertiajs/vue3';
|
||||
import { Head } from '@inertiajs/vue3';
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import axios from 'axios';
|
||||
import GalleryGrid from '../Components/GalleryGrid.vue';
|
||||
@@ -123,6 +130,7 @@ const currentPage = ref(1);
|
||||
const currentOverlayComponent = ref(null);
|
||||
const selectedImage = ref(null);
|
||||
const styledImage = ref(null);
|
||||
const aiAvailable = ref(true);
|
||||
const processingProgress = ref(0);
|
||||
const isLoading = ref(false);
|
||||
const currentTheme = ref('light');
|
||||
@@ -134,6 +142,7 @@ const toastMessage = ref(null);
|
||||
const toastVariant = ref('info');
|
||||
let toastTimer = null;
|
||||
let refreshTimer = null;
|
||||
let aiStatusTimer = null;
|
||||
|
||||
const getImageIdentifier = (image) => image?.id ?? image?.image_id ?? null;
|
||||
|
||||
@@ -226,6 +235,18 @@ const toggleTheme = () => {
|
||||
applyTheme(newTheme);
|
||||
};
|
||||
|
||||
const checkAiStatus = () => {
|
||||
axios
|
||||
.get('/api/ai-status')
|
||||
.then((response) => {
|
||||
aiAvailable.value = response.data.some((provider) => provider.available);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error checking AI status:', error);
|
||||
aiAvailable.value = false;
|
||||
});
|
||||
};
|
||||
|
||||
const closeOverlays = () => {
|
||||
currentOverlayComponent.value = null;
|
||||
selectedImage.value = null;
|
||||
@@ -236,24 +257,23 @@ const fetchImages = (options = { silent: false }) => {
|
||||
isRefreshing.value = true;
|
||||
}
|
||||
|
||||
router.reload({
|
||||
only: ['images'],
|
||||
preserveScroll: true,
|
||||
onSuccess: (page) => {
|
||||
if (Array.isArray(page.props.images)) {
|
||||
images.value = page.props.images;
|
||||
axios
|
||||
.get('/api/images')
|
||||
.then((response) => {
|
||||
if (Array.isArray(response.data)) {
|
||||
images.value = response.data;
|
||||
lastRefreshedAt.value = new Date();
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error fetching images:', error);
|
||||
showToast('Die Galerie konnte nicht aktualisiert werden.', 'error');
|
||||
},
|
||||
onFinish: () => {
|
||||
})
|
||||
.finally(() => {
|
||||
if (!options.silent) {
|
||||
isRefreshing.value = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const startAutoRefresh = (intervalMs) => {
|
||||
@@ -538,6 +558,9 @@ onMounted(() => {
|
||||
.catch((error) => {
|
||||
console.error('Error fetching max copies setting:', error);
|
||||
});
|
||||
|
||||
checkAiStatus();
|
||||
aiStatusTimer = setInterval(() => checkAiStatus(), 300000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -547,5 +570,8 @@ onUnmounted(() => {
|
||||
if (toastTimer) {
|
||||
clearTimeout(toastTimer);
|
||||
}
|
||||
if (aiStatusTimer) {
|
||||
clearInterval(aiStatusTimer);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user