Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,910 @@
|
||||
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<typeof useNavigate> {
|
||||
try {
|
||||
return useNavigate()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
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])
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, boolean>): 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<string | null>(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 (
|
||||
<EnvVarContextMenu {...actionProps}>
|
||||
<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 ? copy.set : copy.notSet}
|
||||
</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 {...actionProps}>
|
||||
<EnvVarActionsTrigger 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 />}
|
||||
{t.common.save}
|
||||
</Button>
|
||||
<Button onClick={() => setEditing(false)} size="sm" variant="text">
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</EnvVarContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
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<ActionStatusResponse | null>(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 (
|
||||
<div className="grid gap-2 rounded-lg bg-background/55 p-2.5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[0.72rem] text-muted-foreground">
|
||||
{installed ? copy.postSetupInstalledHint : copy.postSetupHint(postSetupKey)}
|
||||
</p>
|
||||
</div>
|
||||
{installed ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Pill tone="primary">
|
||||
<Check className="size-3" />
|
||||
{copy.postSetupInstalled}
|
||||
</Pill>
|
||||
<Button disabled={running} onClick={() => void run()} size="sm" variant="text">
|
||||
{running ? <Loader2 className="size-3.5 animate-spin" /> : <Terminal className="size-3.5" />}
|
||||
{running ? copy.postSetupRunning : copy.postSetupRerun}
|
||||
</Button>
|
||||
</span>
|
||||
) : (
|
||||
<Button disabled={running} onClick={() => void run()} size="sm">
|
||||
{running ? <Loader2 className="size-3.5 animate-spin" /> : <Terminal className="size-3.5" />}
|
||||
{running ? copy.postSetupRunning : copy.postSetupRun}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{status && (status.lines.length > 0 || status.running) && (
|
||||
<pre
|
||||
className="max-h-48 overflow-y-auto rounded-md bg-background px-2.5 py-1.5 font-mono text-[0.7rem] leading-relaxed text-muted-foreground whitespace-pre-wrap"
|
||||
data-selectable-text="true"
|
||||
>
|
||||
{status.lines.length > 0 ? status.lines.join('\n') : copy.postSetupStarting}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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<ToolsetModelsResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState<string | null>(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 (
|
||||
<div className="flex items-center gap-2 px-1 py-2 text-[0.72rem] text-muted-foreground">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
{copy.loadingModels}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!catalog || !catalog.has_models || catalog.models.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const selected = catalog.current ?? catalog.default
|
||||
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
<div className="flex items-baseline justify-between gap-2 px-0.5">
|
||||
<span className="text-[0.72rem] font-medium">{copy.modelSectionTitle}</span>
|
||||
<span className="text-[0.68rem] text-muted-foreground">{copy.modelCount(catalog.models.length)}</span>
|
||||
</div>
|
||||
{!isActiveBackend && <p className="px-0.5 text-[0.68rem] text-muted-foreground">{copy.modelInactiveHint}</p>}
|
||||
<div className="grid gap-1">
|
||||
{catalog.models.map(model => {
|
||||
const isSelected = selected === model.id
|
||||
const isDefault = catalog.default === model.id
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-pressed={isSelected}
|
||||
className={cn(
|
||||
'grid gap-0.5 rounded-lg border px-2.5 py-2 text-left transition',
|
||||
isSelected
|
||||
? 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
|
||||
: 'border-transparent bg-background/55 hover:bg-accent/40',
|
||||
!isActiveBackend && 'opacity-60'
|
||||
)}
|
||||
disabled={saving !== null || !isActiveBackend}
|
||||
key={model.id}
|
||||
onClick={() => void pick(model.id)}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-mono text-xs font-medium">{model.display || model.id}</span>
|
||||
{isSelected && (
|
||||
<Pill tone="primary">
|
||||
<Check className="size-3" />
|
||||
{copy.modelInUse}
|
||||
</Pill>
|
||||
)}
|
||||
{!isSelected && isDefault && <Pill>{copy.modelDefault}</Pill>}
|
||||
{saving === model.id && <Loader2 className="size-3 animate-spin" />}
|
||||
</span>
|
||||
<span className="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-[0.68rem] text-muted-foreground">
|
||||
{model.speed && <span>{model.speed}</span>}
|
||||
{model.strengths && <span>{model.strengths}</span>}
|
||||
{model.price && <span className="font-mono">{model.price}</span>}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ToolsetConfigPanel({ toolset, onConfiguredChange, profile }: ToolsetConfigPanelProps) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.settings.toolsets
|
||||
const [cfg, setCfg] = useState<ToolsetConfig | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [selecting, setSelecting] = useState<string | null>(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<string | null>(null)
|
||||
// Live per-key set/unset state, seeded from the endpoint then patched locally.
|
||||
const [envState, setEnvState] = useState<Record<string, boolean>>({})
|
||||
// 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<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, 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 (
|
||||
<div className="flex items-center gap-2 px-1 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
{copy.loadingConfig}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 <p className="px-1 py-3 text-xs text-muted-foreground">{copy.noProviders}</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
{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.
|
||||
<div className="flex flex-wrap items-center gap-2 px-1">
|
||||
<Pill>{copy.webSearchActive(cfg.active_search_backend || copy.webCapabilityUnset)}</Pill>
|
||||
<Pill>{copy.webExtractActive(cfg.active_extract_backend || copy.webCapabilityUnset)}</Pill>
|
||||
</div>
|
||||
)}
|
||||
{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 (
|
||||
<div className="overflow-hidden rounded-xl bg-background/60" key={provider.name}>
|
||||
<button
|
||||
aria-expanded={isExpanded}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between gap-3 px-3 py-2.5 text-left transition hover:bg-accent/50',
|
||||
isExpanded && 'bg-accent/40'
|
||||
)}
|
||||
onClick={() => {
|
||||
// Row click only expands/collapses — activating a backend is
|
||||
// the explicit "Use this backend" button below, so browsing
|
||||
// provider details never silently rewrites config.
|
||||
providerChoiceClaimedRef.current = true
|
||||
setExpandedProvider(current => (current === provider.name ? null : provider.name))
|
||||
}}
|
||||
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>}
|
||||
{isBackendActive && (
|
||||
<Pill tone="primary">
|
||||
<Check className="size-3" />
|
||||
{copy.activeBackend}
|
||||
</Pill>
|
||||
)}
|
||||
{status === 'ready' && !isBackendActive && (
|
||||
<Pill tone="primary">
|
||||
<Check className="size-3" />
|
||||
{copy.ready}
|
||||
</Pill>
|
||||
)}
|
||||
{status === 'needs_auth' && <Pill tone="warn">{copy.needsSignIn}</Pill>}
|
||||
{status === 'needs_setup' && <Pill tone="warn">{copy.needsSetup}</Pill>}
|
||||
{isSearchBackend && <Pill tone="primary">{copy.webUsedForSearch}</Pill>}
|
||||
{isExtractBackend && <Pill tone="primary">{copy.webUsedForExtract}</Pill>}
|
||||
</span>
|
||||
{selecting === provider.name && <Loader2 className="size-3.5 shrink-0 animate-spin" />}
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="grid gap-2 bg-muted/20 p-3">
|
||||
{provider.tag && <p className="text-[0.72rem] text-muted-foreground">{provider.tag}</p>}
|
||||
{(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.
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{isBackendActive ? (
|
||||
<Pill tone="primary">
|
||||
<Check className="size-3" />
|
||||
{copy.activeBackendHint}
|
||||
</Pill>
|
||||
) : (
|
||||
<Button disabled={selecting !== null} onClick={() => void handleSelect(provider)} size="sm">
|
||||
{selecting === provider.name ? <Loader2 className="size-3.5 animate-spin" /> : <Check />}
|
||||
{copy.useBackend}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{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).
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{webCaps.includes('search') && (
|
||||
<Button
|
||||
disabled={selecting !== null || isSearchBackend}
|
||||
onClick={() => void handleSelectCapability(provider, 'search')}
|
||||
size="xs"
|
||||
variant="text"
|
||||
>
|
||||
{copy.webUseForSearch}
|
||||
</Button>
|
||||
)}
|
||||
{webCaps.includes('extract') && (
|
||||
<Button
|
||||
disabled={selecting !== null || isExtractBackend}
|
||||
onClick={() => void handleSelectCapability(provider, 'extract')}
|
||||
size="xs"
|
||||
variant="text"
|
||||
>
|
||||
{copy.webUseForExtract}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{provider.requires_nous_auth && (
|
||||
<p className="text-[0.72rem] text-muted-foreground">{copy.nousIncluded}</p>
|
||||
)}
|
||||
{provider.env_vars.length === 0 ? (
|
||||
<p className="text-[0.72rem] text-muted-foreground">{copy.noApiKeyRequired}</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)}
|
||||
profile={profile}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
{provider.post_setup && (
|
||||
<PostSetupRunner
|
||||
installed={provider.status === 'ready'}
|
||||
onComplete={() => void refresh()}
|
||||
postSetupKey={provider.post_setup}
|
||||
profile={profile}
|
||||
toolset={toolset}
|
||||
/>
|
||||
)}
|
||||
{toolset === 'tts' && provider.tts_provider && (
|
||||
// Voice/model settings for this backend (tts.<key>.*) —
|
||||
// the same fields Settings → Voice renders, inline so the
|
||||
// Capabilities panel is a complete setup surface.
|
||||
<VoiceProviderFields providerKey={provider.tts_provider} section="tts" />
|
||||
)}
|
||||
{MODEL_CATALOG_TOOLSETS.has(toolset) && (
|
||||
<ModelCatalogPicker
|
||||
isActiveBackend={provider.is_active || cfg?.active_provider === provider.name}
|
||||
profile={profile}
|
||||
providerName={provider.name}
|
||||
toolset={toolset}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user