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 <noreply@anthropic.com>
This commit is contained in:
Housemann
2026-06-13 17:37:26 +02:00
co-authored by Claude Sonnet 4.6
parent 7a24e554b0
commit f715bc4552
15 changed files with 738 additions and 0 deletions
@@ -0,0 +1,31 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\AiSetting;
use Illuminate\Http\Request;
class AiSettingController extends Controller
{
public function show()
{
$ai = AiSetting::get();
return view('admin.ai.show', compact('ai'));
}
public function update(Request $request)
{
$request->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.');
}
}
@@ -0,0 +1,54 @@
<?php
namespace App\Http\Controllers;
use App\Jobs\ProcessNailPolishImage;
use App\Models\NailPolishImport;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class NailPolishImportController extends Controller
{
public function index()
{
$imports = NailPolishImport::where('user_id', auth()->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')]);
}
}
+119
View File
@@ -0,0 +1,119 @@
<?php
namespace App\Jobs;
use App\Models\AiSetting;
use App\Models\Manufacturer;
use App\Models\NailPolish;
use App\Models\NailPolishImport;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
class ProcessNailPolishImage implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $timeout = 180;
public int $tries = 2;
public function __construct(private int $importId) {}
public function handle(): void
{
$import = NailPolishImport::findOrFail($this->importId);
$import->update(['status' => 'processing']);
try {
$ai = AiSetting::get();
$imageB64 = base64_encode(Storage::disk('public')->get($import->image_path));
$prompt = <<<PROMPT
Analyze this nail polish bottle image carefully.
Extract the following data and respond ONLY with valid JSON, no other text:
{
"name": "the nail polish color name",
"number": "the product number or color code (often printed on bottle or cap)",
"manufacturer": "the brand name printed on the bottle"
}
If a value is not visible or unclear, use null.
PROMPT;
$response = Http::timeout(160)->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;
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class AiSetting extends Model
{
protected $table = 'ai_settings';
protected $fillable = ['ollama_url', 'ollama_model'];
public static function get(): self
{
return static::firstOrCreate([], [
'ollama_url' => 'http://192.168.30.172:11434',
'ollama_model' => 'llama3.2-vision',
]);
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class NailPolishImport extends Model
{
protected $fillable = [
'user_id',
'image_path',
'status',
'result',
'nail_polish_id',
'error_message',
];
protected function casts(): array
{
return [
'result' => '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',
};
}
}
+5
View File
@@ -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
*/