Add AI Studio with agentic system, SnapGen video gen, Perchance image gen

Features:
- AI Studio module with 4 tabs: Video Generation, Image Generation, Lesson Plan, AI Agent Chat
- SnapGen AI API integration for video lesson generation (https://api.snapgen.ai/uapi/v1/generate)
  - Educational prompt enhancement with subject/topic/language context
  - Async polling for video generation status
  - Fallback to z-ai-web-dev-sdk if SnapGen not configured
- Perchance AI API integration for image generation (https://perchance.org/text-to-image-plugin)
  - Multiple styles: realistic, cartoon, watercolor, pixel, sketch, 3D
  - Fallback to z-ai-web-dev-sdk if Perchance not configured
- Agentic AI chat with teacher assistant context
- AI lesson plan generation

Fixes:
- Fix duplicate close button in onboarding tour (removed default Dialog close, kept custom one)
- Add SnapGen and Perchance fields to AISettings model

Navigation:
- AI Studio added to Insights section (teachers) and Student nav
- Digital Notebook confirmed available for both students and teachers
This commit is contained in:
Z User committed 2026-08-10 16:33:23 +00:00
1 parent c078ec36aa
commit 763ebef22b
8 files changed
+1278 -2

No files matched your search

+144
View File
@@ -0,0 +1,144 @@
// @ts-nocheck
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
/**
* Perchance AI Image Generation API
* POST /api/ai/perchance
* Body: { prompt, style?, size? }
*
* Uses Perchance AI: https://perchance.org/text-to-image-plugin
* Falls back to z-ai-web-dev-sdk if Perchance is not configured
*/
export async function POST(request: Request) {
try {
const session = await getSession();
if (!session?.user) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
// Only admin/teacher can generate images (students can if enabled)
const role = session.user.role;
const isStaff = role === 'SCHOOL_ADMIN' || role === 'SUPER_ADMIN' || role === 'TEACHER';
if (!isStaff && role !== 'STUDENT') {
return NextResponse.json(
{ error: 'Only teachers, admins, and students can generate images' },
{ status: 403 }
);
}
const { schoolId } = session.user;
if (!schoolId) {
return NextResponse.json({ error: 'No school assigned' }, { status: 403 });
}
// Check AI settings
const aiSettings = await db.aISettings.findUnique({
where: { schoolId },
});
if (!aiSettings || (!aiSettings.perchanceEnabled && !aiSettings.aiImageGenEnabled)) {
return NextResponse.json(
{ error: 'Image generation is disabled for your school. Enable it in Settings > AI.' },
{ status: 403 }
);
}
const body = await request.json();
const {
prompt,
style = 'realistic',
size = '1024x1024',
negativePrompt,
seed,
} = body;
if (!prompt || typeof prompt !== 'string' || prompt.trim().length === 0) {
return NextResponse.json(
{ error: 'Prompt is required' },
{ status: 400 }
);
}
// Try Perchance API first if enabled
if (aiSettings.perchanceEnabled) {
try {
// Perchance uses a web-based generator. We'll use their API endpoint.
const perchanceResponse = await fetch('https://image-generation.perchance.org/api/generate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
prompt,
style,
size,
negativePrompt: negativePrompt || '',
seed: seed || Math.floor(Math.random() * 1000000),
}),
});
if (perchanceResponse.ok) {
const data = await perchanceResponse.json();
return NextResponse.json({
success: true,
provider: 'perchance',
image: {
url: data.imageUrl || data.url || null,
b64_json: data.b64_json || null,
revised_prompt: prompt,
seed: data.seed || seed,
},
});
}
} catch (perchanceError) {
console.error('Perchance API error:', perchanceError);
// Fall through to z-ai-web-dev-sdk
}
}
// Fallback to z-ai-web-dev-sdk
try {
const ZAI = (await import('z-ai-web-dev-sdk')).default;
const zai = await ZAI.create();
const response = await zai.images.generations.create({
prompt: `${prompt}. Style: ${style}`,
size,
});
const imageData = response.data?.[0];
if (!imageData) {
return NextResponse.json(
{ error: 'Failed to generate image' },
{ status: 500 }
);
}
return NextResponse.json({
success: true,
provider: 'zai',
image: {
url: imageData.url || null,
b64_json: imageData.b64_json || null,
revised_prompt: imageData.revised_prompt || prompt,
},
});
} catch (zaiError) {
console.error('ZAI Image Generation error:', zaiError);
return NextResponse.json(
{ error: 'Image generation failed. Please try again later.' },
{ status: 500 }
);
}
} catch (error) {
console.error('Perchance Image API error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
+223
View File
@@ -0,0 +1,223 @@
// @ts-nocheck
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
/**
* SnapGen AI Video Generation API
* POST /api/ai/snapgen
* Body: { prompt, duration?, aspectRatio?, style? }
*
* Uses SnapGen API: https://api.snapgen.ai/uapi/v1/generate
*/
export async function POST(request: Request) {
try {
const session = await getSession();
if (!session?.user) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
// Only admin/teacher can generate videos
const role = session.user.role;
if (role !== 'SCHOOL_ADMIN' && role !== 'SUPER_ADMIN' && role !== 'TEACHER') {
return NextResponse.json(
{ error: 'Only teachers and admins can generate videos' },
{ status: 403 }
);
}
const { schoolId } = session.user;
if (!schoolId) {
return NextResponse.json({ error: 'No school assigned' }, { status: 403 });
}
// Check AI settings
const aiSettings = await db.aISettings.findUnique({
where: { schoolId },
});
if (!aiSettings || (!aiSettings.snapgenEnabled && !aiSettings.aiVideoGenEnabled)) {
return NextResponse.json(
{ error: 'Video generation is disabled for your school. Enable it in Settings > AI.' },
{ status: 403 }
);
}
const body = await request.json();
const {
prompt,
duration = 10,
aspectRatio = '16:9',
style = 'realistic',
language = 'de',
subject,
topic,
} = body;
if (!prompt || typeof prompt !== 'string' || prompt.trim().length === 0) {
return NextResponse.json(
{ error: 'Prompt is required' },
{ status: 400 }
);
}
// Build the enhanced prompt for educational content
const enhancedPrompt = buildEducationalPrompt(prompt, {
duration,
aspectRatio,
style,
language,
subject,
topic,
});
// Try SnapGen API first if enabled
if (aiSettings.snapgenEnabled && aiSettings.snapgenApiKey) {
try {
const snapgenResponse = await fetch('https://api.snapgen.ai/uapi/v1/generate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${aiSettings.snapgenApiKey}`,
},
body: JSON.stringify({
prompt: enhancedPrompt,
duration: `${duration}s`,
aspect_ratio: aspectRatio,
style,
language,
type: 'video',
}),
});
if (snapgenResponse.ok) {
const data = await snapgenResponse.json();
return NextResponse.json({
success: true,
provider: 'snapgen',
taskId: data.task_id || data.id || data.taskId,
status: data.status || 'processing',
videoUrl: data.video_url || data.url || null,
thumbnailUrl: data.thumbnail_url || null,
message: 'Video generation started with SnapGen AI.',
});
}
// Fall through to z-ai-web-dev-sdk if SnapGen fails
console.error('SnapGen API error:', snapgenResponse.status);
} catch (snapgenError) {
console.error('SnapGen API error:', snapgenError);
// Fall through to z-ai-web-dev-sdk
}
}
// Fallback to z-ai-web-dev-sdk
try {
const ZAI = (await import('z-ai-web-dev-sdk')).default;
const zai = await ZAI.create();
const task = await zai.video.generations.create({
prompt: enhancedPrompt,
quality: 'standard',
size: aspectRatio === '9:16' ? '720x1280' : '1280x720',
});
return NextResponse.json({
success: true,
provider: 'zai',
taskId: task.id || task.taskId || 'pending',
status: task.status || 'processing',
message: 'Video generation started. Check status for updates.',
});
} catch (zaiError) {
console.error('ZAI Video Generation error:', zaiError);
return NextResponse.json(
{ error: 'Video generation failed. Please try again later.' },
{ status: 500 }
);
}
} catch (error) {
console.error('SnapGen Video API error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
/**
* GET /api/ai/snapgen?taskId=xxx
* Check video generation task status
*/
export async function GET(request: Request) {
try {
const session = await getSession();
if (!session?.user) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const taskId = searchParams.get('taskId');
const provider = searchParams.get('provider') || 'zai';
if (!taskId) {
return NextResponse.json({ error: 'taskId is required' }, { status: 400 });
}
if (provider === 'snapgen') {
const aiSettings = await db.aISettings.findUnique({
where: { schoolId: session.user.schoolId || '' },
});
if (aiSettings?.snapgenApiKey) {
const statusResponse = await fetch(`https://api.snapgen.ai/uapi/v1/generate/${taskId}`, {
headers: {
'Authorization': `Bearer ${aiSettings.snapgenApiKey}`,
},
});
if (statusResponse.ok) {
const data = await statusResponse.json();
return NextResponse.json({
status: data.status || 'processing',
videoUrl: data.video_url || data.url || null,
thumbnailUrl: data.thumbnail_url || null,
progress: data.progress || 0,
});
}
}
}
// Fallback to z-ai-web-dev-sdk
try {
const ZAI = (await import('z-ai-web-dev-sdk')).default;
const zai = await ZAI.create();
const status = await zai.video.generations.retrieve(taskId);
return NextResponse.json({
status: status.status || 'processing',
videoUrl: status.video_url || status.url || null,
});
} catch (error) {
return NextResponse.json({
status: 'processing',
message: 'Task is still being processed.',
});
}
} catch (error) {
console.error('SnapGen status check error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
function buildEducationalPrompt(
prompt: string,
opts: { duration: number; aspectRatio: string; style: string; language: string; subject?: string; topic?: string }
): string {
const langInstruction = opts.language === 'de'
? 'Erkläre auf Deutsch mit klarer, freundlicher Stimme.'
: 'Explain in English with a clear, friendly voice.';
const subjectContext = opts.subject ? `Fach: ${opts.subject}. ` : '';
const topicContext = opts.topic ? `Thema: ${opts.topic}. ` : '';
return `${subjectContext}${topicContext}${langInstruction} Inhalt: ${prompt}. Stil: ${opts.style}. Dauer: ${opts.duration} Sekunden. Aspect Ratio: ${opts.aspectRatio}.`;
}
+895
View File
@@ -0,0 +1,895 @@
'use client';
import React, { useState, useCallback, useRef, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Sparkles, Video, Image as ImageIcon, FileText, Send,
Loader2, Download, Play, RefreshCw, Copy, Check,
Film, Wand2, BookOpen, Lightbulb, GraduationCap,
Clock, CheckCircle2, AlertCircle, Settings,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useAppStore } from '@/lib/store';
import { t } from '@/lib/i18n';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
type AITab = 'video' | 'image' | 'lesson' | 'agent';
type GenStatus = 'idle' | 'generating' | 'success' | 'error';
interface VideoResult {
taskId?: string;
status: string;
videoUrl?: string | null;
thumbnailUrl?: string | null;
message?: string;
provider?: string;
}
interface ImageResult {
url?: string | null;
b64_json?: string | null;
revised_prompt?: string;
provider?: string;
}
export default function AIStudioView() {
const { currentUser } = useAppStore();
const [activeTab, setActiveTab] = useState<AITab>('video');
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Sparkles className="h-6 w-6 text-emerald-500" />
AI Studio
</h1>
<p className="text-sm text-muted-foreground mt-1">
KI-gestützte Content-Erstellung für den Unterricht
</p>
</div>
<Badge variant="outline" className="bg-emerald-50 dark:bg-emerald-950/30 text-emerald-700 dark:text-emerald-400">
<Wand2 className="h-3 w-3 mr-1" />
Agentic AI
</Badge>
</div>
{/* Stats cards */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{[
{ label: 'Video-Gen', icon: Video, color: 'text-sky-600 bg-sky-50 dark:bg-sky-950/30' },
{ label: 'Bild-Gen', icon: ImageIcon, color: 'text-violet-600 bg-violet-50 dark:bg-violet-950/30' },
{ label: 'Unterricht', icon: BookOpen, color: 'text-emerald-600 bg-emerald-50 dark:bg-emerald-950/30' },
{ label: 'KI-Agent', icon: Lightbulb, color: 'text-amber-600 bg-amber-50 dark:bg-amber-950/30' },
].map((s, i) => {
const Icon = s.icon;
return (
<Card key={i}>
<CardContent className="p-4 flex items-center gap-3">
<div className={cn('rounded-lg p-2.5', s.color)}>
<Icon className="h-5 w-5" />
</div>
<div>
<p className="text-sm font-medium">{s.label}</p>
<p className="text-xs text-muted-foreground">Bereit</p>
</div>
</CardContent>
</Card>
);
})}
</div>
{/* Main content tabs */}
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as AITab)}>
<TabsList className="grid w-full grid-cols-2 sm:grid-cols-4">
<TabsTrigger value="video">
<Video className="h-4 w-4 mr-1.5" />
<span className="hidden sm:inline">Video-Lektion</span>
<span className="sm:hidden">Video</span>
</TabsTrigger>
<TabsTrigger value="image">
<ImageIcon className="h-4 w-4 mr-1.5" />
<span className="hidden sm:inline">Bild generieren</span>
<span className="sm:hidden">Bild</span>
</TabsTrigger>
<TabsTrigger value="lesson">
<BookOpen className="h-4 w-4 mr-1.5" />
<span className="hidden sm:inline">Unterrichtsentwurf</span>
<span className="sm:hidden">Plan</span>
</TabsTrigger>
<TabsTrigger value="agent">
<Lightbulb className="h-4 w-4 mr-1.5" />
<span className="hidden sm:inline">KI-Agent</span>
<span className="sm:hidden">Agent</span>
</TabsTrigger>
</TabsList>
<TabsContent value="video" className="mt-4">
<VideoGenerationTab />
</TabsContent>
<TabsContent value="image" className="mt-4">
<ImageGenerationTab />
</TabsContent>
<TabsContent value="lesson" className="mt-4">
<LessonPlanTab />
</TabsContent>
<TabsContent value="agent" className="mt-4">
<AgentChatTab />
</TabsContent>
</Tabs>
</div>
);
}
// ─── Video Generation Tab ──────────────────────────────────────────
function VideoGenerationTab() {
const [prompt, setPrompt] = useState('');
const [duration, setDuration] = useState('10');
const [aspectRatio, setAspectRatio] = useState('16:9');
const [style, setStyle] = useState('realistic');
const [language, setLanguage] = useState('de');
const [subject, setSubject] = useState('');
const [topic, setTopic] = useState('');
const [status, setStatus] = useState<GenStatus>('idle');
const [result, setResult] = useState<VideoResult | null>(null);
const [pollInterval, setPollInterval] = useState<NodeJS.Timeout | null>(null);
const handleGenerate = useCallback(async () => {
if (!prompt.trim()) {
toast.error('Bitte gib einen Prompt ein');
return;
}
setStatus('generating');
setResult(null);
try {
const res = await fetch('/api/ai/snapgen', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt, duration: Number(duration), aspectRatio, style, language, subject, topic }),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || 'Generierung fehlgeschlagen');
}
const data = await res.json();
setResult(data);
// If video is not ready, poll for status
if (data.taskId && data.status !== 'completed' && !data.videoUrl) {
startPolling(data.taskId, data.provider);
} else if (data.videoUrl) {
setStatus('success');
} else {
setStatus('generating');
toast.info('Video wird generiert. Dies kann einige Minuten dauern.');
}
} catch (error: any) {
setStatus('error');
toast.error(error.message || 'Fehler bei der Video-Generierung');
}
}, [prompt, duration, aspectRatio, style, language, subject, topic]);
const startPolling = (taskId: string, provider: string) => {
if (pollInterval) clearInterval(pollInterval);
const interval = setInterval(async () => {
try {
const res = await fetch(`/api/ai/snapgen?taskId=${taskId}&provider=${provider}`);
if (res.ok) {
const data = await res.json();
if (data.videoUrl) {
setResult(prev => ({ ...prev, videoUrl: data.videoUrl, status: 'completed' }));
setStatus('success');
toast.success('Video erfolgreich generiert!');
clearInterval(interval);
} else if (data.status === 'failed' || data.status === 'error') {
setStatus('error');
toast.error('Video-Generierung fehlgeschlagen');
clearInterval(interval);
}
}
} catch {
// Continue polling
}
}, 5000);
setPollInterval(interval);
};
useEffect(() => {
return () => {
if (pollInterval) clearInterval(pollInterval);
};
}, [pollInterval]);
const promptSuggestions = [
'Erkläre den Wasserkreislauf mit Animationen',
'Wie funktionieren Brüche? Visuelle Erklärung',
'Der Wasserstoff als chemisches Element',
'Die Französische Revolution - Überblick',
'Photosynthese einfach erklärt',
'Teilen mit Rest - Schritt für Schritt',
];
return (
<div className="grid gap-4 lg:grid-cols-2">
{/* Input panel */}
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Film className="h-4 w-4 text-sky-500" />
Video-Prompt
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label className="text-sm">Thema / Prompt</Label>
<Textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="Beschreibe, was das Video erklären soll..."
rows={4}
className="mt-1.5"
/>
</div>
{/* Prompt suggestions */}
<div className="flex flex-wrap gap-1.5">
{promptSuggestions.slice(0, 3).map((s, i) => (
<button
key={i}
onClick={() => setPrompt(s)}
className="text-xs rounded-full bg-muted hover:bg-muted/70 px-2.5 py-1 transition-colors"
>
{s}
</button>
))}
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs">Dauer (Sek.)</Label>
<Select value={duration} onValueChange={setDuration}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="5">5 Sekunden</SelectItem>
<SelectItem value="10">10 Sekunden</SelectItem>
<SelectItem value="15">15 Sekunden</SelectItem>
<SelectItem value="30">30 Sekunden</SelectItem>
<SelectItem value="60">60 Sekunden</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs">Format</Label>
<Select value={aspectRatio} onValueChange={setAspectRatio}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="16:9">16:9 (Landschaft)</SelectItem>
<SelectItem value="9:16">9:16 (Hochformat)</SelectItem>
<SelectItem value="1:1">1:1 (Quadrat)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs">Stil</Label>
<Select value={style} onValueChange={setStyle}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="realistic">Realistisch</SelectItem>
<SelectItem value="animated">Animiert</SelectItem>
<SelectItem value="cartoon">Zeichentrick</SelectItem>
<SelectItem value="whiteboard">Whiteboard</SelectItem>
<SelectItem value="3d">3D-Animation</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs">Sprache</Label>
<Select value={language} onValueChange={setLanguage}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="de">Deutsch</SelectItem>
<SelectItem value="en">Englisch</SelectItem>
<SelectItem value="tr">Türkçe</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs">Fach (optional)</Label>
<Input value={subject} onChange={(e) => setSubject(e.target.value)} placeholder="z.B. Mathematik" className="mt-1" />
</div>
<div>
<Label className="text-xs">Thema (optional)</Label>
<Input value={topic} onChange={(e) => setTopic(e.target.value)} placeholder="z.B. Brüche" className="mt-1" />
</div>
</div>
<Button
onClick={handleGenerate}
disabled={status === 'generating' || !prompt.trim()}
className="w-full bg-gradient-to-r from-sky-500 to-emerald-500 hover:opacity-90"
>
{status === 'generating' ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Generiere Video...
</>
) : (
<>
<Video className="h-4 w-4 mr-2" />
Video generieren
</>
)}
</Button>
</CardContent>
</Card>
{/* Result panel */}
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Play className="h-4 w-4 text-emerald-500" />
Ergebnis
</CardTitle>
</CardHeader>
<CardContent>
<AnimatePresence mode="wait">
{status === 'idle' && (
<motion.div
key="idle"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex flex-col items-center justify-center py-16 text-center"
>
<Film className="h-12 w-12 text-muted-foreground/30 mb-3" />
<p className="text-sm text-muted-foreground">
Generiere ein Video, um es hier zu sehen
</p>
</motion.div>
)}
{status === 'generating' && (
<motion.div
key="loading"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex flex-col items-center justify-center py-16 text-center"
>
<Loader2 className="h-12 w-12 text-sky-500 animate-spin mb-4" />
<p className="text-sm font-medium">Video wird generiert...</p>
<p className="text-xs text-muted-foreground mt-1">
Dies kann 1-5 Minuten dauern
</p>
{result?.provider && (
<Badge variant="outline" className="mt-3 text-xs">
Provider: {result.provider}
</Badge>
)}
</motion.div>
)}
{status === 'success' && result?.videoUrl && (
<motion.div
key="success"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0 }}
className="space-y-3"
>
<video
src={result.videoUrl}
controls
className="w-full rounded-lg border"
poster={result.thumbnailUrl || undefined}
/>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" asChild>
<a href={result.videoUrl} download="ai-video.mp4">
<Download className="h-4 w-4 mr-1" /> Herunterladen
</a>
</Button>
<Button size="sm" variant="outline" onClick={() => { setStatus('idle'); setResult(null); }}>
<RefreshCw className="h-4 w-4 mr-1" /> Neu
</Button>
</div>
</motion.div>
)}
{status === 'error' && (
<motion.div
key="error"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex flex-col items-center justify-center py-16 text-center"
>
<AlertCircle className="h-12 w-12 text-rose-500 mb-3" />
<p className="text-sm font-medium">Generierung fehlgeschlagen</p>
<Button size="sm" variant="outline" className="mt-3" onClick={() => setStatus('idle')}>
Erneut versuchen
</Button>
</motion.div>
)}
</AnimatePresence>
</CardContent>
</Card>
</div>
);
}
// ─── Image Generation Tab ──────────────────────────────────────────
function ImageGenerationTab() {
const [prompt, setPrompt] = useState('');
const [style, setStyle] = useState('realistic');
const [status, setStatus] = useState<GenStatus>('idle');
const [result, setResult] = useState<ImageResult | null>(null);
const [copied, setCopied] = useState(false);
const handleGenerate = useCallback(async () => {
if (!prompt.trim()) {
toast.error('Bitte gib einen Prompt ein');
return;
}
setStatus('generating');
setResult(null);
try {
const res = await fetch('/api/ai/perchance', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt, style }),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || 'Generierung fehlgeschlagen');
}
const data = await res.json();
setResult(data.image);
setStatus('success');
toast.success('Bild erfolgreich generiert!');
} catch (error: any) {
setStatus('error');
toast.error(error.message || 'Fehler bei der Bild-Generierung');
}
}, [prompt, style]);
const imageSrc = result?.b64_json
? `data:image/png;base64,${result.b64_json}`
: result?.url || null;
const promptSuggestions = [
'Eine Sonne mit freundlichem Gesicht für Kinder',
'Der Wasserstoff als blauer Ball',
'Brüche visuell als Pizza dargestellt',
'Eine Burg im Mittelalter',
'Das Sonnensystem mit allen Planeten',
'Eine Pflanze beim Wachsen',
];
return (
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<ImageIcon className="h-4 w-4 text-violet-500" />
Bild-Prompt
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label className="text-sm">Beschreibung</Label>
<Textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="Beschreibe das Bild, das generiert werden soll..."
rows={4}
className="mt-1.5"
/>
</div>
<div className="flex flex-wrap gap-1.5">
{promptSuggestions.slice(0, 3).map((s, i) => (
<button
key={i}
onClick={() => setPrompt(s)}
className="text-xs rounded-full bg-muted hover:bg-muted/70 px-2.5 py-1 transition-colors"
>
{s}
</button>
))}
</div>
<div>
<Label className="text-sm">Stil</Label>
<Select value={style} onValueChange={setStyle}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="realistic">Realistisch</SelectItem>
<SelectItem value="cartoon">Zeichentrick</SelectItem>
<SelectItem value="watercolor">Aquarell</SelectItem>
<SelectItem value="pixel">Pixel-Art</SelectItem>
<SelectItem value="sketch">Skizze</SelectItem>
<SelectItem value="3d">3D-Render</SelectItem>
</SelectContent>
</Select>
</div>
<Button
onClick={handleGenerate}
disabled={status === 'generating' || !prompt.trim()}
className="w-full bg-gradient-to-r from-violet-500 to-emerald-500 hover:opacity-90"
>
{status === 'generating' ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Generiere Bild...
</>
) : (
<>
<ImageIcon className="h-4 w-4 mr-2" />
Bild generieren
</>
)}
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<ImageIcon className="h-4 w-4 text-emerald-500" />
Ergebnis
</CardTitle>
</CardHeader>
<CardContent>
<AnimatePresence mode="wait">
{status === 'idle' && (
<div className="flex flex-col items-center justify-center py-16 text-center">
<ImageIcon className="h-12 w-12 text-muted-foreground/30 mb-3" />
<p className="text-sm text-muted-foreground">Generiere ein Bild, um es hier zu sehen</p>
</div>
)}
{status === 'generating' && (
<div className="flex flex-col items-center justify-center py-16 text-center">
<Loader2 className="h-12 w-12 text-violet-500 animate-spin mb-4" />
<p className="text-sm font-medium">Bild wird generiert...</p>
</div>
)}
{status === 'success' && imageSrc && (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="space-y-3"
>
<img src={imageSrc} alt={prompt} className="w-full rounded-lg border" />
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" asChild>
<a href={imageSrc} download="ai-image.png">
<Download className="h-4 w-4 mr-1" /> Herunterladen
</a>
</Button>
<Button size="sm" variant="outline" onClick={() => { navigator.clipboard.writeText(imageSrc); setCopied(true); setTimeout(() => setCopied(false), 2000); }}>
{copied ? <Check className="h-4 w-4 mr-1" /> : <Copy className="h-4 w-4 mr-1" />}
{copied ? 'Kopiert!' : 'Kopieren'}
</Button>
<Button size="sm" variant="outline" onClick={() => { setStatus('idle'); setResult(null); }}>
<RefreshCw className="h-4 w-4 mr-1" /> Neu
</Button>
</div>
</motion.div>
)}
{status === 'error' && (
<div className="flex flex-col items-center justify-center py-16 text-center">
<AlertCircle className="h-12 w-12 text-rose-500 mb-3" />
<p className="text-sm font-medium">Generierung fehlgeschlagen</p>
<Button size="sm" variant="outline" className="mt-3" onClick={() => setStatus('idle')}>
Erneut versuchen
</Button>
</div>
)}
</AnimatePresence>
</CardContent>
</Card>
</div>
);
}
// ─── Lesson Plan Tab ──────────────────────────────────────────────
function LessonPlanTab() {
const [subject, setSubject] = useState('');
const [topic, setTopic] = useState('');
const [classLevel, setClassLevel] = useState('3');
const [duration, setDuration] = useState('45');
const [status, setStatus] = useState<GenStatus>('idle');
const [result, setResult] = useState('');
const handleGenerate = useCallback(async () => {
if (!topic.trim()) {
toast.error('Bitte gib ein Thema ein');
return;
}
setStatus('generating');
try {
const res = await fetch('/api/ai', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'lesson-plan',
subject,
topic,
classLevel,
duration: Number(duration),
language: 'de',
}),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || 'Generierung fehlgeschlagen');
}
const data = await res.json();
setResult(data.content || data.response || 'Keine Antwort erhalten.');
setStatus('success');
} catch (error: any) {
setStatus('error');
toast.error(error.message || 'Fehler');
}
}, [subject, topic, classLevel, duration]);
return (
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<BookOpen className="h-4 w-4 text-emerald-500" />
Unterrichtsentwurf
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-sm">Fach</Label>
<Input value={subject} onChange={(e) => setSubject(e.target.value)} placeholder="z.B. Mathematik" className="mt-1" />
</div>
<div>
<Label className="text-sm">Klassenstufe</Label>
<Select value={classLevel} onValueChange={setClassLevel}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
{['1','2','3','4','5','6','7','8','9','10','11','12','13'].map(l => (
<SelectItem key={l} value={l}>Klasse {l}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div>
<Label className="text-sm">Thema</Label>
<Input value={topic} onChange={(e) => setTopic(e.target.value)} placeholder="z.B. Brüche teilen" className="mt-1" />
</div>
<div>
<Label className="text-sm">Dauer (Min.)</Label>
<Select value={duration} onValueChange={setDuration}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="30">30 Min.</SelectItem>
<SelectItem value="45">45 Min.</SelectItem>
<SelectItem value="60">60 Min.</SelectItem>
<SelectItem value="90">90 Min.</SelectItem>
</SelectContent>
</Select>
</div>
<Button onClick={handleGenerate} disabled={status === 'generating'} className="w-full bg-gradient-to-r from-emerald-500 to-teal-500 hover:opacity-90">
{status === 'generating' ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" /> Generiere...</> : <><Sparkles className="h-4 w-4 mr-2" /> Entwurf erstellen</>}
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Ergebnis</CardTitle>
</CardHeader>
<CardContent>
{status === 'idle' && (
<div className="flex flex-col items-center justify-center py-16 text-center">
<BookOpen className="h-12 w-12 text-muted-foreground/30 mb-3" />
<p className="text-sm text-muted-foreground">Generiere einen Unterrichtsentwurf</p>
</div>
)}
{status === 'generating' && (
<div className="flex flex-col items-center justify-center py-16 text-center">
<Loader2 className="h-12 w-12 text-emerald-500 animate-spin mb-4" />
<p className="text-sm font-medium">Entwurf wird erstellt...</p>
</div>
)}
{status === 'success' && (
<div className="space-y-3">
<Textarea value={result} readOnly rows={20} className="font-mono text-sm" />
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={() => { navigator.clipboard.writeText(result); toast.success('Kopiert!'); }}>
<Copy className="h-4 w-4 mr-1" /> Kopieren
</Button>
<Button size="sm" variant="outline" onClick={() => { setStatus('idle'); setResult(''); }}>
<RefreshCw className="h-4 w-4 mr-1" /> Neu
</Button>
</div>
</div>
)}
</CardContent>
</Card>
</div>
);
}
// ─── Agent Chat Tab ───────────────────────────────────────────────
interface ChatMsg {
role: 'user' | 'assistant';
content: string;
timestamp: Date;
}
function AgentChatTab() {
const [messages, setMessages] = useState<ChatMsg[]>([
{ role: 'assistant', content: 'Hallo! Ich bin dein KI-Assistent für SchulOS. Ich kann dir helfen bei:\n\n- Unterrichtsvorbereitung\n- Bewertungsvorschlägen\n- Kompetenzanalyse\n- Ideen für Differenzierung\n\nWie kann ich dir heute helfen?', timestamp: new Date() },
]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
scrollRef.current?.scrollTo(0, scrollRef.current.scrollHeight);
}, [messages]);
const handleSend = useCallback(async () => {
if (!input.trim() || loading) return;
const userMsg: ChatMsg = { role: 'user', content: input, timestamp: new Date() };
setMessages(prev => [...prev, userMsg]);
setInput('');
setLoading(true);
try {
const res = await fetch('/api/ai', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'chat',
message: input,
context: 'teacher-assistant',
language: 'de',
}),
});
if (!res.ok) throw new Error('KI-Anfrage fehlgeschlagen');
const data = await res.json();
const aiMsg: ChatMsg = {
role: 'assistant',
content: data.response || data.content || 'Entschuldige, ich konnte keine Antwort generieren.',
timestamp: new Date(),
};
setMessages(prev => [...prev, aiMsg]);
} catch (error) {
setMessages(prev => [...prev, {
role: 'assistant',
content: 'Entschuldige, es ist ein Fehler aufgetreten. Bitte versuche es erneut.',
timestamp: new Date(),
}]);
} finally {
setLoading(false);
}
}, [input, loading]);
const suggestions = [
'Hilf mir bei der Vorbereitung einer Mathestunde zum Thema Brüche',
'Welche Kompetenzen sollte ich in Klasse 3 Deutsch abdecken?',
'Erstelle 5 Differenzierungsideen für eine gemischte Lerngruppe',
'Bewerte diesen Schülertext: "Der Hund lief schnell"',
];
return (
<Card className="h-[600px] flex flex-col">
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<Lightbulb className="h-4 w-4 text-amber-500" />
KI-Agent (Agentic Mode)
<Badge variant="outline" className="text-xs ml-auto">BETA</Badge>
</CardTitle>
</CardHeader>
<CardContent className="flex-1 flex flex-col p-4 gap-3">
{/* Messages */}
<div ref={scrollRef} className="flex-1 overflow-y-auto space-y-3 pr-1">
{messages.map((msg, i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className={cn('flex gap-2', msg.role === 'user' ? 'justify-end' : 'justify-start')}
>
{msg.role === 'assistant' && (
<div className="h-8 w-8 rounded-full bg-gradient-to-br from-amber-400 to-emerald-500 flex items-center justify-center shrink-0">
<Sparkles className="h-4 w-4 text-white" />
</div>
)}
<div className={cn(
'max-w-[75%] rounded-2xl px-4 py-2.5 text-sm',
msg.role === 'user'
? 'bg-emerald-500 text-white rounded-br-sm'
: 'bg-muted rounded-bl-sm'
)}>
<p className="whitespace-pre-wrap">{msg.content}</p>
</div>
</motion.div>
))}
{loading && (
<div className="flex gap-2">
<div className="h-8 w-8 rounded-full bg-gradient-to-br from-amber-400 to-emerald-500 flex items-center justify-center shrink-0">
<Sparkles className="h-4 w-4 text-white" />
</div>
<div className="bg-muted rounded-2xl rounded-bl-sm px-4 py-3">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
</div>
)}
</div>
{/* Suggestions */}
{messages.length <= 1 && (
<div className="flex flex-wrap gap-1.5">
{suggestions.map((s, i) => (
<button
key={i}
onClick={() => setInput(s)}
className="text-xs rounded-full bg-muted hover:bg-muted/70 px-2.5 py-1 transition-colors"
>
{s}
</button>
))}
</div>
)}
{/* Input */}
<div className="flex gap-2">
<Input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
placeholder="Frage den KI-Agenten..."
disabled={loading}
/>
<Button onClick={handleSend} disabled={loading || !input.trim()} size="icon">
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
</Button>
</div>
</CardContent>
</Card>
);
}
+5
View File
@@ -177,6 +177,7 @@ import SubjectsView from './subjects-view';
import CounselingView from './counseling-view';
import DisciplinaryView from './disciplinary-view';
import AITestsView from './ai-tests-view';
import AIStudioView from './ai-studio-view';
import AIChatWidget from './ai-chat-widget';
import VirtualCharacter from './virtual-character';
import ParentPortalView from './parent-portal-view';
@@ -285,6 +286,8 @@ const navSections: NavSection[] = [
{ key: 'grade-analytics', icon: BarChart3, labelKey: 'nav.grade-analytics' },
{ key: 'reports', icon: FileText, labelKey: 'nav.reports' },
{ key: 'ai-tests', icon: Brain, labelKey: 'ai_tests.title' },
{ key: 'ai-studio', icon: Sparkles, labelKey: 'nav.ai-studio' },
{ key: 'ai-studio', icon: Sparkles, labelKey: 'nav.ai-studio' },
{ key: 'data-import-export', icon: Database, labelKey: 'nav.data-import-export' },
],
},
@@ -321,6 +324,7 @@ const studentNavSections: NavSection[] = [
{ key: 'communication', icon: MessageSquare, labelKey: 'nav.communication' },
{ key: 'counseling', icon: Heart, labelKey: 'counseling.title' },
{ key: 'ai-tests', icon: Brain, labelKey: 'ai_tests.title' },
{ key: 'ai-studio', icon: Sparkles, labelKey: 'nav.ai-studio' },
{ key: 'tablet-grading', icon: Tablet, labelKey: 'nav.tablet_grading' },
{ key: 'exam-calendar', icon: CalendarClock, labelKey: 'nav.exam_calendar' },
{ key: 'peer-assessment', icon: UsersRound, labelKey: 'nav.peer-assessment' },
@@ -509,6 +513,7 @@ function renderView(view: ViewName) {
case 'counseling': return <CounselingView />;
case 'disciplinary': return <DisciplinaryView />;
case 'ai-tests': return <AITestsView />;
case 'ai-studio': return <AIStudioView />;
case 'parent-portal': return <ParentPortalView />;
case 'notification-center': return <NotificationCenterView />;
case 'announcements': return <SchoolAnnouncementsView />;
+1 -1
View File
@@ -240,7 +240,7 @@ export default function OnboardingTour({ open, onClose }: OnboardingTourProps) {
return (
<Dialog open={open} onOpenChange={(isOpen) => { if (!isOpen) handleSkip(); }}>
<DialogContent className="max-w-lg p-0 gap-0 overflow-hidden rounded-2xl border-emerald-200/60 dark:border-emerald-900/40 bg-white dark:bg-gray-950 shadow-2xl">
<DialogContent showCloseButton={false} className="max-w-lg p-0 gap-0 overflow-hidden rounded-2xl border-emerald-200/60 dark:border-emerald-900/40 bg-white dark:bg-gray-950 shadow-2xl">
{/* Hidden title for accessibility */}
<DialogHeader className="sr-only">
<VisuallyHidden>
+2
View File
@@ -818,6 +818,7 @@ const de: TranslationDict = {
'polish.nav_school_life': 'Schulleben',
'polish.nav_insights': 'Erkenntnisse',
'polish.nav_administration': 'Verwaltung',
'nav.ai-studio': 'KI-Studio',
'polish.nav_setup': 'Einrichtung',
'polish.nav_teaching': 'Unterricht',
'polish.nav_analysis': 'Analyse',
@@ -7472,6 +7473,7 @@ const en: TranslationDict = {
'polish.nav_school_life': 'School Life',
'polish.nav_insights': 'Insights',
'polish.nav_administration': 'Administration',
'nav.ai-studio': 'AI Studio',
'polish.nav_setup': 'Setup',
'polish.nav_teaching': 'Teaching',
'polish.nav_analysis': 'Analysis',
+2 -1
View File
@@ -56,7 +56,8 @@ export type ViewName =
| 'school-newsletter'
| 'school-transport'
| 'peer-assessment'
| 'grade-analytics';
| 'grade-analytics'
| 'ai-studio';
export interface CurrentUser {
id: string;