Files
Cli-Proxy-API-Management-Ce…/src/components/providers/OpenAISection/OpenAISection.tsx
T

480 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { IconCheck, IconX } from '@/components/ui/icons';
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
import type { OpenAIProviderConfig } from '@/types';
import { maskApiKey } from '@/utils/format';
import {
buildCandidateUsageSourceIds,
calculateStatusBarData,
type KeyStats,
} from '@/utils/usage';
import { collectUsageDetailsForCandidates, type UsageDetailsBySource } from '@/utils/usageIndex';
import styles from '@/pages/AiProvidersPage.module.scss';
import { ProviderList } from '../ProviderList';
import { ProviderStatusBar } from '../ProviderStatusBar';
import { getOpenAIProviderStats, getStatsBySource } from '../utils';
type SortOption = 'name' | 'priority' | 'recent-success';
type SortDirection = 'asc' | 'desc';
type ToolbarPosition = 'top' | 'bottom';
interface OpenAISectionProps {
configs: OpenAIProviderConfig[];
keyStats: KeyStats;
usageDetailsBySource: UsageDetailsBySource;
loading: boolean;
disableControls: boolean;
isSwitching: boolean;
resolvedTheme: string;
onAdd: () => void;
onEdit: (index: number) => void;
onDelete: (index: number) => void;
}
interface IndexedOpenAIProvider {
config: OpenAIProviderConfig;
originalIndex: number;
}
export function OpenAISection({
configs,
keyStats,
usageDetailsBySource,
loading,
disableControls,
isSwitching,
resolvedTheme,
onAdd,
onEdit,
onDelete,
}: OpenAISectionProps) {
const { t } = useTranslation();
const actionsDisabled = disableControls || loading || isSwitching;
const [sortOption, setSortOption] = useState<SortOption>('priority');
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
const [selectedModels, setSelectedModels] = useState<Set<string>>(new Set());
const [activeDropdown, setActiveDropdown] = useState<ToolbarPosition | null>(null);
const [dropdownLayout, setDropdownLayout] = useState({ openAbove: false, maxHeight: 300 });
const topDropdownRef = useRef<HTMLDivElement>(null);
const bottomDropdownRef = useRef<HTMLDivElement>(null);
const isDropdownOpen = activeDropdown !== null;
useEffect(() => {
if (!isDropdownOpen) {
return;
}
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node;
const clickedTop = topDropdownRef.current?.contains(target);
const clickedBottom = bottomDropdownRef.current?.contains(target);
if (!clickedTop && !clickedBottom) {
setActiveDropdown(null);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isDropdownOpen]);
useEffect(() => {
if (activeDropdown === null) {
return;
}
const updateDropdownLayout = () => {
const wrapper = activeDropdown === 'top' ? topDropdownRef.current : bottomDropdownRef.current;
if (!wrapper) {
return;
}
const rect = wrapper.getBoundingClientRect();
const viewportPadding = 12;
const dropdownGap = 4;
const preferredMaxHeight = 300;
const minimumMaxHeight = 120;
const availableBelow = Math.max(0, window.innerHeight - rect.bottom - viewportPadding - dropdownGap);
const availableAbove = Math.max(0, rect.top - viewportPadding - dropdownGap);
const openAbove = availableBelow < preferredMaxHeight && availableAbove > availableBelow;
const availableSpace = openAbove ? availableAbove : availableBelow;
const maxHeight = Math.max(minimumMaxHeight, Math.min(preferredMaxHeight, availableSpace));
setDropdownLayout((prev) => {
if (prev.openAbove === openAbove && prev.maxHeight === maxHeight) {
return prev;
}
return { openAbove, maxHeight };
});
};
updateDropdownLayout();
window.addEventListener('resize', updateDropdownLayout);
window.addEventListener('scroll', updateDropdownLayout, true);
return () => {
window.removeEventListener('resize', updateDropdownLayout);
window.removeEventListener('scroll', updateDropdownLayout, true);
};
}, [activeDropdown]);
const allModelNames = useMemo(() => {
const modelSet = new Set<string>();
configs.forEach((provider) => {
provider.models?.forEach((model) => {
if (model.name) {
modelSet.add(model.name);
}
});
});
return Array.from(modelSet).sort();
}, [configs]);
const statusBarCache = useMemo(() => {
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
configs.forEach((provider) => {
const sourceIds = new Set<string>();
buildCandidateUsageSourceIds({ prefix: provider.prefix }).forEach((id) => sourceIds.add(id));
(provider.apiKeyEntries || []).forEach((entry) => {
buildCandidateUsageSourceIds({ apiKey: entry.apiKey }).forEach((id) => sourceIds.add(id));
});
const filteredDetails = sourceIds.size
? collectUsageDetailsForCandidates(usageDetailsBySource, sourceIds)
: [];
cache.set(provider.name, calculateStatusBarData(filteredDetails));
});
return cache;
}, [configs, usageDetailsBySource]);
const sortedConfigs = useMemo<IndexedOpenAIProvider[]>(() => {
const indexed = configs.map((config, originalIndex) => ({ config, originalIndex }));
const filtered = indexed.filter(({ config }) => {
if (selectedModels.size === 0) return true;
return config.models?.some((model) => selectedModels.has(model.name));
});
const sorted = [...filtered];
const direction = sortDirection === 'desc' ? -1 : 1;
const providerStats =
sortOption === 'recent-success'
? new Map(
sorted.map(({ config }) => [
config,
getOpenAIProviderStats(config.apiKeyEntries, keyStats, config.prefix),
])
)
: null;
switch (sortOption) {
case 'name':
sorted.sort((a, b) => direction * a.config.name.localeCompare(b.config.name));
break;
case 'priority':
sorted.sort((a, b) => {
const priorityA = a.config.priority ?? Number.MAX_SAFE_INTEGER;
const priorityB = b.config.priority ?? Number.MAX_SAFE_INTEGER;
const priorityDiff = priorityA - priorityB;
if (priorityDiff !== 0) {
return direction * priorityDiff;
}
return direction * a.config.name.localeCompare(b.config.name);
});
break;
case 'recent-success':
sorted.sort((a, b) => {
const successDiff =
(providerStats?.get(a.config)?.success ?? 0) - (providerStats?.get(b.config)?.success ?? 0);
if (successDiff !== 0) {
return direction * successDiff;
}
return direction * a.config.name.localeCompare(b.config.name);
});
break;
default:
break;
}
return sorted;
}, [configs, sortOption, sortDirection, keyStats, selectedModels]);
const toggleModelSelection = (modelName: string) => {
setSelectedModels((prev) => {
const next = new Set(prev);
if (next.has(modelName)) {
next.delete(modelName);
} else {
next.add(modelName);
}
return next;
});
};
const clearAllModels = () => {
setSelectedModels(new Set());
};
const toggleSortDirection = () => {
setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc'));
};
const toggleDropdown = (position: ToolbarPosition) => {
setActiveDropdown((prev) => (prev === position ? null : position));
};
const renderSortControls = () => (
<div className={styles.sortControls}>
<select
value={sortOption}
onChange={(e) => setSortOption(e.target.value as SortOption)}
className={styles.sortSelect}
disabled={actionsDisabled}
>
<option value="priority">{t('ai_providers.sort_by_priority')}</option>
<option value="name">{t('ai_providers.sort_by_name')}</option>
<option value="recent-success">{t('ai_providers.sort_by_recent_success')}</option>
</select>
<button
type="button"
onClick={toggleSortDirection}
className={styles.sortDirectionButton}
disabled={actionsDisabled}
title={sortDirection === 'asc' ? t('common.sort_ascending') : t('common.sort_descending')}
>
{sortDirection === 'asc' ? '↑' : '↓'}
</button>
</div>
);
const renderToolbar = (position: ToolbarPosition) => {
const isDropdownOpen = activeDropdown === position;
const dropdownRef = position === 'top' ? topDropdownRef : bottomDropdownRef;
const wrapperClassName =
position === 'top' ? styles.cardHeaderActions : `${styles.cardHeaderActions} ${styles.cardFooterActions}`;
const dropdownClassName =
dropdownLayout.openAbove ? `${styles.modelDropdownList} ${styles.modelDropdownListAbove}` : styles.modelDropdownList;
return (
<div className={wrapperClassName}>
<div className={styles.modelMultiSelectWrapper} ref={dropdownRef}>
<div className={styles.modelSelectedTags} onClick={() => toggleDropdown(position)}>
{selectedModels.size === 0 ? (
<span className={styles.modelSelectPlaceholder}>
{t('ai_providers.model_search_placeholder')}
</span>
) : (
<>
{Array.from(selectedModels).map((name) => (
<span key={`${position}-${name}`} className={styles.modelTag}>
<span className={styles.modelTagName}>{name}</span>
<button
type="button"
className={styles.modelTagRemove}
onClick={(e) => {
e.stopPropagation();
toggleModelSelection(name);
}}
title={t('ai_providers.model_search_clear')}
>
×
</button>
</span>
))}
</>
)}
<span className={styles.modelSelectArrow}></span>
</div>
{isDropdownOpen && (
<div className={dropdownClassName} style={{ maxHeight: `${dropdownLayout.maxHeight}px` }}>
<div className={styles.modelDropdownHeader}>
<button
type="button"
onClick={() => setSelectedModels(new Set(allModelNames))}
className={styles.modelDropdownSelectAll}
>
{t('ai_providers.model_select_all')}
</button>
{selectedModels.size > 0 && (
<button
type="button"
onClick={clearAllModels}
className={styles.modelDropdownClear}
>
{t('ai_providers.model_search_clear')}
</button>
)}
</div>
{allModelNames.map((name) => (
<label key={`${position}-option-${name}`} className={styles.modelDropdownItem}>
<input
type="checkbox"
checked={selectedModels.has(name)}
onChange={() => toggleModelSelection(name)}
/>
<span>{name}</span>
</label>
))}
</div>
)}
</div>
{renderSortControls()}
<Button size="sm" onClick={onAdd} disabled={actionsDisabled}>
{t('ai_providers.openai_add_button')}
</Button>
</div>
);
};
return (
<>
<Card
title={
<span className={styles.cardTitle}>
<img
src={resolvedTheme === 'dark' ? iconOpenaiDark : iconOpenaiLight}
alt=""
className={styles.cardTitleIcon}
/>
{t('ai_providers.openai_title')}
</span>
}
extra={renderToolbar('top')}
>
<ProviderList<IndexedOpenAIProvider>
items={sortedConfigs}
loading={loading}
keyField={(item) => `openai-provider-${item.originalIndex}`}
emptyTitle={t('ai_providers.openai_empty_title')}
emptyDescription={t('ai_providers.openai_empty_desc')}
listClassName={styles.providerList}
rowClassName={styles.providerCard}
metaClassName={styles.providerMeta}
actionsClassName={styles.providerActions}
onEdit={(item) => {
onEdit(item.originalIndex);
}}
onDelete={(item) => {
onDelete(item.originalIndex);
}}
actionsDisabled={actionsDisabled}
renderContent={(item) => {
const provider = item.config;
const stats = getOpenAIProviderStats(provider.apiKeyEntries, keyStats, provider.prefix);
const headerEntries = Object.entries(provider.headers || {});
const apiKeyEntries = provider.apiKeyEntries || [];
const statusData = statusBarCache.get(provider.name) || calculateStatusBarData([]);
return (
<Fragment>
<div className={styles.providerTitle}>{provider.name}</div>
{provider.priority !== undefined && (
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.priority')}:</span>
<span className={styles.fieldValue}>{provider.priority}</span>
</div>
)}
{provider.prefix && (
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.prefix')}:</span>
<span className={styles.fieldValue}>{provider.prefix}</span>
</div>
)}
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.base_url')}:</span>
<span className={styles.fieldValue}>{provider.baseUrl}</span>
</div>
{headerEntries.length > 0 && (
<div className={styles.headerBadgeList}>
{headerEntries.map(([key, value]) => (
<span key={key} className={styles.headerBadge}>
<strong>{key}:</strong> {value}
</span>
))}
</div>
)}
{apiKeyEntries.length > 0 && (
<div className={styles.apiKeyEntriesSection}>
<div className={styles.apiKeyEntriesLabel}>
{t('ai_providers.openai_keys_count')}: {apiKeyEntries.length}
</div>
<div className={styles.apiKeyEntryList}>
{apiKeyEntries.map((entry, entryIndex) => {
const entryStats = getStatsBySource(entry.apiKey, keyStats);
return (
<div key={entryIndex} className={styles.apiKeyEntryCard}>
<span className={styles.apiKeyEntryIndex}>{entryIndex + 1}</span>
<span className={styles.apiKeyEntryKey}>{maskApiKey(entry.apiKey)}</span>
{entry.proxyUrl && (
<span className={styles.apiKeyEntryProxy}>{entry.proxyUrl}</span>
)}
<div className={styles.apiKeyEntryStats}>
<span
className={`${styles.apiKeyEntryStat} ${styles.apiKeyEntryStatSuccess}`}
>
<IconCheck size={12} /> {entryStats.success}
</span>
<span
className={`${styles.apiKeyEntryStat} ${styles.apiKeyEntryStatFailure}`}
>
<IconX size={12} /> {entryStats.failure}
</span>
</div>
</div>
);
})}
</div>
</div>
)}
<div className={styles.fieldRow} style={{ marginTop: '8px' }}>
<span className={styles.fieldLabel}>{t('ai_providers.openai_models_count')}:</span>
<span className={styles.fieldValue}>{provider.models?.length || 0}</span>
</div>
{provider.models?.length ? (
<div className={styles.modelTagList}>
{provider.models.map((model) => (
<span key={model.name} className={styles.modelTag}>
<span className={styles.modelName}>{model.name}</span>
{model.alias && model.alias !== model.name && (
<span className={styles.modelAlias}>{model.alias}</span>
)}
</span>
))}
</div>
) : null}
{provider.testModel && (
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>Test Model:</span>
<span className={styles.fieldValue}>{provider.testModel}</span>
</div>
)}
<div className={styles.cardStats}>
<span className={`${styles.statPill} ${styles.statSuccess}`}>
{t('stats.success')}: {stats.success}
</span>
<span className={`${styles.statPill} ${styles.statFailure}`}>
{t('stats.failure')}: {stats.failure}
</span>
</div>
<ProviderStatusBar statusData={statusData} />
</Fragment>
);
}}
/>
<div className={styles.providerToolbarFooter}>{renderToolbar('bottom')}</div>
</Card>
</>
);
}