[eric] split: extract Settings custom-providers + api-key cards + styles

This commit is contained in:
ciregenz
2026-05-23 06:26:35 -07:00
parent 5c3e91cfd2
commit 693f9e4fad
3 changed files with 413 additions and 0 deletions
@@ -0,0 +1,84 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import TextField from '@mui/material/TextField';
import IconButton from '@mui/material/IconButton';
import InputAdornment from '@mui/material/InputAdornment';
import VisibilityIcon from '@mui/icons-material/Visibility';
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { AppSettings } from '@/shared/state/settingsSlice';
import type { SettingsStyles } from './settingsStyles';
type ApiKeyField = 'anthropic_api_key' | 'openai_api_key' | 'google_api_key' | 'openrouter_api_key';
export interface ApiKeyConfig {
field: ApiKeyField;
label: string;
desc: string;
placeholder: string;
href: string;
}
export const API_KEY_CARDS: ApiKeyConfig[] = [
{ field: 'anthropic_api_key', label: 'Anthropic', desc: 'Claude Sonnet, Opus, Haiku.', placeholder: 'sk-ant-...', href: 'https://console.anthropic.com/settings/keys' },
{ field: 'openai_api_key', label: 'OpenAI', desc: 'GPT-5.4, GPT-5.4 Mini, o-series reasoning models.', placeholder: 'sk-...', href: 'https://platform.openai.com/api-keys' },
{ field: 'google_api_key', label: 'Google', desc: 'Gemini 3 Pro, Gemini 3 Flash, Gemini 2.5 Pro.', placeholder: 'AIza...', href: 'https://aistudio.google.com/apikey' },
{ field: 'openrouter_api_key', label: 'OpenRouter', desc: '300+ models from xAI, Meta, DeepSeek, Mistral, Qwen, and more.', placeholder: 'sk-or-...', href: 'https://openrouter.ai/keys' },
];
const ApiKeyCard: React.FC<{
config: ApiKeyConfig;
form: AppSettings;
setForm: React.Dispatch<React.SetStateAction<AppSettings>>;
showApiKey: boolean;
setShowApiKey: (v: boolean) => void;
styles: SettingsStyles;
}> = ({ config, form, setForm, showApiKey, setShowApiKey, styles }) => {
const c = useClaudeTokens();
const { fieldSx, descSx, labelSx } = styles;
const value = form[config.field] as string | null | undefined;
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography sx={labelSx}>{config.label}</Typography>
{value ? (
<Typography sx={{ fontSize: '0.6rem', fontWeight: 600, color: c.status.success, bgcolor: `${c.status.success}15`, px: 0.75, py: 0.15, borderRadius: '3px' }}>CONNECTED</Typography>
) : null}
</Box>
<Typography sx={{ ...descSx, mb: 1 }}>{config.desc}</Typography>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<TextField
type={showApiKey ? 'text' : 'password'}
value={value ?? ''}
onChange={(e) => setForm({ ...form, [config.field]: e.target.value || null })}
size="small"
fullWidth
placeholder={config.placeholder}
sx={{ ...fieldSx, '& .MuiOutlinedInput-root': { ...fieldSx['& .MuiOutlinedInput-root'], fontFamily: c.font.mono } }}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton onClick={() => setShowApiKey(!showApiKey)} edge="end" size="small" sx={{ color: c.text.tertiary }}>
{showApiKey ? <VisibilityOffIcon sx={{ fontSize: 16 }} /> : <VisibilityIcon sx={{ fontSize: 16 }} />}
</IconButton>
</InputAdornment>
),
}}
/>
<Typography
component="a"
href={config.href}
target="_blank"
rel="noopener"
sx={{ color: c.accent.primary, fontSize: '0.72rem', whiteSpace: 'nowrap', textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 0.3, '&:hover': { textDecoration: 'underline' } }}
>
Get key <OpenInNewIcon sx={{ fontSize: 11 }} />
</Typography>
</Box>
</Box>
);
};
export default ApiKeyCard;
@@ -0,0 +1,271 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import TextField from '@mui/material/TextField';
import Button from '@mui/material/Button';
import IconButton from '@mui/material/IconButton';
import InputAdornment from '@mui/material/InputAdornment';
import VisibilityIcon from '@mui/icons-material/Visibility';
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
import CloseIcon from '@mui/icons-material/Close';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { AppSettings, CustomProvider } from '@/shared/state/settingsSlice';
import type { SettingsStyles } from './settingsStyles';
const CustomProvidersEditor: React.FC<{
form: AppSettings;
setForm: React.Dispatch<React.SetStateAction<AppSettings>>;
showApiKey: boolean;
setShowApiKey: (v: boolean) => void;
styles: SettingsStyles;
}> = ({ form, setForm, showApiKey, setShowApiKey, styles }) => {
const c = useClaudeTokens();
const { fieldSx, descSx, labelSx } = styles;
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1, mb: 0.25 }}>
<Typography sx={labelSx}>Custom Providers</Typography>
{(() => {
const list = form.custom_providers || [];
if (list.length === 0) return null;
const readyCount = list.filter(cp => {
const filled = (cp.models || []).filter(m => (m.value || '').trim()).length;
return !!cp.name?.trim() && !!cp.base_url?.trim() && !!cp.api_key?.trim() && filled > 0;
}).length;
const allReady = readyCount === list.length;
return (
<Typography sx={{
fontSize: '0.6rem',
fontWeight: 600,
color: allReady ? c.status.success : c.status.warning,
bgcolor: allReady ? `${c.status.success}15` : `${c.status.warning}1F`,
px: 0.75, py: 0.15, borderRadius: '3px',
}}>
{readyCount} OF {list.length} READY
</Typography>
);
})()}
</Box>
<Typography sx={{ ...descSx, mb: 1.25 }}>
Add OpenAI-compatible endpoints (Ollama Cloud, Together, Groq, local Ollama, anything that speaks /v1/chat/completions).
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25 }}>
{(form.custom_providers || []).map((cp, idx) => {
const list = form.custom_providers || [];
const updateProvider = (patch: Partial<CustomProvider>) => {
const next = list.map((x, i) => (i === idx ? { ...x, ...patch } : x));
setForm({ ...form, custom_providers: next });
};
const removeProvider = () => {
const next = list.filter((_, i) => i !== idx);
setForm({ ...form, custom_providers: next });
};
const addModel = () => {
const nextModels = [...(cp.models || []), { value: '', label: '' }];
updateProvider({ models: nextModels });
};
const updateModel = (mIdx: number, value: string) => {
const nextModels = (cp.models || []).map((m, i) =>
i === mIdx ? { ...m, value, label: value } : m
);
updateProvider({ models: nextModels });
};
const removeModel = (mIdx: number) => {
const nextModels = (cp.models || []).filter((_, i) => i !== mIdx);
updateProvider({ models: nextModels });
};
const filledModelCount = (cp.models || []).filter(m => (m.value || '').trim()).length;
const nameMissing = !cp.name?.trim();
const urlMissing = !cp.base_url?.trim();
const modelsMissing = filledModelCount === 0;
// api_key is optional (local LM Studio/Ollama/llama.cpp/vLLM run without auth); hosted providers need a real key.
const isReady = !nameMissing && !urlMissing && !modelsMissing;
const dupeNameWithEarlier = list.findIndex((other, i) =>
i < idx && (other.name || '').trim().toLowerCase() === (cp.name || '').trim().toLowerCase() && (cp.name || '').trim() !== ''
) !== -1;
const missingLabels: string[] = [];
if (nameMissing) missingLabels.push('name');
if (urlMissing) missingLabels.push('base URL');
if (modelsMissing) missingLabels.push('a model');
return (
<Box
key={idx}
sx={{
p: 1.5,
borderRadius: `${c.radius.md}px`,
border: `1px solid ${isReady ? c.status.success + '30' : c.status.warning + '40'}`,
bgcolor: isReady ? `${c.status.success}04` : `${c.status.warning}06`,
transition: 'all 0.2s ease',
display: 'flex',
flexDirection: 'column',
gap: 1,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: -0.25 }}>
<Typography sx={{
fontSize: '0.62rem',
fontWeight: 700,
letterSpacing: '0.05em',
textTransform: 'uppercase' as const,
color: isReady ? c.status.success : c.status.warning,
bgcolor: isReady ? `${c.status.success}15` : `${c.status.warning}1F`,
px: 0.75,
py: 0.2,
borderRadius: '3px',
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
}}>
<Box component="span" sx={{
width: 6, height: 6, borderRadius: '50%', flexShrink: 0,
bgcolor: isReady ? c.status.success : c.status.warning,
}} />
{isReady ? 'Ready' : `Incomplete, add ${missingLabels.join(', ')}`}
</Typography>
<IconButton
onClick={removeProvider}
size="small"
title="Remove provider"
sx={{
color: c.text.tertiary,
'&:hover': { color: c.status.error, bgcolor: `${c.status.error}10` },
}}
>
<CloseIcon sx={{ fontSize: 16 }} />
</IconButton>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
<TextField
value={cp.name || ''}
onChange={(e) => updateProvider({ name: e.target.value })}
size="small"
fullWidth
placeholder="e.g. Ollama Cloud"
label="Name"
required
error={dupeNameWithEarlier}
helperText={dupeNameWithEarlier ? 'Name must be unique' : undefined}
InputLabelProps={{ shrink: true, sx: { fontSize: '0.72rem', color: c.text.tertiary } }}
sx={fieldSx}
/>
<TextField
value={cp.base_url || ''}
onChange={(e) => updateProvider({ base_url: e.target.value })}
size="small"
fullWidth
placeholder="https://ollama.com/v1"
label="Base URL"
required
InputLabelProps={{ shrink: true, sx: { fontSize: '0.72rem', color: c.text.tertiary } }}
sx={{ ...fieldSx, '& .MuiOutlinedInput-root': { ...fieldSx['& .MuiOutlinedInput-root'], fontFamily: c.font.mono } }}
/>
<TextField
type={showApiKey ? 'text' : 'password'}
value={cp.api_key || ''}
onChange={(e) => updateProvider({ api_key: e.target.value })}
size="small"
fullWidth
placeholder="Leave blank for local servers (LM Studio, Ollama, ...)"
label="API Key (optional)"
InputLabelProps={{ shrink: true, sx: { fontSize: '0.72rem', color: c.text.tertiary } }}
sx={{ ...fieldSx, '& .MuiOutlinedInput-root': { ...fieldSx['& .MuiOutlinedInput-root'], fontFamily: c.font.mono } }}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton onClick={() => setShowApiKey(!showApiKey)} edge="end" size="small" sx={{ color: c.text.tertiary }}>
{showApiKey ? <VisibilityOffIcon sx={{ fontSize: 16 }} /> : <VisibilityIcon sx={{ fontSize: 16 }} />}
</IconButton>
</InputAdornment>
),
}}
/>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, mt: 0.25 }}>
<Typography sx={{ fontSize: '0.65rem', fontWeight: 600, color: c.text.tertiary, textTransform: 'uppercase' as const, letterSpacing: '0.05em' }}>
Models
</Typography>
{((cp.models || []).length === 0) ? (
<Typography sx={{ fontSize: '0.7rem', color: c.text.muted, fontStyle: 'italic', px: 0.5 }}>
No models yet, add the model IDs this endpoint serves.
</Typography>
) : (
(cp.models || []).map((m, mIdx) => (
<Box key={mIdx} sx={{ display: 'flex', gap: 0.5, alignItems: 'center' }}>
<TextField
value={m.value || ''}
onChange={(e) => updateModel(mIdx, e.target.value)}
size="small"
fullWidth
placeholder="e.g. gpt-oss:120b"
sx={{ ...fieldSx, '& .MuiOutlinedInput-root': { ...fieldSx['& .MuiOutlinedInput-root'], fontFamily: c.font.mono, fontSize: '0.78rem' } }}
/>
<IconButton
onClick={() => removeModel(mIdx)}
size="small"
title="Remove model"
sx={{
color: c.text.tertiary,
'&:hover': { color: c.status.error, bgcolor: `${c.status.error}10` },
}}
>
<CloseIcon sx={{ fontSize: 14 }} />
</IconButton>
</Box>
))
)}
<Button
onClick={addModel}
size="small"
sx={{
alignSelf: 'flex-start',
mt: 0.25,
textTransform: 'none',
color: c.accent.primary,
fontSize: '0.72rem',
minWidth: 'auto',
px: 0.75,
py: 0.25,
'&:hover': { bgcolor: `${c.accent.primary}10` },
}}
>
+ Add model
</Button>
</Box>
</Box>
);
})}
<Button
onClick={() => {
const next: CustomProvider[] = [
...(form.custom_providers || []),
{ name: '', base_url: '', api_key: '', models: [{ value: '', label: '' }] },
];
setForm({ ...form, custom_providers: next });
}}
variant="outlined"
size="small"
sx={{
alignSelf: 'flex-start',
textTransform: 'none',
color: c.text.primary,
borderColor: c.border.medium,
borderStyle: 'dashed',
fontSize: '0.78rem',
px: 1.5,
py: 0.6,
'&:hover': { borderColor: c.accent.primary, bgcolor: `${c.accent.primary}08` },
transition: 'all 0.2s ease',
}}
>
+ Add Custom Provider
</Button>
</Box>
</Box>
);
};
export default CustomProvidersEditor;
@@ -0,0 +1,58 @@
// Theme-dependent style objects shared across Settings tabs; built from the resolved Claude tokens.
export function makeSettingsStyles(c: any) {
const fieldSx = {
'& .MuiOutlinedInput-root': {
fontSize: '0.85rem',
},
};
const sectionSx = {
fontSize: '0.7rem',
fontWeight: 600,
letterSpacing: '0.06em',
textTransform: 'uppercase' as const,
color: c.text.tertiary,
mb: 0.5,
mt: 0.5,
};
const rowSx = {
py: 2,
borderBottom: `1px solid ${c.border.subtle}`,
};
const rowLastSx = {
py: 2,
};
const inlineRowSx = {
...rowSx,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
};
const inlineRowLastSx = {
...rowLastSx,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
};
const labelSx = {
color: c.text.primary,
fontWeight: 500,
fontSize: '0.875rem',
lineHeight: 1.4,
};
const descSx = {
color: c.text.tertiary,
fontSize: '0.75rem',
lineHeight: 1.4,
};
return { fieldSx, sectionSx, rowSx, rowLastSx, inlineRowSx, inlineRowLastSx, labelSx, descSx };
}
export type SettingsStyles = ReturnType<typeof makeSettingsStyles>;