SuperSeller3000/templates/admin/manual_ingest.html.twig
Simon Kuehn 020a5ddbc8 feat: add manual ingest form, AI status page and pipeline archive
- ManualIngestController: photo upload form that starts a new pipeline job
- AiStatusController: shows active backend config and runs live connectivity tests
- PipelineArchiveCrudController: read-only view of completed/failed jobs
- ManualIngestType / AttributeValueFormType: form types for ingest and attribute editing
- AiConfigService: encapsulates backend info and test methods for the status page

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 07:18:39 +00:00

187 lines
7.7 KiB
Twig

{% extends '@EasyAdmin/page/content.html.twig' %}
{% block page_title %}Ingest Article{% endblock %}
{% block main %}
{% if catalogNumber %}
<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>
<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>
</div>
</div>
</div>
{% endif %}
<div class="row g-4">
{# ── Left: ingest 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'}}) }}
<div class="mb-3">
{{ form_label(form.articleType) }}
{{ form_widget(form.articleType) }}
{{ form_errors(form.articleType) }}
</div>
<div class="mb-3">
{{ 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">
{{ form_label(form.conditionNotes) }}
{{ form_widget(form.conditionNotes, {'attr': {'class': 'form-control'}}) }}
{{ form_errors(form.conditionNotes) }}
</div>
<button type="submit" class="btn btn-primary btn-lg w-100">
<i class="fa fa-rocket me-2"></i>Submit to AI Pipeline
</button>
{{ form_end(form) }}
</div>
</div>
</div>
{# ── Right: AI config panel ────────────────────────────── #}
<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>
<span class="badge bg-{{ aiConfig.backend == 'Mistral' ? 'primary' : 'secondary' }}">
{{ aiConfig.backend }} active
</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>
<td class="font-monospace">{{ aiConfig.vision_model }}</td>
</tr>
<tr>
<th class="ps-3 text-muted fw-normal">Text model</th>
<td class="font-monospace">{{ aiConfig.text_model }}</td>
</tr>
<tr>
<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>
<td>
{% if aiConfig.mistral_key_set %}
<span class="badge bg-success me-1">set</span>
<span class="font-monospace small text-muted">{{ aiConfig.mistral_key_hint }}</span>
{% else %}
<span class="badge bg-danger">not set</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
</button>
<div id="test-result" class="mt-3" style="display:none"></div>
</div>
</div>
</div>
</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';
});
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>Testing…';
resultDiv.style.display = 'none';
const body = new FormData();
body.append('target', 'mistral');
try {
const resp = await fetch('{{ path('admin_ai_test') }}', { method: 'POST', body });
const data = await resp.json();
resultDiv.style.display = 'block';
resultDiv.innerHTML = renderTestResult(data);
} catch (e) {
resultDiv.style.display = 'block';
resultDiv.innerHTML = '<div class="alert alert-danger py-2 mb-0">Request failed: ' + e.message + '</div>';
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="fa fa-plug me-1"></i>Test Mistral Connection';
}
});
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) {
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="text-muted small">${r.ms} ms</td>
</tr>
<tr><td colspan="3" class="ps-3 text-muted small">${escHtml(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>
</tr>`;
}
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
</script>
{% endblock %}