Files
aiturk-hermes-ide/apps/desktop/src/app/settings/memory/provider-config-modal.tsx
T

165 lines
5.6 KiB
TypeScript

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 { useI18n } from '@/i18n'
import { localizeMemoryField } from '@/i18n/tr-memory-fields'
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 { t, locale } = useI18n()
const copy = t.settings.runtime.memory
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: copy.saved(config.label), message: copy.updated })
await onSaved()
onOpenChange(false)
} catch (err) {
notifyError(err, copy.saveFailed(config.label))
} finally {
setSaving(false)
}
}
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent bodyClassName="dt-portal-scrollbar" className="max-w-2xl">
<DialogHeader>
<DialogTitle icon={SlidersHorizontal}>{copy.fullTitle(config.label)}</DialogTitle>
<DialogDescription>{copy.fullDescription(config.label, profile ?? activeProfile)}</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"
>
{copy.reference(config.label)}
<ExternalLink className="size-3" />
</a>
)}
</DialogHeader>
<div className="min-w-0">
{groupFields(config.fields.map(field => localizeMemoryField(config.name, field, locale))).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 === 'Other' ? copy.other : 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">
{t.common.cancel}
</Button>
</DialogClose>
<Button disabled={saving} onClick={() => void save()} size="sm">
{saving ? <Loader2 className="size-3.5 animate-spin" /> : <Save />}
{copy.saveChanges}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}