Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
commit
03634b1ca3
11340 files changed
+3442369
No files matched your search
@@ -0,0 +1,162 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { getMemoryProviderOAuthStatus, startMemoryProviderOAuth } from '@/hermes'
|
||||
import { Check, ExternalLink, Loader2 } from '@/lib/icons'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import type { MemoryProviderOAuthStatus } from '@/types/hermes'
|
||||
|
||||
const POLL_MS = 1500
|
||||
const POLL_TIMEOUT_MS = 120_000
|
||||
|
||||
// Small connect affordance rendered under the provider dropdown. Capability is
|
||||
// backend-driven: the status route 404s for providers without an oauth_flow
|
||||
// module, so non-OAuth providers render nothing.
|
||||
export function MemoryConnect({ profile, provider }: { profile?: string; provider: string }) {
|
||||
const [capable, setCapable] = useState<'no' | 'unknown' | 'yes'>('unknown')
|
||||
const [connected, setConnected] = useState(false)
|
||||
const [auth, setAuth] = useState<MemoryProviderOAuthStatus['auth']>(null)
|
||||
const [phase, setPhase] = useState<'error' | 'idle' | 'pending'>('idle')
|
||||
const [detail, setDetail] = useState('')
|
||||
const timer = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const deadline = useRef(0)
|
||||
|
||||
const stop = useCallback(() => {
|
||||
if (timer.current !== null) {
|
||||
clearInterval(timer.current)
|
||||
timer.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
setCapable('unknown')
|
||||
getMemoryProviderOAuthStatus(provider, profile)
|
||||
.then(s => {
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
|
||||
setCapable('yes')
|
||||
setConnected(s.connected)
|
||||
setAuth(s.auth)
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) {
|
||||
setCapable('no')
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
stop()
|
||||
}
|
||||
}, [profile, provider, stop])
|
||||
|
||||
// An error message isn't sticky — it clears back to the steady state
|
||||
// (Connect link, plus the connected badge if a credential is stored).
|
||||
useEffect(() => {
|
||||
if (phase !== 'error') {
|
||||
return
|
||||
}
|
||||
|
||||
const t = setTimeout(() => {
|
||||
setPhase('idle')
|
||||
setDetail('')
|
||||
}, 6000)
|
||||
|
||||
return () => clearTimeout(t)
|
||||
}, [phase])
|
||||
|
||||
const connect = useCallback(async () => {
|
||||
setPhase('pending')
|
||||
|
||||
try {
|
||||
await startMemoryProviderOAuth(provider, profile)
|
||||
} catch (err) {
|
||||
setPhase('error')
|
||||
setDetail('Could not start the connection.')
|
||||
notifyError(err, 'Failed to start connection')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
deadline.current = Date.now() + POLL_TIMEOUT_MS
|
||||
stop()
|
||||
timer.current = setInterval(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const next = await getMemoryProviderOAuthStatus(provider, profile)
|
||||
|
||||
if (next.state === 'pending') {
|
||||
if (Date.now() > deadline.current) {
|
||||
stop()
|
||||
setPhase('error')
|
||||
setDetail('Timed out — try again.')
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
stop()
|
||||
setConnected(next.connected)
|
||||
setAuth(next.auth)
|
||||
|
||||
if (next.state === 'error') {
|
||||
setPhase('error')
|
||||
setDetail(next.detail || 'Connection failed.')
|
||||
} else {
|
||||
setPhase('idle')
|
||||
}
|
||||
} catch {
|
||||
// Transient poll failure — keep trying until the deadline.
|
||||
}
|
||||
})()
|
||||
}, POLL_MS)
|
||||
}, [profile, provider, stop])
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
stop()
|
||||
setPhase('idle')
|
||||
}, [stop])
|
||||
|
||||
if (capable !== 'yes') {
|
||||
return null
|
||||
}
|
||||
|
||||
const connectLabel = connected ? (auth === 'apikey' ? 'Connect via OAuth' : 'Reconnect') : 'Connect'
|
||||
|
||||
return (
|
||||
<span className="inline-flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||||
{phase === 'idle' && connected && (
|
||||
<span className="inline-flex items-center gap-1 text-muted-foreground">
|
||||
<Check className="size-3" />
|
||||
{auth === 'apikey' ? 'api key set' : 'oauth set'}
|
||||
</span>
|
||||
)}
|
||||
{phase === 'pending' ? (
|
||||
<>
|
||||
<span className="inline-flex items-center gap-1.5 text-muted-foreground">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
Waiting for browser consent…
|
||||
</span>
|
||||
<Button className="h-auto p-0 text-xs" onClick={cancel} size="sm" type="button" variant="link">
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
className="h-auto gap-1 p-0 text-xs"
|
||||
onClick={() => void connect()}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="link"
|
||||
>
|
||||
<ExternalLink className="size-3" />
|
||||
{connectLabel}
|
||||
</Button>
|
||||
)}
|
||||
{phase === 'error' && detail && <span className="text-destructive">{detail}</span>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
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 { Tip } from '@/components/ui/tooltip'
|
||||
import { Check, Info } 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`
|
||||
|
||||
// Field label with an optional info tooltip, shared by the panel and modal rows.
|
||||
export function FieldTitle({ field }: { field: MemoryProviderField }) {
|
||||
if (!field.info) {
|
||||
return <>{field.label}</>
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{field.label}
|
||||
<Tip className="max-w-60 font-normal leading-snug whitespace-normal" label={field.info}>
|
||||
<Info aria-label={`About ${field.label}`} className="size-3.5 text-muted-foreground/70" />
|
||||
</Tip>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Values are edited as strings; the backend coerces them to native types.
|
||||
export function FieldControl({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
onCommit
|
||||
}: {
|
||||
field: MemoryProviderField
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
// Present on autosaving surfaces: discrete controls commit on change, text-like
|
||||
// controls commit on blur. Absent (the modal), edits stay drafts until Save.
|
||||
onCommit?: (value: string) => void
|
||||
}) {
|
||||
const set = (next: string) => {
|
||||
onChange(next)
|
||||
onCommit?.(next)
|
||||
}
|
||||
|
||||
const commitDraft = onCommit ? () => onCommit(value) : undefined
|
||||
|
||||
if (field.kind === 'bool') {
|
||||
return <Switch checked={value === 'true'} onCheckedChange={checked => set(checked ? 'true' : 'false')} />
|
||||
}
|
||||
|
||||
if (field.kind === 'number') {
|
||||
return (
|
||||
<Input
|
||||
className={FIELD_INPUT}
|
||||
inputMode="numeric"
|
||||
onBlur={commitDraft}
|
||||
onChange={event => onChange(event.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
type="number"
|
||||
value={value}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.kind === 'json') {
|
||||
return (
|
||||
<Textarea
|
||||
className={FIELD_INPUT}
|
||||
onBlur={commitDraft}
|
||||
onChange={event => onChange(event.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
spellCheck={false}
|
||||
value={value}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.kind === 'select') {
|
||||
return (
|
||||
<Select onValueChange={set} 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}`}
|
||||
onBlur={commitDraft}
|
||||
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}
|
||||
onBlur={commitDraft}
|
||||
onChange={event => onChange(event.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
value={value}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { MemoryProviderConfig, MemoryProviderField } from '@/types/hermes'
|
||||
|
||||
const saveMemoryProviderConfig = vi.fn()
|
||||
|
||||
vi.mock('@/hermes', () => ({
|
||||
saveMemoryProviderConfig: (provider: string, values: unknown) => saveMemoryProviderConfig(provider, values)
|
||||
}))
|
||||
|
||||
vi.mock('@/store/profile', async () => {
|
||||
const { atom } = await import('nanostores')
|
||||
|
||||
return { $activeGatewayProfile: atom('default') }
|
||||
})
|
||||
|
||||
vi.mock('@/store/notifications', () => ({
|
||||
notify: vi.fn(),
|
||||
notifyError: vi.fn()
|
||||
}))
|
||||
|
||||
function field(
|
||||
overrides: Partial<MemoryProviderField> & Pick<MemoryProviderField, 'key' | 'kind'>
|
||||
): MemoryProviderField {
|
||||
return {
|
||||
label: overrides.key,
|
||||
value: '',
|
||||
description: '',
|
||||
placeholder: '',
|
||||
is_set: false,
|
||||
inline: false,
|
||||
group: 'Other',
|
||||
options: [],
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function schema(): MemoryProviderConfig {
|
||||
return {
|
||||
name: 'honcho',
|
||||
label: 'Honcho',
|
||||
docs_url: 'https://docs.honcho.dev/v3/guides/integrations/hermes',
|
||||
fields: [
|
||||
field({ key: 'workspace', kind: 'text', label: 'Workspace', value: 'myws', inline: true, group: 'Connection' }),
|
||||
field({ key: 'saveMessages', kind: 'bool', label: 'Save messages', value: 'true', group: 'Message writing' }),
|
||||
field({ key: 'dialecticMaxChars', kind: 'number', label: 'Max result chars', value: '1200', group: 'Dialectic' }),
|
||||
field({
|
||||
key: 'userPeerAliases',
|
||||
kind: 'json',
|
||||
label: 'User peer aliases',
|
||||
value: '{"t":"eri"}',
|
||||
group: 'Identity'
|
||||
})
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
saveMemoryProviderConfig.mockResolvedValue({ ok: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
async function renderModal(open = true) {
|
||||
const { ProviderConfigModal } = await import('./provider-config-modal')
|
||||
const onOpenChange = vi.fn()
|
||||
const onSaved = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const result = render(
|
||||
<ProviderConfigModal
|
||||
config={schema()}
|
||||
onOpenChange={onOpenChange}
|
||||
onSaved={onSaved}
|
||||
open={open}
|
||||
provider="honcho"
|
||||
/>
|
||||
)
|
||||
|
||||
return { ...result, onOpenChange, onSaved }
|
||||
}
|
||||
|
||||
describe('ProviderConfigModal', () => {
|
||||
it('renders every field grouped, including inline ones, with kind-specific controls', async () => {
|
||||
await renderModal()
|
||||
|
||||
expect(await screen.findByText('Message writing')).toBeTruthy()
|
||||
expect(screen.getByText('Dialectic')).toBeTruthy()
|
||||
// bool -> switch, number -> spinbutton, json/text -> textbox
|
||||
expect(screen.getByRole('switch')).toBeTruthy()
|
||||
expect(screen.getByDisplayValue('1200')).toBeTruthy()
|
||||
expect(screen.getByDisplayValue('myws')).toBeTruthy()
|
||||
expect(screen.getByDisplayValue('{"t":"eri"}')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('saves only edited fields, serializing the toggled bool to "false"', async () => {
|
||||
const { onSaved, onOpenChange } = await renderModal()
|
||||
|
||||
fireEvent.click(await screen.findByRole('switch'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save changes' }))
|
||||
|
||||
// A save must never ratify rendered defaults the backend does not store.
|
||||
await waitFor(() => expect(saveMemoryProviderConfig).toHaveBeenCalledWith('honcho', { saveMessages: 'false' }))
|
||||
await waitFor(() => expect(onSaved).toHaveBeenCalled())
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('renders nothing while closed', async () => {
|
||||
await renderModal(false)
|
||||
expect(screen.queryByText('Message writing')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
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 { $activeGatewayProfile } from '@/store/profile'
|
||||
import type { MemoryProviderConfig, MemoryProviderField } from '@/types/hermes'
|
||||
|
||||
import { ListRow } from '../primitives'
|
||||
|
||||
import { FieldControl, FieldTitle } from './field-control'
|
||||
|
||||
// Secrets seed blank: values are write-only and 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,
|
||||
profile = null,
|
||||
provider,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSaved
|
||||
}: {
|
||||
config: MemoryProviderConfig
|
||||
profile?: null | string
|
||||
provider: string
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSaved: () => Promise<void> | void
|
||||
}) {
|
||||
const activeProfile = useStore($activeGatewayProfile)
|
||||
const [values, setValues] = useState<Record<string, string>>({})
|
||||
const [seeded, setSeeded] = useState<Record<string, string>>({})
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
// Reseed on open so edits never start from a stale prior-session snapshot.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const seed = seedAll(config)
|
||||
setSeeded(seed)
|
||||
setValues(seed)
|
||||
}
|
||||
}, [open, config])
|
||||
|
||||
const save = async () => {
|
||||
// Untouched keys stay unsubmitted; runtime defaults still own their values.
|
||||
const edited = Object.fromEntries(Object.entries(values).filter(([key, value]) => value !== seeded[key]))
|
||||
|
||||
setSaving(true)
|
||||
|
||||
try {
|
||||
await saveMemoryProviderConfig(provider, edited, profile)
|
||||
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 bodyClassName="dt-portal-scrollbar" className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle icon={SlidersHorizontal}>{config.label} — full configuration</DialogTitle>
|
||||
<DialogDescription>
|
||||
Every {config.label} option for the <span className="font-medium">{profile ?? activeProfile}</span> 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={<FieldTitle field={field} />}
|
||||
/>
|
||||
</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 changes
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { MemoryProviderConfig } from '@/types/hermes'
|
||||
|
||||
const getMemoryProviderConfig = vi.fn()
|
||||
const saveMemoryProviderConfig = vi.fn()
|
||||
|
||||
vi.mock('@/hermes', () => ({
|
||||
getMemoryProviderConfig: (provider: string) => getMemoryProviderConfig(provider),
|
||||
saveMemoryProviderConfig: (provider: string, values: unknown) => saveMemoryProviderConfig(provider, values)
|
||||
}))
|
||||
|
||||
vi.mock('@/store/profile', async () => {
|
||||
const { atom } = await import('nanostores')
|
||||
|
||||
return { $activeGatewayProfile: atom('default') }
|
||||
})
|
||||
|
||||
vi.mock('@/store/notifications', () => ({
|
||||
notify: vi.fn(),
|
||||
notifyError: vi.fn()
|
||||
}))
|
||||
|
||||
function honchoSchema(): MemoryProviderConfig {
|
||||
return {
|
||||
name: 'honcho',
|
||||
label: 'Honcho',
|
||||
docs_url: 'https://docs.honcho.dev/v3/guides/integrations/hermes',
|
||||
fields: [
|
||||
{
|
||||
key: 'apiKey',
|
||||
label: 'API key',
|
||||
kind: 'secret',
|
||||
value: '',
|
||||
description: 'Authenticate with Honcho Cloud.',
|
||||
placeholder: 'Enter Honcho API key',
|
||||
is_set: false,
|
||||
inline: true,
|
||||
group: 'Connection',
|
||||
options: []
|
||||
},
|
||||
{
|
||||
key: 'baseUrl',
|
||||
label: 'Base URL',
|
||||
kind: 'text',
|
||||
value: '',
|
||||
description: 'Self-hosted Honcho URL.',
|
||||
placeholder: 'https://… (self-hosted)',
|
||||
is_set: false,
|
||||
inline: true,
|
||||
group: 'Connection',
|
||||
options: []
|
||||
},
|
||||
{
|
||||
key: 'environment',
|
||||
label: 'Environment',
|
||||
kind: 'select',
|
||||
value: 'production',
|
||||
description: 'Honcho environment.',
|
||||
placeholder: '',
|
||||
is_set: true,
|
||||
inline: true,
|
||||
group: 'Connection',
|
||||
options: [
|
||||
{ value: 'production', label: 'Production', description: '' },
|
||||
{ value: 'demo', label: 'Demo', description: '' },
|
||||
{ value: 'local', label: 'Local', description: '' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'workspace',
|
||||
label: 'Workspace',
|
||||
kind: 'text',
|
||||
value: 'myws',
|
||||
description: 'Honcho workspace ID.',
|
||||
placeholder: 'hermes',
|
||||
is_set: true,
|
||||
inline: true,
|
||||
group: 'Connection',
|
||||
options: []
|
||||
},
|
||||
// Non-inline field: must NOT render in the compact panel and must NOT be
|
||||
// submitted when the panel saves.
|
||||
{
|
||||
key: 'writeFrequency',
|
||||
label: 'Write frequency',
|
||||
kind: 'text',
|
||||
value: 'async',
|
||||
description: '',
|
||||
placeholder: '',
|
||||
is_set: true,
|
||||
inline: false,
|
||||
group: 'Message writing',
|
||||
options: []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
getMemoryProviderConfig.mockResolvedValue(honchoSchema())
|
||||
saveMemoryProviderConfig.mockResolvedValue({ ok: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
async function renderPanel(provider = 'honcho') {
|
||||
const { ProviderConfigPanel } = await import('./provider-config-panel')
|
||||
|
||||
return render(<ProviderConfigPanel provider={provider} />)
|
||||
}
|
||||
|
||||
describe('ProviderConfigPanel', () => {
|
||||
it('renders the declared inline fields generically', async () => {
|
||||
await renderPanel()
|
||||
|
||||
expect(await screen.findByDisplayValue('myws')).toBeTruthy()
|
||||
expect(screen.getByPlaceholderText('https://… (self-hosted)')).toBeTruthy()
|
||||
expect(screen.getByText('Production')).toBeTruthy()
|
||||
expect(screen.getByText('Self-hosted Honcho URL.')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('hides fields that are not marked inline', async () => {
|
||||
await renderPanel()
|
||||
|
||||
await screen.findByDisplayValue('myws')
|
||||
expect(screen.queryByDisplayValue('async')).toBeNull()
|
||||
expect(screen.queryByText('Write frequency')).toBeNull()
|
||||
})
|
||||
|
||||
it('collapses and expands the fields', async () => {
|
||||
await renderPanel()
|
||||
|
||||
expect(await screen.findByDisplayValue('myws')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: /Honcho settings/ }))
|
||||
expect(screen.queryByDisplayValue('myws')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: /Honcho settings/ }))
|
||||
expect(await screen.findByDisplayValue('myws')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('autosaves a text field on blur as a one-key partial save', async () => {
|
||||
await renderPanel()
|
||||
|
||||
const baseUrl = await screen.findByPlaceholderText('https://… (self-hosted)')
|
||||
fireEvent.change(baseUrl, { target: { value: 'http://localhost:8000' } })
|
||||
fireEvent.blur(baseUrl)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(saveMemoryProviderConfig).toHaveBeenCalledWith('honcho', { baseUrl: 'http://localhost:8000' })
|
||||
)
|
||||
expect(saveMemoryProviderConfig).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not save on blur when nothing changed', async () => {
|
||||
await renderPanel()
|
||||
|
||||
const workspace = await screen.findByDisplayValue('myws')
|
||||
fireEvent.blur(workspace)
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('button', { name: 'Save' })).toBeNull())
|
||||
expect(saveMemoryProviderConfig).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('autosaves a committed secret and clears the draft', async () => {
|
||||
await renderPanel()
|
||||
|
||||
const apiKey = await screen.findByPlaceholderText('Enter Honcho API key')
|
||||
fireEvent.blur(apiKey)
|
||||
expect(saveMemoryProviderConfig).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.change(apiKey, { target: { value: 'hch-new-key' } })
|
||||
fireEvent.blur(apiKey)
|
||||
|
||||
await waitFor(() => expect(saveMemoryProviderConfig).toHaveBeenCalledWith('honcho', { apiKey: 'hch-new-key' }))
|
||||
await waitFor(() => expect((apiKey as HTMLInputElement).value).toBe(''))
|
||||
})
|
||||
|
||||
it('offers a full-config trigger when modal-only fields exist', async () => {
|
||||
await renderPanel()
|
||||
|
||||
await screen.findByDisplayValue('myws')
|
||||
expect(screen.getByRole('button', { name: /Full config/ })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows an inline error with retry when the load fails, then recovers', async () => {
|
||||
getMemoryProviderConfig.mockRejectedValueOnce(new Error('Timed out connecting to Hermes backend'))
|
||||
|
||||
await renderPanel()
|
||||
|
||||
expect(await screen.findByText(/Timed out connecting/)).toBeTruthy()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
||||
|
||||
expect(await screen.findByDisplayValue('myws')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders nothing for a provider with no declared config surface', async () => {
|
||||
getMemoryProviderConfig.mockResolvedValue({ name: 'builtin', label: 'builtin', docs_url: '', fields: [] })
|
||||
|
||||
const { container } = await renderPanel('builtin')
|
||||
|
||||
await waitFor(() => expect(getMemoryProviderConfig).toHaveBeenCalledWith('builtin'))
|
||||
expect(container.querySelector('section')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
||||
import { getMemoryProviderConfig, saveMemoryProviderConfig } from '@/hermes'
|
||||
import { SlidersHorizontal } from '@/lib/icons'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import type { MemoryProviderConfig, MemoryProviderField } from '@/types/hermes'
|
||||
|
||||
import { ListRow, Pill } from '../primitives'
|
||||
|
||||
import { FieldControl, FieldTitle } from './field-control'
|
||||
import { ProviderConfigModal } from './provider-config-modal'
|
||||
|
||||
// Inline fields only: the compact panel must never re-write modal-owned keys.
|
||||
function seedValues(config: MemoryProviderConfig): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
config.fields.filter(field => field.inline).map(field => [field.key, field.kind === 'secret' ? '' : field.value])
|
||||
)
|
||||
}
|
||||
|
||||
export function ProviderConfigPanel({ profile, provider }: { profile?: string; provider: string }) {
|
||||
const [config, setConfig] = useState<MemoryProviderConfig | null>(null)
|
||||
const [loadError, setLoadError] = useState<null | string>(null)
|
||||
const [values, setValues] = useState<Record<string, string>>({})
|
||||
const [saved, setSaved] = useState<Record<string, string>>({})
|
||||
const [expanded, setExpanded] = useState(true)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const next = await getMemoryProviderConfig(provider, profile)
|
||||
const seed = seedValues(next)
|
||||
setConfig(next)
|
||||
setValues(seed)
|
||||
setSaved(seed)
|
||||
setLoadError(null)
|
||||
} catch (err) {
|
||||
setConfig(null)
|
||||
setLoadError(err instanceof Error ? err.message : 'Memory provider settings failed to load')
|
||||
}
|
||||
}, [profile, provider])
|
||||
|
||||
useEffect(() => {
|
||||
setConfig(null)
|
||||
void refresh()
|
||||
}, [refresh])
|
||||
|
||||
// Autosave, matching the settings page around the panel: one-key partial PUT
|
||||
// on commit, silent on success, no full refresh (it would reset sibling drafts).
|
||||
const commitField = useCallback(
|
||||
async (field: MemoryProviderField, value: string) => {
|
||||
if (value === (saved[field.key] ?? '') || (field.kind === 'secret' && !value.trim())) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await saveMemoryProviderConfig(provider, { [field.key]: value }, profile)
|
||||
|
||||
if (field.kind === 'secret') {
|
||||
setValues(current => ({ ...current, [field.key]: '' }))
|
||||
setConfig(
|
||||
current =>
|
||||
current && {
|
||||
...current,
|
||||
fields: current.fields.map(f => (f.key === field.key ? { ...f, is_set: true } : f))
|
||||
}
|
||||
)
|
||||
} else {
|
||||
setSaved(current => ({ ...current, [field.key]: value }))
|
||||
}
|
||||
} catch (err) {
|
||||
notifyError(err, `Failed to save ${field.label}`)
|
||||
}
|
||||
},
|
||||
[profile, provider, saved]
|
||||
)
|
||||
|
||||
// Providers without a declared config surface (e.g. builtin) render nothing.
|
||||
if (config && config.fields.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 py-2">
|
||||
<span className="text-[length:var(--conversation-caption-font-size)] text-muted-foreground">
|
||||
Memory provider settings failed to load: {loadError}
|
||||
</span>
|
||||
<Button onClick={() => void refresh()} size="sm" type="button" variant="secondary">
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <PageLoader className="min-h-24" 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-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
|
||||
</span>
|
||||
{secretFields.map(field => (
|
||||
<Pill key={field.key}>{field.is_set ? `${field.label} set` : `${field.label} not set`}</Pill>
|
||||
))}
|
||||
</button>
|
||||
{hasFullConfig && (
|
||||
<Button onClick={() => setShowModal(true)} size="sm" type="button" variant="secondary">
|
||||
<SlidersHorizontal className="size-3.5" />
|
||||
Full config…
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="ml-1.5 border-l-2 border-(--ui-accent-secondary)/25 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 }))}
|
||||
onCommit={value => void commitField(field, value)}
|
||||
value={values[field.key] ?? ''}
|
||||
/>
|
||||
}
|
||||
description={field.description}
|
||||
title={<FieldTitle field={field} />}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasFullConfig && (
|
||||
<ProviderConfigModal
|
||||
config={config}
|
||||
onOpenChange={setShowModal}
|
||||
onSaved={refresh}
|
||||
open={showModal}
|
||||
profile={profile}
|
||||
provider={provider}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user