From e5cae8e13d2cdd09194817349ccf3fdebbbf2558 Mon Sep 17 00:00:00 2001 From: Grivn Date: Sat, 17 Oct 2026 22:42:46 +0800 Subject: [PATCH 1/7] refactor(memory-spaces): give each client page its own module pages.tsx held the six pages, the Overview graph and the widgets they share in 1,774 lines. Each page now has its own module (overview-page, explore-page, entities-page, persistence-strategy-dialog, remember-page, list-page), the Overview graph and its layout are in memory-graph.tsx, and the widgets more than one page uses are in widgets.tsx. The code moved verbatim; the declarations a sibling module now imports gained `export`. pages.tsx stays the module src/client.ts exports, re-exporting the same names. The built lib/client.js holds the same statements and runs the same load-time effects in the same order; only its module regions changed. The dialog-footer check reads the modules the code moved to. Co-Authored-By: Claude Opus 5.5 --- .../src/client/entities-page.tsx | 311 +++ .../src/client/explore-page.tsx | 130 ++ .../src/client/list-page.tsx | 48 + .../src/client/memory-graph.tsx | 443 ++++ .../src/client/overview-page.tsx | 496 +++++ .../src/client/pages.tsx | 1782 +---------------- .../client/persistence-strategy-dialog.tsx | 137 ++ .../src/client/remember-page.tsx | 81 + .../src/client/widgets.tsx | 180 ++ tests/client-modal-css.spec.ts | 3 +- 10 files changed, 1836 insertions(+), 1775 deletions(-) create mode 100644 plugins/dsh-mnemon-source-memory-spaces/src/client/entities-page.tsx create mode 100644 plugins/dsh-mnemon-source-memory-spaces/src/client/explore-page.tsx create mode 100644 plugins/dsh-mnemon-source-memory-spaces/src/client/list-page.tsx create mode 100644 plugins/dsh-mnemon-source-memory-spaces/src/client/memory-graph.tsx create mode 100644 plugins/dsh-mnemon-source-memory-spaces/src/client/overview-page.tsx create mode 100644 plugins/dsh-mnemon-source-memory-spaces/src/client/persistence-strategy-dialog.tsx create mode 100644 plugins/dsh-mnemon-source-memory-spaces/src/client/remember-page.tsx create mode 100644 plugins/dsh-mnemon-source-memory-spaces/src/client/widgets.tsx diff --git a/plugins/dsh-mnemon-source-memory-spaces/src/client/entities-page.tsx b/plugins/dsh-mnemon-source-memory-spaces/src/client/entities-page.tsx new file mode 100644 index 000000000..c6a7ca9ba --- /dev/null +++ b/plugins/dsh-mnemon-source-memory-spaces/src/client/entities-page.tsx @@ -0,0 +1,311 @@ +import { css, useT } from './presentation.ts' +import type { JSX } from 'react' +import { useCallback, useEffect, useId, useMemo, useRef, useState, type FormEvent } from 'react' +import { normalizeEntityKey, type EntityMemoriesView, type EntityRelatedView, type EntityView, type Insight, type MemorySpaceCatalog, type MemoryReadSource } from '../contracts.ts' +import type { MemorySpacesPageClient } from './api.ts' +import { useRequestVersion, appearanceClass, message, PageHeader, SectionSpinner, ProgressiveFooter, EmptyState, SearchField } from 'dsh-mnemon/client' +import { InsightCard, ReadSourcePanel, insightKey } from './widgets.tsx' + +/** Show a loading placeholder only when a read outlasts this, so a quick read never flashes one. */ +const ENTITY_PLACEHOLDER_DELAY_MS = 160 +const ENTITY_RAIL_PAGE = 10 +const ENTITY_CARD_PAGE = 6 +/** Memories one entity-memories read returns; more pages load as the list is revealed. */ +const ENTITY_MEMORY_BATCH = 48 +const ENTITY_RELATED_LIMIT = 20 +/** The choice to find related memories lasts for this browser session; each new session starts collapsed. */ +const RELATED_PREFERENCE_KEY = 'dsh-mnemon.memory-spaces.entities.related' + +function readRelatedPreference(): boolean { + try { return globalThis.sessionStorage?.getItem(RELATED_PREFERENCE_KEY) === 'open' } catch { return false } +} + +function writeRelatedPreference(open: boolean): void { + try { + if (open) globalThis.sessionStorage?.setItem(RELATED_PREFERENCE_KEY, 'open') + else globalThis.sessionStorage?.removeItem(RELATED_PREFERENCE_KEY) + } catch { /* Without storage the choice lasts until the page closes. */ } +} + +function useDelayedFlag(active: boolean, delayMs = ENTITY_PLACEHOLDER_DELAY_MS): boolean { + const [shown, setShown] = useState(false) + useEffect(() => { + if (!active) { setShown(false); return } + const timer = setTimeout(() => setShown(true), delayMs) + return () => clearTimeout(timer) + }, [active, delayMs]) + return active && shown +} + +function InsightSkeletons(props: { count: number }): JSX.Element { + return +} + +/** ProgressiveFooter's layout, with a busy button while the next page loads. */ +function EntityFooter(props: { visible: number; total: number; pageSize: number; busy: boolean; onMore: () => void }): JSX.Element | null { + const t = useT() + if (props.total === 0) return null + const remaining = Math.max(0, props.total - props.visible) + return
{t('common.showing', { visible: props.visible, total: props.total })}{remaining > 0 && }
+} + +/** The active spaces from the directory, shown while their entity indexes are read. */ +function pendingEntitySources(catalog: MemorySpaceCatalog): MemoryReadSource[] { + return catalog.items.filter(body => body.active && body.providerEnabled !== false).map(body => ({ + memoryBodyId: body.id, + memoryBodyName: body.name, + providerId: body.provider.id, + providerLabel: body.provider.label, + mode: body.provider.capabilities.entities ? 'entities' : 'unsupported', + status: body.provider.capabilities.entities ? 'loading' : 'unsupported', + itemCount: 0, + })) +} + +export function EntitiesPage(props: { client: MemorySpacesPageClient; revision: number; writeEnabled: boolean; onForget: (insight: Insight) => Promise; onExplore: (query: string) => void }): JSX.Element { + const t = useT() + const { client } = props + const [rail, setRail] = useState(null) + const [railLoading, setRailLoading] = useState(true) + const [railError, setRailError] = useState(null) + const [pendingSources, setPendingSources] = useState([]) + const [entity, setEntity] = useState('') + const [entityFilter, setEntityFilter] = useState('') + const [visibleEntityLimit, setVisibleEntityLimit] = useState(ENTITY_RAIL_PAGE) + const [selected, setSelected] = useState(undefined) + const [memories, setMemories] = useState(null) + const [memoriesLoading, setMemoriesLoading] = useState(false) + const [memoriesError, setMemoriesError] = useState(null) + const [moreLoading, setMoreLoading] = useState(false) + const [visibleMemoryLimit, setVisibleMemoryLimit] = useState(ENTITY_CARD_PAGE) + const [related, setRelated] = useState(null) + const [relatedLoading, setRelatedLoading] = useState(false) + const [relatedError, setRelatedError] = useState(null) + const [visibleRelatedLimit, setVisibleRelatedLimit] = useState(ENTITY_CARD_PAGE) + // Related memories cost a recall per space, so they load only when asked for; the choice is remembered. + const [relatedOpen, setRelatedOpen] = useState(readRelatedPreference) + const relatedOpenRef = useRef(relatedOpen) + // Whether related memories show by themselves, because nothing carries the selection. + const relatedAutoRef = useRef(false) + const relatedId = useId() + const railRequests = useRequestVersion() + const directoryRequests = useRequestVersion() + const memoryRequests = useRequestVersion() + const moreRequests = useRequestVersion() + const relatedRequests = useRequestVersion() + const selectedRef = useRef(undefined) + const revisionRef = useRef(props.revision) + // One id per page view, so its next selection cancels the related read it is still waiting for; other tabs keep theirs. + const [view] = useState(() => globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`) + const split = client.entityMemories !== undefined && client.entityRelated !== undefined + + const loadRail = useCallback(async () => { + const request = railRequests.begin() + setRailLoading(true); setRailError(null) + try { + const view = await client.entities() + if (railRequests.isCurrent(request)) setRail(view) + } catch (reason) { + if (railRequests.isCurrent(request)) setRailError(message(reason)) + } finally { + if (railRequests.isCurrent(request)) setRailLoading(false) + } + }, [client, railRequests]) + + const loadRelated = useCallback((name: string) => { + if (client.entityRelated === undefined) return + const request = relatedRequests.begin() + setRelatedLoading(true); setRelatedError(null) + void client.entityRelated(name, ENTITY_RELATED_LIMIT, view).then(next => { + if (relatedRequests.isCurrent(request)) setRelated(next) + }, (reason: unknown) => { + if (relatedRequests.isCurrent(request)) setRelatedError(message(reason)) + }).finally(() => { + if (relatedRequests.isCurrent(request)) setRelatedLoading(false) + }) + }, [client, relatedRequests, view]) + + /** + * The carrying memories load at once; related memories only when they were asked for, or when + * nothing carries the name. A refresh keeps the current cards until they are replaced. + */ + const loadSelection = useCallback((name: string, refresh: boolean) => { + const memoryRequest = memoryRequests.begin() + relatedRequests.begin() + moreRequests.begin() + setMoreLoading(false) + setRelatedLoading(false); setRelatedError(null) + if (!refresh) { + setMemories(null) + setVisibleMemoryLimit(ENTITY_CARD_PAGE); setVisibleRelatedLimit(ENTITY_CARD_PAGE) + relatedAutoRef.current = false + } + // Related memories from before a refresh stay while shown, until replaced; collapsed, they are read again when opened. + if (!refresh || !(relatedOpenRef.current || relatedAutoRef.current)) setRelated(null) + setMemoriesLoading(true); setMemoriesError(null) + const memoryRead: Promise = split + ? client.entityMemories!(name, 0, ENTITY_MEMORY_BATCH) + : client.entities(name, 20).then(view => ({ entity: view.selected ?? name, total: view.insights.length, offset: 0, items: view.insights, complete: view.complete ?? true, sources: view.sources ?? [] })) + void memoryRead.then(next => { + if (!memoryRequests.isCurrent(memoryRequest)) return + setMemories(next) + // Nothing carries a name the user asked for: related memories are all the page can offer. + const auto = split && next.total === 0 + if (auto && !relatedOpenRef.current) loadRelated(name) + else if (relatedAutoRef.current && !relatedOpenRef.current) setRelated(null) + relatedAutoRef.current = auto + }, (reason: unknown) => { + if (memoryRequests.isCurrent(memoryRequest)) setMemoriesError(message(reason)) + }).finally(() => { + if (memoryRequests.isCurrent(memoryRequest)) setMemoriesLoading(false) + }) + if (split && relatedOpenRef.current) loadRelated(name) + }, [client, loadRelated, memoryRequests, moreRequests, relatedRequests, split]) + + const select = (name: string) => { + const trimmed = name.trim() + if (trimmed === '') return + selectedRef.current = trimmed + setSelected(trimmed) + setEntity(trimmed) + loadSelection(trimmed, false) + } + + // The directory needs no Provider I/O, so the spaces show before their indexes are read. + useEffect(() => { + const request = directoryRequests.begin() + void client.bodyDirectory().then(catalog => { + if (directoryRequests.isCurrent(request)) setPendingSources(pendingEntitySources(catalog)) + }, () => undefined) + }, [client, directoryRequests]) + useEffect(() => { void loadRail() }, [loadRail, props.revision]) + useEffect(() => { + if (revisionRef.current === props.revision) return + revisionRef.current = props.revision + if (selectedRef.current !== undefined) loadSelection(selectedRef.current, true) + }, [loadSelection, props.revision]) + + const showMoreMemories = () => { + const current = memories + if (current === null || moreLoading) return + const nextVisible = visibleMemoryLimit + ENTITY_CARD_PAGE + if (nextVisible <= current.items.length || current.items.length >= current.total || client.entityMemories === undefined) { + setVisibleMemoryLimit(nextVisible) + return + } + const request = moreRequests.begin() + setMoreLoading(true) + void client.entityMemories(current.entity, current.items.length, ENTITY_MEMORY_BATCH).then(page => { + if (!moreRequests.isCurrent(request)) return + setMemories(previous => { + if (previous === null) return previous + const seen = new Set(previous.items.map(insightKey)) + return { ...page, offset: 0, items: [...previous.items, ...page.items.filter(item => !seen.has(insightKey(item)))] } + }) + setVisibleMemoryLimit(nextVisible) + }, (reason: unknown) => { + if (moreRequests.isCurrent(request)) setMemoriesError(message(reason)) + }).finally(() => { + if (moreRequests.isCurrent(request)) setMoreLoading(false) + }) + } + + const findRelated = () => { + relatedOpenRef.current = true + setRelatedOpen(true) + writeRelatedPreference(true) + if (selected !== undefined && related === null && !relatedLoading) loadRelated(selected) + } + const hideRelated = () => { + relatedOpenRef.current = false + setRelatedOpen(false) + writeRelatedPreference(false) + } + + /** A forgotten memory leaves both lists at once; the refresh that follows replaces them. */ + const forget = async (insight: Insight) => { + await props.onForget(insight) + const key = insightKey(insight) + setMemories(current => { + const items = current?.items.filter(item => insightKey(item) !== key) + return current === null || items === undefined || items.length === current.items.length ? current : { ...current, total: Math.max(0, current.total - 1), items } + }) + setRelated(current => current === null ? current : { ...current, items: current.items.filter(item => insightKey(item) !== key) }) + } + + const submit = (event: FormEvent) => { event.preventDefault(); select(entity) } + const items = rail?.items ?? [] + const filterKey = entityFilter.trim().toLocaleLowerCase() + const filteredItems = useMemo(() => filterKey === '' ? items : items.filter(item => item.entity.toLocaleLowerCase().includes(filterKey)), [items, filterKey]) + const visibleEntities = filteredItems.slice(0, visibleEntityLimit) + const selectedKey = selected === undefined ? undefined : normalizeEntityKey(selected) + const railCount = selectedKey === undefined ? undefined : items.find(item => normalizeEntityKey(item.entity) === selectedKey)?.count + const sources = rail?.sources ?? pendingSources + const hasEntityProvider = sources.length === 0 || sources.some(source => source.mode === 'entities' && source.status !== 'unavailable') + const railPlaceholder = useDelayedFlag(rail === null && railLoading) + const memoryPlaceholder = useDelayedFlag(memories === null && memoriesLoading) + // When nothing carries the entity, related memories open by themselves and need no toggle. + const relatedAuto = memories !== null && memories.total === 0 + const relatedShown = split && (relatedOpen || relatedAuto) + const relatedPlaceholder = useDelayedFlag(relatedShown && related === null && relatedLoading) + const railBusy = useDelayedFlag(railLoading) + const detailBusy = useDelayedFlag(memoriesLoading || (relatedShown && relatedLoading)) + const memoryTotal = memories?.total ?? railCount + const visibleMemories = memories?.items.slice(0, visibleMemoryLimit) ?? [] + const visibleRelated = related?.items.slice(0, visibleRelatedLimit) ?? [] + const nothingFound = memories !== null && memories.total === 0 && (!split || (related !== null && related.items.length === 0)) + const card = (insight: Insight) => props.onExplore(insight.content)} /> + + return ( +
+ + + {rail !== null && !hasEntityProvider + ? {t('entities.unsupportedText')} + :
+ +
+ {detailBusy && selected !== undefined && } + {selected === undefined && {t('entities.selectText')}} + {selected !== undefined && nothingFound && {t('entities.emptyText')}} + {selected !== undefined && !nothingFound && <> +
+

{memories?.entity ?? selected}

{t('entities.memoriesHint')}
{memoryTotal !== undefined && {memoryTotal}}
+
+ {memoriesError !== null &&
{memoriesError}
} + {memoryPlaceholder && } + {memories !== null && memories.total === 0 &&

{t('entities.noCarrying')}

} + {visibleMemories.map(card)} + {memories !== null && } +
+
+ {split &&
+
+

{t('entities.relatedTitle')}

{t('entities.relatedHint')}
{relatedShown && related !== null && {related.items.length}}
+ {!relatedAuto && } +
+ +
} + } +
+
} +
+ ) +} diff --git a/plugins/dsh-mnemon-source-memory-spaces/src/client/explore-page.tsx b/plugins/dsh-mnemon-source-memory-spaces/src/client/explore-page.tsx new file mode 100644 index 000000000..5c6c325b7 --- /dev/null +++ b/plugins/dsh-mnemon-source-memory-spaces/src/client/explore-page.tsx @@ -0,0 +1,130 @@ +import { css, useT } from './presentation.ts' +import type { JSX } from 'react' +import { useEffect, useRef, useState, type FormEvent } from 'react' +import { CATEGORIES, type Category, type Insight, type MemoryReadSource } from '../contracts.ts' +import type { MemorySpacesPageClient } from './api.ts' +import { useRequestVersion, message, short, PageHeader, SectionSpinner, ProgressiveFooter, EmptyState, SearchField, SelectField } from 'dsh-mnemon/client' +import { InsightCard, ReadSourcePanel, categoryLabel, insightKey } from './widgets.tsx' + +export interface MemorySpacesPageStatus { defaultRecallLimit?: number } + +export function ExplorePage(props: { client: MemorySpacesPageClient; agentClient: MemorySpacesPageClient; agentAvailable: boolean; status: MemorySpacesPageStatus | null; seed: string; writeEnabled: boolean; onForget: (insight: Insight) => Promise; onRevealElement?(element: HTMLElement): void }): JSX.Element { + const t = useT() + const pageSize = 6 + const [query, setQuery] = useState(props.seed) + const [mode, setMode] = useState<'smart' | 'keyword' | 'basic'>('smart') + const [category, setCategory] = useState('') + const [results, setResults] = useState([]) + const [sources, setSources] = useState([]) + const [searchKind, setSearchKind] = useState<'direct' | 'agent' | null>(null) + const [agentAnswer, setAgentAnswer] = useState<{ answer: string; citations: string[]; runId: string } | null>(null) + /** A citation names the recalled memory it points at, not its identifiers. */ + const citationLabel = (citation: string): string => { + const cited = results.find(result => `${result.memoryBodyId}/${result.id}` === citation || result.id === citation) + return cited === undefined ? short(citation.split('/').at(-1) ?? citation, 12) : short(cited.content, 40) + } + const [searched, setSearched] = useState(false) + const [error, setError] = useState(null) + const [relatedTo, setRelatedTo] = useState<{ insight: Insight; request: number } | null>(null) + const relatedPaneRef = useRef(null) + const [related, setRelated] = useState([]) + const [relatedLoading, setRelatedLoading] = useState(false) + const [visibleResultLimit, setVisibleResultLimit] = useState(pageSize) + const [visibleRelatedLimit, setVisibleRelatedLimit] = useState(pageSize) + const relatedRequests = useRequestVersion() + + useEffect(() => { if (props.seed !== '') setQuery(props.seed) }, [props.seed]) + // Opening Recall with a query runs it once, with the configured recall limit, + // whether a conversation turn or another page sent it. + const ranSeed = useRef('') + useEffect(() => { + if (props.seed === '' || props.status === null || ranSeed.current === props.seed) return + ranSeed.current = props.seed + void runSearch(false, props.seed) + }, [props.seed, props.status]) + useEffect(() => { + if (relatedTo === null || props.onRevealElement === undefined) return + const reveal = () => { + if (relatedPaneRef.current !== null && relatedRequests.isCurrent(relatedTo.request)) props.onRevealElement?.(relatedPaneRef.current) + } + if (typeof window.requestAnimationFrame !== 'function') { reveal(); return } + const frame = window.requestAnimationFrame(reveal) + return () => { if (typeof window.cancelAnimationFrame === 'function') window.cancelAnimationFrame(frame) } + }, [relatedTo, relatedRequests, props.onRevealElement]) + + const runSearch = async (withAgent: boolean, text = query) => { + if (text.trim() === '') return + relatedRequests.begin() + setSearchKind(withAgent ? 'agent' : 'direct'); setSearched(true); setError(null); setRelatedTo(null); setAgentAnswer(null); setVisibleResultLimit(pageSize); setVisibleRelatedLimit(pageSize) + try { + const request = { query: text, mode, ...(category === '' ? {} : { category }), limit: props.status?.defaultRecallLimit ?? 10 } + if (withAgent) { + const response = await props.agentClient.agentSearch(request) + setResults(response.results) + setSources(response.sources ?? []) + setAgentAnswer({ answer: response.answer, citations: response.citations, runId: response.delegation.runId }) + } else { + const response = await props.client.search(request) + setResults(response.results) + setSources(response.sources ?? []) + } + } catch (reason) { + setError(message(reason)); setResults([]); setSources([]); setAgentAnswer(null) + } finally { + setSearchKind(null) + } + } + + const search = (event: FormEvent) => { event.preventDefault(); void runSearch(false) } + const searching = searchKind !== null + + const showRelated = async (insight: Insight) => { + const request = relatedRequests.begin() + setRelatedTo({ insight, request }); setRelated([]); setRelatedLoading(true); setError(null); setVisibleRelatedLimit(pageSize) + try { + const response = await props.client.related(insight.id, insight.memoryBodyId) + if (relatedRequests.isCurrent(request)) setRelated(response) + } catch (reason) { + if (relatedRequests.isCurrent(request)) setError(message(reason)) + } finally { + if (relatedRequests.isCurrent(request)) setRelatedLoading(false) + } + } + + const forget = async (insight: Insight) => { + await props.onForget(insight) + setResults(items => items.filter(item => insightKey(item) !== insightKey(insight))) + setRelated(items => items.filter(item => insightKey(item) !== insightKey(insight))) + if (relatedTo !== null && insightKey(relatedTo.insight) === insightKey(insight)) setRelatedTo(null) + } + const visibleResults = results.slice(0, visibleResultLimit) + const visibleRelated = related.slice(0, visibleRelatedLimit) + + return ( +
+ +
void search(event)}> + setQuery(event.target.value)} placeholder={t('search.placeholder')} /> +
+ ({ value, label: categoryLabel(t, value) }))]} onChange={setCategory} /> + +
+
+ + +
+ {searching && } + {agentAnswer !== null &&
{t('search.agentAnswerHint')}

{t('search.agentAnswer')}

{agentAnswer.answer}

{agentAnswer.citations.length > 0 &&
{t('search.citations')}{agentAnswer.citations.map((citation, index) => {index + 1}. {citationLabel(citation)})}
}
} + {error !== null &&
{error}
} + {!searched && {t('search.startText')}} + {searched && !searching && results.length === 0 && error === null && {t('search.emptyText')}} + {results.length > 0 && ( +
+

{t('search.results')}

{results.length}
{visibleResults.map(insight => void showRelated(item)} />)}{ setVisibleResultLimit(value => value + pageSize)} />}
+ {relatedTo !== null && } +
+ )} +
+
+ ) +} diff --git a/plugins/dsh-mnemon-source-memory-spaces/src/client/list-page.tsx b/plugins/dsh-mnemon-source-memory-spaces/src/client/list-page.tsx new file mode 100644 index 000000000..9315e15a7 --- /dev/null +++ b/plugins/dsh-mnemon-source-memory-spaces/src/client/list-page.tsx @@ -0,0 +1,48 @@ +import { css, useT } from './presentation.ts' +import type { JSX } from 'react' +import { useCallback, useEffect, useState, type FormEvent } from 'react' +import { CATEGORIES, type Category, type Insight, type MemoryListView } from '../contracts.ts' +import type { MemorySpacesPageClient } from './api.ts' +import { message, PageHeader, SectionSpinner, ProgressiveFooter, EmptyState, SearchField, SelectField } from 'dsh-mnemon/client' +import { InsightCard, ReadSourcePanel, categoryLabel, insightKey } from './widgets.tsx' + +export function ListPage(props: { client: MemorySpacesPageClient; revision: number; writeEnabled: boolean; onForget: (insight: Insight) => Promise; onClone: (insight: Insight) => void; onExplore: (query: string) => void }): JSX.Element { + const t = useT() + const pageSize = 12 + const [query, setQuery] = useState('') + const [category, setCategory] = useState('') + const [view, setView] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [visibleLimit, setVisibleLimit] = useState(pageSize) + const [selectedBodyId, setSelectedBodyId] = useState() + + const load = useCallback(async () => { + setLoading(true); setError(null) + try { setView(await props.client.list({ ...(query.trim() === '' ? {} : { query }), ...(category === '' ? {} : { category }), limit: 1000 })) } catch (reason) { setError(message(reason)) } finally { setLoading(false) } + }, [category, props.client, query]) + useEffect(() => { setVisibleLimit(pageSize); void load() }, [pageSize, props.revision]) + const submit = (event: FormEvent) => { event.preventDefault(); setVisibleLimit(pageSize); void load() } + const forget = async (insight: Insight) => { await props.onForget(insight); setView(current => current === null ? current : { ...current, total: Math.max(0, current.total - 1), items: current.items.filter(item => insightKey(item) !== insightKey(insight)) }) } + const filteredItems = view?.items.filter(item => selectedBodyId === undefined || item.memoryBodyId === selectedBodyId) ?? [] + const visibleItems = filteredItems.slice(0, visibleLimit) + const sources = view?.sources ?? [] + const waitingForQuery = query.trim() === '' && sources.some(source => source.status === 'query-required' && (selectedBodyId === undefined || source.memoryBodyId === selectedBodyId)) + const selectBody = (memoryBodyId: string | undefined) => { setSelectedBodyId(memoryBodyId); setVisibleLimit(pageSize) } + + return ( +
+ +
setQuery(event.target.value)} placeholder={t('content.filterPlaceholder')} /> ({ value, label: categoryLabel(t, value) }))]} onChange={setCategory} /> +
{t('content.notice')}
+ + {error !== null &&
{error}
} +
+ {loading && } + {!loading && filteredItems.length === 0 && {t(waitingForQuery ? 'content.queryRequiredText' : 'content.emptyText')}} +
{visibleItems.map(insight => props.onExplore(insight.content)} />)}
+ {view !== null && !loading && setVisibleLimit(value => value + pageSize)} />} +
+
+ ) +} diff --git a/plugins/dsh-mnemon-source-memory-spaces/src/client/memory-graph.tsx b/plugins/dsh-mnemon-source-memory-spaces/src/client/memory-graph.tsx new file mode 100644 index 000000000..a647b9aea --- /dev/null +++ b/plugins/dsh-mnemon-source-memory-spaces/src/client/memory-graph.tsx @@ -0,0 +1,443 @@ +import { css, useT } from './presentation.ts' +import type { JSX } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react' +import { normalizeEntityKey, type MemorySpaceView, type MemoryGraphNode, type MemoryGraphSnapshot } from '../contracts.ts' +import { type MnemonTranslate, short } from 'dsh-mnemon/client' +import { categoryLabel } from './widgets.tsx' + +const GRAPH_WIDTH = 930 + +const GRAPH_HEIGHT = 520 + +const GRAPH_MARGIN_X = 58 + +const GRAPH_MARGIN_Y = 58 + +const CATEGORY_ORDER = ['space', 'entity', 'preference', 'decision', 'fact', 'insight', 'context', 'general'] + +interface GraphPosition { x: number; y: number } + +type GraphPositions = Map + +type GraphLayoutMode = 'natural' | 'uniform' | 'custom' + +function hash(value: string): number { + let result = 2166136261 + for (const char of value) result = Math.imul(result ^ char.charCodeAt(0), 16777619) + return result >>> 0 +} + +export function graphNodeKey(node: MemoryGraphNode): string { + return node.graphId ?? node.id +} + +export function graphNodeKind(node: MemoryGraphNode): NonNullable { + return node.kind ?? 'memory' +} + +function spaceGraphId(id: string): string { + return `space:${id}` +} + +function entityGraphId(entity: string): string { + return `entity:${encodeURIComponent(normalizeEntity(entity))}` +} + +/** The graph and the Entities page count an entity by the same key. */ +const normalizeEntity = normalizeEntityKey + +/** Add routing scopes and entity indexes without issuing another recall. */ +export function enrichMultiSpaceGraph(graph: MemoryGraphSnapshot, bodies: MemorySpaceView[]): MemoryGraphSnapshot { + if (graph.nodes.length === 0) return graph + const memories = graph.nodes.map(node => ({ ...node, kind: 'memory' as const })) + const memoriesBySpace = new Map() + for (const node of memories) { + if (node.memoryBodyId === undefined) continue + memoriesBySpace.set(node.memoryBodyId, [...(memoriesBySpace.get(node.memoryBodyId) ?? []), node]) + } + + const activeSpaces = bodies.filter(body => body.active && ((memoriesBySpace.get(body.id)?.length ?? 0) > 0 || (body.stats?.topEntities.length ?? 0) > 0)) + const spaceNodes: MemoryGraphNode[] = activeSpaces.map(body => ({ + id: body.id, + graphId: spaceGraphId(body.id), + kind: 'space', + category: 'space', + content: body.name, + color: '#22a879', + memoryBodyId: body.id, + memoryBodyName: body.name, + memoryProviderId: body.provider.id, + occurrenceCount: body.stats?.totalInsights ?? memoriesBySpace.get(body.id)?.length ?? 0, + })) + + // Native Mnemon entity edges connect two memories. This overview renders + // entities as first-class nodes, so retaining those edges would falsely make + // a memory-to-memory edge look like an entity-to-memory association. + const edges: MemoryGraphSnapshot['edges'] = graph.edges.filter(edge => edge.type !== 'entity') + for (const body of activeSpaces) { + for (const memory of memoriesBySpace.get(body.id) ?? []) { + edges.push({ sourceId: spaceGraphId(body.id), targetId: graphNodeKey(memory), label: 'scope', color: '#708199', type: 'scope' }) + } + } + + const spacesById = new Map(activeSpaces.map(body => [body.id, body])) + const indexedEntities = new Map() + for (const memory of memories) { + const body = memory.memoryBodyId === undefined ? undefined : spacesById.get(memory.memoryBodyId) + if (body === undefined) continue + const seen = new Set() + for (const rawEntity of memory.entities ?? []) { + const entity = rawEntity.trim() + const key = normalizeEntity(entity) + if (key === '' || seen.has(key)) continue + seen.add(key) + const current = indexedEntities.get(key) + if (current === undefined) indexedEntities.set(key, { entity, memories: [memory], bodies: [body] }) + else { + current.memories.push(memory) + if (!current.bodies.some(candidate => candidate.id === body.id)) current.bodies.push(body) + } + } + } + const entities = [...indexedEntities.values()].sort((left, right) => right.memories.length - left.memories.length || left.entity.localeCompare(right.entity)).slice(0, 24) + const entityNodes: MemoryGraphNode[] = entities.map(item => ({ + id: item.entity, + graphId: entityGraphId(item.entity), + kind: 'entity', + category: 'entity', + content: item.entity, + color: '#2b9db9', + occurrenceCount: item.memories.length, + memoryBodyIds: item.bodies.map(body => body.id), + memoryBodyNames: item.bodies.map(body => body.name), + })) + for (const item of entities) { + const key = entityGraphId(item.entity) + for (const memory of item.memories) edges.push({ sourceId: key, targetId: graphNodeKey(memory), label: item.entity, color: '#22a879', type: 'entity' }) + } + + return { ...graph, nodes: [...spaceNodes, ...entityNodes, ...memories], edges } +} + +export function graphKindLabel(t: MnemonTranslate, node: MemoryGraphNode): string { + const kind = graphNodeKind(node) + return kind === 'space' ? t('graph.kindSpace') : kind === 'entity' ? t('graph.kindEntity') : categoryLabel(t, node.category ?? 'general') +} + +function activeCategoryAnchors(grouped: Map): Map { + const categories = [...grouped.keys()].sort((left, right) => { + const leftIndex = CATEGORY_ORDER.indexOf(left) + const rightIndex = CATEGORY_ORDER.indexOf(right) + return (leftIndex < 0 ? CATEGORY_ORDER.length : leftIndex) - (rightIndex < 0 ? CATEGORY_ORDER.length : rightIndex) + }) + const anchors = new Map() + if (categories.length === 1) { + anchors.set(categories[0]!, { x: GRAPH_WIDTH / 2, y: GRAPH_HEIGHT / 2 }) + return anchors + } + categories.forEach((category, index) => { + const angle = -Math.PI / 2 + (index / categories.length) * Math.PI * 2 + anchors.set(category, { + x: GRAPH_WIDTH / 2 + Math.cos(angle) * Math.min(250, 115 + categories.length * 23), + y: GRAPH_HEIGHT / 2 + Math.sin(angle) * Math.min(165, 78 + categories.length * 15), + }) + }) + return anchors +} + +function clampGraphPosition(position: GraphPosition): GraphPosition { + return { + x: Math.min(GRAPH_WIDTH - GRAPH_MARGIN_X, Math.max(GRAPH_MARGIN_X, position.x)), + y: Math.min(GRAPH_HEIGHT - GRAPH_MARGIN_Y, Math.max(GRAPH_MARGIN_Y, position.y)), + } +} + +function naturalGraphPositions(nodes: MemoryGraphNode[], edges: MemoryGraphSnapshot['edges']): GraphPositions { + const positions: GraphPositions = new Map() + const grouped = new Map() + for (const node of nodes) { + const category = node.category ?? 'general' + grouped.set(category, [...(grouped.get(category) ?? []), node]) + } + const anchors = activeCategoryAnchors(grouped) + for (const [category, items] of grouped) { + const anchor = anchors.get(category) ?? { x: GRAPH_WIDTH / 2, y: GRAPH_HEIGHT / 2 } + items.forEach((node, index) => { + const seed = hash(graphNodeKey(node)) + const angle = index * 2.399963 + ((seed % 37) / 37) * .4 + const radius = items.length === 1 ? 0 : 24 + Math.sqrt(index + 1) * 35 + positions.set(graphNodeKey(node), clampGraphPosition({ x: anchor.x + Math.cos(angle) * radius, y: anchor.y + Math.sin(angle) * radius })) + }) + } + + const velocities = new Map(nodes.map(node => [graphNodeKey(node), { x: 0, y: 0 }])) + const visibleIds = new Set(nodes.map(graphNodeKey)) + const visibleEdges = edges.filter(edge => visibleIds.has(edge.sourceId) && visibleIds.has(edge.targetId)) + for (let iteration = 0; iteration < 150; iteration += 1) { + const cooling = 1 - iteration / 180 + for (let leftIndex = 0; leftIndex < nodes.length; leftIndex += 1) { + const left = nodes[leftIndex]! + const leftPosition = positions.get(graphNodeKey(left))! + const leftVelocity = velocities.get(graphNodeKey(left))! + for (let rightIndex = leftIndex + 1; rightIndex < nodes.length; rightIndex += 1) { + const right = nodes[rightIndex]! + const rightPosition = positions.get(graphNodeKey(right))! + const rightVelocity = velocities.get(graphNodeKey(right))! + let dx = leftPosition.x - rightPosition.x + let dy = leftPosition.y - rightPosition.y + if (dx === 0 && dy === 0) { dx = ((hash(graphNodeKey(left)) % 13) - 6) || 1; dy = ((hash(graphNodeKey(right)) % 11) - 5) || -1 } + const distanceSquared = Math.max(100, dx * dx + dy * dy) + const distance = Math.sqrt(distanceSquared) + const repulsion = Math.min(9, 18_000 / distanceSquared) * cooling + const collision = distance < 66 ? (66 - distance) * .08 : 0 + const force = repulsion + collision + const forceX = (dx / distance) * force + const forceY = (dy / distance) * force + leftVelocity.x += forceX; leftVelocity.y += forceY + rightVelocity.x -= forceX; rightVelocity.y -= forceY + } + } + for (const edge of visibleEdges) { + const source = positions.get(edge.sourceId)! + const target = positions.get(edge.targetId)! + const sourceVelocity = velocities.get(edge.sourceId)! + const targetVelocity = velocities.get(edge.targetId)! + const dx = target.x - source.x + const dy = target.y - source.y + const distance = Math.max(1, Math.hypot(dx, dy)) + const sparseScale = nodes.length <= 3 ? 2 : nodes.length <= 8 ? 1.45 : 1 + const desired = (edge.type === 'scope' ? 138 : edge.type === 'entity' ? 94 : edge.type === 'semantic' ? 118 : 106) * sparseScale + const spring = (distance - desired) * .018 * cooling + const forceX = (dx / distance) * spring + const forceY = (dy / distance) * spring + sourceVelocity.x += forceX; sourceVelocity.y += forceY + targetVelocity.x -= forceX; targetVelocity.y -= forceY + } + for (const node of nodes) { + const key = graphNodeKey(node) + const position = positions.get(key)! + const velocity = velocities.get(key)! + const anchor = anchors.get(node.category ?? 'general') ?? { x: GRAPH_WIDTH / 2, y: GRAPH_HEIGHT / 2 } + velocity.x += (anchor.x - position.x) * .0035 * cooling + (GRAPH_WIDTH / 2 - position.x) * .0008 + velocity.y += (anchor.y - position.y) * .0035 * cooling + (GRAPH_HEIGHT / 2 - position.y) * .0008 + velocity.x = Math.max(-12, Math.min(12, velocity.x * .76)) + velocity.y = Math.max(-12, Math.min(12, velocity.y * .76)) + positions.set(key, clampGraphPosition({ x: position.x + velocity.x, y: position.y + velocity.y })) + } + } + return positions +} + +function uniformGraphPositions(nodes: MemoryGraphNode[]): GraphPositions { + const positions: GraphPositions = new Map() + const ordered = [...nodes].sort((left, right) => { + const categoryDifference = CATEGORY_ORDER.indexOf(left.category ?? 'general') - CATEGORY_ORDER.indexOf(right.category ?? 'general') + return categoryDifference === 0 ? left.id.localeCompare(right.id) : categoryDifference + }) + const columns = Math.max(1, Math.ceil(Math.sqrt(ordered.length * 1.65))) + const rows = Math.max(1, Math.ceil(ordered.length / columns)) + const cellWidth = (GRAPH_WIDTH - GRAPH_MARGIN_X * 2) / columns + const cellHeight = (GRAPH_HEIGHT - GRAPH_MARGIN_Y * 2) / rows + ordered.forEach((node, index) => { + const row = Math.floor(index / columns) + const column = index % columns + const rowLength = Math.min(columns, ordered.length - row * columns) + const rowOffset = (columns - rowLength) * cellWidth / 2 + positions.set(graphNodeKey(node), { + x: GRAPH_MARGIN_X + rowOffset + cellWidth * (column + .5), + y: GRAPH_MARGIN_Y + cellHeight * (row + .5), + }) + }) + return positions +} + +function graphPoint(svg: SVGSVGElement, clientX: number, clientY: number): GraphPosition { + const matrix = svg.getScreenCTM?.() + if (matrix !== null && matrix !== undefined && typeof svg.createSVGPoint === 'function') { + const point = svg.createSVGPoint() + point.x = clientX; point.y = clientY + return clampGraphPosition(point.matrixTransform(matrix.inverse())) + } + const bounds = svg.getBoundingClientRect() + const width = bounds.width || GRAPH_WIDTH + const height = bounds.height || GRAPH_HEIGHT + return clampGraphPosition({ x: (clientX - bounds.left) * GRAPH_WIDTH / width, y: (clientY - bounds.top) * GRAPH_HEIGHT / height }) +} + +export function MemoryGraph(props: { graph: MemoryGraphSnapshot; selectedId?: string | undefined; onSelect: (node: MemoryGraphNode) => void }): JSX.Element { + const t = useT() + const visibleNodes = useMemo(() => { + const spaces = props.graph.nodes.filter(node => graphNodeKind(node) === 'space') + const entities = props.graph.nodes.filter(node => graphNodeKind(node) === 'entity').slice(0, 20) + const memories = props.graph.nodes.filter(node => graphNodeKind(node) === 'memory').slice(0, Math.max(0, 60 - spaces.length - entities.length)) + return [...spaces, ...entities, ...memories].slice(0, 60) + }, [props.graph.nodes]) + const visibleIds = useMemo(() => new Set(visibleNodes.map(graphNodeKey)), [visibleNodes]) + const visibleKinds = useMemo(() => new Map(visibleNodes.map(node => [graphNodeKey(node), graphNodeKind(node)])), [visibleNodes]) + const edges = useMemo(() => { + const priority = new Map([['entity', 0], ['scope', 1], ['causal', 2], ['semantic', 3], ['temporal', 4]]) + return props.graph.edges + .filter(edge => visibleIds.has(edge.sourceId) && visibleIds.has(edge.targetId)) + .map((edge, index) => ({ edge, index })) + .sort((left, right) => (priority.get(left.edge.type ?? 'temporal') ?? 5) - (priority.get(right.edge.type ?? 'temporal') ?? 5) || left.index - right.index) + .slice(0, 180) + .map(({ edge }) => edge) + }, [props.graph.edges, visibleIds]) + const curvedEdges = useMemo(() => { + const groups = new Map() + edges.forEach((edge, index) => { + const key = [edge.sourceId, edge.targetId].sort().join('::') + groups.set(key, [...(groups.get(key) ?? []), index]) + }) + return edges.map((edge, index) => { + const key = [edge.sourceId, edge.targetId].sort().join('::') + const group = groups.get(key) ?? [index] + const groupIndex = group.indexOf(index) + return { edge, offset: (groupIndex - (group.length - 1) / 2) * 12 } + }) + }, [edges]) + const layoutKey = `${visibleNodes.map(node => `${graphNodeKey(node)}:${graphNodeKind(node)}:${node.category ?? 'general'}`).join('|')}::${edges.map(edge => `${edge.sourceId}>${edge.targetId}:${edge.type ?? 'temporal'}`).join('|')}` + const naturalLayout = useMemo(() => naturalGraphPositions(visibleNodes, edges), [layoutKey]) + const [positions, setPositions] = useState(() => naturalLayout) + const [layoutMode, setLayoutMode] = useState('natural') + const positionsRef = useRef(positions) + const animationRef = useRef(null) + const dragRef = useRef<{ nodeId: string; pointerId: number; startX: number; startY: number; moved: boolean } | null>(null) + + const commitPositions = useCallback((next: GraphPositions) => { + positionsRef.current = next + setPositions(next) + }, []) + + const cancelAnimation = useCallback(() => { + if (animationRef.current !== null && typeof window.cancelAnimationFrame === 'function') window.cancelAnimationFrame(animationRef.current) + animationRef.current = null + }, []) + + const animateTo = useCallback((target: GraphPositions, mode: Exclude) => { + cancelAnimation() + setLayoutMode(mode) + if (typeof window.requestAnimationFrame !== 'function') { commitPositions(target); return } + const start = new Map(positionsRef.current) + const startedAt = performance.now() + const tick = (time: number) => { + const progress = Math.min(1, (time - startedAt) / 620) + const eased = 1 - Math.pow(1 - progress, 3) + const next: GraphPositions = new Map() + for (const [id, destination] of target) { + const origin = start.get(id) ?? { x: GRAPH_WIDTH / 2, y: GRAPH_HEIGHT / 2 } + next.set(id, { x: origin.x + (destination.x - origin.x) * eased, y: origin.y + (destination.y - origin.y) * eased }) + } + commitPositions(next) + if (progress < 1) animationRef.current = window.requestAnimationFrame(tick) + else animationRef.current = null + } + animationRef.current = window.requestAnimationFrame(tick) + }, [cancelAnimation, commitPositions]) + + useEffect(() => { animateTo(naturalLayout, 'natural') }, [layoutKey]) + useEffect(() => () => cancelAnimation(), [cancelAnimation]) + + const beginDrag = (event: ReactPointerEvent, nodeId: string) => { + cancelAnimation() + dragRef.current = { nodeId, pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, moved: false } + event.currentTarget.setPointerCapture?.(event.pointerId) + } + const moveDrag = (event: ReactPointerEvent) => { + const drag = dragRef.current + const svg = event.currentTarget.ownerSVGElement + if (drag === null || svg === null || drag.pointerId !== event.pointerId) return + if (!drag.moved && Math.hypot(event.clientX - drag.startX, event.clientY - drag.startY) < 4) return + drag.moved = true + const point = graphPoint(svg, event.clientX, event.clientY) + const next = new Map(positionsRef.current) + next.set(drag.nodeId, point) + commitPositions(next) + setLayoutMode('custom') + } + const endDrag = (event: ReactPointerEvent) => { + const drag = dragRef.current + if (drag === null || drag.pointerId !== event.pointerId) return + const svg = event.currentTarget.ownerSVGElement + if (drag.moved && svg !== null) { + const next = new Map(positionsRef.current) + next.set(drag.nodeId, graphPoint(svg, event.clientX, event.clientY)) + commitPositions(next) + } + dragRef.current = null + event.currentTarget.releasePointerCapture?.(event.pointerId) + if (!drag.moved) { + const node = visibleNodes.find(candidate => graphNodeKey(candidate) === drag.nodeId) + if (node !== undefined) props.onSelect(node) + } + } + const cancelDrag = (event: ReactPointerEvent) => { + if (dragRef.current?.pointerId === event.pointerId) dragRef.current = null + } + const nudge = (nodeId: string, dx: number, dy: number) => { + cancelAnimation() + const current = positionsRef.current.get(nodeId) + if (current === undefined) return + const next = new Map(positionsRef.current) + next.set(nodeId, clampGraphPosition({ x: current.x + dx, y: current.y + dy })) + commitPositions(next) + setLayoutMode('custom') + } + const layoutLabel = t(layoutMode === 'natural' ? 'graph.layoutNatural' : layoutMode === 'uniform' ? 'graph.layoutUniform' : 'graph.layoutCustom') + return ( + <> +
+ {t('graph.draggable', { layout: layoutLabel })} + + +
+ + + + + + + + {curvedEdges.map(({ edge, offset }, index) => { + const source = positions.get(edge.sourceId) ?? naturalLayout.get(edge.sourceId) ?? { x: GRAPH_WIDTH / 2, y: GRAPH_HEIGHT / 2 } + const target = positions.get(edge.targetId) ?? naturalLayout.get(edge.targetId) ?? { x: GRAPH_WIDTH / 2, y: GRAPH_HEIGHT / 2 } + const dx = target.x - source.x + const dy = target.y - source.y + const distance = Math.max(1, Math.hypot(dx, dy)) + const direction = edge.sourceId.localeCompare(edge.targetId) <= 0 ? 1 : -1 + const controlX = (source.x + target.x) / 2 - (dy / distance) * offset * direction + const controlY = (source.y + target.y) / 2 + (dx / distance) * offset * direction + return + })} + {visibleNodes.map((node, index) => { + const nodeKey = graphNodeKey(node) + const position = positions.get(nodeKey) ?? naturalLayout.get(nodeKey) ?? { x: GRAPH_WIDTH / 2, y: GRAPH_HEIGHT / 2 } + const selected = props.selectedId === nodeKey + const showLabel = selected || visibleNodes.length < 22 || index % 3 === 0 + return ( + beginDrag(event, nodeKey)} onPointerMove={moveDrag} onPointerUp={endDrag} onPointerCancel={cancelDrag} onLostPointerCapture={cancelDrag} + onClick={() => props.onSelect(node)} + onKeyDown={event => { + if (event.key === 'Enter' || event.key === ' ') props.onSelect(node) + else if (event.key === 'ArrowLeft') { event.preventDefault(); nudge(nodeKey, -12, 0) } + else if (event.key === 'ArrowRight') { event.preventDefault(); nudge(nodeKey, 12, 0) } + else if (event.key === 'ArrowUp') { event.preventDefault(); nudge(nodeKey, 0, -12) } + else if (event.key === 'ArrowDown') { event.preventDefault(); nudge(nodeKey, 0, 12) } + }}> + {graphNodeKind(node) === 'space' + ? <> + : graphNodeKind(node) === 'entity' + ? <> + : <>} + {(selected || visibleNodes.length <= 12) && graphNodeKind(node) === 'memory' && node.memoryBodyName !== undefined && {short(node.memoryBodyName, 12)}} + {showLabel && {short(node.content.replace(/\s+/gu, ' '), selected ? 34 : visibleNodes.length <= 12 ? 26 : 19)}} + + ) + })} + + + ) +} diff --git a/plugins/dsh-mnemon-source-memory-spaces/src/client/overview-page.tsx b/plugins/dsh-mnemon-source-memory-spaces/src/client/overview-page.tsx new file mode 100644 index 000000000..9b7da9501 --- /dev/null +++ b/plugins/dsh-mnemon-source-memory-spaces/src/client/overview-page.tsx @@ -0,0 +1,496 @@ +import { css, sidebarCss, useT } from './presentation.ts' +import type { JSX } from 'react' +import { useCallback, useEffect, useId, useRef, useState, type FormEvent } from 'react' +import { writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives' +import { MEMORY_SPACE_DESCRIPTION_MAX_LENGTH, MEMORY_SPACE_NAME_MAX_LENGTH, NATIVE_PROVIDER_ID, type MemorySpaceCatalog, type MemorySpaceMetadataUpdate, type MemorySpaceRediscovery, type MemorySpaceView, type MemoryGraphNode, type MemoryGraphSnapshot, type MemoryProviderConnection, type MemoryProviderId } from '../contracts.ts' +import type { MemorySpacesPageClient } from './api.ts' +import { ProviderIcon } from './ProviderIcon.tsx' +import { providerSummary } from './provider-presentation.ts' +import { appearanceClass, useLocale, humanBytes, message, PageHeader, SidebarModal, EmptyState } from 'dsh-mnemon/client' +import { MemoryProviderBadge, ProviderMemoryFields, ReadSourcePanel, categoryLabel, mergeProviderDefaults, nativeSpaceProvider, providerDraftComplete, providerUnavailableKey, readyFirst, readyProviderId, type ProviderDrafts } from './widgets.tsx' +import { MemoryGraph, enrichMultiSpaceGraph, graphKindLabel, graphNodeKey, graphNodeKind } from './memory-graph.tsx' + +/** Full-text popup for a selected graph node whose inspector preview is clamped. */ +function ContentPreview(props: { node: MemoryGraphNode; kind: string; onClose: () => void }): JSX.Element { + const t = useT() + const meta = [props.kind, props.node.id, props.node.memoryBodyName].filter((entry): entry is string => entry !== undefined).join(' · ') + return ( + +

{props.node.content}

+
+ ) +} + +export function OverviewPage(props: { client: MemorySpacesPageClient; metadataClient: MemorySpacesPageClient; revision: number; reloadKey?: number; activationEnabled: boolean; writeEnabled: boolean; agentAvailable: boolean; fallbackBodies: MemorySpaceView[]; fallbackDirectory: string | undefined; catalogKnown: boolean; onMutate: () => void; onAgentRefresh: () => void; onBodyReconnect: (body: MemorySpaceView) => void; onBodyMetadata: (updates: readonly MemorySpaceMetadataUpdate[]) => void; onExplore: (query: string) => void }): JSX.Element { + const t = useT() + const locale = useLocale() + const spaceCreateFormId = useId() + const spaceEditFormId = useId() + const [graph, setGraph] = useState(null) + const [catalog, setCatalog] = useState(null) + const [selected, setSelected] = useState(null) + const [catalogLoading, setCatalogLoading] = useState(true) + const [healthLoading, setHealthLoading] = useState(true) + const [graphLoading, setGraphLoading] = useState(true) + const [error, setError] = useState(null) + const [changing, setChanging] = useState(null) + const [reconnectingSpace, setReconnectingSpace] = useState(null) + const [creating, setCreating] = useState(false) + const [creatingSpaceOpen, setCreatingSpaceOpen] = useState(false) + const [spaceName, setSpaceName] = useState('') + const [spaceDescription, setSpaceDescription] = useState('') + const [spaceProviderId, setSpaceProviderId] = useState(NATIVE_PROVIDER_ID) + const [providerDrafts, setProviderDrafts] = useState({}) + const [catalogUnavailable, setCatalogUnavailable] = useState(false) + const [editingSpace, setEditingSpace] = useState(null) + const [editName, setEditName] = useState('') + const [editDescription, setEditDescription] = useState('') + const [editConnection, setEditConnection] = useState({}) + const [editClearSecrets, setEditClearSecrets] = useState([]) + const [savingSpace, setSavingSpace] = useState(null) + const [confirmingDeleteSpace, setConfirmingDeleteSpace] = useState(null) + const [deletingSpace, setDeletingSpace] = useState(null) + const [preview, setPreview] = useState(null) + const [metadataOpen, setMetadataOpen] = useState(false) + const [metadataSelection, setMetadataSelection] = useState([]) + const [metadataTasks, setMetadataTasks] = useState>({}) + const [lastFullSyncAt, setLastFullSyncAt] = useState(null) + const [rediscovering, setRediscovering] = useState(false) + const [pageRediscovery, setPageRediscovery] = useState(null) + const [seenRediscovery, setSeenRediscovery] = useState(readSeenRediscovery) + const [syncClock, setSyncClock] = useState(() => Date.now()) + const loadRequest = useRef(0) + const initialSyncStarted = useRef(false) + const directoryRetryStarted = useRef(false) + const fullSyncObserved = useRef(true) + const load = useCallback(async (quiet = false) => { + const request = ++loadRequest.current + setCatalogLoading(true) + setHealthLoading(true) + setGraphLoading(true) + setError(null) + let directoryUnavailable = false + try { + // Render the control-plane directory first. Health and graph I/O then + // resolve independently without holding the cards or each other back. + const nextCatalog = await props.client.bodyDirectory().then(next => { setCatalogUnavailable(false); return next }).catch(() => { + directoryUnavailable = true + setCatalogUnavailable(!props.catalogKnown) + return { + items: props.fallbackBodies, + providers: [], + total: props.fallbackBodies.length, + activeCount: props.fallbackBodies.filter(body => body.active).length, + directory: props.fallbackDirectory ?? '', + generatedAt: new Date().toISOString(), + } + }) + if (request !== loadRequest.current) return + setProviderDrafts(current => mergeProviderDefaults(nextCatalog.providers, current)) + setCatalog(nextCatalog) + setCatalogLoading(false) + void props.client.bodies().then(next => { + if (request !== loadRequest.current) return + setCatalog(next) + }).catch(reason => { + if (request === loadRequest.current && !quiet && !directoryUnavailable) setError(message(reason)) + }).finally(() => { if (request === loadRequest.current) setHealthLoading(false) }) + void props.client.graph().then(next => { + if (request !== loadRequest.current) return + const enriched = enrichMultiSpaceGraph(next, nextCatalog.items) + setGraph(enriched) + setSelected(current => current === null ? null : enriched.nodes.find(node => graphNodeKey(node) === graphNodeKey(current)) ?? null) + }).catch(reason => { + if (request === loadRequest.current && !directoryUnavailable) setError(message(reason)) + }).finally(() => { if (request === loadRequest.current) setGraphLoading(false) }) + } catch (reason) { + if (request === loadRequest.current) { + setError(message(reason)) + setCatalogLoading(false) + setHealthLoading(false) + setGraphLoading(false) + } + } + }, [props.catalogKnown, props.client, props.fallbackBodies, props.fallbackDirectory]) + + useEffect(() => { + if (initialSyncStarted.current) return + initialSyncStarted.current = true + void load() + }, [load]) + // The workspace's refresh reloads the whole directory quietly. + const seenReload = useRef(props.reloadKey) + useEffect(() => { + if (seenReload.current === props.reloadKey) return + seenReload.current = props.reloadKey + void load(true) + }, [load, props.reloadKey]) + useEffect(() => { + if (!catalogUnavailable || !props.catalogKnown || directoryRetryStarted.current) return + directoryRetryStarted.current = true + void load(true) + }, [catalogUnavailable, load, props.catalogKnown]) + useEffect(() => { + const timer = window.setInterval(() => setSyncClock(Date.now()), 1_000) + return () => window.clearInterval(timer) + }, []) + // A new space starts with a provider that can take it now. + useEffect(() => { setSpaceProviderId(current => readyProviderId(catalog?.providers ?? [], current) ?? current) }, [catalog]) + // A pass the Host started while this page is open: read the directory again until it ends, then + // show what it found. The read also brings the Source's new revision, so the switches keep working. + const hostRediscovering = catalog?.rediscovery?.running === true && !rediscovering + useEffect(() => { + if (!hostRediscovering) return + let active = true + const timer = window.setInterval(() => { + void props.client.bodyDirectory().then(next => { + if (!active || next.rediscovery?.running === true) return + active = false + window.clearInterval(timer) + void load(true) + if (next.rediscovery?.last?.results.some(result => result.added.length > 0)) props.onMutate() + }).catch(() => {}) + }, REDISCOVERY_POLL_MS) + return () => { active = false; window.clearInterval(timer) } + }, [hostRediscovering, load, props.client, props.onMutate]) + + const toggle = async (body: MemorySpaceView) => { + setChanging(body.id); setError(null) + try { + await props.client.updateBody(body.id, { active: !body.active }) + await load(true) + props.onMutate() + } catch (reason) { setError(message(reason)) } finally { setChanging(null) } + } + + const reconnect = async (body: MemorySpaceView) => { + if (reconnectingSpace !== null || editingSpace !== null || deletingSpace !== null) return + setReconnectingSpace(body.id); setError(null) + setCatalog(current => current === null ? current : { + ...current, + items: current.items.map(item => item.id === body.id ? { ...item, statusLoading: true } : item), + }) + try { + const next = await props.client.reconnectBody(body.id) + setCatalog(current => current === null ? current : { + ...current, + items: current.items.map(item => item.id === next.id ? next : item), + }) + props.onBodyReconnect(next) + } catch (reason) { + const failure = message(reason) + setCatalog(current => current === null ? current : { + ...current, + items: current.items.map(item => item.id === body.id ? { ...item, healthy: false, statusLoading: false, error: failure } : item), + }) + setError(failure) + } finally { + setReconnectingSpace(null) + } + } + + const beginEdit = (body: MemorySpaceView) => { + setEditingSpace(body.id); setEditName(body.name); setEditDescription(body.description ?? ''); setError(null) + setEditConnection(nativeSpaceProvider(body.provider) ? {} : { ...body.provider.settings }) + setEditClearSecrets([]) + } + + const saveEdit = async (event: FormEvent, body: MemorySpaceView) => { + event.preventDefault() + if (editName.trim() === '') return + setSavingSpace(body.id); setError(null) + try { + const descriptor = catalog?.providers.find(provider => provider.id === body.provider.id) + const connection = descriptor === undefined ? {} : Object.fromEntries(Object.entries(editConnection).filter(([key, value]) => { + const field = descriptor.fields.find(candidate => candidate.key === key) + return field?.input !== 'secret' || String(value) !== '' + })) + await props.client.updateBody(body.id, { + name: editName, + description: editDescription, + ...(nativeSpaceProvider(body.provider) ? {} : { connection, ...(editClearSecrets.length === 0 ? {} : { clearSecrets: editClearSecrets }) }), + }) + setEditingSpace(null) + await load(true) + props.onMutate() + } catch (reason) { setError(message(reason)) } finally { setSavingSpace(null) } + } + + const create = async (event: FormEvent) => { + event.preventDefault() + const providers = catalog?.providers ?? [] + const manualProvider = providers.find(provider => provider.id === spaceProviderId) + if (spaceName.trim() === '' || spaceDescription.trim() === '' || !providerDraftComplete(manualProvider, providerDrafts[spaceProviderId])) return + setCreating(true); setError(null) + try { + await props.client.createBody({ + name: spaceName, + description: spaceDescription, + providerId: spaceProviderId, + ...(manualProvider?.origin === 'native' ? {} : { connection: providerDrafts[spaceProviderId] ?? {} }), + }) + setSpaceName(''); setSpaceDescription(''); setSpaceProviderId(current => readyProviderId(providers) ?? current) + setProviderDrafts(current => Object.fromEntries(providers.map(provider => [provider.id, Object.fromEntries(Object.entries(current[provider.id] ?? {}).map(([key, value]) => [key, provider.fields.some(field => field.key === key && field.input === 'secret') ? '' : value]))]))) + setCreatingSpaceOpen(false) + await load(true) + props.onMutate() + } catch (reason) { setError(message(reason)) } finally { setCreating(false) } + } + + // Providers are asked again for namespaces they gained; the new spaces start inactive (#373). + const rediscover = async () => { + if (props.client.rediscoverBodies === undefined || rediscovering) return + setRediscovering(true); setError(null) + try { + const outcome = await props.client.rediscoverBodies() + setPageRediscovery(outcome) + await load(true) + if (outcome.results.some(result => result.added.length > 0)) props.onMutate() + } catch (reason) { setError(message(reason)) } finally { setRediscovering(false) } + } + const dismissRediscovery = (at: string) => { + setSeenRediscovery(at) + writeSeenRediscovery(at) + } + + const deleteBody = async (body: MemorySpaceView) => { + setDeletingSpace(body.id); setError(null) + try { + await props.client.deleteBody(body.id) + setConfirmingDeleteSpace(null) + await load(true) + props.onMutate() + } catch (reason) { setError(message(reason)) } finally { setDeletingSpace(null) } + } + + const maintainMetadata = () => { + if (metadataSelection.length === 0) return + const selectedIds = metadataSelection.filter(id => metadataTasks[id]?.status !== 'running') + if (selectedIds.length === 0) return + setError(null) + setMetadataSelection([]) + setMetadataTasks(current => ({ + ...current, + ...Object.fromEntries(selectedIds.map(id => [id, { status: 'running' as const }])), + })) + for (const id of selectedIds) { + void props.metadataClient.maintainBodyMetadata([id]).then(result => { + const update = result.updates.find(candidate => candidate.memoryBodyId === id) + if (update === undefined) throw new Error(`metadata task Agent omitted Memory Space ${id}`) + setCatalog(current => current === null ? current : { + ...current, + items: current.items.map(body => body.id === id ? { ...body, name: update.title, description: update.description } : body), + }) + props.onBodyMetadata([update]) + setMetadataTasks(current => ({ ...current, [id]: { status: 'success' } })) + }).catch(reason => { + setMetadataTasks(current => ({ ...current, [id]: { status: 'error', error: message(reason) } })) + }) + } + } + + const generated = graph === null ? t('overview.waitingSnapshot') : t('overview.updatedAt', { time: new Date(graph.generatedAt).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', second: '2-digit' }) }) + const graphSpaces = graph?.nodes.filter(node => graphNodeKind(node) === 'space').length ?? 0 + const graphEntities = graph?.nodes.filter(node => graphNodeKind(node) === 'entity').length ?? 0 + const graphMemories = graph?.nodes.filter(node => graphNodeKind(node) === 'memory').length ?? 0 + const graphSources = graph?.sources ?? [] + const onlyQueryOrUnsupported = graphSources.length > 0 && graphSources.every(source => source.mode === 'query-only' || source.mode === 'unsupported' || source.status === 'unavailable') + const selectedKind = selected === null ? null : graphNodeKind(selected) + const editingSpaceView = editingSpace === null ? undefined : catalog?.items.find(body => body.id === editingSpace) + const deletingSpaceView = confirmingDeleteSpace === null ? undefined : catalog?.items.find(body => body.id === confirmingDeleteSpace) + const providers = catalog?.providers ?? [] + // The status summary already carries the non-blocking control-plane + // directory. Keep metadata maintenance usable while the richer Memory page + // health/graph requests are still resolving. + const metadataCandidates = (catalog?.items ?? props.fallbackBodies).filter(body => body.active && body.providerEnabled !== false) + const metadataRunningCount = Object.values(metadataTasks).filter(task => task.status === 'running').length + const metadataBusy = metadataRunningCount > 0 + const metadataSelectable = metadataCandidates.filter(body => metadataTasks[body.id]?.status !== 'running') + const metadataAllSelected = metadataSelectable.length > 0 && metadataSelectable.every(body => metadataSelection.includes(body.id)) + const loading = catalogLoading || healthLoading || graphLoading + useEffect(() => { + if (loading) { + fullSyncObserved.current = true + return + } + if (!fullSyncObserved.current) return + fullSyncObserved.current = false + if (error !== null) return + const completedAt = Date.now() + setLastFullSyncAt(completedAt) + setSyncClock(completedAt) + }, [error, loading]) + const fullSyncAge = lastFullSyncAt === null + ? t('overview.fullSyncPending') + : (() => { + const seconds = Math.max(0, Math.floor((syncClock - lastFullSyncAt) / 1_000)) + if (seconds < 5) return t('overview.fullSyncJustNow') + if (seconds < 60) return t('overview.fullSyncSeconds', { count: seconds }) + const minutes = Math.floor(seconds / 60) + if (minutes < 60) return t('overview.fullSyncMinutes', { count: minutes }) + const hours = Math.floor(minutes / 60) + if (hours < 24) return t('overview.fullSyncHours', { count: hours }) + return t('overview.fullSyncDays', { count: Math.floor(hours / 24) }) + })() + const selectedProvider = providers.find(provider => provider.id === spaceProviderId) + // The latest pass: one started here answers even when it found nothing; one from start-up + // speaks only when it has news. + const directoryRediscovery = catalog?.rediscovery?.last ?? null + const rediscovery = pageRediscovery !== null && (directoryRediscovery === null || pageRediscovery.at >= directoryRediscovery.at) ? pageRediscovery : directoryRediscovery + const rediscoveryAdded = rediscovery?.results.flatMap(result => result.added) ?? [] + const rediscoveryDisconnected = rediscovery?.results.reduce((sum, result) => sum + result.disconnected, 0) ?? 0 + const rediscoveryFailures = rediscovery?.results.filter(result => result.error !== undefined) ?? [] + const rediscoveryShown = rediscovery !== null && rediscovery.at !== seenRediscovery + && (rediscovery === pageRediscovery || rediscoveryAdded.length > 0 || rediscoveryFailures.length > 0) + const newSpaceIds = new Set(rediscoveryShown ? rediscoveryAdded.map(body => body.id) : []) + const canRediscover = props.writeEnabled && !catalogUnavailable && props.client.rediscoverBodies !== undefined + && providers.some(provider => provider.origin !== 'native' && provider.serviceConfigured === true) + const rediscoveryRunning = rediscovering || catalog?.rediscovery?.running === true + const nativeSpaceCount = catalog?.items.filter(body => nativeSpaceProvider(body.provider)).length ?? 0 + const canDeleteSpace = (body: MemorySpaceView): boolean => !nativeSpaceProvider(body.provider) || nativeSpaceCount > 1 + const updateProviderDraft = (providerId: MemoryProviderId, key: string, value: string | number | boolean) => setProviderDrafts(current => ({ ...current, [providerId]: { ...(current[providerId] ?? {}), [key]: value } })) + const placementReceipt = (body: MemorySpaceView) => body.placement === undefined ? null :
{t(body.placement.decidedBy === 'llm' ? 'overview.placementByLlm' : 'overview.placementByRules')}{t('overview.placementConfidence', { confidence: t(`overview.confidence.${body.placement.confidence}`) })}

{body.placement.reason}

+ const spaceEditForm = (body: MemorySpaceView) =>
void saveEdit(event, body)}> + +