import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useNavigate } from 'react-router' import { SETTINGS_ROUTE } from '@/app/routes' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { deleteEnvVar, getActionStatus, getToolsetConfig, getToolsetModels, pollOAuthSession, type ProfileScope, revealEnvVar, runToolsetPostSetup, selectToolsetModel, selectToolsetProvider, setEnvVar, startOAuthLogin } from '@/hermes' import { useI18n } from '@/i18n' import { Check, Loader2, Save, Terminal } from '@/lib/icons' import { cn } from '@/lib/utils' import { upsertDesktopActionTask } from '@/store/activity' import { confirm } from '@/store/confirm' import { notify, notifyError } from '@/store/notifications' import type { ActionStatusResponse, ToolEnvVar, ToolProvider, ToolProviderStatus, ToolsetConfig, ToolsetModelsResponse } from '@/types/hermes' import { EnvVarActionsMenu, EnvVarActionsTrigger, EnvVarContextMenu } from './env-var-actions-menu' import { Pill } from './primitives' import { VoiceProviderFields } from './voice-provider-fields' 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 /** Capabilities profile-scope override: configure THIS profile instead of the * app-wide active one. Omitted (every other caller) → app-wide active * profile, so behavior is unchanged. Threaded into every fetch below. */ profile?: ProfileScope } /** Toolsets whose backends expose a selectable model catalog (mirrors the * backend's _MODEL_CATALOG_TOOLSETS map). */ const MODEL_CATALOG_TOOLSETS = new Set(['image_gen', 'video_gen']) /** * `useNavigate` throws when there is no react-router context. Inside Settings * (the panel's original home) there always is one, so behavior is unchanged; * embedded in a plugin dialog OUTSIDE the router there is none, and this * degrades to `null` instead of crashing the whole panel. Router presence is * stable for a mounted instance's lifetime, so the try/catch never changes the * hook count between renders (rules-of-hooks safe). */ function useOptionalNavigate(): null | ReturnType { try { return useNavigate() } catch { return null } } function providerConfigured(provider: ToolProvider, envState: Record): boolean { if (provider.env_vars.length === 0) { return true } return provider.env_vars.every(ev => envState[ev.key]) } /** * Resolve the readiness pill state for a provider row. Prefers the honest * server-computed `status` (keys ∧ Nous entitlement ∧ post-setup install * state). Older backends don't send `status` — fall back to the legacy * env-var heuristic, mapped onto the same state space (`ready` / * `needs_keys`), so the pill still renders against an outdated runtime. */ function providerStatus(provider: ToolProvider, envState: Record): ToolProviderStatus { if (provider.status) { // Env-var edits patch envState locally without a refetch — a stale // server `status` must not keep saying "needs keys" (or "ready") after // the user just saved (or cleared) a key in this panel. if (provider.env_vars.length > 0) { return provider.env_vars.every(ev => envState[ev.key]) ? 'ready' : 'needs_keys' } return provider.status } return providerConfigured(provider, envState) ? 'ready' : 'needs_keys' } interface EnvVarFieldProps { envVar: ToolEnvVar isSet: boolean onSaved: (key: string) => void onCleared: (key: string) => void profile?: ProfileScope } function EnvVarField({ envVar, isSet, onSaved, onCleared, profile }: EnvVarFieldProps) { const { t } = useI18n() const copy = t.settings.toolsets const navigate = useOptionalNavigate() const [editing, setEditing] = useState(false) const [value, setValue] = useState('') const [revealed, setRevealed] = useState(null) const [busy, setBusy] = useState(false) // Internal route change to Settings → API Keys (tools sub-view) with the // deep-link param keys-settings consumes to scroll + flash this key's card. // No-op when there is no router (embedded outside Settings, e.g. a plugin // dialog): the "Manage keys" affordance simply doesn't navigate there. const openInKeys = () => navigate?.(`${SETTINGS_ROUTE}?tab=keys&key=${encodeURIComponent(envVar.key)}`) async function handleSave() { if (!value) { return } setBusy(true) try { await setEnvVar(envVar.key, value, profile) setEditing(false) setValue('') onSaved(envVar.key) notify({ kind: 'success', title: copy.savedTitle, message: copy.savedMessage(envVar.key) }) } catch (err) { notifyError(err, copy.failedSave(envVar.key)) } finally { setBusy(false) } } async function handleClear() { if (!(await confirm({ destructive: true, title: copy.removeConfirm(envVar.key) }))) { return } setBusy(true) try { await deleteEnvVar(envVar.key, profile) setRevealed(null) onCleared(envVar.key) notify({ kind: 'success', title: copy.removedTitle, message: copy.removedMessage(envVar.key) }) } catch (err) { notifyError(err, copy.failedRemove(envVar.key)) } finally { setBusy(false) } } async function handleReveal() { if (revealed !== null) { setRevealed(null) return } try { const result = await revealEnvVar(envVar.key, profile) setRevealed(result.value) } catch (err) { notifyError(err, copy.failedReveal(envVar.key)) } } const actionProps = { clearDisabled: busy, docsUrl: envVar.url, isRevealed: revealed !== null, isSet, label: envVar.key, onClear: () => void handleClear(), onEdit: () => setEditing(true), onManageKeys: openInKeys, onReveal: () => void handleReveal() } return (
{envVar.key} {isSet && } {isSet ? copy.set : copy.notSet}
{envVar.prompt && envVar.prompt !== envVar.key && (

{envVar.prompt}

)}
{!editing && ( event.stopPropagation()} /> )}
{isSet && revealed !== null && (
{revealed || '---'}
)} {editing && (
setValue(e.target.value)} placeholder={envVar.prompt || envVar.key} type={envVar.default ? 'text' : 'password'} value={value} />
)}
) } interface PostSetupRunnerProps { toolset: string /** The provider's post_setup hook key (e.g. "camofox", "ddgs"). */ postSetupKey: string /** True when the server reports the install side-effect already satisfied * (provider status === 'ready') — renders the resting "Installed" state * with a low-key re-run affordance instead of the primary CTA. */ installed?: boolean /** Refresh the parent config after the install finishes (a backend may now * report itself configured). */ onComplete?: () => void profile?: ProfileScope } /** * Runs a provider's post-setup install hook (npm / pip / binary) via the * `/api/tools/toolsets/{name}/post-setup` spawn-action and tails the resulting * log inline — the GUI equivalent of the install step `hermes tools` runs * after you pick a backend that needs extra dependencies. * * Idempotent UX: when the backend's readiness status says the install is * already satisfied, the primary "Run setup" CTA is replaced by an * "Installed" pill plus a small "Re-run setup" text button, so clicking * around the panel doesn't look like it keeps reinstalling. */ function PostSetupRunner({ toolset, postSetupKey, installed = false, onComplete, profile }: PostSetupRunnerProps) { const { t } = useI18n() const copy = t.settings.toolsets const [running, setRunning] = useState(false) const [status, setStatus] = useState(null) // Guard against overlapping polls / state updates after unmount. const activeRef = useRef(false) // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { return () => { activeRef.current = false } }, []) const run = useCallback(async () => { setRunning(true) setStatus(null) activeRef.current = true try { const started = await runToolsetPostSetup(toolset, postSetupKey, profile) // The spawn endpoint reports ok:false if it couldn't launch the action // (e.g. unknown key, server-side spawn failure). Don't poll a status // that will never exist — surface the failure and stop. if (!started.ok) { notifyError(new Error('spawn failed'), copy.postSetupFailed(postSetupKey)) return } let last: ActionStatusResponse | null = null // Mirror command-center's runSystemAction poll loop: poll the action log // until it exits (or we hit the attempt ceiling), feeding the global // activity rail as we go. for (let attempt = 0; attempt < 150 && activeRef.current; attempt += 1) { await new Promise(resolve => window.setTimeout(resolve, 1200)) if (!activeRef.current) { break } const polled = await getActionStatus(started.name, 300, profile) last = polled setStatus(polled) upsertDesktopActionTask(polled) if (!polled.running) { break } } if (activeRef.current) { const ok = last?.exit_code === 0 notify( ok ? { kind: 'success', title: copy.postSetupCompleteTitle, message: copy.postSetupCompleteMessage(postSetupKey) } : { kind: 'error', title: copy.postSetupErrorTitle, message: copy.postSetupErrorMessage(postSetupKey) } ) onComplete?.() } } catch (err) { if (activeRef.current) { notifyError(err, copy.postSetupFailed(postSetupKey)) } } finally { if (activeRef.current) { setRunning(false) } } }, [toolset, postSetupKey, onComplete, copy, profile]) return (

{installed ? copy.postSetupInstalledHint : copy.postSetupHint(postSetupKey)}

{installed ? ( {copy.postSetupInstalled} ) : ( )}
{status && (status.lines.length > 0 || status.running) && (
          {status.lines.length > 0 ? status.lines.join('\n') : copy.postSetupStarting}
        
)}
) } interface ModelCatalogPickerProps { toolset: string /** The picker-row name of the provider whose catalog to show. */ providerName: string /** True when this provider is the one written to config — selecting a model * only makes sense for the active backend. */ isActiveBackend: boolean profile?: ProfileScope } /** * Backend model catalog — the GUI counterpart of the model picker `hermes * tools` runs after you choose an image/video generation backend (e.g. FAL's * multi-model catalog). Renders speed / strengths / price per model as a * radio-card list and persists the choice to `image_gen.model` / * `video_gen.model`. */ function ModelCatalogPicker({ toolset, providerName, isActiveBackend, profile }: ModelCatalogPickerProps) { const { t } = useI18n() const copy = t.settings.toolsets const [catalog, setCatalog] = useState(null) const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(null) useEffect(() => { let cancelled = false setLoading(true) getToolsetModels(toolset, providerName, profile) .then(next => { if (!cancelled) { setCatalog(next) } }) .catch(() => { // Backend predates the models endpoint or the provider has no // catalog — hide the section entirely rather than erroring. if (!cancelled) { setCatalog(null) } }) .finally(() => { if (!cancelled) { setLoading(false) } }) return () => void (cancelled = true) }, [toolset, providerName, profile]) const pick = async (modelId: string) => { setSaving(modelId) try { await selectToolsetModel(toolset, modelId, providerName, profile) setCatalog(current => (current ? { ...current, current: modelId } : current)) notify({ kind: 'success', title: copy.modelSelectedTitle, message: copy.modelSelectedMessage(modelId) }) } catch (err) { notifyError(err, copy.failedSelectModel(modelId)) } finally { setSaving(null) } } if (loading) { return (
{copy.loadingModels}
) } if (!catalog || !catalog.has_models || catalog.models.length === 0) { return null } const selected = catalog.current ?? catalog.default return (
{copy.modelSectionTitle} {copy.modelCount(catalog.models.length)}
{!isActiveBackend &&

{copy.modelInactiveHint}

}
{catalog.models.map(model => { const isSelected = selected === model.id const isDefault = catalog.default === model.id return ( ) })}
) } export function ToolsetConfigPanel({ toolset, onConfiguredChange, profile }: ToolsetConfigPanelProps) { const { t } = useI18n() const copy = t.settings.toolsets const [cfg, setCfg] = useState(null) const [loading, setLoading] = useState(true) const [selecting, setSelecting] = useState(null) // Which provider row is EXPANDED in the panel (purely presentational — // distinct from the backend-active provider in cfg.active_provider). const [expandedProvider, setExpandedProvider] = useState(null) // Live per-key set/unset state, seeded from the endpoint then patched locally. const [envState, setEnvState] = useState>({}) // Default-provider selection and a user click race just after config arrives: // a stale initialization effect must never replace an explicit choice. const providerChoiceClaimedRef = useRef(false) // Guard the Nous Portal sign-in poll loop against unmount/state updates. const mountedRef = useRef(true) // eslint-disable-next-line no-restricted-syntax -- mount flag guarding an async poll loop, not an atom mirror useEffect(() => { mountedRef.current = true return () => { mountedRef.current = false } }, []) const refresh = useCallback(async () => { setLoading(true) try { const next = await getToolsetConfig(toolset, profile) setCfg(next) const seeded: Record = {} 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, copy.failedLoad) } finally { setLoading(false) } }, [copy.failedLoad, toolset, profile]) 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). // eslint-disable-next-line no-restricted-syntax -- one-shot provider-choice claim flag, not an atom mirror useEffect(() => { if (providerChoiceClaimedRef.current || expandedProvider || 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] // Claim before enqueueing the state update. Effects can run with a stale // expandedProvider closure after a user click, so state alone is too late // to protect that choice. providerChoiceClaimedRef.current = true setExpandedProvider(selected.name) }, [expandedProvider, providers, envState, cfg]) async function handleSelect(provider: ToolProvider) { if (selecting !== null) { return } providerChoiceClaimedRef.current = true setExpandedProvider(provider.name) setSelecting(provider.name) try { const result = await selectToolsetProvider(toolset, provider.name, undefined, profile) // Mirror the backend write locally so dependent UI (model catalog // enablement) tracks the new active backend without a refetch. setCfg(current => current ? { ...current, active_provider: provider.name, providers: current.providers.map(p => ({ ...p, is_active: p.name === provider.name })) } : current ) if (result.needs_nous_auth) { // Managed Nous row selected without Portal entitlement: the config // keys are written but the backend won't activate until the user // signs in (the CLI runs this gate inline; the GUI surfaces it as a // sign-in action). Reuses the existing Nous Portal device-code flow. notify({ kind: 'warning', title: copy.nousAuthNeededTitle, message: copy.nousAuthNeededMessage(provider.name), action: { label: copy.nousAuthSignIn, onClick: () => void signInToNousPortal() } }) return } notify({ kind: 'success', title: copy.selectedTitle, message: copy.selectedMessage(provider.name) }) onConfiguredChange?.() } catch (err) { notifyError(err, copy.failedSelect(provider.name)) } finally { setSelecting(null) } } // Drive the existing Nous Portal OAuth device-code flow (the same session // machinery onboarding uses: start → open verification URL → poll), then // refetch the toolset config so is_active / status flip once entitled. async function signInToNousPortal() { try { const start = await startOAuthLogin('nous', profile) if (start.flow !== 'device_code') { notifyError(new Error(`unexpected flow: ${start.flow}`), copy.nousAuthFailed) return } const url = start.verification_url if (window.hermesDesktop?.openExternal) { try { await window.hermesDesktop.openExternal(url) } catch { window.open(url, '_blank', 'noopener,noreferrer') } } else { window.open(url, '_blank', 'noopener,noreferrer') } // Poll until the device-code session resolves (~5s cadence, bounded). for (let attempt = 0; attempt < 120 && mountedRef.current; attempt += 1) { await new Promise(resolve => window.setTimeout(resolve, 5000)) if (!mountedRef.current) { return } const polled = await pollOAuthSession('nous', start.session_id, profile) if (polled.status === 'approved') { notify({ kind: 'success', title: copy.nousAuthDoneTitle, message: copy.nousAuthDoneMessage }) await refresh() onConfiguredChange?.() return } if (polled.status !== 'pending') { notifyError(new Error(polled.error_message || `Sign-in ${polled.status}`), copy.nousAuthFailed) return } } } catch (err) { if (mountedRef.current) { notifyError(err, copy.nousAuthFailed) } } } function patchEnv(key: string, isSet: boolean) { setEnvState(c => ({ ...c, [key]: isSet })) onConfiguredChange?.() } async function handleSelectCapability(provider: ToolProvider, capability: 'search' | 'extract') { setSelecting(provider.name) try { await selectToolsetProvider(toolset, provider.name, capability, profile) // Mirror the backend write locally so the Search:/Extract: badges track // the new per-capability backend without a refetch. setCfg(current => current ? { ...current, ...(capability === 'search' ? { active_search_backend: provider.web_backend ?? provider.name } : { active_extract_backend: provider.web_backend ?? provider.name }) } : current ) notify({ kind: 'success', title: copy.selectedTitle, message: copy.webCapabilitySelectedMessage(provider.name, capability) }) onConfiguredChange?.() } catch (err) { notifyError(err, copy.failedSelectCapability(provider.name)) } finally { setSelecting(null) } } if (loading) { // Inline row, not a full block loader — a big centered spinner is what // caused the Skills/Tools tab-switch layout jump; this reads as "more // config incoming" without reserving a tall empty area. return (
{copy.loadingConfig}
) } // Nothing to configure → render nothing. An inspector explaining that there // is nothing to explain is noise (the old expander UX needed the message so // an expanded-empty panel didn't look broken; the always-open detail doesn't). if (!cfg || !cfg.has_category) { return null } if (providers.length === 0) { return

{copy.noProviders}

} return (
{toolset === 'web' && cfg.active_search_backend !== undefined && ( // The runtime dispatches web_search and web_extract independently // (web.search_backend / web.extract_backend) — show which backend // each capability resolves to right now.
{copy.webSearchActive(cfg.active_search_backend || copy.webCapabilityUnset)} {copy.webExtractActive(cfg.active_extract_backend || copy.webCapabilityUnset)}
)} {providers.map(provider => { const isExpanded = expandedProvider === provider.name const isBackendActive = provider.is_active || cfg?.active_provider === provider.name const status = providerStatus(provider, envState) const webCaps = toolset === 'web' ? (provider.capabilities ?? []) : [] const isSearchBackend = Boolean(provider.web_backend && cfg.active_search_backend === provider.web_backend) const isExtractBackend = Boolean(provider.web_backend && cfg.active_extract_backend === provider.web_backend) return (
{isExpanded && (
{provider.tag &&

{provider.tag}

} {(toolset !== 'web' || webCaps.length === 0) && ( // Explicit activation — the old row-click-selects UX gave no // signal about which backend was actually in use and made // reading a row's details indistinguishable from choosing it.
{isBackendActive ? ( {copy.activeBackendHint} ) : ( )}
)} {webCaps.length > 0 && ( // Per-capability assignment: writes web.search_backend / // web.extract_backend without touching the shared // web.backend key. Hidden for capabilities the backend // can't serve (e.g. ddgs is search-only).
{webCaps.includes('search') && ( )} {webCaps.includes('extract') && ( )}
)} {provider.requires_nous_auth && (

{copy.nousIncluded}

)} {provider.env_vars.length === 0 ? (

{copy.noApiKeyRequired}

) : ( provider.env_vars.map(ev => ( patchEnv(key, false)} onSaved={key => patchEnv(key, true)} profile={profile} /> )) )} {provider.post_setup && ( void refresh()} postSetupKey={provider.post_setup} profile={profile} toolset={toolset} /> )} {toolset === 'tts' && provider.tts_provider && ( // Voice/model settings for this backend (tts..*) — // the same fields Settings → Voice renders, inline so the // Capabilities panel is a complete setup surface. )} {MODEL_CATALOG_TOOLSETS.has(toolset) && ( )}
)}
) })}
) }