165 lines
5.6 KiB
TypeScript
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>
|
|
)
|
|
}
|