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:
1 parent
c078ec36aa
commit
763ebef22b
8 files changed
+1278
-2
No files matched your search
@@ -2430,6 +2430,12 @@ model AISettings {
|
||||
aiMaxRequestsPerDay Int @default(50) // Student daily limit
|
||||
aiHelperMode String @default("guided") // guided, full, restricted
|
||||
aiSystemPrompt String? // Custom system prompt for AI chat
|
||||
// SnapGen AI (Video generation)
|
||||
snapgenEnabled Boolean @default(false)
|
||||
snapgenApiKey String?
|
||||
// Perchance AI (Image generation)
|
||||
perchanceEnabled Boolean @default(false)
|
||||
perchanceApiKey String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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}.`;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -56,7 +56,8 @@ export type ViewName =
|
||||
| 'school-newsletter'
|
||||
| 'school-transport'
|
||||
| 'peer-assessment'
|
||||
| 'grade-analytics';
|
||||
| 'grade-analytics'
|
||||
| 'ai-studio';
|
||||
|
||||
export interface CurrentUser {
|
||||
id: string;
|
||||
|
||||
Reference in new issue
Block a user