Add first-class profile support to the desktop app without app reloads. - Swap the single live gateway onto a session's profile lazily (spawned on demand by the Electron backend pool), so one backend serves the active profile and others stay cold — no OOM with many profiles. - Aggregate sessions across profiles by reading each profile's state.db read-only; unified "All profiles" view groups sessions per profile with per-profile pagination, while the default view stays scoped to one profile. - Add an Arc-style profile rail at the sidebar foot: a default<->all toggle pinned left, colored named-profile squares scrolling between, Manage pinned right. Profile identity is a deterministic per-name color. - Route profile-scoped REST (config/env/skills/tools/model) to the active gateway profile and invalidate React Query caches on swap. Single-profile users never trigger a swap, so their path is unchanged. Backend: - web_server: profile-aware active/list endpoints + per-profile session totals; hermes_state: session_count(exclude_children); main.py: honor --profile over HERMES_HOME env for pooled backends. UI primitives: - Add a position-aware Tip tooltip (instant, themed) as a drop-in for native title=, and strip redundant tooltips from self-descriptive chrome.
321 lines
10 KiB
TypeScript
321 lines
10 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
|
|
import { PageLoader } from '@/components/page-loader'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Input } from '@/components/ui/input'
|
|
import { deleteEnvVar, getToolsetConfig, revealEnvVar, selectToolsetProvider, setEnvVar } from '@/hermes'
|
|
import { Check, Loader2, Save } from '@/lib/icons'
|
|
import { cn } from '@/lib/utils'
|
|
import { notify, notifyError } from '@/store/notifications'
|
|
import type { ToolEnvVar, ToolProvider, ToolsetConfig } from '@/types/hermes'
|
|
|
|
import { EnvVarActionsMenu, EnvVarActionsTrigger } from './env-var-actions-menu'
|
|
import { Pill } from './primitives'
|
|
|
|
interface ToolsetConfigPanelProps {
|
|
toolset: string
|
|
/** Called after a key is saved/cleared or a provider chosen, so the parent
|
|
* can refresh the "Configured / Needs keys" pill. */
|
|
onConfiguredChange?: () => void
|
|
}
|
|
|
|
function providerConfigured(provider: ToolProvider, envState: Record<string, boolean>): boolean {
|
|
if (provider.env_vars.length === 0) {
|
|
return true
|
|
}
|
|
|
|
return provider.env_vars.every(ev => envState[ev.key])
|
|
}
|
|
|
|
interface EnvVarFieldProps {
|
|
envVar: ToolEnvVar
|
|
isSet: boolean
|
|
onSaved: (key: string) => void
|
|
onCleared: (key: string) => void
|
|
}
|
|
|
|
function EnvVarField({ envVar, isSet, onSaved, onCleared }: EnvVarFieldProps) {
|
|
const [editing, setEditing] = useState(false)
|
|
const [value, setValue] = useState('')
|
|
const [revealed, setRevealed] = useState<string | null>(null)
|
|
const [busy, setBusy] = useState(false)
|
|
|
|
async function handleSave() {
|
|
if (!value) {
|
|
return
|
|
}
|
|
|
|
setBusy(true)
|
|
|
|
try {
|
|
await setEnvVar(envVar.key, value)
|
|
setEditing(false)
|
|
setValue('')
|
|
onSaved(envVar.key)
|
|
notify({ kind: 'success', title: 'Credential saved', message: `${envVar.key} updated.` })
|
|
} catch (err) {
|
|
notifyError(err, `Failed to save ${envVar.key}`)
|
|
} finally {
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
async function handleClear() {
|
|
if (!window.confirm(`Remove ${envVar.key} from .env?`)) {
|
|
return
|
|
}
|
|
|
|
setBusy(true)
|
|
|
|
try {
|
|
await deleteEnvVar(envVar.key)
|
|
setRevealed(null)
|
|
onCleared(envVar.key)
|
|
notify({ kind: 'success', title: 'Credential removed', message: `${envVar.key} removed.` })
|
|
} catch (err) {
|
|
notifyError(err, `Failed to remove ${envVar.key}`)
|
|
} finally {
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
async function handleReveal() {
|
|
if (revealed !== null) {
|
|
setRevealed(null)
|
|
|
|
return
|
|
}
|
|
|
|
try {
|
|
const result = await revealEnvVar(envVar.key)
|
|
setRevealed(result.value)
|
|
} catch (err) {
|
|
notifyError(err, `Failed to reveal ${envVar.key}`)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="grid gap-2 rounded-lg bg-background/55 p-2.5">
|
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
|
<div className="min-w-0">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className="font-mono text-xs font-medium">{envVar.key}</span>
|
|
<Pill tone={isSet ? 'primary' : 'muted'}>
|
|
{isSet && <Check className="size-3" />}
|
|
{isSet ? 'Set' : 'Not set'}
|
|
</Pill>
|
|
</div>
|
|
{envVar.prompt && envVar.prompt !== envVar.key && (
|
|
<p className="mt-0.5 text-[0.7rem] text-muted-foreground">{envVar.prompt}</p>
|
|
)}
|
|
</div>
|
|
{!editing && (
|
|
<EnvVarActionsMenu
|
|
clearDisabled={busy}
|
|
docsUrl={envVar.url}
|
|
isRevealed={revealed !== null}
|
|
isSet={isSet}
|
|
label={envVar.key}
|
|
onClear={() => void handleClear()}
|
|
onEdit={() => setEditing(true)}
|
|
onReveal={() => void handleReveal()}
|
|
>
|
|
<EnvVarActionsTrigger label={envVar.key} onClick={event => event.stopPropagation()} />
|
|
</EnvVarActionsMenu>
|
|
)}
|
|
</div>
|
|
|
|
{isSet && revealed !== null && (
|
|
<div className="rounded-md bg-background px-2.5 py-1.5 font-mono text-xs text-foreground">
|
|
{revealed || '---'}
|
|
</div>
|
|
)}
|
|
|
|
{editing && (
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Input
|
|
autoFocus
|
|
className="min-w-52 flex-1 font-mono"
|
|
onChange={e => setValue(e.target.value)}
|
|
placeholder={envVar.prompt || envVar.key}
|
|
type={envVar.default ? 'text' : 'password'}
|
|
value={value}
|
|
/>
|
|
<Button disabled={busy || !value} onClick={() => void handleSave()} size="sm">
|
|
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Save />}
|
|
Save
|
|
</Button>
|
|
<Button onClick={() => setEditing(false)} size="sm" variant="text">
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfigPanelProps) {
|
|
const [cfg, setCfg] = useState<ToolsetConfig | null>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [selecting, setSelecting] = useState<string | null>(null)
|
|
const [activeProvider, setActiveProvider] = useState<string | null>(null)
|
|
// Live per-key set/unset state, seeded from the endpoint then patched locally.
|
|
const [envState, setEnvState] = useState<Record<string, boolean>>({})
|
|
|
|
const refresh = useCallback(async () => {
|
|
setLoading(true)
|
|
|
|
try {
|
|
const next = await getToolsetConfig(toolset)
|
|
setCfg(next)
|
|
const seeded: Record<string, boolean> = {}
|
|
|
|
for (const provider of next.providers) {
|
|
for (const ev of provider.env_vars) {
|
|
seeded[ev.key] = ev.is_set
|
|
}
|
|
}
|
|
|
|
setEnvState(seeded)
|
|
} catch (err) {
|
|
notifyError(err, 'Tool configuration failed to load')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [toolset])
|
|
|
|
useEffect(() => {
|
|
void refresh()
|
|
}, [refresh])
|
|
|
|
const providers = useMemo(() => cfg?.providers ?? [], [cfg])
|
|
|
|
// Default the expanded provider to the one actually active in config
|
|
// (`is_active` / `cfg.active_provider`, mirroring the CLI picker), then the
|
|
// first fully-configured provider, else the first provider. Without this the
|
|
// panel highlighted the first keyless provider (e.g. Nous Portal) even when
|
|
// the user had already selected another (e.g. DuckDuckGo).
|
|
useEffect(() => {
|
|
if (activeProvider || providers.length === 0) {
|
|
return
|
|
}
|
|
|
|
const selected =
|
|
providers.find(p => p.is_active) ??
|
|
(cfg?.active_provider ? providers.find(p => p.name === cfg.active_provider) : undefined) ??
|
|
providers.find(p => providerConfigured(p, envState)) ??
|
|
providers[0]
|
|
|
|
setActiveProvider(selected.name)
|
|
}, [activeProvider, providers, envState, cfg])
|
|
|
|
async function handleSelect(provider: ToolProvider) {
|
|
setActiveProvider(provider.name)
|
|
setSelecting(provider.name)
|
|
|
|
try {
|
|
await selectToolsetProvider(toolset, provider.name)
|
|
notify({ kind: 'success', title: 'Provider selected', message: `${provider.name} is now active.` })
|
|
onConfiguredChange?.()
|
|
} catch (err) {
|
|
notifyError(err, `Failed to select ${provider.name}`)
|
|
} finally {
|
|
setSelecting(null)
|
|
}
|
|
}
|
|
|
|
function patchEnv(key: string, isSet: boolean) {
|
|
setEnvState(c => ({ ...c, [key]: isSet }))
|
|
onConfiguredChange?.()
|
|
}
|
|
|
|
const emptyMessage = useMemo(() => {
|
|
if (loading || !cfg) {
|
|
return null
|
|
}
|
|
|
|
if (!cfg.has_category) {
|
|
return 'This toolset has no provider options — enable it and it works with your current setup.'
|
|
}
|
|
|
|
if (providers.length === 0) {
|
|
return 'No providers are available for this toolset right now.'
|
|
}
|
|
|
|
return null
|
|
}, [cfg, loading, providers.length])
|
|
|
|
if (loading) {
|
|
return <PageLoader className="min-h-32" label="Loading configuration" />
|
|
}
|
|
|
|
if (emptyMessage) {
|
|
return <p className="px-1 py-3 text-xs text-muted-foreground">{emptyMessage}</p>
|
|
}
|
|
|
|
return (
|
|
<div className="mt-3 grid gap-2">
|
|
{providers.map(provider => {
|
|
const isActive = activeProvider === provider.name
|
|
const configured = providerConfigured(provider, envState)
|
|
|
|
return (
|
|
<div className="overflow-hidden rounded-xl bg-background/60" key={provider.name}>
|
|
<button
|
|
aria-pressed={isActive}
|
|
className={cn(
|
|
'flex w-full items-center justify-between gap-3 px-3 py-2.5 text-left transition hover:bg-accent/50',
|
|
isActive && 'bg-accent/40'
|
|
)}
|
|
onClick={() => void handleSelect(provider)}
|
|
type="button"
|
|
>
|
|
<span className="flex min-w-0 items-center gap-2">
|
|
<span className="truncate text-sm font-medium">{provider.name}</span>
|
|
{provider.badge && <Pill>{provider.badge}</Pill>}
|
|
{configured && (
|
|
<Pill tone="primary">
|
|
<Check className="size-3" />
|
|
Ready
|
|
</Pill>
|
|
)}
|
|
</span>
|
|
{selecting === provider.name && <Loader2 className="size-3.5 shrink-0 animate-spin" />}
|
|
</button>
|
|
|
|
{isActive && (
|
|
<div className="grid gap-2 bg-muted/20 p-3">
|
|
{provider.tag && <p className="text-[0.72rem] text-muted-foreground">{provider.tag}</p>}
|
|
{provider.requires_nous_auth && (
|
|
<p className="text-[0.72rem] text-muted-foreground">
|
|
Included with a Nous subscription — sign in to Nous Portal to activate.
|
|
</p>
|
|
)}
|
|
{provider.env_vars.length === 0 ? (
|
|
<p className="text-[0.72rem] text-muted-foreground">No API key required.</p>
|
|
) : (
|
|
provider.env_vars.map(ev => (
|
|
<EnvVarField
|
|
envVar={ev}
|
|
isSet={Boolean(envState[ev.key])}
|
|
key={ev.key}
|
|
onCleared={key => patchEnv(key, false)}
|
|
onSaved={key => patchEnv(key, true)}
|
|
/>
|
|
))
|
|
)}
|
|
{provider.post_setup && (
|
|
<p className="text-[0.72rem] text-muted-foreground">
|
|
This provider needs an extra setup step ({provider.post_setup}). Run it from the CLI with{' '}
|
|
<code className="font-mono">hermes tools</code> for now.
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
}
|