Fix: Remove broken getKlausurApiUrl and clean up empty lines
Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 42s
CI / test-go-edu-search (push) Successful in 34s
CI / test-python-klausur (push) Failing after 2m51s
CI / test-python-agent-core (push) Successful in 21s
CI / test-nodejs-website (push) Successful in 29s

sed replacement left orphaned hostname references in story page
and empty lines in getApiBase functions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-24 16:02:04 +02:00
parent b07f802c24
commit 9ba420fa91
150 changed files with 30231 additions and 32053 deletions

View File

@@ -0,0 +1,64 @@
'use client'
interface GlobalDragOverlayProps {
active: boolean
}
export function GlobalDragOverlay({ active }: GlobalDragOverlayProps) {
if (!active) return null
return (
<div className="fixed inset-0 z-50 bg-purple-900/80 backdrop-blur-sm flex items-center justify-center pointer-events-none">
<div className="text-center">
<div className="text-7xl mb-4 animate-bounce">📄</div>
<div className="text-2xl font-bold text-white">Bild hier ablegen</div>
<div className="text-purple-200 mt-2">PNG, JPG - Handgeschriebener Text</div>
</div>
</div>
)
}
interface KeyboardShortcutsModalProps {
open: boolean
onClose: () => void
}
export function KeyboardShortcutsModal({ open, onClose }: KeyboardShortcutsModalProps) {
if (!open) return null
return (
<div className="fixed inset-0 z-40 bg-black/50 flex items-center justify-center" onClick={onClose}>
<div className="bg-white rounded-xl shadow-2xl p-6 max-w-md" onClick={e => e.stopPropagation()}>
<h3 className="text-lg font-bold text-slate-900 mb-4">Tastenkuerzel</h3>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-slate-600">Bild einfuegen</span>
<kbd className="px-2 py-1 bg-slate-100 rounded text-xs font-mono">Ctrl+V</kbd>
</div>
<div className="flex justify-between">
<span className="text-slate-600">OCR starten</span>
<kbd className="px-2 py-1 bg-slate-100 rounded text-xs font-mono">Ctrl+Enter</kbd>
</div>
<div className="flex justify-between">
<span className="text-slate-600">Tab wechseln</span>
<kbd className="px-2 py-1 bg-slate-100 rounded text-xs font-mono">Alt+1-6</kbd>
</div>
<div className="flex justify-between">
<span className="text-slate-600">Bild entfernen</span>
<kbd className="px-2 py-1 bg-slate-100 rounded text-xs font-mono">Escape</kbd>
</div>
<div className="flex justify-between">
<span className="text-slate-600">Shortcuts anzeigen</span>
<kbd className="px-2 py-1 bg-slate-100 rounded text-xs font-mono">?</kbd>
</div>
</div>
<button
onClick={onClose}
className="w-full mt-4 px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg text-sm"
>
Schliessen
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,185 @@
'use client'
export function TabArchitecture() {
return (
<div className="space-y-6">
{/* Architecture Diagram */}
<ArchitectureDiagram />
{/* Components */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<ComponentCard
icon="🔍"
title="TrOCR Service"
description="Das TrOCR-Modell von Microsoft ist speziell fuer Handschrifterkennung trainiert. Es verwendet eine Vision-Transformer (ViT) Architektur fuer Bildverarbeitung und einen Text-Decoder fuer die Textgenerierung."
specs={[
{ label: 'Modell', value: 'microsoft/trocr-base-handwritten' },
{ label: 'Groesse', value: '~350 MB' },
{ label: 'Lizenz', value: 'MIT' },
{ label: 'Framework', value: 'PyTorch / Transformers' },
]}
/>
<ComponentCard
icon="🎯"
title="LoRA Fine-Tuning"
description="LoRA fuegt kleine, trainierbare Matrizen zu bestimmten Schichten hinzu, ohne das Basismodell zu veraendern. Dies ermoeglicht effizientes Fine-Tuning mit minimaler Speichernutzung."
specs={[
{ label: 'Methode', value: 'Low-Rank Adaptation' },
{ label: 'Adapter-Groesse', value: '~10 MB' },
{ label: 'Trainingszeit', value: '5-15 Min (CPU)' },
{ label: 'Min. Beispiele', value: '10' },
]}
/>
<ComponentCard
icon="🔒"
title="Pseudonymisierung"
description="Schuelernamen werden durch anonyme Tokens ersetzt, bevor Daten die lokale Umgebung verlassen. Das Mapping wird ausschliesslich lokal gespeichert."
specs={[
{ label: 'Methode', value: 'QR-Code Tokens' },
{ label: 'Token-Format', value: 'UUID v4' },
{ label: 'Mapping', value: 'Lokal beim Lehrer' },
{ label: 'Cloud-Daten', value: 'Nur Tokens + Text' },
]}
/>
<ComponentCard
icon="☁️"
title="Cloud LLM"
description="Die KI-Korrektur erfolgt auf deutschen Servern mit strikter Mandantentrennung. Es werden keine Klarnamen oder identifizierenden Informationen uebertragen."
specs={[
{ label: 'Provider', value: 'SysEleven (DE)' },
{ label: 'Standort', value: 'Deutschland' },
{ label: 'Isolation', value: 'Namespace pro Schule' },
{ label: 'Datenverarbeitung', value: 'Nur pseudonymisiert' },
]}
/>
</div>
{/* Data Flow */}
<DataFlowCard />
</div>
)
}
/* ------------------------------------------------------------------ */
function ArchitectureDiagram() {
return (
<div className="bg-white rounded-xl shadow-sm border p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-6">Systemarchitektur</h2>
<div className="bg-slate-900 rounded-lg p-6 font-mono text-xs overflow-x-auto">
<pre className="text-slate-300">
{`┌─────────────────────────────────────────────────────────────────────────────┐
│ MAGIC HELP ARCHITEKTUR │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌───────────────┐ ┌──────────────────┐ ┌───────────────┐ │
│ │ FRONTEND │ │ BACKEND │ │ STORAGE │ │
│ │ (Next.js) │ │ (FastAPI) │ │ │ │
│ │ │ │ │ │ │ │
│ │ ┌─────────┐ │ REST │ ┌────────────┐ │ │ ┌─────────┐ │ │
│ │ │ Admin │──┼─────────┼──│ TrOCR │ │ │ │ Models │ │ │
│ │ │ Panel │ │ │ │ Service │──┼─────────┼──│ (ONNX) │ │ │
│ │ └─────────┘ │ │ └────────────┘ │ │ └─────────┘ │ │
│ │ │ │ │ │ │ │ │
│ │ ┌─────────┐ │ WebSocket│ ┌────────────┐ │ │ ┌─────────┐ │ │
│ │ │ Lehrer │──┼─────────┼──│ Klausur │ │ │ │ LoRA │ │ │
│ │ │ Portal │ │ │ │ Processor │──┼─────────┼──│ Adapter │ │ │
│ │ └─────────┘ │ │ └────────────┘ │ │ └─────────┘ │ │
│ │ │ │ │ │ │ │ │
│ └───────────────┘ │ ┌────────────┐ │ │ ┌─────────┐ │ │
│ │ │ Pseudo- │ │ │ │Training │ │ │
│ │ │ nymizer │──┼─────────┼──│ Data │ │ │
│ │ └────────────┘ │ │ └─────────┘ │ │
│ │ │ │ │ │
│ └──────────────────┘ └───────────────┘ │
│ │ │
│ │ (nur pseudonymisiert) │
│ ▼ │
│ ┌──────────────────┐ │
│ │ CLOUD LLM │ │
│ │ (SysEleven) │ │
│ │ Namespace- │ │
│ │ Isolation │ │
│ └──────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘`}
</pre>
</div>
</div>
)
}
interface ComponentCardProps {
icon: string
title: string
description: string
specs: Array<{ label: string; value: string }>
}
function ComponentCard({ icon, title, description, specs }: ComponentCardProps) {
return (
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4 flex items-center gap-2">
<span>{icon}</span> {title}
</h3>
<div className="space-y-3 text-sm">
{specs.map((spec) => (
<div key={spec.label} className="flex justify-between">
<span className="text-slate-500">{spec.label}</span>
<span className="text-slate-900">{spec.value}</span>
</div>
))}
</div>
<p className="text-slate-500 text-sm mt-4">{description}</p>
</div>
)
}
const DATA_FLOW_STEPS = [
{
num: 1,
color: 'bg-blue-100 text-blue-600',
title: 'Lokale Header-Extraktion',
desc: 'TrOCR erkennt Schuelernamen, Klasse und Fach direkt im Browser/PWA (offline-faehig)',
},
{
num: 2,
color: 'bg-purple-100 text-purple-600',
title: 'Pseudonymisierung',
desc: 'Namen werden durch QR-Code Tokens ersetzt, Mapping bleibt lokal',
},
{
num: 3,
color: 'bg-green-100 text-green-600',
title: 'Cloud-Korrektur',
desc: 'Nur pseudonymisierte Dokument-Tokens werden an die KI gesendet',
},
{
num: 4,
color: 'bg-yellow-100 text-yellow-600',
title: 'Re-Identifikation',
desc: 'Ergebnisse werden lokal mit dem Mapping wieder den echten Namen zugeordnet',
},
]
function DataFlowCard() {
return (
<div className="bg-white rounded-xl shadow-sm border p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-4">Datenfluss</h2>
<div className="space-y-4">
{DATA_FLOW_STEPS.map((step) => (
<div key={step.num} className="flex items-start gap-4 bg-slate-50 rounded-lg p-4">
<div className={`w-8 h-8 rounded-full ${step.color} flex items-center justify-center font-bold`}>
{step.num}
</div>
<div>
<div className="font-medium text-slate-900">{step.title}</div>
<div className="text-sm text-slate-500">{step.desc}</div>
</div>
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,53 @@
'use client'
import { BatchUploader } from '@/components/ai/BatchUploader'
import { API_BASE } from '../types'
export function TabBatch() {
return (
<div className="space-y-6">
{/* Batch OCR Processing */}
<div className="bg-white rounded-xl shadow-sm border p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-2">Batch-Verarbeitung</h2>
<p className="text-sm text-slate-500 mb-6">
Verarbeite mehrere Bilder gleichzeitig mit Echtzeit-Fortschrittsanzeige.
Die Ergebnisse werden per Server-Sent Events gestreamt.
</p>
<BatchUploader
apiBase={API_BASE}
maxFiles={20}
autoProcess={false}
onComplete={(results) => {
console.log('Batch complete:', results)
}}
/>
</div>
{/* Batch Processing Info */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="bg-gradient-to-br from-blue-50 to-blue-100 border border-blue-200 rounded-xl p-6">
<div className="text-3xl mb-2">🚀</div>
<h3 className="text-lg font-semibold text-slate-900 mb-2">Parallele Verarbeitung</h3>
<p className="text-sm text-slate-600">
Mehrere Bilder werden parallel verarbeitet fuer maximale Geschwindigkeit.
</p>
</div>
<div className="bg-gradient-to-br from-green-50 to-green-100 border border-green-200 rounded-xl p-6">
<div className="text-3xl mb-2">💾</div>
<h3 className="text-lg font-semibold text-slate-900 mb-2">Smart Caching</h3>
<p className="text-sm text-slate-600">
Identische Bilder werden automatisch aus dem Cache geladen (unter 50ms).
</p>
</div>
<div className="bg-gradient-to-br from-purple-50 to-purple-100 border border-purple-200 rounded-xl p-6">
<div className="text-3xl mb-2">📊</div>
<h3 className="text-lg font-semibold text-slate-900 mb-2">Live-Fortschritt</h3>
<p className="text-sm text-slate-600">
Echtzeit-Updates via Server-Sent Events zeigen den Verarbeitungsfortschritt.
</p>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,127 @@
'use client'
import { SkeletonText } from '@/components/common/SkeletonText'
import type { TrOCRStatus } from '../types'
interface TabOverviewProps {
status: TrOCRStatus | null
loading: boolean
onRefresh: () => void
}
export function TabOverview({ status, loading, onRefresh }: TabOverviewProps) {
return (
<div className="space-y-6">
{/* Status Card */}
<div className="bg-white rounded-xl shadow-sm border p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-slate-900">Systemstatus</h2>
<button
onClick={onRefresh}
className="px-3 py-1 bg-purple-600 hover:bg-purple-700 text-white rounded text-sm transition-colors"
>
Aktualisieren
</button>
</div>
{loading ? (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="bg-slate-50 rounded-lg p-4">
<SkeletonText lines={1} className="mb-2" />
<div className="h-3 w-16 bg-slate-200 rounded animate-pulse" />
</div>
))}
</div>
) : status?.status === 'available' ? (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-slate-50 rounded-lg p-4">
<div className="text-2xl font-bold text-slate-900">{status.model_name || 'trocr-base'}</div>
<div className="text-xs text-slate-500">Modell</div>
</div>
<div className="bg-slate-50 rounded-lg p-4">
<div className="text-2xl font-bold text-slate-900">{status.device || 'CPU'}</div>
<div className="text-xs text-slate-500">Geraet</div>
</div>
<div className="bg-slate-50 rounded-lg p-4">
<div className="text-2xl font-bold text-slate-900">{status.training_examples_count || 0}</div>
<div className="text-xs text-slate-500">Trainingsbeispiele</div>
</div>
<div className="bg-slate-50 rounded-lg p-4">
<div className="text-2xl font-bold text-slate-900">{status.has_lora_adapter ? 'Aktiv' : 'Keiner'}</div>
<div className="text-xs text-slate-500">LoRA Adapter</div>
</div>
</div>
) : status?.status === 'not_installed' ? (
<div className="text-slate-600">
<p className="mb-2">TrOCR ist nicht installiert. Fuehre aus:</p>
<code className="bg-slate-100 px-3 py-2 rounded text-sm block font-mono">{status.install_command}</code>
</div>
) : (
<div className="text-red-600">{status?.error || 'Unbekannter Fehler'}</div>
)}
</div>
{/* Quick Overview Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="bg-gradient-to-br from-purple-50 to-purple-100 border border-purple-200 rounded-xl p-6">
<div className="text-3xl mb-2">🎯</div>
<h3 className="text-lg font-semibold text-slate-900 mb-2">Handschrifterkennung</h3>
<p className="text-sm text-slate-600">
TrOCR erkennt automatisch handgeschriebenen Text in Klausuren.
Das Modell wurde speziell fuer deutsche Handschriften optimiert.
</p>
</div>
<div className="bg-gradient-to-br from-green-50 to-green-100 border border-green-200 rounded-xl p-6">
<div className="text-3xl mb-2">🔒</div>
<h3 className="text-lg font-semibold text-slate-900 mb-2">Privacy by Design</h3>
<p className="text-sm text-slate-600">
Alle Daten werden lokal verarbeitet. Schuelernamen werden durch
QR-Codes pseudonymisiert - DSGVO-konform.
</p>
</div>
<div className="bg-gradient-to-br from-blue-50 to-blue-100 border border-blue-200 rounded-xl p-6">
<div className="text-3xl mb-2">📈</div>
<h3 className="text-lg font-semibold text-slate-900 mb-2">Kontinuierliches Lernen</h3>
<p className="text-sm text-slate-600">
Mit LoRA Fine-Tuning passt sich das Modell an individuelle
Handschriften an - ohne das Basismodell zu veraendern.
</p>
</div>
</div>
{/* Workflow Overview */}
<div className="bg-white rounded-xl shadow-sm border p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-4">Magic Onboarding Workflow</h2>
<div className="flex flex-wrap items-center gap-4 text-sm">
{WORKFLOW_STEPS.map((step, i) => (
<WorkflowStep key={step.title} step={step} showArrow={i < WORKFLOW_STEPS.length - 1} />
))}
</div>
</div>
</div>
)
}
const WORKFLOW_STEPS = [
{ icon: '📄', title: '1. Upload', desc: '25 Klausuren hochladen' },
{ icon: '🔍', title: '2. Analyse', desc: 'Lokale OCR in 5-10 Sek' },
{ icon: '✅', title: '3. Bestaetigung', desc: 'Klasse, Schueler, Fach' },
{ icon: '🤖', title: '4. KI-Korrektur', desc: 'Cloud mit Pseudonymisierung' },
{ icon: '📊', title: '5. Integration', desc: 'Notenbuch, Zeugnisse' },
]
function WorkflowStep({ step, showArrow }: { step: typeof WORKFLOW_STEPS[number]; showArrow: boolean }) {
return (
<>
<div className="flex items-center gap-2 bg-slate-50 rounded-lg px-4 py-3">
<span className="text-2xl">{step.icon}</span>
<div>
<div className="font-medium text-slate-900">{step.title}</div>
<div className="text-slate-500">{step.desc}</div>
</div>
</div>
{showArrow && <div className="text-slate-400">&rarr;</div>}
</>
)
}

View File

@@ -0,0 +1,226 @@
'use client'
import type { MagicSettings } from '../types'
import { DEFAULT_SETTINGS } from '../types'
interface TabSettingsProps {
settings: MagicSettings
settingsSaved: boolean
onUpdateSettings: (settings: MagicSettings) => void
onSave: () => void
}
export function TabSettings({ settings, settingsSaved, onUpdateSettings, onSave }: TabSettingsProps) {
const update = (partial: Partial<MagicSettings>) => {
onUpdateSettings({ ...settings, ...partial })
}
return (
<div className="space-y-6">
{/* OCR Settings */}
<div className="bg-white rounded-xl shadow-sm border p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-4">OCR Einstellungen</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<CheckboxSetting
label="Automatische Zeilenerkennung"
description="Erkennt und verarbeitet einzelne Zeilen separat"
checked={settings.autoDetectLines}
onChange={(v) => update({ autoDetectLines: v })}
/>
<CheckboxSetting
label="Live-Vorschau"
description="OCR startet automatisch nach Bild-Upload"
checked={settings.livePreview}
onChange={(v) => update({ livePreview: v })}
/>
<CheckboxSetting
label="Sound-Feedback"
description="Akustisches Feedback bei erfolgreicher Erkennung"
checked={settings.soundFeedback}
onChange={(v) => update({ soundFeedback: v })}
/>
<div>
<label className="block text-sm text-slate-700 mb-2">Konfidenz-Schwellwert</label>
<input
type="range"
min="0"
max="1"
step="0.1"
value={settings.confidenceThreshold}
onChange={(e) => update({ confidenceThreshold: parseFloat(e.target.value) })}
className="w-full"
/>
<div className="flex justify-between text-xs text-slate-400 mt-1">
<span>0%</span>
<span className="text-slate-900">{(settings.confidenceThreshold * 100).toFixed(0)}%</span>
<span>100%</span>
</div>
</div>
<div>
<label className="block text-sm text-slate-700 mb-2">Max. Bildgroesse (px)</label>
<input
type="number"
value={settings.maxImageSize}
onChange={(e) => update({ maxImageSize: parseInt(e.target.value) })}
className="w-full bg-slate-50 border border-slate-300 rounded-lg px-3 py-2 text-slate-900"
/>
<div className="text-xs text-slate-400 mt-1">Groessere Bilder werden skaliert</div>
</div>
<CheckboxSetting
label="Ergebnis-Cache aktivieren"
description="Speichert OCR-Ergebnisse fuer identische Bilder"
checked={settings.enableCache}
onChange={(v) => update({ enableCache: v })}
/>
</div>
</div>
{/* Training Settings */}
<div className="bg-white rounded-xl shadow-sm border p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-4">Training Einstellungen</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm text-slate-700 mb-2">LoRA Rank</label>
<select
value={settings.loraRank}
onChange={(e) => update({ loraRank: parseInt(e.target.value) })}
className="w-full bg-slate-50 border border-slate-300 rounded-lg px-3 py-2 text-slate-900"
>
<option value="4">4 (Schnell, weniger Kapazitaet)</option>
<option value="8">8 (Ausgewogen)</option>
<option value="16">16 (Mehr Kapazitaet)</option>
<option value="32">32 (Maximum)</option>
</select>
</div>
<div>
<label className="block text-sm text-slate-700 mb-2">LoRA Alpha</label>
<input
type="number"
value={settings.loraAlpha}
onChange={(e) => update({ loraAlpha: parseInt(e.target.value) })}
className="w-full bg-slate-50 border border-slate-300 rounded-lg px-3 py-2 text-slate-900"
/>
<div className="text-xs text-slate-400 mt-1">Empfohlen: 4 x LoRA Rank</div>
</div>
<div>
<label className="block text-sm text-slate-700 mb-2">Epochen</label>
<input
type="number"
min="1"
max="10"
value={settings.epochs}
onChange={(e) => update({ epochs: parseInt(e.target.value) })}
className="w-full bg-slate-50 border border-slate-300 rounded-lg px-3 py-2 text-slate-900"
/>
</div>
<div>
<label className="block text-sm text-slate-700 mb-2">Batch Size</label>
<select
value={settings.batchSize}
onChange={(e) => update({ batchSize: parseInt(e.target.value) })}
className="w-full bg-slate-50 border border-slate-300 rounded-lg px-3 py-2 text-slate-900"
>
<option value="1">1 (Wenig RAM)</option>
<option value="2">2</option>
<option value="4">4 (Standard)</option>
<option value="8">8 (Viel RAM)</option>
</select>
</div>
<div>
<label className="block text-sm text-slate-700 mb-2">Learning Rate</label>
<select
value={settings.learningRate}
onChange={(e) => update({ learningRate: parseFloat(e.target.value) })}
className="w-full bg-slate-50 border border-slate-300 rounded-lg px-3 py-2 text-slate-900"
>
<option value="0.0001">0.0001 (Schnell)</option>
<option value="0.00005">0.00005 (Standard)</option>
<option value="0.00001">0.00001 (Konservativ)</option>
</select>
</div>
</div>
</div>
{/* Save Button */}
<div className="flex justify-end gap-4">
<button
onClick={() => onUpdateSettings(DEFAULT_SETTINGS)}
className="px-6 py-2 bg-slate-200 hover:bg-slate-300 text-slate-700 rounded-lg text-sm font-medium transition-colors"
>
Zuruecksetzen
</button>
<button
onClick={onSave}
className="px-6 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg text-sm font-medium transition-colors"
>
{settingsSaved ? '\u2713 Gespeichert!' : 'Einstellungen speichern'}
</button>
</div>
{/* Technical Info */}
<div className="bg-white rounded-xl shadow-sm border p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-4">Technische Informationen</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<span className="text-slate-500">API Endpoint:</span>
<code className="text-slate-900 ml-2 bg-slate-100 px-2 py-1 rounded text-xs">/api/klausur/trocr</code>
</div>
<div>
<span className="text-slate-500">Model Path:</span>
<code className="text-slate-900 ml-2 bg-slate-100 px-2 py-1 rounded text-xs">~/.cache/huggingface</code>
</div>
<div>
<span className="text-slate-500">LoRA Path:</span>
<code className="text-slate-900 ml-2 bg-slate-100 px-2 py-1 rounded text-xs">./models/lora</code>
</div>
<div>
<span className="text-slate-500">Training Data:</span>
<code className="text-slate-900 ml-2 bg-slate-100 px-2 py-1 rounded text-xs">./data/training</code>
</div>
</div>
</div>
</div>
)
}
/* ------------------------------------------------------------------ */
function CheckboxSetting({
label,
description,
checked,
onChange,
}: {
label: string
description: string
checked: boolean
onChange: (value: boolean) => void
}) {
return (
<div>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
className="w-5 h-5 rounded bg-slate-100 border-slate-300"
/>
<div>
<div className="text-slate-900 font-medium">{label}</div>
<div className="text-sm text-slate-500">{description}</div>
</div>
</label>
</div>
)
}

View File

@@ -0,0 +1,304 @@
'use client'
import { SkeletonOCRResult, SkeletonDots } from '@/components/common/SkeletonText'
import { ConfidenceHeatmap } from '@/components/ai/ConfidenceHeatmap'
import type { OCRResult, MagicSettings } from '../types'
interface TabTestProps {
ocrResult: OCRResult | null
ocrLoading: boolean
imagePreview: string | null
uploadedImage: File | null
settings: MagicSettings
showHeatmap: boolean
onToggleHeatmap: () => void
onFileUpload: (file: File) => void
onManualOCR: () => void
onClearImage: () => void
onSendToTraining: () => void
}
function getConfidenceColor(confidence: number) {
if (confidence >= 0.9) return 'bg-green-500'
if (confidence >= 0.7) return 'bg-yellow-500'
return 'bg-red-500'
}
export function TabTest({
ocrResult,
ocrLoading,
imagePreview,
uploadedImage,
settings,
showHeatmap,
onToggleHeatmap,
onFileUpload,
onManualOCR,
onClearImage,
onSendToTraining,
}: TabTestProps) {
return (
<div className="space-y-6">
{/* OCR Test */}
<div className="bg-white rounded-xl shadow-sm border p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-4">OCR Test</h2>
<p className="text-sm text-slate-500 mb-4">
Teste die Handschrifterkennung mit einem eigenen Bild. Das Ergebnis zeigt
den erkannten Text, Konfidenz und Verarbeitungszeit.
{settings.livePreview && (
<span className="text-purple-600 ml-1">(Live-Vorschau aktiv)</span>
)}
</p>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Upload Area */}
<UploadArea
imagePreview={imagePreview}
uploadedImage={uploadedImage}
ocrLoading={ocrLoading}
livePreview={settings.livePreview}
onFileUpload={onFileUpload}
onManualOCR={onManualOCR}
onClearImage={onClearImage}
/>
{/* Results Area */}
<ResultsArea
ocrResult={ocrResult}
ocrLoading={ocrLoading}
onSendToTraining={onSendToTraining}
/>
</div>
</div>
{/* Confidence Heatmap */}
{imagePreview && ocrResult && ocrResult.confidence > 0 && (
<div className="bg-white rounded-xl shadow-sm border p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-slate-900">Konfidenz-Visualisierung</h2>
<button
onClick={onToggleHeatmap}
className={`px-3 py-1 rounded text-sm font-medium transition-colors ${
showHeatmap
? 'bg-purple-600 text-white'
: 'bg-slate-200 text-slate-700 hover:bg-slate-300'
}`}
>
{showHeatmap ? 'Heatmap verbergen' : 'Heatmap anzeigen'}
</button>
</div>
{showHeatmap && (
<ConfidenceHeatmap
imageSrc={imagePreview}
text={ocrResult.text}
confidence={ocrResult.confidence}
wordBoxes={ocrResult.word_boxes?.map(w => ({
text: w.text,
confidence: w.confidence,
bbox: w.bbox as [number, number, number, number]
})) || []}
charConfidences={ocrResult.char_confidences || []}
showLegend={true}
toggleable={true}
/>
)}
</div>
)}
{/* Confidence Interpretation */}
<div className="bg-white rounded-xl shadow-sm border p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-4">Konfidenz-Interpretation</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<div className="text-green-700 font-medium">90-100%</div>
<div className="text-sm text-slate-600 mt-1">Sehr hohe Sicherheit - Text kann direkt uebernommen werden</div>
</div>
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<div className="text-yellow-700 font-medium">70-90%</div>
<div className="text-sm text-slate-600 mt-1">Gute Sicherheit - manuelle Ueberpruefung empfohlen</div>
</div>
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="text-red-700 font-medium">&lt; 70%</div>
<div className="text-sm text-slate-600 mt-1">Niedrige Sicherheit - manuelle Eingabe erforderlich</div>
</div>
</div>
</div>
</div>
)
}
/* ------------------------------------------------------------------ */
/* Sub-components */
/* ------------------------------------------------------------------ */
interface UploadAreaProps {
imagePreview: string | null
uploadedImage: File | null
ocrLoading: boolean
livePreview: boolean
onFileUpload: (file: File) => void
onManualOCR: () => void
onClearImage: () => void
}
function UploadArea({ imagePreview, uploadedImage, ocrLoading, livePreview, onFileUpload, onManualOCR, onClearImage }: UploadAreaProps) {
return (
<div>
<div
className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-all ${
imagePreview
? 'border-purple-500 bg-purple-50'
: 'border-slate-300 hover:border-purple-500'
}`}
onClick={() => document.getElementById('ocr-file-input')?.click()}
onDragOver={(e) => { e.preventDefault(); e.currentTarget.classList.add('border-purple-500', 'bg-purple-50') }}
onDragLeave={(e) => { e.currentTarget.classList.remove('border-purple-500', 'bg-purple-50') }}
onDrop={(e) => {
e.preventDefault()
e.stopPropagation()
e.currentTarget.classList.remove('border-purple-500', 'bg-purple-50')
const file = e.dataTransfer.files[0]
if (file?.type.startsWith('image/')) onFileUpload(file)
}}
>
{imagePreview ? (
<div className="relative">
<img
src={imagePreview}
alt="Hochgeladenes Bild"
className="max-h-64 mx-auto rounded-lg shadow-sm"
/>
<button
onClick={(e) => {
e.stopPropagation()
onClearImage()
}}
className="absolute top-2 right-2 p-1 bg-red-500 text-white rounded-full hover:bg-red-600 transition-colors"
title="Bild entfernen (Escape)"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
) : (
<>
<div className="text-4xl mb-2">📄</div>
<div className="text-slate-700">Bild hierher ziehen oder klicken zum Hochladen</div>
<div className="text-xs text-slate-400 mt-1">PNG, JPG - Handgeschriebener Text</div>
<div className="text-xs text-purple-500 mt-2">
oder <kbd className="px-1.5 py-0.5 bg-purple-100 rounded font-mono">Ctrl+V</kbd> zum Einfuegen
</div>
</>
)}
</div>
<input
type="file"
id="ocr-file-input"
accept="image/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) onFileUpload(file)
}}
/>
{uploadedImage && !livePreview && (
<button
onClick={onManualOCR}
disabled={ocrLoading}
className="w-full mt-4 px-4 py-2 bg-purple-600 hover:bg-purple-700 disabled:bg-slate-300 text-white rounded-lg text-sm font-medium transition-colors"
>
{ocrLoading ? (
<span className="flex items-center justify-center gap-2">
<SkeletonDots />
Analysiere...
</span>
) : (
'OCR starten (Ctrl+Enter)'
)}
</button>
)}
</div>
)
}
interface ResultsAreaProps {
ocrResult: OCRResult | null
ocrLoading: boolean
onSendToTraining: () => void
}
function ResultsArea({ ocrResult, ocrLoading, onSendToTraining }: ResultsAreaProps) {
if (ocrLoading) return <SkeletonOCRResult />
if (!ocrResult) {
return (
<div className="bg-slate-50 rounded-lg p-8 text-center text-slate-400">
<div className="text-4xl mb-2">🔍</div>
<div>Lade ein Bild hoch um die Erkennung zu testen</div>
</div>
)
}
return (
<div className="bg-slate-50 rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-medium text-slate-700">Erkannter Text:</h3>
<div className={`px-2 py-1 rounded-full text-xs font-medium ${
ocrResult.confidence >= 0.9 ? 'bg-green-100 text-green-700' :
ocrResult.confidence >= 0.7 ? 'bg-yellow-100 text-yellow-700' :
'bg-red-100 text-red-700'
}`}>
{(ocrResult.confidence * 100).toFixed(0)}% Konfidenz
</div>
</div>
<pre className="bg-white border p-3 rounded text-sm text-slate-900 whitespace-pre-wrap max-h-48 overflow-y-auto">
{ocrResult.text || '(Kein Text erkannt)'}
</pre>
{/* Confidence bar */}
<div className="mt-3 mb-3">
<div className="h-2 bg-slate-200 rounded-full overflow-hidden">
<div
className={`h-full transition-all duration-500 ${getConfidenceColor(ocrResult.confidence)}`}
style={{ width: `${ocrResult.confidence * 100}%` }}
/>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div className="bg-white border rounded p-2">
<div className="text-slate-500 text-xs">Konfidenz</div>
<div className="text-slate-900 font-medium">{(ocrResult.confidence * 100).toFixed(1)}%</div>
</div>
<div className="bg-white border rounded p-2">
<div className="text-slate-500 text-xs">Verarbeitungszeit</div>
<div className="text-slate-900 font-medium">{ocrResult.processing_time_ms}ms</div>
</div>
<div className="bg-white border rounded p-2">
<div className="text-slate-500 text-xs">Modell</div>
<div className="text-slate-900 font-medium">{ocrResult.model || 'TrOCR'}</div>
</div>
<div className="bg-white border rounded p-2">
<div className="text-slate-500 text-xs">LoRA Adapter</div>
<div className="text-slate-900 font-medium">{ocrResult.has_lora_adapter ? 'Ja' : 'Nein'}</div>
</div>
</div>
{ocrResult.confidence < 0.9 && (
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
<p className="text-sm text-blue-800 mb-2">
Die Erkennung koennte verbessert werden! Moechtest du dieses Beispiel zum Training hinzufuegen?
</p>
<button
onClick={onSendToTraining}
className="px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm transition-colors"
>
Als Trainingsbeispiel hinzufuegen
</button>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,333 @@
'use client'
import Link from 'next/link'
import { SkeletonDots } from '@/components/common/SkeletonText'
import { TrainingMetrics } from '@/components/ai/TrainingMetrics'
import type { TrOCRStatus, TrainingExample, MagicSettings } from '../types'
import { API_BASE } from '../types'
interface TabTrainingProps {
status: TrOCRStatus | null
examples: TrainingExample[]
trainingImage: File | null
trainingText: string
fineTuning: boolean
settings: MagicSettings
showTrainingDashboard: boolean
onSetTrainingImage: (file: File | null) => void
onSetTrainingText: (text: string) => void
onAddExample: () => void
onFineTune: () => void
onToggleDashboard: () => void
}
export function TabTraining({
status,
examples,
trainingImage,
trainingText,
fineTuning,
settings,
showTrainingDashboard,
onSetTrainingImage,
onSetTrainingText,
onAddExample,
onFineTune,
onToggleDashboard,
}: TabTrainingProps) {
const exampleCount = status?.training_examples_count || 0
const progressPct = Math.min(100, (exampleCount / 10) * 100)
return (
<div className="space-y-6">
{/* Training Overview */}
<TrainingOverviewCard
status={status}
settings={settings}
exampleCount={exampleCount}
progressPct={progressPct}
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Add Training Example */}
<AddExampleCard
trainingImage={trainingImage}
trainingText={trainingText}
onSetTrainingImage={onSetTrainingImage}
onSetTrainingText={onSetTrainingText}
onAddExample={onAddExample}
/>
{/* Fine-Tuning */}
<FineTuningCard
settings={settings}
fineTuning={fineTuning}
exampleCount={exampleCount}
hasLoraAdapter={status?.has_lora_adapter || false}
onFineTune={onFineTune}
/>
</div>
{/* Training Examples List */}
{examples.length > 0 && (
<ExamplesListCard examples={examples} />
)}
{/* Training Dashboard Demo */}
<TrainingDashboardCard
showDashboard={showTrainingDashboard}
onToggle={onToggleDashboard}
/>
</div>
)
}
/* ------------------------------------------------------------------ */
function TrainingOverviewCard({
status,
settings,
exampleCount,
progressPct,
}: {
status: TrOCRStatus | null
settings: MagicSettings
exampleCount: number
progressPct: number
}) {
return (
<div className="bg-white rounded-xl shadow-sm border p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-4">Training mit LoRA</h2>
<p className="text-sm text-slate-500 mb-4">
LoRA (Low-Rank Adaptation) ermoeglicht effizientes Fine-Tuning ohne das Basismodell zu veraendern.
Das Training erfolgt lokal auf Ihrem System.
</p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div className="bg-slate-50 rounded-lg p-4 text-center">
<div className="text-3xl font-bold text-slate-900">{exampleCount}</div>
<div className="text-xs text-slate-500">Trainingsbeispiele</div>
</div>
<div className="bg-slate-50 rounded-lg p-4 text-center">
<div className="text-3xl font-bold text-slate-900">10</div>
<div className="text-xs text-slate-500">Minimum benoetigt</div>
</div>
<div className="bg-slate-50 rounded-lg p-4 text-center">
<div className="text-3xl font-bold text-slate-900">{settings.loraRank}</div>
<div className="text-xs text-slate-500">LoRA Rank</div>
</div>
<div className="bg-slate-50 rounded-lg p-4 text-center">
<div className="text-3xl font-bold text-slate-900">{status?.has_lora_adapter ? '\u2713' : '\u2717'}</div>
<div className="text-xs text-slate-500">Adapter aktiv</div>
</div>
</div>
<div className="mb-6">
<div className="flex justify-between text-sm mb-1">
<span className="text-slate-500">Fortschritt zum Fine-Tuning</span>
<span className="text-slate-500">{progressPct.toFixed(0)}%</span>
</div>
<div className="h-2 bg-slate-200 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-purple-500 to-blue-500 transition-all duration-500"
style={{ width: `${progressPct}%` }}
/>
</div>
</div>
</div>
)
}
function AddExampleCard({
trainingImage,
trainingText,
onSetTrainingImage,
onSetTrainingText,
onAddExample,
}: {
trainingImage: File | null
trainingText: string
onSetTrainingImage: (file: File | null) => void
onSetTrainingText: (text: string) => void
onAddExample: () => void
}) {
return (
<div className="bg-white rounded-xl shadow-sm border p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-4">Trainingsbeispiel hinzufuegen</h2>
<p className="text-sm text-slate-500 mb-4">
Lade ein Bild mit handgeschriebenem Text hoch und gib die korrekte Transkription ein.
</p>
<div className="space-y-4">
<div>
<label className="block text-sm text-slate-700 mb-1">Bild</label>
<input
type="file"
accept="image/*"
className="w-full bg-slate-50 border border-slate-300 rounded-lg px-3 py-2 text-sm"
onChange={(e) => onSetTrainingImage(e.target.files?.[0] || null)}
/>
{trainingImage && (
<div className="mt-2 text-xs text-green-600">
Bild ausgewaehlt: {trainingImage.name}
</div>
)}
</div>
<div>
<label className="block text-sm text-slate-700 mb-1">Korrekter Text (Ground Truth)</label>
<textarea
className="w-full bg-slate-50 border border-slate-300 rounded-lg px-3 py-2 text-sm text-slate-900 resize-none"
rows={3}
placeholder="Gib hier den korrekten Text ein..."
value={trainingText}
onChange={(e) => onSetTrainingText(e.target.value)}
/>
</div>
<button
onClick={onAddExample}
className="w-full px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg text-sm font-medium transition-colors"
>
+ Trainingsbeispiel hinzufuegen
</button>
</div>
</div>
)
}
function FineTuningCard({
settings,
fineTuning,
exampleCount,
hasLoraAdapter,
onFineTune,
}: {
settings: MagicSettings
fineTuning: boolean
exampleCount: number
hasLoraAdapter: boolean
onFineTune: () => void
}) {
return (
<div className="bg-white rounded-xl shadow-sm border p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-4">Fine-Tuning starten</h2>
<p className="text-sm text-slate-500 mb-4">
Trainiere das Modell mit den gesammelten Beispielen. Der Prozess dauert
je nach Anzahl der Beispiele einige Minuten.
</p>
<div className="bg-slate-50 rounded-lg p-4 mb-4">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-slate-500">Epochen:</span>
<span className="text-slate-900 ml-2">{settings.epochs}</span>
</div>
<div>
<span className="text-slate-500">Learning Rate:</span>
<span className="text-slate-900 ml-2">{settings.learningRate}</span>
</div>
<div>
<span className="text-slate-500">LoRA Rank:</span>
<span className="text-slate-900 ml-2">{settings.loraRank}</span>
</div>
<div>
<span className="text-slate-500">Batch Size:</span>
<span className="text-slate-900 ml-2">{settings.batchSize}</span>
</div>
</div>
</div>
<button
onClick={onFineTune}
disabled={fineTuning || exampleCount < 10}
className="w-full px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-slate-300 disabled:cursor-not-allowed text-white rounded-lg text-sm font-medium transition-colors"
>
{fineTuning ? (
<span className="flex items-center justify-center gap-2">
<SkeletonDots />
Fine-Tuning laeuft...
</span>
) : (
'Fine-Tuning starten'
)}
</button>
{exampleCount < 10 && (
<p className="text-xs text-yellow-600 mt-2 text-center">
Noch {10 - exampleCount} Beispiele benoetigt
</p>
)}
<Link
href="/ai/ocr-labeling?model=trocr-lora"
className="w-full mt-4 px-4 py-2 bg-teal-100 text-teal-700 border border-teal-300 rounded-lg hover:bg-teal-200 flex items-center justify-center gap-2 transition-colors"
>
<span>🏷</span>
Ground Truth in OCR-Labeling sammeln
</Link>
</div>
)
}
function ExamplesListCard({ examples }: { examples: TrainingExample[] }) {
return (
<div className="bg-white rounded-xl shadow-sm border p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-4">Trainingsbeispiele ({examples.length})</h2>
<div className="space-y-2 max-h-64 overflow-y-auto">
{examples.map((ex, i) => (
<div key={i} className="flex items-center gap-4 bg-slate-50 rounded-lg p-3">
<span className="text-slate-400 font-mono text-sm w-8">{i + 1}.</span>
<span className="text-slate-900 text-sm flex-1 truncate">{ex.ground_truth}</span>
<span className="text-slate-400 text-xs">{new Date(ex.created_at).toLocaleDateString('de-DE')}</span>
</div>
))}
</div>
</div>
)
}
function TrainingDashboardCard({
showDashboard,
onToggle,
}: {
showDashboard: boolean
onToggle: () => void
}) {
return (
<div className="bg-white rounded-xl shadow-sm border p-6">
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-lg font-semibold text-slate-900">Training Dashboard</h2>
<p className="text-sm text-slate-500">Live-Metriken waehrend des Trainings</p>
</div>
<button
onClick={onToggle}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
showDashboard
? 'bg-red-600 hover:bg-red-700 text-white'
: 'bg-purple-600 hover:bg-purple-700 text-white'
}`}
>
{showDashboard ? 'Demo stoppen' : 'Demo starten'}
</button>
</div>
{showDashboard ? (
<TrainingMetrics
apiBase={API_BASE}
simulateMode={true}
onComplete={onToggle}
/>
) : (
<div className="bg-slate-50 rounded-lg p-8 text-center">
<div className="text-4xl mb-3">📈</div>
<div className="text-slate-600 mb-2">
Das Training Dashboard zeigt Echtzeit-Metriken waehrend des Fine-Tunings
</div>
<div className="text-sm text-slate-400">
Klicke &quot;Demo starten&quot; um eine simulierte Training-Session zu sehen
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,7 @@
export { GlobalDragOverlay, KeyboardShortcutsModal } from './GlobalOverlays'
export { TabOverview } from './TabOverview'
export { TabTest } from './TabTest'
export { TabBatch } from './TabBatch'
export { TabTraining } from './TabTraining'
export { TabArchitecture } from './TabArchitecture'
export { TabSettings } from './TabSettings'

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,71 @@
export type TabId = 'overview' | 'test' | 'batch' | 'training' | 'architecture' | 'settings'
export interface TrOCRStatus {
status: 'available' | 'not_installed' | 'error'
model_name?: string
model_id?: string
device?: string
is_loaded?: boolean
has_lora_adapter?: boolean
training_examples_count?: number
error?: string
install_command?: string
}
export interface OCRResult {
text: string
confidence: number
processing_time_ms: number
model: string
has_lora_adapter: boolean
char_confidences?: number[]
word_boxes?: Array<{ text: string; confidence: number; bbox: number[] }>
}
export interface TrainingExample {
image_path: string
ground_truth: string
teacher_id: string
created_at: string
}
export interface MagicSettings {
autoDetectLines: boolean
confidenceThreshold: number
maxImageSize: number
loraRank: number
loraAlpha: number
learningRate: number
epochs: number
batchSize: number
enableCache: boolean
cacheMaxAge: number
livePreview: boolean
soundFeedback: boolean
}
export const DEFAULT_SETTINGS: MagicSettings = {
autoDetectLines: true,
confidenceThreshold: 0.7,
maxImageSize: 4096,
loraRank: 8,
loraAlpha: 32,
learningRate: 0.00005,
epochs: 3,
batchSize: 4,
enableCache: true,
cacheMaxAge: 3600,
livePreview: true,
soundFeedback: false,
}
export const TABS = [
{ id: 'overview' as TabId, label: 'Uebersicht', icon: '\u{1F4CA}', shortcut: 'Alt+1' },
{ id: 'test' as TabId, label: 'OCR Test', icon: '\u{1F50D}', shortcut: 'Alt+2' },
{ id: 'batch' as TabId, label: 'Batch OCR', icon: '\u{1F4C1}', shortcut: 'Alt+3' },
{ id: 'training' as TabId, label: 'Training', icon: '\u{1F3AF}', shortcut: 'Alt+4' },
{ id: 'architecture' as TabId, label: 'Architektur', icon: '\u{1F3D7}\uFE0F', shortcut: 'Alt+5' },
{ id: 'settings' as TabId, label: 'Einstellungen', icon: '\u2699\uFE0F', shortcut: 'Alt+6' },
] as const
export const API_BASE = '/klausur-api'

View File

@@ -0,0 +1,382 @@
'use client'
import { useState, useEffect, useCallback, useRef } from 'react'
import {
type TabId,
type TrOCRStatus,
type OCRResult,
type TrainingExample,
type MagicSettings,
DEFAULT_SETTINGS,
API_BASE,
} from './types'
function playSuccessSound() {
try {
const audioContext = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)()
const oscillator = audioContext.createOscillator()
const gainNode = audioContext.createGain()
oscillator.connect(gainNode)
gainNode.connect(audioContext.destination)
oscillator.frequency.value = 800
oscillator.type = 'sine'
gainNode.gain.setValueAtTime(0.1, audioContext.currentTime)
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.2)
oscillator.start(audioContext.currentTime)
oscillator.stop(audioContext.currentTime + 0.2)
} catch {
// Audio not supported, ignore
}
}
export function useMagicHelp() {
const [activeTab, setActiveTab] = useState<TabId>('overview')
const [status, setStatus] = useState<TrOCRStatus | null>(null)
const [loading, setLoading] = useState(true)
const [ocrResult, setOcrResult] = useState<OCRResult | null>(null)
const [ocrLoading, setOcrLoading] = useState(false)
const [examples, setExamples] = useState<TrainingExample[]>([])
const [trainingImage, setTrainingImage] = useState<File | null>(null)
const [trainingText, setTrainingText] = useState('')
const [fineTuning, setFineTuning] = useState(false)
const [settings, setSettings] = useState<MagicSettings>(DEFAULT_SETTINGS)
const [settingsSaved, setSettingsSaved] = useState(false)
// Phase 1: New state for enhanced features
const [globalDragActive, setGlobalDragActive] = useState(false)
const [uploadedImage, setUploadedImage] = useState<File | null>(null)
const [imagePreview, setImagePreview] = useState<string | null>(null)
const [showShortcutHint, setShowShortcutHint] = useState(false)
const [showHeatmap, setShowHeatmap] = useState(false)
const [showTrainingDashboard, setShowTrainingDashboard] = useState(false)
const debounceTimer = useRef<NodeJS.Timeout | null>(null)
const dragCounter = useRef(0)
const fetchStatus = useCallback(async () => {
try {
const res = await fetch(`${API_BASE}/api/klausur/trocr/status`)
const data = await res.json()
setStatus(data)
} catch {
setStatus({ status: 'error', error: 'Failed to fetch status' })
} finally {
setLoading(false)
}
}, [])
const fetchExamples = useCallback(async () => {
try {
const res = await fetch(`${API_BASE}/api/klausur/trocr/training/examples`)
const data = await res.json()
setExamples(data.examples || [])
} catch (error) {
console.error('Failed to fetch examples:', error)
}
}, [])
// Phase 1: Live OCR with debounce
const triggerOCR = useCallback(async (file: File) => {
setOcrLoading(true)
setOcrResult(null)
const formData = new FormData()
formData.append('file', file)
try {
const res = await fetch(`${API_BASE}/api/klausur/trocr/extract?detect_lines=${settings.autoDetectLines}`, {
method: 'POST',
body: formData,
})
const data = await res.json()
if (data.text !== undefined) {
setOcrResult(data)
if (settings.soundFeedback && data.confidence > 0.7) {
playSuccessSound()
}
} else {
setOcrResult({ text: `Error: ${data.detail || 'Unknown error'}`, confidence: 0, processing_time_ms: 0, model: '', has_lora_adapter: false })
}
} catch (error) {
setOcrResult({ text: `Error: ${error}`, confidence: 0, processing_time_ms: 0, model: '', has_lora_adapter: false })
} finally {
setOcrLoading(false)
}
}, [settings.autoDetectLines, settings.soundFeedback])
// Handle file upload with live preview
const handleFileUpload = useCallback((file: File) => {
if (!file.type.startsWith('image/')) return
setUploadedImage(file)
const previewUrl = URL.createObjectURL(file)
setImagePreview(previewUrl)
setActiveTab('test')
if (settings.livePreview) {
if (debounceTimer.current) {
clearTimeout(debounceTimer.current)
}
debounceTimer.current = setTimeout(() => {
triggerOCR(file)
}, 500)
}
}, [settings.livePreview, triggerOCR])
const handleManualOCR = () => {
if (uploadedImage) {
triggerOCR(uploadedImage)
}
}
// Phase 1: Global Drag & Drop handler
useEffect(() => {
const handleDragEnter = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
dragCounter.current++
if (e.dataTransfer?.types.includes('Files')) {
setGlobalDragActive(true)
}
}
const handleDragLeave = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
dragCounter.current--
if (dragCounter.current === 0) {
setGlobalDragActive(false)
}
}
const handleDragOver = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
}
const handleDrop = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
dragCounter.current = 0
setGlobalDragActive(false)
const file = e.dataTransfer?.files[0]
if (file?.type.startsWith('image/')) {
handleFileUpload(file)
}
}
document.addEventListener('dragenter', handleDragEnter)
document.addEventListener('dragleave', handleDragLeave)
document.addEventListener('dragover', handleDragOver)
document.addEventListener('drop', handleDrop)
return () => {
document.removeEventListener('dragenter', handleDragEnter)
document.removeEventListener('dragleave', handleDragLeave)
document.removeEventListener('dragover', handleDragOver)
document.removeEventListener('drop', handleDrop)
}
}, [handleFileUpload])
// Phase 1: Clipboard paste handler (Ctrl+V)
useEffect(() => {
const handlePaste = async (e: ClipboardEvent) => {
const items = e.clipboardData?.items
if (!items) return
for (const item of items) {
if (item.type.startsWith('image/')) {
e.preventDefault()
const file = item.getAsFile()
if (file) {
handleFileUpload(file)
}
break
}
}
}
document.addEventListener('paste', handlePaste)
return () => document.removeEventListener('paste', handlePaste)
}, [handleFileUpload])
// Phase 1: Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.ctrlKey && e.key === 'Enter' && uploadedImage) {
e.preventDefault()
handleManualOCR()
}
if (e.key >= '1' && e.key <= '6' && e.altKey) {
e.preventDefault()
const tabIndex = parseInt(e.key) - 1
const tabIds: TabId[] = ['overview', 'test', 'batch', 'training', 'architecture', 'settings']
if (tabIds[tabIndex]) {
setActiveTab(tabIds[tabIndex])
}
}
if (e.key === 'Escape' && uploadedImage) {
setUploadedImage(null)
setImagePreview(null)
setOcrResult(null)
}
if (e.key === '?') {
setShowShortcutHint(prev => !prev)
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [uploadedImage])
// Initial data load + settings from localStorage
useEffect(() => {
fetchStatus()
fetchExamples()
const saved = localStorage.getItem('magic-help-settings')
if (saved) {
try {
setSettings({ ...DEFAULT_SETTINGS, ...JSON.parse(saved) })
} catch {
// ignore parse errors
}
}
}, [fetchStatus, fetchExamples])
// Cleanup preview URL
useEffect(() => {
return () => {
if (imagePreview) {
URL.revokeObjectURL(imagePreview)
}
}
}, [imagePreview])
const handleAddTrainingExample = async () => {
if (!trainingImage || !trainingText.trim()) {
alert('Please provide both an image and the correct text')
return
}
const formData = new FormData()
formData.append('file', trainingImage)
try {
const res = await fetch(`${API_BASE}/api/klausur/trocr/training/add?ground_truth=${encodeURIComponent(trainingText)}`, {
method: 'POST',
body: formData,
})
const data = await res.json()
if (data.example_id) {
alert(`Training example added! Total: ${data.total_examples}`)
setTrainingImage(null)
setTrainingText('')
fetchStatus()
fetchExamples()
} else {
alert(`Error: ${data.detail || 'Unknown error'}`)
}
} catch (error) {
alert(`Error: ${error}`)
}
}
const handleFineTune = async () => {
if (!confirm('Start fine-tuning? This may take several minutes.')) return
setFineTuning(true)
try {
const res = await fetch(`${API_BASE}/api/klausur/trocr/training/fine-tune`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
epochs: settings.epochs,
learning_rate: settings.learningRate,
lora_rank: settings.loraRank,
lora_alpha: settings.loraAlpha,
}),
})
const data = await res.json()
if (data.status === 'success') {
alert(`Fine-tuning successful!\nExamples used: ${data.examples_used}\nEpochs: ${data.epochs}`)
fetchStatus()
} else {
alert(`Fine-tuning failed: ${data.message}`)
}
} catch (error) {
alert(`Error: ${error}`)
} finally {
setFineTuning(false)
}
}
const saveSettings = () => {
localStorage.setItem('magic-help-settings', JSON.stringify(settings))
setSettingsSaved(true)
setTimeout(() => setSettingsSaved(false), 2000)
}
const clearUploadedImage = () => {
setUploadedImage(null)
setImagePreview(null)
setOcrResult(null)
}
const sendToTraining = () => {
if (uploadedImage && ocrResult) {
setTrainingImage(uploadedImage)
setTrainingText(ocrResult.text)
setActiveTab('training')
}
}
return {
// State
activeTab,
setActiveTab,
status,
loading,
ocrResult,
ocrLoading,
examples,
trainingImage,
setTrainingImage,
trainingText,
setTrainingText,
fineTuning,
settings,
setSettings,
settingsSaved,
globalDragActive,
uploadedImage,
imagePreview,
showShortcutHint,
setShowShortcutHint,
showHeatmap,
setShowHeatmap,
showTrainingDashboard,
setShowTrainingDashboard,
// Actions
fetchStatus,
handleFileUpload,
handleManualOCR,
handleAddTrainingExample,
handleFineTune,
saveSettings,
clearUploadedImage,
sendToTraining,
}
}
export type UseMagicHelpReturn = ReturnType<typeof useMagicHelp>