| import { useState, useEffect, useMemo } from 'react'; |
| import { useTranslation } from 'react-i18next'; |
| import { request as invoke } from '../utils/request'; |
| import { isTauri } from '../utils/env'; |
| import { copyToClipboard } from '../utils/clipboard'; |
| import { |
| Power, |
| Copy, |
| RefreshCw, |
| CheckCircle, |
| Settings, |
| Target, |
| Plus, |
| Terminal, |
| Trash2, |
| BrainCircuit, |
| Puzzle, |
| Zap, |
| ArrowRight, |
| Sparkles, |
| Code, |
| Check, |
| X, |
| Edit2, |
| Save |
| } from 'lucide-react'; |
| import { AppConfig, ProxyConfig, StickySessionConfig, ExperimentalConfig } from '../types/config'; |
| import HelpTooltip from '../components/common/HelpTooltip'; |
| import ModalDialog from '../components/common/ModalDialog'; |
| import { showToast } from '../components/common/ToastContainer'; |
| import { cn } from '../utils/cn'; |
| import { useProxyModels } from '../hooks/useProxyModels'; |
| import GroupedSelect, { SelectOption } from '../components/common/GroupedSelect'; |
| import { CliSyncCard } from '../components/proxy/CliSyncCard'; |
| import DebouncedSlider from '../components/common/DebouncedSlider'; |
| import { listAccounts } from '../services/accountService'; |
| import CircuitBreaker from '../components/settings/CircuitBreaker'; |
| import AdvancedThinking from '../components/settings/AdvancedThinking'; |
| import { CircuitBreakerConfig } from '../types/config'; |
|
|
| interface ProxyStatus { |
| running: boolean; |
| port: number; |
| base_url: string; |
| active_accounts: number; |
| } |
|
|
| interface CustomPreset { |
| id: string; |
| name: string; |
| description: string; |
| mappings: Record<string, string>; |
| } |
|
|
|
|
| interface CollapsibleCardProps { |
| title: string; |
| icon: React.ReactNode; |
| enabled?: boolean; |
| onToggle?: (enabled: boolean) => void; |
| children: React.ReactNode; |
| defaultExpanded?: boolean; |
| rightElement?: React.ReactNode; |
| allowInteractionWhenDisabled?: boolean; |
| } |
|
|
| function CollapsibleCard({ |
| title, |
| icon, |
| enabled, |
| onToggle, |
| children, |
| defaultExpanded = false, |
| rightElement, |
| allowInteractionWhenDisabled = false, |
| }: CollapsibleCardProps) { |
| const [isExpanded, setIsExpanded] = useState(defaultExpanded); |
| const { t } = useTranslation(); |
|
|
| return ( |
| <div className="bg-white dark:bg-base-100 rounded-xl shadow-sm border border-gray-100 dark:border-gray-700/50 overflow-hidden transition-all duration-200 hover:shadow-md"> |
| <div |
| className="px-5 py-4 flex items-center justify-between cursor-pointer bg-gray-50/50 dark:bg-gray-800/50 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors" |
| onClick={(e) => { |
| // Prevent toggle when clicking the switch or right element |
| if ((e.target as HTMLElement).closest('.no-expand')) return; |
| setIsExpanded(!isExpanded); |
| }} |
| > |
| <div className="flex items-center gap-3"> |
| <div className="text-gray-500 dark:text-gray-400"> |
| {icon} |
| </div> |
| <span className="font-medium text-sm text-gray-900 dark:text-gray-100"> |
| {title} |
| </span> |
| {enabled !== undefined && ( |
| <div className={cn('text-xs px-2 py-0.5 rounded-full', enabled ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400' : 'bg-gray-100 text-gray-500 dark:bg-gray-600/50 dark:text-gray-300')}> |
| {enabled ? t('common.enabled') : t('common.disabled')} |
| </div> |
| )} |
| </div> |
| |
| <div className="flex items-center gap-4 no-expand"> |
| {rightElement} |
| |
| {enabled !== undefined && onToggle && ( |
| <div className="flex items-center" onClick={(e) => e.stopPropagation()}> |
| <input |
| type="checkbox" |
| className="toggle toggle-sm bg-gray-200 dark:bg-gray-700 border-gray-300 dark:border-gray-600 checked:bg-blue-500 checked:border-blue-500" |
| checked={enabled} |
| onChange={(e) => onToggle(e.target.checked)} |
| /> |
| </div> |
| )} |
| |
| <button |
| className={cn('p-1 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 transition-all duration-200', isExpanded ? 'rotate-180' : '')} |
| > |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> |
| <path d="m6 9 6 6 6-6" /> |
| </svg> |
| </button> |
| </div> |
| </div> |
| |
| <div |
| className={`transition-all duration-300 ease-in-out border-t border-gray-100 dark:border-base-200 ${isExpanded ? 'max-h-[2000px] opacity-100' : 'max-h-0 opacity-0 overflow-hidden' |
| }`} |
| > |
| <div className="p-5 relative"> |
| {/* Overlay when disabled */} |
| {enabled === false && !allowInteractionWhenDisabled && ( |
| <div className="absolute inset-0 bg-gray-100/40 dark:bg-black/30 z-10 cursor-not-allowed" /> |
| )} |
| <div className={enabled === false && !allowInteractionWhenDisabled ? 'opacity-60 pointer-events-none select-none' : ''}> |
| {children} |
| </div> |
| </div> |
| |
| </div> |
| </div> |
| ); |
| } |
|
|
| export default function ApiProxy() { |
| const { t } = useTranslation(); |
|
|
| const { models } = useProxyModels(); |
|
|
| const [status, setStatus] = useState<ProxyStatus>({ |
| running: false, |
| port: 0, |
| base_url: '', |
| active_accounts: 0, |
| }); |
|
|
| const [appConfig, setAppConfig] = useState<AppConfig | null>(null); |
| const [configLoading, setConfigLoading] = useState(true); |
| const [configError, setConfigError] = useState<string | null>(null); |
| const [loading, setLoading] = useState(false); |
| const [copied, setCopied] = useState<string | null>(null); |
| const [selectedProtocol, setSelectedProtocol] = useState<'openai' | 'anthropic' | 'gemini'>('openai'); |
| const [selectedModelId, setSelectedModelId] = useState('gemini-3-flash'); |
| const [zaiAvailableModels, setZaiAvailableModels] = useState<string[]>([]); |
| const [zaiModelsLoading, setZaiModelsLoading] = useState(false); |
| const [, setZaiModelsError] = useState<string | null>(null); |
| const [zaiNewMappingFrom, setZaiNewMappingFrom] = useState(''); |
| const [zaiNewMappingTo, setZaiNewMappingTo] = useState(''); |
| const [customMappingValue, setCustomMappingValue] = useState(''); |
| const [editingKey, setEditingKey] = useState<string | null>(null); |
| const [editingValue, setEditingValue] = useState<string>(''); |
|
|
| |
| const [isEditingApiKey, setIsEditingApiKey] = useState(false); |
| const [tempApiKey, setTempApiKey] = useState(''); |
|
|
| const [isEditingAdminPassword, setIsEditingAdminPassword] = useState(false); |
| const [tempAdminPassword, setTempAdminPassword] = useState(''); |
|
|
| |
| const [selectedPreset, setSelectedPreset] = useState<string>('default'); |
| const [customPresets, setCustomPresets] = useState<CustomPreset[]>([]); |
| const [isPresetManagerOpen, setIsPresetManagerOpen] = useState(false); |
| const [newPresetName, setNewPresetName] = useState(''); |
|
|
| |
|
|
| |
| const [isResetConfirmOpen, setIsResetConfirmOpen] = useState(false); |
| const [isRegenerateKeyConfirmOpen, setIsRegenerateKeyConfirmOpen] = useState(false); |
| const [isClearBindingsConfirmOpen, setIsClearBindingsConfirmOpen] = useState(false); |
| const [isClearRateLimitsConfirmOpen, setIsClearRateLimitsConfirmOpen] = useState(false); |
|
|
| |
| const [preferredAccountId, setPreferredAccountId] = useState<string | null>(null); |
| const [availableAccounts, setAvailableAccounts] = useState<Array<{ id: string; email: string }>>([]); |
|
|
| |
| const [cfStatus, setCfStatus] = useState<{ installed: boolean; version?: string; running: boolean; url?: string; error?: string }>({ |
| installed: false, |
| running: false, |
| }); |
| const [cfLoading, setCfLoading] = useState(false); |
| const [cfMode, setCfMode] = useState<'quick' | 'auth'>('quick'); |
| const [cfToken, setCfToken] = useState(''); |
| const [cfUseHttp2, setCfUseHttp2] = useState(true); |
|
|
| const zaiModelOptions = useMemo(() => { |
| const unique = new Set(zaiAvailableModels); |
| return Array.from(unique).sort(); |
| }, [zaiAvailableModels]); |
|
|
| const zaiModelMapping = useMemo(() => { |
| return appConfig?.proxy.zai?.model_mapping || {}; |
| }, [appConfig?.proxy.zai?.model_mapping]); |
|
|
|
|
| |
| const customMappingOptions: SelectOption[] = useMemo(() => { |
| return models.map(model => ({ |
| value: model.id, |
| label: `${model.id} (${model.name})`, |
| group: model.group || 'Other' |
| })); |
| }, [models]); |
|
|
| |
| useEffect(() => { |
| loadConfig(); |
| loadStatus(); |
| loadAccounts(); |
| loadPreferredAccount(); |
| loadCfStatus(); |
| loadCustomPresets(); |
| const interval = setInterval(loadStatus, 3000); |
| const cfInterval = setInterval(loadCfStatus, 5000); |
| return () => { |
| clearInterval(interval); |
| clearInterval(cfInterval); |
| }; |
| }, []); |
|
|
|
|
|
|
| |
| const loadAccounts = async () => { |
| try { |
| const accounts = await listAccounts(); |
| setAvailableAccounts(accounts.map(a => ({ id: a.id, email: a.email }))); |
| } catch (error) { |
| console.error('Failed to load accounts:', error); |
| } |
| }; |
|
|
| |
| const loadCfStatus = async () => { |
| try { |
| const status = await invoke<typeof cfStatus>('cloudflared_get_status'); |
| setCfStatus(status); |
| } catch (error) { |
| |
| } |
| }; |
|
|
| |
| const handleCfInstall = async () => { |
| console.log('[Cloudflared] Install button clicked'); |
| setCfLoading(true); |
| try { |
| console.log('[Cloudflared] Calling cloudflared_install...'); |
| const status = await invoke<typeof cfStatus>('cloudflared_install'); |
| console.log('[Cloudflared] Install result:', status); |
| setCfStatus(status); |
| showToast(t('proxy.cloudflared.install_success', { defaultValue: 'Cloudflared installed successfully' }), 'success'); |
| } catch (error) { |
| console.error('[Cloudflared] Install error:', error); |
| showToast(String(error), 'error'); |
| } finally { |
| setCfLoading(false); |
| } |
| }; |
|
|
| |
| const handleCfToggle = async (enable: boolean) => { |
| if (enable && !status.running) { |
| showToast( |
| t('proxy.cloudflared.require_proxy_running', { defaultValue: 'Please start the local proxy service first' }), |
| 'warning' |
| ); |
| return; |
| } |
| setCfLoading(true); |
| try { |
| if (enable) { |
| if (!cfStatus.installed) { |
| const installStatus = await invoke<typeof cfStatus>('cloudflared_install'); |
| setCfStatus(installStatus); |
| if (!installStatus.installed) { |
| throw new Error('Cloudflared install failed'); |
| } |
| showToast(t('proxy.cloudflared.install_success', { defaultValue: 'Cloudflared installed successfully' }), 'success'); |
| } |
|
|
| const config = { |
| enabled: true, |
| mode: cfMode, |
| port: appConfig?.proxy.port || 8045, |
| token: cfMode === 'auth' ? cfToken : null, |
| use_http2: cfUseHttp2, |
| }; |
| const status = await invoke<typeof cfStatus>('cloudflared_start', { config }); |
| setCfStatus(status); |
| showToast(t('proxy.cloudflared.started', { defaultValue: 'Tunnel started' }), 'success'); |
|
|
| |
| if (appConfig) { |
| const newConfig = { |
| ...appConfig, |
| cloudflared: { |
| ...appConfig.cloudflared, |
| enabled: true, |
| mode: cfMode, |
| token: cfToken, |
| use_http2: cfUseHttp2, |
| port: appConfig.proxy.port || 8045 |
| } |
| }; |
| saveConfig(newConfig); |
| } |
| } else { |
| const status = await invoke<typeof cfStatus>('cloudflared_stop'); |
| setCfStatus(status); |
| showToast(t('proxy.cloudflared.stopped', { defaultValue: 'Tunnel stopped' }), 'success'); |
|
|
| |
| if (appConfig) { |
| const newConfig = { |
| ...appConfig, |
| cloudflared: { |
| ...appConfig.cloudflared, |
| enabled: false |
| } |
| }; |
| saveConfig(newConfig); |
| } |
| } |
| } catch (error) { |
| showToast(String(error), 'error'); |
| } finally { |
| setCfLoading(false); |
| } |
| }; |
|
|
| |
| const handleCfCopyUrl = async () => { |
| if (cfStatus.url) { |
| const success = await copyToClipboard(cfStatus.url); |
| if (success) { |
| setCopied('cf-url'); |
| setTimeout(() => setCopied(null), 2000); |
| } |
| } |
| }; |
|
|
| |
| const loadPreferredAccount = async () => { |
| try { |
| const prefId = await invoke<string | null>('get_preferred_account'); |
| setPreferredAccountId(prefId); |
| } catch (error) { |
| |
| } |
| }; |
|
|
| |
| const handleSetPreferredAccount = async (accountId: string | null) => { |
| try { |
| const wasEnabled = preferredAccountId !== null; |
| await invoke('set_preferred_account', { accountId }); |
| setPreferredAccountId(accountId); |
|
|
| |
| let message: string; |
| if (accountId === null) { |
| message = t('proxy.config.scheduling.round_robin_set', { defaultValue: 'Round-robin mode enabled' }); |
| } else if (wasEnabled) { |
| |
| const account = availableAccounts.find(a => a.id === accountId); |
| message = t('proxy.config.scheduling.account_changed', { |
| defaultValue: `Switched to ${account?.email || accountId}`, |
| email: account?.email || accountId |
| }); |
| } else { |
| |
| message = t('proxy.config.scheduling.fixed_account_set', { defaultValue: 'Fixed account mode enabled' }); |
| } |
|
|
| showToast(message, 'success'); |
| } catch (error) { |
| showToast(String(error), 'error'); |
| } |
| }; |
|
|
| const loadConfig = async () => { |
| setConfigLoading(true); |
| setConfigError(null); |
| try { |
| const config = await invoke<AppConfig>('load_config'); |
| setAppConfig(config); |
|
|
| |
| if (config.cloudflared) { |
| setCfMode(config.cloudflared.mode || 'quick'); |
| setCfToken(config.cloudflared.token || ''); |
| setCfUseHttp2(config.cloudflared.use_http2 !== false); |
| } |
|
|
| |
| if (config.cloudflared) { |
| setCfMode(config.cloudflared.mode || 'quick'); |
| setCfToken(config.cloudflared.token || ''); |
| setCfUseHttp2(config.cloudflared.use_http2 !== false); |
| } |
| } catch (error) { |
| console.error('加载配置失败:', error); |
| setConfigError(String(error)); |
| } finally { |
| setConfigLoading(false); |
| } |
| }; |
|
|
| const loadStatus = async () => { |
| try { |
| const s = await invoke<ProxyStatus>('get_proxy_status'); |
| |
| if (s.base_url === 'starting' || s.base_url === 'busy') { |
| |
| setStatus(prev => ({ ...s, running: prev.running })); |
| } else { |
| setStatus(s); |
| } |
| } catch (error) { |
| console.error('获取状态失败:', error); |
| } |
| }; |
|
|
|
|
| const saveConfig = async (newConfig: AppConfig) => { |
| |
| setAppConfig(newConfig); |
| try { |
| await invoke('save_config', { config: newConfig }); |
| } catch (error) { |
| console.error('保存配置失败:', error); |
| showToast(`${t('common.error')}: ${error}`, 'error'); |
| } |
| }; |
|
|
| |
| const handleMappingUpdate = async (type: 'custom', key: string, value: string) => { |
| if (!appConfig) return; |
|
|
| console.log('[DEBUG] handleMappingUpdate called:', { type, key, value }); |
|
|
| const newConfig = { ...appConfig.proxy }; |
| newConfig.custom_mapping = { ...(newConfig.custom_mapping || {}), [key]: value }; |
|
|
| try { |
| await invoke('update_model_mapping', { config: newConfig }); |
| setAppConfig({ ...appConfig, proxy: newConfig }); |
| console.log('[DEBUG] Mapping updated successfully'); |
| showToast(t('common.saved'), 'success'); |
| } catch (error) { |
| console.error('Failed to update mapping:', error); |
| showToast(`${t('common.error')}: ${error}`, 'error'); |
| } |
| }; |
|
|
| const handleResetMapping = () => { |
| if (!appConfig) return; |
| setIsResetConfirmOpen(true); |
| }; |
|
|
| const executeResetMapping = async () => { |
| if (!appConfig) return; |
| setIsResetConfirmOpen(false); |
|
|
| |
| const newConfig = { |
| ...appConfig.proxy, |
| custom_mapping: {} |
| }; |
|
|
| try { |
| await invoke('update_model_mapping', { config: newConfig }); |
| setAppConfig({ ...appConfig, proxy: newConfig }); |
| showToast(t('common.success'), 'success'); |
| } catch (error) { |
| console.error('Failed to reset mapping:', error); |
| showToast(`${t('common.error')}: ${error}`, 'error'); |
| } |
| }; |
|
|
|
|
| |
| const defaultPresets = useMemo(() => [ |
| { |
| id: 'default', |
| name: t('proxy.router.preset_default'), |
| description: t('proxy.router.preset_default_desc'), |
| mappings: { |
| "gpt-4*": "gemini-3.1-pro-high", |
| "gpt-4o*": "gemini-3-flash", |
| "gpt-3.5*": "gemini-2.5-flash", |
| "o1-*": "gemini-3.1-pro-high", |
| "o3-*": "gemini-3.1-pro-high", |
| "claude-3-5-sonnet-*": "claude-sonnet-4-6", |
| "claude-3-opus-*": "claude-opus-4-6-thinking", |
| "claude-opus-4-6*": "claude-opus-4-6-thinking", |
| "claude-haiku-*": "gemini-2.5-flash", |
| "claude-3-haiku-*": "gemini-2.5-flash", |
| } |
| }, |
| { |
| id: 'performance', |
| name: t('proxy.router.preset_performance'), |
| description: t('proxy.router.preset_performance_desc'), |
| mappings: { |
| "gpt-4*": "claude-opus-4-6-thinking", |
| "gpt-4o*": "claude-sonnet-4-6", |
| "gpt-3.5*": "gemini-3-flash", |
| "o1-*": "claude-opus-4-6-thinking", |
| "o3-*": "claude-opus-4-6-thinking", |
| "claude-3-5-sonnet-*": "claude-sonnet-4-6", |
| "claude-3-opus-*": "claude-opus-4-6-thinking", |
| "claude-opus-4-6*": "claude-opus-4-6-thinking", |
| "claude-haiku-*": "claude-sonnet-4-6", |
| "claude-3-haiku-*": "claude-sonnet-4-6", |
| } |
| }, |
| { |
| id: 'cost-effective', |
| name: t('proxy.router.preset_cost'), |
| description: t('proxy.router.preset_cost_desc'), |
| mappings: { |
| "gpt-4*": "gemini-3-flash", |
| "gpt-4o*": "gemini-2.5-flash", |
| "gpt-3.5*": "gemini-2.5-flash", |
| "o1-*": "gemini-3-flash", |
| "o3-*": "gemini-3-flash", |
| "claude-3-5-sonnet-*": "gemini-3-flash", |
| "claude-3-opus-*": "gemini-3-flash", |
| "claude-opus-4-*": "gemini-3-flash", |
| "claude-haiku-*": "gemini-2.5-flash", |
| "claude-3-haiku-*": "gemini-2.5-flash", |
| } |
| }, |
| { |
| id: 'balanced', |
| name: t('proxy.router.preset_balanced'), |
| description: t('proxy.router.preset_balanced_desc'), |
| mappings: { |
| "gpt-4*": "gemini-3.1-pro-high", |
| "gpt-4o*": "gemini-3-flash", |
| "gpt-3.5*": "gemini-2.5-flash", |
| "o1-*": "claude-sonnet-4-6", |
| "o3-*": "claude-sonnet-4-6", |
| "claude-3-5-sonnet-*": "claude-sonnet-4-6", |
| "claude-3-opus-*": "gemini-3.1-pro-high", |
| "claude-opus-4-5*": "gemini-3.1-pro-high", |
| "claude-opus-4-6*": "claude-opus-4-6-thinking", |
| "claude-haiku-*": "gemini-2.5-flash", |
| "claude-3-haiku-*": "gemini-2.5-flash", |
| } |
| }, |
| ], [t]); |
|
|
| const presetOptions = useMemo(() => { |
| return [...defaultPresets, ...customPresets]; |
| }, [defaultPresets, customPresets]); |
|
|
| |
| const loadCustomPresets = () => { |
| try { |
| const saved = localStorage.getItem('antigravity_custom_presets'); |
| if (saved) { |
| setCustomPresets(JSON.parse(saved)); |
| } |
| } catch (error) { |
| console.error('Failed to load custom presets:', error); |
| } |
| }; |
|
|
| const saveCustomPresetsToStorage = (presets: CustomPreset[]) => { |
| try { |
| localStorage.setItem('antigravity_custom_presets', JSON.stringify(presets)); |
| setCustomPresets(presets); |
| } catch (error) { |
| console.error('Failed to save custom presets:', error); |
| showToast('Failed to save preset', 'error'); |
| } |
| }; |
|
|
| const handleSaveCurrentAsPreset = () => { |
| if (!appConfig?.proxy.custom_mapping || Object.keys(appConfig.proxy.custom_mapping).length === 0) { |
| showToast(t('proxy.router.no_mapping_to_save'), 'warning'); |
| return; |
| } |
| if (!newPresetName.trim()) { |
| showToast(t('proxy.router.preset_name_required'), 'warning'); |
| return; |
| } |
|
|
| const newPreset: CustomPreset = { |
| id: `custom_${Date.now()}`, |
| name: newPresetName, |
| description: t('proxy.router.custom_preset_desc'), |
| mappings: { ...appConfig.proxy.custom_mapping } |
| }; |
|
|
| const updatedPresets = [...customPresets, newPreset]; |
| saveCustomPresetsToStorage(updatedPresets); |
| setNewPresetName(''); |
| setIsPresetManagerOpen(false); |
| showToast(t('proxy.router.preset_saved', { defaultValue: 'Preset saved successfully' }), 'success'); |
| |
| setSelectedPreset(newPreset.id); |
| }; |
|
|
| const handleDeletePreset = (id: string) => { |
| const updatedPresets = customPresets.filter(p => p.id !== id); |
| saveCustomPresetsToStorage(updatedPresets); |
| if (selectedPreset === id) { |
| setSelectedPreset('default'); |
| } |
| }; |
|
|
| |
| const handleApplyPresets = async () => { |
| if (!appConfig) return; |
|
|
| const selectedPresetData = presetOptions.find(p => p.id === selectedPreset); |
| if (!selectedPresetData) return; |
|
|
| |
| const newConfig = { |
| ...appConfig.proxy, |
| |
| |
| custom_mapping: { ...appConfig.proxy.custom_mapping, ...selectedPresetData.mappings } as Record<string, string> |
| }; |
|
|
| |
| const oldConfig = { ...appConfig }; |
|
|
| try { |
| |
| setAppConfig({ ...appConfig, proxy: newConfig }); |
| showToast(t('proxy.router.presets_applied') + ` (${selectedPresetData.name})`, 'success'); |
|
|
| |
| await invoke('update_model_mapping', { config: newConfig }); |
|
|
| |
| await loadConfig(); |
| } catch (error) { |
| console.error('Failed to apply presets:', error); |
| |
| setAppConfig(oldConfig); |
| showToast(`${t('common.error')}: ${error}`, 'error'); |
| } |
| }; |
|
|
| const handleRemoveCustomMapping = async (key: string) => { |
| if (!appConfig || !appConfig.proxy.custom_mapping) return; |
| const newCustom = { ...appConfig.proxy.custom_mapping }; |
| delete newCustom[key]; |
| const newConfig = { ...appConfig.proxy, custom_mapping: newCustom }; |
| try { |
| await invoke('update_model_mapping', { config: newConfig }); |
| setAppConfig({ ...appConfig, proxy: newConfig }); |
| } catch (error) { |
| console.error('Failed to remove custom mapping:', error); |
| } |
| }; |
|
|
| const updateProxyConfig = (updates: Partial<ProxyConfig>) => { |
| if (!appConfig) return; |
| const newConfig = { |
| ...appConfig, |
| proxy: { |
| ...appConfig.proxy, |
| ...updates |
| } |
| }; |
| saveConfig(newConfig); |
| }; |
|
|
| const updateSchedulingConfig = (updates: Partial<StickySessionConfig>) => { |
| if (!appConfig) return; |
| const currentScheduling = appConfig.proxy.scheduling || { mode: 'Balance', max_wait_seconds: 60 }; |
| const newScheduling = { ...currentScheduling, ...updates }; |
|
|
| const newAppConfig = { |
| ...appConfig, |
| proxy: { |
| ...appConfig.proxy, |
| scheduling: newScheduling |
| } |
| }; |
| saveConfig(newAppConfig); |
| }; |
|
|
| const updateExperimentalConfig = (updates: Partial<ExperimentalConfig>) => { |
| if (!appConfig) return; |
| const newConfig = { |
| ...appConfig, |
| proxy: { |
| ...appConfig.proxy, |
| experimental: { |
| ...(appConfig.proxy.experimental || { |
| enable_usage_scaling: true, |
| context_compression_threshold_l1: 0.4, |
| context_compression_threshold_l2: 0.55, |
| context_compression_threshold_l3: 0.7 |
| }), |
| ...updates |
| } |
| } |
| }; |
| saveConfig(newConfig); |
| }; |
|
|
| const updateCircuitBreakerConfig = (newBreakerConfig: CircuitBreakerConfig) => { |
| if (!appConfig) return; |
| const newConfig = { |
| ...appConfig, |
| circuit_breaker: newBreakerConfig |
| }; |
| saveConfig(newConfig); |
| }; |
|
|
| const handleClearSessionBindings = () => { |
| setIsClearBindingsConfirmOpen(true); |
| }; |
|
|
| const executeClearSessionBindings = async () => { |
| setIsClearBindingsConfirmOpen(false); |
| try { |
| await invoke('clear_proxy_session_bindings'); |
| showToast(t('common.success'), 'success'); |
| } catch (error) { |
| console.error('Failed to clear session bindings:', error); |
| showToast(`${t('common.error')}: ${error}`, 'error'); |
| } |
| }; |
|
|
| const handleClearRateLimits = () => { |
| setIsClearRateLimitsConfirmOpen(true); |
| }; |
|
|
| const executeClearRateLimits = async () => { |
| setIsClearRateLimitsConfirmOpen(false); |
| try { |
| await invoke('clear_all_proxy_rate_limits'); |
| showToast(t('common.success'), 'success'); |
| } catch (error) { |
| console.error('Failed to clear rate limits:', error); |
| showToast(`${t('common.error')}: ${error}`, 'error'); |
| } |
| }; |
|
|
| const refreshZaiModels = async () => { |
| if (!appConfig?.proxy.zai) return; |
| setZaiModelsLoading(true); |
| setZaiModelsError(null); |
| try { |
| const models = await invoke<string[]>('fetch_zai_models', { |
| zai: appConfig.proxy.zai, |
| upstreamProxy: appConfig.proxy.upstream_proxy, |
| requestTimeout: appConfig.proxy.request_timeout, |
| }); |
| setZaiAvailableModels(models); |
| } catch (error: any) { |
| console.error('Failed to fetch z.ai models:', error); |
| setZaiModelsError(error.toString()); |
| } finally { |
| setZaiModelsLoading(false); |
| } |
| }; |
|
|
| const updateZaiDefaultModels = (updates: Partial<NonNullable<ProxyConfig['zai']>['models']>) => { |
| if (!appConfig?.proxy.zai) return; |
| const newConfig = { |
| ...appConfig, |
| proxy: { |
| ...appConfig.proxy, |
| zai: { |
| ...appConfig.proxy.zai, |
| models: { ...appConfig.proxy.zai.models, ...updates } |
| } |
| } |
| }; |
| saveConfig(newConfig); |
| }; |
|
|
| const upsertZaiModelMapping = (from: string, to: string) => { |
| if (!appConfig?.proxy.zai) return; |
| const currentMapping = appConfig.proxy.zai.model_mapping || {}; |
| const newMapping = { ...currentMapping, [from]: to }; |
|
|
| const newConfig = { |
| ...appConfig, |
| proxy: { |
| ...appConfig.proxy, |
| zai: { |
| ...appConfig.proxy.zai, |
| model_mapping: newMapping |
| } |
| } |
| }; |
| saveConfig(newConfig); |
| }; |
|
|
| const removeZaiModelMapping = (from: string) => { |
| if (!appConfig?.proxy.zai) return; |
| const currentMapping = appConfig.proxy.zai.model_mapping || {}; |
| const newMapping = { ...currentMapping }; |
| delete newMapping[from]; |
|
|
| const newConfig = { |
| ...appConfig, |
| proxy: { |
| ...appConfig.proxy, |
| zai: { |
| ...appConfig.proxy.zai, |
| model_mapping: newMapping |
| } |
| } |
| }; |
| saveConfig(newConfig); |
| }; |
|
|
| const updateZaiGeneralConfig = (updates: Partial<NonNullable<ProxyConfig['zai']>>) => { |
| if (!appConfig?.proxy.zai) return; |
| const newConfig = { |
| ...appConfig, |
| proxy: { |
| ...appConfig.proxy, |
| zai: { |
| ...appConfig.proxy.zai, |
| ...updates |
| } |
| } |
| }; |
| saveConfig(newConfig); |
| }; |
|
|
| const handleToggle = async () => { |
| if (!appConfig) return; |
| setLoading(true); |
| try { |
| if (status.running) { |
| await invoke('stop_proxy_service'); |
| } else { |
| |
| await invoke('start_proxy_service', { config: appConfig.proxy }); |
| } |
| await loadStatus(); |
| } catch (error: any) { |
| showToast(t('proxy.dialog.operate_failed', { error: error.toString() }), 'error'); |
| } finally { |
| setLoading(false); |
| } |
| }; |
|
|
| const handleGenerateApiKey = () => { |
| setIsRegenerateKeyConfirmOpen(true); |
| }; |
|
|
| const executeGenerateApiKey = async () => { |
| setIsRegenerateKeyConfirmOpen(false); |
| try { |
| const newKey = await invoke<string>('generate_api_key'); |
| updateProxyConfig({ api_key: newKey }); |
| showToast(t('common.success'), 'success'); |
| } catch (error: any) { |
| console.error('生成 API Key 失败:', error); |
| showToast(t('proxy.dialog.operate_failed', { error: error.toString() }), 'error'); |
| } |
| }; |
|
|
| const copyToClipboardHandler = (text: string, label: string) => { |
| copyToClipboard(text).then((success) => { |
| if (success) { |
| setCopied(label); |
| setTimeout(() => setCopied(null), 2000); |
| } |
| }); |
| }; |
|
|
| |
| const validateApiKey = (key: string): boolean => { |
| |
| return key.startsWith('sk-') && key.length >= 10; |
| }; |
|
|
| const handleEditApiKey = () => { |
| setTempApiKey(appConfig?.proxy.api_key || ''); |
| setIsEditingApiKey(true); |
| }; |
|
|
| const handleSaveApiKey = () => { |
| if (!validateApiKey(tempApiKey)) { |
| showToast(t('proxy.config.api_key_invalid'), 'error'); |
| return; |
| } |
| updateProxyConfig({ api_key: tempApiKey }); |
| setIsEditingApiKey(false); |
| showToast(t('proxy.config.api_key_updated'), 'success'); |
| }; |
|
|
| const handleCancelEditApiKey = () => { |
| setTempApiKey(''); |
| setIsEditingApiKey(false); |
| }; |
|
|
| |
| const handleEditAdminPassword = () => { |
| setTempAdminPassword(appConfig?.proxy.admin_password || ''); |
| setIsEditingAdminPassword(true); |
| }; |
|
|
| const handleSaveAdminPassword = () => { |
| |
| if (tempAdminPassword && tempAdminPassword.length < 4) { |
| showToast(t('proxy.config.admin_password_short', { defaultValue: 'Password is too short (min 4 chars)' }), 'error'); |
| return; |
| } |
| updateProxyConfig({ admin_password: tempAdminPassword || undefined }); |
| setIsEditingAdminPassword(false); |
| showToast(t('proxy.config.admin_password_updated', { defaultValue: 'Web UI password updated' }), 'success'); |
| }; |
|
|
| const handleCancelEditAdminPassword = () => { |
| setTempAdminPassword(''); |
| setIsEditingAdminPassword(false); |
| }; |
|
|
|
|
| const getPythonExample = (modelId: string) => { |
| const port = status.running ? status.port : (appConfig?.proxy.port || 8045); |
| |
| const baseUrl = `http://127.0.0.1:${port}/v1`; |
| const apiKey = appConfig?.proxy.api_key || 'YOUR_API_KEY'; |
|
|
| |
| if (selectedProtocol === 'anthropic') { |
| return `from anthropic import Anthropic |
| |
| client = Anthropic( |
| # 推荐使用 127.0.0.1 |
| base_url="${`http://127.0.0.1:${port}`}", |
| api_key="${apiKey}" |
| ) |
| |
| # 注意: Antigravity 支持使用 Anthropic SDK 调用任意模型 |
| response = client.messages.create( |
| model="${modelId}", |
| max_tokens=1024, |
| messages=[{"role": "user", "content": "Hello"}] |
| ) |
| |
| print(response.content[0].text)`; |
| } |
|
|
| |
| if (selectedProtocol === 'gemini') { |
| const rawBaseUrl = `http://127.0.0.1:${port}`; |
| return `# 需要安装: pip install google-generativeai |
| import google.generativeai as genai |
| |
| # 使用 Antigravity 代理地址 (推荐 127.0.0.1) |
| genai.configure( |
| api_key="${apiKey}", |
| transport='rest', |
| client_options={'api_endpoint': '${rawBaseUrl}'} |
| ) |
| |
| model = genai.GenerativeModel('${modelId}') |
| response = model.generate_content("Hello") |
| print(response.text)`; |
| } |
|
|
| |
| if (modelId.startsWith('gemini-3-pro-image')) { |
| return `from openai import OpenAI |
| |
| client = OpenAI( |
| base_url="${baseUrl}", |
| api_key="${apiKey}" |
| ) |
| |
| response = client.chat.completions.create( |
| model="${modelId}", |
| # 方式 1: 使用 size 参数 (推荐) |
| # 支持: "1024x1024" (1:1), "1280x720" (16:9), "720x1280" (9:16), "1216x896" (4:3) |
| extra_body={ "size": "1024x1024" }, |
| |
| # 方式 2: 使用模型后缀 |
| # 例如: gemini-3-pro-image-16-9, gemini-3-pro-image-4-3 |
| # model="gemini-3-pro-image-16-9", |
| messages=[{ |
| "role": "user", |
| "content": "Draw a futuristic city" |
| }] |
| ) |
| |
| print(response.choices[0].message.content)`; |
| } |
|
|
| return `from openai import OpenAI |
| |
| client = OpenAI( |
| base_url="${baseUrl}", |
| api_key="${apiKey}" |
| ) |
| |
| response = client.chat.completions.create( |
| model="${modelId}", |
| messages=[{"role": "user", "content": "Hello"}] |
| ) |
| |
| print(response.choices[0].message.content)`; |
| }; |
|
|
| |
| const filteredModels = models.filter(model => { |
| if (selectedProtocol === 'openai') { |
| return true; |
| } |
| |
| if (selectedProtocol === 'anthropic') { |
| return !model.id.includes('image'); |
| } |
| return true; |
| }); |
|
|
| return ( |
| <div className="h-full w-full overflow-y-auto overflow-x-hidden"> |
| <div className="p-5 space-y-4 max-w-7xl mx-auto"> |
| |
| {/* Loading State */} |
| {configLoading && ( |
| <div className="flex items-center justify-center py-20"> |
| <div className="flex flex-col items-center gap-4"> |
| <RefreshCw size={32} className="animate-spin text-blue-500" /> |
| <span className="text-sm text-gray-500 dark:text-gray-400"> |
| {t('common.loading')} |
| </span> |
| </div> |
| </div> |
| )} |
| |
| {/* Error State */} |
| {!configLoading && configError && ( |
| <div className="flex items-center justify-center py-20"> |
| <div className="flex flex-col items-center gap-4 text-center"> |
| <div className="w-16 h-16 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center"> |
| <Settings size={32} className="text-red-500" /> |
| </div> |
| <div className="space-y-2"> |
| <h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100"> |
| {t('proxy.error.load_failed')} |
| </h3> |
| <p className="text-sm text-gray-500 dark:text-gray-400 max-w-md"> |
| {configError} |
| </p> |
| </div> |
| <button |
| onClick={loadConfig} |
| className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium flex items-center gap-2 transition-colors" |
| > |
| <RefreshCw size={16} /> |
| {t('common.retry')} |
| </button> |
| </div> |
| </div> |
| )} |
| |
| {/* 配置区 */} |
| {!configLoading && !configError && appConfig && ( |
| <div className="bg-white dark:bg-base-100 rounded-xl shadow-sm border border-gray-100 dark:border-base-200"> |
| <div className="px-4 py-2.5 border-b border-gray-100 dark:border-base-200 flex items-center justify-between"> |
| <div className="flex items-center gap-4"> |
| <h2 className="text-base font-semibold flex items-center gap-2 text-gray-900 dark:text-base-content"> |
| <Settings size={18} /> |
| {t('proxy.config.title')} |
| </h2> |
| {/* 状态指示器 */} |
| <div className="flex items-center gap-2 pl-4 border-l border-gray-200 dark:border-base-300"> |
| <div className={`w-2 h-2 rounded-full ${status.running ? 'bg-green-500 animate-pulse' : 'bg-gray-400'}`} /> |
| <span className={`text-xs font-medium ${status.running ? 'text-green-600' : 'text-gray-500'}`}> |
| {status.running |
| ? `${t('proxy.status.running')} (${status.active_accounts} ${t('common.accounts')})` |
| : t('proxy.status.stopped')} |
| </span> |
| </div> |
| </div> |
| |
| {/* 控制按钮 */} |
| <div className="flex items-center gap-2"> |
| <button |
| onClick={handleToggle} |
| disabled={loading || !appConfig} |
| className={`px-3 py-1 rounded-lg text-xs font-medium transition-colors flex items-center gap-2 ${status.running |
| ? 'bg-red-50 to-red-600 text-red-600 hover:bg-red-100 border border-red-200' |
| : 'bg-blue-600 hover:bg-blue-700 text-white shadow-sm shadow-blue-500/30' |
| } ${(loading || !appConfig) ? 'opacity-50 cursor-not-allowed' : ''}`} |
| > |
| <Power size={14} /> |
| {loading ? t('proxy.status.processing') : (status.running ? t('proxy.action.stop') : t('proxy.action.start'))} |
| </button> |
| </div> |
| </div> |
| <div className="p-3 space-y-3"> |
| {/* 监听端口、超时和自启动 */} |
| <div className="grid grid-cols-1 md:grid-cols-3 gap-3"> |
| <div> |
| <label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1"> |
| <span className="inline-flex items-center gap-1"> |
| {t('proxy.config.port')} |
| <HelpTooltip |
| text={t('proxy.config.port_tooltip')} |
| ariaLabel={t('proxy.config.port')} |
| placement="right" |
| /> |
| </span> |
| </label> |
| <input |
| type="number" |
| value={appConfig.proxy.port} |
| onChange={(e) => updateProxyConfig({ port: parseInt(e.target.value) })} |
| min={8000} |
| max={65535} |
| disabled={status.running} |
| className="w-full px-2.5 py-1.5 border border-gray-300 dark:border-base-200 rounded-lg bg-white dark:bg-base-200 text-xs text-gray-900 dark:text-base-content focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed" |
| /> |
| <p className="mt-0.5 text-[10px] text-gray-500 dark:text-gray-400"> |
| {t('proxy.config.port_hint')} |
| </p> |
| </div> |
| <div> |
| <label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1"> |
| <span className="inline-flex items-center gap-1"> |
| {t('proxy.config.request_timeout')} |
| <HelpTooltip |
| text={t('proxy.config.request_timeout_tooltip')} |
| ariaLabel={t('proxy.config.request_timeout')} |
| placement="top" |
| /> |
| </span> |
| </label> |
| <input |
| type="number" |
| value={appConfig.proxy.request_timeout || 120} |
| onChange={(e) => { |
| const value = parseInt(e.target.value); |
| const timeout = Math.max(30, Math.min(7200, value)); |
| updateProxyConfig({ request_timeout: timeout }); |
| }} |
| min={30} |
| max={7200} |
| disabled={status.running} |
| className="w-full px-2.5 py-1.5 border border-gray-300 dark:border-base-200 rounded-lg bg-white dark:bg-base-200 text-xs text-gray-900 dark:text-base-content focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed" |
| /> |
| <p className="mt-0.5 text-[10px] text-gray-500 dark:text-gray-400"> |
| {t('proxy.config.request_timeout_hint')} |
| </p> |
| </div> |
| <div className="flex items-center"> |
| <label className="flex items-center cursor-pointer gap-3"> |
| <input |
| type="checkbox" |
| className="toggle toggle-sm bg-gray-200 dark:bg-gray-700 border-gray-300 dark:border-gray-600 checked:bg-blue-500 checked:border-blue-500 disabled:opacity-50 disabled:bg-gray-100 dark:disabled:bg-gray-800" |
| checked={appConfig.proxy.auto_start} |
| onChange={(e) => updateProxyConfig({ auto_start: e.target.checked })} |
| /> |
| <span className="text-xs font-medium text-gray-900 dark:text-base-content inline-flex items-center gap-1"> |
| {t('proxy.config.auto_start')} |
| <HelpTooltip |
| text={t('proxy.config.auto_start_tooltip')} |
| ariaLabel={t('proxy.config.auto_start')} |
| placement="right" |
| /> |
| </span> |
| </label> |
| </div> |
| </div> |
| |
| |
| {/* 局域网访问 & 访问授权 - 合并到同一行 */} |
| <div className="border-t border-gray-200 dark:border-base-300 pt-3 mt-3"> |
| <div className="grid grid-cols-1 lg:grid-cols-2 gap-4"> |
| {/* 允许局域网访问 */} |
| <div className="space-y-2"> |
| <div className="flex items-center justify-between"> |
| <span className="text-xs font-medium text-gray-700 dark:text-gray-300 inline-flex items-center gap-1"> |
| {t('proxy.config.allow_lan_access')} |
| <HelpTooltip |
| text={t('proxy.config.allow_lan_access_tooltip')} |
| ariaLabel={t('proxy.config.allow_lan_access')} |
| placement="right" |
| /> |
| </span> |
| <input |
| type="checkbox" |
| className="toggle toggle-sm bg-gray-200 dark:bg-gray-700 border-gray-300 dark:border-gray-600 checked:bg-blue-500 checked:border-blue-500" |
| checked={appConfig.proxy.allow_lan_access || false} |
| onChange={(e) => updateProxyConfig({ allow_lan_access: e.target.checked })} |
| /> |
| </div> |
| <p className="text-[10px] text-gray-500 dark:text-gray-400"> |
| {(appConfig.proxy.allow_lan_access || false) |
| ? t('proxy.config.allow_lan_access_hint_enabled') |
| : t('proxy.config.allow_lan_access_hint_disabled')} |
| </p> |
| {(appConfig.proxy.allow_lan_access || false) && ( |
| <p className="text-[10px] text-amber-600 dark:text-amber-500"> |
| {t('proxy.config.allow_lan_access_warning')} |
| </p> |
| )} |
| {status.running && ( |
| <p className="text-[10px] text-blue-600 dark:text-blue-400"> |
| {t('proxy.config.allow_lan_access_restart_hint')} |
| </p> |
| )} |
| </div> |
| |
| {/* 访问授权 */} |
| <div className="space-y-2"> |
| <div className="flex items-center justify-between"> |
| <label className="text-xs font-medium text-gray-700 dark:text-gray-300"> |
| <span className="inline-flex items-center gap-1"> |
| {t('proxy.config.auth.title')} |
| <HelpTooltip |
| text={t('proxy.config.auth.title_tooltip')} |
| ariaLabel={t('proxy.config.auth.title')} |
| placement="top" |
| /> |
| </span> |
| </label> |
| <label className="flex items-center cursor-pointer gap-2"> |
| <span className="text-[11px] text-gray-600 dark:text-gray-400 inline-flex items-center gap-1"> |
| {(appConfig.proxy.auth_mode || 'off') !== 'off' ? t('proxy.config.auth.enabled') : t('common.disabled')} |
| <HelpTooltip |
| text={t('proxy.config.auth.enabled_tooltip')} |
| ariaLabel={t('proxy.config.auth.enabled')} |
| placement="left" |
| /> |
| </span> |
| <input |
| type="checkbox" |
| className="toggle toggle-sm bg-gray-200 dark:bg-gray-700 border-gray-300 dark:border-gray-600 checked:bg-blue-500 checked:border-blue-500 disabled:opacity-50 disabled:bg-gray-100 dark:disabled:bg-gray-800" |
| checked={(appConfig.proxy.auth_mode || 'off') !== 'off'} |
| onChange={(e) => { |
| const nextMode = e.target.checked ? 'all_except_health' : 'off'; |
| updateProxyConfig({ auth_mode: nextMode }); |
| }} |
| /> |
| </label> |
| </div> |
| |
| <div> |
| <label className="block text-[11px] text-gray-600 dark:text-gray-400 mb-1"> |
| <span className="inline-flex items-center gap-1"> |
| {t('proxy.config.auth.mode')} |
| <HelpTooltip |
| text={t('proxy.config.auth.mode_tooltip')} |
| ariaLabel={t('proxy.config.auth.mode')} |
| placement="top" |
| /> |
| </span> |
| </label> |
| <select |
| value={appConfig.proxy.auth_mode || 'off'} |
| onChange={(e) => |
| updateProxyConfig({ |
| auth_mode: e.target.value as ProxyConfig['auth_mode'], |
| }) |
| } |
| className="w-full px-2.5 py-1.5 border border-gray-300 dark:border-base-200 rounded-lg bg-white dark:bg-base-200 text-xs text-gray-900 dark:text-base-content focus:ring-2 focus:ring-blue-500 focus:border-transparent" |
| > |
| <option value="off">{t('proxy.config.auth.modes.off')}</option> |
| <option value="strict">{t('proxy.config.auth.modes.strict')}</option> |
| <option value="all_except_health">{t('proxy.config.auth.modes.all_except_health')}</option> |
| <option value="auto">{t('proxy.config.auth.modes.auto')}</option> |
| </select> |
| <p className="mt-0.5 text-[10px] text-gray-500 dark:text-gray-400"> |
| {t('proxy.config.auth.hint')} |
| </p> |
| </div> |
| </div> |
| </div> |
| </div> |
| |
| {/* API 密钥 */} |
| <div> |
| <label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1"> |
| <span className="inline-flex items-center gap-1"> |
| {t('proxy.config.api_key')} |
| <HelpTooltip |
| text={t('proxy.config.api_key_tooltip')} |
| ariaLabel={t('proxy.config.api_key')} |
| placement="right" |
| /> |
| </span> |
| </label> |
| <div className="flex gap-2"> |
| <input |
| type="text" |
| value={isEditingApiKey ? tempApiKey : (appConfig.proxy.api_key)} |
| onChange={(e) => isEditingApiKey && setTempApiKey(e.target.value)} |
| readOnly={!isEditingApiKey} |
| className={`flex-1 px-2.5 py-1.5 border border-gray-300 dark:border-base-200 rounded-lg text-xs font-mono ${isEditingApiKey |
| ? 'bg-white dark:bg-base-200 text-gray-900 dark:text-base-content' |
| : 'bg-gray-50 dark:bg-base-300 text-gray-600 dark:text-gray-400' |
| }`} |
| /> |
| {isEditingApiKey ? ( |
| <> |
| <button |
| onClick={handleSaveApiKey} |
| className="px-2.5 py-1.5 border border-green-300 dark:border-green-700 rounded-lg bg-green-50 dark:bg-green-900/20 hover:bg-green-100 dark:hover:bg-green-900/30 transition-colors text-green-600 dark:text-green-400" |
| title={t('proxy.config.btn_save')} |
| > |
| <CheckCircle size={14} /> |
| </button> |
| <button |
| onClick={handleCancelEditApiKey} |
| className="px-2.5 py-1.5 border border-gray-300 dark:border-base-200 rounded-lg bg-white dark:bg-base-200 hover:bg-gray-50 dark:hover:bg-base-300 transition-colors" |
| title={t('common.cancel')} |
| > |
| <X size={14} /> |
| </button> |
| </> |
| ) : ( |
| <> |
| <button |
| onClick={handleEditApiKey} |
| className="px-2.5 py-1.5 border border-gray-300 dark:border-base-200 rounded-lg bg-white dark:bg-base-200 hover:bg-gray-50 dark:hover:bg-base-300 transition-colors" |
| title={t('proxy.config.btn_edit')} |
| > |
| <Edit2 size={14} /> |
| </button> |
| <button |
| onClick={handleGenerateApiKey} |
| className="px-2.5 py-1.5 border border-gray-300 dark:border-base-200 rounded-lg bg-white dark:bg-base-200 hover:bg-gray-50 dark:hover:bg-base-300 transition-colors" |
| title={t('proxy.config.btn_regenerate')} |
| > |
| <RefreshCw size={14} /> |
| </button> |
| <button |
| onClick={() => copyToClipboardHandler(appConfig.proxy.api_key, 'api_key')} |
| className="px-2.5 py-1.5 border border-gray-300 dark:border-base-200 rounded-lg bg-white dark:bg-base-200 hover:bg-gray-50 dark:hover:bg-base-300 transition-colors" |
| title={t('proxy.config.btn_copy')} |
| > |
| {copied === 'api_key' ? ( |
| <CheckCircle size={14} className="text-green-500" /> |
| ) : ( |
| <Copy size={14} /> |
| )} |
| </button> |
| </> |
| )} |
| </div> |
| <p className="mt-0.5 text-[10px] text-amber-600 dark:text-amber-500"> |
| {t('proxy.config.warning_key')} |
| </p> |
| </div> |
| |
| {/* Web UI 管理密码 */} |
| <div className="border-t border-gray-200 dark:border-base-300 pt-3 mt-3"> |
| <label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1"> |
| <span className="inline-flex items-center gap-1"> |
| {t('proxy.config.admin_password', { defaultValue: 'Web UI Login Password' })} |
| <HelpTooltip |
| text={t('proxy.config.admin_password_tooltip', { defaultValue: 'Used for logging into the Web Management Console. If empty, it defaults to the API Key.' })} |
| ariaLabel={t('proxy.config.admin_password')} |
| placement="right" |
| /> |
| </span> |
| </label> |
| <div className="flex gap-2"> |
| <input |
| type="text" |
| value={isEditingAdminPassword ? tempAdminPassword : (appConfig.proxy.admin_password || t('proxy.config.admin_password_default', { defaultValue: '(Same as API Key)' }))} |
| onChange={(e) => isEditingAdminPassword && setTempAdminPassword(e.target.value)} |
| readOnly={!isEditingAdminPassword} |
| placeholder={t('proxy.config.admin_password_placeholder', { defaultValue: 'Enter new password or leave empty to use API Key' })} |
| className={`flex-1 px-2.5 py-1.5 border border-gray-300 dark:border-base-200 rounded-lg text-xs font-mono ${isEditingAdminPassword |
| ? 'bg-white dark:bg-base-200 text-gray-900 dark:text-base-content' |
| : 'bg-gray-50 dark:bg-base-300 text-gray-600 dark:text-gray-400' |
| }`} |
| /> |
| {isEditingAdminPassword ? ( |
| <> |
| <button |
| onClick={handleSaveAdminPassword} |
| className="px-2.5 py-1.5 border border-green-300 dark:border-green-700 rounded-lg bg-green-50 dark:bg-green-900/20 hover:bg-green-100 dark:hover:bg-green-900/30 transition-colors text-green-600 dark:text-green-400" |
| title={t('proxy.config.btn_save')} |
| > |
| <CheckCircle size={14} /> |
| </button> |
| <button |
| onClick={handleCancelEditAdminPassword} |
| className="px-2.5 py-1.5 border border-gray-300 dark:border-base-200 rounded-lg bg-white dark:bg-base-200 hover:bg-gray-50 dark:hover:bg-base-300 transition-colors" |
| title={t('common.cancel')} |
| > |
| <X size={14} /> |
| </button> |
| </> |
| ) : ( |
| <> |
| <button |
| onClick={handleEditAdminPassword} |
| className="px-2.5 py-1.5 border border-gray-300 dark:border-base-200 rounded-lg bg-white dark:bg-base-200 hover:bg-gray-50 dark:hover:bg-base-300 transition-colors" |
| title={t('proxy.config.btn_edit')} |
| > |
| <Edit2 size={14} /> |
| </button> |
| <button |
| onClick={() => copyToClipboardHandler(appConfig.proxy.admin_password || appConfig.proxy.api_key, 'admin_password')} |
| className="px-2.5 py-1.5 border border-gray-300 dark:border-base-200 rounded-lg bg-white dark:bg-base-200 hover:bg-gray-50 dark:hover:bg-base-300 transition-colors" |
| title={t('proxy.config.btn_copy')} |
| > |
| {copied === 'admin_password' ? ( |
| <CheckCircle size={14} className="text-green-500" /> |
| ) : ( |
| <Copy size={14} /> |
| )} |
| </button> |
| </> |
| )} |
| </div> |
| <p className="mt-0.5 text-[10px] text-gray-500 dark:text-gray-400"> |
| {t('proxy.config.admin_password_hint', { defaultValue: 'For safety in Docker/Browser environments, you can set a separate login password from your API Key.' })} |
| </p> |
| </div> |
|
|
| {} |
| <div className="border-t border-gray-200 dark:border-base-300 pt-3 mt-3"> |
| <div className="flex items-center justify-between mb-2"> |
| <label className="text-xs font-medium text-gray-700 dark:text-gray-300 inline-flex items-center gap-1"> |
| {t('proxy.config.request.user_agent', { defaultValue: 'User-Agent Override' })} |
| <HelpTooltip text={t('proxy.config.request.user_agent_tooltip', { defaultValue: 'Override the User-Agent header sent to upstream APIs.' })} /> |
| </label> |
| <input |
| type="checkbox" |
| className="toggle toggle-sm bg-gray-200 dark:bg-gray-700 border-gray-300 dark:border-gray-600 checked:bg-blue-500 checked:border-blue-500" |
| checked={!!appConfig.proxy.user_agent_override} |
| onChange={(e) => { |
| const enabled = e.target.checked; |
| if (enabled) { |
| // Restore saved override from config or use default |
| const restoredValue = appConfig.proxy.saved_user_agent || 'antigravity/1.15.8 darwin/arm64'; |
| updateProxyConfig({ |
| user_agent_override: restoredValue, |
| saved_user_agent: restoredValue |
| }); |
| } else { |
| // Disable active override but keep saved value |
| updateProxyConfig({ user_agent_override: undefined }); |
| } |
| }} |
| /> |
| </div> |
|
|
| {!!appConfig.proxy.user_agent_override && ( |
| <div className="space-y-2 animate-in fade-in slide-in-from-top-1 duration-200"> |
| <input |
| type="text" |
| value={appConfig.proxy.user_agent_override} |
| onChange={(e) => { |
| const newValue = e.target.value; |
| updateProxyConfig({ |
| user_agent_override: newValue, |
| saved_user_agent: newValue |
| }); |
| }} |
| className="w-full px-2.5 py-1.5 border border-gray-300 dark:border-base-200 rounded-lg bg-white dark:bg-base-200 text-xs font-mono text-gray-900 dark:text-base-content focus:ring-2 focus:ring-blue-500 focus:border-transparent" |
| placeholder={t('proxy.config.request.user_agent_placeholder', { defaultValue: 'Enter custom User-Agent string...' })} |
| /> |
| <div className="bg-gray-50 dark:bg-base-300 rounded p-2 text-[10px] text-gray-500 font-mono break-all"> |
| <span className="font-bold select-none mr-2">{t('common.example', { defaultValue: 'Example' })}:</span> |
| antigravity/1.15.8 darwin/arm64 |
| </div> |
| </div> |
| )} |
| </div> |
|
|
|
|
| </div> |
| </div> |
| )} |
|
|
| {} |
| { |
| !configLoading && !configError && appConfig && ( |
| <div className="space-y-4"> |
| <CollapsibleCard |
| title={t('proxy.cli_sync.title', { defaultValue: 'CLI Sync' })} |
| icon={<Terminal size={18} className="text-gray-500" />} |
| defaultExpanded={false} |
| > |
| <CliSyncCard |
| proxyUrl={status.running ? status.base_url : `http://127.0.0.1:${appConfig.proxy.port || 8045}`} |
| apiKey={appConfig.proxy.api_key} |
| /> |
| </CollapsibleCard> |
| |
| {/* z.ai (GLM) Dispatcher */} |
| <CollapsibleCard |
| title={t('proxy.config.zai.title')} |
| icon={<Zap size={18} className="text-amber-500" />} |
| enabled={!!appConfig.proxy.zai?.enabled} |
| onToggle={(checked) => updateZaiGeneralConfig({ enabled: checked })} |
| > |
| <div className="space-y-4"> |
| <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> |
| <div className="space-y-1"> |
| <label className="text-[11px] font-medium text-gray-500 dark:text-gray-400"> |
| {t('proxy.config.zai.base_url')} |
| </label> |
| <input |
| type="text" |
| value={appConfig.proxy.zai?.base_url || 'https://api.z.ai/api/anthropic'} |
| onChange={(e) => updateZaiGeneralConfig({ base_url: e.target.value })} |
| className="input input-sm input-bordered w-full font-mono text-xs" |
| /> |
| </div> |
| <div className="space-y-1"> |
| <label className="text-[11px] font-medium text-gray-500 dark:text-gray-400"> |
| {t('proxy.config.zai.dispatch_mode')} |
| </label> |
| <select |
| className="select select-sm select-bordered w-full text-xs" |
| value={appConfig.proxy.zai?.dispatch_mode || 'off'} |
| onChange={(e) => updateZaiGeneralConfig({ dispatch_mode: e.target.value as any })} |
| > |
| <option value="off">{t('proxy.config.zai.modes.off')}</option> |
| <option value="exclusive">{t('proxy.config.zai.modes.exclusive')}</option> |
| <option value="pooled">{t('proxy.config.zai.modes.pooled')}</option> |
| <option value="fallback">{t('proxy.config.zai.modes.fallback')}</option> |
| </select> |
| </div> |
| </div> |
| |
| <div className="space-y-1"> |
| <label className="text-[11px] font-medium text-gray-500 dark:text-gray-400 flex items-center justify-between"> |
| <span>{t('proxy.config.zai.api_key')}</span> |
| {!(appConfig.proxy.zai?.api_key) && ( |
| <span className="text-amber-500 text-[10px] flex items-center gap-1"> |
| <HelpTooltip text={t('proxy.config.zai.warning')} /> |
| {t('common.required')} |
| </span> |
| )} |
| </label> |
| <input |
| type="password" |
| value={appConfig.proxy.zai?.api_key || ''} |
| onChange={(e) => updateZaiGeneralConfig({ api_key: e.target.value })} |
| placeholder="sk-..." |
| className="input input-sm input-bordered w-full font-mono text-xs" |
| /> |
| </div> |
| |
| {/* Model Mapping Section */} |
| <div className="pt-4 border-t border-gray-100 dark:border-base-200"> |
| <div className="flex items-center justify-between mb-3"> |
| <h4 className="text-[11px] font-bold text-gray-400 uppercase tracking-widest"> |
| {t('proxy.config.zai.models.title')} |
| </h4> |
| <button |
| onClick={refreshZaiModels} |
| disabled={zaiModelsLoading || !appConfig.proxy.zai?.api_key} |
| className="btn btn-ghost btn-xs gap-1" |
| > |
| <RefreshCw size={12} className={zaiModelsLoading ? 'animate-spin' : ''} /> |
| {t('proxy.config.zai.models.refresh')} |
| </button> |
| </div> |
| |
| <div className="grid grid-cols-1 md:grid-cols-3 gap-3"> |
| {['opus', 'sonnet', 'haiku'].map((family) => ( |
| <div key={family} className="space-y-1"> |
| <label className="text-[10px] text-gray-500 capitalize">{family}</label> |
| <div className="flex gap-1"> |
| {zaiModelOptions.length > 0 && ( |
| <select |
| className="select select-xs select-bordered max-w-[80px]" |
| value="" |
| onChange={(e) => e.target.value && updateZaiDefaultModels({ [family]: e.target.value })} |
| > |
| <option value="">{t('proxy.config.zai.models.select_placeholder')}</option> |
| {zaiModelOptions.map(m => <option key={m} value={m}>{m}</option>)} |
| </select> |
| )} |
| <input |
| type="text" |
| className="input input-xs input-bordered w-full font-mono" |
| value={appConfig.proxy.zai?.models?.[family as keyof typeof appConfig.proxy.zai.models] || ''} |
| onChange={(e) => updateZaiDefaultModels({ [family]: e.target.value })} |
| /> |
| </div> |
| </div> |
| ))} |
| </div> |
| |
| <details className="mt-3 group"> |
| <summary className="cursor-pointer text-[10px] text-gray-500 hover:text-blue-500 transition-colors inline-flex items-center gap-1 select-none"> |
| <Settings size={12} /> |
| {t('proxy.config.zai.models.advanced_title')} |
| </summary> |
| <div className="mt-2 space-y-2 p-2 bg-gray-50 dark:bg-base-200/50 rounded-lg"> |
| {/* Advanced Mapping Table */} |
| {Object.entries(zaiModelMapping).map(([from, to]) => ( |
| <div key={from} className="flex items-center gap-2"> |
| <div className="flex-1 bg-white dark:bg-base-100 px-2 py-1 rounded border border-gray-200 dark:border-base-300 text-[10px] font-mono truncate" title={from}>{from}</div> |
| <ArrowRight size={10} className="text-gray-400" /> |
| <div className="flex-[1.5] flex gap-1"> |
| {zaiModelOptions.length > 0 && ( |
| <select |
| className="select select-xs select-ghost h-6 min-h-0 px-1" |
| value="" |
| onChange={(e) => e.target.value && upsertZaiModelMapping(from, e.target.value)} |
| > |
| <option value="">▼</option> |
| {zaiModelOptions.map(m => <option key={m} value={m}>{m}</option>)} |
| </select> |
| )} |
| <input |
| type="text" |
| className="input input-xs input-bordered w-full font-mono h-6" |
| value={to} |
| onChange={(e) => upsertZaiModelMapping(from, e.target.value)} |
| /> |
| </div> |
| <button onClick={() => removeZaiModelMapping(from)} className="text-gray-400 hover:text-red-500"><Trash2 size={12} /></button> |
| </div> |
| ))} |
| |
| <div className="flex items-center gap-2 pt-2 border-t border-gray-200/50"> |
| <input |
| className="input input-xs input-bordered flex-1 font-mono" |
| placeholder={t('proxy.config.zai.models.from_placeholder') || "From (e.g. claude-3-opus)"} |
| value={zaiNewMappingFrom} |
| onChange={e => setZaiNewMappingFrom(e.target.value)} |
| /> |
| <input |
| className="input input-xs input-bordered flex-1 font-mono" |
| placeholder={t('proxy.config.zai.models.to_placeholder') || "To (e.g. glm-4)"} |
| value={zaiNewMappingTo} |
| onChange={e => setZaiNewMappingTo(e.target.value)} |
| /> |
| <button |
| className="btn btn-xs btn-primary" |
| onClick={() => { |
| if (zaiNewMappingFrom && zaiNewMappingTo) { |
| upsertZaiModelMapping(zaiNewMappingFrom, zaiNewMappingTo); |
| setZaiNewMappingFrom(''); |
| setZaiNewMappingTo(''); |
| } |
| }} |
| > |
| <Plus size={12} /> |
| </button> |
| </div> |
| </div> |
| </details> |
| </div> |
| </div> |
| </CollapsibleCard> |
| |
| {/* MCP System */} |
| <CollapsibleCard |
| title={t('proxy.config.zai.mcp.title')} |
| icon={<Puzzle size={18} className="text-blue-500" />} |
| enabled={!!appConfig.proxy.zai?.mcp?.enabled} |
| onToggle={(checked) => updateZaiGeneralConfig({ mcp: { ...(appConfig.proxy.zai?.mcp || {}), enabled: checked } as any })} |
| rightElement={ |
| <div className="flex gap-2 text-[10px]"> |
| {['web_search', 'web_reader', 'vision'].map(f => |
| appConfig.proxy.zai?.mcp?.[(f + '_enabled') as keyof typeof appConfig.proxy.zai.mcp] && ( |
| <span key={f} className="bg-blue-500 dark:bg-blue-600 px-1.5 py-0.5 rounded text-white font-semibold shadow-sm"> |
| {t(`proxy.config.zai.mcp.${f}`).split(' ')[0]} |
| </span> |
| ) |
| )} |
| </div> |
| } |
| > |
| <div className="space-y-3"> |
| <div className="grid grid-cols-2 md:grid-cols-4 gap-3"> |
| <label className="flex items-center gap-2 border border-gray-100 dark:border-base-200 p-2 rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-base-200/50 transition-colors"> |
| <input |
| type="checkbox" |
| className="checkbox checkbox-xs rounded border-2 border-gray-400 dark:border-gray-500 checked:border-blue-600 checked:bg-blue-600 [--chkbg:theme(colors.blue.600)] [--chkfg:white]" |
| checked={!!appConfig.proxy.zai?.mcp?.web_search_enabled} |
| onChange={(e) => updateZaiGeneralConfig({ mcp: { ...(appConfig.proxy.zai?.mcp || {}), web_search_enabled: e.target.checked } as any })} |
| /> |
| <span className="text-xs">{t('proxy.config.zai.mcp.web_search')}</span> |
| </label> |
| <label className="flex items-center gap-2 border border-gray-100 dark:border-base-200 p-2 rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-base-200/50 transition-colors"> |
| <input |
| type="checkbox" |
| className="checkbox checkbox-xs rounded border-2 border-gray-400 dark:border-gray-500 checked:border-blue-600 checked:bg-blue-600 [--chkbg:theme(colors.blue.600)] [--chkfg:white]" |
| checked={!!appConfig.proxy.zai?.mcp?.web_reader_enabled} |
| onChange={(e) => updateZaiGeneralConfig({ mcp: { ...(appConfig.proxy.zai?.mcp || {}), web_reader_enabled: e.target.checked } as any })} |
| /> |
| <span className="text-xs">{t('proxy.config.zai.mcp.web_reader')}</span> |
| </label> |
| <label className="flex items-center gap-2 border border-gray-100 dark:border-base-200 p-2 rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-base-200/50 transition-colors"> |
| <input |
| type="checkbox" |
| className="checkbox checkbox-xs rounded border-2 border-gray-400 dark:border-gray-500 checked:border-blue-600 checked:bg-blue-600 [--chkbg:theme(colors.blue.600)] [--chkfg:white]" |
| checked={!!appConfig.proxy.zai?.mcp?.vision_enabled} |
| onChange={(e) => updateZaiGeneralConfig({ mcp: { ...(appConfig.proxy.zai?.mcp || {}), vision_enabled: e.target.checked } as any })} |
| /> |
| <span className="text-xs">{t('proxy.config.zai.mcp.vision')}</span> |
| </label> |
| </div> |
| |
| {appConfig.proxy.zai?.mcp?.enabled && ( |
| <div className="bg-slate-100 dark:bg-slate-800/80 rounded-lg p-3 text-[10px] font-mono text-slate-600 dark:text-slate-400"> |
| <div className="mb-1 font-bold text-gray-400 uppercase tracking-wider">{t('proxy.config.zai.mcp.local_endpoints')}</div> |
| <div className="space-y-0.5 select-all"> |
| {appConfig.proxy.zai?.mcp?.web_search_enabled && <div>http://127.0.0.1:{status.running ? status.port : (appConfig.proxy.port || 8045)}/mcp/web_search_prime/mcp</div>} |
| {appConfig.proxy.zai?.mcp?.web_reader_enabled && <div>http://127.0.0.1:{status.running ? status.port : (appConfig.proxy.port || 8045)}/mcp/web_reader/mcp</div>} |
| {appConfig.proxy.zai?.mcp?.vision_enabled && <div>http://127.0.0.1:{status.running ? status.port : (appConfig.proxy.port || 8045)}/mcp/zai-mcp-server/mcp</div>} |
| </div> |
| </div> |
| )} |
| </div> |
| </CollapsibleCard> |
| |
| {/* Account Scheduling & Rotation */} |
| <CollapsibleCard |
| title={t('proxy.config.scheduling.title')} |
| icon={<RefreshCw size={18} className="text-indigo-500" />} |
| > |
| <div className="space-y-4"> |
| <div className="grid grid-cols-1 md:grid-cols-2 gap-6"> |
| <div className="space-y-3"> |
| <div className="flex items-center justify-between"> |
| <label className="text-xs font-medium text-gray-700 dark:text-gray-300 inline-flex items-center gap-1"> |
| {t('proxy.config.scheduling.mode')} |
| <HelpTooltip |
| text={t('proxy.config.scheduling.mode_tooltip')} |
| placement="right" |
| /> |
| </label> |
| <div className="flex items-center gap-3"> |
| {/* [MOVED] Clear Rate Limit button moved to CircuitBreaker component */} |
| <button |
| onClick={handleClearSessionBindings} |
| className="text-[10px] text-indigo-500 hover:text-indigo-600 transition-colors flex items-center gap-1" |
| title={t('proxy.config.scheduling.clear_bindings_tooltip')} |
| > |
| <Trash2 size={12} /> |
| {t('proxy.config.scheduling.clear_bindings')} |
| </button> |
| </div> |
| </div> |
| <div className="grid grid-cols-1 gap-2"> |
| {(['CacheFirst', 'Balance', 'PerformanceFirst'] as const).map(mode => ( |
| <label |
| key={mode} |
| className={`flex items-start gap-3 p-3 rounded-xl border cursor-pointer transition-all duration-200 ${(appConfig.proxy.scheduling?.mode || 'Balance') === mode |
| ? 'border-indigo-500 bg-indigo-50/30 dark:bg-indigo-900/10' |
| : 'border-gray-100 dark:border-base-200 hover:border-indigo-200' |
| }`} |
| > |
| <input |
| type="radio" |
| className="radio radio-xs radio-primary mt-1" |
| checked={(appConfig.proxy.scheduling?.mode || 'Balance') === mode} |
| onChange={() => updateSchedulingConfig({ mode })} |
| /> |
| <div className="space-y-1"> |
| <div className="text-xs font-bold text-gray-900 dark:text-base-content"> |
| {t(`proxy.config.scheduling.modes.${mode}`)} |
| </div> |
| <div className="text-[10px] text-gray-500 line-clamp-2"> |
| {t(`proxy.config.scheduling.modes_desc.${mode}`, { |
| defaultValue: mode === 'CacheFirst' ? 'Binds session to account, waits precisely if limited (Maximizes Prompt Cache hits).' : |
| mode === 'Balance' ? 'Binds session, auto-switches to available account if limited (Balanced cache & availability).' : |
| 'No session binding, pure round-robin rotation (Best for high concurrency).' |
| })} |
| </div> |
| </div> |
| </label> |
| ))} |
| </div> |
| </div> |
| |
| <div className="space-y-4 pt-1"> |
| <div className="bg-slate-100 dark:bg-slate-800/80 rounded-xl p-4 border border-slate-200 dark:border-slate-700"> |
| <div className="flex items-center justify-between mb-2"> |
| <label className="text-xs font-medium text-gray-700 dark:text-gray-300 inline-flex items-center gap-1"> |
| {t('proxy.config.scheduling.max_wait')} |
| <HelpTooltip text={t('proxy.config.scheduling.max_wait_tooltip')} /> |
| </label> |
| <span className="text-xs font-mono text-indigo-600 font-bold"> |
| {appConfig.proxy.scheduling?.max_wait_seconds || 60}s |
| </span> |
| </div> |
| <input |
| type="range" |
| min="0" |
| max="300" |
| step="10" |
| disabled={(appConfig.proxy.scheduling?.mode || 'Balance') !== 'CacheFirst'} |
| className="range range-indigo range-xs" |
| value={appConfig.proxy.scheduling?.max_wait_seconds || 60} |
| onChange={(e) => updateSchedulingConfig({ max_wait_seconds: parseInt(e.target.value) })} |
| /> |
| <div className="flex justify-between px-1 mt-1 text-[10px] text-gray-400 font-mono"> |
| <span>0s</span> |
| <span>300s</span> |
| </div> |
| </div> |
| |
| <div className="p-3 bg-amber-50 dark:bg-amber-900/10 border border-amber-100 dark:border-amber-900/20 rounded-xl"> |
| <p className="text-[10px] text-amber-700 dark:text-amber-500 leading-relaxed"> |
| <strong>{t('common.info')}:</strong> {t('proxy.config.scheduling.subtitle')} |
| </p> |
| </div> |
| |
| {/* [FIX #820] Fixed Account Mode */} |
| <div className="bg-indigo-50 dark:bg-indigo-900/20 rounded-xl p-4 border border-indigo-200 dark:border-indigo-800"> |
| <div className="flex items-center justify-between mb-3"> |
| <label className="text-xs font-medium text-gray-700 dark:text-gray-300 inline-flex items-center gap-1"> |
| 🔒 {t('proxy.config.scheduling.fixed_account', { defaultValue: 'Fixed Account Mode' })} |
| <HelpTooltip text={t('proxy.config.scheduling.fixed_account_tooltip', { defaultValue: 'When enabled, all API requests will use only the selected account instead of rotating between accounts.' })} /> |
| </label> |
| <input |
| type="checkbox" |
| className="toggle toggle-sm toggle-primary" |
| checked={preferredAccountId !== null} |
| onChange={(e) => { |
| if (e.target.checked) { |
| // Enable fixed mode with first available account |
| if (availableAccounts.length > 0) { |
| handleSetPreferredAccount(availableAccounts[0].id); |
| } |
| } else { |
| // Disable fixed mode |
| handleSetPreferredAccount(null); |
| } |
| }} |
| disabled={!status.running} |
| /> |
| </div> |
| {preferredAccountId !== null && ( |
| <select |
| className="select select-bordered select-sm w-full text-xs" |
| value={preferredAccountId || ''} |
| onChange={(e) => handleSetPreferredAccount(e.target.value || null)} |
| disabled={!status.running} |
| > |
| {availableAccounts.map(account => ( |
| <option key={account.id} value={account.id}> |
| {account.email} |
| </option> |
| ))} |
| </select> |
| )} |
| {!status.running && ( |
| <p className="text-[10px] text-gray-500 mt-2"> |
| {t('proxy.config.scheduling.start_proxy_first', { defaultValue: 'Start the proxy service to configure fixed account mode.' })} |
| </p> |
| )} |
| </div> |
| </div> |
| </div> |
| |
| {/* Circuit Breaker Section */} |
| {appConfig.circuit_breaker && ( |
| <div className="pt-4 border-t border-gray-100 dark:border-gray-700/50"> |
| <div className="flex items-center justify-between mb-4"> |
| <label className="text-xs font-medium text-gray-700 dark:text-gray-300 inline-flex items-center gap-1"> |
| {t('proxy.config.circuit_breaker.title', { defaultValue: 'Adaptive Circuit Breaker' })} |
| <HelpTooltip text={t('proxy.config.circuit_breaker.tooltip', { defaultValue: 'Prevent continuous failures by exponentially backing off when quota is exhausted.' })} /> |
| </label> |
| <input |
| type="checkbox" |
| className="toggle toggle-sm toggle-warning" |
| checked={appConfig.circuit_breaker.enabled} |
| onChange={(e) => updateCircuitBreakerConfig({ ...appConfig.circuit_breaker, enabled: e.target.checked })} |
| /> |
| </div> |
| |
| {appConfig.circuit_breaker.enabled && ( |
| <CircuitBreaker |
| config={appConfig.circuit_breaker} |
| onChange={updateCircuitBreakerConfig} |
| onClearRateLimits={handleClearRateLimits} |
| /> |
| )} |
| </div> |
| )} |
| </div> |
| </CollapsibleCard> |
| |
| {/* Advanced Thinking & Global Config */} |
| <CollapsibleCard |
| title={t('settings.advanced_thinking.title', { defaultValue: 'Advanced Thinking & Global Config' })} |
| icon={<BrainCircuit size={18} className="text-pink-500" />} |
| > |
| <AdvancedThinking |
| config={appConfig.proxy} |
| onChange={(newProxyConfig) => updateProxyConfig(newProxyConfig)} |
| /> |
| </CollapsibleCard> |
| |
| {/* 实验性设置 */} |
| <CollapsibleCard |
| title={t('proxy.config.experimental.title')} |
| icon={<Sparkles size={18} className="text-purple-500" />} |
| > |
| <div className="space-y-4"> |
| <div className="flex items-center justify-between p-4 bg-gray-50 dark:bg-base-200 rounded-xl border border-gray-100 dark:border-base-300"> |
| <div className="space-y-1"> |
| <div className="flex items-center gap-2"> |
| <span className="text-sm font-bold text-gray-900 dark:text-base-content"> |
| {t('proxy.config.experimental.enable_usage_scaling')} |
| </span> |
| <HelpTooltip text={t('proxy.config.experimental.enable_usage_scaling_tooltip')} /> |
| <span className="px-1.5 py-0.5 rounded bg-purple-100 dark:bg-purple-900/30 text-[10px] text-purple-600 dark:text-purple-400 font-bold border border-purple-200 dark:border-purple-800"> |
| Claude |
| </span> |
| </div> |
| <p className="text-[10px] text-gray-500 dark:text-gray-400 max-w-lg"> |
| {t('proxy.config.experimental.enable_usage_scaling_tooltip')} |
| </p> |
| </div> |
| <label className="relative inline-flex items-center cursor-pointer"> |
| <input |
| type="checkbox" |
| className="sr-only peer" |
| checked={!!appConfig.proxy.experimental?.enable_usage_scaling} |
| onChange={(e) => updateExperimentalConfig({ enable_usage_scaling: e.target.checked })} |
| /> |
| <div className="w-11 h-6 bg-gray-200 dark:bg-base-300 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-purple-500 shadow-inner"></div> |
| </label> |
| </div> |
| |
| {/* L1 Threshold */} |
| <div className="flex flex-col gap-2 p-4 bg-gray-50 dark:bg-base-200 rounded-xl border border-gray-100 dark:border-base-300"> |
| <div className="flex items-center justify-between w-full"> |
| <div className="flex items-center gap-2"> |
| <span className="text-sm font-bold text-gray-900 dark:text-base-content"> |
| {t('proxy.config.experimental.context_compression_threshold_l1')} |
| </span> |
| <HelpTooltip text={t('proxy.config.experimental.context_compression_threshold_l1_tooltip')} /> |
| </div> |
| </div> |
| <DebouncedSlider |
| min={0.1} |
| max={1} |
| step={0.05} |
| className="range range-purple range-xs" |
| value={appConfig.proxy.experimental?.context_compression_threshold_l1 || 0.4} |
| onChange={(val) => updateExperimentalConfig({ context_compression_threshold_l1: val })} |
| /> |
| </div> |
| |
| {/* L2 Threshold */} |
| <div className="flex flex-col gap-2 p-4 bg-gray-50 dark:bg-base-200 rounded-xl border border-gray-100 dark:border-base-300"> |
| <div className="flex items-center justify-between w-full"> |
| <div className="flex items-center gap-2"> |
| <span className="text-sm font-bold text-gray-900 dark:text-base-content"> |
| {t('proxy.config.experimental.context_compression_threshold_l2')} |
| </span> |
| <HelpTooltip text={t('proxy.config.experimental.context_compression_threshold_l2_tooltip')} /> |
| </div> |
| </div> |
| <DebouncedSlider |
| min={0.1} |
| max={1} |
| step={0.05} |
| className="range range-purple range-xs" |
| value={appConfig.proxy.experimental?.context_compression_threshold_l2 || 0.55} |
| onChange={(val) => updateExperimentalConfig({ context_compression_threshold_l2: val })} |
| /> |
| </div> |
| |
| {/* L3 Threshold */} |
| <div className="flex flex-col gap-2 p-4 bg-gray-50 dark:bg-base-200 rounded-xl border border-gray-100 dark:border-base-300"> |
| <div className="flex items-center justify-between w-full"> |
| <div className="flex items-center gap-2"> |
| <span className="text-sm font-bold text-gray-900 dark:text-base-content"> |
| {t('proxy.config.experimental.context_compression_threshold_l3')} |
| </span> |
| <HelpTooltip text={t('proxy.config.experimental.context_compression_threshold_l3_tooltip')} /> |
| </div> |
| </div> |
| <DebouncedSlider |
| min={0.1} |
| max={1} |
| step={0.05} |
| className="range range-purple range-xs" |
| value={appConfig.proxy.experimental?.context_compression_threshold_l3 || 0.7} |
| onChange={(val) => updateExperimentalConfig({ context_compression_threshold_l3: val })} |
| /> |
| </div> |
| </div> |
| </CollapsibleCard> |
| |
| {/* 公网访问 (Cloudflared) - 仅在桌面端显示 */} |
| {isTauri() && ( |
| <CollapsibleCard |
| title={t('proxy.cloudflared.title', { defaultValue: 'Public Access (Cloudflared)' })} |
| icon={<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-orange-500"><path d="M12 2L2 7l10 5 10-5-10-5z" /><path d="M2 17l10 5 10-5" /><path d="M2 12l10 5 10-5" /></svg>} |
| enabled={cfStatus.running} |
| onToggle={handleCfToggle} |
| allowInteractionWhenDisabled={true} |
| rightElement={ |
| cfLoading ? ( |
| <span className="loading loading-spinner loading-xs"></span> |
| ) : cfStatus.running && cfStatus.url ? ( |
| <button |
| onClick={(e) => { e.stopPropagation(); handleCfCopyUrl(); }} |
| className="text-xs px-2 py-1 rounded bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 hover:bg-green-200 dark:hover:bg-green-900/50 transition-colors flex items-center gap-1" |
| > |
| {copied === 'cf-url' ? <CheckCircle size={12} /> : <Copy size={12} />} |
| {cfStatus.url.replace('https://', '').slice(0, 20)}... |
| </button> |
| ) : null |
| } |
| > |
| <div className="space-y-4"> |
| {/* 安装状态 */} |
| {!cfStatus.installed ? ( |
| <div className="flex items-center justify-between p-4 bg-yellow-50 dark:bg-yellow-900/20 rounded-xl border border-yellow-200 dark:border-yellow-800"> |
| <div className="space-y-1"> |
| <span className="text-sm font-bold text-yellow-800 dark:text-yellow-200"> |
| {t('proxy.cloudflared.not_installed', { defaultValue: 'Cloudflared not installed' })} |
| </span> |
| <p className="text-xs text-yellow-600 dark:text-yellow-400"> |
| {t('proxy.cloudflared.install_hint', { defaultValue: 'Click to download and install cloudflared binary' })} |
| </p> |
| </div> |
| <button |
| onClick={handleCfInstall} |
| disabled={cfLoading} |
| className="px-4 py-2 rounded-lg text-sm font-medium bg-yellow-500 text-white hover:bg-yellow-600 disabled:opacity-50 flex items-center gap-2" |
| > |
| {cfLoading ? <span className="loading loading-spinner loading-xs"></span> : null} |
| {t('proxy.cloudflared.install', { defaultValue: 'Install' })} |
| </button> |
| </div> |
| ) : ( |
| <> |
| {/* 版本信息 */} |
| <div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400"> |
| <CheckCircle size={14} className="text-green-500" /> |
| {t('proxy.cloudflared.installed', { defaultValue: 'Installed' })}: {cfStatus.version || 'Unknown'} |
| </div> |
| |
| {/* 隧道模式选择 */} |
| <div className="grid grid-cols-2 gap-3"> |
| <button |
| onClick={() => { |
| setCfMode('quick'); |
| if (appConfig) { |
| saveConfig({ |
| ...appConfig, |
| cloudflared: { ...appConfig.cloudflared, mode: 'quick' } |
| }); |
| } |
| }} |
| disabled={cfStatus.running} |
| className={cn( |
| "p-3 rounded-lg border-2 text-left transition-all", |
| cfMode === 'quick' |
| ? "border-orange-500 bg-orange-50 dark:bg-orange-900/20" |
| : "border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600", |
| cfStatus.running && "opacity-60 cursor-not-allowed" |
| )} |
| > |
| <div className="text-sm font-bold text-gray-900 dark:text-base-content"> |
| {t('proxy.cloudflared.mode_quick', { defaultValue: 'Quick Tunnel' })} |
| </div> |
| <p className="text-[10px] text-gray-500 dark:text-gray-400 mt-1"> |
| {t('proxy.cloudflared.mode_quick_desc', { defaultValue: 'Auto-generated temporary URL (*.trycloudflare.com)' })} |
| </p> |
| </button> |
| <button |
| onClick={() => { |
| setCfMode('auth'); |
| if (appConfig) { |
| saveConfig({ |
| ...appConfig, |
| cloudflared: { ...appConfig.cloudflared, mode: 'auth' } |
| }); |
| } |
| }} |
| disabled={cfStatus.running} |
| className={cn( |
| "p-3 rounded-lg border-2 text-left transition-all", |
| cfMode === 'auth' |
| ? "border-orange-500 bg-orange-50 dark:bg-orange-900/20" |
| : "border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600", |
| cfStatus.running && "opacity-60 cursor-not-allowed" |
| )} |
| > |
| <div className="text-sm font-bold text-gray-900 dark:text-base-content"> |
| {t('proxy.cloudflared.mode_auth', { defaultValue: 'Named Tunnel' })} |
| </div> |
| <p className="text-[10px] text-gray-500 dark:text-gray-400 mt-1"> |
| {t('proxy.cloudflared.mode_auth_desc', { defaultValue: 'Use your Cloudflare account with custom domain' })} |
| </p> |
| </button> |
| </div> |
| |
| {/* Token输入 (仅auth模式) */} |
| {cfMode === 'auth' && ( |
| <div className="space-y-2"> |
| <label className="text-sm font-medium text-gray-700 dark:text-gray-300"> |
| {t('proxy.cloudflared.token', { defaultValue: 'Tunnel Token' })} |
| </label> |
| <input |
| type="password" |
| value={cfToken} |
| onChange={(e) => setCfToken(e.target.value)} |
| onBlur={() => { |
| if (appConfig) { |
| saveConfig({ |
| ...appConfig, |
| cloudflared: { ...appConfig.cloudflared, token: cfToken } |
| }); |
| } |
| }} |
| disabled={cfStatus.running} |
| placeholder="eyJhIjoiNj..." |
| className="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-base-200 text-sm font-mono disabled:opacity-60" |
| /> |
| </div> |
| )} |
| |
| {/* HTTP2选项 */} |
| <div className="flex items-center justify-between p-3 bg-gray-50 dark:bg-base-200 rounded-lg"> |
| <div className="space-y-0.5"> |
| <span className="text-sm font-medium text-gray-900 dark:text-base-content"> |
| {t('proxy.cloudflared.use_http2', { defaultValue: 'Use HTTP/2' })} |
| </span> |
| <p className="text-[10px] text-gray-500 dark:text-gray-400"> |
| {t('proxy.cloudflared.use_http2_desc', { defaultValue: 'More compatible, recommended for China mainland' })} |
| </p> |
| </div> |
| <input |
| type="checkbox" |
| className="toggle toggle-sm" |
| checked={cfUseHttp2} |
| onChange={(e) => { |
| const val = e.target.checked; |
| setCfUseHttp2(val); |
| if (appConfig) { |
| const newConfig = { |
| ...appConfig, |
| cloudflared: { |
| ...appConfig.cloudflared, |
| use_http2: val |
| } |
| }; |
| saveConfig(newConfig); |
| } |
| }} |
| disabled={cfStatus.running} |
| /> |
| </div> |
| |
| {/* 运行状态和URL */} |
| {cfStatus.running && ( |
| <div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-xl border border-green-200 dark:border-green-800"> |
| <div className="flex items-center gap-2 mb-2"> |
| <div className="w-2 h-2 rounded-full bg-green-500 animate-pulse"></div> |
| <span className="text-sm font-bold text-green-800 dark:text-green-200"> |
| {t('proxy.cloudflared.running', { defaultValue: 'Tunnel Running' })} |
| </span> |
| </div> |
| {cfStatus.url && ( |
| <div className="flex items-center gap-2"> |
| <code className="flex-1 px-3 py-2 bg-white dark:bg-base-100 rounded text-xs font-mono text-gray-800 dark:text-gray-200 border border-green-200 dark:border-green-800"> |
| {cfStatus.url} |
| </code> |
| <button |
| onClick={handleCfCopyUrl} |
| className="p-2 rounded-lg bg-green-500 text-white hover:bg-green-600 transition-colors" |
| > |
| {copied === 'cf-url' ? <CheckCircle size={16} /> : <Copy size={16} />} |
| </button> |
| </div> |
| )} |
| </div> |
| )} |
| |
| {/* 错误信息 */} |
| {cfStatus.error && ( |
| <div className="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:border-red-800 text-sm text-red-700 dark:text-red-300"> |
| {cfStatus.error} |
| </div> |
| )} |
| </> |
| )} |
| </div> |
| </CollapsibleCard> |
| )} |
| </div> |
| ) |
| } |
|
|
| {} |
| { |
| !configLoading && !configError && appConfig && ( |
| <div className="bg-white dark:bg-base-100 rounded-xl shadow-sm border border-gray-100 dark:border-base-200 overflow-hidden"> |
| <div className="px-4 py-3 border-b border-gray-100 dark:border-gray-700/50 bg-gray-50/50 dark:bg-gray-800/50"> |
| <div className="flex flex-col md:flex-row md:items-center justify-between gap-4"> |
| <div className="flex-1"> |
| <h2 className="text-base font-bold flex items-center gap-2 text-gray-900 dark:text-base-content"> |
| <BrainCircuit size={18} className="text-blue-500" /> |
| {t('proxy.router.title')} |
| </h2> |
| <p className="text-xs text-gray-500 dark:text-gray-400 mt-1 max-w-xl leading-relaxed"> |
| {t('proxy.router.subtitle_simple')} |
| </p> |
| </div> |
| <div className="flex flex-wrap items-center gap-2 bg-white dark:bg-base-100 p-1.5 rounded-xl border border-gray-100 dark:border-gray-700/50 shadow-sm"> |
| {/* 预设选择下拉框 */} |
| <div className="relative min-w-[140px]"> |
| <select |
| value={selectedPreset} |
| onChange={(e) => setSelectedPreset(e.target.value)} |
| className="select select-sm w-full bg-gray-50 dark:bg-base-200 border-gray-200 dark:border-gray-700 text-xs font-medium focus:ring-1 focus:ring-blue-500 h-9 min-h-0 rounded-lg" |
| > |
| <optgroup label={t('proxy.router.built_in_presets')}> |
| {defaultPresets.map(preset => ( |
| <option key={preset.id} value={preset.id}> |
| {preset.name} |
| </option> |
| ))} |
| </optgroup> |
| {customPresets.length > 0 && ( |
| <optgroup label={t('proxy.router.custom_presets')}> |
| {customPresets.map(preset => ( |
| <option key={preset.id} value={preset.id}> |
| {preset.name} |
| </option> |
| ))} |
| </optgroup> |
| )} |
| </select> |
| </div> |
| |
| <button |
| onClick={handleApplyPresets} |
| className="px-3 md:px-4 py-1.5 rounded-lg text-xs font-bold transition-all flex items-center gap-1.5 bg-blue-600 hover:bg-blue-700 text-white shadow-sm hover:shadow active:scale-95 h-9" |
| title={presetOptions.find(p => p.id === selectedPreset)?.description} |
| > |
| <Sparkles size={14} className="fill-white/20" /> |
| {t('proxy.router.apply_selected')} |
| </button> |
| |
| <div className="w-[1px] h-5 bg-gray-200 dark:bg-gray-700 mx-1"></div> |
| |
| {/* 添加映射预设 */} |
| <button |
| onClick={() => setIsPresetManagerOpen(true)} |
| className="p-2 rounded-lg text-gray-500 hover:text-green-600 hover:bg-green-50 dark:hover:bg-green-900/20 transition-all h-9 w-9 flex items-center justify-center border border-transparent hover:border-green-100 dark:hover:border-green-900/30" |
| title={t('proxy.router.add_preset')} |
| > |
| <Plus size={16} /> |
| </button> |
| |
| {/* 删除当前预设(仅自定义预设) */} |
| <button |
| onClick={() => { |
| if (selectedPreset.startsWith('custom_')) { |
| handleDeletePreset(selectedPreset); |
| } else { |
| showToast(t('proxy.router.cannot_delete_builtin'), 'warning'); |
| } |
| }} |
| className={`p-2 rounded-lg transition-all h-9 w-9 flex items-center justify-center border border-transparent ${selectedPreset.startsWith('custom_') |
| ? 'text-gray-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 hover:border-red-100 dark:hover:border-red-900/30' |
| : 'text-gray-300 dark:text-gray-600 cursor-not-allowed' |
| }`} |
| title={selectedPreset.startsWith('custom_') |
| ? t('proxy.router.delete_preset') |
| : t('proxy.router.cannot_delete_builtin')} |
| disabled={!selectedPreset.startsWith('custom_')} |
| > |
| <Trash2 size={16} /> |
| </button> |
| |
| <div className="w-[1px] h-5 bg-gray-200 dark:bg-gray-700 mx-1"></div> |
| |
| <button |
| onClick={handleResetMapping} |
| className="p-2 rounded-lg text-gray-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 transition-all h-9 w-9 flex items-center justify-center border border-transparent hover:border-red-100 dark:hover:border-red-900/30" |
| title={t('proxy.router.reset_mapping')} |
| > |
| <RefreshCw size={16} /> |
| </button> |
| </div> |
| </div> |
| </div> |
| |
| <div className="p-3 space-y-3"> |
| {/* 精确映射管理 */} |
| <div> |
| {/* 后台任务模型配置 (Compact Mode) */} |
| <div className="mb-4 pb-4 border-b border-gray-100 dark:border-base-200"> |
| <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3"> |
| <div className="flex-1"> |
| <h3 className="text-xs font-bold text-gray-700 dark:text-gray-300 flex items-center gap-2"> |
| <Sparkles size={14} className="text-blue-500" /> |
| {t('proxy.router.background_task_title')} |
| </h3> |
| <p className="text-[10px] text-gray-500 dark:text-gray-400 mt-0.5"> |
| {t('proxy.router.background_task_desc')} |
| </p> |
| </div> |
| |
| <div className="flex items-center gap-2 w-full sm:w-auto min-w-[200px] max-w-sm"> |
| <div className="relative flex-1"> |
| <GroupedSelect |
| value={appConfig.proxy.custom_mapping?.['internal-background-task'] || ''} |
| onChange={(val) => handleMappingUpdate('custom', 'internal-background-task', val)} |
| options={[ |
| { value: '', label: 'Default (gemini-2.5-flash)', group: 'System' }, |
| ...customMappingOptions |
| ]} |
| placeholder="Default (gemini-2.5-flash)" |
| className="font-mono text-[11px] h-8 dark:bg-base-200 w-full" |
| /> |
| </div> |
| |
| {appConfig.proxy.custom_mapping && appConfig.proxy.custom_mapping['internal-background-task'] && ( |
| <button |
| onClick={() => handleRemoveCustomMapping('internal-background-task')} |
| className="p-1.5 text-gray-400 hover:text-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/30 rounded transition-colors" |
| title={t('proxy.router.use_default')} |
| > |
| <RefreshCw size={12} /> |
| </button> |
| )} |
| </div> |
| </div> |
| </div> |
| |
| <div className="flex items-center justify-between mb-3"> |
| <div className="flex flex-col gap-1"> |
| <h3 className="text-[10px] font-bold text-gray-400 uppercase tracking-widest flex items-center gap-2"> |
| <ArrowRight size={14} /> {t('proxy.router.custom_mappings')} |
| </h3> |
| <p className="text-[9px] text-gray-500 dark:text-gray-400 leading-relaxed"> |
| {t('proxy.router.custom_mapping_tip')} |
| <span className="text-amber-600 dark:text-amber-400">{t('proxy.router.custom_mapping_warning')}</span> |
| </p> |
| </div> |
| </div> |
| <div className="flex flex-col gap-4"> |
| {/* 当前映射列表 (置顶 2 列) */} |
| <div className="w-full flex flex-col"> |
| <div className="flex items-center justify-between mb-2"> |
| <span className="text-[10px] font-bold text-gray-400 dark:text-gray-500 uppercase tracking-wider"> |
| {t('proxy.router.current_list')} |
| </span> |
| </div> |
| <div className="overflow-y-auto max-h-[180px] border border-gray-100 dark:border-white/5 rounded-lg bg-gray-50/10 dark:bg-white/5 p-3" data-custom-mapping-list> |
| <div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-2"> |
| {appConfig.proxy.custom_mapping && Object.entries(appConfig.proxy.custom_mapping).length > 0 ? ( |
| Object.entries(appConfig.proxy.custom_mapping).map(([key, val]) => ( |
| <div key={key} className={`flex items-center justify-between p-1.5 rounded-md transition-all border group ${editingKey === key ? 'bg-blue-50/80 dark:bg-blue-900/15 border-blue-300/50 dark:border-blue-500/30 shadow-sm' : 'border-transparent hover:bg-gray-100 dark:hover:bg-white/5 hover:border-gray-200 dark:hover:border-white/10'}`}> |
| <div className="flex items-center gap-2.5 overflow-hidden flex-1"> |
| <span className="font-mono text-[10px] font-bold text-blue-600 dark:text-blue-400 truncate max-w-[140px]" title={key}>{key}</span> |
| <ArrowRight size={10} className="text-gray-300 dark:text-gray-600 shrink-0" /> |
| |
| {editingKey === key ? ( |
| <div className="flex-1 mr-2"> |
| <GroupedSelect |
| value={editingValue} |
| onChange={setEditingValue} |
| options={customMappingOptions} |
| placeholder="Select..." |
| className="font-mono text-[10px] h-7 dark:bg-gray-800 border-blue-200 dark:border-blue-800" |
| allowCustomInput={true} |
| /> |
| </div> |
| ) : ( |
| <span className="font-mono text-[10px] text-gray-500 dark:text-gray-400 truncate cursor-pointer hover:text-blue-500" |
| onClick={() => { setEditingKey(key); setEditingValue(val); }} |
| title={val}>{val}</span> |
| )} |
| </div> |
| |
| <div className="flex items-center gap-1.5 shrink-0"> |
| {editingKey === key ? ( |
| <div className="flex items-center gap-1 bg-white dark:bg-gray-800 rounded-md border border-blue-200 dark:border-blue-800 p-0.5 shadow-sm"> |
| <button |
| className="btn btn-ghost btn-xs text-primary hover:bg-blue-50 dark:hover:bg-blue-900/30 p-0 h-6 w-6 min-h-0" |
| onClick={() => { |
| handleMappingUpdate('custom', key, editingValue); |
| setEditingKey(null); |
| }} |
| title={t('common.save') || 'Save'} |
| > |
| <Check size={14} strokeWidth={3} /> |
| </button> |
| <div className="w-[1px] h-3 bg-gray-200 dark:bg-gray-700" /> |
| <button |
| className="btn btn-ghost btn-xs text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 p-0 h-6 w-6 min-h-0" |
| onClick={() => setEditingKey(null)} |
| title={t('common.cancel') || 'Cancel'} |
| > |
| <X size={14} strokeWidth={3} /> |
| </button> |
| </div> |
| ) : ( |
| <div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity"> |
| <button |
| className="btn btn-ghost btn-xs text-gray-400 hover:text-blue-500 hover:bg-blue-50 dark:hover:bg-white/10 p-0 h-6 w-6 min-h-0" |
| onClick={() => { setEditingKey(key); setEditingValue(val); }} |
| title={t('common.edit') || 'Edit'} |
| > |
| <Edit2 size={12} /> |
| </button> |
| <button |
| className="btn btn-ghost btn-xs text-error hover:bg-red-50 dark:hover:bg-red-900/20 p-0 h-6 w-6 min-h-0" |
| onClick={() => handleRemoveCustomMapping(key)} |
| title={t('common.delete') || 'Delete'} |
| > |
| <Trash2 size={12} /> |
| </button> |
| </div> |
| )} |
| </div> |
| </div> |
| )) |
| ) : ( |
| <div className="col-span-full text-center py-4 text-gray-400 dark:text-gray-600 italic text-[11px]">{t('proxy.router.no_custom_mapping')}</div> |
| )} |
| </div> |
| </div> |
| </div> |
| |
| {/* 添加映射表单 (置底单行) */} |
| <div className="w-full bg-gray-50/50 dark:bg-white/5 p-2.5 rounded-xl border border-gray-100 dark:border-white/5 shadow-inner"> |
| <div className="flex flex-col sm:flex-row items-center gap-3"> |
| <div className="flex items-center gap-1.5 shrink-0"> |
| <Target size={14} className="text-gray-400 dark:text-gray-500" /> |
| <span className="text-[10px] font-bold text-gray-400 dark:text-gray-500 uppercase tracking-wider">{t('proxy.router.add_mapping')}</span> |
| </div> |
| <div className="flex-1 flex flex-col sm:flex-row gap-2 w-full"> |
| <input |
| id="custom-key" |
| type="text" |
| placeholder={t('proxy.router.original_placeholder') || "Original (e.g. gpt-4 or gpt-4*)"} |
| className="input input-xs input-bordered flex-1 font-mono text-[11px] bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-all placeholder:text-gray-400 dark:placeholder:text-gray-600 h-8" |
| /> |
| <div className="w-full sm:w-48"> |
| <GroupedSelect |
| value={customMappingValue} |
| onChange={setCustomMappingValue} |
| options={customMappingOptions} |
| placeholder={t('proxy.router.select_target_model') || 'Select Target Model'} |
| className="font-mono text-[11px] h-8 dark:bg-gray-800" |
| allowCustomInput={true} |
| /> |
| </div> |
| </div> |
| <button |
| className="btn btn-xs sm:w-20 gap-1.5 shadow-md hover:shadow-lg transition-all bg-blue-600 hover:bg-blue-700 text-white border-none h-8" |
| onClick={() => { |
| const k = (document.getElementById('custom-key') as HTMLInputElement).value; |
| const v = customMappingValue; |
| if (k && v) { |
| handleMappingUpdate('custom', k, v); |
| (document.getElementById('custom-key') as HTMLInputElement).value = ''; |
| setCustomMappingValue(''); // 清空选择 |
| } |
| }} |
| > |
| <Plus size={14} /> |
| {t('common.add')} |
| </button> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
| ) |
| } |
|
|
| {} |
| { |
| !configLoading && !configError && appConfig && ( |
| <div className="bg-white dark:bg-base-100 rounded-xl shadow-sm border border-gray-100 dark:border-base-200 overflow-hidden"> |
| <div className="p-3"> |
| <div className="flex items-center gap-3 mb-3"> |
| <div className="w-8 h-8 rounded-lg bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center shadow-md"> |
| <Code size={16} className="text-white" /> |
| </div> |
| <div> |
| <h3 className="text-base font-bold text-gray-900 dark:text-base-content"> |
| 🔗 {t('proxy.multi_protocol.title')} |
| </h3> |
| <p className="text-[10px] text-gray-500 dark:text-gray-400"> |
| {t('proxy.multi_protocol.subtitle')} |
| </p> |
| </div> |
| </div> |
| |
| <p className="text-xs text-gray-700 dark:text-gray-300 mb-4 leading-relaxed"> |
| {t('proxy.multi_protocol.description')} |
| </p> |
| |
| <div className="grid grid-cols-1 md:grid-cols-3 gap-3"> |
| {/* OpenAI Card */} |
| <div |
| className={`p-3 rounded-xl border-2 transition-all cursor-pointer ${selectedProtocol === 'openai' ? 'border-blue-500 bg-blue-50/30 dark:bg-blue-900/10' : 'border-gray-100 dark:border-base-200 hover:border-blue-200'}`} |
| onClick={() => setSelectedProtocol('openai')} |
| > |
| <div className="flex items-center justify-between mb-2"> |
| <span className="text-xs font-bold text-blue-600">{t('proxy.multi_protocol.openai_label')}</span> |
| <button onClick={(e) => { |
| e.stopPropagation(); |
| const baseUrl = status.running ? status.base_url : `http://127.0.0.1:${appConfig.proxy.port || 8045}`; |
| copyToClipboardHandler(`${baseUrl}/v1`, 'openai'); |
| }} className="btn btn-ghost btn-xs"> |
| {copied === 'openai' ? <CheckCircle size={14} /> : <div className="flex items-center gap-1 text-[10px] uppercase font-bold tracking-tighter"><Copy size={12} /> {t('proxy.multi_protocol.copy_base', { defaultValue: 'Base' })}</div>} |
| </button> |
| </div> |
| <div className="space-y-1"> |
| <div className="flex items-center justify-between hover:bg-black/5 dark:hover:bg-white/5 rounded p-0.5 group"> |
| <code className="text-[10px] opacity-70">/v1/chat/completions</code> |
| <button onClick={(e) => { |
| e.stopPropagation(); |
| const baseUrl = status.running ? status.base_url : `http://127.0.0.1:${appConfig.proxy.port || 8045}`; |
| copyToClipboardHandler(`${baseUrl}/v1/chat/completions`, 'openai-chat'); |
| }} className="opacity-0 group-hover:opacity-100 transition-opacity"> |
| {copied === 'openai-chat' ? <CheckCircle size={10} className="text-green-500" /> : <Copy size={10} />} |
| </button> |
| </div> |
| <div className="flex items-center justify-between hover:bg-black/5 dark:hover:bg-white/5 rounded p-0.5 group"> |
| <code className="text-[10px] opacity-70">/v1/completions</code> |
| <button onClick={(e) => { |
| e.stopPropagation(); |
| const baseUrl = status.running ? status.base_url : `http://127.0.0.1:${appConfig.proxy.port || 8045}`; |
| copyToClipboardHandler(`${baseUrl}/v1/completions`, 'openai-compl'); |
| }} className="opacity-0 group-hover:opacity-100 transition-opacity"> |
| {copied === 'openai-compl' ? <CheckCircle size={10} className="text-green-500" /> : <Copy size={10} />} |
| </button> |
| </div> |
| <div className="flex items-center justify-between hover:bg-black/5 dark:hover:bg-white/5 rounded p-0.5 group"> |
| <code className="text-[10px] opacity-70 font-bold text-blue-500">/v1/responses (Codex)</code> |
| <button onClick={(e) => { |
| e.stopPropagation(); |
| const baseUrl = status.running ? status.base_url : `http://127.0.0.1:${appConfig.proxy.port || 8045}`; |
| copyToClipboardHandler(`${baseUrl}/v1/responses`, 'openai-resp'); |
| }} className="opacity-0 group-hover:opacity-100 transition-opacity"> |
| {copied === 'openai-resp' ? <CheckCircle size={10} className="text-green-500" /> : <Copy size={10} />} |
| </button> |
| </div> |
| </div> |
| </div> |
| |
| {/* Anthropic Card */} |
| <div |
| className={`p-3 rounded-xl border-2 transition-all cursor-pointer ${selectedProtocol === 'anthropic' ? 'border-purple-500 bg-purple-50/30 dark:bg-purple-900/10' : 'border-gray-100 dark:border-base-200 hover:border-purple-200'}`} |
| onClick={() => setSelectedProtocol('anthropic')} |
| > |
| <div className="flex items-center justify-between mb-2"> |
| <span className="text-xs font-bold text-purple-600">{t('proxy.multi_protocol.anthropic_label')}</span> |
| <button onClick={(e) => { |
| e.stopPropagation(); |
| const baseUrl = status.running ? status.base_url : `http://127.0.0.1:${appConfig.proxy.port || 8045}`; |
| copyToClipboardHandler(`${baseUrl}/v1/messages`, 'anthropic'); |
| }} className="btn btn-ghost btn-xs"> |
| {copied === 'anthropic' ? <CheckCircle size={14} /> : <Copy size={14} />} |
| </button> |
| </div> |
| <code className="text-[10px] block truncate bg-black/5 dark:bg-white/5 p-1 rounded">/v1/messages</code> |
| </div> |
| |
| {/* Gemini Card */} |
| <div |
| className={`p-3 rounded-xl border-2 transition-all cursor-pointer ${selectedProtocol === 'gemini' ? 'border-green-500 bg-green-50/30 dark:bg-green-900/10' : 'border-gray-100 dark:border-base-200 hover:border-green-200'}`} |
| onClick={() => setSelectedProtocol('gemini')} |
| > |
| <div className="flex items-center justify-between mb-2"> |
| <span className="text-xs font-bold text-green-600">{t('proxy.multi_protocol.gemini_label')}</span> |
| <button onClick={(e) => { |
| e.stopPropagation(); |
| const baseUrl = status.running ? status.base_url : `http://127.0.0.1:${appConfig.proxy.port || 8045}`; |
| copyToClipboardHandler(`${baseUrl}/v1beta/models`, 'gemini'); |
| }} className="btn btn-ghost btn-xs"> |
| {copied === 'gemini' ? <CheckCircle size={14} /> : <Copy size={14} />} |
| </button> |
| </div> |
| <code className="text-[10px] block truncate bg-black/5 dark:bg-white/5 p-1 rounded">/v1beta/models/...</code> |
| </div> |
| </div> |
| </div> |
| </div> |
| ) |
| } |
|
|
|
|
| {} |
| { |
| !configLoading && !configError && appConfig && ( |
| <div className="bg-white dark:bg-base-100 rounded-xl shadow-sm border border-gray-100 dark:border-base-200 overflow-hidden mt-4"> |
| <div className="px-4 py-2.5 border-b border-gray-100 dark:border-base-200"> |
| <h2 className="text-base font-bold text-gray-900 dark:text-base-content flex items-center gap-2"> |
| <Terminal size={18} /> |
| {t('proxy.supported_models.title')} |
| </h2> |
| </div> |
| |
| <div className="grid grid-cols-1 lg:grid-cols-3 gap-0 lg:divide-x dark:divide-gray-700"> |
| {/* 左侧:模型列表 */} |
| <div className="col-span-2 p-0"> |
| <div className="overflow-x-auto"> |
| <table className="table w-full"> |
| <thead className="bg-gray-50/50 dark:bg-gray-800/50 text-gray-500 dark:text-gray-400"> |
| <tr> |
| <th className="w-10 pl-3"></th> |
| <th className="text-[11px] font-medium">{t('proxy.supported_models.model_name')}</th> |
| <th className="text-[11px] font-medium">{t('proxy.supported_models.model_id')}</th> |
| <th className="text-[11px] hidden sm:table-cell font-medium">{t('proxy.supported_models.description')}</th> |
| <th className="text-[11px] w-20 text-center font-medium">{t('proxy.supported_models.action')}</th> |
| </tr> |
| </thead> |
| <tbody> |
| {filteredModels.map((m) => ( |
| <tr |
| key={m.id} |
| className={`hover:bg-blue-50/50 dark:hover:bg-blue-900/10 cursor-pointer transition-colors ${selectedModelId === m.id ? 'bg-blue-50/80 dark:bg-blue-900/20' : ''}`} |
| onClick={() => setSelectedModelId(m.id)} |
| > |
| <td className="pl-4 text-blue-500">{m.icon}</td> |
| <td className="font-bold text-xs">{m.name}</td> |
| <td className="font-mono text-[10px] text-gray-500">{m.id}</td> |
| <td className="text-[10px] text-gray-400 hidden sm:table-cell">{m.desc}</td> |
| <td className="text-center"> |
| <button |
| className="btn btn-ghost btn-xs text-blue-500" |
| onClick={(e) => { |
| e.stopPropagation(); |
| copyToClipboardHandler(m.id, `model-${m.id}`); |
| }} |
| > |
| {copied === `model-${m.id}` ? <CheckCircle size={14} /> : <div className="flex items-center gap-1 text-[10px] font-bold tracking-tight"><Copy size={12} /> {t('common.copy')}</div>} |
| </button> |
| </td> |
| </tr> |
| ))} |
| </tbody> |
| </table> |
| </div> |
| </div> |
| |
| {/* 右侧:代码预览 */} |
| <div className="col-span-1 bg-gray-900 text-blue-100 flex flex-col h-[400px] lg:h-auto"> |
| <div className="p-3 border-b border-gray-800 flex items-center justify-between"> |
| <span className="text-xs font-bold text-gray-400 uppercase tracking-wider">{t('proxy.multi_protocol.quick_integration')}</span> |
| <div className="flex gap-2"> |
| {/* 这里可以放 cURL/Python 切换,或者直接默认显示 Python,根据 selectedProtocol 决定 */} |
| <span className="text-[10px] px-2 py-0.5 rounded bg-blue-500/20 text-blue-400 border border-blue-500/30"> |
| {selectedProtocol === 'anthropic' ? 'Python (Anthropic SDK)' : (selectedProtocol === 'gemini' ? 'Python (Google GenAI)' : 'Python (OpenAI SDK)')} |
| </span> |
| </div> |
| </div> |
| <div className="flex-1 relative overflow-hidden group"> |
| <div className="absolute inset-0 overflow-auto scrollbar-thin scrollbar-thumb-gray-700 scrollbar-track-transparent"> |
| <pre className="p-4 text-[10px] font-mono leading-relaxed"> |
| {getPythonExample(selectedModelId)} |
| </pre> |
| </div> |
| <button |
| onClick={() => copyToClipboardHandler(getPythonExample(selectedModelId), 'example-code')} |
| className="absolute top-4 right-4 p-2 bg-white/10 hover:bg-white/20 rounded-lg transition-colors text-white opacity-0 group-hover:opacity-100" |
| > |
| {copied === 'example-code' ? <CheckCircle size={16} /> : <Copy size={16} />} |
| </button> |
| </div> |
| <div className="p-3 bg-gray-800/50 border-t border-gray-800 text-[10px] text-gray-400"> |
| {t('proxy.multi_protocol.click_tip')} |
| </div> |
| </div> |
| </div> |
| </div> |
| ) |
| } |
| {} |
| <ModalDialog |
| isOpen={isResetConfirmOpen} |
| title={t('proxy.dialog.reset_mapping_title') || '重置映射'} |
| message={t('proxy.dialog.reset_mapping_msg') || '确定要重置所有模型映射为系统默认吗?'} |
| type="confirm" |
| isDestructive={true} |
| onConfirm={executeResetMapping} |
| onCancel={() => setIsResetConfirmOpen(false)} |
| /> |
|
|
| <ModalDialog |
| isOpen={isRegenerateKeyConfirmOpen} |
| title={t('proxy.dialog.regenerate_key_title') || t('proxy.dialog.confirm_regenerate')} |
| message={t('proxy.dialog.regenerate_key_msg') || t('proxy.dialog.confirm_regenerate')} |
| type="confirm" |
| isDestructive={true} |
| onConfirm={executeGenerateApiKey} |
| onCancel={() => setIsRegenerateKeyConfirmOpen(false)} |
| /> |
|
|
| <ModalDialog |
| isOpen={isClearBindingsConfirmOpen} |
| title={t('proxy.dialog.clear_bindings_title') || '清除会话绑定'} |
| message={t('proxy.dialog.clear_bindings_msg') || '确定要清除所有会话与账号的绑定映射吗?'} |
| type="confirm" |
| isDestructive={true} |
| onConfirm={executeClearSessionBindings} |
| onCancel={() => setIsClearBindingsConfirmOpen(false)} |
| /> |
|
|
| <ModalDialog |
| isOpen={isClearRateLimitsConfirmOpen} |
| title={t('proxy.dialog.clear_rate_limits_title') || '清除限流记录'} |
| message={t('proxy.dialog.clear_rate_limits_confirm') || '确定要清除所有本地限流记录吗?'} |
| type="confirm" |
| isDestructive={true} |
| onConfirm={executeClearRateLimits} |
| onCancel={() => setIsClearRateLimitsConfirmOpen(false)} |
| /> |
|
|
| <ModalDialog |
| isOpen={isPresetManagerOpen} |
| title={t('proxy.router.manage_presets_title')} |
| onConfirm={() => setIsPresetManagerOpen(false)} |
| confirmText={t('common.close')} |
| type="info" |
| > |
| <div className="space-y-6"> |
| {/* Save Current Section */} |
| <div className="space-y-3 p-4 bg-blue-50/50 dark:bg-blue-900/10 rounded-xl border border-blue-100 dark:border-blue-900/20"> |
| <h3 className="text-sm font-bold text-gray-800 dark:text-gray-200 flex items-center gap-2"> |
| <Save size={16} className="text-blue-500" /> |
| {t('proxy.router.save_current_as_preset')} |
| </h3> |
| <div className="flex gap-2"> |
| <input |
| type="text" |
| value={newPresetName} |
| onChange={(e) => setNewPresetName(e.target.value)} |
| placeholder={t('proxy.router.preset_name_placeholder')} |
| className="input input-sm flex-1 border-gray-300 focus:border-blue-500" |
| /> |
| <button |
| onClick={handleSaveCurrentAsPreset} |
| disabled={!newPresetName.trim()} |
| className="btn btn-sm btn-primary text-white" |
| > |
| {t('common.save')} |
| </button> |
| </div> |
| <p className="text-[10px] text-gray-500 dark:text-gray-400"> |
| {t('proxy.router.save_hint')} |
| </p> |
| </div> |
| |
| {/* Existing Presets List */} |
| <div className="space-y-3"> |
| <h3 className="text-sm font-bold text-gray-800 dark:text-gray-200 px-1"> |
| {t('proxy.router.your_presets')} |
| </h3> |
| <div className="max-h-[300px] overflow-y-auto space-y-2 pr-1"> |
| {customPresets.length === 0 ? ( |
| <div className="text-center py-8 text-gray-400 dark:text-gray-600 bg-gray-50 dark:bg-base-200 rounded-xl border border-dashed border-gray-200 dark:border-gray-700"> |
| <p>{t('proxy.router.no_custom_presets')}</p> |
| </div> |
| ) : ( |
| customPresets.map(preset => ( |
| <div key={preset.id} className="flex items-center justify-between p-3 bg-white dark:bg-base-200 border border-gray-100 dark:border-gray-700 rounded-xl hover:shadow-sm transition-all group"> |
| <div className="flex-1 min-w-0"> |
| <div className="font-bold text-sm text-gray-800 dark:text-gray-200 truncate">{preset.name}</div> |
| <div className="text-[10px] text-gray-400 dark:text-gray-500 truncate"> |
| {Object.keys(preset.mappings).length} {t('proxy.router.mappings_count')} |
| </div> |
| </div> |
| <div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity"> |
| <button |
| onClick={() => handleDeletePreset(preset.id)} |
| className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors" |
| title={t('common.delete')} |
| > |
| <Trash2 size={16} /> |
| </button> |
| </div> |
| </div> |
| )) |
| )} |
| </div> |
| </div> |
| </div> |
| </ModalDialog> |
| </div > |
| </div > |
| ); |
| } |
|
|