mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-23 16:36:23 +00:00
feat(desktop): inline memory config panel + full-config modal
Upgrade ProviderConfigPanel to a compact inline view (inline fields only) with a Full config… modal that groups every field by section. Add the generic kind-dispatched FieldControl (bool/number/json/select/secret/text) and ProviderConfigModal. Extend MemoryProviderField with inline/group and MemoryProviderConfig with docs_url. Order the provider enum honcho-first.
This commit is contained in:
parent
101b9f8dcd
commit
4ef0672cf9
5 changed files with 304 additions and 96 deletions
|
|
@ -227,7 +227,7 @@ export const ENUM_OPTIONS: Record<string, string[]> = {
|
|||
'code_execution.mode': ['project', 'strict'],
|
||||
'context.engine': ['compressor', 'default', 'custom'],
|
||||
'delegation.reasoning_effort': ['', 'minimal', 'low', 'medium', 'high', 'xhigh'],
|
||||
'memory.provider': ['', 'builtin', 'hindsight', 'honcho'],
|
||||
'memory.provider': ['', 'builtin', 'honcho', 'hindsight'],
|
||||
// Terminal execution backends — kept in sync with the dispatch ladder in
|
||||
// tools/terminal_tool.py::_create_environment (local/docker/singularity/
|
||||
// modal/daytona/ssh). Remote backends need extra env (image, tokens, host).
|
||||
|
|
|
|||
98
apps/desktop/src/app/settings/field-control.tsx
Normal file
98
apps/desktop/src/app/settings/field-control.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Check } from '@/lib/icons'
|
||||
import type { MemoryProviderField } from '@/types/hermes'
|
||||
|
||||
import { CONTROL_TEXT } from './constants'
|
||||
|
||||
// Fade the placeholder well below set values so example text never reads as data.
|
||||
const FIELD_INPUT = `font-mono ${CONTROL_TEXT} placeholder:text-muted-foreground/45`
|
||||
|
||||
/** Generic control for one declared provider field, dispatching on its kind.
|
||||
* Values are edited as strings; the backend coerces them to native types. */
|
||||
export function FieldControl({
|
||||
field,
|
||||
value,
|
||||
onChange
|
||||
}: {
|
||||
field: MemoryProviderField
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
}) {
|
||||
if (field.kind === 'bool') {
|
||||
return <Switch checked={value === 'true'} onCheckedChange={checked => onChange(checked ? 'true' : 'false')} />
|
||||
}
|
||||
|
||||
if (field.kind === 'number') {
|
||||
return (
|
||||
<Input
|
||||
className={FIELD_INPUT}
|
||||
inputMode="numeric"
|
||||
onChange={event => onChange(event.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
type="number"
|
||||
value={value}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.kind === 'json') {
|
||||
return (
|
||||
<Textarea
|
||||
className={FIELD_INPUT}
|
||||
onChange={event => onChange(event.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
spellCheck={false}
|
||||
value={value}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.kind === 'select') {
|
||||
return (
|
||||
<Select onValueChange={onChange} value={value}>
|
||||
<SelectTrigger className={CONTROL_TEXT}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{field.options.map(option => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.kind === 'secret') {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<Input
|
||||
className={`w-full ${FIELD_INPUT}`}
|
||||
onChange={event => onChange(event.target.value)}
|
||||
placeholder={field.is_set ? 'Leave blank to keep current value' : field.placeholder}
|
||||
type="password"
|
||||
value={value}
|
||||
/>
|
||||
{field.is_set && (
|
||||
<span className="inline-flex items-center gap-1 self-start font-mono text-[0.65rem] text-(--ui-text-tertiary)">
|
||||
<Check className="size-3 text-(--ui-accent-secondary)" />
|
||||
set
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
className={FIELD_INPUT}
|
||||
onChange={event => onChange(event.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
value={value}
|
||||
/>
|
||||
)
|
||||
}
|
||||
147
apps/desktop/src/app/settings/provider-config-modal.tsx
Normal file
147
apps/desktop/src/app/settings/provider-config-modal.tsx
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { saveMemoryProviderConfig } from '@/hermes'
|
||||
import { ExternalLink, Loader2, Save, SlidersHorizontal } from '@/lib/icons'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import type { MemoryProviderConfig, MemoryProviderField } from '@/types/hermes'
|
||||
|
||||
import { FieldControl } from './field-control'
|
||||
import { ListRow } from './primitives'
|
||||
|
||||
/** Seed every editable field (inline and modal-only). Secrets start blank —
|
||||
* their value is never returned and submitting blank keeps the stored one. */
|
||||
function seedAll(config: MemoryProviderConfig): Record<string, string> {
|
||||
return Object.fromEntries(config.fields.map(field => [field.key, field.kind === 'secret' ? '' : field.value]))
|
||||
}
|
||||
|
||||
/** Group fields in declared order, preserving first-seen group sequence. */
|
||||
function groupFields(fields: MemoryProviderField[]): [string, MemoryProviderField[]][] {
|
||||
const groups: [string, MemoryProviderField[]][] = []
|
||||
for (const field of fields) {
|
||||
const name = field.group || 'Other'
|
||||
const bucket = groups.find(([key]) => key === name)
|
||||
if (bucket) {
|
||||
bucket[1].push(field)
|
||||
} else {
|
||||
groups.push([name, [field]])
|
||||
}
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
export function ProviderConfigModal({
|
||||
config,
|
||||
provider,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSaved
|
||||
}: {
|
||||
config: MemoryProviderConfig
|
||||
provider: string
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSaved: () => Promise<void> | void
|
||||
}) {
|
||||
const [values, setValues] = useState<Record<string, string>>({})
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
// Reseed from the latest config each time the dialog opens so edits never
|
||||
// start from a stale snapshot left over from a prior session.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setValues(seedAll(config))
|
||||
}
|
||||
}, [open, config])
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await saveMemoryProviderConfig(provider, values)
|
||||
notify({ kind: 'success', title: `${config.label} saved`, message: 'Memory provider configuration updated.' })
|
||||
await onSaved()
|
||||
onOpenChange(false)
|
||||
} catch (err) {
|
||||
notifyError(err, `Failed to save ${config.label} settings`)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent className="max-w-2xl dt-portal-scrollbar">
|
||||
<DialogHeader>
|
||||
<DialogTitle icon={SlidersHorizontal}>{config.label} — full configuration</DialogTitle>
|
||||
<DialogDescription>
|
||||
Every {config.label} option for the active profile. Blank fields fall back to the resolved host or
|
||||
built-in default.
|
||||
</DialogDescription>
|
||||
{config.docs_url && (
|
||||
<a
|
||||
className="inline-flex w-fit items-center gap-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-accent-secondary) underline-offset-4 transition-colors hover:underline"
|
||||
href={config.docs_url}
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
void window.hermesDesktop?.openExternal?.(config.docs_url)
|
||||
}}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{config.label} configuration reference
|
||||
<ExternalLink className="size-3" />
|
||||
</a>
|
||||
)}
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-w-0">
|
||||
{groupFields(config.fields).map(([group, fields]) => (
|
||||
<section className="mt-6 first:mt-2" key={group}>
|
||||
<h3 className="border-b border-(--ui-accent-secondary)/30 pb-1.5 font-mono text-[0.68rem] uppercase tracking-wide text-(--ui-accent-secondary)">
|
||||
{group}
|
||||
</h3>
|
||||
<div className="pl-1">
|
||||
{fields.map(field => (
|
||||
<div className="border-b border-border/40 last:border-b-0" key={field.key}>
|
||||
<ListRow
|
||||
action={
|
||||
<FieldControl
|
||||
field={field}
|
||||
onChange={value => setValues(current => ({ ...current, [field.key]: value }))}
|
||||
value={values[field.key] ?? ''}
|
||||
/>
|
||||
}
|
||||
description={field.description}
|
||||
title={field.label}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button size="sm" type="button" variant="ghost">
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button disabled={saving} onClick={() => void save()} size="sm">
|
||||
{saving ? <Loader2 className="size-3.5 animate-spin" /> : <Save />}
|
||||
Save all
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
|
@ -2,82 +2,21 @@ import { useCallback, useEffect, useState } from 'react'
|
|||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { getMemoryProviderConfig, saveMemoryProviderConfig } from '@/hermes'
|
||||
import { Check, Loader2, Save } from '@/lib/icons'
|
||||
import { Loader2, Save, SlidersHorizontal } from '@/lib/icons'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import type { MemoryProviderConfig, MemoryProviderField } from '@/types/hermes'
|
||||
import type { MemoryProviderConfig } from '@/types/hermes'
|
||||
|
||||
import { CONTROL_TEXT } from './constants'
|
||||
import { LoadingState, Pill } from './primitives'
|
||||
import { FieldControl } from './field-control'
|
||||
import { ListRow, LoadingState, Pill } from './primitives'
|
||||
import { ProviderConfigModal } from './provider-config-modal'
|
||||
|
||||
/** Seed editable values from the schema: non-secret fields keep their current
|
||||
* value, secret fields start blank (their value is never returned). */
|
||||
/** Seed editable values from the inline fields only, so saving the compact
|
||||
* panel never re-writes fields owned by the full-config editor. Secret fields
|
||||
* start blank (their value is never returned). */
|
||||
function seedValues(config: MemoryProviderConfig): Record<string, string> {
|
||||
return Object.fromEntries(config.fields.map(field => [field.key, field.kind === 'secret' ? '' : field.value]))
|
||||
}
|
||||
|
||||
function FieldControl({
|
||||
field,
|
||||
value,
|
||||
onChange
|
||||
}: {
|
||||
field: MemoryProviderField
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
}) {
|
||||
if (field.kind === 'select') {
|
||||
const selected = field.options.find(option => option.value === value)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Select onValueChange={onChange} value={value}>
|
||||
<SelectTrigger className={CONTROL_TEXT}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{field.options.map(option => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{(selected?.description || field.description) && (
|
||||
<span className="text-xs text-muted-foreground">{selected?.description || field.description}</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.kind === 'secret') {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
className="min-w-64 flex-1 font-mono"
|
||||
onChange={event => onChange(event.target.value)}
|
||||
placeholder={field.is_set ? 'Leave blank to keep current value' : field.placeholder}
|
||||
type="password"
|
||||
value={value}
|
||||
/>
|
||||
{field.is_set && (
|
||||
<Pill tone="primary">
|
||||
<Check className="size-3" />
|
||||
Set
|
||||
</Pill>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
className="font-mono"
|
||||
onChange={event => onChange(event.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
value={value}
|
||||
/>
|
||||
return Object.fromEntries(
|
||||
config.fields.filter(field => field.inline).map(field => [field.key, field.kind === 'secret' ? '' : field.value])
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -86,6 +25,7 @@ export function ProviderConfigPanel({ provider }: { provider: string }) {
|
|||
const [values, setValues] = useState<Record<string, string>>({})
|
||||
const [expanded, setExpanded] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
|
|
@ -130,17 +70,19 @@ export function ProviderConfigPanel({ provider }: { provider: string }) {
|
|||
return <LoadingState label="Loading memory provider settings..." />
|
||||
}
|
||||
|
||||
const inlineFields = config.fields.filter(field => field.inline)
|
||||
const secretFields = config.fields.filter(field => field.kind === 'secret')
|
||||
const hasFullConfig = config.fields.some(field => !field.inline)
|
||||
|
||||
return (
|
||||
<section className="py-3">
|
||||
<button
|
||||
aria-expanded={expanded}
|
||||
className="flex w-full items-center justify-between gap-3 rounded-lg bg-background/60 px-3 py-2 text-left hover:bg-accent/50"
|
||||
onClick={() => setExpanded(open => !open)}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<section className="py-1">
|
||||
<div className="flex items-center gap-2 py-2">
|
||||
<button
|
||||
aria-expanded={expanded}
|
||||
className="flex min-w-0 flex-1 items-center gap-2 text-left"
|
||||
onClick={() => setExpanded(open => !open)}
|
||||
type="button"
|
||||
>
|
||||
<DisclosureCaret open={expanded} />
|
||||
<span className="text-[length:var(--conversation-text-font-size)] font-medium text-foreground">
|
||||
{config.label} settings
|
||||
|
|
@ -148,26 +90,34 @@ export function ProviderConfigPanel({ provider }: { provider: string }) {
|
|||
{secretFields.map(field => (
|
||||
<Pill key={field.key}>{field.is_set ? `${field.label} set` : `${field.label} not set`}</Pill>
|
||||
))}
|
||||
</span>
|
||||
</button>
|
||||
</button>
|
||||
{hasFullConfig && (
|
||||
<Button onClick={() => setShowModal(true)} size="sm" type="button" variant="secondary">
|
||||
<SlidersHorizontal className="size-3.5" />
|
||||
Full config…
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="mt-3 grid gap-4 rounded-xl bg-background/60 p-4">
|
||||
{config.fields.map(field => (
|
||||
<label className="grid gap-1.5" key={field.key}>
|
||||
<span className="text-xs font-medium text-muted-foreground">{field.label}</span>
|
||||
<FieldControl
|
||||
field={field}
|
||||
onChange={value => setValues(current => ({ ...current, [field.key]: value }))}
|
||||
value={values[field.key] ?? ''}
|
||||
<div className="ml-1.5 border-l-2 border-(--ui-accent-secondary)/25 bg-(--ui-bg-card) pb-4 pl-4 pr-4">
|
||||
{inlineFields.map(field => (
|
||||
<div className="border-b border-border/40 last:border-b-0" key={field.key}>
|
||||
<ListRow
|
||||
action={
|
||||
<FieldControl
|
||||
field={field}
|
||||
onChange={value => setValues(current => ({ ...current, [field.key]: value }))}
|
||||
value={values[field.key] ?? ''}
|
||||
/>
|
||||
}
|
||||
description={field.description}
|
||||
title={field.label}
|
||||
/>
|
||||
{field.kind !== 'select' && field.description && (
|
||||
<span className="text-xs text-muted-foreground">{field.description}</span>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<div className="flex items-center justify-end pt-3">
|
||||
<Button disabled={saving} onClick={() => void save()} size="sm">
|
||||
{saving ? <Loader2 className="size-3.5 animate-spin" /> : <Save />}
|
||||
Save
|
||||
|
|
@ -175,6 +125,16 @@ export function ProviderConfigPanel({ provider }: { provider: string }) {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasFullConfig && (
|
||||
<ProviderConfigModal
|
||||
config={config}
|
||||
onOpenChange={setShowModal}
|
||||
onSaved={refresh}
|
||||
open={showModal}
|
||||
provider={provider}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -120,7 +120,7 @@ export interface EnvVarInfo {
|
|||
url: null | string
|
||||
}
|
||||
|
||||
export type MemoryProviderFieldKind = 'secret' | 'select' | 'text'
|
||||
export type MemoryProviderFieldKind = 'bool' | 'json' | 'number' | 'secret' | 'select' | 'text'
|
||||
|
||||
export interface MemoryProviderFieldOption {
|
||||
description: string
|
||||
|
|
@ -130,6 +130,8 @@ export interface MemoryProviderFieldOption {
|
|||
|
||||
export interface MemoryProviderField {
|
||||
description: string
|
||||
group: string
|
||||
inline: boolean
|
||||
is_set: boolean
|
||||
key: string
|
||||
kind: MemoryProviderFieldKind
|
||||
|
|
@ -140,6 +142,7 @@ export interface MemoryProviderField {
|
|||
}
|
||||
|
||||
export interface MemoryProviderConfig {
|
||||
docs_url: string
|
||||
fields: MemoryProviderField[]
|
||||
label: string
|
||||
name: string
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue