mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-21 10:22:18 +00:00
feat(desktop): add API-keys search; keep provider lists priority-sorted
- API-keys tab: a SearchField filters provider cards by name / env-var key / description, with a 'no providers match' empty state. Card order stays priority-then-name (curated PROVIDER_GROUPS priority floats recommended providers up; equal priority falls back to alphabetical). - Accounts tab: 'Other providers' keep sortProviders order (priority, then name) — unchanged. Adds searchKeys/noKeysMatch i18n strings across all four locales. Vitest covers priority/name ordering + live filtering + empty state.
This commit is contained in:
parent
8fe7b52ebf
commit
ee0de638d7
7 changed files with 102 additions and 11 deletions
|
|
@ -36,6 +36,22 @@ function provider(id: string, loggedIn: boolean, patch: Partial<OAuthProvider> =
|
|||
}
|
||||
}
|
||||
|
||||
// A backend-tagged provider env var (category=provider) for the API-keys view.
|
||||
function keyVar(label: string, slug: string) {
|
||||
return {
|
||||
advanced: false,
|
||||
category: 'provider',
|
||||
description: `${label} direct API`,
|
||||
is_password: true,
|
||||
is_set: false,
|
||||
provider: slug,
|
||||
provider_label: label,
|
||||
redacted_value: null,
|
||||
tools: [],
|
||||
url: ''
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
onboarding.set({ manual: false })
|
||||
getEnvVars.mockResolvedValue({})
|
||||
|
|
@ -124,4 +140,36 @@ describe('ProvidersSettings', () => {
|
|||
|
||||
expect(await screen.findByText('WidgetAI')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('orders API-key providers by priority then name, and filters them via search', async () => {
|
||||
// These three providers have no curated PROVIDER_GROUPS priority, so they
|
||||
// share the default priority and fall back to alphabetical among themselves
|
||||
// (Acme, Middle, Zebra) — exercising the name tiebreak of the priority sort.
|
||||
getEnvVars.mockResolvedValue({
|
||||
ZEBRA_API_KEY: keyVar('Zebra', 'zebra'),
|
||||
ACME_API_KEY: keyVar('Acme', 'acme'),
|
||||
MIDDLE_API_KEY: keyVar('Middle', 'middle')
|
||||
})
|
||||
listOAuthProviders.mockResolvedValue({ providers: [] })
|
||||
|
||||
const { ProvidersSettings } = await import('./providers-settings')
|
||||
render(<ProvidersSettings onClose={vi.fn()} onViewChange={vi.fn()} view="keys" />)
|
||||
|
||||
// Equal priority → alphabetical tiebreak: Acme, Middle, Zebra.
|
||||
await screen.findByText('Acme')
|
||||
const labels = screen.getAllByText(/Acme|Middle|Zebra/).map(el => el.textContent)
|
||||
expect(labels).toEqual(['Acme', 'Middle', 'Zebra'])
|
||||
|
||||
// Typing narrows the list to matching providers only.
|
||||
const search = screen.getByPlaceholderText('Search providers…')
|
||||
fireEvent.change(search, { target: { value: 'mid' } })
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('Acme')).toBeNull())
|
||||
expect(screen.getByText('Middle')).toBeTruthy()
|
||||
expect(screen.queryByText('Zebra')).toBeNull()
|
||||
|
||||
// A non-matching query shows the empty-state copy.
|
||||
fireEvent.change(search, { target: { value: 'nonesuch-xyz' } })
|
||||
expect(await screen.findByText('No providers match your search.')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
sortProviders
|
||||
} from '@/components/desktop-onboarding-overlay'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { SearchField } from '@/components/ui/search-field'
|
||||
import { disconnectOAuthProvider, listOAuthProviders } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { Check, ChevronDown, ChevronRight, KeyRound, Loader2, Terminal, Trash2 } from '@/lib/icons'
|
||||
|
|
@ -145,6 +146,7 @@ function OAuthPicker({
|
|||
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.
|
||||
// Both lists preserve `sortProviders` order (curated priority, then name).
|
||||
const connected = rest.filter(p => p.status?.logged_in)
|
||||
const others = rest.filter(p => !p.status?.logged_in)
|
||||
const collapsible = others.length > 0
|
||||
|
|
@ -298,6 +300,8 @@ export function ProvidersSettings({ onClose, onViewChange, view }: ProvidersSett
|
|||
const [oauthProviders, setOauthProviders] = useState<OAuthProvider[]>([])
|
||||
const [openProvider, setOpenProvider] = useState<null | string>(null)
|
||||
const [disconnecting, setDisconnecting] = useState<null | string>(null)
|
||||
// Free-text filter for the API-keys view (provider name / env-var key / desc).
|
||||
const [keyQuery, setKeyQuery] = useState('')
|
||||
// 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.
|
||||
|
|
@ -386,20 +390,49 @@ export function ProvidersSettings({ onClose, onViewChange, view }: ProvidersSett
|
|||
const keyGroups = buildProviderKeyGroups(vars)
|
||||
|
||||
if (showApiKeys) {
|
||||
const q = keyQuery.trim().toLowerCase()
|
||||
const visibleGroups = q
|
||||
? keyGroups.filter(group => {
|
||||
const haystack = [
|
||||
group.name,
|
||||
group.description ?? '',
|
||||
group.primary[0],
|
||||
...group.advanced.map(([k]) => k)
|
||||
]
|
||||
|
||||
return haystack.some(s => s.toLowerCase().includes(q))
|
||||
})
|
||||
: keyGroups
|
||||
|
||||
return (
|
||||
<SettingsContent>
|
||||
{keyGroups.length > 0 ? (
|
||||
<div className="grid gap-2">
|
||||
{keyGroups.map(group => (
|
||||
<ProviderKeyRows
|
||||
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 className="grid gap-3">
|
||||
<SearchField
|
||||
aria-label={t.settings.providers.searchKeys}
|
||||
containerClassName="w-full"
|
||||
onChange={setKeyQuery}
|
||||
placeholder={t.settings.providers.searchKeys}
|
||||
value={keyQuery}
|
||||
/>
|
||||
{visibleGroups.length > 0 ? (
|
||||
<div className="grid gap-2">
|
||||
{visibleGroups.map(group => (
|
||||
<ProviderKeyRows
|
||||
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>
|
||||
) : (
|
||||
<div className="grid min-h-24 place-items-center px-4 py-6 text-center text-[length:var(--conversation-caption-font-size)] text-muted-foreground">
|
||||
{t.settings.providers.noKeysMatch}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<NoProviderKeys />
|
||||
|
|
|
|||
|
|
@ -581,6 +581,8 @@ export const en: Translations = {
|
|||
removedMessage: provider => `${provider} was removed.`,
|
||||
failedRemove: provider => `Could not remove ${provider}`,
|
||||
noProviderKeys: 'No provider API keys available.',
|
||||
searchKeys: 'Search providers…',
|
||||
noKeysMatch: 'No providers match your search.',
|
||||
loading: 'Loading providers...'
|
||||
},
|
||||
sessions: {
|
||||
|
|
|
|||
|
|
@ -700,6 +700,8 @@ export const ja = defineLocale({
|
|||
removedMessage: provider => `${provider} を削除しました。`,
|
||||
failedRemove: provider => `${provider} を削除できませんでした`,
|
||||
noProviderKeys: '利用可能なプロバイダー API キーがありません。',
|
||||
searchKeys: 'プロバイダーを検索…',
|
||||
noKeysMatch: '一致するプロバイダーがありません。',
|
||||
loading: 'プロバイダーを読み込み中...'
|
||||
},
|
||||
sessions: {
|
||||
|
|
|
|||
|
|
@ -462,6 +462,8 @@ export interface Translations {
|
|||
removedMessage: (provider: string) => string
|
||||
failedRemove: (provider: string) => string
|
||||
noProviderKeys: string
|
||||
searchKeys: string
|
||||
noKeysMatch: string
|
||||
loading: string
|
||||
}
|
||||
sessions: {
|
||||
|
|
|
|||
|
|
@ -677,6 +677,8 @@ export const zhHant = defineLocale({
|
|||
removedMessage: provider => `${provider} 已移除。`,
|
||||
failedRemove: provider => `無法移除 ${provider}`,
|
||||
noProviderKeys: '沒有可用的提供方 API 金鑰。',
|
||||
searchKeys: '搜尋提供方…',
|
||||
noKeysMatch: '沒有符合的提供方。',
|
||||
loading: '正在載入提供方...'
|
||||
},
|
||||
sessions: {
|
||||
|
|
|
|||
|
|
@ -774,6 +774,8 @@ export const zh: Translations = {
|
|||
removedMessage: provider => `${provider} 已移除。`,
|
||||
failedRemove: provider => `无法移除 ${provider}`,
|
||||
noProviderKeys: '没有可用的提供方 API 密钥。',
|
||||
searchKeys: '搜索提供方…',
|
||||
noKeysMatch: '没有匹配的提供方。',
|
||||
loading: '正在加载提供方...'
|
||||
},
|
||||
sessions: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue