frontend_nyla/src/components/ProviderSelector/ProviderSelector.tsx
2026-04-09 00:11:35 +02:00

365 lines
11 KiB
TypeScript

/**
* ProviderSelector Component
*
* Wiederverwendbare Komponente zur Auswahl von AICore-Providern.
* Kann im AI Workspace und Automation Editor verwendet werden.
*
* Selektionsmodell:
* ProviderSelection { include: string[], exclude: string[] }
* - include(["ALL"]), exclude([]) → alle verfügbaren Provider (dynamisch)
* - include(["ALL"]), exclude(["private"]) → alle ausser "private" (dynamisch)
* - include(["anthropic"]), exclude([]) → nur Anthropic
* - include([]), exclude([]) → keiner ausgewählt
*
* resolveProviders(selection, allowedProviders) liefert die konkrete Liste.
*/
import React, { useEffect, useMemo, useState, useRef, useCallback } from 'react';
import { useBilling } from '../../hooks/useBilling';
import styles from './ProviderSelector.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
// ============================================================================
// TYPES & HELPERS
// ============================================================================
export const PROVIDER_ALL = 'ALL';
export interface ProviderSelection {
include: string[];
exclude: string[];
}
export function _defaultProviderSelection(): ProviderSelection {
return { include: [PROVIDER_ALL], exclude: [] };
}
export function _resolveProviders(
selection: ProviderSelection,
allowedProviders: string[],
): string[] {
if (selection.include.includes(PROVIDER_ALL)) {
return allowedProviders.filter((p) => !selection.exclude.includes(p));
}
return selection.include.filter((p) => allowedProviders.includes(p));
}
export function _isAllSelected(selection: ProviderSelection): boolean {
return selection.include.includes(PROVIDER_ALL) && selection.exclude.length === 0;
}
export function _isNoneSelected(
selection: ProviderSelection,
allowedProviders: string[],
): boolean {
return _resolveProviders(selection, allowedProviders).length === 0;
}
/**
* Migrate legacy string[] (old model) to ProviderSelection.
* [] → ALL, [...ids] → include those ids.
*/
export function _migrateFromLegacy(providers: string[]): ProviderSelection {
if (providers.length === 0) return _defaultProviderSelection();
return { include: providers, exclude: [] };
}
/**
* Convert ProviderSelection to flat list for backend API calls.
* Returns [] when ALL are selected (= no restriction / legacy behaviour).
*/
export function _toBackendProviders(
selection: ProviderSelection,
allowedProviders: string[],
): string[] {
if (_isAllSelected(selection)) return [];
return _resolveProviders(selection, allowedProviders);
}
// 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',
};
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 { t } = useLanguage();
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="">{t('providerSelector.auto')}</option>
{providerOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
);
};
// ============================================================================
// MULTI SELECT COMPONENT (Checkbox List) — include / exclude model
// ============================================================================
interface ProviderMultiSelectProps {
selection: ProviderSelection;
onChange: (selection: ProviderSelection) => void;
disabled?: boolean;
className?: string;
label?: string;
showLabel?: boolean;
defaultExpanded?: boolean;
excludeByDefault?: string[];
}
export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
selection,
onChange,
disabled = false,
className,
label = 'AI-Provider',
showLabel = true,
defaultExpanded = false,
excludeByDefault = [],
}) => {
const { t } = useLanguage();
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 once when providers first load
useEffect(() => {
if (
!initialExcludeApplied &&
allowedProviders.length > 0 &&
excludeByDefault.length > 0 &&
_isAllSelected(selection)
) {
onChange({ include: [PROVIDER_ALL], exclude: [...excludeByDefault] });
setInitialExcludeApplied(true);
}
}, [allowedProviders, excludeByDefault, initialExcludeApplied, selection, onChange]);
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]);
const effectiveSelection = useMemo(
() => _resolveProviders(selection, allowedProviders),
[selection, allowedProviders],
);
const allSelected = _isAllSelected(selection);
const noneSelected = effectiveSelection.length === 0;
const _handleToggle = (provider: string) => {
const isChecked = effectiveSelection.includes(provider);
if (selection.include.includes(PROVIDER_ALL)) {
// Currently ALL-based: toggle modifies exclude list
if (isChecked) {
onChange({ include: [PROVIDER_ALL], exclude: [...selection.exclude, provider] });
} else {
const nextExclude = selection.exclude.filter((p) => p !== provider);
onChange({ include: [PROVIDER_ALL], exclude: nextExclude });
}
} else {
// Explicit include list
if (isChecked) {
onChange({ include: selection.include.filter((p) => p !== provider), exclude: [] });
} else {
const nextInclude = [...selection.include, provider];
if (nextInclude.length === allowedProviders.length) {
onChange({ include: [PROVIDER_ALL], exclude: [] });
} else {
onChange({ include: nextInclude, exclude: [] });
}
}
}
};
const _handleSelectAll = () => {
onChange({ include: [PROVIDER_ALL], exclude: [] });
};
const summaryIcon = useMemo(() => {
if (noneSelected) return '⊘';
if (effectiveSelection.length === 1) {
return PROVIDER_ICONS[effectiveSelection[0]] || '🔌';
}
return '⚡';
}, [effectiveSelection, noneSelected]);
const summaryHint = useMemo(() => {
if (noneSelected) return 'Kein Provider ausgewählt';
if (allSelected) return 'Alle Provider aktiv (dynamisch)';
if (selection.include.includes(PROVIDER_ALL)) {
return `Alle ausser ${selection.exclude.length} Provider`;
}
return `${effectiveSelection.length} von ${allowedProviders.length} Provider`;
}, [noneSelected, allSelected, selection, effectiveSelection, allowedProviders]);
return (
<div
ref={containerRef}
className={`${styles.providerMultiSelect} ${className || ''} ${isExpanded ? styles.expanded : styles.collapsed}`}
>
<button
type="button"
className={styles.triggerButton}
onClick={() => setIsExpanded(!isExpanded)}
disabled={disabled}
title={t('providerSelector.providerAuswaehlen')}
>
<span className={styles.buttonIcon}>{summaryIcon}</span>
</button>
{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} ${allSelected ? styles.active : ''}`}
>
Alle
</button>
</div>
{loading ? (
<div className={styles.loading}>{t('providerSelector.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>
)}
<div className={styles.hint}>{summaryHint}</div>
</div>
)}
</div>
);
};
// ============================================================================
// COMPACT PROVIDER BADGE LIST
// ============================================================================
interface ProviderBadgesProps {
providers: string[];
className?: string;
}
export const ProviderBadges: React.FC<ProviderBadgesProps> = ({
providers,
className,
}) => {
const { t } = useLanguage();
if (providers.length === 0) {
return <span className={styles.allProviders}>{t('providerSelector.alleProvider')}</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>
);
};
export default ProviderSelect;