service-llm-private/templates/index.html
2026-02-06 10:27:06 +01:00

1334 lines
47 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Belegscanner PowerOn</title>
<link rel="icon" type="image/png" href="{{ url_for('static', filename='favicon.png') }}">
<style>
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,700&family=JetBrains+Mono:wght@400;500&display=swap');
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
/* Poweron Design System - Light Theme */
:root {
--color-bg: #F8F9FA;
--color-surface: #ffffff;
--color-surface-elevated: #f5f5f5;
--color-text: #1a1a1a;
--color-text-secondary: #666666;
--color-text-muted: #888888;
--color-primary: #F25843;
--color-primary-hover: #D94A37;
--color-primary-disabled: #F5B0A4;
--color-primary-glow: rgba(242, 88, 67, 0.12);
--color-secondary: #F25843;
--color-secondary-hover: #FF6A55;
--color-secondary-disabled: #F5B0A4;
--color-border: #e0e0e0;
--color-border-hover: #d0d0d0;
--color-success: #22c55e;
--color-warning: #f59e0b;
--color-error: #dc2626;
--font-family: "DM Sans", "Trebuchet MS", sans-serif;
--font-mono: 'JetBrains Mono', monospace;
--radius-large: 30px;
--radius-medium: 15px;
--radius-small: 8px;
}
body {
font-family: var(--font-family);
background: var(--color-bg);
color: var(--color-text);
min-height: 100vh;
line-height: 1.6;
}
.app-container {
max-width: 1400px;
margin: 0 auto;
padding: 0 2rem 2rem 2rem;
min-height: 100vh;
}
/* Header - Sticky */
header {
position: sticky;
top: 0;
z-index: 100;
padding: 1rem 2rem;
border-bottom: 1px solid var(--color-border);
backdrop-filter: blur(10px);
background: rgba(255, 255, 255, 0.95);
}
header .app-container {
padding: 0;
min-height: auto;
}
.logo {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 0.25rem;
}
.logo-icon {
width: 64px;
height: 64px;
display: flex;
align-items: center;
justify-content: center;
}
.logo-icon img {
width: 100%;
height: 100%;
object-fit: contain;
}
h1 {
font-size: 1.75rem;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--color-text);
}
.subtitle {
color: var(--color-text-secondary);
font-size: 0.95rem;
}
/* Grid Layout - 3 Spalten */
.main-grid {
display: grid;
grid-template-columns: minmax(280px, 1fr) minmax(350px, 1.2fr) minmax(350px, 1.2fr);
gap: 1.5rem;
align-items: start;
padding-top: 1.5rem;
height: calc(100vh - 120px);
}
@media (max-width: 1400px) {
.main-grid {
grid-template-columns: 1fr 1fr;
height: auto;
}
.upload-panel {
grid-column: 1 / -1;
}
}
@media (max-width: 900px) {
.main-grid {
grid-template-columns: 1fr;
}
}
/* Panel */
.panel {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-medium);
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
display: flex;
flex-direction: column;
max-height: calc(100vh - 140px);
}
.panel-header {
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--color-border);
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
.panel-title {
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-secondary);
}
.panel-body {
padding: 1.25rem;
overflow-y: auto;
flex: 1;
}
/* Upload Panel - kompakt */
.upload-panel {
min-width: 200px;
}
.upload-panel .upload-zone {
padding: 1.5rem 1rem;
min-height: 200px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.upload-panel .preview-image {
max-height: 350px;
}
/* Settings Panel */
.settings-panel .prompt-textarea {
min-height: 180px;
}
/* Settings Row */
.settings-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--color-border);
}
.settings-row label {
font-size: 0.8rem;
color: var(--color-text-secondary);
min-width: 55px;
flex-shrink: 0;
}
.settings-row input {
flex: 1;
min-width: 0;
padding: 0.5rem 0.75rem;
background: var(--color-surface-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-small);
color: var(--color-text);
font-family: var(--font-mono);
font-size: 0.8rem;
transition: border-color 0.2s, box-shadow 0.2s;
}
.settings-row input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-glow);
}
/* Upload Zone */
.upload-zone {
border: 2px dashed var(--color-border);
border-radius: var(--radius-medium);
padding: 3rem 2rem;
text-align: center;
cursor: pointer;
transition: all 0.2s ease;
background: var(--color-surface-elevated);
position: relative;
overflow: hidden;
}
.upload-zone:hover {
border-color: var(--color-primary);
background: var(--color-primary-glow);
}
.upload-zone.dragover {
border-color: var(--color-primary);
background: var(--color-primary-glow);
transform: scale(1.01);
}
.upload-zone.has-image {
padding: 1rem;
}
.upload-icon {
font-size: 3rem;
margin-bottom: 1rem;
opacity: 0.6;
}
.upload-text {
color: var(--color-text-secondary);
margin-bottom: 0.5rem;
}
.upload-hint {
font-size: 0.8rem;
color: var(--color-text-muted);
}
#file-input {
display: none;
}
.preview-image {
max-width: 100%;
max-height: 400px;
border-radius: var(--radius-small);
object-fit: contain;
}
.image-actions {
margin-top: 1rem;
display: flex;
gap: 0.5rem;
justify-content: center;
align-items: center;
flex-wrap: wrap;
}
#pdf-page-selector {
display: flex;
align-items: center;
gap: 0.25rem;
margin-left: 1rem;
padding-left: 1rem;
border-left: 1px solid var(--color-border);
}
/* Prompt Section */
.prompt-section {
margin-top: 1.5rem;
}
.prompt-label {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.prompt-label span {
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text-secondary);
}
.prompt-textarea {
width: 100%;
min-height: 140px;
padding: 1rem;
background: var(--color-surface-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-small);
color: var(--color-text);
font-family: var(--font-mono);
font-size: 0.85rem;
line-height: 1.6;
resize: vertical;
transition: border-color 0.2s, box-shadow 0.2s;
}
.prompt-textarea:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-glow);
}
/* Buttons - Poweron Style */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
border-radius: var(--radius-large);
font-family: inherit;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
border: none;
}
.btn-primary {
background: var(--color-primary);
color: white;
}
.btn-primary:hover:not(:disabled) {
background: var(--color-primary-hover);
transform: translateY(-1px);
}
.btn-primary:disabled {
background: var(--color-primary-disabled);
cursor: not-allowed;
opacity: 0.7;
}
.btn-secondary {
background: #EFEDE5;
color: var(--color-text);
border: 1px solid var(--color-border);
}
.btn-secondary:hover {
background: #E0DDD3;
border-color: var(--color-border-hover);
}
.btn-small {
padding: 0.5rem 1rem;
font-size: 0.8rem;
}
.action-bar {
margin-top: 1.5rem;
display: flex;
gap: 1rem;
align-items: center;
}
/* Results Panel */
.results-panel {
position: sticky;
top: 2rem;
}
/* Status Badge */
.status-badge {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.35rem 0.75rem;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 500;
}
.status-idle {
background: var(--color-surface-elevated);
color: var(--color-text-muted);
}
.status-processing {
background: rgba(245, 158, 11, 0.15);
color: var(--color-warning);
}
.status-success {
background: rgba(34, 197, 94, 0.15);
color: var(--color-success);
}
.status-error {
background: rgba(239, 68, 68, 0.15);
color: var(--color-error);
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
.status-processing .status-dot {
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
/* Form Placeholder */
.form-placeholder {
text-align: center;
padding: 4rem 2rem;
color: var(--color-text-muted);
}
.form-placeholder-icon {
font-size: 3rem;
margin-bottom: 1rem;
opacity: 0.4;
}
/* Form Fields */
.form-grid {
display: flex;
flex-direction: column;
gap: 1rem;
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.form-field label {
font-size: 0.8rem;
font-weight: 500;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.form-field input,
.form-field textarea {
padding: 0.75rem 1rem;
background: var(--color-surface-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-small);
color: var(--color-text);
font-family: inherit;
font-size: 0.95rem;
transition: border-color 0.2s, box-shadow 0.2s;
}
.form-field input:focus,
.form-field textarea:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-glow);
}
.form-field textarea {
min-height: 80px;
resize: vertical;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.form-actions {
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--color-border);
display: flex;
gap: 1rem;
}
/* Spinner */
.spinner {
width: 18px;
height: 18px;
border: 2px solid transparent;
border-top-color: currentColor;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Error Message */
.error-message {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: var(--radius-small);
padding: 1rem;
color: var(--color-error);
font-size: 0.9rem;
}
/* Raw JSON Section */
.raw-json-section {
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--color-border);
}
.raw-json-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8rem;
color: var(--color-text-muted);
cursor: pointer;
user-select: none;
}
.raw-json-toggle:hover {
color: var(--color-text-secondary);
}
.raw-json-content {
margin-top: 1rem;
padding: 1rem;
background: var(--color-surface-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-small);
font-family: var(--font-mono);
font-size: 0.8rem;
overflow-x: auto;
white-space: pre-wrap;
color: var(--color-text-secondary);
}
/* Ollama Status */
.ollama-status {
padding: 0.75rem 1rem;
border-radius: var(--radius-small);
font-size: 0.85rem;
margin-bottom: 1rem;
}
.ollama-status.success {
background: rgba(34, 197, 94, 0.1);
color: #16a34a;
border: 1px solid rgba(34, 197, 94, 0.3);
}
.ollama-status.error {
background: rgba(220, 38, 38, 0.1);
color: var(--color-error);
border: 1px solid rgba(220, 38, 38, 0.3);
}
.ollama-status.loading {
background: rgba(242, 88, 67, 0.1);
color: var(--color-primary);
border: 1px solid rgba(242, 88, 67, 0.3);
}
/* Footer Branding */
.footer-brand {
margin-top: 1.5rem;
padding: 1rem;
text-align: center;
color: var(--color-text-muted);
font-size: 0.75rem;
}
.footer-brand a {
color: var(--color-primary);
text-decoration: none;
font-weight: 500;
}
.footer-brand a:hover {
color: var(--color-primary-hover);
text-decoration: underline;
}
</style>
</head>
<body>
<header>
<div class="app-container" style="display: flex; justify-content: space-between; align-items: center;">
<div>
<div class="logo">
<div class="logo-icon">
<img src="{{ url_for('static', filename='poweron-logo.png') }}" alt="Poweron">
</div>
<h1>Belegscanner</h1>
</div>
<p class="subtitle">KI-gestützte Dokumentenanalyse</p>
</div>
<a href="{{ url_for('_logout') }}" class="btn btn-secondary btn-small" style="text-decoration: none;">Abmelden</a>
</div>
</header>
<div class="app-container">
<div class="main-grid">
<!-- Panel 1: Upload/Preview -->
<div class="panel upload-panel">
<div class="panel-header">
<span class="panel-title">Dokument</span>
</div>
<div class="panel-body">
<div class="upload-zone" id="upload-zone">
<div class="upload-placeholder" id="upload-placeholder">
<div class="upload-icon">📤</div>
<p class="upload-text">Bild oder PDF</p>
<p class="upload-hint">Drag & Drop</p>
</div>
<img id="preview-image" class="preview-image" style="display: none;">
<input type="file" id="file-input" accept="image/*,.pdf,application/pdf">
</div>
<div class="image-actions" id="image-actions" style="display: none;">
<button class="btn btn-secondary btn-small" id="remove-image"></button>
<div id="pdf-page-selector" style="display: none;">
<button class="btn btn-secondary btn-small" id="prev-page" disabled></button>
<span id="page-info" style="font-size: 0.8rem;">1/1</span>
<button class="btn btn-secondary btn-small" id="next-page" disabled></button>
</div>
</div>
</div>
</div>
<!-- Panel 2: Settings & Prompt -->
<div class="panel settings-panel">
<div class="panel-header">
<span class="panel-title">Einstellungen</span>
</div>
<div class="panel-body">
<div class="settings-row">
<label>Server:</label>
<input type="text" id="ollama-url" value="http://localhost:11434" placeholder="http://localhost:11434">
<button class="btn btn-secondary btn-small" id="check-ollama">Prüfen</button>
</div>
<div class="settings-row">
<label>Modell:</label>
<select id="model-name" style="flex:1; padding:0.6rem; background:var(--color-surface-elevated); border:1px solid var(--color-border); border-radius:var(--radius-small); color:var(--color-text); font-family:var(--font-mono); font-size:0.85rem;">
<option value="">-- Modell wählen --</option>
</select>
</div>
<div id="ollama-status" class="ollama-status" style="display:none;"></div>
<div class="prompt-section" style="margin-top: 1rem;">
<div class="prompt-label">
<span>Prompt</span>
<button class="btn btn-secondary btn-small" id="reset-prompt">Reset</button>
</div>
<textarea class="prompt-textarea" id="prompt-input">Analysiere diesen Beleg/diese Rechnung und extrahiere alle relevanten Daten.
Antworte NUR mit einem validen JSON-Objekt im folgenden Format:
{
"haendler": "Name des Geschäfts/Händlers",
"datum": "Datum im Format TT.MM.JJJJ",
"betrag_brutto": "Gesamtbetrag inkl. MwSt",
"betrag_netto": "Nettobetrag ohne MwSt (falls vorhanden)",
"mwst_betrag": "MwSt-Betrag (falls vorhanden)",
"mwst_satz": "MwSt-Satz in Prozent (falls vorhanden)",
"waehrung": "EUR, CHF, USD etc.",
"kategorie": "z.B. Verpflegung, Transport, Unterkunft, Büromaterial",
"zahlungsart": "Bar, Karte, Rechnung etc. (falls erkennbar)",
"rechnungsnummer": "Falls vorhanden",
"positionen": ["Liste der einzelnen Positionen falls erkennbar"],
"notizen": "Alle weiteren Informationen aus dem Dokument hier erfassen"
}
Falls ein Feld nicht erkennbar ist, setze den Wert auf null.</textarea>
</div>
<div class="action-bar">
<button class="btn btn-primary" id="analyze-btn" disabled>
<span id="btn-text">Analysieren</span>
<div class="spinner" id="btn-spinner" style="display: none;"></div>
</button>
</div>
</div>
</div>
<!-- Right Panel: Results -->
<div class="panel results-panel">
<div class="panel-header">
<span class="panel-title">Extrahierte Daten</span>
<span class="status-badge status-idle" id="status-badge">
<span class="status-dot"></span>
<span id="status-text">Warte auf Eingabe</span>
</span>
</div>
<div class="panel-body">
<div id="results-container">
<div class="form-placeholder" id="form-placeholder">
<div class="form-placeholder-icon">🔍</div>
<p>Laden Sie ein Dokument hoch und klicken Sie auf "Analysieren"</p>
</div>
<div id="error-container" style="display: none;"></div>
<div id="form-container" style="display: none;">
<!-- Dynamisch generierte Felder -->
<div id="dynamic-fields" class="form-grid"></div>
<div class="form-actions">
<button class="btn btn-primary" id="copy-json">📋 JSON kopieren</button>
<button class="btn btn-secondary" id="download-json">💾 JSON speichern</button>
</div>
<div class="raw-json-section">
<div class="raw-json-toggle" id="toggle-raw">
<span></span>
<span>Rohe JSON-Antwort anzeigen</span>
</div>
<div class="raw-json-content" id="raw-json" style="display: none;"></div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="footer-brand">
Powered by <a href="#">Poweron</a> • KI-Dokumentenanalyse
</div>
</div>
<script>
// State
let currentImage = null;
let currentImageBase64 = null;
let extractedData = null;
const DEFAULT_PROMPT = document.getElementById('prompt-input').value;
// Elements
const uploadZone = document.getElementById('upload-zone');
const fileInput = document.getElementById('file-input');
const previewImage = document.getElementById('preview-image');
const uploadPlaceholder = document.getElementById('upload-placeholder');
const imageActions = document.getElementById('image-actions');
const removeImageBtn = document.getElementById('remove-image');
const promptInput = document.getElementById('prompt-input');
const resetPromptBtn = document.getElementById('reset-prompt');
const analyzeBtn = document.getElementById('analyze-btn');
const btnText = document.getElementById('btn-text');
const btnSpinner = document.getElementById('btn-spinner');
const statusBadge = document.getElementById('status-badge');
const statusText = document.getElementById('status-text');
const formPlaceholder = document.getElementById('form-placeholder');
const formContainer = document.getElementById('form-container');
const errorContainer = document.getElementById('error-container');
const ollamaUrl = document.getElementById('ollama-url');
const modelName = document.getElementById('model-name');
const toggleRaw = document.getElementById('toggle-raw');
const rawJson = document.getElementById('raw-json');
const copyJsonBtn = document.getElementById('copy-json');
const downloadJsonBtn = document.getElementById('download-json');
const checkOllamaBtn = document.getElementById('check-ollama');
const ollamaStatusDiv = document.getElementById('ollama-status');
// Ollama Status prüfen
checkOllamaBtn.addEventListener('click', _checkOllamaStatus);
// PowerOn Model Definitions (must match app.py MODEL_MAPPING)
const POWERON_MODELS = [
{
name: 'poweron-vision-general',
displayName: 'PowerOn Vision General',
description: 'Handschrift & allgemeine Bilder (qwen2.5vl:7b)',
isVision: true,
ollamaModel: 'qwen2.5vl:7b'
},
{
name: 'poweron-vision-deep',
displayName: 'PowerOn Vision Deep',
description: 'Rechnungen, Belege, Dokumente (granite3.2-vision)',
isVision: true,
ollamaModel: 'granite3.2-vision'
},
{
name: 'poweron-ocr-general',
displayName: 'PowerOn OCR General',
description: 'Text-Extraktion / OCR (deepseek-ocr)',
isVision: true,
ollamaModel: 'deepseek-ocr'
}
];
async function _checkOllamaStatus() {
ollamaStatusDiv.style.display = 'block';
ollamaStatusDiv.className = 'ollama-status loading';
ollamaStatusDiv.textContent = 'Verbinde mit Ollama...';
try {
const response = await fetch(`/api/ollama/status?url=${encodeURIComponent(ollamaUrl.value)}`);
const result = await response.json();
if (result.connected) {
ollamaStatusDiv.className = 'ollama-status success';
// PowerOn Modelle in Dropdown laden (nur wenn Backend-Modell verfügbar)
modelName.innerHTML = '';
const availableModels = result.models || [];
const availablePowerOnModels = POWERON_MODELS.filter(pm =>
availableModels.some(m => m.startsWith(pm.ollamaModel.split(':')[0]))
);
if (availablePowerOnModels.length > 0) {
const optGroup = document.createElement('optgroup');
optGroup.label = 'PowerOn Modelle';
availablePowerOnModels.forEach(model => {
const opt = document.createElement('option');
opt.value = model.name;
opt.textContent = `${model.displayName}`;
opt.title = model.description;
optGroup.appendChild(opt);
});
modelName.appendChild(optGroup);
// Erstes Modell auswählen
modelName.value = availablePowerOnModels[0].name;
}
ollamaStatusDiv.innerHTML = `✓ Verbunden - ${availablePowerOnModels.length} PowerOn Modelle verfügbar`;
// Button-Status nach Modell-Laden aktualisieren
_updateAnalyzeButtonState();
} else {
ollamaStatusDiv.className = 'ollama-status error';
ollamaStatusDiv.textContent = `${result.error}`;
modelName.innerHTML = '<option value="">-- Ollama nicht verbunden --</option>';
}
} catch (error) {
ollamaStatusDiv.className = 'ollama-status error';
ollamaStatusDiv.textContent = `✗ Fehler: ${error.message}`;
}
}
// Helper: Prüft ob Modell ein Vision-Modell ist
function _isVisionModel(model) {
if (!model) return true; // Default: als Vision behandeln
// Check PowerOn models first
const powerOnModel = POWERON_MODELS.find(pm => pm.name === model);
if (powerOnModel) return powerOnModel.isVision;
// Fallback for direct Ollama model names
const modelLower = model.toLowerCase();
return ['vision', 'vl', 'llava', 'bakllava', 'granite', 'deepseek-ocr'].some(indicator => modelLower.includes(indicator));
}
// Button-Status basierend auf Modell und Bild aktualisieren
function _updateAnalyzeButtonState() {
const isVision = _isVisionModel(modelName.value);
const hasImage = !!currentImageBase64;
const hasPrompt = promptInput.value.trim().length > 0;
// Vision-Modelle brauchen Bild, Non-Vision nicht
if (isVision) {
analyzeBtn.disabled = !hasImage;
} else {
analyzeBtn.disabled = !hasPrompt;
}
}
// Bei Modell-Wechsel Button-Status aktualisieren
modelName.addEventListener('change', _updateAnalyzeButtonState);
// Bei Prompt-Änderung Button-Status aktualisieren (für Non-Vision Modelle)
promptInput.addEventListener('input', _updateAnalyzeButtonState);
// Beim Laden automatisch prüfen
window.addEventListener('load', () => {
setTimeout(_checkOllamaStatus, 500);
});
// Upload handling
uploadZone.addEventListener('click', () => fileInput.click());
uploadZone.addEventListener('dragover', (e) => {
e.preventDefault();
uploadZone.classList.add('dragover');
});
uploadZone.addEventListener('dragleave', () => {
uploadZone.classList.remove('dragover');
});
uploadZone.addEventListener('drop', (e) => {
e.preventDefault();
uploadZone.classList.remove('dragover');
const file = e.dataTransfer.files[0];
if (file && (file.type.startsWith('image/') || file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf'))) {
_handleFile(file);
}
});
fileInput.addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) _handleFile(file);
});
// PDF State
let pdfData = null;
let currentPdfPage = 1;
let totalPdfPages = 1;
const pdfPageSelector = document.getElementById('pdf-page-selector');
const prevPageBtn = document.getElementById('prev-page');
const nextPageBtn = document.getElementById('next-page');
const pageInfo = document.getElementById('page-info');
async function _handleFile(file) {
currentImage = file;
// PDF erkennen
if (file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf')) {
await _handlePdfFile(file);
} else {
// Normales Bild
_handleImageFile(file);
}
}
function _handleImageFile(file) {
pdfData = null;
pdfPageSelector.style.display = 'none';
const reader = new FileReader();
reader.onload = (e) => {
currentImageBase64 = e.target.result.split(',')[1];
previewImage.src = e.target.result;
previewImage.style.display = 'block';
uploadPlaceholder.style.display = 'none';
uploadZone.classList.add('has-image');
imageActions.style.display = 'flex';
_updateAnalyzeButtonState();
};
reader.readAsDataURL(file);
}
async function _handlePdfFile(file) {
uploadPlaceholder.innerHTML = '<div class="upload-icon">⏳</div><p class="upload-text">PDF wird verarbeitet...</p>';
try {
const reader = new FileReader();
const pdfBase64 = await new Promise((resolve, reject) => {
reader.onload = (e) => resolve(e.target.result.split(',')[1]);
reader.onerror = reject;
reader.readAsDataURL(file);
});
// PDF an Backend senden zur Bildextraktion
const response = await fetch('/api/pdf/extract', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pdfBase64, page: 1 })
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.error || 'PDF-Verarbeitung fehlgeschlagen');
}
// PDF-Daten speichern
pdfData = pdfBase64;
currentPdfPage = 1;
totalPdfPages = result.image.totalPages;
// Bild anzeigen
currentImageBase64 = result.image.base64;
previewImage.src = 'data:image/png;base64,' + result.image.base64;
previewImage.style.display = 'block';
uploadPlaceholder.style.display = 'none';
uploadZone.classList.add('has-image');
imageActions.style.display = 'flex';
_updateAnalyzeButtonState();
// Page Selector anzeigen wenn mehrere Seiten
if (totalPdfPages > 1) {
pdfPageSelector.style.display = 'flex';
_updatePageSelector();
} else {
pdfPageSelector.style.display = 'none';
}
} catch (error) {
console.error('PDF Error:', error);
uploadPlaceholder.innerHTML = `<div class="upload-icon">❌</div><p class="upload-text">PDF-Fehler: ${error.message}</p><p class="upload-hint">Bitte versuchen Sie es mit einem anderen PDF</p>`;
uploadPlaceholder.style.display = 'block';
}
}
function _updatePageSelector() {
pageInfo.textContent = `Seite ${currentPdfPage}/${totalPdfPages}`;
prevPageBtn.disabled = currentPdfPage <= 1;
nextPageBtn.disabled = currentPdfPage >= totalPdfPages;
}
prevPageBtn.addEventListener('click', () => _changePdfPage(-1));
nextPageBtn.addEventListener('click', () => _changePdfPage(1));
async function _changePdfPage(delta) {
const newPage = currentPdfPage + delta;
if (newPage < 1 || newPage > totalPdfPages || !pdfData) return;
prevPageBtn.disabled = true;
nextPageBtn.disabled = true;
try {
const response = await fetch('/api/pdf/extract', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pdfBase64: pdfData, page: newPage })
});
const result = await response.json();
if (response.ok && result.success) {
currentPdfPage = newPage;
currentImageBase64 = result.image.base64;
previewImage.src = 'data:image/png;base64,' + result.image.base64;
}
} catch (error) {
console.error('Page change error:', error);
}
_updatePageSelector();
}
removeImageBtn.addEventListener('click', (e) => {
e.stopPropagation();
_resetImage();
});
function _resetImage() {
currentImage = null;
currentImageBase64 = null;
pdfData = null;
currentPdfPage = 1;
totalPdfPages = 1;
previewImage.src = '';
previewImage.style.display = 'none';
uploadPlaceholder.innerHTML = '<div class="upload-icon">📤</div><p class="upload-text">Bild oder PDF</p><p class="upload-hint">Drag & Drop</p>';
uploadPlaceholder.style.display = 'block';
uploadZone.classList.remove('has-image');
imageActions.style.display = 'none';
pdfPageSelector.style.display = 'none';
fileInput.value = '';
_updateAnalyzeButtonState();
}
resetPromptBtn.addEventListener('click', () => {
promptInput.value = DEFAULT_PROMPT;
});
// Analysis - using Python backend with CORS
analyzeBtn.addEventListener('click', _analyzeDocument);
async function _analyzeDocument() {
const isVision = _isVisionModel(modelName.value);
// Vision-Modelle brauchen ein Bild
if (isVision && !currentImageBase64) {
_showError('Bitte laden Sie zuerst ein Bild oder PDF hoch (erforderlich für Vision-Modelle).');
return;
}
// Prüfen ob Modell gewählt
if (!modelName.value) {
_showError('Bitte wählen Sie zuerst ein Modell aus. Klicken Sie auf "Prüfen" um die verfügbaren Modelle zu laden.');
return;
}
// Prüfen ob Prompt vorhanden
if (!promptInput.value.trim()) {
_showError('Bitte geben Sie einen Prompt ein.');
return;
}
_setStatus('processing', 'Analysiere...');
_setLoading(true);
_hideError();
try {
// Request-Body erstellen
const requestBody = {
prompt: promptInput.value,
ollamaUrl: ollamaUrl.value,
modelName: modelName.value
};
// Bild nur hinzufügen wenn vorhanden
if (currentImageBase64) {
requestBody.imageBase64 = currentImageBase64;
}
// Call Python backend API (has CORS enabled)
const response = await fetch('/api/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody)
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.error || `HTTP ${response.status}`);
}
if (!result.success) {
throw new Error(result.error || 'Unbekannter Fehler');
}
extractedData = result.data;
_displayResults(extractedData, result.rawResponse);
_setStatus('success', 'Erfolgreich extrahiert');
} catch (error) {
console.error('Error:', error);
_showError(error.message);
_setStatus('error', 'Fehler');
} finally {
_setLoading(false);
}
}
function _setStatus(type, text) {
statusBadge.className = `status-badge status-${type}`;
statusText.textContent = text;
}
function _setLoading(loading) {
analyzeBtn.disabled = loading;
btnText.textContent = loading ? 'Analysiere...' : 'Dokument analysieren';
btnSpinner.style.display = loading ? 'block' : 'none';
}
function _showError(message) {
errorContainer.innerHTML = `<div class="error-message">❌ ${message}</div>`;
errorContainer.style.display = 'block';
formPlaceholder.style.display = 'none';
formContainer.style.display = 'none';
}
function _hideError() {
errorContainer.style.display = 'none';
}
function _displayResults(data, rawResponse) {
formPlaceholder.style.display = 'none';
errorContainer.style.display = 'none';
formContainer.style.display = 'block';
// Dynamische Felder Container
const dynamicFields = document.getElementById('dynamic-fields');
dynamicFields.innerHTML = '';
// Alle Keys aus dem JSON (Ebene 1) als Felder rendern
const keys = Object.keys(data);
// Felder in 2er-Reihen anordnen (ausser bei langen Werten)
let currentRow = null;
let fieldsInRow = 0;
keys.forEach((key, index) => {
const value = data[key];
const valueStr = _formatValue(value);
const isLongValue = valueStr.length > 50 || Array.isArray(value) || (typeof value === 'object' && value !== null);
// Neue Reihe starten wenn nötig
if (!currentRow || fieldsInRow >= 2 || isLongValue) {
currentRow = document.createElement('div');
currentRow.className = isLongValue ? 'form-field' : 'form-row';
dynamicFields.appendChild(currentRow);
fieldsInRow = 0;
}
// Feld-Container erstellen
const fieldDiv = document.createElement('div');
fieldDiv.className = 'form-field';
// Label erstellen (Key formatieren)
const label = document.createElement('label');
label.textContent = _formatLabel(key);
fieldDiv.appendChild(label);
// Input oder Textarea erstellen
if (isLongValue) {
const textarea = document.createElement('textarea');
textarea.id = `field-${key}`;
textarea.rows = Math.min(Math.max(3, valueStr.split('\n').length), 10);
textarea.value = valueStr;
fieldDiv.appendChild(textarea);
currentRow.appendChild(fieldDiv);
// Nach Textarea neue Reihe starten
currentRow = null;
fieldsInRow = 0;
} else {
const input = document.createElement('input');
input.type = 'text';
input.id = `field-${key}`;
input.value = valueStr;
fieldDiv.appendChild(input);
if (currentRow.className === 'form-row') {
currentRow.appendChild(fieldDiv);
fieldsInRow++;
} else {
currentRow.appendChild(fieldDiv);
}
}
});
// Raw JSON
rawJson.textContent = JSON.stringify(data, null, 2);
}
// Hilfsfunktion: Wert für Anzeige formatieren
function _formatValue(value) {
if (value === null || value === undefined) return '';
if (Array.isArray(value)) return value.join('\n');
if (typeof value === 'object') return JSON.stringify(value, null, 2);
return String(value);
}
// Hilfsfunktion: Key als Label formatieren (snake_case -> Title Case)
function _formatLabel(key) {
return key
.replace(/_/g, ' ')
.replace(/([A-Z])/g, ' $1')
.replace(/^./, str => str.toUpperCase())
.trim();
}
// Toggle raw JSON
let rawVisible = false;
toggleRaw.addEventListener('click', () => {
rawVisible = !rawVisible;
rawJson.style.display = rawVisible ? 'block' : 'none';
toggleRaw.querySelector('span:first-child').textContent = rawVisible ? '▼' : '▶';
});
// Get current form data (dynamisch aus allen Feldern)
function _getCurrentFormData() {
const data = {};
const dynamicFields = document.getElementById('dynamic-fields');
const inputs = dynamicFields.querySelectorAll('input, textarea');
inputs.forEach(input => {
// Key aus ID extrahieren (field-xxx -> xxx)
const key = input.id.replace('field-', '');
const value = input.value.trim();
// Mehrzeilige Werte als Array speichern
if (input.tagName === 'TEXTAREA' && value.includes('\n')) {
data[key] = value.split('\n').filter(line => line.trim());
} else {
data[key] = value || null;
}
});
return data;
}
// Copy JSON
copyJsonBtn.addEventListener('click', () => {
const data = _getCurrentFormData();
navigator.clipboard.writeText(JSON.stringify(data, null, 2));
copyJsonBtn.textContent = '✓ Kopiert!';
setTimeout(() => copyJsonBtn.textContent = '📋 JSON kopieren', 2000);
});
// Download JSON
downloadJsonBtn.addEventListener('click', () => {
const data = _getCurrentFormData();
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const timestamp = new Date().toISOString().slice(0, 10);
a.download = `result-${timestamp}.json`;
a.click();
URL.revokeObjectURL(url);
});
</script>
</body>
</html>