Files
openhuman/app/src/components/intelligence/AddMemorySourceDialog.tsx
T

839 lines
28 KiB
TypeScript

/**
* 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>
);
}