feat(settings): custom OpenAI-compatible provider UI with API key management (#287)
From @Interstellar-code's PR #287. Resolves merge conflicts with the matrix theme (#279) and provider-card verified-status fix (#282) that landed earlier in the batch. Closes #287. * Adds 'Custom' provider card to the settings dialog \u2014 always clickable (not gated by red dot) * Adds editable Custom Endpoint section (Base URL + API key) in the provider card flow * Adds matching Custom Providers section to the full /settings page with Base URL and CUSTOM_API_KEY support Co-authored-by: Interstellar-code <Interstellar-code@users.noreply.github.com>
This commit is contained in:
@@ -23,6 +23,7 @@ import type { AccentColor, SettingsThemeMode } from '@/hooks/use-settings'
|
||||
import type { LoaderStyle } from '@/hooks/use-chat-settings'
|
||||
import type { BrailleSpinnerPreset } from '@/components/ui/braille-spinner'
|
||||
import type { ThemeId } from '@/lib/theme'
|
||||
import type {LocaleId} from '@/lib/i18n';
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { applyTheme, useSettings } from '@/hooks/use-settings'
|
||||
@@ -56,6 +57,10 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
|
||||
// ── Language ────────────────────────────────────────────────────────────
|
||||
|
||||
import { LOCALE_LABELS, getLocale, setLocale } from '@/lib/i18n'
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────────
|
||||
|
||||
type SectionId =
|
||||
@@ -185,7 +190,12 @@ const PROVIDER_CARDS: Array<{
|
||||
id: 'nous',
|
||||
name: 'Nous Portal',
|
||||
logo: '/providers/nous.png',
|
||||
models: ['xiaomi/mimo-v2-pro', 'xiaomi/mimo-v2-omni', 'claude-3-llama-3.1-405b', 'claude-3-llama-3.1-70b'],
|
||||
models: [
|
||||
'xiaomi/mimo-v2-pro',
|
||||
'xiaomi/mimo-v2-omni',
|
||||
'claude-3-llama-3.1-405b',
|
||||
'claude-3-llama-3.1-70b',
|
||||
],
|
||||
authType: 'oauth',
|
||||
},
|
||||
{
|
||||
@@ -223,7 +233,7 @@ const PROVIDER_CARDS: Array<{
|
||||
id: 'minimax',
|
||||
name: 'MiniMax',
|
||||
logo: '/providers/minimax.png',
|
||||
models: ['MiniMax-M2.7', 'MiniMax-M2.7-Lightning'],
|
||||
models: ['MiniMax-M2.5', 'MiniMax-M2.5-Lightning'],
|
||||
authType: 'api_key',
|
||||
envKey: 'MINIMAX_API_KEY',
|
||||
},
|
||||
@@ -235,7 +245,7 @@ const PROVIDER_CARDS: Array<{
|
||||
authType: 'api_key',
|
||||
envKey: 'XIAOMI_API_KEY',
|
||||
},
|
||||
{ id: 'custom', name: 'Custom', logo: '', models: [], authType: 'api_key' },
|
||||
{ id: 'custom', name: 'Custom', logo: '', models: [], authType: 'api_key', envKey: 'CUSTOM_API_KEY' },
|
||||
]
|
||||
|
||||
function HermesContent() {
|
||||
@@ -252,40 +262,53 @@ function HermesContent() {
|
||||
)
|
||||
const [memEnabled, setMemEnabled] = useState(true)
|
||||
const [userProfileEnabled, setUserProfileEnabled] = useState(true)
|
||||
const [customBaseUrl, setCustomBaseUrl] = useState('')
|
||||
const [localDiscovery, setLocalDiscovery] = useState<{
|
||||
providers: Array<{ id: string; name: string; online: boolean; modelCount: number; configured: boolean; needsRestart: boolean }>
|
||||
providers: Array<{
|
||||
id: string
|
||||
name: string
|
||||
online: boolean
|
||||
modelCount: number
|
||||
configured: boolean
|
||||
needsRestart: boolean
|
||||
}>
|
||||
models: Array<{ id: string; name: string; provider: string }>
|
||||
} | null>(null)
|
||||
|
||||
const fetchModelsForProvider = useCallback((providerId: string) => {
|
||||
// For local providers, prefer auto-discovered models first
|
||||
if (localDiscovery) {
|
||||
const discovered = localDiscovery.models
|
||||
.filter((m) => m.provider === providerId)
|
||||
.map((m) => m.id)
|
||||
if (discovered.length > 0) {
|
||||
setAvailableModels(discovered)
|
||||
return
|
||||
const fetchModelsForProvider = useCallback(
|
||||
(providerId: string) => {
|
||||
// For local providers, prefer auto-discovered models first
|
||||
if (localDiscovery) {
|
||||
const discovered = localDiscovery.models
|
||||
.filter((m) => m.provider === providerId)
|
||||
.map((m) => m.id)
|
||||
if (discovered.length > 0) {
|
||||
setAvailableModels(discovered)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
fetch(
|
||||
`/api/claude-proxy/api/available-models?provider=${encodeURIComponent(providerId)}`,
|
||||
)
|
||||
.then((r) => r.json())
|
||||
.then((d: { models?: Array<{ id: string }> }) => {
|
||||
setAvailableModels((d.models || []).map((m) => m.id))
|
||||
})
|
||||
.catch(() => {
|
||||
// Fall back to hardcoded
|
||||
const card = PROVIDER_CARDS.find((p) => p.id === providerId)
|
||||
setAvailableModels(card?.models || [])
|
||||
})
|
||||
}, [localDiscovery])
|
||||
fetch(
|
||||
`/api/claude-proxy/api/available-models?provider=${encodeURIComponent(providerId)}`,
|
||||
)
|
||||
.then((r) => r.json())
|
||||
.then((d: { models?: Array<{ id: string }> }) => {
|
||||
setAvailableModels((d.models || []).map((m) => m.id))
|
||||
})
|
||||
.catch(() => {
|
||||
// Fall back to hardcoded
|
||||
const card = PROVIDER_CARDS.find((p) => p.id === providerId)
|
||||
setAvailableModels(card?.models || [])
|
||||
})
|
||||
},
|
||||
[localDiscovery],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/local-providers')
|
||||
.then((r) => r.json())
|
||||
.then((d: any) => { if (d.ok) setLocalDiscovery(d) })
|
||||
.then((d: any) => {
|
||||
if (d.ok) setLocalDiscovery(d)
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
@@ -306,6 +329,10 @@ function HermesContent() {
|
||||
keys[p.envKeys[0]] = p.maskedKeys?.[p.envKeys[0]] || '••••'
|
||||
}
|
||||
setConfiguredKeys(keys)
|
||||
// Load custom provider config (may be stored as 'custom' or legacy 'manifest')
|
||||
const cfgProviders = (d.config?.providers as Record<string, any>) || {}
|
||||
const customCfg = cfgProviders['custom'] || cfgProviders['manifest'] || {}
|
||||
if (customCfg.base_url) setCustomBaseUrl(customCfg.base_url)
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
@@ -409,11 +436,15 @@ function HermesContent() {
|
||||
(p.authType === 'api_key' &&
|
||||
!!p.envKey &&
|
||||
!!configuredKeys[p.envKey])
|
||||
const missingKey = p.authType === 'api_key' && !verified
|
||||
const missingKey =
|
||||
p.authType === 'api_key' && !verified && p.id !== 'custom'
|
||||
// hasKey gates click — keep OAuth + local clickable (existing
|
||||
// behaviour) so users can still authenticate via the card.
|
||||
const hasKey =
|
||||
p.authType === 'none' || p.authType === 'oauth' || verified
|
||||
p.authType === 'none' ||
|
||||
p.authType === 'oauth' ||
|
||||
verified ||
|
||||
p.id === 'custom'
|
||||
return (
|
||||
<button
|
||||
key={p.id}
|
||||
@@ -444,7 +475,9 @@ function HermesContent() {
|
||||
<span className="text-xs font-semibold mt-1">{p.name}</span>
|
||||
<span className="text-[9px]" style={mutedStyle}>
|
||||
{(() => {
|
||||
const disc = localDiscovery?.providers.find((lp) => lp.id === p.id)
|
||||
const disc = localDiscovery?.providers.find(
|
||||
(lp) => lp.id === p.id,
|
||||
)
|
||||
if (disc?.online) return '🟢 Detected'
|
||||
if (p.authType === 'oauth') return 'OAuth'
|
||||
if (p.authType === 'none') return 'Local'
|
||||
@@ -474,7 +507,10 @@ function HermesContent() {
|
||||
.filter((m) => m.provider === activeProvider)
|
||||
.map((m) => m.id)
|
||||
if (discovered && discovered.length > 0) return discovered
|
||||
return PROVIDER_CARDS.find((p) => p.id === activeProvider)?.models || []
|
||||
return (
|
||||
PROVIDER_CARDS.find((p) => p.id === activeProvider)?.models ||
|
||||
[]
|
||||
)
|
||||
})().map((model) => (
|
||||
<button
|
||||
key={model}
|
||||
@@ -495,12 +531,73 @@ function HermesContent() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom OpenAI-compatible endpoint fields — Base URL only; API key lives in API Keys section */}
|
||||
{activeProvider === 'custom' && (
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-semibold uppercase tracking-wider" style={mutedStyle}>
|
||||
Custom Endpoint
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{(() => {
|
||||
const isEditing = editingKey === 'custom_base_url'
|
||||
const hasValue = !!customBaseUrl
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-xl px-3 py-2.5" style={cardStyle}>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium">Base URL</div>
|
||||
<div className="text-[11px] font-mono" style={mutedStyle}>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="url"
|
||||
value={customBaseUrl}
|
||||
onChange={(e) => setCustomBaseUrl(e.target.value)}
|
||||
placeholder="http://127.0.0.1:38238/v1"
|
||||
className="w-full rounded border-0 bg-transparent py-0.5 text-[11px] outline-none"
|
||||
style={{ color: 'var(--theme-text)' }}
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
save({ config: { model: { provider: 'manifest' }, providers: { manifest: { type: 'openai', base_url: customBaseUrl, key_env: 'CUSTOM_API_KEY' } } } })
|
||||
.then(() => setEditingKey(null))
|
||||
}
|
||||
if (e.key === 'Escape') setEditingKey(null)
|
||||
}}
|
||||
/>
|
||||
) : hasValue ? customBaseUrl : 'Not configured'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn('size-2 rounded-full', hasValue ? 'bg-green-500' : 'bg-neutral-500')} />
|
||||
{isEditing ? (
|
||||
<>
|
||||
<button type="button" onClick={() => { save({ config: { model: { provider: 'manifest' }, providers: { manifest: { type: 'openai', base_url: customBaseUrl, key_env: 'CUSTOM_API_KEY' } } } }).then(() => setEditingKey(null)) }} className="text-xs font-medium text-green-400">Save</button>
|
||||
<button type="button" onClick={() => setEditingKey(null)} className="text-xs" style={mutedStyle}>Cancel</button>
|
||||
</>
|
||||
) : (
|
||||
<button type="button" onClick={() => setEditingKey('custom_base_url')} className="text-xs font-medium" style={{ color: 'var(--theme-accent)' }}>
|
||||
{hasValue ? 'Edit' : 'Add'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(() => {
|
||||
const disc = localDiscovery?.providers.find((lp) => lp.id === activeProvider)
|
||||
const disc = localDiscovery?.providers.find(
|
||||
(lp) => lp.id === activeProvider,
|
||||
)
|
||||
if (!disc || !disc.needsRestart) return null
|
||||
return (
|
||||
<div className="rounded-lg border border-yellow-500/30 bg-yellow-500/10 px-3 py-2 text-xs text-yellow-200">
|
||||
⚠️ Gateway restart needed to use {disc.name}. Run <code className="rounded bg-black/30 px-1">hermes gateway restart</code> in your terminal.
|
||||
⚠️ Gateway restart needed to use {disc.name}. Run{' '}
|
||||
<code className="rounded bg-black/30 px-1">
|
||||
hermes gateway restart
|
||||
</code>{' '}
|
||||
in your terminal.
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
@@ -946,69 +1043,69 @@ const ENTERPRISE_THEMES = THEMES.map((theme) => ({
|
||||
accent: '#2557B7',
|
||||
text: '#16315F',
|
||||
}
|
||||
: theme.id === 'matrix'
|
||||
? {
|
||||
bg: '#020804',
|
||||
panel: '#07130A',
|
||||
border: 'rgba(0,255,65,0.28)',
|
||||
accent: '#00FF41',
|
||||
text: '#D8FFE3',
|
||||
}
|
||||
: theme.id === 'matrix-light'
|
||||
: theme.id === 'matrix'
|
||||
? {
|
||||
bg: '#F4FFF6',
|
||||
panel: '#FFFFFF',
|
||||
border: 'rgba(0,126,34,0.2)',
|
||||
accent: '#008F2D',
|
||||
text: '#062A12',
|
||||
bg: '#020804',
|
||||
panel: '#07130A',
|
||||
border: 'rgba(0,255,65,0.28)',
|
||||
accent: '#00FF41',
|
||||
text: '#D8FFE3',
|
||||
}
|
||||
: theme.id === 'claude-official'
|
||||
? {
|
||||
bg: '#0A0E1A',
|
||||
panel: '#11182A',
|
||||
border: '#24304A',
|
||||
accent: '#6366F1',
|
||||
text: '#E6EAF2',
|
||||
}
|
||||
: theme.id === 'claude-official-light'
|
||||
? {
|
||||
bg: '#F7F7F1',
|
||||
panel: '#FAFBF6',
|
||||
border: '#CDD5DA',
|
||||
accent: '#2557B7',
|
||||
text: '#16315F',
|
||||
}
|
||||
: theme.id === 'claude-classic'
|
||||
? {
|
||||
bg: '#0d0f12',
|
||||
panel: '#1a1f26',
|
||||
border: '#2a313b',
|
||||
accent: '#b98a44',
|
||||
text: '#eceff4',
|
||||
}
|
||||
: theme.id === 'claude-classic-light'
|
||||
: theme.id === 'matrix-light'
|
||||
? {
|
||||
bg: '#F5F2ED',
|
||||
panel: '#FCFAF7',
|
||||
border: '#D8CCBC',
|
||||
accent: '#b98a44',
|
||||
text: '#1a1f26',
|
||||
bg: '#F4FFF6',
|
||||
panel: '#FFFFFF',
|
||||
border: 'rgba(0,126,34,0.2)',
|
||||
accent: '#008F2D',
|
||||
text: '#062A12',
|
||||
}
|
||||
: theme.id === 'claude-slate'
|
||||
: theme.id === 'claude-official'
|
||||
? {
|
||||
bg: '#0d1117',
|
||||
panel: '#1c2128',
|
||||
border: '#30363d',
|
||||
accent: '#7eb8f6',
|
||||
text: '#c9d1d9',
|
||||
bg: '#0A0E1A',
|
||||
panel: '#11182A',
|
||||
border: '#24304A',
|
||||
accent: '#6366F1',
|
||||
text: '#E6EAF2',
|
||||
}
|
||||
: {
|
||||
bg: '#F6F8FA',
|
||||
panel: '#FFFFFF',
|
||||
border: '#D0D7DE',
|
||||
accent: '#3b82f6',
|
||||
text: '#24292f',
|
||||
},
|
||||
: theme.id === 'claude-official-light'
|
||||
? {
|
||||
bg: '#F7F7F1',
|
||||
panel: '#FAFBF6',
|
||||
border: '#CDD5DA',
|
||||
accent: '#2557B7',
|
||||
text: '#16315F',
|
||||
}
|
||||
: theme.id === 'claude-classic'
|
||||
? {
|
||||
bg: '#0d0f12',
|
||||
panel: '#1a1f26',
|
||||
border: '#2a313b',
|
||||
accent: '#b98a44',
|
||||
text: '#eceff4',
|
||||
}
|
||||
: theme.id === 'claude-classic-light'
|
||||
? {
|
||||
bg: '#F5F2ED',
|
||||
panel: '#FCFAF7',
|
||||
border: '#D8CCBC',
|
||||
accent: '#b98a44',
|
||||
text: '#1a1f26',
|
||||
}
|
||||
: theme.id === 'claude-slate'
|
||||
? {
|
||||
bg: '#0d1117',
|
||||
panel: '#1c2128',
|
||||
border: '#30363d',
|
||||
accent: '#7eb8f6',
|
||||
text: '#c9d1d9',
|
||||
}
|
||||
: {
|
||||
bg: '#F6F8FA',
|
||||
panel: '#FFFFFF',
|
||||
border: '#D0D7DE',
|
||||
accent: '#3b82f6',
|
||||
text: '#24292f',
|
||||
},
|
||||
}))
|
||||
|
||||
function ThemeSwatch({
|
||||
@@ -1280,10 +1377,7 @@ function ChatContent() {
|
||||
value={cs.chatWidth}
|
||||
onChange={(e) =>
|
||||
updateCS({
|
||||
chatWidth: e.target.value as
|
||||
| 'comfortable'
|
||||
| 'wide'
|
||||
| 'full',
|
||||
chatWidth: e.target.value as 'comfortable' | 'wide' | 'full',
|
||||
})
|
||||
}
|
||||
className="h-8 rounded-md border border-primary-200 bg-primary-50 px-2 text-sm text-primary-900 outline-none transition-colors focus-visible:ring-2 focus-visible:ring-primary-400"
|
||||
@@ -1398,11 +1492,14 @@ function _AdvancedContent() {
|
||||
description="Hermes Agent endpoint and connectivity."
|
||||
/>
|
||||
<div className={SETTINGS_CARD_CLASS}>
|
||||
<Row label="Hermes Agent URL" description="Used for API requests from Studio">
|
||||
<Row
|
||||
label="Hermes Agent URL"
|
||||
description="Used for API requests from Studio"
|
||||
>
|
||||
<div className="w-full max-w-sm">
|
||||
<Input
|
||||
type="url"
|
||||
placeholder="http://127.0.0.1:8642"
|
||||
placeholder="https://api.claudeworkspace.app"
|
||||
value={settings.claudeUrl}
|
||||
onChange={(e) => validateAndUpdateUrl(e.target.value)}
|
||||
className="h-8 w-full rounded-lg border-primary-200 text-sm"
|
||||
@@ -1927,10 +2024,6 @@ function DisplayContent() {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Language ────────────────────────────────────────────────────────────
|
||||
|
||||
import { getLocale, setLocale, LOCALE_LABELS, type LocaleId } from '@/lib/i18n'
|
||||
|
||||
function LanguageContent() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -1938,7 +2031,10 @@ function LanguageContent() {
|
||||
title="Language"
|
||||
description="Choose the display language for the workspace UI."
|
||||
/>
|
||||
<Row label="Interface Language" description="Translates navigation, labels, and buttons.">
|
||||
<Row
|
||||
label="Interface Language"
|
||||
description="Translates navigation, labels, and buttons."
|
||||
>
|
||||
<select
|
||||
value={getLocale()}
|
||||
onChange={(e) => {
|
||||
@@ -1947,9 +2043,13 @@ function LanguageContent() {
|
||||
}}
|
||||
className="h-9 w-full rounded-lg border border-primary-200 dark:border-neutral-700 bg-primary-50 dark:bg-neutral-800 px-3 text-sm text-primary-900 dark:text-neutral-100 outline-none md:max-w-xs"
|
||||
>
|
||||
{(Object.entries(LOCALE_LABELS) as Array<[LocaleId, string]>).map(([id, label]) => (
|
||||
<option key={id} value={id}>{label}</option>
|
||||
))}
|
||||
{(Object.entries(LOCALE_LABELS) as Array<[LocaleId, string]>).map(
|
||||
([id, label]) => (
|
||||
<option key={id} value={id}>
|
||||
{label}
|
||||
</option>
|
||||
),
|
||||
)}
|
||||
</select>
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
@@ -77,7 +77,7 @@ const PROVIDERS = [
|
||||
id: 'custom',
|
||||
name: 'Custom OpenAI-compatible',
|
||||
authType: 'api_key',
|
||||
envKeys: [],
|
||||
envKeys: ['CUSTOM_API_KEY'],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import type { LoaderStyle } from '@/hooks/use-chat-settings'
|
||||
import type { BrailleSpinnerPreset } from '@/components/ui/braille-spinner'
|
||||
import type { ThemeId } from '@/lib/theme'
|
||||
import type { SettingsNavId } from '@/components/settings/settings-sidebar'
|
||||
import type {LocaleId} from '@/lib/i18n';
|
||||
import {
|
||||
SETTINGS_NAV_ITEMS,
|
||||
SettingsMobilePills,
|
||||
@@ -29,7 +30,7 @@ import { usePageTitle } from '@/hooks/use-page-title'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { useSettings } from '@/hooks/use-settings'
|
||||
import { getLocale, setLocale, LOCALE_LABELS, type LocaleId } from '@/lib/i18n'
|
||||
import { LOCALE_LABELS, getLocale, setLocale } from '@/lib/i18n'
|
||||
import { THEMES, getTheme, isDarkTheme, setTheme } from '@/lib/theme'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
@@ -160,6 +161,20 @@ const THEME_PREVIEWS: Record<
|
||||
accent: '#b98a44',
|
||||
text: '#1a1f26',
|
||||
},
|
||||
'matrix': {
|
||||
bg: '#020804',
|
||||
panel: '#07130A',
|
||||
border: 'rgba(0,255,65,0.28)',
|
||||
accent: '#00FF41',
|
||||
text: '#D8FFE3',
|
||||
},
|
||||
'matrix-light': {
|
||||
bg: '#F4FFF6',
|
||||
panel: '#FFFFFF',
|
||||
border: 'rgba(0,126,34,0.2)',
|
||||
accent: '#008F2D',
|
||||
text: '#062A12',
|
||||
},
|
||||
'claude-slate-light': {
|
||||
bg: '#F6F8FA',
|
||||
panel: '#FFFFFF',
|
||||
@@ -440,8 +455,12 @@ function SettingsRoute() {
|
||||
}}
|
||||
className="h-9 w-full rounded-lg border border-primary-200 dark:border-gray-600 bg-primary-50 dark:bg-gray-800 px-3 text-sm text-primary-900 dark:text-gray-100 outline-none transition-colors focus-visible:ring-2 focus-visible:ring-primary-400 md:max-w-xs"
|
||||
>
|
||||
{(Object.entries(LOCALE_LABELS) as Array<[LocaleId, string]>).map(([id, label]) => (
|
||||
<option key={id} value={id}>{label}</option>
|
||||
{(
|
||||
Object.entries(LOCALE_LABELS) as Array<[LocaleId, string]>
|
||||
).map(([id, label]) => (
|
||||
<option key={id} value={id}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</SettingsRow>
|
||||
@@ -813,10 +832,7 @@ function ChatDisplaySection() {
|
||||
value={chatSettings.chatWidth}
|
||||
onChange={(e) =>
|
||||
updateChatSettings({
|
||||
chatWidth: e.target.value as
|
||||
| 'comfortable'
|
||||
| 'wide'
|
||||
| 'full',
|
||||
chatWidth: e.target.value as 'comfortable' | 'wide' | 'full',
|
||||
})
|
||||
}
|
||||
className="h-8 rounded-md border border-primary-200 bg-primary-50 px-2 text-sm text-primary-900 outline-none transition-colors focus-visible:ring-2 focus-visible:ring-primary-400"
|
||||
@@ -855,7 +871,7 @@ type LoaderStyleOption = { value: LoaderStyle; label: string }
|
||||
|
||||
const LOADER_STYLES: Array<LoaderStyleOption> = [
|
||||
{ value: 'dots', label: 'Dots' },
|
||||
{ value: 'braille-claude', label: 'Hermes' },
|
||||
{ value: 'braille-claude', label: 'Claude' },
|
||||
{ value: 'braille-orbit', label: 'Orbit' },
|
||||
{ value: 'braille-breathe', label: 'Breathe' },
|
||||
{ value: 'braille-pulse', label: 'Pulse' },
|
||||
@@ -952,7 +968,10 @@ type ClaudeConfigData = {
|
||||
claudeHome: string
|
||||
}
|
||||
|
||||
const CLAUDE_API = process.env.HERMES_API_URL || process.env.CLAUDE_API_URL || 'http://127.0.0.1:8642'
|
||||
const CLAUDE_API =
|
||||
process.env.HERMES_API_URL ||
|
||||
process.env.CLAUDE_API_URL ||
|
||||
'http://127.0.0.1:8642'
|
||||
|
||||
type AvailableModelsResponse = {
|
||||
provider: string
|
||||
@@ -974,6 +993,10 @@ function ClaudeConfigSection({
|
||||
const [modelInput, setModelInput] = useState('')
|
||||
const [providerInput, setProviderInput] = useState('')
|
||||
const [baseUrlInput, setBaseUrlInput] = useState('')
|
||||
const [customApiKey, setCustomApiKey] = useState('')
|
||||
const [customBaseUrl, setCustomBaseUrl] = useState('')
|
||||
const [editingCustomKey, setEditingCustomKey] = useState(false)
|
||||
const [editingCustomBaseUrl, setEditingCustomBaseUrl] = useState(false)
|
||||
|
||||
const [availableProviders, setAvailableProviders] = useState<
|
||||
Array<{ id: string; label: string; authenticated: boolean }>
|
||||
@@ -987,6 +1010,9 @@ function ClaudeConfigSection({
|
||||
setModelInput(configData.activeModel || '')
|
||||
setProviderInput(configData.activeProvider || '')
|
||||
setBaseUrlInput((configData.config?.base_url as string) || '')
|
||||
const providersConfig = configData.config?.providers as Record<string, unknown> | undefined
|
||||
const customConfig = (providersConfig?.manifest || providersConfig?.custom) as Record<string, unknown> | undefined
|
||||
setCustomBaseUrl((customConfig?.base_url as string) || '')
|
||||
}, [])
|
||||
|
||||
const fetchConfig = useCallback(async () => {
|
||||
@@ -1172,7 +1198,7 @@ function ClaudeConfigSection({
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
label="Model"
|
||||
description="The default model Hermes Agent uses for conversations."
|
||||
description="The model Claude uses for conversations."
|
||||
>
|
||||
<div className="flex w-full max-w-sm gap-2">
|
||||
{availableModels.length > 0 ? (
|
||||
@@ -1245,7 +1271,7 @@ function ClaudeConfigSection({
|
||||
icon={CloudIcon}
|
||||
>
|
||||
{data.providers
|
||||
.filter((p) => p.envKeys.length > 0)
|
||||
.filter((p) => p.envKeys.length > 0 && p.id !== 'custom')
|
||||
.map((provider) => (
|
||||
<SettingsRow
|
||||
key={provider.id}
|
||||
@@ -1380,64 +1406,141 @@ function ClaudeConfigSection({
|
||||
|
||||
<SettingsSection
|
||||
title="Custom Providers"
|
||||
description="Read-only provider details loaded from config.yaml."
|
||||
description="Configure a custom OpenAI-compatible endpoint."
|
||||
icon={CloudIcon}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{customProviders.length === 0 ? (
|
||||
<div className="rounded-xl border border-primary-200 bg-primary-100/40 p-3 text-sm text-primary-600">
|
||||
No custom providers configured.
|
||||
</div>
|
||||
) : (
|
||||
customProviders.map((provider, index) => (
|
||||
<div
|
||||
key={`${String(provider.name || provider.base_url || index)}`}
|
||||
className="rounded-xl border border-primary-200 bg-primary-100/40 p-3"
|
||||
>
|
||||
<div className="grid gap-2 text-sm md:grid-cols-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-primary-500">
|
||||
Name
|
||||
</p>
|
||||
<p className="font-medium text-primary-900">
|
||||
{String(provider.name || 'Unnamed')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-primary-500">
|
||||
Base URL
|
||||
</p>
|
||||
<p className="font-mono text-xs text-primary-700 break-all">
|
||||
{String(provider.base_url || 'Not set')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-primary-500">
|
||||
Type
|
||||
</p>
|
||||
<p className="text-primary-700">
|
||||
{String(provider.type || provider.auth_type || 'Unknown')}
|
||||
</p>
|
||||
</div>
|
||||
<SettingsRow
|
||||
label="Custom OpenAI-compatible"
|
||||
description={
|
||||
data.providers.find((p) => p.envKeys.includes('CUSTOM_API_KEY'))
|
||||
?.configured
|
||||
? '✅ Configured'
|
||||
: '❌ Not configured'
|
||||
}
|
||||
>
|
||||
<div className="flex w-full max-w-sm items-center gap-2">
|
||||
<div className="flex-1">
|
||||
{editingCustomKey ? (
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="password"
|
||||
value={customApiKey}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setCustomApiKey(e.target.value)
|
||||
}
|
||||
placeholder="Enter CUSTOM_API_KEY"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
void saveConfig({ env: { CUSTOM_API_KEY: customApiKey } })
|
||||
setEditingCustomKey(false)
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setEditingCustomKey(false)}
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div className="flex flex-col gap-3 rounded-xl border border-primary-200 bg-primary-100/40 p-3 md:flex-row md:items-center md:justify-between">
|
||||
<p className="text-sm text-primary-600">
|
||||
Edit custom providers in config.yaml for security.
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
void navigator.clipboard?.writeText(data.claudeHome)
|
||||
}
|
||||
>
|
||||
Copy config path
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="text-xs font-mono"
|
||||
style={{ color: 'var(--theme-muted)' }}
|
||||
>
|
||||
{data.providers.find((p) =>
|
||||
p.envKeys.includes('CUSTOM_API_KEY'),
|
||||
)?.maskedKeys?.['CUSTOM_API_KEY'] || 'Not set'}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setEditingCustomKey(true)
|
||||
setCustomApiKey('')
|
||||
}}
|
||||
>
|
||||
{data.providers.find((p) =>
|
||||
p.envKeys.includes('CUSTOM_API_KEY'),
|
||||
)?.configured
|
||||
? 'Change'
|
||||
: 'Add'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
label="Custom Base URL"
|
||||
description={customBaseUrl ? `✅ ${customBaseUrl}` : '❌ Not configured'}
|
||||
>
|
||||
<div className="flex w-full max-w-sm items-center gap-2">
|
||||
<div className="flex-1">
|
||||
{editingCustomBaseUrl ? (
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={customBaseUrl}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setCustomBaseUrl(e.target.value)
|
||||
}
|
||||
placeholder="https://api.example.com/v1"
|
||||
className="flex-1 font-mono text-sm"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
void saveConfig({
|
||||
config: {
|
||||
model: { provider: 'manifest' },
|
||||
providers: {
|
||||
manifest: {
|
||||
type: 'openai',
|
||||
base_url: customBaseUrl,
|
||||
key_env: 'CUSTOM_API_KEY',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
setEditingCustomBaseUrl(false)
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setEditingCustomBaseUrl(false)}
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="text-xs font-mono"
|
||||
style={{ color: 'var(--theme-muted)' }}
|
||||
>
|
||||
{customBaseUrl || 'Not set'}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setEditingCustomBaseUrl(true)}
|
||||
>
|
||||
{customBaseUrl ? 'Edit' : 'Add'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
@@ -1447,7 +1550,7 @@ function ClaudeConfigSection({
|
||||
>
|
||||
<SettingsRow
|
||||
label="Config location"
|
||||
description="Where Hermes Agent stores its configuration."
|
||||
description="Where Claude stores its configuration."
|
||||
>
|
||||
<span
|
||||
className="text-xs font-mono"
|
||||
@@ -2032,11 +2135,11 @@ function ConnectionSection() {
|
||||
</div>
|
||||
|
||||
<div className="mt-3 rounded-lg border border-primary-200 bg-primary-100/50 p-3 text-xs text-primary-600">
|
||||
<strong className="font-semibold">Tailscale / remote tip:</strong>{' '}
|
||||
Set the gateway to its Tailscale IP (e.g. <code>http://100.x.y.z:8642</code>)
|
||||
and ensure the gateway listens on <code>0.0.0.0</code> (set{' '}
|
||||
<code>API_SERVER_HOST=0.0.0.0</code> in the agent-side <code>.env</code>).
|
||||
No workspace restart needed — capabilities reprobe on save.
|
||||
<strong className="font-semibold">Tailscale / remote tip:</strong> Set
|
||||
the gateway to its Tailscale IP (e.g. <code>http://100.x.y.z:8642</code>
|
||||
) and ensure the gateway listens on <code>0.0.0.0</code> (set{' '}
|
||||
<code>API_SERVER_HOST=0.0.0.0</code> in the agent-side <code>.env</code>
|
||||
). No workspace restart needed — capabilities reprobe on save.
|
||||
</div>
|
||||
</SettingsSection>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user