365 lines
11 KiB
TypeScript
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;
|