mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 23:14:37 +00:00
420 lines
14 KiB
TypeScript
420 lines
14 KiB
TypeScript
/**
|
|
* Unified memory-source list.
|
|
*
|
|
* One row per connected source identity, joining two RPCs:
|
|
*
|
|
* - `composio.list_connections` — gives us the live OAuth identities
|
|
* (id + toolkit + accountEmail/workspace/username), used as the
|
|
* row key and to enable the per-row Sync button.
|
|
*
|
|
* - `memory_tree.memory_sync_status_list` — gives us aggregated
|
|
* stats per toolkit (chunks synced, freshness pill, active wave
|
|
* progress). Stats are matched onto rows by toolkit slug, so two
|
|
* Gmail accounts will share the same chunk-count number until
|
|
* the Rust side splits stats by account_email.
|
|
*
|
|
* Toolkits that have chunks in the memory tree but no live Composio
|
|
* connection (rare — usually a legacy or revoked auth) still render
|
|
* as anonymous rows so the user sees the data exists.
|
|
*
|
|
* Replaces both the old `MemorySyncConnections` card and the standalone
|
|
* "Connected sources" panel with one section, one Sync button, one
|
|
* stats block per identity. Sync only appears when:
|
|
* 1. the connection is currently ACTIVE/CONNECTED, AND
|
|
* 2. the toolkit is in the syncable allow-list (today: gmail).
|
|
*/
|
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
|
|
import { listConnections, syncConnection } from '../../lib/composio/composioApi';
|
|
import type { ComposioConnection } from '../../lib/composio/types';
|
|
import { useT } from '../../lib/i18n/I18nContext';
|
|
import {
|
|
type FreshnessLabel,
|
|
type MemorySyncStatus,
|
|
memorySyncStatusList,
|
|
} from '../../services/memorySyncService';
|
|
import type { ToastNotification } from '../../types/intelligence';
|
|
|
|
interface MemorySourcesProps {
|
|
/** Toolkits whose Composio sync writes into the memory tree. */
|
|
syncableToolkits: ReadonlySet<string>;
|
|
/** Refetch cadence for the stats poll. */
|
|
pollIntervalMs?: number;
|
|
/** Toast hook (success/failure). */
|
|
onToast?: (toast: Omit<ToastNotification, 'id'>) => void;
|
|
}
|
|
|
|
const TOOLKIT_LABEL: Record<string, string> = {
|
|
gmail: 'Gmail',
|
|
slack: 'Slack',
|
|
notion: 'Notion',
|
|
github: 'GitHub',
|
|
discord: 'Discord',
|
|
telegram: 'Telegram',
|
|
whatsapp: 'WhatsApp',
|
|
meeting_notes: 'Meeting notes',
|
|
drive_docs: 'Drive docs',
|
|
chat: 'Chat',
|
|
email: 'Email',
|
|
document: 'Document',
|
|
};
|
|
|
|
function useFreshnessLabel() {
|
|
const { t } = useT();
|
|
return { active: t('sync.active'), recent: t('sync.recent'), idle: t('sync.idle') };
|
|
}
|
|
|
|
function freshnessBadge(label: FreshnessLabel): string {
|
|
switch (label) {
|
|
case 'active':
|
|
return 'bg-primary-100 text-primary-700';
|
|
case 'recent':
|
|
return 'bg-sage-100 text-sage-700';
|
|
case 'idle':
|
|
return 'bg-stone-100 text-stone-700';
|
|
}
|
|
}
|
|
|
|
function relativeTimestamp(epochMs: number | null): string | null {
|
|
if (epochMs === null) return null;
|
|
const delta = Date.now() - epochMs;
|
|
if (delta < 1000) return 'just now';
|
|
const seconds = Math.floor(delta / 1000);
|
|
if (seconds < 60) return `${seconds}s ago`;
|
|
const minutes = Math.floor(seconds / 60);
|
|
if (minutes < 60) return `${minutes}m ago`;
|
|
const hours = Math.floor(minutes / 60);
|
|
if (hours < 24) return `${hours}h ago`;
|
|
const days = Math.floor(hours / 24);
|
|
return `${days}d ago`;
|
|
}
|
|
|
|
/** Identity field — first of accountEmail/workspace/username present. */
|
|
function identityFor(conn: ComposioConnection): string | null {
|
|
return conn.accountEmail ?? conn.workspace ?? conn.username ?? null;
|
|
}
|
|
|
|
/** A row to render: connection identity (when known) plus its toolkit stats. */
|
|
interface SourceRow {
|
|
/** Stable React key. */
|
|
key: string;
|
|
toolkit: string;
|
|
/** Display title — `"Gmail · stevent95@gmail.com"` or just `"Gmail"`. */
|
|
title: string;
|
|
/** Composio connection backing the row, when there is one. */
|
|
connection: ComposioConnection | null;
|
|
/** Aggregated stats for this toolkit, when chunks exist. */
|
|
status: MemorySyncStatus | null;
|
|
}
|
|
|
|
function buildRows(
|
|
connections: ComposioConnection[],
|
|
statuses: MemorySyncStatus[],
|
|
syncableToolkits: ReadonlySet<string>
|
|
): SourceRow[] {
|
|
// Hide rows the user can't act on: only render identities that are
|
|
// (1) currently connected via Composio AND (2) whose toolkit has a
|
|
// memory-tree sync implementation. Orphan toolkits with chunks but
|
|
// no live auth, and connected toolkits without a sync provider, are
|
|
// both filtered out — neither offers a working Sync button so they
|
|
// were just clutter at the top of the Memory tab.
|
|
const statusByToolkit = new Map<string, MemorySyncStatus>();
|
|
for (const s of statuses) statusByToolkit.set(s.provider, s);
|
|
|
|
const rows: SourceRow[] = [];
|
|
for (const conn of connections) {
|
|
if (!syncableToolkits.has(conn.toolkit)) continue;
|
|
const label = TOOLKIT_LABEL[conn.toolkit] ?? conn.toolkit;
|
|
const identity = identityFor(conn);
|
|
const title = identity ? `${label} · ${identity}` : label;
|
|
rows.push({
|
|
key: `conn:${conn.id}`,
|
|
toolkit: conn.toolkit,
|
|
title,
|
|
connection: conn,
|
|
status: statusByToolkit.get(conn.toolkit) ?? null,
|
|
});
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
export function MemorySources({
|
|
syncableToolkits,
|
|
pollIntervalMs = 5000,
|
|
onToast,
|
|
}: MemorySourcesProps) {
|
|
const { t } = useT();
|
|
const [connections, setConnections] = useState<ComposioConnection[]>([]);
|
|
const [statuses, setStatuses] = useState<MemorySyncStatus[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [loadError, setLoadError] = useState<string | null>(null);
|
|
const [syncingId, setSyncingId] = useState<string | null>(null);
|
|
|
|
const loadAll = useCallback(async () => {
|
|
try {
|
|
const [conns, stats] = await Promise.all([
|
|
listConnections()
|
|
.then(r => r.connections)
|
|
.catch(err => {
|
|
// Composio may be unreachable in dev; degrade to anonymous
|
|
// toolkit rows from sync-status alone rather than masking
|
|
// the rest of the UI behind an error.
|
|
console.warn('[ui-flow][memory-sources] list_connections failed', err);
|
|
return [] as ComposioConnection[];
|
|
}),
|
|
memorySyncStatusList(),
|
|
]);
|
|
setConnections(conns);
|
|
setStatuses(stats);
|
|
setLoadError(null);
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
console.error('[ui-flow][memory-sources] load failed', message);
|
|
setLoadError(message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void loadAll();
|
|
}, [loadAll]);
|
|
|
|
useEffect(() => {
|
|
if (!pollIntervalMs) return undefined;
|
|
const id = setInterval(() => {
|
|
void loadAll();
|
|
}, pollIntervalMs);
|
|
return () => clearInterval(id);
|
|
}, [pollIntervalMs, loadAll]);
|
|
|
|
const rows = useMemo(
|
|
() => buildRows(connections, statuses, syncableToolkits),
|
|
[connections, statuses, syncableToolkits]
|
|
);
|
|
|
|
const handleSync = useCallback(
|
|
async (conn: ComposioConnection, title: string) => {
|
|
setSyncingId(conn.id);
|
|
try {
|
|
await syncConnection(conn.id, 'manual');
|
|
onToast?.({
|
|
type: 'success',
|
|
title: `Synced ${title}`,
|
|
message: 'New raw items will be admitted into the memory tree shortly.',
|
|
});
|
|
// Refresh stats immediately so the freshness pill updates
|
|
// without waiting for the next poll tick.
|
|
void loadAll();
|
|
} catch (err) {
|
|
console.error('[ui-flow][memory-sources] sync failed conn=%s', conn.id, err);
|
|
onToast?.({
|
|
type: 'error',
|
|
title: `Sync failed: ${title}`,
|
|
message: err instanceof Error ? err.message : String(err),
|
|
});
|
|
} finally {
|
|
setSyncingId(prev => (prev === conn.id ? null : prev));
|
|
}
|
|
},
|
|
[onToast, loadAll]
|
|
);
|
|
|
|
if (loading) {
|
|
return (
|
|
<section
|
|
className="rounded-lg border border-stone-200 bg-white p-4"
|
|
data-testid="memory-sources">
|
|
<h3 className="text-sm font-semibold text-stone-700">{t('sync.memorySources')}</h3>
|
|
<p className="mt-2 text-xs text-stone-500">{t('common.loading')}</p>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
if (loadError) {
|
|
return (
|
|
<section
|
|
className="rounded-lg border border-stone-200 bg-white p-4"
|
|
data-testid="memory-sources">
|
|
<h3 className="text-sm font-semibold text-stone-700">{t('sync.memorySources')}</h3>
|
|
<p className="mt-2 break-words rounded-md bg-coral-50 p-2 text-xs text-coral-800">
|
|
{loadError}
|
|
</p>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
if (rows.length === 0) {
|
|
return (
|
|
<section
|
|
className="rounded-lg border border-stone-200 bg-white p-4"
|
|
data-testid="memory-sources">
|
|
<h3 className="text-sm font-semibold text-stone-700">{t('sync.memorySources')}</h3>
|
|
<p className="mt-2 text-xs text-stone-500">{t('sync.noConnectedSources')}</p>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<section
|
|
className="rounded-lg border border-stone-200 bg-white p-4"
|
|
data-testid="memory-sources">
|
|
<header className="mb-3 flex items-center justify-between">
|
|
<h3 className="text-sm font-semibold text-stone-700">{t('sync.memorySources')}</h3>
|
|
<span className="text-xs text-stone-400">
|
|
{rows.length} identit{rows.length === 1 ? 'y' : 'ies'}
|
|
</span>
|
|
</header>
|
|
<ul className="divide-y divide-stone-100">
|
|
{rows.map(row => (
|
|
<SourceRowCard
|
|
key={row.key}
|
|
row={row}
|
|
isSyncing={row.connection?.id != null && syncingId === row.connection.id}
|
|
onSync={handleSync}
|
|
/>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
interface SourceRowCardProps {
|
|
row: SourceRow;
|
|
isSyncing: boolean;
|
|
onSync: (conn: ComposioConnection, title: string) => void;
|
|
}
|
|
|
|
function SourceRowCard({ row, isSyncing, onSync }: SourceRowCardProps) {
|
|
const { t } = useT();
|
|
const freshnessLabels = useFreshnessLabel();
|
|
// `buildRows` already filtered down to (connected toolkit + syncable),
|
|
// so `connection` is non-null and `isSyncable` is always true here.
|
|
const { connection, status, title, toolkit } = row;
|
|
if (!connection) return null;
|
|
|
|
const lastSync = status ? relativeTimestamp(status.last_chunk_at_ms) : null;
|
|
const lifetime = status?.chunks_synced ?? 0;
|
|
const pending = status?.chunks_pending ?? 0;
|
|
const batchTotal = status?.batch_total ?? 0;
|
|
const batchProcessed = status?.batch_processed ?? 0;
|
|
const batchPending = batchTotal - batchProcessed;
|
|
const pct = batchTotal > 0 ? Math.round((batchProcessed / batchTotal) * 100) : 0;
|
|
const showProgress = batchTotal > 0 && batchPending > 0;
|
|
const isActive = connection.status === 'ACTIVE' || connection.status === 'CONNECTED';
|
|
|
|
return (
|
|
<li
|
|
className="flex flex-col gap-2 py-3 sm:flex-row sm:items-start sm:justify-between"
|
|
data-testid={`memory-source-row-${toolkit}`}>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className="truncate text-sm font-medium text-stone-900">{title}</span>
|
|
{status && (
|
|
<span
|
|
className={`rounded-md px-2 py-0.5 text-xs font-medium ${freshnessBadge(status.freshness)}`}
|
|
data-testid={`memory-source-freshness-${toolkit}`}>
|
|
{freshnessLabels[status.freshness]}
|
|
</span>
|
|
)}
|
|
{!isActive && (
|
|
<span className="rounded-md bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-700">
|
|
{connection.status}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-stone-500">
|
|
<span data-testid={`memory-source-chunks-${toolkit}`}>
|
|
{lifetime.toLocaleString()} {t('sync.chunks')}
|
|
</span>
|
|
{lastSync && (
|
|
<span>
|
|
{t('sync.lastChunk')} {lastSync}
|
|
</span>
|
|
)}
|
|
{pending > 0 && (
|
|
<span>
|
|
{pending.toLocaleString()} {t('sync.pending')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{showProgress && (
|
|
<div className="mt-2 max-w-md" data-testid={`memory-source-progress-${toolkit}`}>
|
|
<div className="h-1.5 w-full overflow-hidden rounded-full bg-stone-100">
|
|
<div
|
|
className="h-full bg-primary-500 transition-all"
|
|
style={{ width: `${pct}%` }}
|
|
role="progressbar"
|
|
aria-valuenow={batchProcessed}
|
|
aria-valuemin={0}
|
|
aria-valuemax={batchTotal}
|
|
/>
|
|
</div>
|
|
<div className="mt-1 text-xs text-stone-500">
|
|
{batchProcessed.toLocaleString()} / {batchTotal.toLocaleString()}{' '}
|
|
{t('sync.processed')}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => onSync(connection, title)}
|
|
disabled={isSyncing || !isActive}
|
|
data-testid={`memory-source-sync-${toolkit}`}
|
|
className="inline-flex items-center gap-1.5 rounded-md
|
|
bg-primary-500 px-3 py-1.5 text-xs font-semibold text-white
|
|
shadow-sm transition-colors hover:bg-primary-600
|
|
disabled:cursor-not-allowed disabled:opacity-50
|
|
focus:outline-none focus:ring-2 focus:ring-primary-200">
|
|
{isSyncing ? (
|
|
<>
|
|
<Spinner /> {t('sync.syncing')}
|
|
</>
|
|
) : (
|
|
<>
|
|
<SyncIcon /> {t('sync.sync')}
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</li>
|
|
);
|
|
}
|
|
|
|
function SyncIcon() {
|
|
return (
|
|
<svg
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.8"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
aria-hidden="true">
|
|
<path d="M21 12a9 9 0 11-3-6.7" />
|
|
<path d="M21 4v5h-5" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function Spinner() {
|
|
return (
|
|
<svg
|
|
className="animate-spin"
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
aria-hidden="true">
|
|
<circle cx="12" cy="12" r="9" opacity="0.25" />
|
|
<path d="M21 12a9 9 0 00-9-9" />
|
|
</svg>
|
|
);
|
|
}
|