Services: Admin-Lehrer, Backend-Lehrer, Studio v2, Website, Klausur-Service, School-Service, Voice-Service, Geo-Service, BreakPilot Drive, Agent-Core Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
258 lines
9.3 KiB
TypeScript
258 lines
9.3 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useCallback, useRef, useEffect } from 'react'
|
|
import { useTheme } from '@/lib/ThemeContext'
|
|
import { useWorksheet } from '@/lib/worksheet-editor/WorksheetContext'
|
|
|
|
interface AIPromptBarProps {
|
|
className?: string
|
|
}
|
|
|
|
export function AIPromptBar({ className = '' }: AIPromptBarProps) {
|
|
const { isDark } = useTheme()
|
|
const { canvas, saveToHistory } = useWorksheet()
|
|
|
|
const [prompt, setPrompt] = useState('')
|
|
const [isLoading, setIsLoading] = useState(false)
|
|
const [lastResult, setLastResult] = useState<string | null>(null)
|
|
const [showHistory, setShowHistory] = useState(false)
|
|
const [promptHistory, setPromptHistory] = useState<string[]>([])
|
|
const inputRef = useRef<HTMLInputElement>(null)
|
|
|
|
// Load prompt history from localStorage
|
|
useEffect(() => {
|
|
const stored = localStorage.getItem('worksheet_prompt_history')
|
|
if (stored) {
|
|
try {
|
|
setPromptHistory(JSON.parse(stored))
|
|
} catch (e) {
|
|
// Ignore parse errors
|
|
}
|
|
}
|
|
}, [])
|
|
|
|
// Save prompt to history
|
|
const addToHistory = (newPrompt: string) => {
|
|
const updated = [newPrompt, ...promptHistory.filter(p => p !== newPrompt)].slice(0, 10)
|
|
setPromptHistory(updated)
|
|
localStorage.setItem('worksheet_prompt_history', JSON.stringify(updated))
|
|
}
|
|
|
|
// Handle AI prompt submission
|
|
const handleSubmit = useCallback(async () => {
|
|
if (!prompt.trim() || !canvas || isLoading) return
|
|
|
|
setIsLoading(true)
|
|
setLastResult(null)
|
|
addToHistory(prompt.trim())
|
|
|
|
try {
|
|
// Get current canvas state
|
|
const canvasJSON = JSON.stringify(canvas.toJSON())
|
|
|
|
// Get API base URL (use same protocol as page)
|
|
const { hostname, protocol } = window.location
|
|
const apiBase = hostname === 'localhost' ? 'http://localhost:8086' : `${protocol}//${hostname}:8086`
|
|
|
|
// Send prompt to AI endpoint with timeout (3 minutes for large models)
|
|
const controller = new AbortController()
|
|
const timeoutId = setTimeout(() => controller.abort(), 180000) // 3 minutes
|
|
|
|
try {
|
|
const response = await fetch(`${apiBase}/api/v1/worksheet/ai-modify`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
prompt: prompt.trim(),
|
|
canvas_json: canvasJSON,
|
|
model: 'qwen2.5vl:32b'
|
|
}),
|
|
signal: controller.signal
|
|
})
|
|
clearTimeout(timeoutId)
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ detail: 'Unknown error' }))
|
|
throw new Error(error.detail || `HTTP ${response.status}`)
|
|
}
|
|
|
|
const result = await response.json()
|
|
|
|
// Apply changes to canvas if we got new JSON
|
|
if (result.modified_canvas_json) {
|
|
const parsedJSON = JSON.parse(result.modified_canvas_json)
|
|
|
|
// Preserve current background if not specified in response
|
|
const currentBg = canvas.backgroundColor
|
|
|
|
canvas.loadFromJSON(parsedJSON, () => {
|
|
// Ensure white background is set (Fabric.js sometimes loses it)
|
|
if (!canvas.backgroundColor || canvas.backgroundColor === 'transparent' || canvas.backgroundColor === '#000000') {
|
|
canvas.backgroundColor = parsedJSON.background || currentBg || '#ffffff'
|
|
}
|
|
canvas.renderAll()
|
|
saveToHistory(`AI: ${prompt.trim().substring(0, 30)}`)
|
|
})
|
|
setLastResult(result.message || 'Aenderungen angewendet')
|
|
} else if (result.message) {
|
|
setLastResult(result.message)
|
|
}
|
|
|
|
setPrompt('')
|
|
} catch (fetchError) {
|
|
clearTimeout(timeoutId)
|
|
throw fetchError
|
|
}
|
|
} catch (error) {
|
|
console.error('AI prompt error:', error)
|
|
if (error instanceof Error && error.name === 'AbortError') {
|
|
setLastResult('Zeitüberschreitung - das KI-Modell braucht zu lange. Bitte versuchen Sie es erneut.')
|
|
} else {
|
|
setLastResult(`Fehler: ${error instanceof Error ? error.message : 'Unbekannter Fehler'}`)
|
|
}
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}, [prompt, canvas, isLoading, saveToHistory])
|
|
|
|
// Handle keyboard shortcuts
|
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault()
|
|
handleSubmit()
|
|
}
|
|
if (e.key === 'ArrowUp' && promptHistory.length > 0 && !prompt) {
|
|
e.preventDefault()
|
|
setPrompt(promptHistory[0])
|
|
}
|
|
if (e.key === 'Escape') {
|
|
setPrompt('')
|
|
setShowHistory(false)
|
|
inputRef.current?.blur()
|
|
}
|
|
}
|
|
|
|
// Example prompts
|
|
const examplePrompts = [
|
|
'Fuege eine Ueberschrift "Arbeitsblatt" oben hinzu',
|
|
'Erstelle ein 3x4 Raster fuer Aufgaben',
|
|
'Fuege Linien fuer Schueler-Antworten hinzu',
|
|
'Mache alle Texte groesser',
|
|
'Zentriere alle Elemente',
|
|
'Fuege Nummerierung 1-10 hinzu'
|
|
]
|
|
|
|
// Glassmorphism styles
|
|
const barStyle = isDark
|
|
? 'backdrop-blur-xl bg-white/10 border border-white/20'
|
|
: 'backdrop-blur-xl bg-white/70 border border-black/10 shadow-lg'
|
|
|
|
const inputStyle = isDark
|
|
? 'bg-white/10 border-white/20 text-white placeholder-white/40 focus:border-purple-400 focus:ring-purple-400/30'
|
|
: 'bg-white/50 border-black/10 text-slate-900 placeholder-slate-400 focus:border-purple-500 focus:ring-purple-500/30'
|
|
|
|
const buttonStyle = isDark
|
|
? 'bg-gradient-to-r from-purple-500 to-pink-500 text-white hover:shadow-lg hover:shadow-purple-500/30'
|
|
: 'bg-gradient-to-r from-purple-600 to-pink-600 text-white hover:shadow-lg hover:shadow-purple-600/30'
|
|
|
|
return (
|
|
<div className={`rounded-2xl p-4 ${barStyle} ${className}`}>
|
|
{/* Prompt Input */}
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex-shrink-0">
|
|
<span className="text-xl">✨</span>
|
|
</div>
|
|
|
|
<div className="flex-1 relative">
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
value={prompt}
|
|
onChange={(e) => setPrompt(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
onFocus={() => setShowHistory(true)}
|
|
onBlur={() => setTimeout(() => setShowHistory(false), 200)}
|
|
placeholder="Beschreibe, was du aendern moechtest... (z.B. 'Fuege eine Ueberschrift hinzu')"
|
|
disabled={isLoading}
|
|
className={`w-full px-4 py-3 rounded-xl border text-sm transition-all focus:outline-none focus:ring-2 ${inputStyle} ${
|
|
isLoading ? 'opacity-50 cursor-not-allowed' : ''
|
|
}`}
|
|
/>
|
|
|
|
{/* History Dropdown */}
|
|
{showHistory && promptHistory.length > 0 && !prompt && (
|
|
<div className={`absolute top-full left-0 right-0 mt-2 rounded-xl border overflow-hidden z-50 ${
|
|
isDark ? 'bg-slate-800 border-white/20' : 'bg-white border-slate-200 shadow-lg'
|
|
}`}>
|
|
<div className={`px-3 py-2 text-xs font-medium ${isDark ? 'text-white/50' : 'text-slate-400'}`}>
|
|
Letzte Prompts
|
|
</div>
|
|
{promptHistory.map((historyItem, idx) => (
|
|
<button
|
|
key={idx}
|
|
onClick={() => setPrompt(historyItem)}
|
|
className={`w-full text-left px-3 py-2 text-sm transition-colors ${
|
|
isDark ? 'text-white/80 hover:bg-white/10' : 'text-slate-700 hover:bg-slate-50'
|
|
}`}
|
|
>
|
|
{historyItem}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<button
|
|
onClick={handleSubmit}
|
|
disabled={isLoading || !prompt.trim()}
|
|
className={`flex-shrink-0 px-5 py-3 rounded-xl font-medium transition-all disabled:opacity-50 disabled:cursor-not-allowed ${buttonStyle}`}
|
|
>
|
|
{isLoading ? (
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
|
<span>KI denkt...</span>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center gap-2">
|
|
<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>Anwenden</span>
|
|
</div>
|
|
)}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Result Message */}
|
|
{lastResult && (
|
|
<div className={`mt-3 px-4 py-2 rounded-lg text-sm ${
|
|
lastResult.startsWith('Fehler')
|
|
? isDark ? 'bg-red-500/20 text-red-300' : 'bg-red-50 text-red-700'
|
|
: isDark ? 'bg-green-500/20 text-green-300' : 'bg-green-50 text-green-700'
|
|
}`}>
|
|
{lastResult}
|
|
</div>
|
|
)}
|
|
|
|
{/* Example Prompts */}
|
|
{!prompt && !isLoading && (
|
|
<div className="mt-3 flex flex-wrap gap-2">
|
|
{examplePrompts.slice(0, 4).map((example, idx) => (
|
|
<button
|
|
key={idx}
|
|
onClick={() => setPrompt(example)}
|
|
className={`px-3 py-1 rounded-lg text-xs font-medium transition-colors ${
|
|
isDark
|
|
? 'bg-white/10 text-white/70 hover:bg-white/20 hover:text-white'
|
|
: 'bg-slate-100 text-slate-600 hover:bg-slate-200 hover:text-slate-900'
|
|
}`}
|
|
>
|
|
{example}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|