/
GreyCat
/
openhuman
Обзор
Документация
Войти
/
GreyCat
/
openhuman
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
app/src/components/intelligence/AddMemorySourceDialog.tsx
838 строк
28 KB
Steven Enamakel
feat(theme): runtime theming — semantic tokens, Theme Studio, presets (#4130)
26 июн 2026, 00:33
Не верифицирован
26 июн 2026, 00:33
a49f2ce
Код
Авторство
О чём код?
/** * Dialog for adding a new memory source. * * Step 1: pick a source kind (Composio / Folder / GitHub / RSS / Web / Twitter). * Step 2: fill in kind-specific fields and submit. * * For Composio, the dialog fetches the user's active connections and * presents them as a dropdown — the user picks an existing OAuth * connection rather than typing toolkit + connection_id. */ import debug from 'debug'; import { type KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useMemo, useRef, useState, } from 'react'; import { listConnections } from '../../lib/composio/composioApi'; import type { ComposioConnection } from '../../lib/composio/types'; import { useT } from '../../lib/i18n/I18nContext'; import { addMemorySource, getSupportedToolkits, type MemorySourceEntry, SOURCE_KIND_ICONS, SOURCE_KIND_LABEL_KEYS, type SourceKind, } from '../../services/memorySourcesService'; import Button from '../ui/Button'; const log = debug('intelligence:add-memory-source-dialog'); /** Safe, PII-free string for an unknown error — message/name only, no stack. */ function errMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); } interface AddMemorySourceDialogProps { open: boolean; onClose: () => void; onAdded: (source: MemorySourceEntry) => void; } const ALL_KINDS: SourceKind[] = [ 'composio', 'conversation', 'folder', 'github_repo', 'rss_feed', 'web_page', 'twitter_query', ]; export function AddMemorySourceDialog({ open, onClose, onAdded }: AddMemorySourceDialogProps) { const { t } = useT(); const [kind, setKind] = useState<SourceKind | null>(null); const [label, setLabel] = useState(''); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState<string | null>(null); // Kind-specific fields const [path, setPath] = useState(''); const [glob, setGlob] = useState('**/*.md'); const [url, setUrl] = useState(''); const [branch, setBranch] = useState('main'); const [query, setQuery] = useState(''); const [selector, setSelector] = useState(''); const [connectionId, setConnectionId] = useState(''); const [toolkit, setToolkit] = useState(''); // Composio connection picker state const [connections, setConnections] = useState<ComposioConnection[]>([]); const [loadingConnections, setLoadingConnections] = useState(false); // Toolkit slugs that can actually sync (backend registry). `null` means the // set hasn't loaded (or the RPC failed) — in that case we treat every // connection as supported rather than locking the user out of all of them. const [supportedToolkits, setSupportedToolkits] = useState<string[] | null>(null); // Fetch composio connections + the supported-toolkit set when the user picks // the composio kind. setState calls live inside the spawned async closure // (not the synchronous effect body) to satisfy `react-hooks/set-state-in-effect`. useEffect(() => { if (kind !== 'composio') return undefined; let cancelled = false; void (async () => { if (cancelled) return; setLoadingConnections(true); try { const [resp, toolkits] = await Promise.all([ listConnections(), getSupportedToolkits().catch((err: unknown) => { // Non-fatal: fall back to "everything supported" so the picker // still works if the supported-toolkit RPC is unavailable. log('[composio-picker] getSupportedToolkits failed: %s', errMessage(err)); return null; }), ]); if (cancelled) return; setConnections(resp.connections); setSupportedToolkits(toolkits); } catch (err) { if (cancelled) return; log('[add-memory-source] listConnections failed: %s', errMessage(err)); setError(t('memorySources.composioListFailed')); } finally { if (!cancelled) setLoadingConnections(false); } })(); return () => { cancelled = true; }; }, [kind, t]); const reset = useCallback(() => { setKind(null); setLabel(''); setPath(''); setGlob('**/*.md'); setUrl(''); setBranch('main'); setQuery(''); setSelector(''); setConnectionId(''); setToolkit(''); setError(null); }, []); const handleClose = useCallback(() => { reset(); onClose(); }, [onClose, reset]); const handleSubmit = useCallback(async () => { if (!kind || !label.trim()) return; setSubmitting(true); setError(null); try { const params: Record<string, unknown> = { kind, label: label.trim(), enabled: true }; switch (kind) { case 'composio': params.toolkit = toolkit; params.connection_id = connectionId; break; case 'conversation': break; case 'folder': params.path = path.trim(); params.glob = glob.trim() || '**/*.md'; break; case 'github_repo': params.url = url.trim(); params.branch = branch.trim() || 'main'; break; case 'rss_feed': params.url = url.trim(); break; case 'web_page': params.url = url.trim(); if (selector.trim()) params.selector = selector.trim(); break; case 'twitter_query': params.query = query.trim(); break; } const source = await addMemorySource(params as Omit<MemorySourceEntry, 'id'>); onAdded(source); handleClose(); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setSubmitting(false); } }, [ kind, label, path, glob, url, branch, query, selector, connectionId, toolkit, onAdded, handleClose, ]); if (!open) return null; const isValid = kind && label.trim() && isKindFieldsValid(kind, { path, url, query, connectionId }); return ( <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"> <div className="w-full max-w-lg rounded-xl border border-line bg-surface p-6 shadow-xl"> <h2 className="text-lg font-semibold text-content">{t('memorySources.addSource')}</h2> {!kind ? ( <> <p className="mt-2 text-sm text-content-muted">{t('memorySources.pickKind')}</p> <div className="mt-4 grid grid-cols-2 gap-3"> {ALL_KINDS.map(k => ( <button key={k} type="button" onClick={() => setKind(k)} className="flex items-center gap-3 rounded-lg border border-line p-3 text-left transition-colors hover:border-primary-400 hover:bg-primary-50 dark:border-line-strong dark:hover:border-primary-500 dark:hover:bg-primary-500/10"> <span className="text-xl">{SOURCE_KIND_ICONS[k]}</span> <span className="text-sm font-medium text-content"> {t(SOURCE_KIND_LABEL_KEYS[k])} </span> </button> ))} </div> <div className="mt-4 flex justify-end"> <Button variant="tertiary" size="md" onClick={handleClose}> {t('common.cancel')} </Button> </div> </> ) : ( <> <p className="mt-1 text-sm text-content-muted"> {SOURCE_KIND_ICONS[kind]} {t(SOURCE_KIND_LABEL_KEYS[kind])} </p> <div className="mt-4 space-y-3"> <Field label={t('memorySources.label')} value={label} onChange={setLabel} placeholder={t('memorySources.labelPlaceholder')} /> <KindFields kind={kind} path={path} setPath={setPath} glob={glob} setGlob={setGlob} url={url} setUrl={setUrl} branch={branch} setBranch={setBranch} query={query} setQuery={setQuery} selector={selector} setSelector={setSelector} connections={connections} loadingConnections={loadingConnections} supportedToolkits={supportedToolkits} connectionId={connectionId} setConnection={(connId, tk, identityLabel) => { setConnectionId(connId); setToolkit(tk); if (!label) setLabel(identityLabel); }} /> </div> {error && ( <p className="mt-3 rounded-md bg-coral-50 p-2 text-xs text-coral-800 dark:bg-coral-500/10 dark:text-coral-300"> {error} </p> )} <div className="mt-5 flex items-center justify-between"> <Button variant="tertiary" size="sm" onClick={() => { setKind(null); setError(null); }}> ← {t('memorySources.backToKinds')} </Button> <div className="flex gap-2"> <Button variant="tertiary" size="md" onClick={handleClose}> {t('common.cancel')} </Button> <Button variant="primary" size="md" onClick={handleSubmit} disabled={!isValid || submitting}> {submitting ? t('memorySources.adding') : t('memorySources.add')} </Button> </div> </div> </> )} </div> </div> ); } function isKindFieldsValid( kind: SourceKind, fields: { path: string; url: string; query: string; connectionId: string } ): boolean { switch (kind) { case 'composio': return fields.connectionId.length > 0; case 'conversation': return true; case 'folder': return fields.path.trim().length > 0; case 'github_repo': case 'rss_feed': case 'web_page': return fields.url.trim().length > 0; case 'twitter_query': return fields.query.trim().length > 0; default: return true; } } interface FieldProps { label: string; value: string; onChange: (v: string) => void; placeholder?: string; type?: string; } interface FolderFieldProps { label: string; value: string; onChange: (v: string) => void; } function FolderField({ label, value, onChange }: FolderFieldProps) { const { t } = useT(); return ( <label className="block"> <span className="text-xs font-medium text-content-secondary">{label}</span> <div className="mt-1 flex gap-2"> <input type="text" value={value} onChange={e => onChange(e.target.value)} placeholder={t('memorySources.folderPathPlaceholder')} className="block w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-content placeholder-content-faint focus:border-primary-400 focus:outline-none focus:ring-1 focus:ring-primary-400 dark:border-neutral-600 dark:bg-surface-muted dark:text-content dark:placeholder-content-faint dark:focus:border-primary-500" /> <label className="shrink-0 cursor-pointer rounded-md border border-line-strong bg-surface px-3 py-2 text-xs font-medium text-content-secondary transition-colors hover:border-primary-400 hover:text-primary-600 dark:border-neutral-600 dark:bg-surface-muted dark:text-content-secondary dark:hover:border-primary-500 dark:hover:text-primary-400"> {t('memorySources.browse')} <input type="file" // @ts-expect-error — non-standard but supported in CEF/Chromium webkitdirectory="" multiple className="hidden" onChange={e => { const files = e.target.files; if (!files || files.length === 0) return; // Chromium exposes the chosen directory path on the first file's `path` // attribute when the renderer has filesystem-aware integration (CEF). // Fall back to webkitRelativePath split if `path` isn't available. const first = files[0] as File & { path?: string }; if (first.path) { // first.path is the absolute path to the file. Derive the directory // by trimming the relative portion (everything after the chosen root). const rel = first.webkitRelativePath || first.name; const abs = first.path; const idx = abs.lastIndexOf(rel); onChange(idx > 0 ? abs.slice(0, idx).replace(/\/$/, '') : abs); } else if (first.webkitRelativePath) { onChange(first.webkitRelativePath.split('/')[0]); } }} /> </label> </div> </label> ); } function Field({ label, value, onChange, placeholder, type = 'text' }: FieldProps) { return ( <label className="block"> <span className="text-xs font-medium text-content-secondary">{label}</span> <input type={type} value={value} onChange={e => onChange(e.target.value)} placeholder={placeholder} className="mt-1 block w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-content placeholder-content-faint focus:border-primary-400 focus:outline-none focus:ring-1 focus:ring-primary-400 dark:border-neutral-600 dark:bg-surface-muted dark:text-content dark:placeholder-content-faint dark:focus:border-primary-500" /> </label> ); } interface KindFieldsProps { kind: SourceKind; path: string; setPath: (v: string) => void; glob: string; setGlob: (v: string) => void; url: string; setUrl: (v: string) => void; branch: string; setBranch: (v: string) => void; query: string; setQuery: (v: string) => void; selector: string; setSelector: (v: string) => void; connections: ComposioConnection[]; loadingConnections: boolean; /** Syncable toolkit slugs; `null` while unknown (treat all as supported). */ supportedToolkits: string[] | null; connectionId: string; setConnection: (connectionId: string, toolkit: string, identityLabel: string) => void; } function KindFields(props: KindFieldsProps) { const { t } = useT(); switch (props.kind) { case 'composio': return <ComposioPicker {...props} />; case 'conversation': return null; case 'folder': return ( <> <FolderField label={t('memorySources.folderPath')} value={props.path} onChange={props.setPath} /> <Field label={t('memorySources.globPattern')} value={props.glob} onChange={props.setGlob} placeholder={t('memorySources.globPatternPlaceholder')} /> </> ); case 'github_repo': return ( <> <Field label={t('memorySources.repoUrl')} value={props.url} onChange={props.setUrl} placeholder={t('memorySources.repoUrlPlaceholder')} /> <Field label={t('memorySources.branch')} value={props.branch} onChange={props.setBranch} placeholder={t('memorySources.branchPlaceholder')} /> </> ); case 'rss_feed': return ( <Field label={t('memorySources.feedUrl')} value={props.url} onChange={props.setUrl} placeholder={t('memorySources.feedUrlPlaceholder')} /> ); case 'web_page': return ( <> <Field label={t('memorySources.pageUrl')} value={props.url} onChange={props.setUrl} placeholder={t('memorySources.pageUrlPlaceholder')} /> <Field label={t('memorySources.cssSelector')} value={props.selector} onChange={props.setSelector} placeholder={t('memorySources.cssSelectorPlaceholder')} /> </> ); case 'twitter_query': return ( <Field label={t('memorySources.searchQuery')} value={props.query} onChange={props.setQuery} placeholder={t('memorySources.searchQueryPlaceholder')} /> ); default: return null; } } /** Active-first status rank — lower is better. */ const STATUS_RANK: Record<string, number> = { ACTIVE: 0, CONNECTED: 0, PENDING: 1, INITIATED: 1, INITIALIZING: 1, EXPIRED: 2, FAILED: 3, ERROR: 3, }; function statusRank(conn: ComposioConnection): number { return STATUS_RANK[conn.status.toUpperCase()] ?? 2; } /** * Deduplicates and labels connections for display in the picker. * * - Sorts by status rank first (ACTIVE/CONNECTED before EXPIRED/FAILED) so * that when two connections share the same toolkit + identity, the healthier * one wins rather than the first-returned one. * - Connections sharing the same toolkit + identity (accountEmail / workspace / * username) OR the same raw connection id are collapsed to the first * occurrence, preventing both labeled and identity-less duplicates. * - Connections with no identity field fall back to showing the raw connection ID * so users can unambiguously distinguish accounts. */ export function deduplicateConnections( connections: ComposioConnection[] ): Array<{ conn: ComposioConnection; label: string }> { const sorted = [...connections].sort((a, b) => statusRank(a) - statusRank(b)); const seen = new Set<string>(); const result: Array<{ conn: ComposioConnection; label: string }> = []; for (const conn of sorted) { // Always dedup by raw connection id to guard against identity-less dupes. if (seen.has(conn.id)) { log('[composio-picker] dropping duplicate connection toolkit=%s', conn.toolkit); continue; } seen.add(conn.id); const identity = conn.accountEmail ?? conn.workspace ?? conn.username; if (identity) { const key = `${conn.toolkit}:${identity}`; if (seen.has(key)) { log('[composio-picker] dropping duplicate connection toolkit=%s', conn.toolkit); continue; } seen.add(key); result.push({ conn, label: `${conn.toolkit} · ${identity}` }); } else { // Fall back to the raw connection ID so the user can unambiguously // distinguish accounts when no identity data is available. result.push({ conn, label: `${conn.toolkit} · ${conn.id}` }); } } return result; } /** A connection is syncable when its toolkit ships a provider. A `null` * supported-set means "unknown" — treat everything as supported so a failed * lookup never disables the whole picker. */ function isToolkitSupported(toolkit: string, supportedToolkits: string[] | null): boolean { if (supportedToolkits === null) return true; return supportedToolkits.includes(toolkit.trim().toLowerCase()); } interface PickerEntry { conn: ComposioConnection; label: string; supported: boolean; } function ComposioPicker({ connections, loadingConnections, supportedToolkits, connectionId, setConnection, }: KindFieldsProps) { const { t } = useT(); const [open, setOpen] = useState(false); // Index (into `entries`) of the keyboard-highlighted option; -1 when none. const [activeIndex, setActiveIndex] = useState(-1); const containerRef = useRef<HTMLDivElement>(null); const buttonRef = useRef<HTMLButtonElement>(null); const listboxRef = useRef<HTMLUListElement>(null); // useMemo must be declared before any early returns (Rules of Hooks). const entries = useMemo<PickerEntry[]>(() => { const deduped = deduplicateConnections(connections).map(({ conn, label }) => ({ conn, label, supported: isToolkitSupported(conn.toolkit, supportedToolkits), })); // Supported connections first so the actionable ones surface at the top; // stable within each partition (dedup already ranked by health/status). return [...deduped.filter(e => e.supported), ...deduped.filter(e => !e.supported)]; }, [connections, supportedToolkits]); // Indexes of keyboard-selectable (supported) options — unsupported rows are // skipped during arrow navigation, mirroring a native <select>'s disabled opts. const selectableIndexes = useMemo( () => entries.map((e, i) => (e.supported ? i : -1)).filter(i => i >= 0), [entries] ); const selected = entries.find(e => e.conn.id === connectionId) ?? null; // Close the popover on outside click or Escape. useEffect(() => { if (!open) return undefined; const onPointerDown = (event: MouseEvent) => { if (containerRef.current && !containerRef.current.contains(event.target as Node)) { setOpen(false); } }; const onKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') setOpen(false); }; document.addEventListener('mousedown', onPointerDown); document.addEventListener('keydown', onKeyDown); return () => { document.removeEventListener('mousedown', onPointerDown); document.removeEventListener('keydown', onKeyDown); }; }, [open]); // Move keyboard focus into the listbox when it opens so arrow keys work // immediately. This is a DOM side-effect only — the highlighted index is set // in the open/close handlers, not here, to avoid setState-in-effect churn. useEffect(() => { if (open) listboxRef.current?.focus(); }, [open]); if (loadingConnections) { return <p className="text-xs text-content-muted">{t('memorySources.loadingConnections')}</p>; } if (connections.length === 0) { return ( <p className="rounded-md bg-amber-50 p-3 text-xs text-amber-800 dark:bg-amber-500/10 dark:text-amber-300"> {t('memorySources.noConnections')} </p> ); } // Highlight the current selection (or first selectable option) and open. const openListbox = () => { const selIdx = entries.findIndex(e => e.conn.id === connectionId && e.supported); setActiveIndex(selIdx >= 0 ? selIdx : (selectableIndexes[0] ?? -1)); setOpen(true); }; const close = (returnFocus = true) => { setActiveIndex(-1); setOpen(false); if (returnFocus) buttonRef.current?.focus(); }; const select = (entry: PickerEntry) => { if (!entry.supported) { log('[composio-picker] ignoring selection of unsupported toolkit=%s', entry.conn.toolkit); return; } setConnection(entry.conn.id, entry.conn.toolkit, entry.label); close(); }; // Move the highlight to the next/previous selectable option, wrapping around. const moveActive = (dir: 1 | -1) => { if (selectableIndexes.length === 0) return; const pos = selectableIndexes.indexOf(activeIndex); const nextPos = pos === -1 ? dir === 1 ? 0 : selectableIndexes.length - 1 : (pos + dir + selectableIndexes.length) % selectableIndexes.length; setActiveIndex(selectableIndexes[nextPos]); }; const onButtonKeyDown = (event: ReactKeyboardEvent<HTMLButtonElement>) => { // Open with the arrow keys; Enter/Space already toggle via onClick. if (!open && (event.key === 'ArrowDown' || event.key === 'ArrowUp')) { event.preventDefault(); openListbox(); } }; const onListKeyDown = (event: ReactKeyboardEvent<HTMLUListElement>) => { switch (event.key) { case 'ArrowDown': event.preventDefault(); moveActive(1); break; case 'ArrowUp': event.preventDefault(); moveActive(-1); break; case 'Home': event.preventDefault(); if (selectableIndexes.length) setActiveIndex(selectableIndexes[0]); break; case 'End': event.preventDefault(); if (selectableIndexes.length) setActiveIndex(selectableIndexes[selectableIndexes.length - 1]); break; case 'Enter': case ' ': event.preventDefault(); if (activeIndex >= 0 && entries[activeIndex]) select(entries[activeIndex]); break; case 'Escape': event.preventDefault(); close(); break; case 'Tab': // Let focus leave naturally, but collapse the popover. setOpen(false); break; default: break; } }; const LISTBOX_ID = 'composio-connection-listbox'; const optionId = (entry: PickerEntry) => `composio-opt-${entry.conn.id}`; const activeOptionId = activeIndex >= 0 && entries[activeIndex] ? optionId(entries[activeIndex]) : undefined; return ( <div className="block" ref={containerRef}> <span className="text-xs font-medium text-content-secondary"> {t('memorySources.pickConnection')} </span> <div className="relative mt-1"> <button ref={buttonRef} type="button" data-testid="composio-connection-picker" aria-haspopup="listbox" aria-expanded={open} aria-controls={open ? LISTBOX_ID : undefined} onClick={() => (open ? close(false) : openListbox())} onKeyDown={onButtonKeyDown} className="flex w-full items-center justify-between rounded-md border border-line-strong bg-surface px-3 py-2 text-left text-sm text-content focus:border-primary-400 focus:outline-none focus:ring-1 focus:ring-primary-400 dark:border-neutral-600 dark:bg-surface-muted dark:text-content dark:focus:border-primary-500"> <span className={selected ? '' : 'text-content-faint'}> {selected ? selected.label : t('memorySources.selectConnection')} </span> <span aria-hidden className="ml-2 text-content-faint"> ▾ </span> </button> {open && ( <ul ref={listboxRef} id={LISTBOX_ID} role="listbox" tabIndex={-1} aria-label={t('memorySources.pickConnection')} aria-activedescendant={activeOptionId} onKeyDown={onListKeyDown} data-testid="composio-connection-listbox" className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md border border-line bg-surface py-1 shadow-lg focus:outline-none dark:border-line-strong dark:bg-surface-muted"> {entries.map((entry, index) => { const isSelected = entry.conn.id === connectionId; const isActive = index === activeIndex; return ( <li key={entry.conn.id} id={optionId(entry)} role="option" aria-selected={isSelected} aria-disabled={!entry.supported} data-testid={`composio-option-${entry.conn.id}`} data-supported={entry.supported} data-active={isActive} onClick={() => select(entry)} onMouseEnter={() => entry.supported && setActiveIndex(index)} className={[ 'flex items-center justify-between gap-2 px-3 py-2 text-sm', entry.supported ? 'cursor-pointer text-content' : 'cursor-not-allowed text-content-faint', isActive && entry.supported ? 'bg-primary-50 dark:bg-primary-500/10' : '', ].join(' ')}> <span className="flex items-center gap-2 truncate"> {isSelected && entry.supported && ( <span aria-hidden className="text-primary-500"> ✓ </span> )} <span className="truncate">{entry.label}</span> </span> {!entry.supported && ( <span data-testid={`composio-option-coming-soon-${entry.conn.id}`} className="shrink-0 rounded-full bg-surface-subtle px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-content-muted dark:bg-neutral-700 dark:text-content-muted"> {t('memorySources.comingSoon')} </span> )} </li> ); })} </ul> )} </div> </div> ); }