From 693f9e4fade38ab40d9c3df289a7c79bcc99adf3 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sat, 23 May 2026 06:26:35 -0700 Subject: [PATCH] [eric] split: extract Settings custom-providers + api-key cards + styles --- .../pages/Settings/sections/ApiKeyCard.tsx | 84 ++++++ .../sections/CustomProvidersEditor.tsx | 271 ++++++++++++++++++ .../pages/Settings/sections/settingsStyles.ts | 58 ++++ 3 files changed, 413 insertions(+) create mode 100644 frontend/src/app/pages/Settings/sections/ApiKeyCard.tsx create mode 100644 frontend/src/app/pages/Settings/sections/CustomProvidersEditor.tsx create mode 100644 frontend/src/app/pages/Settings/sections/settingsStyles.ts diff --git a/frontend/src/app/pages/Settings/sections/ApiKeyCard.tsx b/frontend/src/app/pages/Settings/sections/ApiKeyCard.tsx new file mode 100644 index 00000000..e048bf5a --- /dev/null +++ b/frontend/src/app/pages/Settings/sections/ApiKeyCard.tsx @@ -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>; + 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 ( + + + {config.label} + {value ? ( + CONNECTED + ) : null} + + {config.desc} + + 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: ( + + setShowApiKey(!showApiKey)} edge="end" size="small" sx={{ color: c.text.tertiary }}> + {showApiKey ? : } + + + ), + }} + /> + + Get key + + + + ); +}; + +export default ApiKeyCard; diff --git a/frontend/src/app/pages/Settings/sections/CustomProvidersEditor.tsx b/frontend/src/app/pages/Settings/sections/CustomProvidersEditor.tsx new file mode 100644 index 00000000..def9a84a --- /dev/null +++ b/frontend/src/app/pages/Settings/sections/CustomProvidersEditor.tsx @@ -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>; + showApiKey: boolean; + setShowApiKey: (v: boolean) => void; + styles: SettingsStyles; +}> = ({ form, setForm, showApiKey, setShowApiKey, styles }) => { + const c = useClaudeTokens(); + const { fieldSx, descSx, labelSx } = styles; + return ( + + + Custom Providers + {(() => { + 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 ( + + {readyCount} OF {list.length} READY + + ); + })()} + + + Add OpenAI-compatible endpoints (Ollama Cloud, Together, Groq, local Ollama, anything that speaks /v1/chat/completions). + + + + {(form.custom_providers || []).map((cp, idx) => { + const list = form.custom_providers || []; + const updateProvider = (patch: Partial) => { + 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 ( + + + + + {isReady ? 'Ready' : `Incomplete, add ${missingLabels.join(', ')}`} + + + + + + + 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} + /> + 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 } }} + /> + 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: ( + + setShowApiKey(!showApiKey)} edge="end" size="small" sx={{ color: c.text.tertiary }}> + {showApiKey ? : } + + + ), + }} + /> + + + + + Models + + {((cp.models || []).length === 0) ? ( + + No models yet, add the model IDs this endpoint serves. + + ) : ( + (cp.models || []).map((m, mIdx) => ( + + 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' } }} + /> + removeModel(mIdx)} + size="small" + title="Remove model" + sx={{ + color: c.text.tertiary, + '&:hover': { color: c.status.error, bgcolor: `${c.status.error}10` }, + }} + > + + + + )) + )} + + + + ); + })} + + + + + ); +}; + +export default CustomProvidersEditor; diff --git a/frontend/src/app/pages/Settings/sections/settingsStyles.ts b/frontend/src/app/pages/Settings/sections/settingsStyles.ts new file mode 100644 index 00000000..42776755 --- /dev/null +++ b/frontend/src/app/pages/Settings/sections/settingsStyles.ts @@ -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;