feat: camera capture and multi-photo upload at article ingest
- Ingest page redesigned: camera modal (getUserMedia + capture fallback
for mobile), mandatory search photo with client-side validation,
optional extra photos grid with per-photo remove button
- Camera modal: live video preview, capture → preview → confirm/retake
flow; stops stream on modal close; falls back to native file picker
if getUserMedia is unavailable
- ManualIngestController: uploads extra photos via PhotoService::uploadRaw(),
stores [{storagePathId, filename}] in job inputData as extraPhotos
- PhotoService::attachExtra(): attach already-stored file to an article
by StoragePath ID + filename
- DraftArticleHandler: after creating the article, attaches extra photos
in sort order; errors are best-effort (pipeline not aborted)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
693e458e07
commit
6241398390
4 changed files with 407 additions and 103 deletions
|
|
@ -9,6 +9,7 @@ use App\Application\Storage\StoredFile;
|
|||
use App\Domain\Article\ArticlePhoto;
|
||||
use App\Domain\Article\Repository\ArticlePhotoRepositoryInterface;
|
||||
use App\Domain\Article\Repository\ArticleRepositoryInterface;
|
||||
use App\Domain\Storage\Repository\StoragePathRepositoryInterface;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
final class PhotoService
|
||||
|
|
@ -17,6 +18,7 @@ final class PhotoService
|
|||
private readonly ArticleRepositoryInterface $articleRepository,
|
||||
private readonly ArticlePhotoRepositoryInterface $photoRepository,
|
||||
private readonly StorageManagerInterface $storageManager,
|
||||
private readonly StoragePathRepositoryInterface $storagePathRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
|
|
@ -58,6 +60,23 @@ final class PhotoService
|
|||
/**
|
||||
* @param list<string> $orderedPhotoIds UUIDs in the desired display order
|
||||
*/
|
||||
/**
|
||||
* Attach a file that was already stored (e.g. uploaded at ingest time) to an article.
|
||||
*
|
||||
* @param string $storagePathId UUID of the StoragePath entity
|
||||
*/
|
||||
public function attachExtra(Uuid $articleId, string $storagePathId, string $filename, int $sortOrder): void
|
||||
{
|
||||
$article = $this->articleRepository->findById($articleId)
|
||||
?? throw new \DomainException("Article {$articleId->toRfc4122()} not found");
|
||||
|
||||
$storagePath = $this->storagePathRepository->findById(Uuid::fromString($storagePathId))
|
||||
?? throw new \DomainException("StoragePath {$storagePathId} not found");
|
||||
|
||||
$photo = new ArticlePhoto($article, $storagePath, $filename, false, $sortOrder);
|
||||
$this->photoRepository->save($photo);
|
||||
}
|
||||
|
||||
public function reorder(Uuid $articleId, array $orderedPhotoIds): void
|
||||
{
|
||||
$photos = $this->photoRepository->findByArticle($articleId);
|
||||
|
|
|
|||
|
|
@ -57,6 +57,28 @@ final class ManualIngestController extends AbstractController
|
|||
|
||||
$storedPath = $stored->storagePath->resolveFilePath($stored->filename);
|
||||
|
||||
// Upload any extra photos and store references for DraftArticleHandler
|
||||
$extraPhotos = [];
|
||||
/** @var UploadedFile[] $additionalFiles */
|
||||
$additionalFiles = array_filter(
|
||||
(array) $request->files->get('additional_photos', []),
|
||||
static fn ($f) => $f instanceof UploadedFile,
|
||||
);
|
||||
foreach ($additionalFiles as $extra) {
|
||||
try {
|
||||
$extraStored = $this->photoService->uploadRaw(
|
||||
$extra->getRealPath(),
|
||||
$extra->getClientOriginalName(),
|
||||
);
|
||||
$extraPhotos[] = [
|
||||
'storagePathId' => $extraStored->storagePath->getId()->toRfc4122(),
|
||||
'filename' => $extraStored->filename,
|
||||
];
|
||||
} catch (\RuntimeException) {
|
||||
// skip photos that fail to store — don't abort the whole job
|
||||
}
|
||||
}
|
||||
|
||||
$job = new AIPipelineJob(AIPipelineJobType::Photo, [
|
||||
'inventoryNumber' => $catalogNumber,
|
||||
'articleTypeId' => $articleType->getId()->toRfc4122(),
|
||||
|
|
@ -64,6 +86,7 @@ final class ManualIngestController extends AbstractController
|
|||
'conditionNotes' => $form->get('conditionNotes')->getData(),
|
||||
'originalFilename' => $image->getClientOriginalName(),
|
||||
'storedPhotoPath' => $storedPath,
|
||||
'extraPhotos' => $extraPhotos,
|
||||
]);
|
||||
$this->jobRepository->save($job);
|
||||
|
||||
|
|
|
|||
|
|
@ -5,8 +5,10 @@ declare(strict_types=1);
|
|||
namespace App\Infrastructure\Messenger\Handler;
|
||||
|
||||
use App\Application\Article\ArticleService;
|
||||
use App\Application\Article\PhotoService;
|
||||
use App\Domain\Article\ArticleCondition;
|
||||
use App\Domain\Article\ArticleStatus;
|
||||
use App\Domain\Article\Repository\ArticlePhotoRepositoryInterface;
|
||||
use App\Domain\Article\Repository\ArticleRepositoryInterface;
|
||||
use App\Domain\Pipeline\Repository\AIPipelineJobRepositoryInterface;
|
||||
use App\Infrastructure\Messenger\Message\DraftArticleMessage;
|
||||
|
|
@ -23,6 +25,8 @@ final class DraftArticleHandler
|
|||
private readonly ArticleRepositoryInterface $articleRepository,
|
||||
private readonly AIPipelineJobRepositoryInterface $jobRepository,
|
||||
private readonly MessageBusInterface $bus,
|
||||
private readonly PhotoService $photoService,
|
||||
private readonly ArticlePhotoRepositoryInterface $photoRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
|
|
@ -80,6 +84,21 @@ final class DraftArticleHandler
|
|||
$job->markCompleted(['articleId' => $article->getId()->toRfc4122()]);
|
||||
$this->jobRepository->save($job);
|
||||
|
||||
// Attach any extra photos uploaded during ingest
|
||||
$existingCount = \count($this->photoRepository->findByArticle($article->getId()));
|
||||
foreach ($job->getInputData()['extraPhotos'] ?? [] as $i => $photoData) {
|
||||
try {
|
||||
$this->photoService->attachExtra(
|
||||
$article->getId(),
|
||||
(string) $photoData['storagePathId'],
|
||||
(string) $photoData['filename'],
|
||||
$existingCount + $i,
|
||||
);
|
||||
} catch (\DomainException) {
|
||||
// best-effort — don't fail the whole pipeline for a missing extra photo
|
||||
}
|
||||
}
|
||||
|
||||
$this->bus->dispatch(new EbayTextMessage(
|
||||
jobId: $message->jobId,
|
||||
articleId: $article->getId()->toRfc4122(),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{% extends '@EasyAdmin/page/content.html.twig' %}
|
||||
|
||||
{% block page_title %}Ingest Article{% endblock %}
|
||||
{% block page_title %}Artikel einlesen{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
|
||||
|
|
@ -8,109 +8,193 @@
|
|||
<div class="alert alert-success d-flex align-items-center gap-3 mb-4" role="alert">
|
||||
<i class="fa fa-check-circle fa-2x"></i>
|
||||
<div>
|
||||
<strong>Queued — catalog number:</strong>
|
||||
<strong>In der Pipeline —</strong>
|
||||
<span class="fs-4 ms-2 font-monospace fw-bold">{{ catalogNumber }}</span>
|
||||
<div class="small mt-1">
|
||||
Pipeline running.
|
||||
<a href="{{ ea_url().setController('App\\Infrastructure\\Http\\Controller\\Admin\\AIPipelineJobCrudController').setAction('index').generateUrl() }}">Check job status →</a>
|
||||
<a href="{{ ea_url().setController('App\\Infrastructure\\Http\\Controller\\Admin\\AIPipelineJobCrudController').setAction('index').generateUrl() }}">Job-Status ansehen →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Camera Modal ─────────────────────────────────────────────────── #}
|
||||
<div class="modal fade" id="cameraModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="cameraModalLabel"><i class="fa fa-camera me-2"></i>Foto aufnehmen</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body p-0 bg-black text-center" style="min-height:300px;display:flex;align-items:center;justify-content:center;">
|
||||
<video id="camera-video" autoplay playsinline muted
|
||||
style="max-width:100%;max-height:420px;display:block;"></video>
|
||||
<canvas id="camera-canvas" style="display:none;max-width:100%;max-height:420px;"></canvas>
|
||||
<div id="camera-error" class="text-white p-4" style="display:none;">
|
||||
<i class="fa fa-exclamation-circle fa-2x mb-2 d-block text-warning"></i>
|
||||
Kein Kamerazugriff. Bitte Datei hochladen.
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer justify-content-center gap-2">
|
||||
<button id="camera-capture-btn" class="btn btn-primary btn-lg" style="display:none;">
|
||||
<i class="fa fa-circle me-1" style="color:#e74c3c;"></i> Aufnehmen
|
||||
</button>
|
||||
<button id="camera-retake-btn" class="btn btn-secondary" style="display:none;">
|
||||
<i class="fa fa-redo me-1"></i> Wiederholen
|
||||
</button>
|
||||
<button id="camera-confirm-btn" class="btn btn-success btn-lg" style="display:none;">
|
||||
<i class="fa fa-check me-1"></i> Verwenden
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
|
||||
{# ── Left: ingest form ─────────────────────────────────── #}
|
||||
{# ── Left: form ───────────────────────────────────────────────── #}
|
||||
<div class="col-lg-7">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0"><i class="fa fa-camera me-2"></i>Scan Nameplate</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{{ form_start(form, {'attr': {'enctype': 'multipart/form-data', 'novalidate': 'novalidate'}}) }}
|
||||
{{ form_start(form, {'attr': {'enctype': 'multipart/form-data', 'id': 'ingest-form', 'novalidate': 'novalidate'}}) }}
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><h5 class="mb-0"><i class="fa fa-tag me-2"></i>Artikelinfo</h5></div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
{{ form_label(form.articleType) }}
|
||||
{{ form_widget(form.articleType) }}
|
||||
{{ form_errors(form.articleType) }}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="row g-3">
|
||||
<div class="col-sm-6">
|
||||
{{ form_label(form.condition) }}
|
||||
{{ form_widget(form.condition) }}
|
||||
{{ form_errors(form.condition) }}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form_label(form.image) }}
|
||||
<div id="image-preview-wrapper" class="mb-2" style="display:none">
|
||||
<img id="image-preview" src="" alt="Preview" class="img-thumbnail" style="max-height:220px">
|
||||
</div>
|
||||
{{ form_widget(form.image, {'attr': {'class': 'form-control', 'id': 'ingest-image-input'}}) }}
|
||||
{{ form_errors(form.image) }}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="col-sm-6">
|
||||
{{ form_label(form.conditionNotes) }}
|
||||
{{ form_widget(form.conditionNotes, {'attr': {'class': 'form-control'}}) }}
|
||||
{{ form_errors(form.conditionNotes) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-lg w-100">
|
||||
<i class="fa fa-rocket me-2"></i>Submit to AI Pipeline
|
||||
{# ── Search photo (mandatory) ─────────────────────────────── #}
|
||||
<div class="card mb-3 border-primary">
|
||||
<div class="card-header bg-primary bg-opacity-10">
|
||||
<h5 class="mb-0 text-primary">
|
||||
<i class="fa fa-search me-2"></i>Erkennungs-Foto
|
||||
<span class="badge bg-danger ms-2 fs-6">Pflicht</span>
|
||||
</h5>
|
||||
<div class="text-muted small mt-1">Typenschild / Aufkleber mit Modell & Seriennummer</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{# Hidden actual file input #}
|
||||
{{ form_widget(form.image, {'attr': {'class': 'd-none', 'id': 'search-photo-input'}}) }}
|
||||
{{ form_errors(form.image) }}
|
||||
|
||||
{# Preview #}
|
||||
<div id="search-photo-preview" class="mb-3 text-center" style="display:none;">
|
||||
<img id="search-photo-img" src="" alt="Vorschau"
|
||||
class="img-fluid rounded border" style="max-height:220px;">
|
||||
<div class="mt-2">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="search-photo-clear">
|
||||
<i class="fa fa-times me-1"></i>Entfernen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Placeholder when empty #}
|
||||
<div id="search-photo-placeholder" class="border rounded text-center p-4 text-muted bg-light">
|
||||
<i class="fa fa-image fa-3x mb-3 d-block"></i>
|
||||
Noch kein Foto ausgewählt
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2 mt-3">
|
||||
<button type="button" class="btn btn-primary flex-fill" id="btn-search-camera">
|
||||
<i class="fa fa-camera me-2"></i>Kamera
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary flex-fill" id="btn-search-file">
|
||||
<i class="fa fa-folder-open me-2"></i>Datei wählen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Additional photos (optional) ─────────────────────────── #}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="fa fa-images me-2"></i>Weitere Fotos <span class="text-muted fw-normal fs-6">(optional)</span></h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{# Hidden input that holds extra files #}
|
||||
<input type="file" name="additional_photos[]" id="extra-photos-input"
|
||||
accept="image/jpeg,image/png,image/webp" multiple class="d-none">
|
||||
|
||||
<div id="extra-photos-grid" class="d-flex flex-wrap gap-2 mb-3" style="min-height:60px;">
|
||||
<div id="extra-photos-empty" class="text-muted small fst-italic d-flex align-items-center">
|
||||
Keine weiteren Fotos hinzugefügt
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" class="btn btn-outline-primary flex-fill" id="btn-extra-camera">
|
||||
<i class="fa fa-camera me-2"></i>Kamera
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary flex-fill" id="btn-extra-file">
|
||||
<i class="fa fa-folder-open me-2"></i>Datei wählen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-success btn-lg w-100" id="submit-btn">
|
||||
<i class="fa fa-rocket me-2"></i>Zur KI-Pipeline senden
|
||||
</button>
|
||||
|
||||
{{ form_end(form) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Right: AI config panel ────────────────────────────── #}
|
||||
{# ── Right: AI config ─────────────────────────────────────────── #}
|
||||
<div class="col-lg-5">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="card-title mb-0"><i class="fa fa-robot me-2"></i>AI Configuration</h5>
|
||||
<h5 class="card-title mb-0"><i class="fa fa-robot me-2"></i>KI-Konfiguration</h5>
|
||||
<span class="badge bg-{{ aiConfig.backend == 'Mistral' ? 'primary' : 'secondary' }}">
|
||||
{{ aiConfig.backend }} active
|
||||
{{ aiConfig.backend }} aktiv
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-sm table-borderless mb-0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th class="ps-3 text-muted fw-normal" style="width:45%">Vision model</th>
|
||||
<th class="ps-3 text-muted fw-normal" style="width:45%">Vision-Modell</th>
|
||||
<td class="font-monospace">{{ aiConfig.vision_model }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="ps-3 text-muted fw-normal">Text model</th>
|
||||
<th class="ps-3 text-muted fw-normal">Text-Modell</th>
|
||||
<td class="font-monospace">{{ aiConfig.text_model }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="ps-3 text-muted fw-normal">Mistral endpoint</th>
|
||||
<th class="ps-3 text-muted fw-normal">Mistral Endpoint</th>
|
||||
<td class="font-monospace small">{{ aiConfig.mistral_base_url }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="ps-3 text-muted fw-normal">Mistral API key</th>
|
||||
<th class="ps-3 text-muted fw-normal">Mistral API-Key</th>
|
||||
<td>
|
||||
{% if aiConfig.mistral_key_set %}
|
||||
<span class="badge bg-success me-1">set</span>
|
||||
<span class="badge bg-success me-1">gesetzt</span>
|
||||
<span class="font-monospace small text-muted">{{ aiConfig.mistral_key_hint }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger">not set</span>
|
||||
<span class="badge bg-danger">nicht gesetzt</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="ps-3 text-muted fw-normal">Ollama endpoint</th>
|
||||
<td class="font-monospace small">{{ aiConfig.ollama_base_url }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<button id="btn-test-mistral" class="btn btn-outline-primary btn-sm w-100"
|
||||
{% if not aiConfig.mistral_key_set %}disabled title="Set MISTRAL_API_KEY first"{% endif %}>
|
||||
<i class="fa fa-plug me-1"></i>Test Mistral Connection
|
||||
{% if not aiConfig.mistral_key_set %}disabled title="MISTRAL_API_KEY setzen"{% endif %}>
|
||||
<i class="fa fa-plug me-1"></i>Mistral-Verbindung testen
|
||||
</button>
|
||||
<div id="test-result" class="mt-3" style="display:none"></div>
|
||||
</div>
|
||||
|
|
@ -120,68 +204,227 @@
|
|||
</div>
|
||||
|
||||
<script>
|
||||
document.getElementById('ingest-image-input')?.addEventListener('change', function () {
|
||||
const file = this.files[0];
|
||||
if (!file) return;
|
||||
const wrapper = document.getElementById('image-preview-wrapper');
|
||||
const img = document.getElementById('image-preview');
|
||||
img.src = URL.createObjectURL(file);
|
||||
wrapper.style.display = 'block';
|
||||
});
|
||||
(function () {
|
||||
/* ── Camera state ──────────────────────────────────────────── */
|
||||
const video = document.getElementById('camera-video');
|
||||
const canvas = document.getElementById('camera-canvas');
|
||||
const errDiv = document.getElementById('camera-error');
|
||||
const btnCapture = document.getElementById('camera-capture-btn');
|
||||
const btnRetake = document.getElementById('camera-retake-btn');
|
||||
const btnConfirm = document.getElementById('camera-confirm-btn');
|
||||
|
||||
document.getElementById('btn-test-mistral')?.addEventListener('click', async function () {
|
||||
const btn = this;
|
||||
const resultDiv = document.getElementById('test-result');
|
||||
let cameraStream = null;
|
||||
let cameraTarget = null; // 'search' | 'extra'
|
||||
let capturedBlob = null;
|
||||
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fa fa-spinner fa-spin me-1"></i>Testing…';
|
||||
resultDiv.style.display = 'none';
|
||||
const modal = new bootstrap.Modal(document.getElementById('cameraModal'));
|
||||
|
||||
const body = new FormData();
|
||||
body.append('target', 'mistral');
|
||||
async function openCamera(target) {
|
||||
cameraTarget = target;
|
||||
capturedBlob = null;
|
||||
canvas.style.display = 'none';
|
||||
errDiv.style.display = 'none';
|
||||
video.style.display = 'block';
|
||||
btnCapture.style.display = 'none';
|
||||
btnRetake.style.display = 'none';
|
||||
btnConfirm.style.display = 'none';
|
||||
modal.show();
|
||||
|
||||
try {
|
||||
const resp = await fetch('{{ path('admin_ai_test') }}', { method: 'POST', body });
|
||||
const data = await resp.json();
|
||||
cameraStream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: { ideal: 'environment' }, width: { ideal: 1920 } },
|
||||
});
|
||||
video.srcObject = cameraStream;
|
||||
await video.play();
|
||||
btnCapture.style.display = 'inline-block';
|
||||
} catch {
|
||||
stopCamera();
|
||||
video.style.display = 'none';
|
||||
errDiv.style.display = 'block';
|
||||
// Mobile fallback: trigger native camera input
|
||||
if (cameraTarget === 'search') {
|
||||
document.getElementById('search-photo-input').click();
|
||||
} else {
|
||||
document.getElementById('extra-photos-input').click();
|
||||
}
|
||||
modal.hide();
|
||||
}
|
||||
}
|
||||
|
||||
function stopCamera() {
|
||||
cameraStream?.getTracks().forEach(t => t.stop());
|
||||
cameraStream = null;
|
||||
}
|
||||
|
||||
document.getElementById('cameraModal').addEventListener('hidden.bs.modal', stopCamera);
|
||||
|
||||
btnCapture.addEventListener('click', () => {
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
canvas.getContext('2d').drawImage(video, 0, 0);
|
||||
stopCamera();
|
||||
video.style.display = 'none';
|
||||
canvas.style.display = 'block';
|
||||
btnCapture.style.display = 'none';
|
||||
btnRetake.style.display = 'inline-block';
|
||||
btnConfirm.style.display = 'inline-block';
|
||||
canvas.toBlob(b => { capturedBlob = b; }, 'image/jpeg', 0.92);
|
||||
});
|
||||
|
||||
btnRetake.addEventListener('click', () => openCamera(cameraTarget));
|
||||
|
||||
btnConfirm.addEventListener('click', () => {
|
||||
if (!capturedBlob) return;
|
||||
const file = new File([capturedBlob], 'kamera-' + Date.now() + '.jpg', { type: 'image/jpeg' });
|
||||
if (cameraTarget === 'search') {
|
||||
setSearchPhoto(file);
|
||||
} else {
|
||||
addExtraPhoto(file);
|
||||
}
|
||||
modal.hide();
|
||||
});
|
||||
|
||||
/* ── Search photo ──────────────────────────────────────────── */
|
||||
const searchInput = document.getElementById('search-photo-input');
|
||||
const searchPreview = document.getElementById('search-photo-preview');
|
||||
const searchImg = document.getElementById('search-photo-img');
|
||||
const searchHolder = document.getElementById('search-photo-placeholder');
|
||||
|
||||
document.getElementById('btn-search-camera').addEventListener('click', () => openCamera('search'));
|
||||
document.getElementById('btn-search-file').addEventListener('click', () => searchInput.click());
|
||||
document.getElementById('search-photo-clear').addEventListener('click', () => {
|
||||
searchInput.value = '';
|
||||
searchImg.src = '';
|
||||
searchPreview.style.display = 'none';
|
||||
searchHolder.style.display = 'block';
|
||||
});
|
||||
|
||||
searchInput.addEventListener('change', () => {
|
||||
if (searchInput.files[0]) setSearchPhoto(searchInput.files[0]);
|
||||
});
|
||||
|
||||
function setSearchPhoto(file) {
|
||||
const dt = new DataTransfer();
|
||||
dt.items.add(file);
|
||||
searchInput.files = dt.files;
|
||||
searchImg.src = URL.createObjectURL(file);
|
||||
searchPreview.style.display = 'block';
|
||||
searchHolder.style.display = 'none';
|
||||
}
|
||||
|
||||
/* ── Extra photos ──────────────────────────────────────────── */
|
||||
const extraInput = document.getElementById('extra-photos-input');
|
||||
const extraGrid = document.getElementById('extra-photos-grid');
|
||||
const extraEmpty = document.getElementById('extra-photos-empty');
|
||||
let extraFiles = [];
|
||||
|
||||
document.getElementById('btn-extra-camera').addEventListener('click', () => openCamera('extra'));
|
||||
document.getElementById('btn-extra-file').addEventListener('click', () => extraInput.click());
|
||||
|
||||
extraInput.addEventListener('change', () => {
|
||||
for (const f of extraInput.files) addExtraPhoto(f);
|
||||
extraInput.value = '';
|
||||
});
|
||||
|
||||
function addExtraPhoto(file) {
|
||||
extraFiles.push(file);
|
||||
syncExtraInput();
|
||||
renderExtraGrid();
|
||||
}
|
||||
|
||||
function removeExtraPhoto(idx) {
|
||||
extraFiles.splice(idx, 1);
|
||||
syncExtraInput();
|
||||
renderExtraGrid();
|
||||
}
|
||||
|
||||
function syncExtraInput() {
|
||||
const dt = new DataTransfer();
|
||||
extraFiles.forEach(f => dt.items.add(f));
|
||||
extraInput.files = dt.files;
|
||||
}
|
||||
|
||||
function renderExtraGrid() {
|
||||
// remove all thumbnail cards (keep the empty placeholder)
|
||||
extraGrid.querySelectorAll('.extra-thumb').forEach(el => el.remove());
|
||||
extraEmpty.style.display = extraFiles.length === 0 ? 'flex' : 'none';
|
||||
|
||||
extraFiles.forEach((file, idx) => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'extra-thumb position-relative';
|
||||
card.style.cssText = 'width:90px;height:90px;';
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = URL.createObjectURL(file);
|
||||
img.className = 'w-100 h-100 rounded border object-fit-cover';
|
||||
img.style.objectFit = 'cover';
|
||||
|
||||
const del = document.createElement('button');
|
||||
del.type = 'button';
|
||||
del.className = 'btn btn-danger btn-sm position-absolute top-0 end-0 p-0 d-flex align-items-center justify-content-center';
|
||||
del.style.cssText = 'width:20px;height:20px;font-size:.65rem;line-height:1;border-radius:50%;transform:translate(40%,-40%);';
|
||||
del.innerHTML = '×';
|
||||
del.addEventListener('click', () => removeExtraPhoto(idx));
|
||||
|
||||
card.appendChild(img);
|
||||
card.appendChild(del);
|
||||
extraGrid.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Submit validation ─────────────────────────────────────── */
|
||||
document.getElementById('ingest-form').addEventListener('submit', e => {
|
||||
if (!searchInput.files || searchInput.files.length === 0) {
|
||||
e.preventDefault();
|
||||
searchHolder.classList.add('border-danger');
|
||||
searchHolder.innerHTML = '<i class="fa fa-exclamation-triangle fa-2x text-danger mb-2 d-block"></i>'
|
||||
+ '<span class="text-danger fw-bold">Erkennungs-Foto ist Pflicht</span>';
|
||||
searchHolder.style.display = 'block';
|
||||
}
|
||||
});
|
||||
|
||||
/* ── Mistral test ──────────────────────────────────────────── */
|
||||
document.getElementById('btn-test-mistral')?.addEventListener('click', async function () {
|
||||
const btn = this;
|
||||
const resultDiv = document.getElementById('test-result');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fa fa-spinner fa-spin me-1"></i>Teste…';
|
||||
resultDiv.style.display = 'none';
|
||||
try {
|
||||
const resp = await fetch('{{ path('admin_ai_test') }}', { method: 'POST', body: new FormData() });
|
||||
const data = await resp.json();
|
||||
resultDiv.style.display = 'block';
|
||||
resultDiv.innerHTML = renderTestResult(data);
|
||||
} catch (e) {
|
||||
} catch (err) {
|
||||
resultDiv.style.display = 'block';
|
||||
resultDiv.innerHTML = '<div class="alert alert-danger py-2 mb-0">Request failed: ' + e.message + '</div>';
|
||||
resultDiv.innerHTML = '<div class="alert alert-danger py-2 mb-0">Fehler: ' + err.message + '</div>';
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fa fa-plug me-1"></i>Test Mistral Connection';
|
||||
btn.innerHTML = '<i class="fa fa-plug me-1"></i>Mistral-Verbindung testen';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function renderTestResult(data) {
|
||||
const rows = [
|
||||
modelRow('Text', data.text),
|
||||
modelRow('Vision', data.vision),
|
||||
].join('');
|
||||
|
||||
return `<table class="table table-sm mb-0">${rows}</table>`;
|
||||
}
|
||||
|
||||
function modelRow(label, r) {
|
||||
if (r.ok) {
|
||||
function renderTestResult(data) {
|
||||
return '<table class="table table-sm mb-0">'
|
||||
+ modelRow('Text', data.text)
|
||||
+ modelRow('Vision', data.vision)
|
||||
+ '</table>';
|
||||
}
|
||||
function modelRow(label, r) {
|
||||
if (r?.ok) {
|
||||
return `<tr>
|
||||
<td><span class="badge bg-success">OK</span> <strong>${label}</strong></td>
|
||||
<td class="font-monospace small">${escHtml(r.model)}</td>
|
||||
<td class="font-monospace small">${e(r.model)}</td>
|
||||
<td class="text-muted small">${r.ms} ms</td>
|
||||
</tr>
|
||||
<tr><td colspan="3" class="ps-3 text-muted small">${escHtml(r.response ?? '')}</td></tr>`;
|
||||
</tr><tr><td colspan="3" class="ps-3 text-muted small">${e(r.response ?? '')}</td></tr>`;
|
||||
}
|
||||
return `<tr>
|
||||
<td><span class="badge bg-danger">FAIL</span> <strong>${label}</strong></td>
|
||||
<td colspan="2" class="font-monospace small text-danger">${escHtml(r.error ?? 'unknown error')}</td>
|
||||
<td colspan="2" class="font-monospace small text-danger">${e(r?.error ?? 'Unbekannter Fehler')}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
}
|
||||
function e(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
})();
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
|
|
|||
Loading…
Reference in a new issue