Nexus/client/src/components/AIProviderSettings.tsx

611 lines
18 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import { Check, X, Loader2, Eye, EyeOff, AlertCircle } from 'lucide-react';
import { createStyles } from 'antd-style';
import { lobeChatColors, lobeChatSpacing } from '../styles/lobeChatTheme';
import { AI_PROVIDERS } from '../config/aiProviders';
const useStyles = createStyles(({ css }) => ({
container: css`
width: 100%;
height: 100%;
`,
title: css`
font-size: 20px;
font-weight: 600;
color: white;
margin-bottom: ${lobeChatSpacing.md}px;
`,
description: css`
font-size: 14px;
color: ${lobeChatColors.icon.default};
margin-bottom: ${lobeChatSpacing.xl}px;
`,
providersList: css`
display: flex;
flex-direction: column;
gap: ${lobeChatSpacing.lg}px;
`,
providerCard: css`
background: ${lobeChatColors.sidebar.background};
border: 1px solid ${lobeChatColors.sidebar.border};
border-radius: 12px;
padding: ${lobeChatSpacing.lg}px;
transition: all 0.2s;
&:hover {
border-color: ${lobeChatColors.input.focus};
}
`,
providerHeader: css`
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: ${lobeChatSpacing.md}px;
`,
providerInfo: css`
display: flex;
align-items: center;
gap: ${lobeChatSpacing.md}px;
`,
providerIcon: css`
font-size: 32px;
`,
providerName: css`
font-size: 16px;
font-weight: 600;
color: white;
`,
providerStatus: css`
display: flex;
align-items: center;
gap: ${lobeChatSpacing.xs}px;
padding: 4px 12px;
border-radius: 16px;
font-size: 12px;
font-weight: 500;
`,
providerStatusEnabled: css`
background: rgba(34, 197, 94, 0.1);
color: #22c55e;
`,
providerStatusDisabled: css`
background: rgba(156, 163, 175, 0.1);
color: #9ca3af;
`,
formGroup: css`
margin-bottom: ${lobeChatSpacing.md}px;
`,
label: css`
display: flex;
align-items: center;
gap: ${lobeChatSpacing.xs}px;
font-size: 13px;
font-weight: 500;
color: ${lobeChatColors.icon.default};
margin-bottom: ${lobeChatSpacing.xs}px;
`,
required: css`
color: #ef4444;
`,
inputWrapper: css`
position: relative;
display: flex;
gap: ${lobeChatSpacing.xs}px;
`,
input: css`
flex: 1;
background: ${lobeChatColors.input.background};
border: 1px solid ${lobeChatColors.sidebar.border};
border-radius: 8px;
padding: ${lobeChatSpacing.sm}px ${lobeChatSpacing.md}px;
padding-right: 40px;
color: white;
font-size: 14px;
outline: none;
transition: all 0.2s;
font-family: 'Monaco', 'Courier New', monospace;
&:focus {
border-color: ${lobeChatColors.input.focus};
background: rgba(102, 126, 234, 0.05);
}
&::placeholder {
color: ${lobeChatColors.icon.default};
opacity: 0.5;
}
&[type="password"] {
letter-spacing: 2px;
}
`,
toggleButton: css`
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
border-radius: 4px;
color: ${lobeChatColors.icon.default};
cursor: pointer;
transition: all 0.2s;
&:hover {
background: ${lobeChatColors.sidebar.hover};
color: white;
}
`,
testButton: css`
display: flex;
align-items: center;
gap: ${lobeChatSpacing.xs}px;
padding: ${lobeChatSpacing.sm}px ${lobeChatSpacing.md}px;
background: ${lobeChatColors.sidebar.background};
border: 1px solid ${lobeChatColors.sidebar.border};
border-radius: 8px;
color: white;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
&:hover:not(:disabled) {
border-color: ${lobeChatColors.input.focus};
background: ${lobeChatColors.sidebar.hover};
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`,
testButtonTesting: css`
border-color: #3b82f6;
`,
testButtonSuccess: css`
border-color: #22c55e;
color: #22c55e;
`,
testButtonError: css`
border-color: #ef4444;
color: #ef4444;
`,
checkboxWrapper: css`
display: flex;
align-items: center;
gap: ${lobeChatSpacing.sm}px;
margin-top: ${lobeChatSpacing.xs}px;
`,
checkbox: css`
width: 18px;
height: 18px;
cursor: pointer;
`,
checkboxLabel: css`
font-size: 13px;
color: ${lobeChatColors.icon.default};
cursor: pointer;
`,
testResult: css`
display: flex;
align-items: flex-start;
gap: ${lobeChatSpacing.sm}px;
padding: ${lobeChatSpacing.sm}px ${lobeChatSpacing.md}px;
border-radius: 8px;
font-size: 13px;
margin-top: ${lobeChatSpacing.sm}px;
`,
testResultSuccess: css`
background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.3);
color: #22c55e;
`,
testResultError: css`
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
color: #ef4444;
`,
modelsCount: css`
font-size: 12px;
color: ${lobeChatColors.icon.default};
margin-top: ${lobeChatSpacing.xs}px;
`,
saveButton: css`
margin-top: ${lobeChatSpacing.lg}px;
padding: ${lobeChatSpacing.md}px ${lobeChatSpacing.xl}px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
border-radius: 8px;
color: white;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.3);
&:hover {
box-shadow: 0 6px 24px rgba(102, 126, 234, 0.4);
transform: translateY(-1px);
}
`,
}));
// URLs API por defecto
const DEFAULT_API_URLS: Record<string, string> = {
openai: 'https://api.openai.com/v1',
anthropic: 'https://api.anthropic.com/v1',
google: 'https://generativelanguage.googleapis.com/v1',
mistral: 'https://api.mistral.ai/v1',
cohere: 'https://api.cohere.ai/v1',
};
interface ProviderConfig {
apiKey: string;
apiUrl: string;
useCustomUrl: boolean;
enabled: boolean;
}
export const AIProviderSettings: React.FC = () => {
const { styles, cx } = useStyles();
const [configs, setConfigs] = useState<Record<string, ProviderConfig>>({});
const [showKeys, setShowKeys] = useState<Record<string, boolean>>({});
const [testingStates, setTestingStates] = useState<Record<string, 'idle' | 'testing' | 'success' | 'error'>>({});
const [testMessages, setTestMessages] = useState<Record<string, string>>({});
// Cargar configuraciones guardadas
useEffect(() => {
const savedConfigs = localStorage.getItem('aiProviderConfigs');
if (savedConfigs) {
setConfigs(JSON.parse(savedConfigs));
} else {
// Inicializar con valores por defecto
const initialConfigs: Record<string, ProviderConfig> = {};
AI_PROVIDERS.forEach(provider => {
initialConfigs[provider.id] = {
apiKey: '',
apiUrl: DEFAULT_API_URLS[provider.id] || '',
useCustomUrl: false,
enabled: false,
};
});
setConfigs(initialConfigs);
}
}, []);
const handleApiKeyChange = (providerId: string, apiKey: string) => {
setConfigs(prev => ({
...prev,
[providerId]: { ...prev[providerId], apiKey },
}));
};
const handleApiUrlChange = (providerId: string, apiUrl: string) => {
setConfigs(prev => ({
...prev,
[providerId]: { ...prev[providerId], apiUrl },
}));
};
const handleUseCustomUrlChange = (providerId: string, useCustomUrl: boolean) => {
setConfigs(prev => ({
...prev,
[providerId]: {
...prev[providerId],
useCustomUrl,
apiUrl: useCustomUrl ? prev[providerId].apiUrl : DEFAULT_API_URLS[providerId],
},
}));
};
const toggleShowKey = (providerId: string) => {
setShowKeys(prev => ({ ...prev, [providerId]: !prev[providerId] }));
};
const testConnection = async (providerId: string) => {
const config = configs[providerId];
if (!config?.apiKey) {
setTestMessages(prev => ({ ...prev, [providerId]: 'API Key es requerido' }));
setTestingStates(prev => ({ ...prev, [providerId]: 'error' }));
return;
}
// Sanitizar API Key (eliminar espacios en blanco)
const cleanApiKey = config.apiKey.trim();
if (!cleanApiKey) {
setTestMessages(prev => ({ ...prev, [providerId]: 'API Key inválida' }));
setTestingStates(prev => ({ ...prev, [providerId]: 'error' }));
return;
}
setTestingStates(prev => ({ ...prev, [providerId]: 'testing' }));
setTestMessages(prev => ({ ...prev, [providerId]: '' }));
try {
const apiUrl = config.useCustomUrl ? config.apiUrl : DEFAULT_API_URLS[providerId];
let testUrl = '';
let headers: Record<string, string> = {
'Content-Type': 'application/json',
};
// Configurar según el provider
switch (providerId) {
case 'openai':
testUrl = `${apiUrl}/models`;
headers['Authorization'] = `Bearer ${cleanApiKey}`;
break;
case 'anthropic':
testUrl = `${apiUrl}/messages`;
headers['x-api-key'] = cleanApiKey;
headers['anthropic-version'] = '2023-06-01';
break;
case 'google':
// Para Google, el API key va en el query parameter
testUrl = `${apiUrl}/models?key=${encodeURIComponent(cleanApiKey)}`;
break;
case 'mistral':
testUrl = `${apiUrl}/models`;
headers['Authorization'] = `Bearer ${cleanApiKey}`;
break;
case 'cohere':
testUrl = `${apiUrl}/models`;
headers['Authorization'] = `Bearer ${cleanApiKey}`;
break;
default:
throw new Error(`Provider ${providerId} no soportado`);
}
// Instead of calling provider directly from browser (CORS issues), call our backend API
const backendUrl = '/api/test-provider';
const response = await fetch(backendUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ providerId, apiKey: cleanApiKey, apiUrl: apiUrl }),
});
if (response.ok) {
const data = await response.json();
if (data.success) {
const modelsCount = data.modelsCount || 0;
setTestingStates(prev => ({ ...prev, [providerId]: 'success' }));
setTestMessages(prev => ({
...prev,
[providerId]: modelsCount > 0
? `✓ Connection successful. ${modelsCount} models available.`
: `✓ Connection successful. API Key valid.`
}));
// Enable provider automatically
setConfigs(prev => ({
...prev,
[providerId]: { ...prev[providerId], enabled: true },
}));
} else {
setTestingStates(prev => ({ ...prev, [providerId]: 'error' }));
setTestMessages(prev => ({
...prev,
[providerId]: `${data.error || 'Unknown error'}`
}));
}
} else {
let msg = `HTTP ${response.status}`;
try {
const err = await response.json();
msg = err.error || err.message || msg;
} catch (e) {}
setTestingStates(prev => ({ ...prev, [providerId]: 'error' }));
setTestMessages(prev => ({
...prev,
[providerId]: `✗ Error: ${msg}`
}));
}
} catch (error: any) {
let errorMessage = 'Error de red';
if (error.message?.includes('Failed to fetch')) {
errorMessage = 'Error de CORS o red. Verifica tu API Key y URL.';
} else if (error.message) {
errorMessage = error.message;
}
setTestingStates(prev => ({ ...prev, [providerId]: 'error' }));
setTestMessages(prev => ({
...prev,
[providerId]: `✗ Error: ${errorMessage}`
}));
}
// Resetear estado después de 5 segundos
setTimeout(() => {
setTestingStates(prev => ({ ...prev, [providerId]: 'idle' }));
}, 5000);
};
const handleSave = () => {
localStorage.setItem('aiProviderConfigs', JSON.stringify(configs));
alert('Configuración guardada correctamente');
};
return (
<div className={styles.container}>
<div className={styles.title}>AI Provider Settings</div>
<div className={styles.description}>
Configure sus API Keys y URLs para cada proveedor de IA. Los providers habilitados estarán disponibles en el selector de modelos.
</div>
<div className={styles.providersList}>
{AI_PROVIDERS.map((provider) => {
const config = configs[provider.id] || {
apiKey: '',
apiUrl: DEFAULT_API_URLS[provider.id] || '',
useCustomUrl: false,
enabled: false,
};
const testState = testingStates[provider.id] || 'idle';
const testMessage = testMessages[provider.id];
return (
<div key={provider.id} className={styles.providerCard}>
<div className={styles.providerHeader}>
<div className={styles.providerInfo}>
<div className={styles.providerIcon}>{provider.icon}</div>
<div>
<div className={styles.providerName}>{provider.name}</div>
<div className={styles.modelsCount}>
{provider.models.length} modelos disponibles
</div>
</div>
</div>
<div className={cx(styles.providerStatus, config.enabled ? styles.providerStatusEnabled : styles.providerStatusDisabled)}>
{config.enabled ? '✓ Habilitado' : '○ Deshabilitado'}
</div>
</div>
{/* API Key */}
<div className={styles.formGroup}>
<label className={styles.label}>
API Key <span className={styles.required}>*</span>
</label>
<div className={styles.inputWrapper}>
<input
type={showKeys[provider.id] ? 'text' : 'password'}
className={styles.input}
value={config.apiKey}
onChange={(e) => handleApiKeyChange(provider.id, e.target.value)}
placeholder={`Ingrese su ${provider.name} API Key`}
/>
<button
className={styles.toggleButton}
onClick={() => toggleShowKey(provider.id)}
title={showKeys[provider.id] ? 'Ocultar' : 'Mostrar'}
>
{showKeys[provider.id] ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
<button
className={cx(
styles.testButton,
testState === 'testing' && styles.testButtonTesting,
testState === 'success' && styles.testButtonSuccess,
testState === 'error' && styles.testButtonError
)}
onClick={() => testConnection(provider.id)}
disabled={!config.apiKey || testState === 'testing'}
>
{testState === 'testing' ? (
<>
<Loader2 size={14} className="animate-spin" />
Probando...
</>
) : testState === 'success' ? (
<>
<Check size={14} />
Exitoso
</>
) : testState === 'error' ? (
<>
<X size={14} />
Error
</>
) : (
'Test Conexión'
)}
</button>
</div>
</div>
{/* Test Result Message */}
{testMessage && (
<div className={cx(
styles.testResult,
testState === 'success' && styles.testResultSuccess,
testState === 'error' && styles.testResultError
)}>
<AlertCircle size={16} />
<span>{testMessage}</span>
</div>
)}
{/* API URL */}
<div className={styles.formGroup}>
<label className={styles.label}>API URL</label>
<input
type="text"
className={styles.input}
value={config.useCustomUrl ? config.apiUrl : DEFAULT_API_URLS[provider.id]}
onChange={(e) => handleApiUrlChange(provider.id, e.target.value)}
placeholder={DEFAULT_API_URLS[provider.id]}
disabled={!config.useCustomUrl}
style={{ opacity: config.useCustomUrl ? 1 : 0.6 }}
/>
<div className={styles.checkboxWrapper}>
<input
type="checkbox"
id={`custom-url-${provider.id}`}
className={styles.checkbox}
checked={config.useCustomUrl}
onChange={(e) => handleUseCustomUrlChange(provider.id, e.target.checked)}
/>
<label
htmlFor={`custom-url-${provider.id}`}
className={styles.checkboxLabel}
>
Usar URL personalizada (por defecto: {DEFAULT_API_URLS[provider.id]})
</label>
</div>
</div>
</div>
);
})}
</div>
<button className={styles.saveButton} onClick={handleSave}>
Guardar Configuración
</button>
</div>
);
};