Files
breakpilot-lehrer/website/app/admin/mac-mini/_components/OllamaSection.tsx
Benjamin Admin b6983ab1dc [split-required] Split 500-1000 LOC files across all services
backend-lehrer (5 files):
- alerts_agent/db/repository.py (992 → 5), abitur_docs_api.py (956 → 3)
- teacher_dashboard_api.py (951 → 3), services/pdf_service.py (916 → 3)
- mail/mail_db.py (987 → 6)

klausur-service (5 files):
- legal_templates_ingestion.py (942 → 3), ocr_pipeline_postprocess.py (929 → 4)
- ocr_pipeline_words.py (876 → 3), ocr_pipeline_ocr_merge.py (616 → 2)
- KorrekturPage.tsx (956 → 6)

website (5 pages):
- mail (985 → 9), edu-search (958 → 8), mac-mini (950 → 7)
- ocr-labeling (946 → 7), audit-workspace (871 → 4)

studio-v2 (5 files + 1 deleted):
- page.tsx (946 → 5), MessagesContext.tsx (925 → 4)
- korrektur (914 → 6), worksheet-cleanup (899 → 6)
- useVocabWorksheet.ts (888 → 3)
- Deleted dead page-original.tsx (934 LOC)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-24 23:35:37 +02:00

290 lines
14 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useState } from 'react'
import type { MacMiniStatus, DownloadProgress, ModelDescription } from '../types'
import { MODEL_DATABASE, RECOMMENDED_MODELS } from '../constants'
function getModelInfo(modelName: string): ModelDescription | null {
if (MODEL_DATABASE[modelName]) return MODEL_DATABASE[modelName]
const baseName = modelName.split(':')[0]
const matchingKey = Object.keys(MODEL_DATABASE).find(key =>
key.startsWith(baseName) || key === baseName
)
return matchingKey ? MODEL_DATABASE[matchingKey] : null
}
function formatBytes(bytes: number) {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
export default function OllamaSection({
status,
actionLoading,
downloadProgress,
modelInput,
setModelInput,
onPullModel,
}: {
status: MacMiniStatus | null
actionLoading: string | null
downloadProgress: DownloadProgress | null
modelInput: string
setModelInput: (v: string) => void
onPullModel: () => void
}) {
const [selectedModel, setSelectedModel] = useState<string | null>(null)
const [showRecommendations, setShowRecommendations] = useState(false)
const isModelInstalled = (modelName: string): boolean => {
if (!status?.models) return false
return status.models.some(m =>
m.name === modelName || m.name.startsWith(modelName.split(':')[0])
)
}
return (
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-900 flex items-center gap-2 mb-4">
<span className="text-2xl">🤖</span> Ollama LLM Modelle
</h3>
{/* Installed Models */}
{status?.models && status.models.length > 0 ? (
<div className="space-y-2 mb-6">
{status.models.map((model, idx) => {
const modelInfo = getModelInfo(model.name)
return (
<div key={idx} className="flex items-center justify-between bg-slate-50 rounded-lg p-3 hover:bg-slate-100 transition-colors">
<div className="flex items-center gap-3">
<span className="w-2 h-2 rounded-full bg-green-500"></span>
<span className="font-medium text-slate-700">{model.name}</span>
{modelInfo && (
<button
onClick={() => setSelectedModel(model.name)}
className="text-blue-500 hover:text-blue-700 transition-colors"
title="Modell-Info anzeigen"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
)}
{modelInfo?.category === 'vision' && (
<span className="px-2 py-0.5 text-xs bg-purple-100 text-purple-700 rounded-full">Vision</span>
)}
</div>
<div className="flex items-center gap-4">
<span className="text-sm text-slate-500">{model.size}</span>
<span className="text-sm text-slate-400">{model.modified}</span>
</div>
</div>
)
})}
</div>
) : (
<p className="text-slate-500 text-center py-4 mb-6">
{status?.ollama ? 'Keine Modelle installiert' : 'Ollama nicht erreichbar'}
</p>
)}
{/* Model Info Modal */}
{selectedModel && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={() => setSelectedModel(null)}>
<div className="bg-white rounded-xl p-6 max-w-lg w-full mx-4 shadow-2xl" onClick={e => e.stopPropagation()}>
{(() => {
const info = getModelInfo(selectedModel)
if (!info) return <p>Keine Informationen verfügbar</p>
return (
<>
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="text-xl font-bold text-slate-900">{info.name}</h3>
<div className="flex items-center gap-2 mt-1">
<span className={`px-2 py-0.5 text-xs rounded-full ${
info.category === 'vision' ? 'bg-purple-100 text-purple-700' :
info.category === 'text' ? 'bg-blue-100 text-blue-700' :
'bg-slate-100 text-slate-700'
}`}>
{info.category === 'vision' ? '👁️ Vision' : info.category === 'text' ? '📝 Text' : info.category}
</span>
<span className="text-sm text-slate-500">{info.size}</span>
</div>
</div>
<button onClick={() => setSelectedModel(null)} className="text-slate-400 hover:text-slate-600">
<svg className="w-6 h-6" 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>
<p className="text-slate-600 mb-4">{info.description}</p>
<div>
<h4 className="font-medium text-slate-700 mb-2">Geeignet für:</h4>
<div className="flex flex-wrap gap-2">
{info.useCases.map((useCase, i) => (
<span key={i} className="px-3 py-1 bg-slate-100 text-slate-700 rounded-full text-sm">
{useCase}
</span>
))}
</div>
</div>
</>
)
})()}
</div>
</div>
)}
{/* Download New Model */}
<div className="border-t border-slate-200 pt-6">
<h4 className="font-medium text-slate-700 mb-3">Neues Modell herunterladen</h4>
<div className="flex gap-3 mb-4">
<input
type="text"
value={modelInput}
onChange={(e) => setModelInput(e.target.value)}
placeholder="z.B. llama3.2, mistral, qwen2.5:14b"
className="flex-1 px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
disabled={actionLoading === 'pull'}
/>
<button
onClick={onPullModel}
disabled={actionLoading !== null || !status?.ollama || !modelInput.trim()}
className="px-6 py-2 bg-primary-600 text-white rounded-lg font-medium hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{actionLoading === 'pull' ? 'Lädt...' : 'Herunterladen'}
</button>
</div>
{/* Download Progress */}
{downloadProgress && (
<div className="bg-slate-50 rounded-lg p-4">
<div className="flex justify-between mb-2">
<span className="font-medium text-slate-700">{downloadProgress.model}</span>
<span className="text-sm text-slate-500">
{formatBytes(downloadProgress.completed)} / {formatBytes(downloadProgress.total)}
</span>
</div>
<div className="h-3 bg-slate-200 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-primary-500 to-primary-600 transition-all duration-300"
style={{ width: `${downloadProgress.percent}%` }}
></div>
</div>
<div className="text-center mt-2 text-sm font-medium text-slate-600">
{downloadProgress.percent}%
</div>
</div>
)}
{/* Toggle Recommendations */}
<button
onClick={() => setShowRecommendations(!showRecommendations)}
className="mt-4 text-primary-600 hover:text-primary-700 font-medium text-sm flex items-center gap-2"
>
<svg className={`w-4 h-4 transition-transform ${showRecommendations ? '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>
{showRecommendations ? 'Empfehlungen ausblenden' : 'Modell-Empfehlungen für Klausurkorrektur & Handschrift anzeigen'}
</button>
</div>
{/* Recommendations Section */}
{showRecommendations && (
<div className="border-t border-slate-200 pt-6 mt-6">
<h4 className="font-semibold text-slate-900 mb-4">📚 Empfohlene Modelle</h4>
{/* Handwriting Recognition */}
<div className="mb-6">
<h5 className="font-medium text-slate-700 flex items-center gap-2 mb-3">
<span className="text-lg"></span> Handschrifterkennung (Vision-Modelle)
</h5>
<div className="space-y-2">
{RECOMMENDED_MODELS.handwriting.map((rec, idx) => {
const info = MODEL_DATABASE[rec.model]
const installed = isModelInstalled(rec.model)
return (
<div key={idx} className={`flex items-center justify-between rounded-lg p-3 ${installed ? 'bg-green-50 border border-green-200' : 'bg-slate-50'}`}>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-slate-700">{info?.name || rec.model}</span>
<span className="px-2 py-0.5 text-xs bg-purple-100 text-purple-700 rounded-full">Vision</span>
{info?.recommended && <span className="px-2 py-0.5 text-xs bg-yellow-100 text-yellow-700 rounded-full"> Empfohlen</span>}
{installed && <span className="px-2 py-0.5 text-xs bg-green-100 text-green-700 rounded-full"> Installiert</span>}
</div>
<p className="text-sm text-slate-500 mt-1">{rec.reason}</p>
<p className="text-xs text-slate-400 mt-0.5">Größe: {info?.size || 'unbekannt'}</p>
</div>
{!installed && (
<button
onClick={() => { setModelInput(rec.model); onPullModel() }}
disabled={actionLoading !== null || !status?.ollama}
className="ml-4 px-4 py-2 bg-primary-600 text-white text-sm rounded-lg font-medium hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Installieren
</button>
)}
</div>
)
})}
</div>
</div>
{/* Grading / Text Analysis */}
<div>
<h5 className="font-medium text-slate-700 flex items-center gap-2 mb-3">
<span className="text-lg">📝</span> Klausurkorrektur (Text-Modelle)
</h5>
<div className="space-y-2">
{RECOMMENDED_MODELS.grading.map((rec, idx) => {
const info = MODEL_DATABASE[rec.model]
const installed = isModelInstalled(rec.model)
return (
<div key={idx} className={`flex items-center justify-between rounded-lg p-3 ${installed ? 'bg-green-50 border border-green-200' : 'bg-slate-50'}`}>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-slate-700">{info?.name || rec.model}</span>
<span className="px-2 py-0.5 text-xs bg-blue-100 text-blue-700 rounded-full">Text</span>
{info?.recommended && <span className="px-2 py-0.5 text-xs bg-yellow-100 text-yellow-700 rounded-full"> Empfohlen</span>}
{installed && <span className="px-2 py-0.5 text-xs bg-green-100 text-green-700 rounded-full"> Installiert</span>}
</div>
<p className="text-sm text-slate-500 mt-1">{rec.reason}</p>
<p className="text-xs text-slate-400 mt-0.5">Größe: {info?.size || 'unbekannt'}</p>
</div>
{!installed && (
<button
onClick={() => { setModelInput(rec.model); onPullModel() }}
disabled={actionLoading !== null || !status?.ollama}
className="ml-4 px-4 py-2 bg-primary-600 text-white text-sm rounded-lg font-medium hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Installieren
</button>
)}
</div>
)
})}
</div>
</div>
{/* Info Box */}
<div className="mt-6 bg-amber-50 border border-amber-200 rounded-lg p-4">
<div className="flex gap-3">
<span className="text-xl">💡</span>
<div>
<h5 className="font-medium text-amber-900">Tipp: Modell-Kombinationen</h5>
<p className="text-sm text-amber-800 mt-1">
Für beste Ergebnisse bei Klausuren mit Handschrift kombiniere ein <strong>Vision-Modell</strong> (für OCR/Handschrifterkennung)
mit einem <strong>Text-Modell</strong> (für Bewertung und Feedback). Beispiel: <code className="bg-amber-100 px-1 rounded">llama3.2-vision:11b</code> + <code className="bg-amber-100 px-1 rounded">qwen2.5:14b</code>
</p>
</div>
</div>
</div>
</div>
)}
</div>
)
}