mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-06-16 21:03:58 +08:00
480 lines
18 KiB
TypeScript
480 lines
18 KiB
TypeScript
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>
|
||
</>
|
||
);
|
||
}
|