Services: Admin-Lehrer, Backend-Lehrer, Studio v2, Website, Klausur-Service, School-Service, Voice-Service, Geo-Service, BreakPilot Drive, Agent-Core Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
253 lines
10 KiB
TypeScript
253 lines
10 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect } from 'react'
|
|
|
|
interface AIStatus {
|
|
provider: string
|
|
model: string
|
|
is_available: boolean
|
|
is_mock: boolean
|
|
error?: string | null
|
|
}
|
|
|
|
interface LLMProviderToggleProps {
|
|
aiStatus: AIStatus | null
|
|
onStatusChange?: () => void
|
|
}
|
|
|
|
/**
|
|
* LLM Provider Toggle Component
|
|
*
|
|
* Allows developers to switch between:
|
|
* - Anthropic Claude API (Cloud, kostenpflichtig)
|
|
* - Self-Hosted Ollama (Lokal auf Mac Mini, kostenlos, DSGVO-konform)
|
|
*/
|
|
export default function LLMProviderToggle({ aiStatus, onStatusChange }: LLMProviderToggleProps) {
|
|
const [switching, setSwitching] = useState(false)
|
|
const [showDetails, setShowDetails] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'
|
|
|
|
const isAnthropicActive = aiStatus?.provider === 'anthropic'
|
|
const isSelfHostedActive = aiStatus?.provider === 'self_hosted'
|
|
const isMockActive = aiStatus?.provider === 'mock'
|
|
|
|
const switchProvider = async (newProvider: 'anthropic' | 'self_hosted') => {
|
|
setSwitching(true)
|
|
setError(null)
|
|
|
|
try {
|
|
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/ai/switch-provider`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ provider: newProvider }),
|
|
})
|
|
|
|
if (res.ok) {
|
|
const result = await res.json()
|
|
console.log('Provider switched:', result)
|
|
onStatusChange?.()
|
|
} else {
|
|
const errorText = await res.text()
|
|
setError(`Fehler: ${errorText}`)
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to switch provider:', err)
|
|
setError('Verbindung zum Backend fehlgeschlagen')
|
|
} finally {
|
|
setSwitching(false)
|
|
}
|
|
}
|
|
|
|
// Determine the badge color based on provider
|
|
const getBadgeColor = () => {
|
|
if (isMockActive) return 'bg-gray-100 text-gray-700 border-gray-300'
|
|
if (isSelfHostedActive) return 'bg-green-100 text-green-700 border-green-300'
|
|
if (isAnthropicActive) return 'bg-purple-100 text-purple-700 border-purple-300'
|
|
return 'bg-gray-100 text-gray-600 border-gray-200'
|
|
}
|
|
|
|
const getProviderIcon = () => {
|
|
if (isSelfHostedActive) {
|
|
// Local/Server icon
|
|
return (
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
|
</svg>
|
|
)
|
|
}
|
|
if (isAnthropicActive) {
|
|
// Cloud icon
|
|
return (
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z" />
|
|
</svg>
|
|
)
|
|
}
|
|
// Mock/Unknown icon
|
|
return (
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="relative">
|
|
{/* Toggle Button */}
|
|
<button
|
|
onClick={() => setShowDetails(!showDetails)}
|
|
disabled={switching}
|
|
className={`
|
|
inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium rounded-lg border transition-all
|
|
${getBadgeColor()}
|
|
hover:shadow-md cursor-pointer
|
|
${switching ? 'opacity-50' : ''}
|
|
`}
|
|
>
|
|
{getProviderIcon()}
|
|
<span>
|
|
{isSelfHostedActive && 'Lokal (DSGVO)'}
|
|
{isAnthropicActive && 'Anthropic API'}
|
|
{isMockActive && 'Mock (Test)'}
|
|
{!aiStatus && 'Laden...'}
|
|
</span>
|
|
<svg
|
|
className={`w-4 h-4 transition-transform ${showDetails ? 'rotate-180' : ''}`}
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
</button>
|
|
|
|
{/* Dropdown Panel */}
|
|
{showDetails && (
|
|
<div className="absolute right-0 mt-2 w-96 bg-white rounded-xl shadow-xl border border-slate-200 z-50 overflow-hidden">
|
|
{/* Header */}
|
|
<div className="px-4 py-3 bg-slate-50 border-b border-slate-200">
|
|
<h3 className="font-semibold text-slate-800">KI-Provider Einstellungen</h3>
|
|
<p className="text-xs text-slate-500 mt-1">
|
|
Wechseln Sie zwischen Cloud-API und lokalem Modell
|
|
</p>
|
|
</div>
|
|
|
|
{/* Current Status */}
|
|
<div className="px-4 py-3 border-b border-slate-100">
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<span className="text-slate-500">Aktuell:</span>
|
|
<span className="font-medium text-slate-800">{aiStatus?.provider || 'Unbekannt'}</span>
|
|
<span className="text-slate-400">|</span>
|
|
<span className="text-slate-600 text-xs">{aiStatus?.model || 'Kein Modell'}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Provider Options */}
|
|
<div className="p-3 space-y-2">
|
|
{/* Self-Hosted Option */}
|
|
<button
|
|
onClick={() => switchProvider('self_hosted')}
|
|
disabled={switching || isSelfHostedActive}
|
|
className={`
|
|
w-full p-3 rounded-lg border text-left transition-all
|
|
${isSelfHostedActive
|
|
? 'bg-green-50 border-green-300 ring-2 ring-green-200'
|
|
: 'bg-white border-slate-200 hover:border-green-300 hover:bg-green-50'
|
|
}
|
|
${switching ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
|
|
`}
|
|
>
|
|
<div className="flex items-start gap-3">
|
|
<div className={`p-2 rounded-lg ${isSelfHostedActive ? 'bg-green-200' : 'bg-slate-100'}`}>
|
|
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2" />
|
|
</svg>
|
|
</div>
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-semibold text-slate-800">Self-Hosted (Ollama)</span>
|
|
<span className="px-2 py-0.5 text-xs bg-green-100 text-green-700 rounded-full">DSGVO</span>
|
|
{isSelfHostedActive && (
|
|
<span className="px-2 py-0.5 text-xs bg-green-500 text-white rounded-full">Aktiv</span>
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-slate-500 mt-1">
|
|
Lokales LLM auf dem Mac Mini M4 Pro (64GB RAM).
|
|
</p>
|
|
<div className="flex items-center gap-4 mt-2 text-xs">
|
|
<span className="text-green-600 font-medium">Kostenlos</span>
|
|
<span className="text-green-600">Daten bleiben intern</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
|
|
{/* Anthropic Option */}
|
|
<button
|
|
onClick={() => switchProvider('anthropic')}
|
|
disabled={switching || isAnthropicActive}
|
|
className={`
|
|
w-full p-3 rounded-lg border text-left transition-all
|
|
${isAnthropicActive
|
|
? 'bg-purple-50 border-purple-300 ring-2 ring-purple-200'
|
|
: 'bg-white border-slate-200 hover:border-purple-300 hover:bg-purple-50'
|
|
}
|
|
${switching ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
|
|
`}
|
|
>
|
|
<div className="flex items-start gap-3">
|
|
<div className={`p-2 rounded-lg ${isAnthropicActive ? 'bg-purple-200' : 'bg-slate-100'}`}>
|
|
<svg className="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z" />
|
|
</svg>
|
|
</div>
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-semibold text-slate-800">Anthropic Claude API</span>
|
|
<span className="px-2 py-0.5 text-xs bg-yellow-100 text-yellow-700 rounded-full">Cloud</span>
|
|
{isAnthropicActive && (
|
|
<span className="px-2 py-0.5 text-xs bg-purple-500 text-white rounded-full">Aktiv</span>
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-slate-500 mt-1">
|
|
Cloud-basierte KI von Anthropic (claude-sonnet-4).
|
|
</p>
|
|
<div className="flex items-center gap-4 mt-2 text-xs">
|
|
<span className="text-yellow-600 font-medium">Kostenpflichtig</span>
|
|
<span className="text-yellow-600">Daten gehen zu Anthropic</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Error Message */}
|
|
{error && (
|
|
<div className="px-4 py-2 bg-red-50 border-t border-red-200">
|
|
<p className="text-sm text-red-600">{error}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Footer Info */}
|
|
<div className="px-4 py-3 bg-slate-50 border-t border-slate-200">
|
|
<p className="text-xs text-slate-500">
|
|
<strong>Hinweis:</strong> Die Umschaltung gilt nur fuer diese Session.
|
|
Fuer permanente Aenderungen docker-compose.yml anpassen.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Click outside to close */}
|
|
{showDetails && (
|
|
<div
|
|
className="fixed inset-0 z-40"
|
|
onClick={() => setShowDetails(false)}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|