feat: redesign API keys settings page with .env paste support
- Add Quick Import section to paste entire .env files - Auto-detect supported providers (OPENAI_API_KEY, ANTHROPIC_API_KEY, etc.) - Add per-provider inline inputs with auto-save on blur/debounce - Show save status indicators (loading spinner, checkmark) - Simplify UI with compact provider rows - Add supported models reference section 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
4f95046249
commit
1b1c4f6474
1 changed files with 342 additions and 160 deletions
|
|
@ -6,7 +6,7 @@ import { Button } from '@tpmjs/ui/Button/Button';
|
|||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { AppHeader } from '~/components/AppHeader';
|
||||
|
||||
interface ApiKeyInfo {
|
||||
|
|
@ -32,16 +32,59 @@ const PROVIDER_DESCRIPTIONS: Record<AIProvider, string> = {
|
|||
MISTRAL: 'Mistral Large, Mistral Small',
|
||||
};
|
||||
|
||||
const PROVIDER_LINKS: Record<AIProvider, { url: string; label: string }> = {
|
||||
OPENAI: { url: 'https://platform.openai.com/api-keys', label: 'platform.openai.com' },
|
||||
ANTHROPIC: { url: 'https://console.anthropic.com/settings/keys', label: 'console.anthropic.com' },
|
||||
GOOGLE: { url: 'https://aistudio.google.com/apikey', label: 'aistudio.google.com' },
|
||||
GROQ: { url: 'https://console.groq.com/keys', label: 'console.groq.com' },
|
||||
MISTRAL: { url: 'https://console.mistral.ai/api-keys', label: 'console.mistral.ai' },
|
||||
};
|
||||
|
||||
// Map common env var names to our provider enum
|
||||
const ENV_VAR_TO_PROVIDER: Record<string, AIProvider> = {
|
||||
OPENAI_API_KEY: 'OPENAI',
|
||||
ANTHROPIC_API_KEY: 'ANTHROPIC',
|
||||
GOOGLE_API_KEY: 'GOOGLE',
|
||||
GOOGLE_GENERATIVE_AI_API_KEY: 'GOOGLE',
|
||||
GROQ_API_KEY: 'GROQ',
|
||||
MISTRAL_API_KEY: 'MISTRAL',
|
||||
};
|
||||
|
||||
export default function ApiKeysPage(): React.ReactElement {
|
||||
const router = useRouter();
|
||||
const [apiKeys, setApiKeys] = useState<ApiKeyInfo[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [addingProvider, setAddingProvider] = useState<AIProvider | null>(null);
|
||||
const [newApiKey, setNewApiKey] = useState('');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// Per-provider input state
|
||||
const [keyInputs, setKeyInputs] = useState<Record<AIProvider, string>>({
|
||||
OPENAI: '',
|
||||
ANTHROPIC: '',
|
||||
GOOGLE: '',
|
||||
GROQ: '',
|
||||
MISTRAL: '',
|
||||
});
|
||||
const [savingProviders, setSavingProviders] = useState<Set<AIProvider>>(new Set());
|
||||
const [savedProviders, setSavedProviders] = useState<Set<AIProvider>>(new Set());
|
||||
const [deletingProvider, setDeletingProvider] = useState<AIProvider | null>(null);
|
||||
|
||||
// .env paste state
|
||||
const [envText, setEnvText] = useState('');
|
||||
const [parsedKeys, setParsedKeys] = useState<{ provider: AIProvider; key: string }[]>([]);
|
||||
const [importStatus, setImportStatus] = useState<'idle' | 'importing' | 'done'>('idle');
|
||||
const [importResults, setImportResults] = useState<
|
||||
{ provider: AIProvider; success: boolean; error?: string }[]
|
||||
>([]);
|
||||
|
||||
// Auto-save debounce refs
|
||||
const saveTimeoutRefs = useRef<Record<AIProvider, NodeJS.Timeout | null>>({
|
||||
OPENAI: null,
|
||||
ANTHROPIC: null,
|
||||
GOOGLE: null,
|
||||
GROQ: null,
|
||||
MISTRAL: null,
|
||||
});
|
||||
|
||||
const fetchApiKeys = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch('/api/user/api-keys');
|
||||
|
|
@ -68,21 +111,63 @@ export default function ApiKeysPage(): React.ReactElement {
|
|||
fetchApiKeys();
|
||||
}, [fetchApiKeys]);
|
||||
|
||||
const handleAddKey = async (provider: AIProvider) => {
|
||||
if (!newApiKey.trim()) return;
|
||||
// Parse .env content
|
||||
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Simple env parsing with multiple conditionals
|
||||
const parseEnvContent = useCallback((text: string) => {
|
||||
const lines = text.split('\n');
|
||||
const keys: { provider: AIProvider; key: string }[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
|
||||
// Handle KEY=VALUE format (with optional quotes)
|
||||
const match = trimmed.match(/^([A-Z_]+)\s*=\s*["']?([^"'\s]+)["']?$/);
|
||||
if (match) {
|
||||
const envVar = match[1];
|
||||
const value = match[2];
|
||||
if (!envVar || !value) continue;
|
||||
const provider = ENV_VAR_TO_PROVIDER[envVar];
|
||||
if (provider) {
|
||||
// Check if this provider is already in our list
|
||||
const existingIndex = keys.findIndex((k) => k.provider === provider);
|
||||
if (existingIndex >= 0) {
|
||||
keys[existingIndex] = { provider, key: value };
|
||||
} else {
|
||||
keys.push({ provider, key: value });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return keys;
|
||||
}, []);
|
||||
|
||||
// Update parsed keys when env text changes
|
||||
useEffect(() => {
|
||||
const keys = parseEnvContent(envText);
|
||||
setParsedKeys(keys);
|
||||
}, [envText, parseEnvContent]);
|
||||
|
||||
// Save a single key
|
||||
const saveKey = useCallback(async (provider: AIProvider, apiKey: string) => {
|
||||
setSavingProviders((prev) => new Set(prev).add(provider));
|
||||
setSavedProviders((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(provider);
|
||||
return next;
|
||||
});
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const response = await fetch('/api/user/api-keys', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ provider, apiKey: newApiKey }),
|
||||
body: JSON.stringify({ provider, apiKey }),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
// Update or add the key in state
|
||||
setApiKeys((prev) => {
|
||||
const existing = prev.findIndex((k) => k.provider === provider);
|
||||
if (existing >= 0) {
|
||||
|
|
@ -92,20 +177,101 @@ export default function ApiKeysPage(): React.ReactElement {
|
|||
}
|
||||
return [...prev, result.data];
|
||||
});
|
||||
setAddingProvider(null);
|
||||
setNewApiKey('');
|
||||
} else {
|
||||
throw new Error(result.error || 'Failed to save API key');
|
||||
setSavedProviders((prev) => new Set(prev).add(provider));
|
||||
// Clear input after successful save
|
||||
setKeyInputs((prev) => ({ ...prev, [provider]: '' }));
|
||||
// Clear saved indicator after 2 seconds
|
||||
setTimeout(() => {
|
||||
setSavedProviders((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(provider);
|
||||
return next;
|
||||
});
|
||||
}, 2000);
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: result.error };
|
||||
} catch (err) {
|
||||
console.error('Failed to save API key:', err);
|
||||
alert(err instanceof Error ? err.message : 'Failed to save API key');
|
||||
return { success: false, error: 'Network error' };
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
setSavingProviders((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(provider);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleDeleteKey = async (provider: AIProvider) => {
|
||||
// Handle input change with debounced auto-save
|
||||
const handleKeyInputChange = useCallback(
|
||||
(provider: AIProvider, value: string) => {
|
||||
setKeyInputs((prev) => ({ ...prev, [provider]: value }));
|
||||
|
||||
// Clear existing timeout
|
||||
const existingTimeout = saveTimeoutRefs.current[provider];
|
||||
if (existingTimeout) {
|
||||
clearTimeout(existingTimeout);
|
||||
}
|
||||
|
||||
// Set new timeout for auto-save (1.5 seconds after typing stops)
|
||||
if (value.trim()) {
|
||||
saveTimeoutRefs.current[provider] = setTimeout(() => {
|
||||
saveKey(provider, value.trim());
|
||||
}, 1500);
|
||||
}
|
||||
},
|
||||
[saveKey]
|
||||
);
|
||||
|
||||
// Handle blur - save immediately
|
||||
const handleKeyInputBlur = useCallback(
|
||||
(provider: AIProvider) => {
|
||||
const value = keyInputs[provider].trim();
|
||||
if (value) {
|
||||
// Clear any pending timeout
|
||||
const pendingTimeout = saveTimeoutRefs.current[provider];
|
||||
if (pendingTimeout) {
|
||||
clearTimeout(pendingTimeout);
|
||||
saveTimeoutRefs.current[provider] = null;
|
||||
}
|
||||
saveKey(provider, value);
|
||||
}
|
||||
},
|
||||
[keyInputs, saveKey]
|
||||
);
|
||||
|
||||
// Import all parsed keys
|
||||
const handleImportAll = useCallback(async () => {
|
||||
if (parsedKeys.length === 0) return;
|
||||
|
||||
setImportStatus('importing');
|
||||
setImportResults([]);
|
||||
|
||||
const results: { provider: AIProvider; success: boolean; error?: string }[] = [];
|
||||
|
||||
for (const { provider, key } of parsedKeys) {
|
||||
const result = await saveKey(provider, key);
|
||||
results.push({
|
||||
provider,
|
||||
success: result.success,
|
||||
error: result.success ? undefined : result.error,
|
||||
});
|
||||
}
|
||||
|
||||
setImportResults(results);
|
||||
setImportStatus('done');
|
||||
setEnvText('');
|
||||
setParsedKeys([]);
|
||||
|
||||
// Clear results after 3 seconds
|
||||
setTimeout(() => {
|
||||
setImportResults([]);
|
||||
setImportStatus('idle');
|
||||
}, 3000);
|
||||
}, [parsedKeys, saveKey]);
|
||||
|
||||
const handleDeleteKey = useCallback(async (provider: AIProvider) => {
|
||||
if (
|
||||
!confirm(`Are you sure you want to delete your ${PROVIDER_DISPLAY_NAMES[provider]} API key?`)
|
||||
) {
|
||||
|
|
@ -131,7 +297,7 @@ export default function ApiKeysPage(): React.ReactElement {
|
|||
} finally {
|
||||
setDeletingProvider(null);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const hasKey = (provider: AIProvider) => apiKeys.some((k) => k.provider === provider);
|
||||
const getKeyInfo = (provider: AIProvider) => apiKeys.find((k) => k.provider === provider);
|
||||
|
|
@ -190,174 +356,190 @@ export default function ApiKeysPage(): React.ReactElement {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Import Section */}
|
||||
<div className="bg-surface-secondary border border-border rounded-lg p-6 mb-8">
|
||||
<h2 className="text-lg font-medium text-foreground mb-2">Quick Import</h2>
|
||||
<p className="text-sm text-foreground-secondary mb-4">
|
||||
Paste your .env file or environment variables below to import multiple keys at once.
|
||||
</p>
|
||||
|
||||
<textarea
|
||||
value={envText}
|
||||
onChange={(e) => setEnvText(e.target.value)}
|
||||
placeholder={`OPENAI_API_KEY=sk-...
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
GOOGLE_API_KEY=...
|
||||
GROQ_API_KEY=gsk_...
|
||||
MISTRAL_API_KEY=...`}
|
||||
rows={5}
|
||||
className="w-full px-4 py-3 bg-background border border-border rounded-lg text-foreground font-mono text-sm placeholder:text-foreground-tertiary focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary resize-none"
|
||||
/>
|
||||
|
||||
{/* Parsed keys preview */}
|
||||
{parsedKeys.length > 0 && (
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm text-foreground-secondary">Detected:</span>
|
||||
{parsedKeys.map(({ provider }) => (
|
||||
<span
|
||||
key={provider}
|
||||
className="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-primary/10 text-primary"
|
||||
>
|
||||
{PROVIDER_DISPLAY_NAMES[provider]}
|
||||
</span>
|
||||
))}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleImportAll}
|
||||
disabled={importStatus === 'importing'}
|
||||
className="ml-auto"
|
||||
>
|
||||
{importStatus === 'importing' ? (
|
||||
<>
|
||||
<Icon icon="loader" size="xs" className="animate-spin mr-1" />
|
||||
Importing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon icon="plus" size="xs" className="mr-1" />
|
||||
Import {parsedKeys.length} key{parsedKeys.length > 1 ? 's' : ''}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Import results */}
|
||||
{importResults.length > 0 && (
|
||||
<div className="mt-4 space-y-2">
|
||||
{importResults.map(({ provider, success, error: err }) => (
|
||||
<div
|
||||
key={provider}
|
||||
className={`flex items-center gap-2 text-sm ${
|
||||
success
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-red-600 dark:text-red-400'
|
||||
}`}
|
||||
>
|
||||
<Icon icon={success ? 'check' : 'x'} size="xs" />
|
||||
<span>
|
||||
{PROVIDER_DISPLAY_NAMES[provider]}: {success ? 'Saved' : err || 'Failed'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="bg-primary/5 border border-primary/20 rounded-lg p-4 mb-8">
|
||||
<div className="flex items-start gap-3">
|
||||
<Icon icon="info" size="sm" className="text-primary mt-0.5" />
|
||||
<div className="text-sm">
|
||||
<p className="text-foreground">
|
||||
Your API keys are encrypted and stored securely. They are used to make calls to AI
|
||||
providers when running your agents.
|
||||
Your API keys are encrypted and stored securely. Keys auto-save when you stop typing
|
||||
or leave the field.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Provider Cards */}
|
||||
<div className="space-y-4">
|
||||
{/* Provider Rows */}
|
||||
<div className="space-y-3">
|
||||
{SUPPORTED_PROVIDERS.map((provider) => {
|
||||
const keyInfo = getKeyInfo(provider);
|
||||
const isAdding = addingProvider === provider;
|
||||
const isSaving = savingProviders.has(provider);
|
||||
const isSaved = savedProviders.has(provider);
|
||||
const isDeleting = deletingProvider === provider;
|
||||
const link = PROVIDER_LINKS[provider];
|
||||
|
||||
return (
|
||||
<div key={provider} className="bg-background border border-border rounded-lg p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-lg font-medium text-foreground">
|
||||
{PROVIDER_DISPLAY_NAMES[provider]}
|
||||
</h3>
|
||||
{hasKey(provider) && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
|
||||
Configured
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-foreground-secondary mt-1">
|
||||
{PROVIDER_DESCRIPTIONS[provider]}
|
||||
</p>
|
||||
{keyInfo && (
|
||||
<p className="text-xs text-foreground-tertiary mt-2">
|
||||
Key ending in <span className="font-mono">{keyInfo.keyHint}</span>
|
||||
</p>
|
||||
<div
|
||||
key={provider}
|
||||
className="bg-background border border-border rounded-lg p-4 flex items-center gap-4"
|
||||
>
|
||||
{/* Provider info */}
|
||||
<div className="flex-shrink-0 w-32">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-foreground">
|
||||
{PROVIDER_DISPLAY_NAMES[provider]}
|
||||
</span>
|
||||
{hasKey(provider) && (
|
||||
<span className="w-2 h-2 rounded-full bg-green-500" title="Configured" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isAdding && (
|
||||
<div className="flex items-center gap-2">
|
||||
{hasKey(provider) ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => setAddingProvider(provider)}
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleDeleteKey(provider)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button size="sm" onClick={() => setAddingProvider(provider)}>
|
||||
Add Key
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-foreground-tertiary mt-0.5">
|
||||
{keyInfo ? `...${keyInfo.keyHint}` : 'Not set'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Add/Update Form */}
|
||||
{isAdding && (
|
||||
<div className="mt-4 pt-4 border-t border-border">
|
||||
<label
|
||||
htmlFor={`api-key-${provider}`}
|
||||
className="block text-sm font-medium text-foreground mb-2"
|
||||
>
|
||||
{hasKey(provider) ? 'New' : ''} API Key
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
id={`api-key-${provider}`}
|
||||
type="password"
|
||||
value={newApiKey}
|
||||
onChange={(e) => setNewApiKey(e.target.value)}
|
||||
placeholder={`Enter your ${PROVIDER_DISPLAY_NAMES[provider]} API key`}
|
||||
className="flex-1 px-3 py-2 bg-background border border-border rounded-lg text-foreground placeholder:text-foreground-tertiary focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary"
|
||||
autoFocus
|
||||
{/* Input */}
|
||||
<div className="flex-1 relative">
|
||||
<input
|
||||
type="password"
|
||||
value={keyInputs[provider]}
|
||||
onChange={(e) => handleKeyInputChange(provider, e.target.value)}
|
||||
onBlur={() => handleKeyInputBlur(provider)}
|
||||
placeholder={
|
||||
hasKey(provider) ? 'Enter new key to update...' : 'Paste API key...'
|
||||
}
|
||||
className="w-full px-3 py-2 bg-surface-secondary border border-border rounded-lg text-foreground text-sm placeholder:text-foreground-tertiary focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary font-mono"
|
||||
/>
|
||||
{/* Status indicator */}
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
{isSaving && (
|
||||
<Icon
|
||||
icon="loader"
|
||||
size="xs"
|
||||
className="animate-spin text-foreground-tertiary"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => handleAddKey(provider)}
|
||||
disabled={isSaving || !newApiKey.trim()}
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setAddingProvider(null);
|
||||
setNewApiKey('');
|
||||
}}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-foreground-tertiary mt-2">
|
||||
Get your API key from{' '}
|
||||
{provider === 'OPENAI' && (
|
||||
<a
|
||||
href="https://platform.openai.com/api-keys"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
platform.openai.com
|
||||
</a>
|
||||
)}
|
||||
{provider === 'ANTHROPIC' && (
|
||||
<a
|
||||
href="https://console.anthropic.com/settings/keys"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
console.anthropic.com
|
||||
</a>
|
||||
)}
|
||||
{provider === 'GOOGLE' && (
|
||||
<a
|
||||
href="https://aistudio.google.com/apikey"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
aistudio.google.com
|
||||
</a>
|
||||
)}
|
||||
{provider === 'GROQ' && (
|
||||
<a
|
||||
href="https://console.groq.com/keys"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
console.groq.com
|
||||
</a>
|
||||
)}
|
||||
{provider === 'MISTRAL' && (
|
||||
<a
|
||||
href="https://console.mistral.ai/api-keys"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
console.mistral.ai
|
||||
</a>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{isSaved && !isSaving && (
|
||||
<Icon icon="check" size="xs" className="text-green-500" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-primary hover:underline whitespace-nowrap"
|
||||
>
|
||||
Get key →
|
||||
</a>
|
||||
{hasKey(provider) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeleteKey(provider)}
|
||||
disabled={isDeleting}
|
||||
className="p-1 text-foreground-tertiary hover:text-red-500 transition-colors disabled:opacity-50"
|
||||
title="Delete key"
|
||||
>
|
||||
<Icon icon="trash" size="xs" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Models reference */}
|
||||
<div className="mt-8 pt-8 border-t border-border">
|
||||
<h3 className="text-sm font-medium text-foreground mb-4">Supported Models</h3>
|
||||
<div className="grid md:grid-cols-2 gap-3 text-sm">
|
||||
{SUPPORTED_PROVIDERS.map((provider) => (
|
||||
<div key={provider} className="flex gap-2">
|
||||
<span className="font-medium text-foreground-secondary w-24">
|
||||
{PROVIDER_DISPLAY_NAMES[provider]}:
|
||||
</span>
|
||||
<span className="text-foreground-tertiary">{PROVIDER_DESCRIPTIONS[provider]}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue