mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-29 14:02:19 +00:00
1226 lines
44 KiB
TypeScript
1226 lines
44 KiB
TypeScript
/**
|
||
* Unified memory sources panel.
|
||
*
|
||
* Single source of truth for **what feeds memory**: folders, GitHub
|
||
* repos, RSS feeds, web pages, Twitter queries, and Composio
|
||
* integrations. Polls `openhuman.memory_sources_status_list` every 5s
|
||
* for per-source chunk counts and freshness. The Sync button on each
|
||
* row dispatches `openhuman.memory_sources_sync` which runs in the
|
||
* background and emits MemorySyncStageChanged events.
|
||
*/
|
||
import debug from 'debug';
|
||
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||
import { useNavigate } from 'react-router-dom';
|
||
|
||
import { useT } from '../../lib/i18n/I18nContext';
|
||
import { CoreStateContext } from '../../providers/coreStateContext';
|
||
import {
|
||
applyAllIn,
|
||
type FreshnessLabel,
|
||
listMemorySources,
|
||
type MemorySourceEntry,
|
||
memorySourcesStatusList,
|
||
removeMemorySource,
|
||
SOURCE_KIND_ICONS,
|
||
SOURCE_KIND_LABEL_KEYS,
|
||
type SourceStatus,
|
||
syncMemorySource,
|
||
updateMemorySource,
|
||
} from '../../services/memorySourcesService';
|
||
import type {
|
||
ConfirmationModal as ConfirmationModalType,
|
||
ToastNotification,
|
||
} from '../../types/intelligence';
|
||
import {
|
||
type MemorySyncSettings,
|
||
openhumanGetMemorySyncSettings,
|
||
openhumanUpdateMemorySyncSettings,
|
||
} from '../../utils/tauriCommands/config';
|
||
import {
|
||
memoryTreeFlushSource,
|
||
memoryTreePipelineStatus,
|
||
type MemoryTreePipelineStatus,
|
||
} from '../../utils/tauriCommands/memoryTree';
|
||
import Button from '../ui/Button';
|
||
import { AddMemorySourceDialog } from './AddMemorySourceDialog';
|
||
import { ConfirmationModal } from './ConfirmationModal';
|
||
import {
|
||
deriveSourcePipelineHealth,
|
||
pipelineIssueMessageKey,
|
||
type SourcePipelineHealth,
|
||
} from './sourcePipelineStatus';
|
||
import { SourceSettingsPanel } from './SourceSettingsPanel';
|
||
|
||
const log = debug('intelligence:memory-sync');
|
||
|
||
interface MemorySourcesRegistryProps {
|
||
onToast?: (toast: Omit<ToastNotification, 'id'>) => void;
|
||
pollIntervalMs?: number;
|
||
}
|
||
|
||
interface SyncProgress {
|
||
stage: string;
|
||
detail: string | null;
|
||
percent: number | null;
|
||
}
|
||
|
||
/**
|
||
* Terminal outcome of a sync run, shown on the row after the `completed` or
|
||
* `failed` stage event arrives (#3295). Persists until the next sync starts,
|
||
* so a no-op ("0 new items") or failed sync leaves visible confirmation
|
||
* instead of the indicator silently vanishing.
|
||
*/
|
||
interface SyncResult {
|
||
kind: 'success' | 'failed';
|
||
/** New items ingested (success only); null when the count is unknown. */
|
||
items: number | null;
|
||
/** Human-readable failure reason (failed only). */
|
||
reason: string | null;
|
||
}
|
||
|
||
/**
|
||
* Per-stage fallback percentages so the progress bar always advances even
|
||
* when no numeric "N/M" ratio is present in the detail string (RC#4, #3295).
|
||
*/
|
||
const STAGE_FALLBACK_PERCENT: Record<string, number> = {
|
||
requested: 2,
|
||
fetching: 5,
|
||
stored: 15,
|
||
queued: 25,
|
||
ingesting: 40,
|
||
completed: 100,
|
||
};
|
||
|
||
/**
|
||
* Parse a sync progress detail string into a 0–100 percent.
|
||
*
|
||
* - Recognises "N/M ..." numeric patterns and returns N/M as a ratio.
|
||
* - Falls back to the per-stage baseline when no ratio is present rather
|
||
* than returning a bogus number (RC#4, issue #3295).
|
||
* - Returns `null` when both approaches are unavailable (no stage either).
|
||
*/
|
||
export function parseSyncProgress(detail: string | null, stage?: string): number | null {
|
||
// Try the numeric "N/M ..." ratio first.
|
||
if (detail) {
|
||
const match = detail.match(/^(\d+)\/(\d+)[\s/]/);
|
||
if (match) {
|
||
const current = parseInt(match[1], 10);
|
||
const total = parseInt(match[2], 10);
|
||
if (total > 0) return Math.round((current / total) * 100);
|
||
}
|
||
}
|
||
// Fall back to the per-stage baseline percentage.
|
||
if (stage && stage in STAGE_FALLBACK_PERCENT) {
|
||
return STAGE_FALLBACK_PERCENT[stage];
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Parse the number of newly-ingested items from a `completed` stage detail
|
||
* string. The backend formats this as `"ingested N item(s)"`
|
||
* (`memory_sources/sync.rs`). Returns `null` when no count is present so the
|
||
* UI can fall back to a generic "synced" confirmation (#3295).
|
||
*/
|
||
export function parseIngestedCount(detail: string | null): number | null {
|
||
if (!detail) return null;
|
||
const match = detail.match(/ingested\s+(\d+)\s+item/i);
|
||
if (match) return parseInt(match[1], 10);
|
||
return null;
|
||
}
|
||
|
||
export function MemorySourcesRegistry({
|
||
onToast,
|
||
pollIntervalMs = 5000,
|
||
}: MemorySourcesRegistryProps) {
|
||
const { t } = useT();
|
||
const navigate = useNavigate();
|
||
// Read the core snapshot directly (not via the throwing `useCoreState`
|
||
// hook) so this component still renders in unit tests that mount it
|
||
// without a CoreStateProvider — there `ctx` is null and `isAuthenticated`
|
||
// stays a stable `false`, so the load effect behaves exactly as before.
|
||
const coreState = useContext(CoreStateContext);
|
||
const isAuthenticated = coreState?.snapshot.auth.isAuthenticated ?? false;
|
||
const [sources, setSources] = useState<MemorySourceEntry[]>([]);
|
||
const [statuses, setStatuses] = useState<SourceStatus[]>([]);
|
||
// Global downstream pipeline health (GH-4690) — the same snapshot the
|
||
// Brain > Memory > Sync panel renders. Folded into each row so a source that
|
||
// ingested but failed embeddings/extraction/tree-build shows a warning rather
|
||
// than a clean synced badge. `null` until the first poll resolves.
|
||
const [pipeline, setPipeline] = useState<MemoryTreePipelineStatus | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [dialogOpen, setDialogOpen] = useState(false);
|
||
// RC#1 (#3295): use a Set so multiple sources can show "syncing" concurrently.
|
||
// Set state is always replaced with a new Set to trigger re-renders.
|
||
const [syncingIds, setSyncingIds] = useState<Set<string>>(new Set());
|
||
const [buildingId, setBuildingId] = useState<string | null>(null);
|
||
const [syncProgress, setSyncProgress] = useState<Map<string, SyncProgress>>(new Map());
|
||
// Terminal per-source result (success/failure) shown after a sync ends (#3295).
|
||
const [syncResults, setSyncResults] = useState<Map<string, SyncResult>>(new Map());
|
||
const [allInModalOpen, setAllInModalOpen] = useState(false);
|
||
const [applyingAllIn, setApplyingAllIn] = useState(false);
|
||
const allInInFlightRef = useRef(false);
|
||
const [expandedSettingsId, setExpandedSettingsId] = useState<string | null>(null);
|
||
|
||
// Refs let the (intentionally dep-free) sync-stage listener fire accurate
|
||
// toasts on the *terminal* event without re-subscribing on every render or
|
||
// 5s poll. The handler must read the latest onToast/sources/t (#3295).
|
||
const onToastRef = useRef(onToast);
|
||
const sourcesRef = useRef(sources);
|
||
const tRef = useRef(t);
|
||
useEffect(() => {
|
||
onToastRef.current = onToast;
|
||
sourcesRef.current = sources;
|
||
tRef.current = t;
|
||
});
|
||
|
||
useEffect(() => {
|
||
const handler = (e: Event) => {
|
||
const data = (e as CustomEvent).detail as {
|
||
stage?: string;
|
||
/** Originating memory-source id (RC#2, #3295). Preferred over connection_id. */
|
||
source_id?: string | null;
|
||
/** Legacy: document/connection id. Still present for backward compat. */
|
||
connection_id?: string | null;
|
||
detail?: string;
|
||
} | null;
|
||
|
||
// RC#2 (#3295): prefer source_id when present; fall back to connection_id for
|
||
// backward compat with older core versions that don't emit source_id yet.
|
||
const rowId = data?.source_id ?? data?.connection_id;
|
||
if (!rowId) return;
|
||
|
||
const stage = data?.stage ?? '';
|
||
|
||
console.debug(
|
||
`[ui-flow][memory-sync] stage=${stage} rowId=${rowId} source_id=${data?.source_id ?? 'absent'} connection_id=${data?.connection_id ?? 'absent'}`
|
||
);
|
||
|
||
if (stage === 'completed' || stage === 'failed') {
|
||
// Clear the live progress bar + syncing flag for this row.
|
||
setSyncProgress(prev => {
|
||
const next = new Map(prev);
|
||
next.delete(rowId);
|
||
return next;
|
||
});
|
||
// RC#1: immutable Set update — remove just this source, keep others syncing.
|
||
setSyncingIds(prev => {
|
||
const next = new Set(prev);
|
||
next.delete(rowId);
|
||
return next;
|
||
});
|
||
|
||
const tt = tRef.current;
|
||
const label = sourcesRef.current.find(s => s.id === rowId)?.label ?? rowId;
|
||
|
||
if (stage === 'completed') {
|
||
// Success: record + toast the item count parsed from the detail
|
||
// ("ingested N item(s)"). 0 new items → "up to date" (#3295).
|
||
const items = parseIngestedCount(data?.detail ?? null);
|
||
setSyncResults(prev => {
|
||
const next = new Map(prev);
|
||
next.set(rowId, { kind: 'success', items, reason: null });
|
||
return next;
|
||
});
|
||
onToastRef.current?.({
|
||
type: 'success',
|
||
title: `${tt('memorySources.sync.completeTitle')} ${label}`,
|
||
message:
|
||
items && items > 0
|
||
? `${items} ${tt('memorySources.sync.itemsSynced')}`
|
||
: tt('memorySources.sync.upToDate'),
|
||
});
|
||
} else {
|
||
// Failure: surface the reason on the row + a toast. The core already
|
||
// reported internal bugs to Sentry via report_error_or_expected.
|
||
const reason = data?.detail ?? null;
|
||
setSyncResults(prev => {
|
||
const next = new Map(prev);
|
||
next.set(rowId, { kind: 'failed', items: null, reason });
|
||
return next;
|
||
});
|
||
onToastRef.current?.({
|
||
type: 'error',
|
||
title: `${tt('memorySources.sync.failedLabel')} · ${label}`,
|
||
message: reason ?? tt('memorySources.sync.failedLabel'),
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Non-terminal stage: a sync is genuinely in progress. Drop any stale
|
||
// terminal result for this row so the live bar replaces the old chip.
|
||
setSyncResults(prev => {
|
||
if (!prev.has(rowId)) return prev;
|
||
const next = new Map(prev);
|
||
next.delete(rowId);
|
||
return next;
|
||
});
|
||
const percent = parseSyncProgress(data?.detail ?? null, stage);
|
||
setSyncProgress(prev => {
|
||
const next = new Map(prev);
|
||
next.set(rowId, { stage, detail: data?.detail ?? null, percent });
|
||
return next;
|
||
});
|
||
// RC#1: ADD this source id to the set (immutable update).
|
||
if (
|
||
stage === 'requested' ||
|
||
stage === 'fetching' ||
|
||
stage === 'stored' ||
|
||
stage === 'queued' ||
|
||
stage === 'ingesting'
|
||
) {
|
||
setSyncingIds(prev => {
|
||
if (prev.has(rowId)) return prev; // no change — avoid re-render
|
||
const next = new Set(prev);
|
||
next.add(rowId);
|
||
return next;
|
||
});
|
||
}
|
||
};
|
||
window.addEventListener('openhuman:memory-sync-stage', handler);
|
||
return () => window.removeEventListener('openhuman:memory-sync-stage', handler);
|
||
}, []);
|
||
|
||
const refresh = useCallback(async () => {
|
||
try {
|
||
const [list, stats, health] = await Promise.all([
|
||
listMemorySources().catch(err => {
|
||
console.warn('[ui-flow][memory-sources] list failed', err);
|
||
return [] as MemorySourceEntry[];
|
||
}),
|
||
memorySourcesStatusList().catch(err => {
|
||
console.warn('[ui-flow][memory-sources] status_list failed', err);
|
||
return [] as SourceStatus[];
|
||
}),
|
||
// GH-4690: downstream pipeline health. Best-effort — a failure here
|
||
// must never hide the source list, so we fall back to `null` (rows then
|
||
// rely on the precise per-source pending-chunk signal alone).
|
||
memoryTreePipelineStatus().catch(err => {
|
||
console.warn('[ui-flow][memory-sources] pipeline_status failed', err);
|
||
return null;
|
||
}),
|
||
]);
|
||
setSources(list);
|
||
setStatuses(stats);
|
||
setPipeline(health);
|
||
// RC#5 (#3295): The 5s poll is the safety net for missed completed/failed events.
|
||
// If a source is in syncingIds but the poll shows it's no longer active (no
|
||
// in-progress status indicator from the server), we clear it here. In practice
|
||
// the event stream covers this; on remount the state rehydrates within ~5s via poll.
|
||
// No new RPC needed — reconciliation is best-effort and relies on the existing poll.
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
// Load on mount AND whenever the session transitions to authenticated.
|
||
// After a page reload the registry can mount (e.g. via a persisted
|
||
// `?tab=memory` deep link) *before* CoreStateProvider has restored the
|
||
// session, so the initial fetch runs against a not-yet-ready core and
|
||
// surfaces nothing. Re-running when `isAuthenticated` flips true picks up
|
||
// sources immediately instead of waiting for the next 5s poll — which
|
||
// under CI load was racing the E2E visibility timeout (#3449).
|
||
useEffect(() => {
|
||
void refresh();
|
||
}, [refresh, isAuthenticated]);
|
||
|
||
useEffect(() => {
|
||
if (!pollIntervalMs) return undefined;
|
||
const id = setInterval(() => {
|
||
void refresh();
|
||
}, pollIntervalMs);
|
||
return () => clearInterval(id);
|
||
}, [pollIntervalMs, refresh]);
|
||
|
||
const statusById = useMemo(() => {
|
||
const m = new Map<string, SourceStatus>();
|
||
for (const s of statuses) m.set(s.source_id, s);
|
||
return m;
|
||
}, [statuses]);
|
||
|
||
// Newest chunk timestamp across every source — the "Last synced …" anchor
|
||
// for the global schedule header. Derived from persisted chunk data, so it
|
||
// survives restarts.
|
||
const overallLastSyncMs = useMemo(() => {
|
||
let newest: number | null = null;
|
||
for (const s of statuses) {
|
||
if (s.last_chunk_at_ms != null && (newest === null || s.last_chunk_at_ms > newest)) {
|
||
newest = s.last_chunk_at_ms;
|
||
}
|
||
}
|
||
return newest;
|
||
}, [statuses]);
|
||
|
||
const handleToggle = useCallback(
|
||
async (source: MemorySourceEntry) => {
|
||
try {
|
||
const updated = await updateMemorySource(source.id, { enabled: !source.enabled });
|
||
setSources(prev => prev.map(s => (s.id === updated.id ? updated : s)));
|
||
} catch (err) {
|
||
onToast?.({
|
||
type: 'error',
|
||
title: t('memorySources.toggleFailed'),
|
||
message: err instanceof Error ? err.message : String(err),
|
||
});
|
||
}
|
||
},
|
||
[onToast, t]
|
||
);
|
||
|
||
const handleRemove = useCallback(
|
||
async (source: MemorySourceEntry) => {
|
||
try {
|
||
await removeMemorySource(source.id);
|
||
setSources(prev => prev.filter(s => s.id !== source.id));
|
||
onToast?.({ type: 'success', title: t('memorySources.removed'), message: source.label });
|
||
} catch (err) {
|
||
onToast?.({
|
||
type: 'error',
|
||
title: t('memorySources.removeFailed'),
|
||
message: err instanceof Error ? err.message : String(err),
|
||
});
|
||
}
|
||
},
|
||
[onToast, t]
|
||
);
|
||
|
||
const handleSync = useCallback(
|
||
async (source: MemorySourceEntry) => {
|
||
// RC#1 (#3295): add immediately on click — event will also fire, but this
|
||
// ensures the row lights up before the first sync-stage event arrives.
|
||
setSyncingIds(prev => {
|
||
const next = new Set(prev);
|
||
next.add(source.id);
|
||
return next;
|
||
});
|
||
// A fresh sync is starting — drop any prior terminal result chip (#3295).
|
||
setSyncResults(prev => {
|
||
if (!prev.has(source.id)) return prev;
|
||
const next = new Map(prev);
|
||
next.delete(source.id);
|
||
return next;
|
||
});
|
||
console.debug(`[ui-flow][memory-sync] manual sync triggered source_id=${source.id}`);
|
||
try {
|
||
await syncMemorySource(source.id);
|
||
// NOTE: success/failure feedback is intentionally NOT fired here. This
|
||
// RPC returns in ~4ms after merely *spawning* the background sync; the
|
||
// real outcome arrives via the terminal `completed`/`failed` stage event
|
||
// (handled above), which carries the item count / failure reason (#3295).
|
||
void refresh();
|
||
} catch (err) {
|
||
// The RPC call itself failed (transport/validation) — the background
|
||
// sync never started, so no stage event will arrive. Surface it here:
|
||
// clear the syncing flag and record a failed result on the row.
|
||
const reason = err instanceof Error ? err.message : String(err);
|
||
setSyncingIds(prev => {
|
||
const next = new Set(prev);
|
||
next.delete(source.id);
|
||
return next;
|
||
});
|
||
setSyncResults(prev => {
|
||
const next = new Map(prev);
|
||
next.set(source.id, { kind: 'failed', items: null, reason });
|
||
return next;
|
||
});
|
||
onToast?.({
|
||
type: 'error',
|
||
title: `${t('memorySources.sync.failedLabel')} · ${source.label}`,
|
||
message: reason,
|
||
});
|
||
}
|
||
// No `finally` clear: on success the row stays "syncing" until the
|
||
// terminal stage event arrives (the sync is still running in the
|
||
// background). Clearing here is what made the indicator vanish in ~4ms.
|
||
},
|
||
[onToast, refresh, t]
|
||
);
|
||
|
||
const handleBuild = useCallback(
|
||
async (source: MemorySourceEntry) => {
|
||
const scope = sourceTreeScope(source);
|
||
if (!scope) return;
|
||
setBuildingId(source.id);
|
||
try {
|
||
const resp = await memoryTreeFlushSource(scope);
|
||
onToast?.({
|
||
type: 'success',
|
||
title: t('memorySources.build.successTitle'),
|
||
message: `${resp.seals_fired} ${t('memorySources.build.sealsMessage')}`,
|
||
});
|
||
} catch (err) {
|
||
onToast?.({
|
||
type: 'error',
|
||
title: t('memorySources.build.failedTitle'),
|
||
message: err instanceof Error ? err.message : String(err),
|
||
});
|
||
} finally {
|
||
setBuildingId(prev => (prev === source.id ? null : prev));
|
||
}
|
||
},
|
||
[onToast, t]
|
||
);
|
||
|
||
const handleAdded = useCallback(
|
||
(source: MemorySourceEntry) => {
|
||
setSources(prev => [...prev, source]);
|
||
onToast?.({ type: 'success', title: t('memorySources.added'), message: source.label });
|
||
void refresh();
|
||
},
|
||
[onToast, refresh, t]
|
||
);
|
||
|
||
const handleConfirmAllIn = useCallback(async () => {
|
||
if (allInInFlightRef.current) return;
|
||
allInInFlightRef.current = true;
|
||
setApplyingAllIn(true);
|
||
try {
|
||
const result = await applyAllIn();
|
||
setSources(result.sources);
|
||
onToast?.({ type: 'success', title: t('memorySources.allIn.success') });
|
||
} catch (err) {
|
||
onToast?.({
|
||
type: 'error',
|
||
title: t('memorySources.allIn.failed'),
|
||
message: err instanceof Error ? err.message : String(err),
|
||
});
|
||
} finally {
|
||
allInInFlightRef.current = false;
|
||
setApplyingAllIn(false);
|
||
setAllInModalOpen(false);
|
||
}
|
||
}, [onToast, t]);
|
||
|
||
const handleSettingsSaved = useCallback((updated: MemorySourceEntry) => {
|
||
setSources(prev => prev.map(s => (s.id === updated.id ? updated : s)));
|
||
}, []);
|
||
|
||
const handleToggleSettings = useCallback((sourceId: string) => {
|
||
setExpandedSettingsId(prev => (prev === sourceId ? null : sourceId));
|
||
}, []);
|
||
|
||
const allInModal: ConfirmationModalType = {
|
||
isOpen: allInModalOpen,
|
||
title: t('memorySources.allIn.title'),
|
||
message: t('memorySources.allIn.message'),
|
||
confirmText: t('memorySources.allIn.confirm'),
|
||
cancelText: t('memorySources.allIn.cancel'),
|
||
destructive: false,
|
||
onConfirm: () => {
|
||
void handleConfirmAllIn();
|
||
},
|
||
onCancel: () => {
|
||
setAllInModalOpen(false);
|
||
},
|
||
};
|
||
|
||
return (
|
||
<section className="rounded-lg border border-line bg-surface p-4" data-testid="memory-sources">
|
||
<header className="mb-3 flex items-center justify-between gap-2">
|
||
<h3 className="text-sm font-semibold text-content-secondary">{t('memorySources.title')}</h3>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => setAllInModalOpen(true)}
|
||
disabled={applyingAllIn}
|
||
data-testid="all-in-button"
|
||
className="inline-flex items-center gap-1 rounded-md border border-primary-300
|
||
bg-surface px-3 py-1.5 text-xs font-semibold text-primary-600
|
||
shadow-sm transition-colors hover:bg-primary-50
|
||
disabled:cursor-not-allowed disabled:opacity-50
|
||
dark:border-primary-500/30 dark:bg-surface dark:text-primary-400
|
||
dark:hover:bg-primary-500/10
|
||
focus:outline-none focus:ring-2 focus:ring-primary-200">
|
||
<AllInIcon />
|
||
{t('memorySources.allIn.button')}
|
||
</button>
|
||
<Button
|
||
variant="primary"
|
||
size="sm"
|
||
onClick={() => setDialogOpen(true)}
|
||
leadingIcon={<PlusIcon />}>
|
||
{t('memorySources.addSource')}
|
||
</Button>
|
||
</div>
|
||
</header>
|
||
|
||
<MemorySyncSchedule lastSyncMs={overallLastSyncMs} onToast={onToast} />
|
||
|
||
{loading ? (
|
||
<p className="text-xs text-content-muted">{t('common.loading')}</p>
|
||
) : sources.length === 0 ? (
|
||
<p className="text-xs text-content-muted">{t('memorySources.empty')}</p>
|
||
) : (
|
||
<ul className="divide-y divide-line-subtle dark:divide-neutral-800">
|
||
{sources.map(source => (
|
||
<SourceRow
|
||
key={source.id}
|
||
source={source}
|
||
status={statusById.get(source.id) ?? null}
|
||
pipeline={pipeline}
|
||
isAuthenticated={isAuthenticated}
|
||
isSyncing={syncingIds.has(source.id) || syncProgress.has(source.id)}
|
||
isBuilding={buildingId === source.id}
|
||
progress={syncProgress.get(source.id) ?? null}
|
||
result={syncResults.get(source.id) ?? null}
|
||
settingsExpanded={expandedSettingsId === source.id}
|
||
onToggle={handleToggle}
|
||
onRemove={handleRemove}
|
||
onSync={handleSync}
|
||
onBuild={handleBuild}
|
||
onToggleSettings={handleToggleSettings}
|
||
onSettingsSaved={handleSettingsSaved}
|
||
onToast={onToast}
|
||
onViewHealth={() => {
|
||
console.debug('[ui-flow][memory-sources] view memory health from source row');
|
||
navigate('/brain?tab=sync');
|
||
}}
|
||
onSignIn={() => {
|
||
console.debug(
|
||
'[ui-flow][memory-sources] sign-in prompt from stored-without-vectors'
|
||
);
|
||
navigate('/auth');
|
||
}}
|
||
/>
|
||
))}
|
||
</ul>
|
||
)}
|
||
|
||
<AddMemorySourceDialog
|
||
open={dialogOpen}
|
||
onClose={() => setDialogOpen(false)}
|
||
onAdded={handleAdded}
|
||
/>
|
||
|
||
{allInModalOpen && (
|
||
<ConfirmationModal modal={allInModal} onClose={() => setAllInModalOpen(false)} />
|
||
)}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
/** Manual-only sentinel — stored as `sync_interval_secs = 0`. */
|
||
const MANUAL_INTERVAL_SECS = 0;
|
||
/** Preset cadences offered in the UI (seconds): 4h / 12h / 24h. */
|
||
const SYNC_INTERVAL_PRESETS_SECS = [14_400, 43_200, 86_400];
|
||
|
||
/** Human label for a cadence ("Every 4h" / "Every 30m" / "Manual only"). */
|
||
function intervalChipLabel(secs: number, t: (k: string) => string): string {
|
||
if (secs === MANUAL_INTERVAL_SECS) return t('memorySyncInterval.manual');
|
||
if (secs % 3600 === 0) {
|
||
return t('memorySyncInterval.everyHours').replace('{h}', String(secs / 3600));
|
||
}
|
||
return t('memorySyncInterval.everyMinutes').replace('{m}', String(Math.round(secs / 60)));
|
||
}
|
||
|
||
interface MemorySyncScheduleProps {
|
||
lastSyncMs: number | null;
|
||
onToast?: (toast: Omit<ToastNotification, 'id'>) => void;
|
||
}
|
||
|
||
/**
|
||
* Global memory-sync schedule control (#3302). Presented like a backup
|
||
* schedule: "Last synced … · Sync every …", with preset cadences (4h / 12h /
|
||
* 24h) plus "Manual only". Reads/writes `config_*_memory_sync_settings`.
|
||
*/
|
||
function MemorySyncSchedule({ lastSyncMs, onToast }: MemorySyncScheduleProps) {
|
||
const { t } = useT();
|
||
const [settings, setSettings] = useState<MemorySyncSettings | null>(null);
|
||
const [saving, setSaving] = useState(false);
|
||
|
||
useEffect(() => {
|
||
let active = true;
|
||
const loadSettings = async () => {
|
||
try {
|
||
const resp = await openhumanGetMemorySyncSettings();
|
||
if (active) setSettings(resp.result);
|
||
} catch (err) {
|
||
log('get settings failed: %O', err);
|
||
}
|
||
};
|
||
void loadSettings();
|
||
return () => {
|
||
active = false;
|
||
};
|
||
}, []);
|
||
|
||
const handleSelect = useCallback(
|
||
async (secs: number) => {
|
||
setSaving(true);
|
||
try {
|
||
const resp = await openhumanUpdateMemorySyncSettings({ sync_interval_secs: secs });
|
||
setSettings(resp.result);
|
||
} catch (err) {
|
||
onToast?.({
|
||
type: 'error',
|
||
title: t('memorySyncInterval.saveFailed'),
|
||
message: err instanceof Error ? err.message : String(err),
|
||
});
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
},
|
||
[onToast, t]
|
||
);
|
||
|
||
if (!settings) return null;
|
||
|
||
const lastSync = relativeTimestamp(lastSyncMs, t);
|
||
const currentLabel = settings.is_manual
|
||
? t('memorySyncInterval.manual')
|
||
: intervalChipLabel(settings.selected_secs, t);
|
||
// Option list: backend presets (fall back to the local defaults) + Manual.
|
||
const presetSecs =
|
||
settings.presets && settings.presets.length > 0 ? settings.presets : SYNC_INTERVAL_PRESETS_SECS;
|
||
const options = [...presetSecs, MANUAL_INTERVAL_SECS];
|
||
const selectedSecs = settings.is_manual ? MANUAL_INTERVAL_SECS : settings.selected_secs;
|
||
|
||
return (
|
||
<div
|
||
className="mb-3 rounded-md border border-line bg-surface-muted p-3"
|
||
data-testid="memory-sync-schedule">
|
||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||
<div className="min-w-0">
|
||
<p className="text-xs font-semibold text-content-secondary">
|
||
{t('memorySyncInterval.title')}
|
||
</p>
|
||
<p className="mt-0.5 text-xs text-content-muted">
|
||
<span>
|
||
{t('memorySyncInterval.lastSynced')} {lastSync ?? t('memorySyncInterval.never')}
|
||
</span>
|
||
<span aria-hidden="true"> · </span>
|
||
<span data-testid="memory-sync-current">{currentLabel}</span>
|
||
</p>
|
||
</div>
|
||
<div
|
||
role="radiogroup"
|
||
aria-label={t('memorySyncInterval.title')}
|
||
className="flex flex-wrap items-center gap-1.5">
|
||
{options.map(secs => {
|
||
const isSelected = secs === selectedSecs;
|
||
return (
|
||
<button
|
||
key={secs}
|
||
type="button"
|
||
role="radio"
|
||
aria-checked={isSelected}
|
||
disabled={saving}
|
||
onClick={() => void handleSelect(secs)}
|
||
data-testid={`memory-sync-preset-${secs}`}
|
||
className={`rounded-md border px-2.5 py-1 text-xs font-medium transition-colors
|
||
focus:outline-none focus:ring-2 focus:ring-primary-200
|
||
disabled:cursor-not-allowed disabled:opacity-50 ${
|
||
isSelected
|
||
? 'border-primary-500 bg-primary-50 text-primary-700 dark:border-primary-500/50 dark:bg-primary-500/10 dark:text-primary-300'
|
||
: 'border-line bg-surface text-content-secondary hover:bg-surface-hover'
|
||
}`}>
|
||
{intervalChipLabel(secs, t)}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
interface SourceRowProps {
|
||
source: MemorySourceEntry;
|
||
status: SourceStatus | null;
|
||
/** Global downstream pipeline health (GH-4690); `null` before first poll. */
|
||
pipeline: MemoryTreePipelineStatus | null;
|
||
/** Whether a backend session exists — gates the "Sign in to enable" prompt. */
|
||
isAuthenticated: boolean;
|
||
isSyncing: boolean;
|
||
isBuilding: boolean;
|
||
progress: SyncProgress | null;
|
||
result: SyncResult | null;
|
||
settingsExpanded: boolean;
|
||
onToggle: (source: MemorySourceEntry) => void;
|
||
onRemove: (source: MemorySourceEntry) => void;
|
||
onSync: (source: MemorySourceEntry) => void;
|
||
onBuild: (source: MemorySourceEntry) => void;
|
||
onToggleSettings: (sourceId: string) => void;
|
||
onSettingsSaved: (updated: MemorySourceEntry) => void;
|
||
onToast?: (toast: Omit<ToastNotification, 'id'>) => void;
|
||
/** Navigate to Brain > Memory > Sync (the full memory-health panel). */
|
||
onViewHealth: () => void;
|
||
/** Navigate to sign-in (embeddings need a backend session). */
|
||
onSignIn: () => void;
|
||
}
|
||
|
||
function SourceRow({
|
||
source,
|
||
status,
|
||
pipeline,
|
||
isAuthenticated,
|
||
isSyncing,
|
||
isBuilding,
|
||
progress,
|
||
result,
|
||
settingsExpanded,
|
||
onToggle,
|
||
onRemove,
|
||
onSync,
|
||
onBuild,
|
||
onToggleSettings,
|
||
onSettingsSaved,
|
||
onToast,
|
||
onViewHealth,
|
||
onSignIn,
|
||
}: SourceRowProps) {
|
||
const { t } = useT();
|
||
const icon = SOURCE_KIND_ICONS[source.kind] ?? '📄';
|
||
const kindLabel = t(SOURCE_KIND_LABEL_KEYS[source.kind] ?? source.kind);
|
||
const detail = sourceDetail(source);
|
||
const lastSync = status ? relativeTimestamp(status.last_chunk_at_ms, t) : null;
|
||
|
||
// GH-4690: layered pipeline verdict. Only meaningful in a settled state —
|
||
// during an active sync (`progress`) or right after one (`result`) the row
|
||
// renders live progress / a terminal chip instead, and `chunks_pending` is
|
||
// legitimately transient, so we suppress the warning until things settle.
|
||
const settled = !progress && !result;
|
||
const health: SourcePipelineHealth = settled
|
||
? deriveSourcePipelineHealth(status, pipeline)
|
||
: { state: 'none', issues: [], authRelated: false };
|
||
const ingestedOnly = health.state === 'ingested_only';
|
||
if (ingestedOnly) {
|
||
console.debug(
|
||
`[ui-flow][memory-sources] source=${source.id} ingested-only issues=${health.issues.join(',')}`
|
||
);
|
||
}
|
||
|
||
return (
|
||
<li className="flex flex-col gap-2 py-3" data-testid={`memory-source-row-${source.kind}`}>
|
||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||
<div className="min-w-0 flex-1">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<span className="text-base">{icon}</span>
|
||
<span
|
||
className={`truncate text-sm font-medium ${
|
||
source.enabled ? 'text-content' : 'text-content-faint line-through'
|
||
}`}>
|
||
{source.label}
|
||
</span>
|
||
<span className="rounded-md bg-surface-subtle px-1.5 py-0.5 text-[10px] font-medium text-content-muted">
|
||
{kindLabel}
|
||
</span>
|
||
{status &&
|
||
status.chunks_synced > 0 &&
|
||
(ingestedOnly ? (
|
||
<IngestedOnlyPill sourceId={source.id} />
|
||
) : (
|
||
<FreshnessPill freshness={status.freshness} />
|
||
))}
|
||
</div>
|
||
{detail && <p className="mt-0.5 truncate pl-7 text-xs text-content-faint">{detail}</p>}
|
||
{progress && (
|
||
<div className="mt-2 pl-7">
|
||
<div className="flex items-center gap-2 text-xs text-content-muted">
|
||
<span className="capitalize">{progress.stage}</span>
|
||
{progress.percent !== null && (
|
||
<span className="font-medium text-primary-600 dark:text-primary-400">
|
||
{progress.percent}%
|
||
</span>
|
||
)}
|
||
{progress.detail && (
|
||
<span className="truncate text-content-faint">{progress.detail}</span>
|
||
)}
|
||
</div>
|
||
<div className="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-surface-strong">
|
||
<div
|
||
className="h-full rounded-full bg-primary-500 transition-all duration-300"
|
||
style={{
|
||
width: `${progress.percent ?? STAGE_FALLBACK_PERCENT[progress.stage] ?? 2}%`,
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{!progress && result && (
|
||
<div className="mt-2 pl-7" data-testid={`memory-source-result-${source.id}`}>
|
||
{result.kind === 'success' ? (
|
||
<span className="inline-flex items-center gap-1 rounded-md bg-sage-100 px-2 py-0.5 text-xs font-medium text-sage-700 dark:bg-sage-500/20 dark:text-sage-300">
|
||
<CheckIcon />
|
||
{result.items && result.items > 0
|
||
? `${result.items.toLocaleString()} ${t('memorySources.sync.itemsSynced')}`
|
||
: t('memorySources.sync.upToDate')}
|
||
</span>
|
||
) : (
|
||
<span
|
||
className="inline-flex items-start gap-1 rounded-md bg-coral-50 px-2 py-0.5 text-xs font-medium text-coral-700 dark:bg-coral-500/10 dark:text-coral-300"
|
||
title={result.reason ?? undefined}>
|
||
<WarnIcon />
|
||
<span className="break-words">
|
||
{t('memorySources.sync.failedLabel')}
|
||
{result.reason ? `: ${result.reason}` : ''}
|
||
</span>
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
{!progress &&
|
||
!result &&
|
||
status &&
|
||
(status.chunks_synced > 0 || status.chunks_pending > 0) && (
|
||
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 pl-7 text-xs text-content-muted">
|
||
<span>
|
||
{status.chunks_synced.toLocaleString()} {t('sync.chunks')}
|
||
</span>
|
||
{lastSync && (
|
||
<span>
|
||
{t('sync.lastChunk')} {lastSync}
|
||
</span>
|
||
)}
|
||
{status.chunks_pending > 0 && (
|
||
<span>
|
||
{status.chunks_pending.toLocaleString()} {t('sync.pending')}
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
{ingestedOnly && (
|
||
<div
|
||
className="mt-2 flex flex-col gap-1 rounded-md border border-amber-200 bg-amber-50 px-2.5 py-1.5 pl-7 text-xs text-amber-800 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200"
|
||
data-testid={`memory-source-pipeline-warning-${source.id}`}
|
||
role="status">
|
||
{health.issues.map(issue => (
|
||
<div key={issue} className="flex items-start gap-1.5">
|
||
<WarnIcon />
|
||
<span className="break-words">{t(pipelineIssueMessageKey(issue))}</span>
|
||
</div>
|
||
))}
|
||
<div className="mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-1 pl-[18px]">
|
||
{health.authRelated && !isAuthenticated && (
|
||
<button
|
||
type="button"
|
||
onClick={onSignIn}
|
||
data-testid={`memory-source-signin-${source.id}`}
|
||
className="font-medium text-amber-900 underline underline-offset-2 hover:text-amber-950 dark:text-amber-100 dark:hover:text-white">
|
||
{t('sync.pipeline.signInToEnable')}
|
||
</button>
|
||
)}
|
||
<button
|
||
type="button"
|
||
onClick={onViewHealth}
|
||
data-testid={`memory-source-view-health-${source.id}`}
|
||
className="font-medium text-amber-900 underline underline-offset-2 hover:text-amber-950 dark:text-amber-100 dark:hover:text-white">
|
||
{t('sync.pipeline.viewHealth')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="flex shrink-0 items-center gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => onToggleSettings(source.id)}
|
||
title={t('memorySources.settings.button')}
|
||
data-testid={`memory-source-settings-${source.id}`}
|
||
aria-expanded={settingsExpanded}
|
||
className={`rounded p-1 transition-colors focus:outline-none focus:ring-2 focus:ring-primary-200 ${
|
||
settingsExpanded
|
||
? 'bg-primary-100 text-primary-600 dark:bg-primary-500/20 dark:text-primary-400'
|
||
: 'text-content-faint hover:bg-surface-hover hover:text-content-secondary'
|
||
}`}>
|
||
<GearIcon />
|
||
</button>
|
||
<Button
|
||
variant="primary"
|
||
size="sm"
|
||
onClick={() => onSync(source)}
|
||
disabled={!source.enabled || isSyncing}
|
||
title={t('sync.sync')}
|
||
data-testid={`memory-source-sync-${source.toolkit ?? source.kind}`}
|
||
leadingIcon={isSyncing ? <Spinner /> : <SyncIcon />}>
|
||
{isSyncing ? t('sync.syncing') : t('sync.sync')}
|
||
</Button>
|
||
<button
|
||
type="button"
|
||
onClick={() => onBuild(source)}
|
||
disabled={!source.enabled || isBuilding || isSyncing}
|
||
title={t('memorySources.build.title')}
|
||
className="inline-flex items-center gap-1 rounded-md border border-primary-300
|
||
bg-surface px-3 py-1.5 text-xs font-semibold text-primary-600
|
||
shadow-sm transition-colors hover:bg-primary-50
|
||
disabled:cursor-not-allowed disabled:opacity-50
|
||
dark:border-primary-500/30 dark:bg-surface dark:text-primary-400
|
||
dark:hover:bg-primary-500/10
|
||
focus:outline-none focus:ring-2 focus:ring-primary-200">
|
||
{isBuilding ? <Spinner /> : <BuildIcon />}
|
||
{isBuilding ? t('memorySources.build.building') : t('memorySources.build.title')}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => onToggle(source)}
|
||
title={source.enabled ? t('memorySources.disable') : t('memorySources.enable')}
|
||
className={`relative h-5 w-9 rounded-full transition-colors ${
|
||
source.enabled ? 'bg-primary-500' : 'bg-surface-strong'
|
||
}`}>
|
||
<span
|
||
className={`absolute top-0.5 h-4 w-4 rounded-full bg-surface shadow transition-transform ${
|
||
source.enabled ? 'left-[18px]' : 'left-0.5'
|
||
}`}
|
||
/>
|
||
</button>
|
||
<Button
|
||
iconOnly
|
||
variant="tertiary"
|
||
tone="danger"
|
||
size="xs"
|
||
onClick={() => onRemove(source)}
|
||
title={t('memorySources.remove')}
|
||
aria-label={t('memorySources.remove')}>
|
||
<TrashIcon />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
{settingsExpanded && (
|
||
<SourceSettingsPanel
|
||
source={source}
|
||
syncedCount={status?.chunks_synced}
|
||
onSaved={onSettingsSaved}
|
||
onToast={onToast}
|
||
/>
|
||
)}
|
||
</li>
|
||
);
|
||
}
|
||
|
||
function FreshnessPill({ freshness }: { freshness: FreshnessLabel }) {
|
||
const { t } = useT();
|
||
const label =
|
||
freshness === 'active'
|
||
? t('sync.active')
|
||
: freshness === 'recent'
|
||
? t('sync.recent')
|
||
: t('sync.idle');
|
||
const cls =
|
||
freshness === 'active'
|
||
? 'bg-primary-100 dark:bg-primary-500/20 text-primary-700 dark:text-primary-300'
|
||
: freshness === 'recent'
|
||
? 'bg-sage-100 dark:bg-sage-500/20 text-sage-700 dark:text-sage-300'
|
||
: 'bg-surface-subtle text-content-secondary';
|
||
return <span className={`rounded-md px-2 py-0.5 text-[10px] font-medium ${cls}`}>{label}</span>;
|
||
}
|
||
|
||
/**
|
||
* GH-4690: the "Ingested only" warning pill. Replaces the clean freshness pill
|
||
* when a source ingested chunks but the downstream retrieval pipeline failed
|
||
* (no vectors / extraction / degraded tree). Amber, matching the degraded
|
||
* badges in the Brain > Memory > Sync panel, so raw sync ≠ retrieval-ready is
|
||
* unmistakable at a glance.
|
||
*/
|
||
function IngestedOnlyPill({ sourceId }: { sourceId: string }) {
|
||
const { t } = useT();
|
||
return (
|
||
<span
|
||
className="rounded-md bg-amber-100 px-2 py-0.5 text-[10px] font-medium text-amber-800 dark:bg-amber-500/20 dark:text-amber-200"
|
||
data-testid={`memory-source-ingested-only-${sourceId}`}>
|
||
{t('sync.pipeline.ingestedOnly')}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function relativeTimestamp(epochMs: number | null, t: (k: string) => string): string | null {
|
||
if (epochMs === null) return null;
|
||
const delta = Date.now() - epochMs;
|
||
if (delta < 1000) return t('time.justNow');
|
||
const seconds = Math.floor(delta / 1000);
|
||
if (seconds < 60) return `${seconds}${t('time.secondsAgoSuffix')}`;
|
||
const minutes = Math.floor(seconds / 60);
|
||
if (minutes < 60) return `${minutes}${t('time.minutesAgoSuffix')}`;
|
||
const hours = Math.floor(minutes / 60);
|
||
if (hours < 24) return `${hours}${t('time.hoursAgoSuffix')}`;
|
||
const days = Math.floor(hours / 24);
|
||
return `${days}${t('time.daysAgoSuffix')}`;
|
||
}
|
||
|
||
function sourceTreeScope(source: MemorySourceEntry): string | null {
|
||
if (source.kind === 'github_repo' && source.url) {
|
||
const m = source.url.match(/github\.com\/([^/]+)\/([^/.]+)/);
|
||
if (m) return `github:${m[1]}/${m[2]}`;
|
||
}
|
||
return source.id;
|
||
}
|
||
|
||
function sourceDetail(source: MemorySourceEntry): string | null {
|
||
switch (source.kind) {
|
||
case 'composio': {
|
||
const parts = [source.toolkit, source.connection_id].filter(Boolean);
|
||
return parts.length ? parts.join(' · ') : null;
|
||
}
|
||
case 'folder':
|
||
return source.path ?? null;
|
||
case 'github_repo':
|
||
return source.url ?? null;
|
||
case 'rss_feed':
|
||
return source.url ?? null;
|
||
case 'web_page':
|
||
return source.url ?? null;
|
||
case 'twitter_query':
|
||
return source.query ?? null;
|
||
default:
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function PlusIcon() {
|
||
return (
|
||
<svg
|
||
width="14"
|
||
height="14"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="2"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
aria-hidden="true">
|
||
<path d="M12 5v14M5 12h14" />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
function TrashIcon() {
|
||
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="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6" />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
function BuildIcon() {
|
||
return (
|
||
<svg
|
||
width="12"
|
||
height="12"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="1.8"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
aria-hidden="true">
|
||
<path d="M22 11.08V12a10 10 0 11-5.93-9.14" />
|
||
<path d="M22 4L12 14.01l-3-3" />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
function SyncIcon() {
|
||
return (
|
||
<svg
|
||
width="12"
|
||
height="12"
|
||
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="12"
|
||
height="12"
|
||
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>
|
||
);
|
||
}
|
||
|
||
function GearIcon() {
|
||
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">
|
||
<circle cx="12" cy="12" r="3" />
|
||
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z" />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
function CheckIcon() {
|
||
return (
|
||
<svg
|
||
width="12"
|
||
height="12"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="2.5"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
aria-hidden="true">
|
||
<path d="M20 6L9 17l-5-5" />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
function WarnIcon() {
|
||
return (
|
||
<svg
|
||
width="12"
|
||
height="12"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="2"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
aria-hidden="true"
|
||
className="mt-0.5 shrink-0">
|
||
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
|
||
<path d="M12 9v4M12 17h.01" />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
function AllInIcon() {
|
||
return (
|
||
<svg
|
||
width="12"
|
||
height="12"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="2"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
aria-hidden="true">
|
||
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" />
|
||
</svg>
|
||
);
|
||
}
|