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,83 @@
@extends('layouts.app')
@section('title', 'Meine Importe NeoNail DB')
@section('content')
<div class="page-hero d-flex align-items-center justify-content-between flex-wrap gap-3 mb-4">
<div>
<h1><i class="fas fa-history me-2"></i>Meine Importe</h1>
<p>KI-Analysen deiner hochgeladenen Fotos</p>
</div>
<a href="{{ route('nail-polish-imports.create') }}" class="btn btn-glass">
<i class="fas fa-camera me-2"></i>Neues Foto
</a>
</div>
@if($imports->isEmpty())
<div class="empty-state">
<div class="empty-icon"><i class="fas fa-camera"></i></div>
<h4>Noch keine Importe</h4>
<p>Lade ein Foto hoch und lass die KI deinen Nagellack erkennen.</p>
<a href="{{ route('nail-polish-imports.create') }}" class="btn btn-primary">
<i class="fas fa-camera me-2"></i>Jetzt importieren
</a>
</div>
@else
<div class="card">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table mb-0" style="font-size:.875rem;">
<thead style="background:#f9fafb; border-bottom:1px solid #f3f4f6;">
<tr>
<th style="padding:12px 16px; font-weight:600; color:#374151;">Bild</th>
<th style="padding:12px 16px; font-weight:600; color:#374151;">Ergebnis</th>
<th style="padding:12px 16px; font-weight:600; color:#374151;">Status</th>
<th style="padding:12px 16px; font-weight:600; color:#374151;">Datum</th>
<th style="padding:12px 16px;"></th>
</tr>
</thead>
<tbody>
@foreach($imports as $import)
<tr style="border-bottom:1px solid #f3f4f6;">
<td style="padding:12px 16px;">
<img src="{{ Storage::url($import->image_path) }}"
alt=""
style="width:52px; height:52px; object-fit:cover; border-radius:10px;">
</td>
<td style="padding:12px 16px;">
@if($import->result)
<div style="font-weight:600; color:#111827;">{{ $import->result['name'] ?? '—' }}</div>
<div style="color:#9ca3af; font-size:.78rem;">{{ $import->result['manufacturer'] ?? '' }}</div>
@elseif($import->status === 'failed')
<span style="color:#dc2626; font-size:.8rem;">{{ Str::limit($import->error_message, 60) }}</span>
@else
<span style="color:#9ca3af;">wird analysiert…</span>
@endif
</td>
<td style="padding:12px 16px;">
<span class="badge bg-{{ $import->statusColor() }}">{{ $import->statusLabel() }}</span>
</td>
<td style="padding:12px 16px; color:#9ca3af;">
{{ $import->created_at->format('d.m.Y H:i') }}
</td>
<td style="padding:12px 16px; text-align:right;">
<a href="{{ route('nail-polish-imports.show', $import) }}"
class="btn btn-outline-secondary btn-sm">
<i class="fas fa-eye"></i>
</a>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
</div>
<div class="mt-3">
{{ $imports->links() }}
</div>
@endif
@endsection