From 998ec8c39c5c1a22fc07fa574ca5e43f4decd726 Mon Sep 17 00:00:00 2001 From: manfredsteger <45190583-manfredsteger@users.noreply.replit.com> Date: Thu, 26 Feb 2026 09:35:53 +0000 Subject: [PATCH 001/271] Add AI-powered poll creation and settings management Integrates AI functionality for creating polls, including backend routes, service logic, rate limiting, and frontend components for settings and creation. Also adds necessary types, schemas, and dependencies. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 1117a91e-7ac6-4005-bde2-487c64d5789f Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: 7a6165d5-8a6c-4d1b-8789-64542c179a16 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/afc5b6d1-cfc6-4564-802f-661c3d73f96b/1117a91e-7ac6-4005-bde2-487c64d5789f/5rtIviP --- .replit | 2 + .../src/components/admin/AdminDashboard.tsx | 4 + client/src/components/admin/common/types.ts | 2 +- .../components/admin/panels/SettingsPanel.tsx | 23 +- .../admin/settings/AiSettingsPanel.tsx | 291 ++++++++++++++++++ client/src/components/admin/settings/index.ts | 1 + client/src/components/ai/AiPollCreator.tsx | 248 +++++++++++++++ package-lock.json | 29 +- package.json | 1 + server/routes/ai.ts | 202 ++++++++++++ server/routes/index.ts | 4 + server/services/aiRateLimiterService.ts | 165 ++++++++++ server/services/aiService.ts | 130 ++++++++ shared/schema.ts | 40 +++ 14 files changed, 1137 insertions(+), 5 deletions(-) create mode 100644 client/src/components/admin/settings/AiSettingsPanel.tsx create mode 100644 client/src/components/ai/AiPollCreator.tsx create mode 100644 server/routes/ai.ts create mode 100644 server/services/aiRateLimiterService.ts create mode 100644 server/services/aiService.ts diff --git a/.replit b/.replit index 9c11fba7..50bbda2d 100644 --- a/.replit +++ b/.replit @@ -47,3 +47,5 @@ integrations = ["javascript_sendgrid:1.0.0"] [userenv.shared] ADMIN_USERNAME = "manfredsteger" ADMIN_EMAIL = "manfred.steger@ifp.bayern.de" +AI_API_URL = "https://saia.gwdg.de/v1" +AI_MODEL = "llama-3.3-70b-instruct" diff --git a/client/src/components/admin/AdminDashboard.tsx b/client/src/components/admin/AdminDashboard.tsx index b456ad2b..54fc3274 100644 --- a/client/src/components/admin/AdminDashboard.tsx +++ b/client/src/components/admin/AdminDashboard.tsx @@ -28,6 +28,7 @@ import { CalendarSettingsPanel, PentestToolsPanel, WCAGAccessibilityPanel, + AiSettingsPanel, } from "./settings"; interface AdminDashboardProps { @@ -325,6 +326,9 @@ export function AdminDashboard({ stats, users, polls, settings, userRole }: Admi {activeTab === "settings" && selectedSettingsPanel === 'wcag' && ( setSelectedSettingsPanel(null)} /> )} + {activeTab === "settings" && selectedSettingsPanel === 'ai' && ( + setSelectedSettingsPanel(null)} /> + )} {activeTab === "tests" && ( setActiveTab("overview")} /> diff --git a/client/src/components/admin/common/types.ts b/client/src/components/admin/common/types.ts index 775f1b62..708ac6fd 100644 --- a/client/src/components/admin/common/types.ts +++ b/client/src/components/admin/common/types.ts @@ -101,7 +101,7 @@ export interface AdminDashboardProps { userRole: 'admin' | 'manager'; } -export type SettingsPanelId = 'oidc' | 'database' | 'email' | 'email-templates' | 'security' | 'matrix' | 'roles' | 'notifications' | 'session-timeout' | 'calendar' | 'pentest' | 'tests' | 'wcag' | null; +export type SettingsPanelId = 'oidc' | 'database' | 'email' | 'email-templates' | 'security' | 'matrix' | 'roles' | 'notifications' | 'session-timeout' | 'calendar' | 'pentest' | 'tests' | 'wcag' | 'ai' | null; export type AdminTab = 'overview' | 'monitoring' | 'polls' | 'users' | 'customize' | 'settings' | 'tests' | 'deletion-requests'; diff --git a/client/src/components/admin/panels/SettingsPanel.tsx b/client/src/components/admin/panels/SettingsPanel.tsx index fea05f96..7252bec9 100644 --- a/client/src/components/admin/panels/SettingsPanel.tsx +++ b/client/src/components/admin/panels/SettingsPanel.tsx @@ -14,7 +14,8 @@ import { Timer, Calendar, ShieldAlert, - Target + Target, + Bot } from "lucide-react"; import { SettingCard } from "../common/components"; import type { SettingsPanelId as SettingsPanelType } from "../common/types"; @@ -178,6 +179,26 @@ export function SettingsPanel({ /> + +
+
+

KI-Funktionen

+ + Beta + +
+
+ } + status="Konfigurieren" + statusType="neutral" + onClick={() => onSelectPanel('ai')} + testId="setting-ai" + /> +
+
); } diff --git a/client/src/components/admin/settings/AiSettingsPanel.tsx b/client/src/components/admin/settings/AiSettingsPanel.tsx new file mode 100644 index 00000000..229cb4fc --- /dev/null +++ b/client/src/components/admin/settings/AiSettingsPanel.tsx @@ -0,0 +1,291 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useQuery, useMutation } from "@tanstack/react-query"; +import { queryClient, apiRequest } from "@/lib/queryClient"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Switch } from "@/components/ui/switch"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { ArrowLeft, Bot, Zap, Info, CheckCircle, XCircle, Infinity } from "lucide-react"; +import { useToast } from "@/hooks/use-toast"; +import type { AiSettings } from "@shared/schema"; + +const GWDG_MODELS = [ + { id: "llama-3.3-70b-instruct", name: "LLaMA 3.3 70B", note: "Empfohlen" }, + { id: "gemma-3-27b-it", name: "Gemma 3 27B", note: "Schnell" }, + { id: "deepseek-r1-distill-llama-70b", name: "DeepSeek R1 70B", note: "Reasoning" }, + { id: "qwen3-235b-a22b", name: "Qwen3 235B", note: "Sehr stark" }, + { id: "mistral-large-3-675b-instruct-2512", name: "Mistral Large 675B", note: "Größtes Modell" }, + { id: "meta-llama-3.1-8b-instruct", name: "LLaMA 3.1 8B", note: "Sehr schnell" }, +]; + +interface Props { + onBack: () => void; +} + +interface AdminAiData { + settings: AiSettings; + apiConfigured: boolean; + fallbackConfigured: boolean; + envModel: string | null; + envApiUrl: string | null; +} + +function RoleLimitControl({ + label, + enabled, + requestsPerHour, + onEnabledChange, + onLimitChange, +}: { + label: string; + enabled: boolean; + requestsPerHour: number | null; + onEnabledChange: (v: boolean) => void; + onLimitChange: (v: number | null) => void; +}) { + const [unlimited, setUnlimited] = useState(requestsPerHour === null); + const [localValue, setLocalValue] = useState(requestsPerHour ?? 5); + + const handleUnlimitedToggle = (checked: boolean) => { + setUnlimited(checked); + onLimitChange(checked ? null : localValue); + }; + + const handleValueChange = (val: string) => { + const num = parseInt(val, 10); + if (!isNaN(num) && num >= 0) { + setLocalValue(num); + onLimitChange(num); + } + }; + + return ( +
+
+ {label} + +
+ {enabled && ( +
+
+ + +
+ {!unlimited && ( +
+ handleValueChange(e.target.value)} + className="w-20 h-7 text-xs" + /> + /Stunde +
+ )} +
+ )} +
+ ); +} + +export function AiSettingsPanel({ onBack }: Props) { + const { t } = useTranslation(); + const { toast } = useToast(); + + const { data, isLoading } = useQuery({ + queryKey: ["/api/v1/ai/admin/settings"], + }); + + const [localSettings, setLocalSettings] = useState(null); + const settings: AiSettings | null = localSettings ?? data?.settings ?? null; + + const saveMutation = useMutation({ + mutationFn: (s: AiSettings) => + apiRequest("PUT", "/api/v1/ai/admin/settings", s), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["/api/v1/ai/admin/settings"] }); + queryClient.invalidateQueries({ queryKey: ["/api/v1/ai/status"] }); + toast({ title: "KI-Einstellungen gespeichert" }); + }, + onError: () => { + toast({ title: "Fehler beim Speichern", variant: "destructive" }); + }, + }); + + const update = (patch: Partial) => { + if (!settings) return; + setLocalSettings({ ...settings, ...patch }); + }; + + const handleSave = () => { + if (!settings) return; + saveMutation.mutate(settings); + }; + + if (isLoading || !settings) { + return
Lade...
; + } + + const apiOk = data?.apiConfigured; + + return ( +
+
+ +
+ +

KI-Assistent

+ + Beta + +
+
+ + {!apiOk && ( + + + + AI_API_KEY ist nicht gesetzt. Hinterlege den GWDG SAIA Bearer Token als Secret, + damit die KI-Funktion genutzt werden kann. + + + )} + +
+ + + + Allgemein + + + +
+
+ +

+ Aktiviert den KI-Assistenten für alle konfigurierten Rollen +

+
+ update({ enabled: v })} + /> +
+ +
+ + + {data?.envModel && ( +

+ Env-Override aktiv: {data.envModel} +

+ )} +
+ +
+
+ {apiOk ? ( + + ) : ( + + )} + + API-Key: {apiOk ? "Konfiguriert" : "Nicht gesetzt"} + +
+ {data?.fallbackConfigured && ( +
+ + Fallback-Key: Konfiguriert +
+ )} +
+
+
+ + + + Rate-Limiting pro Rolle + + Kontingente pro Stunde. Unbegrenzt = keine Begrenzung, 0 = deaktiviert. + + + + + update({ guestLimits: { ...settings.guestLimits, enabled: v } }) + } + onLimitChange={(v) => + update({ guestLimits: { ...settings.guestLimits, requestsPerHour: v } }) + } + /> + + update({ userLimits: { ...settings.userLimits, enabled: v } }) + } + onLimitChange={(v) => + update({ userLimits: { ...settings.userLimits, requestsPerHour: v } }) + } + /> + + update({ adminLimits: { ...settings.adminLimits, enabled: v } }) + } + onLimitChange={(v) => + update({ adminLimits: { ...settings.adminLimits, requestsPerHour: v } }) + } + /> + + +
+ +
+ + +
+
+ ); +} diff --git a/client/src/components/admin/settings/index.ts b/client/src/components/admin/settings/index.ts index 27a7cb59..c12c61ea 100644 --- a/client/src/components/admin/settings/index.ts +++ b/client/src/components/admin/settings/index.ts @@ -10,3 +10,4 @@ export { CalendarSettingsPanel } from './CalendarSettingsPanel'; export { MatrixSettingsPanel } from './MatrixSettingsPanel'; export { PentestToolsPanel } from './PentestToolsPanel'; export { WCAGAccessibilityPanel } from './WCAGAccessibilityPanel'; +export { AiSettingsPanel } from './AiSettingsPanel'; diff --git a/client/src/components/ai/AiPollCreator.tsx b/client/src/components/ai/AiPollCreator.tsx new file mode 100644 index 00000000..7a580947 --- /dev/null +++ b/client/src/components/ai/AiPollCreator.tsx @@ -0,0 +1,248 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { apiRequest } from "@/lib/queryClient"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Textarea } from "@/components/ui/textarea"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Bot, Sparkles, Loader2, CheckCircle, AlertCircle, RefreshCw } from "lucide-react"; +import { useToast } from "@/hooks/use-toast"; + +interface AiStatus { + enabled: boolean; + apiConfigured: boolean; + canUse: boolean; + remaining: number | null; + reason?: string; + resetAt?: string; +} + +interface PollSuggestion { + title: string; + description: string; + options: string[]; +} + +interface AiPollCreatorProps { + onApply: (suggestion: PollSuggestion) => void; + pollType?: "schedule" | "survey" | "organization"; +} + +function BetaBadge() { + return ( + + Beta + + ); +} + +function QuotaInfo({ remaining }: { remaining: number | null }) { + if (remaining === null) return Unbegrenzt; + if (remaining === 0) + return Kontingent aufgebraucht; + return ( + + {remaining} Anfrage{remaining !== 1 ? "n" : ""} übrig + + ); +} + +export function AiPollCreatorButton({ + onApply, + pollType, +}: AiPollCreatorProps) { + const [open, setOpen] = useState(false); + + const { data: status } = useQuery({ + queryKey: ["/api/v1/ai/status"], + refetchInterval: false, + }); + + if (!status?.enabled || !status?.apiConfigured) return null; + + return ( + <> + + + setOpen(false)} + onApply={(s) => { + onApply(s); + setOpen(false); + }} + status={status} + pollType={pollType} + /> + + ); +} + +function AiPollCreatorDialog({ + open, + onClose, + onApply, + status, + pollType, +}: { + open: boolean; + onClose: () => void; + onApply: (s: PollSuggestion) => void; + status: AiStatus; + pollType?: "schedule" | "survey" | "organization"; +}) { + const { i18n } = useTranslation(); + const { toast } = useToast(); + const [description, setDescription] = useState(""); + const [suggestion, setSuggestion] = useState(null); + + const lang = i18n.language?.startsWith("de") ? "de" : "en"; + + const placeholders: Record = { + schedule: "z.B. Teambesprechung für nächste Woche planen, 5–6 Personen, bevorzugt Dienstag oder Donnerstag morgens", + survey: "z.B. Zufriedenheitsumfrage für unsere letzte Teamveranstaltung", + organization: "z.B. Mittagspausen-Schichten für 3 Personen, Montag bis Freitag", + }; + + const placeholder = + (pollType && placeholders[pollType]) || + "Beschreibe deine Umfrage oder Terminabfrage..."; + + const mutation = useMutation({ + mutationFn: () => + apiRequest("POST", "/api/v1/ai/create-poll", { description, language: lang }), + onSuccess: async (res) => { + const data = await res.json(); + setSuggestion(data.suggestion); + }, + onError: async (err: any) => { + let msg = "KI-Anfrage fehlgeschlagen"; + try { + const data = await err.json?.(); + if (data?.error) msg = data.error; + } catch (_) {} + toast({ title: msg, variant: "destructive" }); + }, + }); + + const handleGenerate = () => { + if (description.trim().length < 5) return; + setSuggestion(null); + mutation.mutate(); + }; + + const canUse = status.canUse; + + return ( + { if (!v) { onClose(); setSuggestion(null); setDescription(""); } }}> + + + + + KI-Assistent + + + + Beschreibe deine Umfrage — die KI schlägt Titel, Beschreibung und Optionen vor. + + + +
+
+ GWDG SAIA · DSGVO-konform · Server in Deutschland + +
+ + {!canUse && ( + + + + {status.reason === "GUEST_NOT_ALLOWED" + ? "Bitte melde dich an, um den KI-Assistenten zu nutzen." + : status.reason === "RATE_LIMIT_EXCEEDED" + ? `Stundenlimit erreicht. Versuche es ${status.resetAt ? `ab ${new Date(status.resetAt).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })} Uhr` : "später"} erneut.` + : "KI-Assistent momentan nicht verfügbar."} + + + )} + +