mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-12 08:51:53 +00:00
feat(desktop): dedicated Providers settings + polished Accounts/API-keys UX (#38551)
* feat(desktop): dedicated Providers settings with Accounts/API-keys subnav Rework provider configuration in the desktop app into its own Providers page that mirrors the first-run onboarding picker, instead of burying provider keys in the generic Tools & Keys list. - Add a Providers settings page (providers-settings.tsx) reusing the onboarding picker cards/ApiKeyForm so the two surfaces stay identical - Add a sidebar subnav (Accounts vs API keys) backed by a deep-linkable `pview` URL param; nested OverlayNavItem variant for a lighter active state so children don't compete with the parent item - Scope provider search to the active sub-view in its native card format (no more accordion fallback); collapse the API-key grid to the top providers behind a "Show all" toggle to cut scrolling - Launch real in-app OAuth from settings via startManualProviderOAuth; fix the misleading red "reason" banner that showed during an active connect (neutral style, hidden during a flow, omitted for direct per-provider launches) - Expand PROVIDER_GROUPS and add longest-prefix matching so providers like xAI/Ollama group correctly instead of landing under "Other" - Drop redundant messaging API keys from Tools & Keys (channel_managed) Co-authored-by: Cursor <cursoragent@cursor.com> * feat(desktop): Cursor-style provider key list with inline inputs Replace the card-grid API-key form on the Providers page with a per-provider list (mirrors Cursor's API keys section): - One row per vendor with its primary key input inline; rows with extra vars (base URL, region, alt tokens) expand to reveal those on focus - Set keys show their redacted value as the placeholder; Save appears on edit, Remove on a set key - Hide redundant alias key fields (e.g. ANTHROPIC_TOKEN vs ANTHROPIC_API_KEY) unless already set, and label set aliases by env var name so they're unambiguous - Smaller mono input text + compact height Co-authored-by: Cursor <cursoragent@cursor.com> * style(desktop): flatten providers settings UI chrome Tighten the providers settings surface to match the newer desktop style: remove extra card rails/borders in API-key rows, reduce visual noise in the providers subnav, replace bespoke link-like controls with shared text-button variants, and improve key input readability. * feat(desktop): rework providers settings UI - Flatten the shared OAuth picker rows (accounts + onboarding): drop the rounded-2xl/border cards for flat hover-bg rows; Nous hero keeps a subtle tint plus an animated blue→purple arc border. - Key fields collapse to a single input: a set key reads read-only (redacted) and edits in place on focus/click — no Replace/Cancel chrome. Save on type, Esc cancels (without closing the overlay), "Remove or esc to cancel" hint. - Non-key overrides render boxless, content-sized (field-sizing) and right-anchored; advanced fields align under the primary key column. - Add `xs` control size; size fields via padding (no fixed heights). - Cards expand on key-input focus; chevron shows on hover/expanded; expanded state uses a ring + softer bg tier so hover ≠ focus. - Relocate "Get a key" to the bottom-right of the expanded panel; drop the redundant provider description. - Cmd+K: add Providers (accounts) and Provider API keys deep-links. * fix(desktop): flatten provider fields, drop input shadows, fix Cmd+K provider rank - KeyField: collapse to one stacked label-above-input form field (drop the bespoke `naked`/inline/column branches); empty advanced overrides fade until hover/focus/set - styles: kill the resting + focus drop shadow on shared input chrome so form inputs sit flat (composer keeps its own shadow) - Cmd+K: drop stray `providers` keyword from Skills & Tools so the Providers settings entry ranks first for "provider" * fix(desktop): nous portal arc blue → orange * fix(desktop): rank appearance above settings in Cmd+K --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com>
This commit is contained in:
parent
b36a30db20
commit
9cbc37e25b
16 changed files with 1468 additions and 469 deletions
|
|
@ -36,7 +36,8 @@ import {
|
|||
Settings,
|
||||
Sun,
|
||||
Users,
|
||||
Wrench
|
||||
Wrench,
|
||||
Zap
|
||||
} from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $commandPaletteOpen, closeCommandPalette, setCommandPaletteOpen } from '@/store/command-palette'
|
||||
|
|
@ -98,8 +99,20 @@ const toSessionEntry = (session: SessionRow): SessionEntry => ({
|
|||
})
|
||||
|
||||
const NON_CONFIG_SETTINGS: ReadonlyArray<{ icon: IconComponent; keywords?: string[]; label: string; tab: string }> = [
|
||||
{
|
||||
icon: Zap,
|
||||
keywords: ['accounts', 'sign in', 'oauth', 'login', 'subscription', 'models', 'anthropic', 'openai'],
|
||||
label: 'Providers',
|
||||
tab: 'providers&pview=accounts'
|
||||
},
|
||||
{
|
||||
icon: KeyRound,
|
||||
keywords: ['providers', 'api key', 'keys', 'secrets', 'tokens'],
|
||||
label: 'Provider API keys',
|
||||
tab: 'providers&pview=keys'
|
||||
},
|
||||
{ icon: Globe, keywords: ['connection', 'messaging'], label: 'Gateway', tab: 'gateway' },
|
||||
{ icon: KeyRound, keywords: ['api', 'secrets', 'tokens', 'credentials'], label: 'API Keys', tab: 'keys' },
|
||||
{ icon: KeyRound, keywords: ['api', 'secrets', 'tokens', 'credentials'], label: 'Tools & Keys', tab: 'keys' },
|
||||
{ icon: Wrench, keywords: ['servers', 'tools'], label: 'MCP', tab: 'mcp' },
|
||||
{ icon: Archive, keywords: ['history', 'archived'], label: 'Archived Chats', tab: 'sessions' },
|
||||
{ icon: Info, keywords: ['version', 'about'], label: 'About', tab: 'about' }
|
||||
|
|
@ -169,7 +182,7 @@ export function CommandPalette() {
|
|||
{
|
||||
icon: Wrench,
|
||||
id: 'nav-skills',
|
||||
keywords: ['tools', 'toolsets', 'providers'],
|
||||
keywords: ['tools', 'toolsets'],
|
||||
label: 'Skills & Tools',
|
||||
run: go(SKILLS_ROUTE)
|
||||
},
|
||||
|
|
@ -207,25 +220,9 @@ export function CommandPalette() {
|
|||
]
|
||||
},
|
||||
{
|
||||
heading: 'Settings',
|
||||
items: [
|
||||
...SECTIONS.map(section => ({
|
||||
icon: section.icon,
|
||||
id: `set-config-${section.id}`,
|
||||
keywords: ['settings', section.label],
|
||||
label: section.label,
|
||||
run: go(settingsTab(`config:${section.id}`))
|
||||
})),
|
||||
...NON_CONFIG_SETTINGS.map(entry => ({
|
||||
icon: entry.icon,
|
||||
id: `set-${entry.tab}`,
|
||||
keywords: ['settings', ...(entry.keywords ?? [])],
|
||||
label: entry.label,
|
||||
run: go(settingsTab(entry.tab))
|
||||
}))
|
||||
]
|
||||
},
|
||||
{
|
||||
// Declared before Settings: cmdk keeps group order, so this keeps the
|
||||
// theme/mode pickers on top for "theme"/"color" queries instead of
|
||||
// buried under a fuzzy Settings match.
|
||||
heading: 'Appearance',
|
||||
items: [
|
||||
{
|
||||
|
|
@ -243,6 +240,25 @@ export function CommandPalette() {
|
|||
to: 'color-mode'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
heading: 'Settings',
|
||||
items: [
|
||||
...SECTIONS.map(section => ({
|
||||
icon: section.icon,
|
||||
id: `set-config-${section.id}`,
|
||||
keywords: ['settings', section.label],
|
||||
label: section.label,
|
||||
run: go(settingsTab(`config:${section.id}`))
|
||||
})),
|
||||
...NON_CONFIG_SETTINGS.map(entry => ({
|
||||
icon: entry.icon,
|
||||
id: `set-${entry.tab}`,
|
||||
keywords: ['settings', ...(entry.keywords ?? [])],
|
||||
label: entry.label,
|
||||
run: go(settingsTab(entry.tab))
|
||||
}))
|
||||
]
|
||||
}
|
||||
]
|
||||
}, [go])
|
||||
|
|
|
|||
|
|
@ -316,7 +316,7 @@ export function DesktopController() {
|
|||
})
|
||||
|
||||
const openProviderSettings = useCallback(() => {
|
||||
navigate(`${SETTINGS_ROUTE}?tab=keys`)
|
||||
navigate(`${SETTINGS_ROUTE}?tab=providers`)
|
||||
}, [navigate])
|
||||
|
||||
const modelMenuContent = useMemo(
|
||||
|
|
|
|||
|
|
@ -24,6 +24,9 @@ interface OverlayNavItemProps {
|
|||
active: boolean
|
||||
icon: IconComponent
|
||||
label: string
|
||||
// Renders as an indented child of another nav item: smaller icon and a
|
||||
// lighter active state so it never competes with the boxed parent item.
|
||||
nested?: boolean
|
||||
onClick: () => void
|
||||
trailing?: ReactNode
|
||||
}
|
||||
|
|
@ -70,19 +73,29 @@ export function OverlayMain({ children, className }: OverlayMainProps) {
|
|||
)
|
||||
}
|
||||
|
||||
export function OverlayNavItem({ active, icon: Icon, label, onClick, trailing }: OverlayNavItemProps) {
|
||||
export function OverlayNavItem({ active, icon: Icon, label, nested, onClick, trailing }: OverlayNavItemProps) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'flex h-7 w-full items-center justify-start gap-2 rounded-md border px-2 text-left text-[length:var(--conversation-text-font-size)] font-normal transition-colors',
|
||||
active
|
||||
? 'border-(--ui-stroke-tertiary) bg-(--ui-bg-tertiary) text-foreground'
|
||||
: 'border-transparent bg-transparent text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-foreground'
|
||||
nested
|
||||
? active
|
||||
? 'border-transparent bg-(--chrome-action-hover) font-medium text-foreground'
|
||||
: 'border-transparent bg-transparent text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground'
|
||||
: active
|
||||
? 'border-(--ui-stroke-tertiary) bg-(--ui-bg-tertiary) text-foreground'
|
||||
: 'border-transparent bg-transparent text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-foreground'
|
||||
)}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
<Icon className={cn('size-4 shrink-0', active ? 'text-foreground/80' : 'text-muted-foreground/80')} />
|
||||
<Icon
|
||||
className={cn(
|
||||
'shrink-0',
|
||||
nested ? 'size-3.5' : 'size-4',
|
||||
active ? 'text-foreground/80' : 'text-muted-foreground/80'
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate">{label}</span>
|
||||
{trailing}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -15,9 +15,21 @@ import type { ThemeMode } from '@/themes/context'
|
|||
|
||||
import type { DesktopConfigSection } from './types'
|
||||
|
||||
// Provider group definitions used to fold raw env-var names like
|
||||
// ``XAI_API_KEY`` into a single "xAI" card with a friendly label, short
|
||||
// description, and signup URL. Membership is determined by longest
|
||||
// prefix match (see ``providerGroup`` in helpers.ts) so more specific
|
||||
// prefixes (``MINIMAX_CN_``) correctly beat their general parents
|
||||
// (``MINIMAX_``). New providers should be added here so they get their
|
||||
// own card in Settings → Keys instead of being lumped into "Other".
|
||||
interface ProviderPrefix {
|
||||
prefix: string
|
||||
name: string
|
||||
/** Optional one-line tagline shown beneath the group name. */
|
||||
description?: string
|
||||
/** Optional canonical signup/console URL surfaced from the card header. */
|
||||
docsUrl?: string
|
||||
/** Lower numbers float to the top of the providers list. */
|
||||
priority: number
|
||||
}
|
||||
|
||||
|
|
@ -25,24 +37,180 @@ export const EMPTY_SELECT_VALUE = '__hermes_empty__'
|
|||
export const CONTROL_TEXT = 'text-xs'
|
||||
|
||||
export const PROVIDER_GROUPS: ProviderPrefix[] = [
|
||||
{ prefix: 'NOUS_', name: 'Nous Portal', priority: 0 },
|
||||
{ prefix: 'ANTHROPIC_', name: 'Anthropic', priority: 1 },
|
||||
{ prefix: 'DASHSCOPE_', name: 'DashScope (Qwen)', priority: 2 },
|
||||
{ prefix: 'HERMES_QWEN_', name: 'DashScope (Qwen)', priority: 2 },
|
||||
{ prefix: 'DEEPSEEK_', name: 'DeepSeek', priority: 3 },
|
||||
{ prefix: 'GOOGLE_', name: 'Gemini', priority: 4 },
|
||||
{
|
||||
prefix: 'NOUS_',
|
||||
name: 'Nous Portal',
|
||||
description: 'Hosted Hermes & Nous-trained models',
|
||||
docsUrl: 'https://portal.nousresearch.com',
|
||||
priority: 0
|
||||
},
|
||||
{
|
||||
prefix: 'OPENROUTER_',
|
||||
name: 'OpenRouter',
|
||||
description: 'Aggregator for hundreds of frontier models',
|
||||
docsUrl: 'https://openrouter.ai/keys',
|
||||
priority: 1
|
||||
},
|
||||
{
|
||||
prefix: 'ANTHROPIC_',
|
||||
name: 'Anthropic',
|
||||
description: 'Claude API access (Sonnet, Opus, Haiku)',
|
||||
docsUrl: 'https://console.anthropic.com/settings/keys',
|
||||
priority: 2
|
||||
},
|
||||
{
|
||||
prefix: 'XAI_',
|
||||
name: 'xAI',
|
||||
description: 'Grok models (use OAuth for SuperGrok / Premium+)',
|
||||
docsUrl: 'https://console.x.ai/',
|
||||
priority: 3
|
||||
},
|
||||
{
|
||||
prefix: 'GOOGLE_',
|
||||
name: 'Gemini',
|
||||
description: 'Google AI Studio (Gemini 1.5 / 2.0 / 2.5)',
|
||||
docsUrl: 'https://aistudio.google.com/app/apikey',
|
||||
priority: 4
|
||||
},
|
||||
{ prefix: 'GEMINI_', name: 'Gemini', priority: 4 },
|
||||
{ prefix: 'GLM_', name: 'GLM / Z.AI', priority: 5 },
|
||||
{ prefix: 'ZAI_', name: 'GLM / Z.AI', priority: 5 },
|
||||
{ prefix: 'Z_AI_', name: 'GLM / Z.AI', priority: 5 },
|
||||
{ prefix: 'HF_', name: 'Hugging Face', priority: 6 },
|
||||
{ prefix: 'KIMI_', name: 'Kimi / Moonshot', priority: 7 },
|
||||
{ prefix: 'MINIMAX_', name: 'MiniMax', priority: 8 },
|
||||
{ prefix: 'MINIMAX_CN_', name: 'MiniMax (China)', priority: 9 },
|
||||
{ prefix: 'OPENCODE_GO_', name: 'OpenCode Go', priority: 10 },
|
||||
{ prefix: 'OPENCODE_ZEN_', name: 'OpenCode Zen', priority: 11 },
|
||||
{ prefix: 'OPENROUTER_', name: 'OpenRouter', priority: 12 },
|
||||
{ prefix: 'XIAOMI_', name: 'Xiaomi MiMo', priority: 13 }
|
||||
{ prefix: 'HERMES_GEMINI_', name: 'Gemini', priority: 4 },
|
||||
{
|
||||
prefix: 'DEEPSEEK_',
|
||||
name: 'DeepSeek',
|
||||
description: 'Direct DeepSeek API (V3.x, R1)',
|
||||
docsUrl: 'https://platform.deepseek.com/api_keys',
|
||||
priority: 5
|
||||
},
|
||||
{
|
||||
prefix: 'DASHSCOPE_',
|
||||
name: 'DashScope (Qwen)',
|
||||
description: 'Alibaba Cloud DashScope — Qwen and multi-vendor models',
|
||||
docsUrl: 'https://modelstudio.console.alibabacloud.com/',
|
||||
priority: 6
|
||||
},
|
||||
{ prefix: 'HERMES_QWEN_', name: 'DashScope (Qwen)', priority: 6 },
|
||||
{
|
||||
prefix: 'GLM_',
|
||||
name: 'GLM / Z.AI',
|
||||
description: 'Zhipu GLM-4.6 and Z.AI hosted endpoints',
|
||||
docsUrl: 'https://z.ai/',
|
||||
priority: 7
|
||||
},
|
||||
{ prefix: 'ZAI_', name: 'GLM / Z.AI', priority: 7 },
|
||||
{ prefix: 'Z_AI_', name: 'GLM / Z.AI', priority: 7 },
|
||||
{
|
||||
prefix: 'KIMI_',
|
||||
name: 'Kimi / Moonshot',
|
||||
description: 'Moonshot Kimi K2 / coding endpoints',
|
||||
docsUrl: 'https://platform.moonshot.cn/',
|
||||
priority: 8
|
||||
},
|
||||
{
|
||||
prefix: 'KIMI_CN_',
|
||||
name: 'Kimi (China)',
|
||||
description: 'Moonshot China endpoint',
|
||||
docsUrl: 'https://platform.moonshot.cn/',
|
||||
priority: 9
|
||||
},
|
||||
{
|
||||
prefix: 'MINIMAX_',
|
||||
name: 'MiniMax',
|
||||
description: 'MiniMax-M2 and Hailuo international endpoints',
|
||||
docsUrl: 'https://www.minimax.io/',
|
||||
priority: 10
|
||||
},
|
||||
{
|
||||
prefix: 'MINIMAX_CN_',
|
||||
name: 'MiniMax (China)',
|
||||
description: 'MiniMax mainland China endpoint',
|
||||
docsUrl: 'https://www.minimaxi.com/',
|
||||
priority: 11
|
||||
},
|
||||
{
|
||||
prefix: 'HF_',
|
||||
name: 'Hugging Face',
|
||||
description: 'Inference Providers — 20+ open models via router.huggingface.co',
|
||||
docsUrl: 'https://huggingface.co/settings/tokens',
|
||||
priority: 12
|
||||
},
|
||||
{
|
||||
prefix: 'OPENCODE_ZEN_',
|
||||
name: 'OpenCode Zen',
|
||||
description: 'Pay-as-you-go access to curated coding models',
|
||||
docsUrl: 'https://opencode.ai/auth',
|
||||
priority: 13
|
||||
},
|
||||
{
|
||||
prefix: 'OPENCODE_GO_',
|
||||
name: 'OpenCode Go',
|
||||
description: '$10/month subscription for open coding models',
|
||||
docsUrl: 'https://opencode.ai/auth',
|
||||
priority: 14
|
||||
},
|
||||
{
|
||||
prefix: 'NVIDIA_',
|
||||
name: 'NVIDIA NIM',
|
||||
description: 'build.nvidia.com or your own local NIM endpoint',
|
||||
docsUrl: 'https://build.nvidia.com/',
|
||||
priority: 15
|
||||
},
|
||||
{
|
||||
prefix: 'OLLAMA_',
|
||||
name: 'Ollama Cloud',
|
||||
description: 'Cloud-hosted open models from ollama.com',
|
||||
docsUrl: 'https://ollama.com/settings',
|
||||
priority: 16
|
||||
},
|
||||
{
|
||||
prefix: 'LM_',
|
||||
name: 'LM Studio',
|
||||
description: 'Local LM Studio server (OpenAI-compatible)',
|
||||
docsUrl: 'https://lmstudio.ai/docs/local-server',
|
||||
priority: 17
|
||||
},
|
||||
{
|
||||
prefix: 'STEPFUN_',
|
||||
name: 'StepFun',
|
||||
description: 'StepFun Step Plan coding models',
|
||||
docsUrl: 'https://platform.stepfun.com/',
|
||||
priority: 18
|
||||
},
|
||||
{
|
||||
prefix: 'XIAOMI_',
|
||||
name: 'Xiaomi MiMo',
|
||||
description: 'MiMo-V2.5 and Xiaomi proprietary models',
|
||||
docsUrl: 'https://platform.xiaomimimo.com',
|
||||
priority: 19
|
||||
},
|
||||
{
|
||||
prefix: 'ARCEEAI_',
|
||||
name: 'Arcee AI',
|
||||
description: 'Arcee-hosted small + medium models',
|
||||
docsUrl: 'https://chat.arcee.ai/',
|
||||
priority: 20
|
||||
},
|
||||
{ prefix: 'ARCEE_', name: 'Arcee AI', priority: 20 },
|
||||
{
|
||||
prefix: 'GMI_',
|
||||
name: 'GMI Cloud',
|
||||
description: 'GMI Cloud GPU + model serving',
|
||||
docsUrl: 'https://www.gmicloud.ai/',
|
||||
priority: 21
|
||||
},
|
||||
{
|
||||
prefix: 'AZURE_FOUNDRY_',
|
||||
name: 'Azure Foundry',
|
||||
description: 'Azure AI Foundry custom endpoints (OpenAI / Anthropic-compatible)',
|
||||
docsUrl: 'https://ai.azure.com/',
|
||||
priority: 22
|
||||
},
|
||||
{
|
||||
prefix: 'AWS_',
|
||||
name: 'AWS Bedrock',
|
||||
description: 'Authenticate via AWS profile + region',
|
||||
docsUrl: 'https://docs.aws.amazon.com/bedrock/latest/userguide/bedrock-regions.html',
|
||||
priority: 23
|
||||
}
|
||||
]
|
||||
|
||||
export const BUILTIN_PERSONALITIES = [
|
||||
|
|
|
|||
354
apps/desktop/src/app/settings/env-credentials.tsx
Normal file
354
apps/desktop/src/app/settings/env-credentials.tsx
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { deleteEnvVar, getEnvVars, revealEnvVar, setEnvVar } from '@/hermes'
|
||||
import { Check, Eye, EyeOff, type IconComponent, Save, Trash2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import type { EnvVarInfo } from '@/types/hermes'
|
||||
|
||||
import { CONTROL_TEXT } from './constants'
|
||||
import { asText, includesQuery, redactedValue, withoutKey } from './helpers'
|
||||
import { Pill } from './primitives'
|
||||
import type { EnvRowProps } from './types'
|
||||
|
||||
// Shared filter used by every credential surface (Providers + Keys pages):
|
||||
// category gate first, then a free-text match across key name + description.
|
||||
export function filterEnv(info: EnvVarInfo, key: string, q: string, cat: string, extra?: string): boolean {
|
||||
if (asText(info.category) !== cat) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!q) {
|
||||
return true
|
||||
}
|
||||
|
||||
return (
|
||||
key.toLowerCase().includes(q) ||
|
||||
includesQuery(info.description, q) ||
|
||||
Boolean(extra && extra.toLowerCase().includes(q))
|
||||
)
|
||||
}
|
||||
|
||||
function EnvActions({
|
||||
varKey,
|
||||
info,
|
||||
saving,
|
||||
onEdit,
|
||||
onClear,
|
||||
onReveal,
|
||||
isRevealed,
|
||||
showReveal = true
|
||||
}: EnvActionsProps) {
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{info.url && (
|
||||
<Button asChild size="xs" title="Open provider docs" variant="ghost">
|
||||
<a href={info.url} rel="noreferrer" target="_blank">
|
||||
Docs
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
{info.is_set && showReveal && (
|
||||
<Button
|
||||
onClick={() => onReveal(varKey)}
|
||||
size="icon-xs"
|
||||
title={isRevealed ? 'Hide value' : 'Reveal value'}
|
||||
variant="ghost"
|
||||
>
|
||||
{isRevealed ? <EyeOff /> : <Eye />}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={onEdit} size="xs" variant="outline">
|
||||
{info.is_set ? 'Replace' : 'Set'}
|
||||
</Button>
|
||||
{info.is_set && (
|
||||
<Button
|
||||
disabled={saving === varKey}
|
||||
onClick={() => onClear(varKey)}
|
||||
size="icon-xs"
|
||||
title="Clear value"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function EnvVarRow({
|
||||
varKey,
|
||||
info,
|
||||
edits,
|
||||
revealed,
|
||||
saving,
|
||||
setEdits,
|
||||
onSave,
|
||||
onClear,
|
||||
onReveal,
|
||||
compact = false
|
||||
}: EnvRowProps) {
|
||||
const isEditing = edits[varKey] !== undefined
|
||||
const isRevealed = revealed[varKey] !== undefined
|
||||
const value = isRevealed ? revealed[varKey] : info.redacted_value
|
||||
const startEdit = () => setEdits(c => ({ ...c, [varKey]: '' }))
|
||||
|
||||
if (compact && !isEditing) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 py-1.5">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-mono text-[0.72rem] text-muted-foreground">{varKey}</div>
|
||||
<div className="truncate text-[0.68rem] text-muted-foreground/70">{info.description}</div>
|
||||
</div>
|
||||
<EnvActions
|
||||
info={info}
|
||||
isRevealed={isRevealed}
|
||||
onClear={onClear}
|
||||
onEdit={startEdit}
|
||||
onReveal={onReveal}
|
||||
saving={saving}
|
||||
showReveal={false}
|
||||
varKey={varKey}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-2 rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-tertiary)/20 p-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-mono text-xs font-medium">{varKey}</span>
|
||||
<Pill tone={info.is_set ? 'primary' : 'muted'}>
|
||||
{info.is_set && <Check className="size-3" />}
|
||||
{info.is_set ? 'Set' : 'Not set'}
|
||||
</Pill>
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">{info.description}</p>
|
||||
</div>
|
||||
<EnvActions
|
||||
info={info}
|
||||
isRevealed={isRevealed}
|
||||
onClear={onClear}
|
||||
onEdit={startEdit}
|
||||
onReveal={onReveal}
|
||||
saving={saving}
|
||||
varKey={varKey}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isEditing && info.is_set && (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-md px-3 py-2 font-mono text-xs',
|
||||
isRevealed ? 'bg-background text-foreground' : 'bg-muted/30 text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{value || '---'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEditing && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
autoFocus
|
||||
className={cn('min-w-56 flex-1 font-mono', CONTROL_TEXT)}
|
||||
onChange={e => setEdits(c => ({ ...c, [varKey]: e.target.value }))}
|
||||
placeholder={info.is_set ? 'Replace current value' : 'Enter value'}
|
||||
type={info.is_password ? 'password' : 'text'}
|
||||
value={edits[varKey]}
|
||||
/>
|
||||
<Button disabled={saving === varKey || !edits[varKey]} onClick={() => onSave(varKey)} size="sm">
|
||||
<Save />
|
||||
{saving === varKey ? 'Saving' : 'Save'}
|
||||
</Button>
|
||||
<Button onClick={() => setEdits(c => withoutKey(c, varKey))} size="sm" variant="outline">
|
||||
<Codicon name="close" />
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SettingsCategoryHeading({ count, icon: Icon, title }: CategoryHeadingProps) {
|
||||
return (
|
||||
<div className="mb-3 flex items-center gap-2 text-[length:var(--conversation-text-font-size)] font-medium">
|
||||
<Icon className="size-4 text-muted-foreground" />
|
||||
<span>{title}</span>
|
||||
{count && <Pill>{count}</Pill>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Owns the env-var fetch + the edit/reveal/save/delete lifecycle so multiple
|
||||
// credential pages (Providers, Keys) share one source of truth and one set of
|
||||
// mutation handlers instead of duplicating the plumbing.
|
||||
export function useEnvCredentials(): UseEnvCredentials {
|
||||
const [vars, setVars] = useState<Record<string, EnvVarInfo> | null>(null)
|
||||
const [edits, setEdits] = useState<Record<string, string>>({})
|
||||
const [revealed, setRevealed] = useState<Record<string, string>>({})
|
||||
const [saving, setSaving] = useState<string | null>(null)
|
||||
|
||||
// Best-effort cleanup of a retired localStorage flag (global "Show
|
||||
// advanced" toggle) — everything in these views is configuration-level.
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.removeItem('desktop.settings.keys.show_advanced')
|
||||
} catch {
|
||||
// Ignore — old key cleanup is best-effort.
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const next = await getEnvVars()
|
||||
|
||||
if (!cancelled) {
|
||||
setVars(next)
|
||||
}
|
||||
} catch (err) {
|
||||
notifyError(err, 'API keys failed to load')
|
||||
}
|
||||
})()
|
||||
|
||||
return () => void (cancelled = true)
|
||||
}, [])
|
||||
|
||||
function patchVar(key: string, patch: Partial<Pick<EnvVarInfo, 'is_set' | 'redacted_value'>>) {
|
||||
setVars(c => (c ? { ...c, [key]: { ...c[key], ...patch } } : c))
|
||||
}
|
||||
|
||||
function clearLocalState(key: string) {
|
||||
setEdits(c => withoutKey(c, key))
|
||||
setRevealed(c => withoutKey(c, key))
|
||||
}
|
||||
|
||||
async function handleSave(key: string) {
|
||||
const value = edits[key]
|
||||
|
||||
if (!value) {
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(key)
|
||||
|
||||
try {
|
||||
await setEnvVar(key, value)
|
||||
patchVar(key, { is_set: true, redacted_value: redactedValue(value) })
|
||||
clearLocalState(key)
|
||||
notify({ kind: 'success', title: 'Credential saved', message: `${key} updated.` })
|
||||
} catch (err) {
|
||||
notifyError(err, `Failed to save ${key}`)
|
||||
} finally {
|
||||
setSaving(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Direct save for a known value (no edit-state round-trip) — used by the
|
||||
// onboarding-style key form, which owns its own input. Returns a result so
|
||||
// the form can surface inline errors instead of only toasting.
|
||||
async function saveValue(key: string, value: string): Promise<{ message?: string; ok: boolean }> {
|
||||
const trimmed = value.trim()
|
||||
|
||||
if (!trimmed) {
|
||||
return { message: 'Enter a value first.', ok: false }
|
||||
}
|
||||
|
||||
setSaving(key)
|
||||
|
||||
try {
|
||||
await setEnvVar(key, trimmed)
|
||||
patchVar(key, { is_set: true, redacted_value: redactedValue(trimmed) })
|
||||
clearLocalState(key)
|
||||
notify({ kind: 'success', message: `${key} updated.`, title: 'Credential saved' })
|
||||
|
||||
return { ok: true }
|
||||
} catch (err) {
|
||||
notifyError(err, `Failed to save ${key}`)
|
||||
|
||||
return { message: err instanceof Error ? err.message : 'Could not save credential.', ok: false }
|
||||
} finally {
|
||||
setSaving(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClear(key: string) {
|
||||
if (!window.confirm(`Remove ${key} from .env?`)) {
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(key)
|
||||
|
||||
try {
|
||||
await deleteEnvVar(key)
|
||||
patchVar(key, { is_set: false, redacted_value: null })
|
||||
clearLocalState(key)
|
||||
notify({ kind: 'success', title: 'Credential removed', message: `${key} removed.` })
|
||||
} catch (err) {
|
||||
notifyError(err, `Failed to remove ${key}`)
|
||||
} finally {
|
||||
setSaving(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReveal(key: string) {
|
||||
if (revealed[key]) {
|
||||
setRevealed(c => withoutKey(c, key))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await revealEnvVar(key)
|
||||
setRevealed(c => ({ ...c, [key]: result.value }))
|
||||
} catch (err) {
|
||||
notifyError(err, `Failed to reveal ${key}`)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
saveValue,
|
||||
vars,
|
||||
rowProps: {
|
||||
edits,
|
||||
revealed,
|
||||
saving,
|
||||
setEdits,
|
||||
onSave: handleSave,
|
||||
onClear: handleClear,
|
||||
onReveal: handleReveal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface CategoryHeadingProps {
|
||||
count?: string
|
||||
icon: IconComponent
|
||||
title: string
|
||||
}
|
||||
|
||||
interface EnvActionsProps {
|
||||
varKey: string
|
||||
info: EnvVarInfo
|
||||
saving: string | null
|
||||
onEdit: () => void
|
||||
onClear: (key: string) => void
|
||||
onReveal: (key: string) => void
|
||||
isRevealed: boolean
|
||||
showReveal?: boolean
|
||||
}
|
||||
|
||||
interface UseEnvCredentials {
|
||||
rowProps: Omit<EnvRowProps, 'varKey' | 'info'>
|
||||
saveValue: (key: string, value: string) => Promise<{ message?: string; ok: boolean }>
|
||||
vars: Record<string, EnvVarInfo> | null
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
|
|||
|
||||
import type { HermesConfigRecord } from '@/types/hermes'
|
||||
|
||||
import { getNested, setNested } from './helpers'
|
||||
import { getNested, providerGroup, setNested } from './helpers'
|
||||
|
||||
describe('settings helpers', () => {
|
||||
it('reads and writes nested config paths', () => {
|
||||
|
|
@ -20,4 +20,28 @@ describe('settings helpers', () => {
|
|||
expect(() => setNested(config, 'constructor.prototype.polluted', true)).toThrow('Unsafe config path')
|
||||
expect(({} as Record<string, unknown>).polluted).toBeUndefined()
|
||||
})
|
||||
|
||||
describe('providerGroup', () => {
|
||||
it('maps a provider env var to its labeled group', () => {
|
||||
expect(providerGroup('XAI_API_KEY')).toBe('xAI')
|
||||
expect(providerGroup('NOUS_API_KEY')).toBe('Nous Portal')
|
||||
expect(providerGroup('OPENROUTER_API_KEY')).toBe('OpenRouter')
|
||||
})
|
||||
|
||||
it('prefers the longest matching prefix so CN/regional buckets win', () => {
|
||||
// MINIMAX_CN_ must beat the generic MINIMAX_ prefix.
|
||||
expect(providerGroup('MINIMAX_CN_API_KEY')).toBe('MiniMax (China)')
|
||||
expect(providerGroup('MINIMAX_API_KEY')).toBe('MiniMax')
|
||||
// KIMI_CN_ likewise must beat KIMI_.
|
||||
expect(providerGroup('KIMI_CN_API_KEY')).toBe('Kimi (China)')
|
||||
expect(providerGroup('KIMI_API_KEY')).toBe('Kimi / Moonshot')
|
||||
// HERMES_QWEN_ and HERMES_GEMINI_ both share the HERMES_ stem.
|
||||
expect(providerGroup('HERMES_QWEN_BASE_URL')).toBe('DashScope (Qwen)')
|
||||
expect(providerGroup('HERMES_GEMINI_CLIENT_ID')).toBe('Gemini')
|
||||
})
|
||||
|
||||
it('falls back to "Other" for un-grouped env vars', () => {
|
||||
expect(providerGroup('SOMETHING_RANDOM')).toBe('Other')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -19,9 +19,30 @@ export const withoutKey = <T>(record: Record<string, T>, key: string) => {
|
|||
|
||||
export const redactedValue = (v: string) => (v.length <= 8 ? '••••' : `${v.slice(0, 4)}...${v.slice(-4)}`)
|
||||
|
||||
export const providerGroup = (key: string) => PROVIDER_GROUPS.find(g => key.startsWith(g.prefix))?.name ?? 'Other'
|
||||
// Longest-prefix match so a more specific group like ``MINIMAX_CN_`` is
|
||||
// chosen over its shorter parent ``MINIMAX_``. Falls back to the bucket
|
||||
// "Other" used by the Keys settings view for un-grouped env vars.
|
||||
export const providerGroup = (key: string) => {
|
||||
let best: (typeof PROVIDER_GROUPS)[number] | undefined
|
||||
|
||||
export const providerPriority = (name: string) => PROVIDER_GROUPS.find(g => g.name === name)?.priority ?? 99
|
||||
for (const candidate of PROVIDER_GROUPS) {
|
||||
if (!key.startsWith(candidate.prefix)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!best || candidate.prefix.length > best.prefix.length) {
|
||||
best = candidate
|
||||
}
|
||||
}
|
||||
|
||||
return best?.name ?? 'Other'
|
||||
}
|
||||
|
||||
export const providerMeta = (name: string) =>
|
||||
PROVIDER_GROUPS.find(g => g.name === name && (g.description || g.docsUrl)) ??
|
||||
PROVIDER_GROUPS.find(g => g.name === name)
|
||||
|
||||
export const providerPriority = (name: string) => providerMeta(name)?.priority ?? 99
|
||||
|
||||
const POLLUTING_PATH_PARTS = new Set(['__proto__', 'constructor', 'prototype'])
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { useRef } from 'react'
|
|||
|
||||
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Archive, Globe, Info, KeyRound, Wrench } from '@/lib/icons'
|
||||
import { Archive, Globe, Info, KeyRound, Sparkles, Wrench, Zap } from '@/lib/icons'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
|
||||
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
|
||||
|
|
@ -18,11 +18,13 @@ import { SECTIONS } from './constants'
|
|||
import { GatewaySettings } from './gateway-settings'
|
||||
import { KeysSettings } from './keys-settings'
|
||||
import { McpSettings } from './mcp-settings'
|
||||
import { PROVIDER_VIEWS, ProvidersSettings, type ProviderView } from './providers-settings'
|
||||
import { SessionsSettings } from './sessions-settings'
|
||||
import type { SettingsPageProps, SettingsView as SettingsViewId } from './types'
|
||||
|
||||
const SETTINGS_VIEWS: readonly SettingsViewId[] = [
|
||||
...SECTIONS.map(s => `config:${s.id}` as SettingsViewId),
|
||||
'providers',
|
||||
'gateway',
|
||||
'keys',
|
||||
'mcp',
|
||||
|
|
@ -32,6 +34,14 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
|
|||
|
||||
export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChanged }: SettingsPageProps) {
|
||||
const [activeView, setActiveView] = useRouteEnumParam('tab', SETTINGS_VIEWS, 'config:model' as SettingsViewId)
|
||||
// Providers subnav (Accounts vs API keys) lives in its own param so each
|
||||
// sub-view is deep-linkable and survives a refresh.
|
||||
const [providerView, setProviderView] = useRouteEnumParam<ProviderView>('pview', PROVIDER_VIEWS, 'accounts')
|
||||
|
||||
const openProviderView = (view: ProviderView) => {
|
||||
setActiveView('providers')
|
||||
setProviderView(view)
|
||||
}
|
||||
|
||||
const importInputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
|
|
@ -83,6 +93,30 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
|||
)
|
||||
})}
|
||||
<div className="my-2 h-px bg-border/30" />
|
||||
<OverlayNavItem
|
||||
active={activeView === 'providers'}
|
||||
icon={Zap}
|
||||
label="Providers"
|
||||
onClick={() => setActiveView('providers')}
|
||||
/>
|
||||
{activeView === 'providers' && (
|
||||
<div className="ml-3.5 flex flex-col gap-0.5 pl-1.5">
|
||||
<OverlayNavItem
|
||||
active={providerView === 'accounts'}
|
||||
icon={Sparkles}
|
||||
label="Accounts"
|
||||
nested
|
||||
onClick={() => openProviderView('accounts')}
|
||||
/>
|
||||
<OverlayNavItem
|
||||
active={providerView === 'keys'}
|
||||
icon={KeyRound}
|
||||
label="API keys"
|
||||
nested
|
||||
onClick={() => openProviderView('keys')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<OverlayNavItem
|
||||
active={activeView === 'gateway'}
|
||||
icon={Globe}
|
||||
|
|
@ -92,7 +126,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
|||
<OverlayNavItem
|
||||
active={activeView === 'keys'}
|
||||
icon={KeyRound}
|
||||
label="API Keys"
|
||||
label="Tools & Keys"
|
||||
onClick={() => setActiveView('keys')}
|
||||
/>
|
||||
<OverlayNavItem
|
||||
|
|
@ -154,6 +188,8 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
|||
onConfigSaved={onConfigSaved}
|
||||
onMainModelChanged={onMainModelChanged}
|
||||
/>
|
||||
) : activeView === 'providers' ? (
|
||||
<ProvidersSettings onViewChange={setProviderView} view={providerView} />
|
||||
) : activeView === 'keys' ? (
|
||||
<KeysSettings />
|
||||
) : activeView === 'mcp' ? (
|
||||
|
|
|
|||
|
|
@ -1,425 +1,162 @@
|
|||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { deleteEnvVar, getEnvVars, revealEnvVar, setEnvVar } from '@/hermes'
|
||||
import { Check, Eye, EyeOff, Save, Settings2, Trash2, Zap } from '@/lib/icons'
|
||||
import { Settings2, Wrench } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import type { EnvVarInfo } from '@/types/hermes'
|
||||
|
||||
import { CONTROL_TEXT } from './constants'
|
||||
import { asText, prettyName, providerGroup, providerPriority, redactedValue, withoutKey } from './helpers'
|
||||
import { LoadingState, Pill, SectionHeading, SettingsContent } from './primitives'
|
||||
import type { EnvPatch, EnvRowProps, ProviderGroup } from './types'
|
||||
import { useDeepLinkHighlight } from './use-deep-link-highlight'
|
||||
import { EnvVarRow, useEnvCredentials } from './env-credentials'
|
||||
import { asText } from './helpers'
|
||||
import { LoadingState, SettingsContent } from './primitives'
|
||||
|
||||
interface EnvActionsProps {
|
||||
varKey: string
|
||||
info: EnvVarInfo
|
||||
saving: string | null
|
||||
onEdit: () => void
|
||||
onClear: (key: string) => void
|
||||
onReveal: (key: string) => void
|
||||
isRevealed: boolean
|
||||
showReveal?: boolean
|
||||
// Providers live on their own page; messaging-platform credentials live on the
|
||||
// dedicated Messaging page (and are hidden here via `channel_managed`). This
|
||||
// view covers tool API keys plus server/setting env vars (API server, webhook,
|
||||
// gateway), which fold into the Settings tab.
|
||||
const KEY_TABS = [
|
||||
{ icon: Wrench, id: 'tool', label: 'Tools' },
|
||||
{ icon: Settings2, id: 'setting', label: 'Settings' }
|
||||
] as const
|
||||
|
||||
type KeyCategoryId = (typeof KEY_TABS)[number]['id']
|
||||
|
||||
const CATEGORY_LABELS: Record<KeyCategoryId, string> = {
|
||||
setting: 'Settings',
|
||||
tool: 'Tools'
|
||||
}
|
||||
|
||||
function EnvActions({
|
||||
varKey,
|
||||
info,
|
||||
saving,
|
||||
onEdit,
|
||||
onClear,
|
||||
onReveal,
|
||||
isRevealed,
|
||||
showReveal = true
|
||||
}: EnvActionsProps) {
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{info.url && (
|
||||
<Button asChild size="xs" title="Open provider docs" variant="ghost">
|
||||
<a href={info.url} rel="noreferrer" target="_blank">
|
||||
Docs
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
{info.is_set && showReveal && (
|
||||
<Button
|
||||
onClick={() => onReveal(varKey)}
|
||||
size="icon-xs"
|
||||
title={isRevealed ? 'Hide value' : 'Reveal value'}
|
||||
variant="ghost"
|
||||
>
|
||||
{isRevealed ? <EyeOff /> : <Eye />}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={onEdit} size="xs" variant="textStrong">
|
||||
{info.is_set ? 'Replace' : 'Set'}
|
||||
</Button>
|
||||
{info.is_set && (
|
||||
<Button
|
||||
disabled={saving === varKey}
|
||||
onClick={() => onClear(varKey)}
|
||||
size="icon-xs"
|
||||
title="Clear value"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
// Backend categories that surface under each tab. Server/gateway vars carry the
|
||||
// `messaging` category server-side but belong with general settings here, since
|
||||
// the platform-credential half of `messaging` is owned by the Messaging page.
|
||||
const TAB_CATEGORIES: Record<KeyCategoryId, readonly string[]> = {
|
||||
setting: ['setting', 'messaging'],
|
||||
tool: ['tool']
|
||||
}
|
||||
|
||||
function EnvVarRow({
|
||||
varKey,
|
||||
info,
|
||||
edits,
|
||||
revealed,
|
||||
saving,
|
||||
setEdits,
|
||||
onSave,
|
||||
onClear,
|
||||
onReveal,
|
||||
compact = false
|
||||
}: EnvRowProps) {
|
||||
const isEditing = edits[varKey] !== undefined
|
||||
const isRevealed = revealed[varKey] !== undefined
|
||||
const value = isRevealed ? revealed[varKey] : info.redacted_value
|
||||
const startEdit = () => setEdits(c => ({ ...c, [varKey]: '' }))
|
||||
|
||||
if (compact && !isEditing) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 py-1.5">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-mono text-[0.72rem] text-muted-foreground">{varKey}</div>
|
||||
<div className="truncate text-[0.68rem] text-muted-foreground/70">{info.description}</div>
|
||||
</div>
|
||||
<EnvActions
|
||||
info={info}
|
||||
isRevealed={isRevealed}
|
||||
onClear={onClear}
|
||||
onEdit={startEdit}
|
||||
onReveal={onReveal}
|
||||
saving={saving}
|
||||
showReveal={false}
|
||||
varKey={varKey}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
function tabForCategory(category: string): KeyCategoryId | null {
|
||||
for (const tab of KEY_TABS) {
|
||||
if (TAB_CATEGORIES[tab.id].includes(category)) {
|
||||
return tab.id
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-2 rounded-xl bg-background/55 p-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-mono text-xs font-medium">{varKey}</span>
|
||||
<Pill tone={info.is_set ? 'primary' : 'muted'}>
|
||||
{info.is_set && <Check className="size-3" />}
|
||||
{info.is_set ? 'Set' : 'Not set'}
|
||||
</Pill>
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">{info.description}</p>
|
||||
</div>
|
||||
<EnvActions
|
||||
info={info}
|
||||
isRevealed={isRevealed}
|
||||
onClear={onClear}
|
||||
onEdit={startEdit}
|
||||
onReveal={onReveal}
|
||||
saving={saving}
|
||||
varKey={varKey}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isEditing && info.is_set && (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-md px-3 py-2 font-mono text-xs',
|
||||
isRevealed ? 'bg-background text-foreground' : 'bg-muted/30 text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{value || '---'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEditing && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
autoFocus
|
||||
className={cn('min-w-56 flex-1 font-mono', CONTROL_TEXT)}
|
||||
onChange={e => setEdits(c => ({ ...c, [varKey]: e.target.value }))}
|
||||
placeholder={info.is_set ? 'Replace current value' : 'Enter value'}
|
||||
type={info.is_password ? 'password' : 'text'}
|
||||
value={edits[varKey]}
|
||||
/>
|
||||
<Button disabled={saving === varKey || !edits[varKey]} onClick={() => onSave(varKey)} size="sm">
|
||||
<Save />
|
||||
{saving === varKey ? 'Saving' : 'Save'}
|
||||
</Button>
|
||||
<Button onClick={() => setEdits(c => withoutKey(c, varKey))} size="sm" variant="text">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
function EnvProviderGroup({
|
||||
group,
|
||||
rowProps,
|
||||
forceExpand = false
|
||||
function CategoryTabs({
|
||||
active,
|
||||
counts,
|
||||
onSelect
|
||||
}: {
|
||||
group: ProviderGroup
|
||||
rowProps: Omit<EnvRowProps, 'varKey' | 'info'>
|
||||
forceExpand?: boolean
|
||||
active: KeyCategoryId
|
||||
counts: Record<KeyCategoryId, number>
|
||||
onSelect: (id: KeyCategoryId) => void
|
||||
}) {
|
||||
const setCount = group.entries.filter(([, info]) => info.is_set).length
|
||||
// Default-expand providers that already have at least one key set; the
|
||||
// user is much more likely to be coming back to edit those than to start
|
||||
// configuring a fresh provider from scratch.
|
||||
const [expanded, setExpanded] = useState(setCount > 0 || forceExpand)
|
||||
|
||||
useEffect(() => {
|
||||
if (forceExpand) {
|
||||
setExpanded(true)
|
||||
}
|
||||
}, [forceExpand])
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl bg-background/60">
|
||||
<button
|
||||
className="flex w-full items-center justify-between gap-3 bg-transparent px-3 py-2.5 text-left hover:bg-accent/50"
|
||||
onClick={() => setExpanded(e => !e)}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<Zap className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate text-sm font-medium">
|
||||
{group.name === 'Other' ? 'Other providers' : group.name}
|
||||
</span>
|
||||
{setCount > 0 && <Pill tone="primary">{setCount} set</Pill>}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{group.entries.length} keys</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="grid gap-2 bg-muted/20 p-3">
|
||||
{group.entries.map(([key, info]) => (
|
||||
<div className="scroll-mt-6 rounded-md" id={`env-var-${key}`} key={key}>
|
||||
<EnvVarRow compact={!info.is_set} info={info} varKey={key} {...rowProps} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-4 inline-flex w-full gap-1 rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-tertiary)/30 p-1">
|
||||
{KEY_TABS.map(tab => {
|
||||
const isActive = active === tab.id
|
||||
const count = counts[tab.id]
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'flex flex-1 items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-[length:var(--conversation-text-font-size)] font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-(--ui-chat-surface-background) text-foreground shadow-sm'
|
||||
: 'text-(--ui-text-secondary) hover:text-foreground'
|
||||
)}
|
||||
key={tab.id}
|
||||
onClick={() => onSelect(tab.id)}
|
||||
type="button"
|
||||
>
|
||||
<tab.icon className="size-3.5 shrink-0" />
|
||||
<span className="truncate">{tab.label}</span>
|
||||
{count > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-full px-1.5 text-[0.6875rem] tabular-nums',
|
||||
isActive ? 'bg-primary/12 text-primary' : 'bg-(--ui-bg-tertiary)/60 text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function KeysSettings() {
|
||||
const [vars, setVars] = useState<Record<string, EnvVarInfo> | null>(null)
|
||||
const [edits, setEdits] = useState<Record<string, string>>({})
|
||||
const [revealed, setRevealed] = useState<Record<string, string>>({})
|
||||
const [saving, setSaving] = useState<string | null>(null)
|
||||
const { rowProps, vars } = useEnvCredentials()
|
||||
const [activeCategory, setActiveCategory] = useState<KeyCategoryId>('tool')
|
||||
|
||||
// Deep-link from the command palette (?key=<ENV_VAR>): force-expand the
|
||||
// matching provider group, scroll the row in, and flash it.
|
||||
const highlightKey = useDeepLinkHighlight({
|
||||
elementId: key => `env-var-${key}`,
|
||||
param: 'key',
|
||||
ready: key => Boolean(vars?.[key])
|
||||
})
|
||||
|
||||
// We used to hide ~80% of rows behind a global "Show advanced" toggle, but
|
||||
// everything in this view is configuration-level — "advanced" was a poor
|
||||
// distinction. The full list is rendered now and provider groups
|
||||
// default-collapsed-unless-set keep the surface manageable.
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.removeItem('desktop.settings.keys.show_advanced')
|
||||
} catch {
|
||||
// Ignore — old key cleanup is best-effort.
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const next = await getEnvVars()
|
||||
|
||||
if (!cancelled) {
|
||||
setVars(next)
|
||||
}
|
||||
} catch (err) {
|
||||
notifyError(err, 'API keys failed to load')
|
||||
}
|
||||
})()
|
||||
|
||||
return () => void (cancelled = true)
|
||||
}, [])
|
||||
|
||||
const providerGroups = useMemo<ProviderGroup[]>(() => {
|
||||
const groups = useMemo(() => {
|
||||
if (!vars) {
|
||||
return []
|
||||
}
|
||||
|
||||
const entries = Object.entries(vars).filter(([, info]) => asText(info.category) === 'provider')
|
||||
return KEY_TABS.map(t => t.id).flatMap(tab => {
|
||||
const cats = TAB_CATEGORIES[tab]
|
||||
|
||||
const groups = new Map<string, [string, EnvVarInfo][]>()
|
||||
|
||||
for (const entry of entries) {
|
||||
const name = providerGroup(entry[0])
|
||||
groups.set(name, [...(groups.get(name) ?? []), entry])
|
||||
}
|
||||
|
||||
return Array.from(groups, ([name, entries]) => ({
|
||||
name,
|
||||
priority: providerPriority(name),
|
||||
entries: entries.sort(([a], [b]) => a.localeCompare(b)),
|
||||
hasAnySet: entries.some(([, info]) => info.is_set)
|
||||
})).sort((a, b) => a.priority - b.priority || a.name.localeCompare(b.name))
|
||||
}, [vars])
|
||||
|
||||
const otherGroups = useMemo(() => {
|
||||
if (!vars) {
|
||||
return []
|
||||
}
|
||||
|
||||
const labels: Record<string, string> = {
|
||||
tool: 'Tools',
|
||||
messaging: 'Messaging',
|
||||
setting: 'Settings'
|
||||
}
|
||||
|
||||
return ['tool', 'messaging', 'setting'].flatMap(cat => {
|
||||
const entries = Object.entries(vars)
|
||||
.filter(([, info]) => asText(info.category) === cat)
|
||||
.filter(([, info]) => !info.channel_managed && cats.includes(asText(info.category)))
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
|
||||
return entries.length === 0 ? [] : [{ category: cat, label: labels[cat] ?? prettyName(cat), entries }]
|
||||
return entries.length === 0 ? [] : [{ category: tab, label: CATEGORY_LABELS[tab], entries }]
|
||||
})
|
||||
}, [vars])
|
||||
|
||||
function patchVar(key: string, patch: EnvPatch) {
|
||||
setVars(c => (c ? { ...c, [key]: { ...c[key], ...patch } } : c))
|
||||
}
|
||||
// Tab badge counts reflect how many keys are set per tab. Channel-managed
|
||||
// credentials are owned by the Messaging page and excluded here.
|
||||
const categoryCounts = useMemo<Record<KeyCategoryId, number>>(() => {
|
||||
const counts: Record<KeyCategoryId, number> = { setting: 0, tool: 0 }
|
||||
|
||||
function clearLocalState(key: string) {
|
||||
setEdits(c => withoutKey(c, key))
|
||||
setRevealed(c => withoutKey(c, key))
|
||||
}
|
||||
|
||||
async function handleSave(key: string) {
|
||||
const value = edits[key]
|
||||
|
||||
if (!value) {
|
||||
return
|
||||
if (!vars) {
|
||||
return counts
|
||||
}
|
||||
|
||||
setSaving(key)
|
||||
for (const info of Object.values(vars)) {
|
||||
if (!info.is_set || info.channel_managed) {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
await setEnvVar(key, value)
|
||||
patchVar(key, { is_set: true, redacted_value: redactedValue(value) })
|
||||
clearLocalState(key)
|
||||
notify({ kind: 'success', title: 'Credential saved', message: `${key} updated.` })
|
||||
} catch (err) {
|
||||
notifyError(err, `Failed to save ${key}`)
|
||||
} finally {
|
||||
setSaving(null)
|
||||
}
|
||||
}
|
||||
const tab = tabForCategory(asText(info.category))
|
||||
|
||||
async function handleClear(key: string) {
|
||||
if (!window.confirm(`Remove ${key} from .env?`)) {
|
||||
return
|
||||
if (tab) {
|
||||
counts[tab] += 1
|
||||
}
|
||||
}
|
||||
|
||||
setSaving(key)
|
||||
|
||||
try {
|
||||
await deleteEnvVar(key)
|
||||
patchVar(key, { is_set: false, redacted_value: null })
|
||||
clearLocalState(key)
|
||||
notify({ kind: 'success', title: 'Credential removed', message: `${key} removed.` })
|
||||
} catch (err) {
|
||||
notifyError(err, `Failed to remove ${key}`)
|
||||
} finally {
|
||||
setSaving(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReveal(key: string) {
|
||||
if (revealed[key]) {
|
||||
setRevealed(c => withoutKey(c, key))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await revealEnvVar(key)
|
||||
setRevealed(c => ({ ...c, [key]: result.value }))
|
||||
} catch (err) {
|
||||
notifyError(err, `Failed to reveal ${key}`)
|
||||
}
|
||||
}
|
||||
return counts
|
||||
}, [vars])
|
||||
|
||||
if (!vars) {
|
||||
return <LoadingState label="Loading API keys and credentials..." />
|
||||
}
|
||||
|
||||
const rowProps = {
|
||||
edits,
|
||||
revealed,
|
||||
saving,
|
||||
setEdits,
|
||||
onSave: handleSave,
|
||||
onClear: handleClear,
|
||||
onReveal: handleReveal
|
||||
}
|
||||
|
||||
const configuredCount = providerGroups.filter(g => g.hasAnySet).length
|
||||
const visible = groups.filter(g => g.category === activeCategory)
|
||||
|
||||
return (
|
||||
<SettingsContent>
|
||||
<div className="mb-6">
|
||||
<SectionHeading
|
||||
icon={Zap}
|
||||
meta={`${configuredCount} of ${providerGroups.length} configured`}
|
||||
title="LLM providers"
|
||||
/>
|
||||
<div className="grid gap-2">
|
||||
{providerGroups.map(group => (
|
||||
<EnvProviderGroup
|
||||
forceExpand={Boolean(highlightKey) && group.entries.some(([key]) => key === highlightKey)}
|
||||
group={group}
|
||||
key={group.name}
|
||||
rowProps={rowProps}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<CategoryTabs active={activeCategory} counts={categoryCounts} onSelect={setActiveCategory} />
|
||||
|
||||
{otherGroups.map(group => (
|
||||
<div className="mb-6" key={group.category}>
|
||||
<SectionHeading
|
||||
icon={Settings2}
|
||||
meta={`${group.entries.filter(([, i]) => i.is_set).length} of ${group.entries.length} set`}
|
||||
title={group.label}
|
||||
/>
|
||||
{visible.map(group => (
|
||||
<section className="mb-6" key={group.category}>
|
||||
<div className="grid gap-2">
|
||||
{group.entries.map(([key, info]) => (
|
||||
<div className="scroll-mt-6 rounded-md" id={`env-var-${key}`} key={key}>
|
||||
<EnvVarRow info={info} varKey={key} {...rowProps} />
|
||||
</div>
|
||||
{group.entries.map(([key, info]: [string, EnvVarInfo]) => (
|
||||
<EnvVarRow info={info} key={key} varKey={key} {...rowProps} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
{visible.length === 0 && (
|
||||
<div className="rounded-lg border border-dashed border-(--ui-stroke-tertiary) px-4 py-8 text-center text-[length:var(--conversation-caption-font-size)] text-muted-foreground">
|
||||
Nothing configured in this category yet.
|
||||
</div>
|
||||
)}
|
||||
</SettingsContent>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
489
apps/desktop/src/app/settings/providers-settings.tsx
Normal file
489
apps/desktop/src/app/settings/providers-settings.tsx
Normal file
|
|
@ -0,0 +1,489 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { type ChangeEvent, type KeyboardEvent, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import {
|
||||
FEATURED_ID,
|
||||
FeaturedProviderRow,
|
||||
KeyProviderRow,
|
||||
ProviderRow,
|
||||
sortProviders
|
||||
} from '@/components/desktop-onboarding-overlay'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { listOAuthProviders } from '@/hermes'
|
||||
import { ChevronDown, ExternalLink, KeyRound, Loader2, Save } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $desktopOnboarding, startManualProviderOAuth } from '@/store/onboarding'
|
||||
import type { EnvVarInfo, OAuthProvider } from '@/types/hermes'
|
||||
|
||||
import { SettingsCategoryHeading, useEnvCredentials } from './env-credentials'
|
||||
import { providerGroup, providerMeta, providerPriority, withoutKey } from './helpers'
|
||||
import { LoadingState, SettingsContent } from './primitives'
|
||||
import type { EnvRowProps } from './types'
|
||||
|
||||
// Sub-views surfaced as a sidebar subnav: account sign-in vs raw API keys.
|
||||
export const PROVIDER_VIEWS = ['accounts', 'keys'] as const
|
||||
|
||||
export type ProviderView = (typeof PROVIDER_VIEWS)[number]
|
||||
|
||||
const isKeyVar = (key: string, info: EnvVarInfo) => info.is_password || /(?:_API_KEY|_TOKEN|_KEY)$/.test(key)
|
||||
|
||||
const friendlyFieldLabel = (key: string, info: EnvVarInfo) =>
|
||||
info.description?.trim() || key.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, c => c.toUpperCase())
|
||||
|
||||
// Advanced (non-primary) fields are mostly base-URL / endpoint overrides, not
|
||||
// keys — so don't reuse the "Paste key" placeholder that makes them read as a
|
||||
// duplicate key input. URL-ish vars get a URL hint; everything else stays optional.
|
||||
const advancedPlaceholder = (key: string, info: EnvVarInfo): string =>
|
||||
isKeyVar(key, info) ? 'Paste key' : /URL$/i.test(key) ? 'https://…' : 'Optional'
|
||||
|
||||
// Group the env catalog by provider so the keys view can render one collapsible
|
||||
// row per vendor: a primary key field inline, with any secondary / advanced vars
|
||||
// (base URL overrides, alt tokens) revealed when the row is focused/expanded.
|
||||
// Mirrors what Cursor's API-keys section does. Groups without a key field (e.g.
|
||||
// Nous Portal's lone base-URL override) and the "Other" bucket are skipped.
|
||||
function buildProviderKeyGroups(vars: Record<string, EnvVarInfo>): ProviderKeyGroup[] {
|
||||
const buckets = new Map<string, [string, EnvVarInfo][]>()
|
||||
|
||||
for (const [key, info] of Object.entries(vars)) {
|
||||
if (info.category !== 'provider') {
|
||||
continue
|
||||
}
|
||||
|
||||
const name = providerGroup(key)
|
||||
|
||||
if (name === 'Other') {
|
||||
continue
|
||||
}
|
||||
|
||||
buckets.set(name, [...(buckets.get(name) ?? []), [key, info]])
|
||||
}
|
||||
|
||||
const groups: ProviderKeyGroup[] = []
|
||||
|
||||
for (const [name, entries] of buckets) {
|
||||
const primary = entries.find(([k, i]) => !i.advanced && isKeyVar(k, i)) ?? entries.find(([k, i]) => isKeyVar(k, i))
|
||||
|
||||
if (!primary) {
|
||||
continue
|
||||
}
|
||||
|
||||
const meta = providerMeta(name)
|
||||
|
||||
groups.push({
|
||||
// Advanced = the provider's non-key knobs (base URL, region, deployment).
|
||||
// Skip redundant alias key vars (e.g. ANTHROPIC_TOKEN vs ANTHROPIC_API_KEY)
|
||||
// so we never render a second "Paste key" input — unless one is already
|
||||
// set, in which case keep it visible so it stays clearable.
|
||||
advanced: entries
|
||||
.filter(([k, i]) => k !== primary[0] && (!isKeyVar(k, i) || i.is_set))
|
||||
.sort(([a], [b]) => a.localeCompare(b)),
|
||||
description: meta?.description ?? primary[1].description,
|
||||
docsUrl: meta?.docsUrl ?? primary[1].url ?? undefined,
|
||||
hasAnySet: entries.some(([, i]) => i.is_set),
|
||||
name,
|
||||
primary,
|
||||
priority: providerPriority(name)
|
||||
})
|
||||
}
|
||||
|
||||
return groups.sort((a, b) => a.priority - b.priority || a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
// A single credential field: a set key shows as a filled read-only input
|
||||
// (redacted value) that edits in place on click. Save appears once typed; a set
|
||||
// key also offers Remove, and Esc cancels without closing the overlay.
|
||||
function KeyField({
|
||||
compact = false,
|
||||
info,
|
||||
label,
|
||||
placeholder,
|
||||
rowProps,
|
||||
varKey
|
||||
}: {
|
||||
compact?: boolean
|
||||
info: EnvVarInfo
|
||||
label?: string
|
||||
placeholder?: string
|
||||
rowProps: KeyRowProps
|
||||
varKey: string
|
||||
}) {
|
||||
const { edits, onClear, onSave, saving, setEdits } = rowProps
|
||||
const editing = edits[varKey] !== undefined
|
||||
const draft = edits[varKey] ?? ''
|
||||
const dirty = draft.trim().length > 0
|
||||
const busy = saving === varKey
|
||||
const masked = info.redacted_value ?? '••••••••'
|
||||
const startEdit = () => setEdits(c => ({ ...c, [varKey]: '' }))
|
||||
const cancel = () => setEdits(c => withoutKey(c, varKey))
|
||||
const update = (e: ChangeEvent<HTMLInputElement>) => setEdits(c => ({ ...c, [varKey]: e.target.value }))
|
||||
|
||||
// Enter saves; Esc cancels in place without bubbling to the overlay's window
|
||||
// Escape listener (which would otherwise close the whole settings panel).
|
||||
const keydown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' && dirty) {
|
||||
void onSave(varKey)
|
||||
} else if (e.key === 'Escape' && editing) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// Advanced overrides render quieter (xs) than the primary key field so the key
|
||||
// stays the visual anchor. Padding-driven sizing — no fixed heights.
|
||||
const inputSize = compact ? 'xs' : 'sm'
|
||||
const editType = info.is_password ? 'password' : 'text'
|
||||
|
||||
// A set value reads as a single filled, read-only field (showing the redacted
|
||||
// value). Clicking it drops into edit mode in place — no Replace/Cancel chrome.
|
||||
const control =
|
||||
info.is_set && !editing ? (
|
||||
<Input
|
||||
className="cursor-pointer font-mono text-muted-foreground"
|
||||
onFocus={startEdit}
|
||||
readOnly
|
||||
size={inputSize}
|
||||
value={masked}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
autoFocus={editing}
|
||||
className="min-w-0 flex-1 font-mono"
|
||||
onChange={update}
|
||||
onKeyDown={keydown}
|
||||
placeholder={placeholder ?? 'Paste key'}
|
||||
size={inputSize}
|
||||
type={editType}
|
||||
value={draft}
|
||||
/>
|
||||
{dirty && (
|
||||
<Button disabled={busy} onClick={() => void onSave(varKey)} size="sm">
|
||||
{busy ? <Loader2 className="size-4 animate-spin" /> : <Save />}
|
||||
{busy ? 'Saving' : 'Save'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{editing && (
|
||||
<div className="flex items-center gap-1 text-[0.6875rem]">
|
||||
{info.is_set && (
|
||||
<>
|
||||
<Button
|
||||
className="h-auto px-0 py-0 text-[0.6875rem] text-destructive hover:text-destructive"
|
||||
disabled={busy}
|
||||
onClick={() => void onClear(varKey)}
|
||||
type="button"
|
||||
variant="text"
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
<span className="text-muted-foreground">or</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-muted-foreground">esc to cancel</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
// Standard stacked form field: small muted label above, input below. Same shape
|
||||
// for the primary key and every advanced override — just smaller when compact.
|
||||
// Empty advanced inputs (not labels) fade back, brightening on hover/focus/set.
|
||||
const dim = compact && !info.is_set
|
||||
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
{label && (
|
||||
<label className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
{dim ? (
|
||||
<div className="opacity-55 transition-opacity focus-within:opacity-100 hover:opacity-100">{control}</div>
|
||||
) : (
|
||||
control
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProviderKeyCard({
|
||||
expanded,
|
||||
group,
|
||||
onExpand,
|
||||
onToggle,
|
||||
rowProps
|
||||
}: {
|
||||
expanded: boolean
|
||||
group: ProviderKeyGroup
|
||||
onExpand: () => void
|
||||
onToggle: () => void
|
||||
rowProps: KeyRowProps
|
||||
}) {
|
||||
// Expandable when there's anything to reveal — advanced overrides and/or a
|
||||
// "Get a key" docs link (which lives at the bottom of the expanded panel).
|
||||
const expandable = group.advanced.length > 0 || Boolean(group.docsUrl)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group/card rounded-[6px] px-2 py-2 transition-colors',
|
||||
expandable && 'cursor-pointer',
|
||||
expandable && !expanded && 'hover:bg-(--ui-row-hover-background)',
|
||||
expanded && 'bg-(--ui-bg-quaternary) ring-1 ring-(--ui-stroke-secondary)'
|
||||
)}
|
||||
onClick={expandable ? onToggle : undefined}
|
||||
onKeyDown={
|
||||
expandable
|
||||
? e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onToggle()
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
role={expandable ? 'button' : undefined}
|
||||
tabIndex={expandable ? 0 : undefined}
|
||||
>
|
||||
<div className="flex flex-wrap items-start gap-x-4 gap-y-2">
|
||||
<div className="flex min-w-44 flex-1 items-center gap-2 py-1">
|
||||
<span
|
||||
className={cn('size-2 shrink-0 rounded-full', group.hasAnySet ? 'bg-primary' : 'bg-(--ui-stroke-secondary)')}
|
||||
/>
|
||||
<span className="truncate text-[length:var(--conversation-text-font-size)] font-medium">{group.name}</span>
|
||||
{expandable && (
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'size-3.5 shrink-0 text-muted-foreground transition',
|
||||
expanded ? 'rotate-180 opacity-100' : 'opacity-0 group-hover/card:opacity-100'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="w-full sm:w-80 sm:shrink-0"
|
||||
onClick={e => e.stopPropagation()}
|
||||
onFocus={() => {
|
||||
if (expandable && !expanded) {
|
||||
onExpand()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<KeyField
|
||||
info={group.primary[1]}
|
||||
placeholder={`Paste ${group.name} key`}
|
||||
rowProps={rowProps}
|
||||
varKey={group.primary[0]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{expandable && expanded && (
|
||||
<div className="mt-3 grid gap-2.5 pl-4" onClick={e => e.stopPropagation()}>
|
||||
{group.advanced.map(([key, info]) => (
|
||||
<KeyField
|
||||
compact
|
||||
info={info}
|
||||
key={key}
|
||||
label={isKeyVar(key, info) ? key : friendlyFieldLabel(key, info)}
|
||||
placeholder={advancedPlaceholder(key, info)}
|
||||
rowProps={rowProps}
|
||||
varKey={key}
|
||||
/>
|
||||
))}
|
||||
{group.docsUrl && (
|
||||
<a
|
||||
className="inline-flex w-fit items-center gap-1 justify-self-end text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary) underline-offset-4 transition-colors hover:text-foreground hover:underline"
|
||||
href={group.docsUrl}
|
||||
onClick={e => e.stopPropagation()}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Get a key
|
||||
<ExternalLink className="size-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Deliberately a near-1:1 replica of the first-run onboarding picker
|
||||
// (`Picker` in desktop-onboarding-overlay): same recommended card, same
|
||||
// provider rows, same "Other providers" disclosure, same OpenRouter quick-key
|
||||
// row, and the same bottom-right "I have an API key" affordance. The leaf cards
|
||||
// are the exact shared components, so the two surfaces stay visually identical.
|
||||
// Selecting a provider hands off to the shared onboarding overlay, which runs
|
||||
// that provider's real sign-in flow; the key affordances open the API-key
|
||||
// catalog below.
|
||||
function OAuthPicker({ onWantApiKey, providers }: { onWantApiKey: () => void; providers: OAuthProvider[] }) {
|
||||
const [showAll, setShowAll] = useState(false)
|
||||
const ordered = useMemo(() => sortProviders(providers), [providers])
|
||||
|
||||
if (ordered.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const select = (p: OAuthProvider) => startManualProviderOAuth(p.id)
|
||||
|
||||
const featured = ordered.find(p => p.id === FEATURED_ID) ?? null
|
||||
const rest = featured ? ordered.filter(p => p.id !== FEATURED_ID) : ordered
|
||||
// Keep connected accounts grouped and always visible; only the unconnected
|
||||
// providers hide behind the disclosure, so the page leads with what's set up.
|
||||
const connected = rest.filter(p => p.status?.logged_in)
|
||||
const others = rest.filter(p => !p.status?.logged_in)
|
||||
const collapsible = others.length > 0
|
||||
const showOthers = !collapsible || showAll
|
||||
|
||||
return (
|
||||
<section className="mb-5 grid gap-2">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-x-3">
|
||||
<SettingsCategoryHeading icon={KeyRound} title="Connect an account" />
|
||||
<Button
|
||||
className="h-auto px-0 py-0 text-[length:var(--conversation-caption-font-size)]"
|
||||
onClick={onWantApiKey}
|
||||
type="button"
|
||||
variant="textStrong"
|
||||
>
|
||||
Have an API key instead?
|
||||
</Button>
|
||||
</div>
|
||||
<p className="-mt-2 mb-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
Sign in with a subscription — no API key to copy. Hermes runs the browser sign-in for you, right here in the
|
||||
app.
|
||||
</p>
|
||||
{featured && <FeaturedProviderRow onSelect={select} provider={featured} />}
|
||||
{connected.length > 0 && (
|
||||
<>
|
||||
<p className="mt-1 px-0.5 text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-tertiary)">
|
||||
Connected
|
||||
</p>
|
||||
{connected.map(p => (
|
||||
<ProviderRow key={p.id} onSelect={select} provider={p} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{showOthers && (
|
||||
<>
|
||||
{others.map(p => (
|
||||
<ProviderRow key={p.id} onSelect={select} provider={p} />
|
||||
))}
|
||||
<KeyProviderRow onClick={onWantApiKey} />
|
||||
</>
|
||||
)}
|
||||
{collapsible && (
|
||||
<Button
|
||||
className="h-auto px-0 py-1 text-[length:var(--conversation-caption-font-size)]"
|
||||
onClick={() => setShowAll(v => !v)}
|
||||
type="button"
|
||||
variant="text"
|
||||
>
|
||||
{showAll ? 'Collapse' : connected.length > 0 ? 'Connect another provider' : 'Other providers'}
|
||||
<ChevronDown className={cn('size-3.5 transition', showAll && 'rotate-180')} />
|
||||
</Button>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function NoProviderKeys() {
|
||||
return (
|
||||
<div className="grid min-h-32 place-items-center px-4 py-8 text-center text-[length:var(--conversation-caption-font-size)] text-muted-foreground">
|
||||
No provider API keys available.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps) {
|
||||
const { rowProps, vars } = useEnvCredentials()
|
||||
const [oauthProviders, setOauthProviders] = useState<OAuthProvider[]>([])
|
||||
// Single-open accordion for the per-provider "advanced options" panels.
|
||||
const [openProvider, setOpenProvider] = useState<null | string>(null)
|
||||
// The onboarding overlay owns the OAuth flow. Watch its `manual` flag so we
|
||||
// re-read connection state when the user finishes (or dismisses) a sign-in
|
||||
// they launched from this page — otherwise the cards keep their stale status.
|
||||
const onboardingActive = useStore($desktopOnboarding).manual
|
||||
|
||||
useEffect(() => {
|
||||
if (onboardingActive) {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
// OAuth providers are best-effort — a failure here just hides the panel.
|
||||
void (async () => {
|
||||
try {
|
||||
const { providers } = await listOAuthProviders()
|
||||
|
||||
if (!cancelled) {
|
||||
setOauthProviders(providers)
|
||||
}
|
||||
} catch {
|
||||
// Ignore — the OAuth panel just won't render.
|
||||
}
|
||||
})()
|
||||
|
||||
return () => void (cancelled = true)
|
||||
}, [onboardingActive])
|
||||
|
||||
if (!vars) {
|
||||
return <LoadingState label="Loading providers..." />
|
||||
}
|
||||
|
||||
const hasOauth = oauthProviders.length > 0
|
||||
// The sidebar subnav owns the Accounts/API-keys split now; with no OAuth
|
||||
// providers there's nothing for the "Accounts" view to show, so fall to keys.
|
||||
const showApiKeys = view === 'keys' || !hasOauth
|
||||
|
||||
const keyGroups = buildProviderKeyGroups(vars)
|
||||
|
||||
if (showApiKeys) {
|
||||
return (
|
||||
<SettingsContent>
|
||||
{keyGroups.length > 0 ? (
|
||||
<div className="grid gap-2">
|
||||
{keyGroups.map(group => (
|
||||
<ProviderKeyCard
|
||||
expanded={openProvider === group.name}
|
||||
group={group}
|
||||
key={group.name}
|
||||
onExpand={() => setOpenProvider(group.name)}
|
||||
onToggle={() => setOpenProvider(prev => (prev === group.name ? null : group.name))}
|
||||
rowProps={rowProps}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<NoProviderKeys />
|
||||
)}
|
||||
</SettingsContent>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsContent>
|
||||
<OAuthPicker onWantApiKey={() => onViewChange('keys')} providers={oauthProviders} />
|
||||
</SettingsContent>
|
||||
)
|
||||
}
|
||||
|
||||
type KeyRowProps = Omit<EnvRowProps, 'info' | 'varKey'>
|
||||
|
||||
interface ProviderKeyGroup {
|
||||
advanced: [string, EnvVarInfo][]
|
||||
description?: string
|
||||
docsUrl?: string
|
||||
hasAnySet: boolean
|
||||
name: string
|
||||
primary: [string, EnvVarInfo]
|
||||
priority: number
|
||||
}
|
||||
|
||||
interface ProvidersSettingsProps {
|
||||
onViewChange: (view: ProviderView) => void
|
||||
view: ProviderView
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@ import type { HermesGateway } from '@/hermes'
|
|||
import type { IconComponent } from '@/lib/icons'
|
||||
import type { EnvVarInfo } from '@/types/hermes'
|
||||
|
||||
export type SettingsView = 'about' | 'gateway' | 'keys' | 'mcp' | 'sessions' | `config:${string}`
|
||||
export type SettingsView = 'about' | 'gateway' | 'keys' | 'mcp' | 'providers' | 'sessions' | `config:${string}`
|
||||
export type EnvPatch = Partial<Pick<EnvVarInfo, 'is_set' | 'redacted_value'>>
|
||||
|
||||
export interface SettingsPageProps {
|
||||
|
|
|
|||
|
|
@ -24,12 +24,14 @@ import { $desktopBoot, type DesktopBootState } from '@/store/boot'
|
|||
import {
|
||||
$desktopOnboarding,
|
||||
cancelOnboardingFlow,
|
||||
clearPendingProviderOAuth,
|
||||
closeManualOnboarding,
|
||||
confirmOnboardingModel,
|
||||
copyDeviceCode,
|
||||
copyExternalCommand,
|
||||
type OnboardingContext,
|
||||
type OnboardingFlow,
|
||||
peekPendingProviderOAuth,
|
||||
recheckExternalSignin,
|
||||
refreshOnboarding,
|
||||
saveOnboardingApiKey,
|
||||
|
|
@ -47,7 +49,7 @@ interface DesktopOnboardingOverlayProps {
|
|||
requestGateway: OnboardingContext['requestGateway']
|
||||
}
|
||||
|
||||
interface ApiKeyOption {
|
||||
export interface ApiKeyOption {
|
||||
description: string
|
||||
docsUrl: string
|
||||
envKey: string
|
||||
|
|
@ -125,7 +127,7 @@ const FLOW_SUBTITLES: Record<OAuthProvider['flow'], string> = {
|
|||
const providerTitle = (p: OAuthProvider) => PROVIDER_DISPLAY[p.id]?.title ?? p.name
|
||||
const orderOf = (p: OAuthProvider) => PROVIDER_DISPLAY[p.id]?.order ?? 99
|
||||
|
||||
const sortProviders = (providers: OAuthProvider[]) =>
|
||||
export const sortProviders = (providers: OAuthProvider[]) =>
|
||||
[...providers].sort((a, b) => orderOf(a) - orderOf(b) || a.name.localeCompare(b.name))
|
||||
|
||||
export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway }: DesktopOnboardingOverlayProps) {
|
||||
|
|
@ -148,6 +150,36 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
|
|||
}
|
||||
}, [ctx, enabled, onboarding.requested])
|
||||
|
||||
// When the Providers settings page asked to connect a specific provider, the
|
||||
// store stashed its id. Once the provider list has loaded and we're back at
|
||||
// an idle picker, launch that exact OAuth flow so the user lands directly in
|
||||
// sign-in instead of the picker they just came from.
|
||||
useEffect(() => {
|
||||
if (!onboarding.manual || onboarding.providers === null || onboarding.flow.status !== 'idle') {
|
||||
return
|
||||
}
|
||||
|
||||
const pendingId = peekPendingProviderOAuth()
|
||||
|
||||
if (!pendingId) {
|
||||
return
|
||||
}
|
||||
|
||||
const provider = onboarding.providers.find(p => p.id === pendingId)
|
||||
|
||||
if (provider) {
|
||||
// Only clear once we've committed to launching it, so a failed/empty
|
||||
// provider fetch doesn't silently drop the hand-off.
|
||||
clearPendingProviderOAuth()
|
||||
void startProviderOAuth(provider, ctx)
|
||||
} else if (onboarding.providers.length > 0) {
|
||||
// The list loaded but the id isn't a real provider — drop the stale
|
||||
// hand-off. An empty list means the fetch isn't ready yet, so keep it
|
||||
// and let a later refresh retry.
|
||||
clearPendingProviderOAuth()
|
||||
}
|
||||
}, [ctx, onboarding.flow.status, onboarding.manual, onboarding.providers])
|
||||
|
||||
// Mount from frame 1 so we replace the boot overlay seamlessly. The
|
||||
// configured field stays null until the runtime check resolves; only then
|
||||
// do we know whether to dismiss (true) or surface the picker (false).
|
||||
|
|
@ -190,9 +222,12 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
|
|||
)
|
||||
}
|
||||
|
||||
// The launch reason is a prompt ("why am I seeing this"), not an error — real
|
||||
// provider-setup failures are filtered out upstream and surfaced by FlowPanel.
|
||||
// Keep it neutral so it never reads as a failure.
|
||||
function ReasonNotice({ reason }: { reason: string }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
<div className="rounded-2xl border border-(--ui-stroke-tertiary) bg-(--ui-bg-tertiary)/40 px-4 py-3 text-sm text-muted-foreground">
|
||||
{reason}
|
||||
</div>
|
||||
)
|
||||
|
|
@ -246,7 +281,7 @@ function Header() {
|
|||
)
|
||||
}
|
||||
|
||||
const FEATURED_ID = 'nous'
|
||||
export const FEATURED_ID = 'nous'
|
||||
const FEATURED_PITCH = 'One subscription, 300+ frontier models — the recommended way to run Hermes'
|
||||
const SHOW_ALL_KEY = 'hermes-onboarding-show-all-v1'
|
||||
|
||||
|
|
@ -275,7 +310,13 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) {
|
|||
const hasOauth = ordered.length > 0
|
||||
|
||||
if (mode === 'apikey' || !hasOauth) {
|
||||
return <ApiKeyForm canGoBack={hasOauth} ctx={ctx} />
|
||||
return (
|
||||
<ApiKeyForm
|
||||
canGoBack={hasOauth}
|
||||
onBack={() => setOnboardingMode('oauth')}
|
||||
onSave={(envKey, value, name) => saveOnboardingApiKey(envKey, value, name, ctx)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (providers === null) {
|
||||
|
|
@ -324,7 +365,7 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) {
|
|||
)
|
||||
}
|
||||
|
||||
function FeaturedProviderRow({
|
||||
export function FeaturedProviderRow({
|
||||
onSelect,
|
||||
provider
|
||||
}: {
|
||||
|
|
@ -335,17 +376,17 @@ function FeaturedProviderRow({
|
|||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'group flex w-full items-center justify-between gap-4 rounded-2xl border-2 border-primary/50 bg-primary/5 p-4 text-left transition hover:border-primary hover:bg-primary/10',
|
||||
loggedIn && 'border-primary'
|
||||
)}
|
||||
className="group relative flex w-full items-center justify-between gap-4 rounded-[8px] bg-primary/[0.06] px-3 py-2.5 text-left transition-colors hover:bg-primary/10"
|
||||
onClick={() => onSelect(provider)}
|
||||
type="button"
|
||||
>
|
||||
<span aria-hidden className="arc-border arc-reverse arc-nous" />
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<img alt="" className="size-5 shrink-0 rounded" src={assetPath('apple-touch-icon.png')} />
|
||||
<span className="text-base font-semibold">{providerTitle(provider)}</span>
|
||||
<span className="text-[length:var(--conversation-text-font-size)] font-semibold">
|
||||
{providerTitle(provider)}
|
||||
</span>
|
||||
{loggedIn ? (
|
||||
<ConnectedTag />
|
||||
) : (
|
||||
|
|
@ -357,7 +398,7 @@ function FeaturedProviderRow({
|
|||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">{FEATURED_PITCH}</p>
|
||||
</div>
|
||||
<ChevronRight className="size-5 shrink-0 text-primary transition group-hover:translate-x-0.5" />
|
||||
<ChevronRight className="size-4 shrink-0 text-primary transition group-hover:translate-x-0.5" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
@ -371,15 +412,15 @@ function ConnectedTag() {
|
|||
)
|
||||
}
|
||||
|
||||
function KeyProviderRow({ onClick }: { onClick: () => void }) {
|
||||
export function KeyProviderRow({ onClick }: { onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
className="group flex w-full items-center justify-between gap-3 rounded-2xl border border-border bg-background/60 p-3 text-left transition hover:border-primary/40 hover:bg-accent/40"
|
||||
className="group flex w-full items-center justify-between gap-3 rounded-[6px] px-3 py-2.5 text-left transition-colors hover:bg-(--ui-control-hover-background)"
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm font-semibold">OpenRouter</span>
|
||||
<span className="text-[length:var(--conversation-text-font-size)] font-semibold">OpenRouter</span>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">One key, hundreds of models — a solid default</p>
|
||||
</div>
|
||||
<ChevronRight className="size-4 text-muted-foreground transition group-hover:text-foreground" />
|
||||
|
|
@ -387,22 +428,27 @@ function KeyProviderRow({ onClick }: { onClick: () => void }) {
|
|||
)
|
||||
}
|
||||
|
||||
function ProviderRow({ onSelect, provider }: { onSelect: (provider: OAuthProvider) => void; provider: OAuthProvider }) {
|
||||
export function ProviderRow({
|
||||
onSelect,
|
||||
provider
|
||||
}: {
|
||||
onSelect: (provider: OAuthProvider) => void
|
||||
provider: OAuthProvider
|
||||
}) {
|
||||
const loggedIn = provider.status?.logged_in
|
||||
const Trail = provider.flow === 'external' ? Terminal : ChevronRight
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'group flex w-full items-center justify-between gap-3 rounded-2xl border border-border bg-background/60 p-3 text-left transition hover:border-primary/40 hover:bg-accent/40',
|
||||
loggedIn && 'border-primary/30'
|
||||
)}
|
||||
className="group flex w-full items-center justify-between gap-3 rounded-[6px] px-3 py-2.5 text-left transition-colors hover:bg-(--ui-control-hover-background)"
|
||||
onClick={() => onSelect(provider)}
|
||||
type="button"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold">{providerTitle(provider)}</span>
|
||||
<span className="text-[length:var(--conversation-text-font-size)] font-semibold">
|
||||
{providerTitle(provider)}
|
||||
</span>
|
||||
{loggedIn ? <ConnectedTag /> : null}
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">{FLOW_SUBTITLES[provider.flow]}</p>
|
||||
|
|
@ -412,13 +458,62 @@ function ProviderRow({ onSelect, provider }: { onSelect: (provider: OAuthProvide
|
|||
)
|
||||
}
|
||||
|
||||
function ApiKeyForm({ canGoBack, ctx }: { canGoBack: boolean; ctx: OnboardingContext }) {
|
||||
const [option, setOption] = useState<ApiKeyOption>(API_KEY_OPTIONS[0])
|
||||
// Presentational two-column key picker. Onboarding feeds it its curated
|
||||
// options + a ctx-bound save; the Providers settings page feeds it the full
|
||||
// provider catalog + a setEnvVar-backed save (plus `isSet`/`onClear` so it can
|
||||
// double as a manage surface). Keep it free of store/ctx coupling so both
|
||||
// surfaces render the identical form.
|
||||
export function ApiKeyForm({
|
||||
canGoBack,
|
||||
isSet,
|
||||
onBack,
|
||||
onClear,
|
||||
onSave,
|
||||
options = API_KEY_OPTIONS,
|
||||
redactedValue
|
||||
}: {
|
||||
canGoBack: boolean
|
||||
isSet?: (envKey: string) => boolean
|
||||
onBack: () => void
|
||||
onClear?: (envKey: string) => void
|
||||
onSave: (envKey: string, value: string, name: string) => Promise<{ message?: string; ok: boolean }>
|
||||
options?: ApiKeyOption[]
|
||||
redactedValue?: (envKey: string) => null | string | undefined
|
||||
}) {
|
||||
const [option, setOption] = useState<ApiKeyOption>(options[0])
|
||||
const [value, setValue] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<null | string>(null)
|
||||
// `options` can change at runtime when callers filter the catalog (e.g. the
|
||||
// Providers page wiring its search into this grid). Keep the selection valid
|
||||
// by snapping back to the first remaining option when the current one drops.
|
||||
useEffect(() => {
|
||||
if (options.length > 0 && !options.some(o => o.id === option.id)) {
|
||||
setOption(options[0])
|
||||
setValue('')
|
||||
setError(null)
|
||||
}
|
||||
}, [option.id, options])
|
||||
// The catalog grid can be tall, leaving the entry field far below the fold.
|
||||
// On selection we scroll the field into view and focus it so it's always
|
||||
// obvious where to paste next.
|
||||
const entryRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const pick = (o: ApiKeyOption) => {
|
||||
setOption(o)
|
||||
setValue('')
|
||||
setError(null)
|
||||
requestAnimationFrame(() => {
|
||||
entryRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
entryRef.current?.querySelector('input')?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
const isLocal = option.envKey === 'OPENAI_BASE_URL'
|
||||
const alreadySet = isSet?.(option.envKey) ?? false
|
||||
// When set, surface the backend's redacted value (e.g. "sk-12…wxyz") as the
|
||||
// placeholder so users can eyeball that the right key is in place.
|
||||
const currentRedacted = alreadySet ? (redactedValue?.(option.envKey) ?? null) : null
|
||||
// Only require a non-empty value — no length/format validation, so a short
|
||||
// or unusual key can't block the user from continuing.
|
||||
const canSave = value.trim().length >= 1
|
||||
|
|
@ -430,7 +525,7 @@ function ApiKeyForm({ canGoBack, ctx }: { canGoBack: boolean; ctx: OnboardingCon
|
|||
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
const result = await saveOnboardingApiKey(option.envKey, value, option.name, ctx)
|
||||
const result = await onSave(option.envKey, value, option.name)
|
||||
|
||||
if (result.ok) {
|
||||
setValue('')
|
||||
|
|
@ -446,7 +541,7 @@ function ApiKeyForm({ canGoBack, ctx }: { canGoBack: boolean; ctx: OnboardingCon
|
|||
{canGoBack ? (
|
||||
<button
|
||||
className="-mt-1 flex items-center gap-1 self-start text-xs font-medium text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setOnboardingMode('oauth')}
|
||||
onClick={onBack}
|
||||
type="button"
|
||||
>
|
||||
<ChevronLeft className="size-3" />
|
||||
|
|
@ -455,30 +550,30 @@ function ApiKeyForm({ canGoBack, ctx }: { canGoBack: boolean; ctx: OnboardingCon
|
|||
) : null}
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{API_KEY_OPTIONS.map(o => (
|
||||
{options.map(o => (
|
||||
<button
|
||||
className={cn(
|
||||
'rounded-2xl border bg-background/60 p-3 text-left transition hover:bg-accent/50',
|
||||
option.id === o.id ? 'border-primary ring-2 ring-primary/20' : 'border-border'
|
||||
)}
|
||||
key={o.id}
|
||||
onClick={() => {
|
||||
setOption(o)
|
||||
setValue('')
|
||||
setError(null)
|
||||
}}
|
||||
onClick={() => pick(o)}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium">{o.name}</span>
|
||||
{option.id === o.id ? <Check className="size-4 text-primary" /> : null}
|
||||
{option.id === o.id ? (
|
||||
<Check className="size-4 text-primary" />
|
||||
) : isSet?.(o.envKey) ? (
|
||||
<Check className="size-3.5 text-muted-foreground" />
|
||||
) : null}
|
||||
</div>
|
||||
{o.short ? <p className="mt-1 text-xs text-muted-foreground">{o.short}</p> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<div className="grid scroll-mt-4 gap-2" ref={entryRef}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-sm leading-6 text-muted-foreground">{option.description}</p>
|
||||
{option.docsUrl ? <DocsLink href={option.docsUrl}>Get a key</DocsLink> : null}
|
||||
|
|
@ -489,17 +584,24 @@ function ApiKeyForm({ canGoBack, ctx }: { canGoBack: boolean; ctx: OnboardingCon
|
|||
className="font-mono"
|
||||
onChange={e => setValue(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && void submit()}
|
||||
placeholder={option.placeholder || 'Paste API key'}
|
||||
placeholder={currentRedacted ?? (alreadySet ? 'Replace current value' : option.placeholder || 'Paste API key')}
|
||||
type={isLocal ? 'text' : 'password'}
|
||||
value={value}
|
||||
/>
|
||||
{error ? <p className="text-xs text-destructive">{error}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
{alreadySet && onClear ? (
|
||||
<Button onClick={() => onClear(option.envKey)} size="sm" variant="ghost">
|
||||
Remove
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<Button disabled={!canSave || saving} onClick={() => void submit()}>
|
||||
{saving ? <Loader2 className="size-4 animate-spin" /> : <KeyRound className="size-4" />}
|
||||
{saving ? 'Connecting' : 'Connect'}
|
||||
{saving ? 'Connecting' : alreadySet ? 'Update' : 'Connect'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export const controlVariants = cva(
|
|||
{
|
||||
variants: {
|
||||
size: {
|
||||
xs: 'px-2 py-0.5 text-[0.6875rem] leading-4',
|
||||
sm: 'px-2 py-1',
|
||||
default: 'px-2.5 py-1.5',
|
||||
lg: 'px-3 py-2 text-sm leading-5'
|
||||
|
|
|
|||
|
|
@ -346,20 +346,49 @@ export function requestDesktopOnboarding(reason = DEFAULT_ONBOARDING_REASON) {
|
|||
// onboarding flow (OAuth rows, API-key form, model-confirm) instead of
|
||||
// duplicating provider UI. Sets manual=true so the overlay shows the picker
|
||||
// even though configured===true, and refreshes the provider list.
|
||||
export function startManualOnboarding(reason = 'Add or switch inference provider.') {
|
||||
export function startManualOnboarding(reason: null | string = 'Add or switch inference provider.') {
|
||||
patch({
|
||||
manual: true,
|
||||
requested: true,
|
||||
reason: reason.trim() || DEFAULT_ONBOARDING_REASON,
|
||||
// `null` opts out of the prompt banner entirely (e.g. when the user already
|
||||
// picked a specific provider and we auto-start its sign-in).
|
||||
reason: reason ? reason.trim() || DEFAULT_ONBOARDING_REASON : null,
|
||||
flow: { status: 'idle' }
|
||||
})
|
||||
void refreshProviders()
|
||||
}
|
||||
|
||||
// One-shot hand-off used when the dedicated Providers settings page launches a
|
||||
// specific provider's sign-in: we open the manual onboarding overlay AND
|
||||
// remember which provider to start, so the overlay drives that exact OAuth
|
||||
// flow instead of re-showing the picker the user just clicked through.
|
||||
// Module-level (not store state) because it's consumed immediately on the next
|
||||
// overlay render and never needs to persist or re-render anything itself.
|
||||
let pendingProviderOAuthId: null | string = null
|
||||
|
||||
export function startManualProviderOAuth(providerId: string, reason: null | string = null) {
|
||||
pendingProviderOAuthId = providerId
|
||||
startManualOnboarding(reason)
|
||||
}
|
||||
|
||||
// Read the pending provider id without clearing it. The overlay only clears it
|
||||
// (via clearPendingProviderOAuth) once it has actually launched that provider,
|
||||
// so a transient empty/failed provider fetch doesn't drop the hand-off and the
|
||||
// deep-link can still auto-start after the list loads.
|
||||
export function peekPendingProviderOAuth(): null | string {
|
||||
return pendingProviderOAuthId
|
||||
}
|
||||
|
||||
export function clearPendingProviderOAuth() {
|
||||
pendingProviderOAuthId = null
|
||||
}
|
||||
|
||||
// Dismiss a manually-opened provider selector without touching the existing
|
||||
// (working) configuration. Only valid in the manual path — the unconfigured
|
||||
// first-run flow has no close affordance because the app can't run yet.
|
||||
export function closeManualOnboarding() {
|
||||
pendingProviderOAuthId = null
|
||||
|
||||
patch({ manual: false, requested: false, flow: { status: 'idle' } })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -562,6 +562,19 @@
|
|||
animation: arc-border var(--arc-duration) linear infinite;
|
||||
}
|
||||
|
||||
/* Flip the arc's travel direction (e.g. the Nous Portal hero row). */
|
||||
.arc-border.arc-reverse::before {
|
||||
animation-direction: reverse;
|
||||
}
|
||||
|
||||
/* Nous Portal hero: slower, blue → orange arc. */
|
||||
.arc-border.arc-nous,
|
||||
:root.dark .arc-border.arc-nous {
|
||||
--arc-c1: #4f8cff;
|
||||
--arc-c2: #ff8c42;
|
||||
--arc-duration: 3.27s;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.arc-border::before {
|
||||
animation: none;
|
||||
|
|
@ -669,11 +682,10 @@ canvas {
|
|||
var(--dt-composer-ring) calc(var(--ring-pct) * var(--composer-ring-strength)),
|
||||
var(--ring-fall)
|
||||
);
|
||||
box-shadow: var(--shadow-composer);
|
||||
box-shadow: none;
|
||||
transition:
|
||||
background-color 200ms ease-out,
|
||||
border-color 200ms ease-out,
|
||||
box-shadow 200ms ease-out;
|
||||
border-color 200ms ease-out;
|
||||
}
|
||||
|
||||
.desktop-input-chrome:hover {
|
||||
|
|
@ -685,7 +697,7 @@ canvas {
|
|||
--ring-pct: 45%;
|
||||
--ring-fall: transparent;
|
||||
background: var(--dt-card);
|
||||
box-shadow: var(--shadow-composer-focus);
|
||||
box-shadow: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
|
|
@ -693,13 +705,6 @@ canvas {
|
|||
border-color: var(--dt-destructive);
|
||||
}
|
||||
|
||||
.desktop-input-chrome[aria-invalid='true']:focus {
|
||||
box-shadow:
|
||||
0 0 0 0.125rem color-mix(in srgb, var(--dt-destructive) 18%, transparent),
|
||||
0 0 0 0.0625rem color-mix(in srgb, var(--dt-destructive) 34%, transparent),
|
||||
0 0.1875rem 0.625rem color-mix(in srgb, var(--dt-destructive) 12%, transparent);
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.scrollbar-dt,
|
||||
.scrollbar-dt * {
|
||||
|
|
|
|||
|
|
@ -96,6 +96,10 @@ export interface OAuthPollResponse {
|
|||
export interface EnvVarInfo {
|
||||
advanced: boolean
|
||||
category: string
|
||||
// True when this var is a messaging-platform credential owned by a card on
|
||||
// the dedicated Messaging page. The Keys page hides these to avoid
|
||||
// duplicating the richer channel-configuration UI.
|
||||
channel_managed?: boolean
|
||||
description: string
|
||||
is_password: boolean
|
||||
is_set: boolean
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue