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>
233 lines
7.8 KiB
TypeScript
233 lines
7.8 KiB
TypeScript
'use client'
|
||
|
||
/**
|
||
* AI Prompt Component
|
||
*
|
||
* Eingabezeile für Fragen an den lokalen Ollama-Server.
|
||
* Unterstützt Streaming-Antworten und automatische Modell-Erkennung.
|
||
*/
|
||
|
||
import { useState, useEffect, useRef } from 'react'
|
||
|
||
interface OllamaModel {
|
||
name: string
|
||
size: number
|
||
digest: string
|
||
}
|
||
|
||
export default function AiPrompt() {
|
||
const [prompt, setPrompt] = useState('')
|
||
const [response, setResponse] = useState('')
|
||
const [isLoading, setIsLoading] = useState(false)
|
||
const [models, setModels] = useState<OllamaModel[]>([])
|
||
const [selectedModel, setSelectedModel] = useState('llama3.2:latest')
|
||
const [showResponse, setShowResponse] = useState(false)
|
||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||
const abortControllerRef = useRef<AbortController | null>(null)
|
||
|
||
// Lade verfügbare Modelle von Ollama
|
||
useEffect(() => {
|
||
const loadModels = async () => {
|
||
try {
|
||
const ollamaUrl = getOllamaBaseUrl()
|
||
const res = await fetch(`${ollamaUrl}/api/tags`)
|
||
if (res.ok) {
|
||
const data = await res.json()
|
||
if (data.models && data.models.length > 0) {
|
||
setModels(data.models)
|
||
setSelectedModel(data.models[0].name)
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.log('Ollama nicht erreichbar:', error)
|
||
}
|
||
}
|
||
loadModels()
|
||
}, [])
|
||
|
||
const getOllamaBaseUrl = () => {
|
||
if (typeof window !== 'undefined') {
|
||
if (window.location.hostname === 'macmini') {
|
||
return 'http://macmini:11434'
|
||
}
|
||
}
|
||
return 'http://localhost:11434'
|
||
}
|
||
|
||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||
if (e.key === 'Enter' && !e.shiftKey) {
|
||
e.preventDefault()
|
||
sendPrompt()
|
||
}
|
||
}
|
||
|
||
const autoResize = () => {
|
||
if (textareaRef.current) {
|
||
textareaRef.current.style.height = 'auto'
|
||
textareaRef.current.style.height = Math.min(textareaRef.current.scrollHeight, 120) + 'px'
|
||
}
|
||
}
|
||
|
||
const sendPrompt = async () => {
|
||
if (!prompt.trim() || isLoading) return
|
||
|
||
// Vorherige Anfrage abbrechen
|
||
if (abortControllerRef.current) {
|
||
abortControllerRef.current.abort()
|
||
}
|
||
abortControllerRef.current = new AbortController()
|
||
|
||
setIsLoading(true)
|
||
setResponse('')
|
||
setShowResponse(true)
|
||
|
||
try {
|
||
const ollamaUrl = getOllamaBaseUrl()
|
||
const res = await fetch(`${ollamaUrl}/api/generate`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
model: selectedModel,
|
||
prompt: prompt.trim(),
|
||
stream: true,
|
||
}),
|
||
signal: abortControllerRef.current.signal,
|
||
})
|
||
|
||
if (!res.ok) {
|
||
throw new Error(`Ollama Fehler: ${res.status}`)
|
||
}
|
||
|
||
const reader = res.body?.getReader()
|
||
const decoder = new TextDecoder()
|
||
let fullResponse = ''
|
||
|
||
if (reader) {
|
||
while (true) {
|
||
const { done, value } = await reader.read()
|
||
if (done) break
|
||
|
||
const chunk = decoder.decode(value)
|
||
const lines = chunk.split('\n').filter(l => l.trim())
|
||
|
||
for (const line of lines) {
|
||
try {
|
||
const data = JSON.parse(line)
|
||
if (data.response) {
|
||
fullResponse += data.response
|
||
setResponse(fullResponse)
|
||
}
|
||
} catch {
|
||
// Ignore JSON parse errors for partial chunks
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} catch (error) {
|
||
if ((error as Error).name === 'AbortError') {
|
||
setResponse('Anfrage abgebrochen.')
|
||
} else {
|
||
console.error('AI Prompt Fehler:', error)
|
||
setResponse(`❌ Fehler: ${(error as Error).message}\n\nBitte prüfen Sie, ob Ollama läuft.`)
|
||
}
|
||
} finally {
|
||
setIsLoading(false)
|
||
abortControllerRef.current = null
|
||
}
|
||
}
|
||
|
||
const formatResponse = (text: string) => {
|
||
// Einfache Markdown-Formatierung
|
||
return text
|
||
.replace(/```(\w+)?\n([\s\S]*?)```/g, '<pre class="bg-slate-800 text-slate-100 p-3 rounded-lg my-2 overflow-x-auto text-sm"><code>$2</code></pre>')
|
||
.replace(/`([^`]+)`/g, '<code class="bg-slate-200 px-1.5 py-0.5 rounded text-sm">$1</code>')
|
||
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||
.replace(/\*([^*]+)\*/g, '<em>$1</em>')
|
||
.replace(/\n/g, '<br>')
|
||
}
|
||
|
||
return (
|
||
<div className="bg-gradient-to-br from-slate-50 to-slate-100 rounded-2xl border border-slate-200 p-5 mb-8 shadow-sm">
|
||
{/* Header */}
|
||
<div className="flex items-center gap-3 mb-4">
|
||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary-600 to-primary-700 flex items-center justify-center text-white text-xl shadow-lg">
|
||
🤖
|
||
</div>
|
||
<div>
|
||
<h3 className="font-semibold text-slate-900">KI-Assistent</h3>
|
||
<p className="text-xs text-slate-500">Fragen Sie Ihren lokalen Ollama-Assistenten</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Input */}
|
||
<div className="flex gap-3 items-end">
|
||
<textarea
|
||
ref={textareaRef}
|
||
value={prompt}
|
||
onChange={(e) => {
|
||
setPrompt(e.target.value)
|
||
autoResize()
|
||
}}
|
||
onKeyDown={handleKeyDown}
|
||
placeholder="Stellen Sie eine Frage... (z.B. 'Wie schreibe ich einen Elternbrief?' oder 'Erstelle mir einen Lückentext')"
|
||
rows={1}
|
||
className="flex-1 min-h-[44px] max-h-[120px] px-4 py-3 rounded-xl border border-slate-300 bg-white text-slate-900 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent placeholder:text-slate-400 transition-all"
|
||
/>
|
||
<button
|
||
onClick={sendPrompt}
|
||
disabled={isLoading || !prompt.trim()}
|
||
className={`w-11 h-11 rounded-xl flex items-center justify-center text-white text-lg transition-all shadow-lg ${
|
||
isLoading
|
||
? 'bg-slate-400 cursor-wait animate-pulse'
|
||
: 'bg-gradient-to-br from-primary-600 to-primary-700 hover:from-primary-700 hover:to-primary-800 hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100'
|
||
}`}
|
||
>
|
||
{isLoading ? '⏳' : '➤'}
|
||
</button>
|
||
</div>
|
||
|
||
{/* Response */}
|
||
{showResponse && (
|
||
<div className="mt-4 p-4 bg-white rounded-xl border border-slate-200 shadow-inner">
|
||
<div className="flex items-center gap-2 text-xs text-slate-500 mb-3">
|
||
<span>🤖</span>
|
||
<span className="font-medium">{selectedModel}</span>
|
||
{isLoading && <span className="animate-pulse">• Generiert...</span>}
|
||
</div>
|
||
<div
|
||
className="text-sm text-slate-700 leading-relaxed prose prose-sm max-w-none"
|
||
dangerouslySetInnerHTML={{ __html: formatResponse(response) || '<span class="text-slate-400 italic">Warte auf Antwort...</span>' }}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* Model Selector */}
|
||
<div className="flex items-center gap-2 mt-4 pt-4 border-t border-slate-200">
|
||
<span className="text-xs text-slate-500">Modell:</span>
|
||
<select
|
||
value={selectedModel}
|
||
onChange={(e) => setSelectedModel(e.target.value)}
|
||
className="text-xs px-2 py-1.5 rounded-lg border border-slate-300 bg-white text-slate-700 focus:outline-none focus:ring-2 focus:ring-primary-500 cursor-pointer"
|
||
>
|
||
{models.length > 0 ? (
|
||
models.map((model) => (
|
||
<option key={model.name} value={model.name}>
|
||
{model.name}
|
||
</option>
|
||
))
|
||
) : (
|
||
<>
|
||
<option value="llama3.2:latest">Llama 3.2</option>
|
||
<option value="mistral:latest">Mistral</option>
|
||
<option value="qwen2.5:7b">Qwen 2.5</option>
|
||
</>
|
||
)}
|
||
</select>
|
||
{models.length === 0 && (
|
||
<span className="text-xs text-amber-600">⚠️ Ollama nicht verbunden</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|