mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-27 11:22:03 +00:00
* feat(memory): OAuth token storage and refresh for the Honcho provider * feat(memory): refresh the Honcho OAuth token in the client and session * feat(memory): zero-CLI loopback OAuth authorization flow * feat(memory): generic memory-provider OAuth connect endpoints * feat(desktop): memory-provider OAuth connect link * feat(memory): CLI OAuth sign-in with source-tagged authorize links * fix(memory): IP-literal loopback redirect and consent config_path on the authorize link * fix(memory): profile-scope the memory-provider OAuth endpoints * refactor(desktop): generic memory-provider OAuth client functions * docs(memory): trim OAuth module docstrings to the invariants * docs(memory): document OAuth connect as an optional auth method * fix(memory): send home-relative display path to consent, not the absolute path * perf(memory): cache OAuth token expiry in memory to skip the hot-path disk read * fix(memory): log OAuth refresh failures at warning, not debug * feat(memory): fall back to an OS-assigned loopback port when 8765 is taken * test(memory): cover the desktop Connect launcher, status, and provider dispatch * fix(desktop): keep the memory-provider dropdown one size regardless of connect state * fix(desktop): move the memory connect link to the description line, leaving the dropdown untouched * refactor(memory): move OAuth connect routes out of web_server into a memory-layer router * refactor(desktop): import MemoryConnect directly, drop the single-export barrel * fix(memory): launch CLI OAuth sign-in right after the auth choice, not after the wizard * fix(desktop): auto-clear the OAuth error state instead of leaving it sticky * test(honcho): isolate auth-method prompt from deployment-shape wizard tests main's wizard suite scripts the cloud prompts without the OAuth auth-method step; auto-answer it in the shared helper so the answer lists stay shape-only. * docs(honcho): document query-adaptive reasoning level (reasoningHeuristic) README never mentioned reasoningHeuristic and listed reasoningLevelCap as an orphaned cap with the wrong default (— vs "high"). Add the query-adaptive scaling note + the reasoningHeuristic/reasoningLevelCap rows (grouped under Dialectic & Reasoning), matching the wording already on the hosted honcho.md page, and add a pointer from the memory-providers overview. * fix(honcho): default the CLI peer prompt to the OAuth consent name The CLI runs the grant with apply_config=False, so the peerName the user just entered at consent was dropped and the wizard's 'Your name' prompt fell back to $USER. Surface it as a transient OAuthCredential.consent_peer_name (set even when config isn't merged) and seed the prompt default from it. * feat(honcho): split OAuth client_id by surface (cli=hermes-agent, desktop=hermes-desktop) resolve_endpoints now picks the client_id from the initiating surface and threads it through authorize -> token exchange -> persisted grant -> refresh, so the CLI and desktop register as distinct OAuth clients. Surface-specific env overrides (HONCHO_OAUTH_CLIENT_ID_CLI/_DESKTOP) win over the generic HONCHO_OAUTH_CLIENT_ID, which still overrides every surface. * feat(honcho): show OAuth vs API key in status; detect existing OAuth in setup status now prints 'Auth: OAuth (clientId, token valid Xm/expired)' instead of masking the OAuth access token as a generic API key; setup notes an existing OAuth grant when re-run. * docs(honcho): drop 'shared pool' wording from unified observation mode help * fix(honcho): cross-process lock around OAuth refresh to prevent grant revocation The in-process threading lock can't stop a sibling process (another profile or the desktop app sharing honcho.json) from replaying the single-use refresh token and tripping reuse-detection, which revokes the whole grant. Guard the read-refresh-persist section with an OS file lock on <config>.lock so only one process rotates at a time; the others re-read the freshly-persisted token. Best-effort: platforms without flock degrade to in-process serialization. * refactor(honcho): one OAuth client (hermes-agent) for all surfaces Collapse the per-surface client_id split. CLI and desktop now use a single client_id (hermes-agent); consent branding/UI still adapt via the source query param. One grant identity means no clientId-vs-refresh-token desync that could get the grant revoked. HONCHO_OAUTH_CLIENT_ID still overrides for self-hosting. * fix(honcho): per-session resolves to session_id, never remapped by title Reorder resolve_session_name so stable identifiers win over labels: gateway per-chat key first, then the per-session session_id, then the cwd map / title. A (possibly auto-generated) title can no longer remap a live per-session conversation onto a second Honcho session mid-stream — fixes the desktop, which is per-conversation via session_id. Consequence: a gateway's per-chat key now also wins over a title (titles never remap a stable id).
405 lines
12 KiB
TypeScript
405 lines
12 KiB
TypeScript
import type { ChangeEvent, ReactNode } from 'react'
|
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
import { useSearchParams } from 'react-router-dom'
|
|
|
|
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 {
|
|
getElevenLabsVoices,
|
|
getHermesConfigDefaults,
|
|
getHermesConfigRecord,
|
|
getHermesConfigSchema,
|
|
saveHermesConfig
|
|
} from '@/hermes'
|
|
import { useI18n } from '@/i18n'
|
|
import { cn } from '@/lib/utils'
|
|
import { notify, notifyError } from '@/store/notifications'
|
|
import type { ConfigFieldSchema, HermesConfigRecord } from '@/types/hermes'
|
|
|
|
import { CONTROL_TEXT, EMPTY_SELECT_VALUE, FIELD_DESCRIPTIONS, FIELD_LABELS, SECTIONS } from './constants'
|
|
import { fieldCopyForSchemaKey } from './field-copy'
|
|
import { enumOptionsFor, getNested, prettyName, setNested } from './helpers'
|
|
import { MemoryConnect } from './memory/connect'
|
|
import { ModelSettings } from './model-settings'
|
|
import { EmptyState, ListRow, LoadingState, SettingsContent } from './primitives'
|
|
import { ProviderConfigPanel } from './provider-config-panel'
|
|
|
|
function ConfigField({
|
|
schemaKey,
|
|
schema,
|
|
value,
|
|
enumOptions,
|
|
optionLabels,
|
|
onChange,
|
|
descriptionExtra
|
|
}: {
|
|
schemaKey: string
|
|
schema: ConfigFieldSchema
|
|
value: unknown
|
|
enumOptions?: string[]
|
|
optionLabels?: Record<string, string>
|
|
onChange: (value: unknown) => void
|
|
descriptionExtra?: ReactNode
|
|
}) {
|
|
const { t } = useI18n()
|
|
const c = t.settings.config
|
|
|
|
const label =
|
|
fieldCopyForSchemaKey(t.settings.fieldLabels, schemaKey) ??
|
|
fieldCopyForSchemaKey(FIELD_LABELS, schemaKey) ??
|
|
prettyName(schemaKey.split('.').pop() ?? schemaKey)
|
|
|
|
const normalize = (v: string) => v.toLowerCase().replace(/[^a-z0-9]+/g, '')
|
|
|
|
const rawDescription = (
|
|
fieldCopyForSchemaKey(t.settings.fieldDescriptions, schemaKey) ??
|
|
fieldCopyForSchemaKey(FIELD_DESCRIPTIONS, schemaKey) ??
|
|
schema.description ??
|
|
''
|
|
).trim()
|
|
|
|
const normalizedDesc = normalize(rawDescription)
|
|
|
|
const description =
|
|
rawDescription && normalizedDesc !== normalize(label) && normalizedDesc !== normalize(schemaKey)
|
|
? rawDescription
|
|
: undefined
|
|
|
|
const descriptionNode: ReactNode = descriptionExtra ? (
|
|
<span className="inline-flex flex-wrap items-center gap-x-3 gap-y-1">
|
|
{description}
|
|
{descriptionExtra}
|
|
</span>
|
|
) : (
|
|
description
|
|
)
|
|
|
|
const row = (action: ReactNode, wide = false) => (
|
|
<ListRow action={action} description={descriptionNode} title={label} wide={wide} />
|
|
)
|
|
|
|
if (schema.type === 'boolean') {
|
|
return row(
|
|
<div className="flex items-center justify-end">
|
|
<Switch checked={Boolean(value)} onCheckedChange={onChange} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const selectOptions = enumOptions ?? (schema.type === 'select' ? (schema.options ?? []).map(String) : undefined)
|
|
|
|
if (selectOptions) {
|
|
return row(
|
|
<Select
|
|
onValueChange={next => onChange(next === EMPTY_SELECT_VALUE ? '' : next)}
|
|
value={String(value ?? '') || EMPTY_SELECT_VALUE}
|
|
>
|
|
<SelectTrigger className={CONTROL_TEXT}>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{selectOptions.map(option => (
|
|
<SelectItem key={option || EMPTY_SELECT_VALUE} value={option || EMPTY_SELECT_VALUE}>
|
|
{option
|
|
? (optionLabels?.[option] ?? prettyName(option))
|
|
: schemaKey === 'display.personality'
|
|
? c.none
|
|
: c.noneParen}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
)
|
|
}
|
|
|
|
if (schema.type === 'number') {
|
|
return row(
|
|
<Input
|
|
className={CONTROL_TEXT}
|
|
onChange={e => {
|
|
const raw = e.target.value
|
|
const n = raw === '' ? 0 : Number(raw)
|
|
|
|
if (!Number.isNaN(n)) {
|
|
onChange(n)
|
|
}
|
|
}}
|
|
placeholder={c.notSet}
|
|
type="number"
|
|
value={value === undefined || value === null ? '' : String(value)}
|
|
/>
|
|
)
|
|
}
|
|
|
|
if (schema.type === 'list') {
|
|
return row(
|
|
<Input
|
|
className={CONTROL_TEXT}
|
|
onChange={e =>
|
|
onChange(
|
|
e.target.value
|
|
.split(',')
|
|
.map(s => s.trim())
|
|
.filter(Boolean)
|
|
)
|
|
}
|
|
placeholder={c.commaSeparated}
|
|
value={Array.isArray(value) ? value.join(', ') : String(value ?? '')}
|
|
/>
|
|
)
|
|
}
|
|
|
|
if (typeof value === 'object' && value !== null) {
|
|
return row(
|
|
<Textarea
|
|
className={cn('min-h-28 resize-y bg-background font-mono', CONTROL_TEXT)}
|
|
onChange={e => {
|
|
try {
|
|
onChange(JSON.parse(e.target.value))
|
|
} catch {
|
|
/* keep last valid */
|
|
}
|
|
}}
|
|
placeholder={c.notSet}
|
|
spellCheck={false}
|
|
value={JSON.stringify(value, null, 2)}
|
|
/>,
|
|
true
|
|
)
|
|
}
|
|
|
|
const isLong = schema.type === 'text' || String(value ?? '').length > 100
|
|
|
|
return row(
|
|
isLong ? (
|
|
<Textarea
|
|
className={cn('min-h-24 resize-y bg-background', CONTROL_TEXT)}
|
|
onChange={e => onChange(e.target.value)}
|
|
placeholder={c.notSet}
|
|
value={String(value ?? '')}
|
|
/>
|
|
) : (
|
|
<Input
|
|
className={CONTROL_TEXT}
|
|
onChange={e => onChange(e.target.value)}
|
|
placeholder={c.notSet}
|
|
value={String(value ?? '')}
|
|
/>
|
|
),
|
|
isLong
|
|
)
|
|
}
|
|
|
|
export function ConfigSettings({
|
|
activeSectionId,
|
|
onConfigSaved,
|
|
onMainModelChanged,
|
|
importInputRef
|
|
}: {
|
|
activeSectionId: string
|
|
onConfigSaved?: () => void
|
|
onMainModelChanged?: (provider: string, model: string) => void
|
|
importInputRef: React.RefObject<HTMLInputElement | null>
|
|
}) {
|
|
const { t } = useI18n()
|
|
const c = t.settings.config
|
|
const [config, setConfig] = useState<HermesConfigRecord | null>(null)
|
|
const [_defaults, setDefaults] = useState<HermesConfigRecord | null>(null)
|
|
const [schema, setSchema] = useState<Record<string, ConfigFieldSchema> | null>(null)
|
|
const [elevenLabsVoiceOptions, setElevenLabsVoiceOptions] = useState<string[] | null>(null)
|
|
const [elevenLabsVoiceLabels, setElevenLabsVoiceLabels] = useState<Record<string, string>>({})
|
|
const saveVersionRef = useRef(0)
|
|
const [saveVersion, setSaveVersion] = useState(0)
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
Promise.all([getHermesConfigRecord(), getHermesConfigDefaults(), getHermesConfigSchema()])
|
|
.then(([c, d, s]) => {
|
|
if (cancelled) {
|
|
return
|
|
}
|
|
|
|
setConfig(c)
|
|
setDefaults(d)
|
|
setSchema(s.fields)
|
|
})
|
|
.catch(err => notifyError(err, c.failedLoad))
|
|
|
|
return () => void (cancelled = true)
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
|
|
getElevenLabsVoices()
|
|
.then(result => {
|
|
if (cancelled || !result.available) {
|
|
return
|
|
}
|
|
|
|
setElevenLabsVoiceOptions(result.voices.map(voice => voice.voice_id))
|
|
setElevenLabsVoiceLabels(Object.fromEntries(result.voices.map(voice => [voice.voice_id, voice.label])))
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) {
|
|
setElevenLabsVoiceOptions(null)
|
|
setElevenLabsVoiceLabels({})
|
|
}
|
|
})
|
|
|
|
return () => void (cancelled = true)
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (!config || saveVersion === 0) {
|
|
return
|
|
}
|
|
|
|
const v = saveVersion
|
|
|
|
const t = window.setTimeout(() => {
|
|
void (async () => {
|
|
try {
|
|
await saveHermesConfig(config)
|
|
|
|
if (saveVersionRef.current === v) {
|
|
onConfigSaved?.()
|
|
}
|
|
} catch (err) {
|
|
if (saveVersionRef.current === v) {
|
|
notifyError(err, c.autosaveFailed)
|
|
}
|
|
}
|
|
})()
|
|
}, 550)
|
|
|
|
return () => window.clearTimeout(t)
|
|
}, [config, onConfigSaved, saveVersion])
|
|
|
|
const updateConfig = (next: HermesConfigRecord) => {
|
|
saveVersionRef.current += 1
|
|
setConfig(next)
|
|
setSaveVersion(saveVersionRef.current)
|
|
}
|
|
|
|
const sectionFields = useMemo(() => {
|
|
if (!schema) {
|
|
return new Map<string, [string, ConfigFieldSchema][]>()
|
|
}
|
|
|
|
return new Map(
|
|
SECTIONS.map(s => [s.id, s.keys.flatMap(k => (schema[k] ? [[k, schema[k]] as [string, ConfigFieldSchema]] : []))])
|
|
)
|
|
}, [schema])
|
|
|
|
const fields = sectionFields.get(activeSectionId) ?? []
|
|
|
|
// Deep-link target from the command palette (?field=<key>): scroll the row
|
|
// into view and flash it, then drop the param so it doesn't re-fire.
|
|
const [searchParams, setSearchParams] = useSearchParams()
|
|
const targetField = searchParams.get('field')
|
|
|
|
useEffect(() => {
|
|
if (!targetField || !config || !schema) {
|
|
return
|
|
}
|
|
|
|
const element = document.getElementById(`setting-field-${targetField}`)
|
|
|
|
if (!element) {
|
|
return
|
|
}
|
|
|
|
element.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
element.classList.add('setting-field-highlight')
|
|
|
|
const timeout = window.setTimeout(() => element.classList.remove('setting-field-highlight'), 1600)
|
|
|
|
setSearchParams(
|
|
previous => {
|
|
const next = new URLSearchParams(previous)
|
|
next.delete('field')
|
|
|
|
return next
|
|
},
|
|
{ replace: true }
|
|
)
|
|
|
|
return () => window.clearTimeout(timeout)
|
|
}, [config, schema, setSearchParams, targetField])
|
|
|
|
function handleImport(e: ChangeEvent<HTMLInputElement>) {
|
|
const file = e.target.files?.[0]
|
|
|
|
if (!file) {
|
|
return
|
|
}
|
|
|
|
const reader = new FileReader()
|
|
|
|
reader.onload = () => {
|
|
try {
|
|
updateConfig(JSON.parse(String(reader.result)))
|
|
notify({ kind: 'success', title: c.imported, message: t.common.saving })
|
|
} catch (err) {
|
|
notifyError(err, c.invalidJson)
|
|
}
|
|
}
|
|
|
|
reader.readAsText(file)
|
|
e.target.value = ''
|
|
}
|
|
|
|
if (!config || !schema) {
|
|
return <LoadingState label={c.loading} />
|
|
}
|
|
|
|
return (
|
|
<SettingsContent>
|
|
{activeSectionId === 'model' && (
|
|
<div className="mb-6">
|
|
<ModelSettings onMainModelChanged={onMainModelChanged} />
|
|
</div>
|
|
)}
|
|
{fields.length === 0 ? (
|
|
<EmptyState description={c.emptyDesc} title={c.emptyTitle} />
|
|
) : (
|
|
<div className="grid gap-1">
|
|
{fields.map(([key, field]) => (
|
|
<div className="scroll-mt-6 rounded-lg" id={`setting-field-${key}`} key={key}>
|
|
<ConfigField
|
|
descriptionExtra={
|
|
key === 'memory.provider' && Boolean(getNested(config, key)) ? (
|
|
<MemoryConnect provider={String(getNested(config, key))} />
|
|
) : undefined
|
|
}
|
|
enumOptions={
|
|
key === 'tts.elevenlabs.voice_id'
|
|
? enumOptionsFor(key, getNested(config, key), config, elevenLabsVoiceOptions ?? undefined)
|
|
: enumOptionsFor(key, getNested(config, key), config)
|
|
}
|
|
onChange={value => updateConfig(setNested(config, key, value))}
|
|
optionLabels={key === 'tts.elevenlabs.voice_id' ? elevenLabsVoiceLabels : undefined}
|
|
schema={field}
|
|
schemaKey={key}
|
|
value={getNested(config, key)}
|
|
/>
|
|
{key === 'memory.provider' && typeof getNested(config, key) === 'string' && getNested(config, key) ? (
|
|
<ProviderConfigPanel provider={String(getNested(config, key))} />
|
|
) : null}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
<input
|
|
accept=".json,application/json"
|
|
className="hidden"
|
|
onChange={handleImport}
|
|
ref={importInputRef}
|
|
type="file"
|
|
/>
|
|
</SettingsContent>
|
|
)
|
|
}
|