From f715bc4552506dec81b37a8ae38b477d70c29db1 Mon Sep 17 00:00:00 2001 From: Housemann <40449280+Housemann@users.noreply.github.com> Date: Sat, 13 Jun 2026 17:37:26 +0200 Subject: [PATCH] Add AI photo import via Ollama Vision Users can upload a photo of a nail polish bottle; a background queue job sends it to Ollama (llama3.2-vision at 192.168.30.172:11434), extracts name, number and manufacturer, then creates the nail polish and adds it to the user's collection automatically. - ProcessNailPolishImage job (180s timeout, 2 tries) - NailPolishImport model + migration (status tracking) - AiSetting model + migration (Ollama URL + model, admin-configurable) - NailPolishImportController: upload, status page (auto-refresh every 5s) - Admin\AiSettingController: configure Ollama connection - Views: drag-drop upload form, live status page, import history - Navbar: "Foto importieren" link for all users - Admin dropdown: "KI-Einstellungen" - neonail-worker.conf: Supervisor config for the queue worker Co-Authored-By: Claude Sonnet 4.6 --- .../Controllers/Admin/AiSettingController.php | 31 +++++ .../NailPolishImportController.php | 54 ++++++++ app/Jobs/ProcessNailPolishImage.php | 119 ++++++++++++++++++ app/Models/AiSetting.php | 20 +++ app/Models/NailPolishImport.php | 61 +++++++++ app/Models/User.php | 5 + ...00003_create_nail_polish_imports_table.php | 27 ++++ ..._06_13_000004_create_ai_settings_table.php | 30 +++++ neonail-worker.conf | 12 ++ resources/views/admin/ai/show.blade.php | 72 +++++++++++ resources/views/layouts/app.blade.php | 8 ++ .../nail-polish-imports/create.blade.php | 93 ++++++++++++++ .../views/nail-polish-imports/index.blade.php | 83 ++++++++++++ .../views/nail-polish-imports/show.blade.php | 115 +++++++++++++++++ routes/web.php | 8 ++ 15 files changed, 738 insertions(+) create mode 100644 app/Http/Controllers/Admin/AiSettingController.php create mode 100644 app/Http/Controllers/NailPolishImportController.php create mode 100644 app/Jobs/ProcessNailPolishImage.php create mode 100644 app/Models/AiSetting.php create mode 100644 app/Models/NailPolishImport.php create mode 100644 database/migrations/2026_06_13_000003_create_nail_polish_imports_table.php create mode 100644 database/migrations/2026_06_13_000004_create_ai_settings_table.php create mode 100644 neonail-worker.conf create mode 100644 resources/views/admin/ai/show.blade.php create mode 100644 resources/views/nail-polish-imports/create.blade.php create mode 100644 resources/views/nail-polish-imports/index.blade.php create mode 100644 resources/views/nail-polish-imports/show.blade.php diff --git a/app/Http/Controllers/Admin/AiSettingController.php b/app/Http/Controllers/Admin/AiSettingController.php new file mode 100644 index 0000000..0c26854 --- /dev/null +++ b/app/Http/Controllers/Admin/AiSettingController.php @@ -0,0 +1,31 @@ +validate([ + 'ollama_url' => 'required|url|max:255', + 'ollama_model' => 'required|string|max:100', + ]); + + $ai = AiSetting::get(); + $ai->ollama_url = rtrim($request->ollama_url, '/'); + $ai->ollama_model = $request->ollama_model; + $ai->save(); + + return redirect()->route('admin.ai.show')->with('success', 'KI-Einstellungen gespeichert.'); + } +} diff --git a/app/Http/Controllers/NailPolishImportController.php b/app/Http/Controllers/NailPolishImportController.php new file mode 100644 index 0000000..c1f3581 --- /dev/null +++ b/app/Http/Controllers/NailPolishImportController.php @@ -0,0 +1,54 @@ +id()) + ->latest() + ->paginate(20); + + return view('nail-polish-imports.index', compact('imports')); + } + + public function create() + { + return view('nail-polish-imports.create'); + } + + public function store(Request $request) + { + $request->validate([ + 'image' => 'required|image|mimes:jpeg,png,jpg,webp|max:10240', + ]); + + $file = $request->file('image'); + $path = $file->store('nail-polish-imports', 'public'); + + $import = NailPolishImport::create([ + 'user_id' => auth()->id(), + 'image_path' => $path, + 'status' => 'pending', + ]); + + ProcessNailPolishImage::dispatch($import->id); + + return redirect()->route('nail-polish-imports.show', $import) + ->with('success', 'Bild hochgeladen! Die KI analysiert es jetzt im Hintergrund.'); + } + + public function show(NailPolishImport $nailPolishImport) + { + // Nur eigene Imports ansehen + abort_if($nailPolishImport->user_id !== auth()->id(), 403); + + return view('nail-polish-imports.show', ['import' => $nailPolishImport->load('nailPolish.manufacturer')]); + } +} diff --git a/app/Jobs/ProcessNailPolishImage.php b/app/Jobs/ProcessNailPolishImage.php new file mode 100644 index 0000000..744f42f --- /dev/null +++ b/app/Jobs/ProcessNailPolishImage.php @@ -0,0 +1,119 @@ +importId); + $import->update(['status' => 'processing']); + + try { + $ai = AiSetting::get(); + $imageB64 = base64_encode(Storage::disk('public')->get($import->image_path)); + + $prompt = <<post("{$ai->ollama_url}/api/generate", [ + 'model' => $ai->ollama_model, + 'prompt' => $prompt, + 'images' => [$imageB64], + 'stream' => false, + ]); + + if ($response->failed()) { + throw new \RuntimeException('Ollama API Fehler: ' . $response->status()); + } + + $raw = $response->json('response', ''); + $data = $this->parseJson($raw); + + if (!$data) { + throw new \RuntimeException('Ollama-Antwort konnte nicht als JSON geparst werden: ' . $raw); + } + + // Hersteller suchen oder anlegen + $manufacturer = null; + if (!empty($data['manufacturer'])) { + $manufacturer = Manufacturer::firstOrCreate( + ['name' => trim($data['manufacturer'])] + ); + } + + // Nagellack-Nummer eindeutig machen falls nötig + $number = $data['number'] ?? ('import-' . $this->importId); + if (NailPolish::where('number', $number)->exists()) { + $number .= '-' . $this->importId; + } + + $nailPolish = NailPolish::create([ + 'name' => $data['name'] ?? 'Unbekannt', + 'number' => $number, + 'manufacturer_id' => $manufacturer?->id, + 'image_path' => $import->image_path, + ]); + + // Zur Sammlung des Users hinzufügen + $import->user->nailPolishes()->syncWithoutDetaching([$nailPolish->id]); + + $import->update([ + 'status' => 'done', + 'result' => $data, + 'nail_polish_id' => $nailPolish->id, + ]); + + } catch (\Throwable $e) { + Log::error('ProcessNailPolishImage fehlgeschlagen', [ + 'import_id' => $this->importId, + 'error' => $e->getMessage(), + ]); + + $import->update([ + 'status' => 'failed', + 'error_message' => $e->getMessage(), + ]); + } + } + + private function parseJson(string $raw): ?array + { + // JSON aus der Antwort extrahieren (Ollama kann Text drumherum haben) + if (preg_match('/\{.*\}/s', $raw, $matches)) { + $decoded = json_decode($matches[0], true); + if (json_last_error() === JSON_ERROR_NONE) { + return $decoded; + } + } + return null; + } +} diff --git a/app/Models/AiSetting.php b/app/Models/AiSetting.php new file mode 100644 index 0000000..865de4a --- /dev/null +++ b/app/Models/AiSetting.php @@ -0,0 +1,20 @@ + 'http://192.168.30.172:11434', + 'ollama_model' => 'llama3.2-vision', + ]); + } +} diff --git a/app/Models/NailPolishImport.php b/app/Models/NailPolishImport.php new file mode 100644 index 0000000..43ea8bb --- /dev/null +++ b/app/Models/NailPolishImport.php @@ -0,0 +1,61 @@ + 'array', + ]; + } + + public function user() + { + return $this->belongsTo(User::class); + } + + public function nailPolish() + { + return $this->belongsTo(NailPolish::class); + } + + public function isPending(): bool + { + return in_array($this->status, ['pending', 'processing']); + } + + public function statusLabel(): string + { + return match ($this->status) { + 'pending' => 'Warten', + 'processing' => 'Verarbeitung', + 'done' => 'Fertig', + 'failed' => 'Fehler', + default => $this->status, + }; + } + + public function statusColor(): string + { + return match ($this->status) { + 'pending' => 'warning', + 'processing' => 'info', + 'done' => 'success', + 'failed' => 'danger', + default => 'secondary', + }; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 8458ce0..eae1ff4 100755 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -58,6 +58,11 @@ class User extends Authenticatable return $this->belongsToMany(NailPolish::class, 'user_nail_polishes', 'user_id', 'nail_polish_id'); } + public function nailPolishImports() + { + return $this->hasMany(\App\Models\NailPolishImport::class); + } + /** * Prüft, ob der User ein Admin ist */ diff --git a/database/migrations/2026_06_13_000003_create_nail_polish_imports_table.php b/database/migrations/2026_06_13_000003_create_nail_polish_imports_table.php new file mode 100644 index 0000000..5a0bfd3 --- /dev/null +++ b/database/migrations/2026_06_13_000003_create_nail_polish_imports_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('image_path'); + $table->enum('status', ['pending', 'processing', 'done', 'failed'])->default('pending'); + $table->json('result')->nullable(); + $table->foreignId('nail_polish_id')->nullable()->constrained('nail_polishes')->nullOnDelete(); + $table->text('error_message')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('nail_polish_imports'); + } +}; diff --git a/database/migrations/2026_06_13_000004_create_ai_settings_table.php b/database/migrations/2026_06_13_000004_create_ai_settings_table.php new file mode 100644 index 0000000..3f4db22 --- /dev/null +++ b/database/migrations/2026_06_13_000004_create_ai_settings_table.php @@ -0,0 +1,30 @@ +id(); + $table->string('ollama_url')->default('http://192.168.30.172:11434'); + $table->string('ollama_model')->default('llama3.2-vision'); + $table->timestamps(); + }); + + DB::table('ai_settings')->insert([ + 'ollama_url' => 'http://192.168.30.172:11434', + 'ollama_model' => 'llama3.2-vision', + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + public function down(): void + { + Schema::dropIfExists('ai_settings'); + } +}; diff --git a/neonail-worker.conf b/neonail-worker.conf new file mode 100644 index 0000000..38ccc09 --- /dev/null +++ b/neonail-worker.conf @@ -0,0 +1,12 @@ +[program:neonail-worker] +process_name=%(program_name)s_%(process_num)02d +command=php /var/www/html/artisan queue:work --sleep=3 --tries=2 --timeout=180 +autostart=true +autorestart=true +stopasgroup=true +killasgroup=true +user=www-data +numprocs=1 +redirect_stderr=true +stdout_logfile=/var/www/html/storage/logs/worker.log +stopwaitsecs=180 diff --git a/resources/views/admin/ai/show.blade.php b/resources/views/admin/ai/show.blade.php new file mode 100644 index 0000000..0734bb4 --- /dev/null +++ b/resources/views/admin/ai/show.blade.php @@ -0,0 +1,72 @@ +@extends('layouts.app') + +@section('title', 'KI-Einstellungen – NeoNail DB') + +@section('content') + +
+

KI-Einstellungen

+

Ollama-Konfiguration für den automatischen Foto-Import

+
+ +
+
+
+
Ollama Verbindung
+
+
+ @csrf + @method('PUT') + +
+ + + @error('ollama_url') +
{{ $message }}
+ @enderror +
URL des Ollama-Containers/Servers ohne abschließenden Slash.
+
+ +
+ + + @error('ollama_model') +
{{ $message }}
+ @enderror +
Muss ein Vision-fähiges Modell sein (z.B. llama3.2-vision, llava).
+
+ + +
+
+
+
+ +
+
+
Queue Worker
+
+

+ Jobs werden über die Datenbank-Queue verarbeitet. Auf dem Server muss dauerhaft ein Worker laufen: +

+ php artisan queue:work \ + --timeout=180 \ + --tries=2 \ + --sleep=3 +

+ Empfohlen: Als Supervisor-Dienst einrichten, damit der Worker nach Crashes neu startet. +

+
+
+
+
+ +@endsection diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 6458acd..9dcc06b 100755 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -456,6 +456,12 @@ Hersteller + @if(auth()->user()->isAdmin())
  • SSO-Konfiguration
  • +
  • + KI-Einstellungen
  • @endif diff --git a/resources/views/nail-polish-imports/create.blade.php b/resources/views/nail-polish-imports/create.blade.php new file mode 100644 index 0000000..18987b1 --- /dev/null +++ b/resources/views/nail-polish-imports/create.blade.php @@ -0,0 +1,93 @@ +@extends('layouts.app') + +@section('title', 'Foto importieren – NeoNail DB') + +@section('content') + +
    +

    Foto importieren

    +

    Lade ein Foto deines Nagellacks hoch – die KI erkennt Name, Nummer und Hersteller automatisch.

    +
    + +
    +
    +
    +
    +
    + @csrf + +
    + + +
    +
    + +

    Foto hierher ziehen oder klicken

    +

    JPG, PNG, WebP – max. 10 MB

    +
    + +
    + + + + @error('image') +
    {{ $message }}
    + @enderror +
    + + +
    +
    +
    + + +
    +
    + +@endsection + +@section('scripts') + +@endsection diff --git a/resources/views/nail-polish-imports/index.blade.php b/resources/views/nail-polish-imports/index.blade.php new file mode 100644 index 0000000..3fe773c --- /dev/null +++ b/resources/views/nail-polish-imports/index.blade.php @@ -0,0 +1,83 @@ +@extends('layouts.app') + +@section('title', 'Meine Importe – NeoNail DB') + +@section('content') + +
    +
    +

    Meine Importe

    +

    KI-Analysen deiner hochgeladenen Fotos

    +
    + + Neues Foto + +
    + +@if($imports->isEmpty()) +
    +
    +

    Noch keine Importe

    +

    Lade ein Foto hoch und lass die KI deinen Nagellack erkennen.

    + + Jetzt importieren + +
    +@else +
    +
    +
    + + + + + + + + + + + + @foreach($imports as $import) + + + + + + + + @endforeach + +
    BildErgebnisStatusDatum
    + + + @if($import->result) +
    {{ $import->result['name'] ?? '—' }}
    +
    {{ $import->result['manufacturer'] ?? '' }}
    + @elseif($import->status === 'failed') + {{ Str::limit($import->error_message, 60) }} + @else + wird analysiert… + @endif +
    + {{ $import->statusLabel() }} + + {{ $import->created_at->format('d.m.Y H:i') }} + + + + +
    +
    +
    +
    + +
    + {{ $imports->links() }} +
    +@endif + +@endsection diff --git a/resources/views/nail-polish-imports/show.blade.php b/resources/views/nail-polish-imports/show.blade.php new file mode 100644 index 0000000..54ebffe --- /dev/null +++ b/resources/views/nail-polish-imports/show.blade.php @@ -0,0 +1,115 @@ +@extends('layouts.app') + +@section('title', 'Import-Status – NeoNail DB') + +@php $pending = $import->isPending(); @endphp + +@if($pending) + +@endif + +@section('content') + +
    +

    KI-Analyse

    +

    + + {{ $import->statusLabel() }} + +

    +
    + +
    + + {{-- Bild --}} +
    +
    +
    + Importiertes Bild +
    +
    +
    + + {{-- Status / Ergebnis --}} +
    +
    +
    + @if($pending) + KI analysiert das Bild… + @elseif($import->status === 'done') + Ergebnis + @else + Fehler + @endif +
    +
    + + @if($pending) +

    + Ollama verarbeitet das Bild gerade. Diese Seite aktualisiert sich automatisch alle 5 Sekunden. +

    +
    +
    +
    + + @elseif($import->status === 'done') + @php $result = $import->result; @endphp + + + + + + + + + + + + + + @if($import->nailPolish) + + + + + @endif +
    Name{{ $result['name'] ?? '—' }}
    Nummer{{ $result['number'] ?? '—' }}
    Hersteller{{ $result['manufacturer'] ?? '—' }}
    In Sammlung + + Hinzugefügt + +
    + + @if($import->nailPolish) + + @endif + + @else +
    + + {{ $import->error_message ?? 'Unbekannter Fehler' }} +
    + + Nochmal versuchen + + @endif + +
    +
    + + +
    + +
    + +@endsection diff --git a/routes/web.php b/routes/web.php index cb6990a..e52e1d0 100755 --- a/routes/web.php +++ b/routes/web.php @@ -7,6 +7,8 @@ use App\Http\Controllers\AdminController; use App\Http\Controllers\Auth\LoginController; use App\Http\Controllers\Auth\SsoController; use App\Http\Controllers\Admin\SsoSettingController; +use App\Http\Controllers\Admin\AiSettingController; +use App\Http\Controllers\NailPolishImportController; use App\Http\Controllers\ManufacturerController; // Startseite @@ -50,6 +52,8 @@ Route::middleware(['auth'])->group(function () { Route::get('/users/{user}/collection', [UserNailPolishController::class, 'showUserCollection'])->name('users.collection'); Route::get('/sso', [SsoSettingController::class, 'show'])->name('sso.show'); Route::put('/sso', [SsoSettingController::class, 'update'])->name('sso.update'); + Route::get('/ai', [AiSettingController::class, 'show'])->name('ai.show'); + Route::put('/ai', [AiSettingController::class, 'update'])->name('ai.update'); }); // Nagellack-Verwaltung (nur für Admin) @@ -61,6 +65,10 @@ Route::middleware(['auth'])->group(function () { // Hersteller-Verwaltung (für alle eingeloggten User) Route::resource('manufacturers', ManufacturerController::class); Route::get('/manufacturers-search', [ManufacturerController::class, 'search'])->name('manufacturers.search'); + + // KI Foto-Import (für alle eingeloggten User) + Route::resource('nail-polish-imports', NailPolishImportController::class) + ->only(['index', 'create', 'store', 'show']); }); // Fallback