frontend_nyla/src/components/ProviderSelector/ProviderSelector.tsx

304 lines
9.3 KiB
TypeScript

/**
* ProviderSelector Component
*
* Wiederverwendbare Komponente zur Auswahl von AICore-Providern.
* Kann im Chat Playground und Automation Editor verwendet werden.
*
* Features:
* - Dropdown für Einzelauswahl
* - Checkbox-Liste für Mehrfachauswahl
* - Lädt verfügbare Provider aus dem Billing-System
*/
import React, { useEffect, useMemo, useState, useRef, useCallback } from 'react';
import { useBilling } from '../../hooks/useBilling';
import styles from './ProviderSelector.module.css';
// Provider display names
const PROVIDER_LABELS: Record<string, string> = {
anthropic: 'Anthropic (Claude)',
openai: 'OpenAI (GPT)',
mistral: 'Mistral (Le Chat)',
perplexity: 'Perplexity',
tavily: 'Tavily (Web Search)',
privatellm: 'Private LLM',
internal: 'Internal',
};
// Provider icons (emojis for simplicity)
const PROVIDER_ICONS: Record<string, string> = {
anthropic: '🤖',
openai: '💬',
mistral: '🇫🇷',
perplexity: '🔍',
tavily: '🌐',
privatellm: '🔒',
internal: '🏠',
};
// ============================================================================
// SINGLE SELECT COMPONENT
// ============================================================================
interface ProviderSelectProps {
value: string;
onChange: (provider: string) => void;
disabled?: boolean;
className?: string;
label?: string;
showLabel?: boolean;
}
export const ProviderSelect: React.FC<ProviderSelectProps> = ({
value,
onChange,
disabled = false,
className,
label = 'AI-Provider',
showLabel = true,
}) => {
const { allowedProviders, loadAllowedProviders, loading } = useBilling();
useEffect(() => {
if (allowedProviders.length === 0 && !loading) {
loadAllowedProviders();
}
}, []);
const providerOptions = useMemo(() => {
return allowedProviders.map((provider) => ({
value: provider,
label: `${PROVIDER_ICONS[provider] || '🔌'} ${PROVIDER_LABELS[provider] || provider}`,
}));
}, [allowedProviders]);
return (
<div className={`${styles.providerSelect} ${className || ''}`}>
{showLabel && <label className={styles.label}>{label}</label>}
<select
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled || loading}
className={styles.select}
>
<option value="">-- Auto --</option>
{providerOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
);
};
// ============================================================================
// MULTI SELECT COMPONENT (Checkbox List)
// ============================================================================
interface ProviderMultiSelectProps {
selectedProviders: string[];
onChange: (providers: string[]) => void;
disabled?: boolean;
className?: string;
label?: string;
showLabel?: boolean;
defaultExpanded?: boolean;
excludeByDefault?: string[];
}
export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
selectedProviders,
onChange,
disabled = false,
className,
label = 'AI-Provider',
showLabel = true,
defaultExpanded = false,
excludeByDefault = [],
}) => {
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
const [initialExcludeApplied, setInitialExcludeApplied] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const { allowedProviders, loadAllowedProviders, loading } = useBilling();
useEffect(() => {
if (allowedProviders.length === 0 && !loading) {
loadAllowedProviders();
}
}, []);
// Apply default exclusions when providers first load
useEffect(() => {
if (
!initialExcludeApplied &&
allowedProviders.length > 0 &&
excludeByDefault.length > 0 &&
selectedProviders.length === 0
) {
const initialSelection = allowedProviders.filter(
(p) => !excludeByDefault.includes(p)
);
// Only apply if there's actually something to exclude
if (initialSelection.length < allowedProviders.length) {
onChange(initialSelection);
}
setInitialExcludeApplied(true);
}
}, [allowedProviders, excludeByDefault, initialExcludeApplied, selectedProviders.length, onChange]);
// Click outside handler
const handleClickOutside = useCallback((event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsExpanded(false);
}
}, []);
useEffect(() => {
if (isExpanded) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [isExpanded, handleClickOutside]);
// Effective selection: empty array = all providers active (no restriction)
const effectiveSelection = selectedProviders.length === 0 ? allowedProviders : selectedProviders;
// "Alle" is active when no restriction is set (empty array) OR all explicitly selected
const isAllSelected = selectedProviders.length === 0 ||
(allowedProviders.length > 0 && selectedProviders.length === allowedProviders.length);
const handleToggle = (provider: string) => {
if (selectedProviders.length === 0) {
// Currently "all active" (no restriction) -> make explicit: all except the toggled one
onChange(allowedProviders.filter((p) => p !== provider));
} else if (selectedProviders.includes(provider)) {
// Deactivate: remove from selection
const remaining = selectedProviders.filter((p) => p !== provider);
// If removing leaves all others selected, reset to [] (= all, no restriction)
if (remaining.length === allowedProviders.length) {
onChange([]);
} else {
onChange(remaining);
}
} else {
// Activate: add to selection
const updated = [...selectedProviders, provider];
// If all are now selected, reset to [] (= all, no restriction)
if (updated.length === allowedProviders.length) {
onChange([]);
} else {
onChange(updated);
}
}
};
const handleSelectAll = () => {
onChange([]); // Empty = all active, no restriction
};
// Summary icon for button
const summaryIcon = useMemo(() => {
if (effectiveSelection.length === 1) {
return PROVIDER_ICONS[effectiveSelection[0]] || '🔌';
}
return '🤖';
}, [effectiveSelection]);
return (
<div
ref={containerRef}
className={`${styles.providerMultiSelect} ${className || ''} ${isExpanded ? styles.expanded : styles.collapsed}`}
>
{/* Trigger Button - styled like iconButton */}
<button
type="button"
className={styles.triggerButton}
onClick={() => setIsExpanded(!isExpanded)}
disabled={disabled}
title="Provider auswählen"
>
<span className={styles.buttonIcon}>{summaryIcon}</span>
</button>
{/* Dropdown Content */}
{isExpanded && (
<div className={styles.dropdownContent}>
{showLabel && <div className={styles.dropdownHeader}>{label}</div>}
<div className={styles.selectActions}>
<button
type="button"
onClick={handleSelectAll}
disabled={disabled}
className={`${styles.actionButton} ${isAllSelected ? styles.active : ''}`}
>
Alle
</button>
</div>
{loading ? (
<div className={styles.loading}>Lade...</div>
) : (
<div className={styles.checkboxList}>
{allowedProviders.map((provider) => (
<label
key={provider}
className={`${styles.checkboxItem} ${disabled ? styles.disabled : ''}`}
>
<input
type="checkbox"
checked={effectiveSelection.includes(provider)}
onChange={() => handleToggle(provider)}
disabled={disabled}
/>
<span className={styles.icon}>{PROVIDER_ICONS[provider] || '🔌'}</span>
<span className={styles.providerName}>
{PROVIDER_LABELS[provider] || provider}
</span>
</label>
))}
</div>
)}
{isAllSelected && !loading && (
<div className={styles.hint}>
Alle Provider aktiv (kein Filter)
</div>
)}
</div>
)}
</div>
);
};
// ============================================================================
// COMPACT PROVIDER BADGE LIST
// ============================================================================
interface ProviderBadgesProps {
providers: string[];
className?: string;
}
export const ProviderBadges: React.FC<ProviderBadgesProps> = ({
providers,
className,
}) => {
if (providers.length === 0) {
return <span className={styles.allProviders}>Alle Provider</span>;
}
return (
<div className={`${styles.providerBadges} ${className || ''}`}>
{providers.map((provider) => (
<span key={provider} className={styles.badge}>
{PROVIDER_ICONS[provider] || '🔌'} {PROVIDER_LABELS[provider] || provider}
</span>
))}
</div>
);
};
// Default export
export default ProviderSelect;