fix: Restore all files lost during destructive rebase
A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
405
admin-v2/components/ai/AIModuleSidebar.tsx
Normal file
405
admin-v2/components/ai/AIModuleSidebar.tsx
Normal file
@@ -0,0 +1,405 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* AI Module Sidebar
|
||||
*
|
||||
* Kompakte Sidebar-Komponente für Cross-Navigation zwischen AI-Modulen.
|
||||
* Zeigt den Datenfluss und ermöglicht schnelle Navigation.
|
||||
*
|
||||
* Features:
|
||||
* - Desktop: Fixierte Sidebar rechts
|
||||
* - Mobile: Floating Action Button mit Slide-In Drawer
|
||||
*/
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { AI_MODULES, DATA_FLOW_CONNECTIONS, type AIModuleLink } from '@/types/ai-modules'
|
||||
|
||||
export interface AIModuleSidebarProps {
|
||||
/** ID des aktuell aktiven Moduls */
|
||||
currentModule: 'magic-help' | 'ocr-labeling' | 'rag-pipeline' | 'rag'
|
||||
/** Optional: Kompakter Modus (nur Icons) */
|
||||
compact?: boolean
|
||||
/** Optional: Zusätzliche CSS-Klassen */
|
||||
className?: string
|
||||
}
|
||||
|
||||
export interface AIModuleSidebarResponsiveProps extends AIModuleSidebarProps {
|
||||
/** Position des FAB auf Mobile */
|
||||
fabPosition?: 'bottom-right' | 'bottom-left'
|
||||
}
|
||||
|
||||
// Icons für die Module
|
||||
const ModuleIcon = ({ id }: { id: string }) => {
|
||||
switch (id) {
|
||||
case 'magic-help':
|
||||
return (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
|
||||
</svg>
|
||||
)
|
||||
case 'ocr-labeling':
|
||||
return (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||
</svg>
|
||||
)
|
||||
case 'rag-pipeline':
|
||||
return (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
)
|
||||
case 'rag':
|
||||
return (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Pfeil-Icon für Datenfluss
|
||||
const ArrowIcon = () => (
|
||||
<svg className="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export function AIModuleSidebar({
|
||||
currentModule,
|
||||
compact = false,
|
||||
className = '',
|
||||
}: AIModuleSidebarProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(!compact)
|
||||
|
||||
return (
|
||||
<div className={`bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-slate-200 dark:border-gray-700 overflow-hidden ${className}`}>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="px-4 py-3 bg-gradient-to-r from-teal-50 to-cyan-50 dark:from-teal-900/20 dark:to-cyan-900/20 border-b border-slate-200 dark:border-gray-700 cursor-pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-teal-600 dark:text-teal-400">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="font-semibold text-slate-700 dark:text-slate-200 text-sm">
|
||||
KI-Daten-Pipeline
|
||||
</span>
|
||||
</div>
|
||||
<svg
|
||||
className={`w-4 h-4 text-slate-400 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{isExpanded && (
|
||||
<div className="p-3 space-y-3">
|
||||
{/* Module Links */}
|
||||
<div className="space-y-1">
|
||||
{AI_MODULES.map((module) => (
|
||||
<Link
|
||||
key={module.id}
|
||||
href={module.href}
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-lg transition-colors ${
|
||||
currentModule === module.id
|
||||
? 'bg-teal-100 dark:bg-teal-900/30 text-teal-700 dark:text-teal-300 font-medium'
|
||||
: 'text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<ModuleIcon id={module.id} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">{module.name}</div>
|
||||
<div className="text-xs text-slate-500 dark:text-slate-500 truncate">
|
||||
{module.description}
|
||||
</div>
|
||||
</div>
|
||||
{currentModule === module.id && (
|
||||
<span className="flex-shrink-0 w-2 h-2 bg-teal-500 rounded-full" />
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Datenfluss-Visualisierung */}
|
||||
<div className="pt-2 border-t border-slate-200 dark:border-gray-700">
|
||||
<div className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-2 px-1">
|
||||
Datenfluss
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-2 py-2 bg-slate-50 dark:bg-gray-900 rounded-lg">
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span title="Magic Help">✨</span>
|
||||
<span className="text-slate-400" title="Bidirektional">⟷</span>
|
||||
<span title="OCR-Labeling">🏷️</span>
|
||||
<ArrowIcon />
|
||||
<span title="RAG Pipeline">🔄</span>
|
||||
<ArrowIcon />
|
||||
<span title="Daten & RAG">🔍</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Info zum aktuellen Modul */}
|
||||
<div className="pt-2 border-t border-slate-200 dark:border-gray-700">
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400 px-1">
|
||||
{currentModule === 'magic-help' && (
|
||||
<span>Testen und verbessern Sie die TrOCR-Handschrifterkennung</span>
|
||||
)}
|
||||
{currentModule === 'ocr-labeling' && (
|
||||
<span>Erstellen Sie Ground Truth Daten für das OCR-Training</span>
|
||||
)}
|
||||
{currentModule === 'rag-pipeline' && (
|
||||
<span>Indexieren Sie Dokumente für die semantische Suche</span>
|
||||
)}
|
||||
{currentModule === 'rag' && (
|
||||
<span>Verwalten und durchsuchen Sie indexierte Dokumente</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Kompakte Inline-Version für mobile Ansichten
|
||||
*/
|
||||
export function AIModuleNav({ currentModule }: { currentModule: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1 p-1 bg-slate-100 dark:bg-gray-800 rounded-lg">
|
||||
{AI_MODULES.map((module) => (
|
||||
<Link
|
||||
key={module.id}
|
||||
href={module.href}
|
||||
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
|
||||
currentModule === module.id
|
||||
? 'bg-white dark:bg-gray-700 text-teal-600 dark:text-teal-400 font-medium shadow-sm'
|
||||
: 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-200'
|
||||
}`}
|
||||
title={module.description}
|
||||
>
|
||||
{module.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Responsive Sidebar mit Mobile FAB + Drawer
|
||||
*
|
||||
* Desktop (xl+): Fixierte Sidebar rechts
|
||||
* Mobile/Tablet: Floating Action Button unten rechts, öffnet Drawer
|
||||
*/
|
||||
export function AIModuleSidebarResponsive({
|
||||
currentModule,
|
||||
compact = false,
|
||||
className = '',
|
||||
fabPosition = 'bottom-right',
|
||||
}: AIModuleSidebarResponsiveProps) {
|
||||
const [isMobileOpen, setIsMobileOpen] = useState(false)
|
||||
|
||||
// Close drawer on route change or escape key
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setIsMobileOpen(false)
|
||||
}
|
||||
window.addEventListener('keydown', handleEscape)
|
||||
return () => window.removeEventListener('keydown', handleEscape)
|
||||
}, [])
|
||||
|
||||
// Prevent body scroll when drawer is open
|
||||
useEffect(() => {
|
||||
if (isMobileOpen) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
} else {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
}, [isMobileOpen])
|
||||
|
||||
const fabPositionClasses = fabPosition === 'bottom-right'
|
||||
? 'right-4 bottom-20'
|
||||
: 'left-4 bottom-20'
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop: Fixed Sidebar */}
|
||||
<div className={`hidden xl:block fixed right-6 top-24 w-64 z-10 ${className}`}>
|
||||
<AIModuleSidebar currentModule={currentModule} compact={compact} />
|
||||
</div>
|
||||
|
||||
{/* Mobile/Tablet: FAB */}
|
||||
<button
|
||||
onClick={() => setIsMobileOpen(true)}
|
||||
className={`xl:hidden fixed ${fabPositionClasses} z-40 w-14 h-14 bg-gradient-to-r from-teal-500 to-cyan-500 text-white rounded-full shadow-lg hover:shadow-xl transition-all flex items-center justify-center group`}
|
||||
aria-label="KI-Daten-Pipeline Navigation öffnen"
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6 group-hover:scale-110 transition-transform"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
{/* Pulse indicator */}
|
||||
<span className="absolute -top-1 -right-1 w-3 h-3 bg-orange-400 rounded-full animate-pulse" />
|
||||
</button>
|
||||
|
||||
{/* Mobile/Tablet: Drawer Overlay */}
|
||||
{isMobileOpen && (
|
||||
<div className="xl:hidden fixed inset-0 z-50">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity"
|
||||
onClick={() => setIsMobileOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Drawer */}
|
||||
<div className="absolute right-0 top-0 bottom-0 w-80 max-w-[85vw] bg-white dark:bg-gray-900 shadow-2xl transform transition-transform animate-slide-in-right">
|
||||
{/* Drawer Header */}
|
||||
<div className="flex items-center justify-between px-4 py-4 border-b border-slate-200 dark:border-gray-700 bg-gradient-to-r from-teal-50 to-cyan-50 dark:from-teal-900/20 dark:to-cyan-900/20">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-teal-600 dark:text-teal-400">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="font-semibold text-slate-700 dark:text-slate-200">
|
||||
KI-Daten-Pipeline
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsMobileOpen(false)}
|
||||
className="p-2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 rounded-lg hover:bg-slate-100 dark:hover:bg-gray-800 transition-colors"
|
||||
aria-label="Schließen"
|
||||
>
|
||||
<svg className="w-5 h-5" 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>
|
||||
|
||||
{/* Drawer Content */}
|
||||
<div className="p-4 space-y-4 overflow-y-auto max-h-[calc(100vh-80px)]">
|
||||
{/* Module Links */}
|
||||
<div className="space-y-2">
|
||||
{AI_MODULES.map((module) => (
|
||||
<Link
|
||||
key={module.id}
|
||||
href={module.href}
|
||||
onClick={() => setIsMobileOpen(false)}
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-xl transition-all ${
|
||||
currentModule === module.id
|
||||
? 'bg-teal-100 dark:bg-teal-900/30 text-teal-700 dark:text-teal-300 font-medium shadow-sm'
|
||||
: 'text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
<ModuleIcon id={module.id} />
|
||||
<div className="flex-1">
|
||||
<div className="text-base font-medium">{module.name}</div>
|
||||
<div className="text-sm text-slate-500 dark:text-slate-500">
|
||||
{module.description}
|
||||
</div>
|
||||
</div>
|
||||
{currentModule === module.id && (
|
||||
<span className="flex-shrink-0 w-2.5 h-2.5 bg-teal-500 rounded-full" />
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Datenfluss-Visualisierung */}
|
||||
<div className="pt-4 border-t border-slate-200 dark:border-gray-700">
|
||||
<div className="text-sm font-medium text-slate-500 dark:text-slate-400 mb-3">
|
||||
Datenfluss
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-2 p-4 bg-slate-50 dark:bg-gray-800 rounded-xl">
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-2xl">✨</span>
|
||||
<span className="text-xs text-slate-500 mt-1">Magic</span>
|
||||
</div>
|
||||
<span className="text-slate-400 text-lg" title="Bidirektional">⟷</span>
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-2xl">🏷️</span>
|
||||
<span className="text-xs text-slate-500 mt-1">OCR</span>
|
||||
</div>
|
||||
<ArrowIcon />
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-2xl">🔄</span>
|
||||
<span className="text-xs text-slate-500 mt-1">Pipeline</span>
|
||||
</div>
|
||||
<ArrowIcon />
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-2xl">🔍</span>
|
||||
<span className="text-xs text-slate-500 mt-1">RAG</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Info */}
|
||||
<div className="pt-4 border-t border-slate-200 dark:border-gray-700">
|
||||
<div className="text-sm text-slate-600 dark:text-slate-400 p-3 bg-slate-50 dark:bg-gray-800 rounded-xl">
|
||||
{currentModule === 'magic-help' && (
|
||||
<>
|
||||
<strong className="text-slate-700 dark:text-slate-300">Aktuell:</strong> TrOCR-Handschrifterkennung testen und verbessern
|
||||
</>
|
||||
)}
|
||||
{currentModule === 'ocr-labeling' && (
|
||||
<>
|
||||
<strong className="text-slate-700 dark:text-slate-300">Aktuell:</strong> Ground Truth Daten für OCR-Training erstellen
|
||||
</>
|
||||
)}
|
||||
{currentModule === 'rag-pipeline' && (
|
||||
<>
|
||||
<strong className="text-slate-700 dark:text-slate-300">Aktuell:</strong> Dokumente für semantische Suche indexieren
|
||||
</>
|
||||
)}
|
||||
{currentModule === 'rag' && (
|
||||
<>
|
||||
<strong className="text-slate-700 dark:text-slate-300">Aktuell:</strong> Indexierte Dokumente verwalten und durchsuchen
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CSS for slide-in animation */}
|
||||
<style jsx>{`
|
||||
@keyframes slide-in-right {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
.animate-slide-in-right {
|
||||
animation: slide-in-right 0.2s ease-out;
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default AIModuleSidebar
|
||||
406
admin-v2/components/ai/AIToolsSidebar.tsx
Normal file
406
admin-v2/components/ai/AIToolsSidebar.tsx
Normal file
@@ -0,0 +1,406 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* AI Tools Sidebar
|
||||
*
|
||||
* Kompakte Sidebar-Komponente für Cross-Navigation zwischen KI-Werkzeugen.
|
||||
* Zeigt Shared Resources und ermöglicht schnelle Navigation.
|
||||
*
|
||||
* Features:
|
||||
* - Desktop: Fixierte Sidebar rechts
|
||||
* - Mobile: Floating Action Button mit Slide-In Drawer
|
||||
*/
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
export type AIToolId = 'llm-compare' | 'test-quality' | 'gpu'
|
||||
|
||||
export interface AIToolModule {
|
||||
id: AIToolId
|
||||
name: string
|
||||
href: string
|
||||
description: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
export const AI_TOOLS_MODULES: AIToolModule[] = [
|
||||
{
|
||||
id: 'llm-compare',
|
||||
name: 'LLM Vergleich',
|
||||
href: '/ai/llm-compare',
|
||||
description: 'KI-Provider vergleichen',
|
||||
icon: '⚖️',
|
||||
},
|
||||
{
|
||||
id: 'test-quality',
|
||||
name: 'Test Quality (BQAS)',
|
||||
href: '/ai/test-quality',
|
||||
description: 'Golden Suite & Tests',
|
||||
icon: '🧪',
|
||||
},
|
||||
{
|
||||
id: 'gpu',
|
||||
name: 'GPU Infrastruktur',
|
||||
href: '/ai/gpu',
|
||||
description: 'vast.ai Management',
|
||||
icon: '🖥️',
|
||||
},
|
||||
]
|
||||
|
||||
export interface AIToolsSidebarProps {
|
||||
/** ID des aktuell aktiven Tools */
|
||||
currentTool: AIToolId
|
||||
/** Optional: Kompakter Modus (nur Icons) */
|
||||
compact?: boolean
|
||||
/** Optional: Zusätzliche CSS-Klassen */
|
||||
className?: string
|
||||
}
|
||||
|
||||
export interface AIToolsSidebarResponsiveProps extends AIToolsSidebarProps {
|
||||
/** Position des FAB auf Mobile */
|
||||
fabPosition?: 'bottom-right' | 'bottom-left'
|
||||
}
|
||||
|
||||
// Icons für die Tools
|
||||
const ToolIcon = ({ id }: { id: string }) => {
|
||||
switch (id) {
|
||||
case 'llm-compare':
|
||||
return (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M3 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M6 7l3 9M6 7l6-2m6 2l3-1m-3 1l-3 9a5.002 5.002 0 006.001 0M18 7l3 9m-3-9l-6-2m0-2v2m0 16V5m0 16H9m3 0h3" />
|
||||
</svg>
|
||||
)
|
||||
case 'test-quality':
|
||||
return (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
)
|
||||
case 'gpu':
|
||||
return (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
||||
</svg>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Werkzeug-Icon für Header
|
||||
const WrenchIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export function AIToolsSidebar({
|
||||
currentTool,
|
||||
compact = false,
|
||||
className = '',
|
||||
}: AIToolsSidebarProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(!compact)
|
||||
|
||||
return (
|
||||
<div className={`bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-slate-200 dark:border-gray-700 overflow-hidden ${className}`}>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="px-4 py-3 bg-gradient-to-r from-violet-50 to-purple-50 dark:from-violet-900/20 dark:to-purple-900/20 border-b border-slate-200 dark:border-gray-700 cursor-pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-violet-600 dark:text-violet-400">
|
||||
<WrenchIcon />
|
||||
</span>
|
||||
<span className="font-semibold text-slate-700 dark:text-slate-200 text-sm">
|
||||
KI-Werkzeuge
|
||||
</span>
|
||||
</div>
|
||||
<svg
|
||||
className={`w-4 h-4 text-slate-400 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{isExpanded && (
|
||||
<div className="p-3 space-y-3">
|
||||
{/* Tool Links */}
|
||||
<div className="space-y-1">
|
||||
{AI_TOOLS_MODULES.map((tool) => (
|
||||
<Link
|
||||
key={tool.id}
|
||||
href={tool.href}
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-lg transition-colors ${
|
||||
currentTool === tool.id
|
||||
? 'bg-violet-100 dark:bg-violet-900/30 text-violet-700 dark:text-violet-300 font-medium'
|
||||
: 'text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<ToolIcon id={tool.id} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">{tool.name}</div>
|
||||
<div className="text-xs text-slate-500 dark:text-slate-500 truncate">
|
||||
{tool.description}
|
||||
</div>
|
||||
</div>
|
||||
{currentTool === tool.id && (
|
||||
<span className="flex-shrink-0 w-2 h-2 bg-violet-500 rounded-full" />
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Shared Resources Visualisierung */}
|
||||
<div className="pt-2 border-t border-slate-200 dark:border-gray-700">
|
||||
<div className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-2 px-1">
|
||||
Shared Resources
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-2 py-2 bg-slate-50 dark:bg-gray-900 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span title="GPU Infrastruktur">🖥️</span>
|
||||
<span className="text-slate-400">→</span>
|
||||
<span title="LLM Vergleich">⚖️</span>
|
||||
<span className="text-slate-400">→</span>
|
||||
<span title="Test Quality">🧪</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-slate-400 mt-1 px-1">
|
||||
GPU-Ressourcen fuer Modelle & Tests
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Info zum aktuellen Tool */}
|
||||
<div className="pt-2 border-t border-slate-200 dark:border-gray-700">
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400 px-1">
|
||||
{currentTool === 'llm-compare' && (
|
||||
<span>Vergleichen Sie LLM-Antworten verschiedener Provider</span>
|
||||
)}
|
||||
{currentTool === 'test-quality' && (
|
||||
<span>Ueberwachen Sie die Qualitaet der KI-Ausgaben</span>
|
||||
)}
|
||||
{currentTool === 'gpu' && (
|
||||
<span>Verwalten Sie GPU-Instanzen fuer ML-Training</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Responsive Tools Sidebar mit Mobile FAB + Drawer
|
||||
*
|
||||
* Desktop (xl+): Fixierte Sidebar rechts
|
||||
* Mobile/Tablet: Floating Action Button unten rechts, öffnet Drawer
|
||||
*/
|
||||
export function AIToolsSidebarResponsive({
|
||||
currentTool,
|
||||
compact = false,
|
||||
className = '',
|
||||
fabPosition = 'bottom-right',
|
||||
}: AIToolsSidebarResponsiveProps) {
|
||||
const [isMobileOpen, setIsMobileOpen] = useState(false)
|
||||
|
||||
// Close drawer on escape key
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setIsMobileOpen(false)
|
||||
}
|
||||
window.addEventListener('keydown', handleEscape)
|
||||
return () => window.removeEventListener('keydown', handleEscape)
|
||||
}, [])
|
||||
|
||||
// Prevent body scroll when drawer is open
|
||||
useEffect(() => {
|
||||
if (isMobileOpen) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
} else {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
}, [isMobileOpen])
|
||||
|
||||
const fabPositionClasses = fabPosition === 'bottom-right'
|
||||
? 'right-4 bottom-20'
|
||||
: 'left-4 bottom-20'
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop: Fixed Sidebar */}
|
||||
<div className={`hidden xl:block fixed right-6 top-24 w-64 z-10 ${className}`}>
|
||||
<AIToolsSidebar currentTool={currentTool} compact={compact} />
|
||||
</div>
|
||||
|
||||
{/* Mobile/Tablet: FAB */}
|
||||
<button
|
||||
onClick={() => setIsMobileOpen(true)}
|
||||
className={`xl:hidden fixed ${fabPositionClasses} z-40 w-14 h-14 bg-gradient-to-r from-violet-500 to-purple-500 text-white rounded-full shadow-lg hover:shadow-xl transition-all flex items-center justify-center group`}
|
||||
aria-label="KI-Werkzeuge Navigation oeffnen"
|
||||
>
|
||||
<WrenchIcon />
|
||||
{/* Pulse indicator */}
|
||||
<span className="absolute -top-1 -right-1 w-3 h-3 bg-amber-400 rounded-full animate-pulse" />
|
||||
</button>
|
||||
|
||||
{/* Mobile/Tablet: Drawer Overlay */}
|
||||
{isMobileOpen && (
|
||||
<div className="xl:hidden fixed inset-0 z-50">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity"
|
||||
onClick={() => setIsMobileOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Drawer */}
|
||||
<div className="absolute right-0 top-0 bottom-0 w-80 max-w-[85vw] bg-white dark:bg-gray-900 shadow-2xl transform transition-transform animate-slide-in-right">
|
||||
{/* Drawer Header */}
|
||||
<div className="flex items-center justify-between px-4 py-4 border-b border-slate-200 dark:border-gray-700 bg-gradient-to-r from-violet-50 to-purple-50 dark:from-violet-900/20 dark:to-purple-900/20">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-violet-600 dark:text-violet-400">
|
||||
<WrenchIcon />
|
||||
</span>
|
||||
<span className="font-semibold text-slate-700 dark:text-slate-200">
|
||||
KI-Werkzeuge
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsMobileOpen(false)}
|
||||
className="p-2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 rounded-lg hover:bg-slate-100 dark:hover:bg-gray-800 transition-colors"
|
||||
aria-label="Schliessen"
|
||||
>
|
||||
<svg className="w-5 h-5" 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>
|
||||
|
||||
{/* Drawer Content */}
|
||||
<div className="p-4 space-y-4 overflow-y-auto max-h-[calc(100vh-80px)]">
|
||||
{/* Tool Links */}
|
||||
<div className="space-y-2">
|
||||
{AI_TOOLS_MODULES.map((tool) => (
|
||||
<Link
|
||||
key={tool.id}
|
||||
href={tool.href}
|
||||
onClick={() => setIsMobileOpen(false)}
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-xl transition-all ${
|
||||
currentTool === tool.id
|
||||
? 'bg-violet-100 dark:bg-violet-900/30 text-violet-700 dark:text-violet-300 font-medium shadow-sm'
|
||||
: 'text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
<ToolIcon id={tool.id} />
|
||||
<div className="flex-1">
|
||||
<div className="text-base font-medium">{tool.name}</div>
|
||||
<div className="text-sm text-slate-500 dark:text-slate-500">
|
||||
{tool.description}
|
||||
</div>
|
||||
</div>
|
||||
{currentTool === tool.id && (
|
||||
<span className="flex-shrink-0 w-2.5 h-2.5 bg-violet-500 rounded-full" />
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Shared Resources Visualisierung */}
|
||||
<div className="pt-4 border-t border-slate-200 dark:border-gray-700">
|
||||
<div className="text-sm font-medium text-slate-500 dark:text-slate-400 mb-3">
|
||||
Shared Resources
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-3 p-4 bg-slate-50 dark:bg-gray-800 rounded-xl">
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-2xl">🖥️</span>
|
||||
<span className="text-xs text-slate-500 mt-1">GPU</span>
|
||||
</div>
|
||||
<span className="text-slate-400">→</span>
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-2xl">⚖️</span>
|
||||
<span className="text-xs text-slate-500 mt-1">LLM</span>
|
||||
</div>
|
||||
<span className="text-slate-400">→</span>
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-2xl">🧪</span>
|
||||
<span className="text-xs text-slate-500 mt-1">BQAS</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-slate-400 mt-2 text-center">
|
||||
GPU-Ressourcen fuer Modelle & Tests
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Info */}
|
||||
<div className="pt-4 border-t border-slate-200 dark:border-gray-700">
|
||||
<div className="text-sm text-slate-600 dark:text-slate-400 p-3 bg-slate-50 dark:bg-gray-800 rounded-xl">
|
||||
{currentTool === 'llm-compare' && (
|
||||
<>
|
||||
<strong className="text-slate-700 dark:text-slate-300">Aktuell:</strong> LLM-Antworten verschiedener Provider vergleichen
|
||||
</>
|
||||
)}
|
||||
{currentTool === 'test-quality' && (
|
||||
<>
|
||||
<strong className="text-slate-700 dark:text-slate-300">Aktuell:</strong> Qualitaet der KI-Ausgaben ueberwachen
|
||||
</>
|
||||
)}
|
||||
{currentTool === 'gpu' && (
|
||||
<>
|
||||
<strong className="text-slate-700 dark:text-slate-300">Aktuell:</strong> GPU-Instanzen fuer ML-Training verwalten
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Link zur Pipeline */}
|
||||
<div className="pt-4 border-t border-slate-200 dark:border-gray-700">
|
||||
<Link
|
||||
href="/ai/magic-help"
|
||||
onClick={() => setIsMobileOpen(false)}
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm text-teal-600 dark:text-teal-400 hover:bg-teal-50 dark:hover:bg-teal-900/20 rounded-lg transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<span>Zur KI-Daten-Pipeline</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CSS for slide-in animation */}
|
||||
<style jsx>{`
|
||||
@keyframes slide-in-right {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
.animate-slide-in-right {
|
||||
animation: slide-in-right 0.2s ease-out;
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default AIToolsSidebar
|
||||
454
admin-v2/components/ai/BatchUploader.tsx
Normal file
454
admin-v2/components/ai/BatchUploader.tsx
Normal file
@@ -0,0 +1,454 @@
|
||||
/**
|
||||
* Batch Uploader Component
|
||||
*
|
||||
* Multi-file upload with drag & drop, progress tracking, and SSE integration.
|
||||
* Supports batch OCR processing for multiple images.
|
||||
*
|
||||
* Phase 2.1: Batch Processing UI
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from 'react'
|
||||
|
||||
interface UploadedFile {
|
||||
id: string
|
||||
file: File
|
||||
preview: string
|
||||
status: 'pending' | 'processing' | 'completed' | 'error'
|
||||
result?: {
|
||||
text: string
|
||||
confidence: number
|
||||
processing_time_ms: number
|
||||
}
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface BatchUploaderProps {
|
||||
/** API base URL */
|
||||
apiBase: string
|
||||
/** Maximum files allowed */
|
||||
maxFiles?: number
|
||||
/** Whether to auto-start OCR after upload */
|
||||
autoProcess?: boolean
|
||||
/** Callback when all files are processed */
|
||||
onComplete?: (results: UploadedFile[]) => void
|
||||
/** Callback when a single file is processed */
|
||||
onFileProcessed?: (file: UploadedFile) => void
|
||||
/** Custom class names */
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique ID
|
||||
*/
|
||||
function generateId(): string {
|
||||
return Math.random().toString(36).substring(2, 11)
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch Uploader Component
|
||||
*/
|
||||
export function BatchUploader({
|
||||
apiBase,
|
||||
maxFiles = 20,
|
||||
autoProcess = false,
|
||||
onComplete,
|
||||
onFileProcessed,
|
||||
className = ''
|
||||
}: BatchUploaderProps) {
|
||||
const [files, setFiles] = useState<UploadedFile[]>([])
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const [isDragActive, setIsDragActive] = useState(false)
|
||||
const [progress, setProgress] = useState({ current: 0, total: 0 })
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const eventSourceRef = useRef<EventSource | null>(null)
|
||||
|
||||
// Add files to the list
|
||||
const addFiles = useCallback((newFiles: FileList | File[]) => {
|
||||
const fileArray = Array.from(newFiles)
|
||||
const imageFiles = fileArray.filter(f => f.type.startsWith('image/'))
|
||||
|
||||
if (imageFiles.length === 0) return
|
||||
|
||||
const uploadedFiles: UploadedFile[] = imageFiles
|
||||
.slice(0, maxFiles - files.length)
|
||||
.map(file => ({
|
||||
id: generateId(),
|
||||
file,
|
||||
preview: URL.createObjectURL(file),
|
||||
status: 'pending' as const
|
||||
}))
|
||||
|
||||
setFiles(prev => [...prev, ...uploadedFiles].slice(0, maxFiles))
|
||||
|
||||
// Auto-process if enabled
|
||||
if (autoProcess && uploadedFiles.length > 0) {
|
||||
setTimeout(() => startProcessing(), 100)
|
||||
}
|
||||
}, [files.length, maxFiles, autoProcess])
|
||||
|
||||
// Remove a file
|
||||
const removeFile = useCallback((id: string) => {
|
||||
setFiles(prev => {
|
||||
const file = prev.find(f => f.id === id)
|
||||
if (file) {
|
||||
URL.revokeObjectURL(file.preview)
|
||||
}
|
||||
return prev.filter(f => f.id !== id)
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Clear all files
|
||||
const clearAll = useCallback(() => {
|
||||
files.forEach(f => URL.revokeObjectURL(f.preview))
|
||||
setFiles([])
|
||||
setProgress({ current: 0, total: 0 })
|
||||
}, [files])
|
||||
|
||||
// Start batch processing with SSE
|
||||
const startProcessing = useCallback(async () => {
|
||||
if (isProcessing || files.length === 0) return
|
||||
|
||||
const pendingFiles = files.filter(f => f.status === 'pending')
|
||||
if (pendingFiles.length === 0) return
|
||||
|
||||
setIsProcessing(true)
|
||||
setProgress({ current: 0, total: pendingFiles.length })
|
||||
|
||||
// Close any existing connection
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close()
|
||||
}
|
||||
|
||||
// Use SSE for progress updates
|
||||
const eventSource = new EventSource(
|
||||
`${apiBase}/api/v1/admin/training/ocr/stream?images_count=${pendingFiles.length}`
|
||||
)
|
||||
eventSourceRef.current = eventSource
|
||||
|
||||
let processedIndex = 0
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
|
||||
if (data.type === 'progress') {
|
||||
setProgress({ current: data.current, total: data.total })
|
||||
|
||||
// Update file status
|
||||
if (processedIndex < pendingFiles.length) {
|
||||
const currentFile = pendingFiles[processedIndex]
|
||||
setFiles(prev => prev.map(f =>
|
||||
f.id === currentFile.id
|
||||
? {
|
||||
...f,
|
||||
status: 'completed' as const,
|
||||
result: data.result
|
||||
}
|
||||
: f
|
||||
))
|
||||
|
||||
onFileProcessed?.({
|
||||
...currentFile,
|
||||
status: 'completed',
|
||||
result: data.result
|
||||
})
|
||||
|
||||
processedIndex++
|
||||
}
|
||||
} else if (data.type === 'error') {
|
||||
if (processedIndex < pendingFiles.length) {
|
||||
const currentFile = pendingFiles[processedIndex]
|
||||
setFiles(prev => prev.map(f =>
|
||||
f.id === currentFile.id
|
||||
? { ...f, status: 'error' as const, error: data.error }
|
||||
: f
|
||||
))
|
||||
processedIndex++
|
||||
}
|
||||
} else if (data.type === 'complete') {
|
||||
eventSource.close()
|
||||
setIsProcessing(false)
|
||||
onComplete?.(files)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('SSE parse error:', e)
|
||||
}
|
||||
}
|
||||
|
||||
eventSource.onerror = () => {
|
||||
eventSource.close()
|
||||
setIsProcessing(false)
|
||||
// Mark remaining as error
|
||||
setFiles(prev => prev.map(f =>
|
||||
f.status === 'pending' || f.status === 'processing'
|
||||
? { ...f, status: 'error' as const, error: 'Verbindung unterbrochen' }
|
||||
: f
|
||||
))
|
||||
}
|
||||
|
||||
// Mark files as processing
|
||||
setFiles(prev => prev.map(f =>
|
||||
f.status === 'pending' ? { ...f, status: 'processing' as const } : f
|
||||
))
|
||||
}, [apiBase, files, isProcessing, onComplete, onFileProcessed])
|
||||
|
||||
// Stop processing
|
||||
const stopProcessing = useCallback(() => {
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close()
|
||||
eventSourceRef.current = null
|
||||
}
|
||||
setIsProcessing(false)
|
||||
setFiles(prev => prev.map(f =>
|
||||
f.status === 'processing' ? { ...f, status: 'pending' as const } : f
|
||||
))
|
||||
}, [])
|
||||
|
||||
// Drag handlers
|
||||
const handleDragEnter = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragActive(true)
|
||||
}
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragActive(false)
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragActive(false)
|
||||
addFiles(e.dataTransfer.files)
|
||||
}
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
files.forEach(f => URL.revokeObjectURL(f.preview))
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close()
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const completedCount = files.filter(f => f.status === 'completed').length
|
||||
const errorCount = files.filter(f => f.status === 'error').length
|
||||
const avgConfidence = files
|
||||
.filter(f => f.result)
|
||||
.reduce((sum, f) => sum + (f.result?.confidence || 0), 0) /
|
||||
Math.max(1, files.filter(f => f.result).length)
|
||||
|
||||
return (
|
||||
<div className={`space-y-4 ${className}`}>
|
||||
{/* Upload area */}
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all ${
|
||||
isDragActive
|
||||
? 'border-purple-500 bg-purple-50'
|
||||
: 'border-slate-300 hover:border-purple-400 hover:bg-purple-50/50'
|
||||
}`}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<div className="text-4xl mb-3">📁</div>
|
||||
<div className="text-slate-700 font-medium">
|
||||
{isDragActive
|
||||
? 'Loslassen zum Hochladen'
|
||||
: 'Bilder hierher ziehen oder klicken'}
|
||||
</div>
|
||||
<div className="text-sm text-slate-400 mt-1">
|
||||
Bis zu {maxFiles} Bilder (PNG, JPG)
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => e.target.files && addFiles(e.target.files)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* File list */}
|
||||
{files.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{/* Header with stats */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-slate-600">
|
||||
{files.length} Bilder ausgewaehlt
|
||||
{completedCount > 0 && (
|
||||
<span className="text-green-600 ml-2">
|
||||
({completedCount} verarbeitet, {(avgConfidence * 100).toFixed(0)}% Konfidenz)
|
||||
</span>
|
||||
)}
|
||||
{errorCount > 0 && (
|
||||
<span className="text-red-600 ml-2">({errorCount} Fehler)</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={clearAll}
|
||||
className="px-3 py-1 text-sm text-slate-600 hover:text-slate-900 transition-colors"
|
||||
>
|
||||
Alle entfernen
|
||||
</button>
|
||||
{isProcessing ? (
|
||||
<button
|
||||
onClick={stopProcessing}
|
||||
className="px-4 py-1.5 bg-red-600 hover:bg-red-700 text-white rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
Stoppen
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={startProcessing}
|
||||
disabled={files.filter(f => f.status === 'pending').length === 0}
|
||||
className="px-4 py-1.5 bg-purple-600 hover:bg-purple-700 disabled:bg-slate-300 text-white rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
OCR starten
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
{isProcessing && (
|
||||
<div className="bg-slate-100 rounded-lg p-3">
|
||||
<div className="flex items-center justify-between text-sm mb-2">
|
||||
<span className="text-slate-600">
|
||||
Verarbeite Bild {progress.current} von {progress.total}
|
||||
</span>
|
||||
<span className="text-purple-600 font-medium">
|
||||
{((progress.current / progress.total) * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-slate-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-purple-600 transition-all duration-300"
|
||||
style={{ width: `${(progress.current / progress.total) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File thumbnails */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
|
||||
{files.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className={`relative group rounded-lg overflow-hidden border-2 ${
|
||||
file.status === 'completed'
|
||||
? 'border-green-300'
|
||||
: file.status === 'error'
|
||||
? 'border-red-300'
|
||||
: file.status === 'processing'
|
||||
? 'border-purple-300'
|
||||
: 'border-slate-200'
|
||||
}`}
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
<img
|
||||
src={file.preview}
|
||||
alt={file.file.name}
|
||||
className="w-full h-24 object-cover"
|
||||
/>
|
||||
|
||||
{/* Overlay for status */}
|
||||
{file.status === 'processing' && (
|
||||
<div className="absolute inset-0 bg-purple-900/50 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-white"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{file.status === 'completed' && file.result && (
|
||||
<div className="absolute inset-0 bg-green-900/70 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<div className="text-center text-white text-xs p-2">
|
||||
<div className="font-medium">
|
||||
{(file.result.confidence * 100).toFixed(0)}%
|
||||
</div>
|
||||
<div className="text-green-200 truncate max-w-full">
|
||||
{file.result.text.substring(0, 30)}...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{file.status === 'error' && (
|
||||
<div className="absolute inset-0 bg-red-900/70 flex items-center justify-center">
|
||||
<div className="text-white text-xs text-center px-2">
|
||||
Fehler
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status badge */}
|
||||
<div className={`absolute top-1 right-1 w-3 h-3 rounded-full ${
|
||||
file.status === 'completed'
|
||||
? 'bg-green-500'
|
||||
: file.status === 'error'
|
||||
? 'bg-red-500'
|
||||
: file.status === 'processing'
|
||||
? 'bg-purple-500 animate-pulse'
|
||||
: 'bg-slate-400'
|
||||
}`} />
|
||||
|
||||
{/* Remove button */}
|
||||
{!isProcessing && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
removeFile(file.id)
|
||||
}}
|
||||
className="absolute top-1 left-1 w-5 h-5 bg-red-500 text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center text-xs"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* File name */}
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-black/60 text-white text-xs px-2 py-1 truncate">
|
||||
{file.file.name}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results summary */}
|
||||
{completedCount > 0 && !isProcessing && (
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<h4 className="font-medium text-green-800 mb-2">
|
||||
Verarbeitung abgeschlossen
|
||||
</h4>
|
||||
<div className="grid grid-cols-3 gap-4 text-center text-sm">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-green-700">{completedCount}</div>
|
||||
<div className="text-green-600">Erfolgreich</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-slate-700">
|
||||
{(avgConfidence * 100).toFixed(0)}%
|
||||
</div>
|
||||
<div className="text-slate-600">Durchschnittl. Konfidenz</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-red-700">{errorCount}</div>
|
||||
<div className="text-red-600">Fehler</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default BatchUploader
|
||||
439
admin-v2/components/ai/ConfidenceHeatmap.tsx
Normal file
439
admin-v2/components/ai/ConfidenceHeatmap.tsx
Normal file
@@ -0,0 +1,439 @@
|
||||
/**
|
||||
* Confidence Heatmap Component
|
||||
*
|
||||
* Displays an OCR result with visual confidence overlay on the original image.
|
||||
* Shows word-level or character-level confidence using color gradients.
|
||||
*
|
||||
* Phase 3.1: Wow-Feature for Magic Help
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect, useMemo } from 'react'
|
||||
|
||||
interface WordBox {
|
||||
text: string
|
||||
confidence: number
|
||||
bbox: [number, number, number, number] // [x, y, width, height] as percentages (0-100)
|
||||
}
|
||||
|
||||
interface ConfidenceHeatmapProps {
|
||||
/** Image source URL or data URL */
|
||||
imageSrc: string
|
||||
/** Detected text result */
|
||||
text: string
|
||||
/** Overall confidence score (0-1) */
|
||||
confidence: number
|
||||
/** Word-level boxes with confidence */
|
||||
wordBoxes?: WordBox[]
|
||||
/** Character-level confidences (aligned with text) */
|
||||
charConfidences?: number[]
|
||||
/** Custom class names */
|
||||
className?: string
|
||||
/** Show legend */
|
||||
showLegend?: boolean
|
||||
/** Allow toggling overlay visibility */
|
||||
toggleable?: boolean
|
||||
/** Callback when a word box is clicked */
|
||||
onWordClick?: (word: WordBox) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color based on confidence value
|
||||
* Green (high) -> Yellow (medium) -> Red (low)
|
||||
*/
|
||||
function getConfidenceColor(confidence: number, opacity = 0.5): string {
|
||||
if (confidence >= 0.9) {
|
||||
return `rgba(34, 197, 94, ${opacity})` // green-500
|
||||
} else if (confidence >= 0.7) {
|
||||
return `rgba(234, 179, 8, ${opacity})` // yellow-500
|
||||
} else if (confidence >= 0.5) {
|
||||
return `rgba(249, 115, 22, ${opacity})` // orange-500
|
||||
} else {
|
||||
return `rgba(239, 68, 68, ${opacity})` // red-500
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get border color (more saturated version)
|
||||
*/
|
||||
function getConfidenceBorderColor(confidence: number): string {
|
||||
if (confidence >= 0.9) return '#22c55e' // green-500
|
||||
if (confidence >= 0.7) return '#eab308' // yellow-500
|
||||
if (confidence >= 0.5) return '#f97316' // orange-500
|
||||
return '#ef4444' // red-500
|
||||
}
|
||||
|
||||
/**
|
||||
* Confidence Heatmap Component
|
||||
*/
|
||||
export function ConfidenceHeatmap({
|
||||
imageSrc,
|
||||
text,
|
||||
confidence,
|
||||
wordBoxes = [],
|
||||
charConfidences = [],
|
||||
className = '',
|
||||
showLegend = true,
|
||||
toggleable = true,
|
||||
onWordClick
|
||||
}: ConfidenceHeatmapProps) {
|
||||
const [showOverlay, setShowOverlay] = useState(true)
|
||||
const [hoveredWord, setHoveredWord] = useState<WordBox | null>(null)
|
||||
const [zoom, setZoom] = useState(1)
|
||||
const [pan, setPan] = useState({ x: 0, y: 0 })
|
||||
const [isPanning, setIsPanning] = useState(false)
|
||||
const [lastPanPoint, setLastPanPoint] = useState({ x: 0, y: 0 })
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Generate simulated word boxes if not provided
|
||||
const displayBoxes = useMemo(() => {
|
||||
if (wordBoxes.length > 0) return wordBoxes
|
||||
|
||||
// Simulate word boxes from text and char confidences
|
||||
if (!text || charConfidences.length === 0) return []
|
||||
|
||||
const words = text.split(/\s+/).filter(w => w.length > 0)
|
||||
const boxes: WordBox[] = []
|
||||
let charIndex = 0
|
||||
|
||||
words.forEach((word, idx) => {
|
||||
// Calculate average confidence for this word
|
||||
const wordConfidences = charConfidences.slice(charIndex, charIndex + word.length)
|
||||
const avgConfidence = wordConfidences.length > 0
|
||||
? wordConfidences.reduce((a, b) => a + b, 0) / wordConfidences.length
|
||||
: confidence
|
||||
|
||||
// Simulate bbox positions (simple grid layout for demo)
|
||||
const wordsPerRow = 5
|
||||
const row = Math.floor(idx / wordsPerRow)
|
||||
const col = idx % wordsPerRow
|
||||
const wordWidth = Math.min(18, 5 + word.length * 1.5)
|
||||
|
||||
boxes.push({
|
||||
text: word,
|
||||
confidence: avgConfidence,
|
||||
bbox: [
|
||||
5 + col * 19, // x
|
||||
10 + row * 12, // y
|
||||
wordWidth, // width
|
||||
8 // height
|
||||
]
|
||||
})
|
||||
|
||||
charIndex += word.length + 1 // +1 for space
|
||||
})
|
||||
|
||||
return boxes
|
||||
}, [text, wordBoxes, charConfidences, confidence])
|
||||
|
||||
// Handle mouse wheel zoom
|
||||
const handleWheel = (e: React.WheelEvent) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault()
|
||||
const delta = e.deltaY > 0 ? -0.1 : 0.1
|
||||
setZoom(prev => Math.max(1, Math.min(3, prev + delta)))
|
||||
}
|
||||
}
|
||||
|
||||
// Handle panning
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
if (zoom > 1) {
|
||||
setIsPanning(true)
|
||||
setLastPanPoint({ x: e.clientX, y: e.clientY })
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (isPanning && zoom > 1) {
|
||||
const dx = e.clientX - lastPanPoint.x
|
||||
const dy = e.clientY - lastPanPoint.y
|
||||
setPan(prev => ({
|
||||
x: Math.max(-100, Math.min(100, prev.x + dx / zoom)),
|
||||
y: Math.max(-100, Math.min(100, prev.y + dy / zoom))
|
||||
}))
|
||||
setLastPanPoint({ x: e.clientX, y: e.clientY })
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsPanning(false)
|
||||
}
|
||||
|
||||
// Reset zoom and pan
|
||||
const resetView = () => {
|
||||
setZoom(1)
|
||||
setPan({ x: 0, y: 0 })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
{/* Controls */}
|
||||
<div className="flex items-center justify-between mb-3 flex-wrap gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{toggleable && (
|
||||
<button
|
||||
onClick={() => setShowOverlay(prev => !prev)}
|
||||
className={`px-3 py-1 rounded text-sm font-medium transition-colors ${
|
||||
showOverlay
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-slate-200 text-slate-700 hover:bg-slate-300'
|
||||
}`}
|
||||
>
|
||||
{showOverlay ? 'Overlay An' : 'Overlay Aus'}
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center gap-1 bg-slate-100 rounded p-1">
|
||||
<button
|
||||
onClick={() => setZoom(prev => Math.max(1, prev - 0.25))}
|
||||
className="p-1 hover:bg-slate-200 rounded transition-colors"
|
||||
title="Verkleinern"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="text-xs font-mono w-12 text-center">{(zoom * 100).toFixed(0)}%</span>
|
||||
<button
|
||||
onClick={() => setZoom(prev => Math.min(3, prev + 0.25))}
|
||||
className="p-1 hover:bg-slate-200 rounded transition-colors"
|
||||
title="Vergroessern"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</button>
|
||||
{zoom > 1 && (
|
||||
<button
|
||||
onClick={resetView}
|
||||
className="p-1 hover:bg-slate-200 rounded transition-colors ml-1"
|
||||
title="Zuruecksetzen"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overall confidence badge */}
|
||||
<div className={`px-3 py-1 rounded-full text-sm font-medium ${
|
||||
confidence >= 0.9 ? 'bg-green-100 text-green-700' :
|
||||
confidence >= 0.7 ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
Gesamt: {(confidence * 100).toFixed(0)}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image container with overlay */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative overflow-hidden rounded-lg border border-slate-200 bg-slate-100"
|
||||
style={{ cursor: zoom > 1 ? (isPanning ? 'grabbing' : 'grab') : 'default' }}
|
||||
onWheel={handleWheel}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
>
|
||||
<div
|
||||
className="relative transition-transform duration-200"
|
||||
style={{
|
||||
transform: `scale(${zoom}) translate(${pan.x}px, ${pan.y}px)`,
|
||||
transformOrigin: 'center center'
|
||||
}}
|
||||
>
|
||||
{/* Original image */}
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt="OCR Dokument"
|
||||
className="w-full h-auto"
|
||||
draggable={false}
|
||||
/>
|
||||
|
||||
{/* SVG Overlay */}
|
||||
{showOverlay && displayBoxes.length > 0 && (
|
||||
<svg
|
||||
className="absolute inset-0 w-full h-full pointer-events-none"
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
{displayBoxes.map((box, idx) => (
|
||||
<g
|
||||
key={idx}
|
||||
className="pointer-events-auto cursor-pointer"
|
||||
onMouseEnter={() => setHoveredWord(box)}
|
||||
onMouseLeave={() => setHoveredWord(null)}
|
||||
onClick={() => onWordClick?.(box)}
|
||||
>
|
||||
{/* Background fill */}
|
||||
<rect
|
||||
x={box.bbox[0]}
|
||||
y={box.bbox[1]}
|
||||
width={box.bbox[2]}
|
||||
height={box.bbox[3]}
|
||||
fill={getConfidenceColor(box.confidence, 0.3)}
|
||||
stroke={getConfidenceBorderColor(box.confidence)}
|
||||
strokeWidth="0.3"
|
||||
rx="0.5"
|
||||
className="transition-all duration-150"
|
||||
style={{
|
||||
filter: hoveredWord === box ? 'brightness(1.2)' : 'none'
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Hover highlight */}
|
||||
{hoveredWord === box && (
|
||||
<rect
|
||||
x={box.bbox[0] - 0.5}
|
||||
y={box.bbox[1] - 0.5}
|
||||
width={box.bbox[2] + 1}
|
||||
height={box.bbox[3] + 1}
|
||||
fill="none"
|
||||
stroke="#7c3aed"
|
||||
strokeWidth="0.5"
|
||||
rx="0.5"
|
||||
className="animate-pulse"
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hovered word tooltip */}
|
||||
{hoveredWord && (
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-2 bg-slate-900 text-white text-sm rounded-lg shadow-lg z-10">
|
||||
<div className="font-mono">"{hoveredWord.text}"</div>
|
||||
<div className="text-slate-300 text-xs">
|
||||
Konfidenz: {(hoveredWord.confidence * 100).toFixed(1)}%
|
||||
</div>
|
||||
<div
|
||||
className="absolute top-full left-1/2 -translate-x-1/2 w-2 h-2 bg-slate-900 rotate-45 -mt-1"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Legend */}
|
||||
{showLegend && (
|
||||
<div className="mt-3 flex items-center justify-center gap-4 text-xs text-slate-600">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-4 h-3 rounded" style={{ backgroundColor: 'rgba(34, 197, 94, 0.5)' }} />
|
||||
<span>>90%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-4 h-3 rounded" style={{ backgroundColor: 'rgba(234, 179, 8, 0.5)' }} />
|
||||
<span>70-90%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-4 h-3 rounded" style={{ backgroundColor: 'rgba(249, 115, 22, 0.5)' }} />
|
||||
<span>50-70%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-4 h-3 rounded" style={{ backgroundColor: 'rgba(239, 68, 68, 0.5)' }} />
|
||||
<span><50%</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Instructions */}
|
||||
<p className="mt-2 text-xs text-slate-400 text-center">
|
||||
Fahre ueber markierte Bereiche fuer Details. Strg+Scroll zum Zoomen.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline character confidence display
|
||||
* Shows text with color-coded background for each character
|
||||
*/
|
||||
interface InlineConfidenceTextProps {
|
||||
text: string
|
||||
charConfidences: number[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function InlineConfidenceText({
|
||||
text,
|
||||
charConfidences,
|
||||
className = ''
|
||||
}: InlineConfidenceTextProps) {
|
||||
if (charConfidences.length === 0) {
|
||||
return <span className={className}>{text}</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`font-mono ${className}`}>
|
||||
{text.split('').map((char, idx) => {
|
||||
const conf = charConfidences[idx] ?? 0.5
|
||||
return (
|
||||
<span
|
||||
key={idx}
|
||||
className="relative group"
|
||||
style={{ backgroundColor: getConfidenceColor(conf, 0.3) }}
|
||||
title={`'${char}': ${(conf * 100).toFixed(0)}%`}
|
||||
>
|
||||
{char}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Confidence Statistics Summary
|
||||
*/
|
||||
interface ConfidenceStatsProps {
|
||||
wordBoxes: WordBox[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ConfidenceStats({ wordBoxes, className = '' }: ConfidenceStatsProps) {
|
||||
const stats = useMemo(() => {
|
||||
if (wordBoxes.length === 0) return null
|
||||
|
||||
const confidences = wordBoxes.map(w => w.confidence)
|
||||
const avg = confidences.reduce((a, b) => a + b, 0) / confidences.length
|
||||
const min = Math.min(...confidences)
|
||||
const max = Math.max(...confidences)
|
||||
const highConf = confidences.filter(c => c >= 0.9).length
|
||||
const lowConf = confidences.filter(c => c < 0.7).length
|
||||
|
||||
return { avg, min, max, highConf, lowConf, total: wordBoxes.length }
|
||||
}, [wordBoxes])
|
||||
|
||||
if (!stats) return null
|
||||
|
||||
return (
|
||||
<div className={`grid grid-cols-2 md:grid-cols-5 gap-3 ${className}`}>
|
||||
<div className="bg-slate-50 rounded-lg p-3 text-center">
|
||||
<div className="text-lg font-bold text-slate-900">{(stats.avg * 100).toFixed(0)}%</div>
|
||||
<div className="text-xs text-slate-500">Durchschnitt</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-3 text-center">
|
||||
<div className="text-lg font-bold text-slate-900">{(stats.min * 100).toFixed(0)}%</div>
|
||||
<div className="text-xs text-slate-500">Minimum</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-3 text-center">
|
||||
<div className="text-lg font-bold text-slate-900">{(stats.max * 100).toFixed(0)}%</div>
|
||||
<div className="text-xs text-slate-500">Maximum</div>
|
||||
</div>
|
||||
<div className="bg-green-50 rounded-lg p-3 text-center">
|
||||
<div className="text-lg font-bold text-green-700">{stats.highConf}</div>
|
||||
<div className="text-xs text-slate-500">Sicher (>90%)</div>
|
||||
</div>
|
||||
<div className="bg-red-50 rounded-lg p-3 text-center">
|
||||
<div className="text-lg font-bold text-red-700">{stats.lowConf}</div>
|
||||
<div className="text-xs text-slate-500">Unsicher (<70%)</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ConfidenceHeatmap
|
||||
613
admin-v2/components/ai/TrainingMetrics.tsx
Normal file
613
admin-v2/components/ai/TrainingMetrics.tsx
Normal file
@@ -0,0 +1,613 @@
|
||||
/**
|
||||
* Training Metrics Component
|
||||
*
|
||||
* Real-time visualization of training progress with loss curves and metrics.
|
||||
* Supports SSE (Server-Sent Events) for live updates during LoRA fine-tuning.
|
||||
*
|
||||
* Phase 3.3: Training Dashboard with Live Metrics
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
|
||||
// Training data point for charts
|
||||
interface TrainingDataPoint {
|
||||
epoch: number
|
||||
step: number
|
||||
loss: number
|
||||
val_loss?: number
|
||||
learning_rate: number
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
// Training job status
|
||||
interface TrainingStatus {
|
||||
job_id: string
|
||||
status: 'queued' | 'running' | 'completed' | 'failed' | 'cancelled'
|
||||
progress: number // 0-100
|
||||
current_epoch: number
|
||||
total_epochs: number
|
||||
current_step: number
|
||||
total_steps: number
|
||||
elapsed_time_ms: number
|
||||
estimated_remaining_ms: number
|
||||
metrics: {
|
||||
loss: number
|
||||
val_loss?: number
|
||||
accuracy?: number
|
||||
learning_rate: number
|
||||
}
|
||||
history: TrainingDataPoint[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface TrainingMetricsProps {
|
||||
/** API base URL */
|
||||
apiBase: string
|
||||
/** Job ID to track (null for simulation mode) */
|
||||
jobId?: string | null
|
||||
/** Simulate training progress for demo */
|
||||
simulateMode?: boolean
|
||||
/** Custom class names */
|
||||
className?: string
|
||||
/** Callback when training completes */
|
||||
onComplete?: (status: TrainingStatus) => void
|
||||
/** Callback on error */
|
||||
onError?: (error: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook for SSE-based training metrics
|
||||
*/
|
||||
export function useTrainingMetricsSSE(
|
||||
apiBase: string,
|
||||
jobId: string | null,
|
||||
onUpdate?: (status: TrainingStatus) => void
|
||||
) {
|
||||
const [status, setStatus] = useState<TrainingStatus | null>(null)
|
||||
const [connected, setConnected] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const eventSourceRef = useRef<EventSource | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!jobId) return
|
||||
|
||||
const url = `${apiBase}/api/klausur/trocr/training/metrics/stream?job_id=${jobId}`
|
||||
const eventSource = new EventSource(url)
|
||||
eventSourceRef.current = eventSource
|
||||
|
||||
eventSource.onopen = () => {
|
||||
setConnected(true)
|
||||
setError(null)
|
||||
}
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data) as TrainingStatus
|
||||
setStatus(data)
|
||||
onUpdate?.(data)
|
||||
|
||||
// Close connection when training is done
|
||||
if (data.status === 'completed' || data.status === 'failed' || data.status === 'cancelled') {
|
||||
eventSource.close()
|
||||
setConnected(false)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse SSE message:', e)
|
||||
}
|
||||
}
|
||||
|
||||
eventSource.onerror = () => {
|
||||
setError('Verbindung zum Server verloren')
|
||||
setConnected(false)
|
||||
eventSource.close()
|
||||
}
|
||||
|
||||
return () => {
|
||||
eventSource.close()
|
||||
setConnected(false)
|
||||
}
|
||||
}, [apiBase, jobId, onUpdate])
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
eventSourceRef.current?.close()
|
||||
setConnected(false)
|
||||
}, [])
|
||||
|
||||
return { status, connected, error, disconnect }
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple line chart component for loss visualization
|
||||
*/
|
||||
function LossChart({
|
||||
data,
|
||||
height = 200,
|
||||
className = ''
|
||||
}: {
|
||||
data: TrainingDataPoint[]
|
||||
height?: number
|
||||
className?: string
|
||||
}) {
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className={`flex items-center justify-center bg-slate-50 rounded-lg ${className}`} style={{ height }}>
|
||||
<span className="text-slate-400 text-sm">Keine Daten</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Calculate bounds
|
||||
const losses = data.map(d => d.loss)
|
||||
const valLosses = data.filter(d => d.val_loss !== undefined).map(d => d.val_loss!)
|
||||
const allLosses = [...losses, ...valLosses]
|
||||
const minLoss = Math.min(...allLosses) * 0.9
|
||||
const maxLoss = Math.max(...allLosses) * 1.1
|
||||
const lossRange = maxLoss - minLoss || 1
|
||||
|
||||
// SVG dimensions
|
||||
const width = 400
|
||||
const padding = { top: 20, right: 20, bottom: 30, left: 50 }
|
||||
const chartWidth = width - padding.left - padding.right
|
||||
const chartHeight = height - padding.top - padding.bottom
|
||||
|
||||
// Generate path for loss line
|
||||
const generatePath = (values: number[]) => {
|
||||
if (values.length === 0) return ''
|
||||
return values
|
||||
.map((loss, idx) => {
|
||||
const x = padding.left + (idx / (values.length - 1 || 1)) * chartWidth
|
||||
const y = padding.top + chartHeight - ((loss - minLoss) / lossRange) * chartHeight
|
||||
return `${idx === 0 ? 'M' : 'L'} ${x} ${y}`
|
||||
})
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
const lossPath = generatePath(losses)
|
||||
const valLossPath = valLosses.length > 0 ? generatePath(valLosses) : ''
|
||||
|
||||
// Y-axis labels
|
||||
const yLabels = [maxLoss, (maxLoss + minLoss) / 2, minLoss].map(v => v.toFixed(3))
|
||||
|
||||
return (
|
||||
<svg viewBox={`0 0 ${width} ${height}`} className={`w-full ${className}`} preserveAspectRatio="xMidYMid meet">
|
||||
{/* Grid lines */}
|
||||
{[0, 0.5, 1].map((ratio, idx) => (
|
||||
<line
|
||||
key={idx}
|
||||
x1={padding.left}
|
||||
y1={padding.top + chartHeight * ratio}
|
||||
x2={width - padding.right}
|
||||
y2={padding.top + chartHeight * ratio}
|
||||
stroke="#e2e8f0"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Y-axis labels */}
|
||||
{yLabels.map((label, idx) => (
|
||||
<text
|
||||
key={idx}
|
||||
x={padding.left - 5}
|
||||
y={padding.top + (idx / 2) * chartHeight + 4}
|
||||
textAnchor="end"
|
||||
className="fill-slate-500 text-xs"
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
))}
|
||||
|
||||
{/* X-axis label */}
|
||||
<text
|
||||
x={width / 2}
|
||||
y={height - 5}
|
||||
textAnchor="middle"
|
||||
className="fill-slate-500 text-xs"
|
||||
>
|
||||
Epoche / Schritt
|
||||
</text>
|
||||
|
||||
{/* Y-axis label */}
|
||||
<text
|
||||
x={12}
|
||||
y={height / 2}
|
||||
textAnchor="middle"
|
||||
className="fill-slate-500 text-xs"
|
||||
transform={`rotate(-90, 12, ${height / 2})`}
|
||||
>
|
||||
Loss
|
||||
</text>
|
||||
|
||||
{/* Training loss line */}
|
||||
<path
|
||||
d={lossPath}
|
||||
fill="none"
|
||||
stroke="#8b5cf6"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
|
||||
{/* Validation loss line (dashed) */}
|
||||
{valLossPath && (
|
||||
<path
|
||||
d={valLossPath}
|
||||
fill="none"
|
||||
stroke="#22c55e"
|
||||
strokeWidth="2"
|
||||
strokeDasharray="5,5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Data points */}
|
||||
{data.map((point, idx) => {
|
||||
const x = padding.left + (idx / (data.length - 1 || 1)) * chartWidth
|
||||
const y = padding.top + chartHeight - ((point.loss - minLoss) / lossRange) * chartHeight
|
||||
return (
|
||||
<circle
|
||||
key={idx}
|
||||
cx={x}
|
||||
cy={y}
|
||||
r="3"
|
||||
fill="#8b5cf6"
|
||||
className="hover:r-4 transition-all"
|
||||
>
|
||||
<title>Epoch {point.epoch}, Step {point.step}: {point.loss.toFixed(4)}</title>
|
||||
</circle>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Legend */}
|
||||
<g transform={`translate(${padding.left}, ${height - 25})`}>
|
||||
<line x1="0" y1="0" x2="20" y2="0" stroke="#8b5cf6" strokeWidth="2" />
|
||||
<text x="25" y="4" className="fill-slate-600 text-xs">Training Loss</text>
|
||||
|
||||
{valLossPath && (
|
||||
<>
|
||||
<line x1="120" y1="0" x2="140" y2="0" stroke="#22c55e" strokeWidth="2" strokeDasharray="5,5" />
|
||||
<text x="145" y="4" className="fill-slate-600 text-xs">Val Loss</text>
|
||||
</>
|
||||
)}
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Progress ring component
|
||||
*/
|
||||
function ProgressRing({
|
||||
progress,
|
||||
size = 80,
|
||||
strokeWidth = 6,
|
||||
className = ''
|
||||
}: {
|
||||
progress: number
|
||||
size?: number
|
||||
strokeWidth?: number
|
||||
className?: string
|
||||
}) {
|
||||
const radius = (size - strokeWidth) / 2
|
||||
const circumference = radius * 2 * Math.PI
|
||||
const offset = circumference - (progress / 100) * circumference
|
||||
|
||||
return (
|
||||
<svg width={size} height={size} className={className}>
|
||||
{/* Background circle */}
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="#e2e8f0"
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
{/* Progress circle */}
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="#8b5cf6"
|
||||
strokeWidth={strokeWidth}
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
{/* Center text */}
|
||||
<text
|
||||
x={size / 2}
|
||||
y={size / 2}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
className="fill-slate-900 font-bold text-lg"
|
||||
>
|
||||
{progress.toFixed(0)}%
|
||||
</text>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Format time duration
|
||||
*/
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
const seconds = Math.floor(ms / 1000)
|
||||
if (seconds < 60) return `${seconds}s`
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const remainingSeconds = seconds % 60
|
||||
if (minutes < 60) return `${minutes}m ${remainingSeconds}s`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const remainingMinutes = minutes % 60
|
||||
return `${hours}h ${remainingMinutes}m`
|
||||
}
|
||||
|
||||
/**
|
||||
* Training Metrics Component - Full Dashboard
|
||||
*/
|
||||
export function TrainingMetrics({
|
||||
apiBase,
|
||||
jobId = null,
|
||||
simulateMode = false,
|
||||
className = '',
|
||||
onComplete,
|
||||
onError
|
||||
}: TrainingMetricsProps) {
|
||||
const [status, setStatus] = useState<TrainingStatus | null>(null)
|
||||
const [simulationInterval, setSimulationInterval] = useState<NodeJS.Timeout | null>(null)
|
||||
|
||||
// SSE hook for real connection
|
||||
const { status: sseStatus, connected, error } = useTrainingMetricsSSE(
|
||||
apiBase,
|
||||
simulateMode ? null : jobId,
|
||||
(newStatus) => {
|
||||
if (newStatus.status === 'completed') {
|
||||
onComplete?.(newStatus)
|
||||
} else if (newStatus.status === 'failed' && newStatus.error) {
|
||||
onError?.(newStatus.error)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Use SSE status if available
|
||||
useEffect(() => {
|
||||
if (sseStatus) {
|
||||
setStatus(sseStatus)
|
||||
}
|
||||
}, [sseStatus])
|
||||
|
||||
// Simulation mode for demo
|
||||
useEffect(() => {
|
||||
if (!simulateMode) return
|
||||
|
||||
let step = 0
|
||||
const totalSteps = 100
|
||||
const history: TrainingDataPoint[] = []
|
||||
|
||||
const interval = setInterval(() => {
|
||||
step++
|
||||
const epoch = Math.floor((step / totalSteps) * 3) + 1
|
||||
const progress = (step / totalSteps) * 100
|
||||
|
||||
// Simulate decreasing loss with noise
|
||||
const baseLoss = 2.5 * Math.exp(-step / 30) + 0.1
|
||||
const noise = (Math.random() - 0.5) * 0.1
|
||||
const loss = Math.max(0.05, baseLoss + noise)
|
||||
|
||||
const dataPoint: TrainingDataPoint = {
|
||||
epoch,
|
||||
step,
|
||||
loss,
|
||||
val_loss: step % 10 === 0 ? loss * (1 + Math.random() * 0.2) : undefined,
|
||||
learning_rate: 0.00005 * Math.pow(0.95, epoch - 1),
|
||||
timestamp: Date.now()
|
||||
}
|
||||
history.push(dataPoint)
|
||||
|
||||
const newStatus: TrainingStatus = {
|
||||
job_id: 'simulation',
|
||||
status: step >= totalSteps ? 'completed' : 'running',
|
||||
progress,
|
||||
current_epoch: epoch,
|
||||
total_epochs: 3,
|
||||
current_step: step,
|
||||
total_steps: totalSteps,
|
||||
elapsed_time_ms: step * 500,
|
||||
estimated_remaining_ms: (totalSteps - step) * 500,
|
||||
metrics: {
|
||||
loss,
|
||||
val_loss: dataPoint.val_loss,
|
||||
accuracy: 0.7 + (step / totalSteps) * 0.25,
|
||||
learning_rate: dataPoint.learning_rate
|
||||
},
|
||||
history: [...history]
|
||||
}
|
||||
|
||||
setStatus(newStatus)
|
||||
|
||||
if (step >= totalSteps) {
|
||||
clearInterval(interval)
|
||||
onComplete?.(newStatus)
|
||||
}
|
||||
}, 500)
|
||||
|
||||
setSimulationInterval(interval)
|
||||
|
||||
return () => {
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, [simulateMode, onComplete])
|
||||
|
||||
// Stop simulation
|
||||
const stopSimulation = () => {
|
||||
if (simulationInterval) {
|
||||
clearInterval(simulationInterval)
|
||||
setSimulationInterval(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={`bg-red-50 border border-red-200 rounded-xl p-6 ${className}`}>
|
||||
<div className="flex items-center gap-3 text-red-700">
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!status) {
|
||||
return (
|
||||
<div className={`bg-slate-50 rounded-xl p-6 ${className}`}>
|
||||
<div className="flex items-center justify-center gap-3 text-slate-500">
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-purple-600"></div>
|
||||
<span>Warte auf Training-Daten...</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl shadow-sm border ${className}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-lg font-semibold text-slate-900">Training Dashboard</h3>
|
||||
<div className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${
|
||||
status.status === 'running' ? 'bg-blue-100 text-blue-700' :
|
||||
status.status === 'completed' ? 'bg-green-100 text-green-700' :
|
||||
status.status === 'failed' ? 'bg-red-100 text-red-700' :
|
||||
'bg-slate-100 text-slate-700'
|
||||
}`}>
|
||||
{status.status === 'running' && (
|
||||
<span className="w-2 h-2 rounded-full bg-blue-500 animate-pulse"></span>
|
||||
)}
|
||||
{status.status === 'running' ? 'Laeuft' :
|
||||
status.status === 'completed' ? 'Abgeschlossen' :
|
||||
status.status === 'failed' ? 'Fehlgeschlagen' :
|
||||
status.status}
|
||||
</div>
|
||||
{connected && (
|
||||
<span className="text-xs text-green-600 flex items-center gap-1">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-500"></span>
|
||||
Live
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{simulateMode && status.status === 'running' && (
|
||||
<button
|
||||
onClick={stopSimulation}
|
||||
className="px-3 py-1 text-sm bg-slate-200 hover:bg-slate-300 text-slate-700 rounded transition-colors"
|
||||
>
|
||||
Stoppen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="p-4 grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Progress section */}
|
||||
<div className="flex flex-col items-center justify-center gap-4">
|
||||
<ProgressRing progress={status.progress} size={120} strokeWidth={8} />
|
||||
<div className="text-center">
|
||||
<div className="text-sm text-slate-500">
|
||||
Epoche {status.current_epoch} / {status.total_epochs}
|
||||
</div>
|
||||
<div className="text-xs text-slate-400">
|
||||
Schritt {status.current_step} / {status.total_steps}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loss chart */}
|
||||
<div className="lg:col-span-2">
|
||||
<h4 className="text-sm font-medium text-slate-700 mb-2">Loss-Verlauf</h4>
|
||||
<LossChart data={status.history} height={180} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metrics grid */}
|
||||
<div className="px-4 pb-4 grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div className="bg-slate-50 rounded-lg p-3 text-center">
|
||||
<div className="text-lg font-bold text-slate-900">{status.metrics.loss.toFixed(4)}</div>
|
||||
<div className="text-xs text-slate-500">Aktueller Loss</div>
|
||||
</div>
|
||||
{status.metrics.val_loss !== undefined && (
|
||||
<div className="bg-slate-50 rounded-lg p-3 text-center">
|
||||
<div className="text-lg font-bold text-slate-900">{status.metrics.val_loss.toFixed(4)}</div>
|
||||
<div className="text-xs text-slate-500">Validation Loss</div>
|
||||
</div>
|
||||
)}
|
||||
{status.metrics.accuracy !== undefined && (
|
||||
<div className="bg-green-50 rounded-lg p-3 text-center">
|
||||
<div className="text-lg font-bold text-green-700">{(status.metrics.accuracy * 100).toFixed(1)}%</div>
|
||||
<div className="text-xs text-slate-500">Genauigkeit</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-slate-50 rounded-lg p-3 text-center">
|
||||
<div className="text-lg font-bold text-slate-900">{status.metrics.learning_rate.toExponential(1)}</div>
|
||||
<div className="text-xs text-slate-500">Learning Rate</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time info */}
|
||||
<div className="px-4 pb-4 flex items-center justify-between text-sm text-slate-500">
|
||||
<div>Vergangen: {formatDuration(status.elapsed_time_ms)}</div>
|
||||
{status.status === 'running' && (
|
||||
<div>Geschaetzt: {formatDuration(status.estimated_remaining_ms)} verbleibend</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact Training Metrics for inline display
|
||||
*/
|
||||
export function TrainingMetricsCompact({
|
||||
progress,
|
||||
currentEpoch,
|
||||
totalEpochs,
|
||||
loss,
|
||||
status,
|
||||
className = ''
|
||||
}: {
|
||||
progress: number
|
||||
currentEpoch: number
|
||||
totalEpochs: number
|
||||
loss: number
|
||||
status: 'running' | 'completed' | 'failed'
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={`flex items-center gap-4 ${className}`}>
|
||||
<ProgressRing progress={progress} size={48} strokeWidth={4} />
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="font-medium text-slate-900">Epoche {currentEpoch}/{totalEpochs}</span>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs ${
|
||||
status === 'running' ? 'bg-blue-100 text-blue-700' :
|
||||
status === 'completed' ? 'bg-green-100 text-green-700' :
|
||||
'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{status === 'running' ? 'Laeuft' : status === 'completed' ? 'Fertig' : 'Fehler'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 mt-1">
|
||||
Loss: {loss.toFixed(4)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TrainingMetrics
|
||||
13
admin-v2/components/ai/index.ts
Normal file
13
admin-v2/components/ai/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* AI Components Index
|
||||
*
|
||||
* Exports für alle AI-spezifischen Komponenten
|
||||
*/
|
||||
|
||||
export { AIModuleSidebar, AIModuleNav, AIModuleSidebarResponsive } from './AIModuleSidebar'
|
||||
export type { AIModuleSidebarProps, AIModuleSidebarResponsiveProps } from './AIModuleSidebar'
|
||||
|
||||
// Magic Help Components
|
||||
export { ConfidenceHeatmap, InlineConfidenceText, ConfidenceStats } from './ConfidenceHeatmap'
|
||||
export { TrainingMetrics, TrainingMetricsCompact, useTrainingMetricsSSE } from './TrainingMetrics'
|
||||
export { BatchUploader } from './BatchUploader'
|
||||
Reference in New Issue
Block a user