611 lines
18 KiB
TypeScript
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>
|
|
);
|
|
};
|
|
|