/** * Platform context: recollections list and handlers for create/delete/rename/updateLastEdited/reorder/restore. * Used by AppSidebar, RecollectionsPage, and canvas route. */ import React, { createContext, useCallback, useContext, useMemo, useState } from 'react' import type { Recollection } from './types' import { saveGraphToStorage, RECOLLECTION_VERSION } from '@/app/recollections/recollectionGraphStorage' const STORAGE_KEY = 'zui_platform_recollections' const ORDER_STORAGE_KEY = 'zui_platform_recollection_order' const RECENT_STORAGE_KEY = 'zui_platform_recent_recollection_ids' const LEGACY_STORAGE_KEY = 'zui_platform_emanations' const LEGACY_ORDER_STORAGE_KEY = 'zui_platform_emanation_order' const LEGACY_RECENT_STORAGE_KEY = 'zui_platform_recent_emanation_ids' const CANVAS_MINIMAP_KEY = 'zui_canvas_show_minimap' const AI_CONNECTION_KEY = 'zui_ai_connection' const RECENT_MAX = 5 export type AiConnectionProvider = 'openai' | 'local' export type AiConnection = { provider: AiConnectionProvider baseURL: string model: string apiKey: string } const DEFAULT_AI_CONNECTION: AiConnection = { provider: 'local', baseURL: 'http://localhost:1234/v1', model: 'local-model', apiKey: '', } function loadAiConnection(): AiConnection { try { const raw = localStorage.getItem(AI_CONNECTION_KEY) if (!raw) return DEFAULT_AI_CONNECTION const parsed = JSON.parse(raw) as unknown if (!parsed || typeof parsed !== 'object') return DEFAULT_AI_CONNECTION const p = parsed as Record return { provider: p.provider === 'openai' ? 'openai' : 'local', baseURL: typeof p.baseURL === 'string' ? p.baseURL : DEFAULT_AI_CONNECTION.baseURL, model: typeof p.model === 'string' ? p.model : DEFAULT_AI_CONNECTION.model, apiKey: typeof p.apiKey === 'string' ? p.apiKey : '', } } catch { return DEFAULT_AI_CONNECTION } } function saveAiConnection(value: AiConnection) { try { localStorage.setItem(AI_CONNECTION_KEY, JSON.stringify(value)) } catch {} } function loadShowMinimap(): boolean { try { const raw = localStorage.getItem(CANVAS_MINIMAP_KEY) if (raw === 'true') return true if (raw === 'false') return false } catch {} return false } function saveShowMinimap(value: boolean) { try { localStorage.setItem(CANVAS_MINIMAP_KEY, JSON.stringify(value)) } catch {} } function loadOrder(): string[] { try { let raw = localStorage.getItem(ORDER_STORAGE_KEY) if (!raw) { const legacy = localStorage.getItem(LEGACY_ORDER_STORAGE_KEY) if (legacy) { localStorage.setItem(ORDER_STORAGE_KEY, legacy) localStorage.removeItem(LEGACY_ORDER_STORAGE_KEY) raw = legacy } else return [] } const parsed = JSON.parse(raw) as unknown return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : [] } catch { return [] } } function saveOrder(ids: string[]) { localStorage.setItem(ORDER_STORAGE_KEY, JSON.stringify(ids)) } function loadRecentIds(): string[] { try { let raw = localStorage.getItem(RECENT_STORAGE_KEY) if (!raw) { const legacy = localStorage.getItem(LEGACY_RECENT_STORAGE_KEY) if (legacy) { localStorage.setItem(RECENT_STORAGE_KEY, legacy) localStorage.removeItem(LEGACY_RECENT_STORAGE_KEY) raw = legacy } else return [] } const parsed = JSON.parse(raw) as unknown return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string').slice(0, RECENT_MAX) : [] } catch { return [] } } function saveRecentIds(ids: string[]) { localStorage.setItem(RECENT_STORAGE_KEY, JSON.stringify(ids)) } function loadRecollections(): Recollection[] { try { let raw = localStorage.getItem(STORAGE_KEY) if (!raw) { const legacy = localStorage.getItem(LEGACY_STORAGE_KEY) if (legacy) { localStorage.setItem(STORAGE_KEY, legacy) localStorage.removeItem(LEGACY_STORAGE_KEY) raw = legacy } else return [] } const parsed = JSON.parse(raw) as unknown if (!Array.isArray(parsed)) return [] return parsed .filter( (p): p is Recollection => p && typeof p === 'object' && typeof (p as Recollection).id === 'string' && typeof (p as Recollection).name === 'string' && typeof (p as Recollection).iconId === 'string' && typeof (p as Recollection).createdAt === 'number' ) .map((p) => ({ ...p, lastEditedAt: typeof (p as Recollection).lastEditedAt === 'number' ? (p as Recollection).lastEditedAt : p.createdAt, })) } catch { return [] } } function saveRecollections(recollections: Recollection[]) { localStorage.setItem(STORAGE_KEY, JSON.stringify(recollections)) } export type GraphSnapshot = { nodes: unknown[]; edges: unknown[] } /** Recollections sorted by recollectionOrder, then by lastEditedAt desc for any not in order */ export function sortRecollectionsByOrder(recollections: Recollection[], order: string[]): Recollection[] { const byId = new Map(recollections.map((p) => [p.id, p])) const ordered: Recollection[] = [] const seen = new Set() for (const id of order) { const p = byId.get(id) if (p) { ordered.push(p) seen.add(id) } } const rest = recollections .filter((p) => !seen.has(p.id)) .sort((a, b) => (b.lastEditedAt ?? b.createdAt) - (a.lastEditedAt ?? a.createdAt)) return [...ordered, ...rest] } export type KosmosContextValue = { recollections: Recollection[] recollectionOrder: string[] /** Recollections in display order (sidebar order, then lastEditedAt) */ orderedRecollections: Recollection[] /** Last RECENT_MAX accessed recollection IDs (most recent first) */ recentRecollectionIds: string[] recordRecollectionAccess: (id: string) => void persist: (next: Recollection[]) => void createRecollection: (recollection: Recollection) => void deleteRecollection: (id: string) => void renameRecollection: (id: string, name: string) => void updateLastEdited: (id: string) => void reorderRecollections: (orderedIds: string[]) => void restoreRecollection: (recollection: Recollection, graphSnapshot: GraphSnapshot | null) => void /** Canvas: show React Flow minimap (persisted) */ showMinimap: boolean setShowMinimap: (value: boolean) => void /** Agent node: AI connection (persisted). Sent to backend when running agent. */ aiConnection: AiConnection setAiConnection: (value: AiConnection) => void } const KosmosContext = createContext(null) export function KosmosProvider({ children }: { children: React.ReactNode }) { const [recollections, setRecollections] = useState(loadRecollections) const [recollectionOrder, setRecollectionOrder] = useState(loadOrder) const [recentRecollectionIds, setRecentRecollectionIds] = useState(loadRecentIds) const [showMinimap, setShowMinimapState] = useState(loadShowMinimap) const [aiConnection, setAiConnectionState] = useState(loadAiConnection) const setShowMinimap = useCallback((value: boolean) => { setShowMinimapState(value) saveShowMinimap(value) }, []) const setAiConnection = useCallback((value: AiConnection) => { setAiConnectionState(value) saveAiConnection(value) }, []) const persist = useCallback((next: Recollection[]) => { setRecollections(next) saveRecollections(next) }, []) const createRecollection = useCallback( (recollection: Recollection) => { const withEdited = { ...recollection, lastEditedAt: recollection.createdAt } persist([...recollections, withEdited]) setRecollectionOrder((prev) => { const next = prev.includes(recollection.id) ? prev : [recollection.id, ...prev] saveOrder(next) return next }) }, [recollections, persist] ) const deleteRecollection = useCallback( (id: string) => { const next = recollections.filter((p) => p.id !== id) persist(next) setRecollectionOrder((prev) => { const nextOrder = prev.filter((oid) => oid !== id) saveOrder(nextOrder) return nextOrder }) setRecentRecollectionIds((prev) => { const next = prev.filter((oid) => oid !== id) saveRecentIds(next) return next }) }, [recollections, persist] ) const renameRecollection = useCallback( (id: string, name: string) => { const next = recollections.map((p) => (p.id === id ? { ...p, name } : p)) persist(next) }, [recollections, persist] ) const updateLastEdited = useCallback( (id: string) => { const now = Date.now() const next = recollections.map((p) => (p.id === id ? { ...p, lastEditedAt: now } : p)) persist(next) }, [recollections, persist] ) const reorderRecollections = useCallback((orderedIds: string[]) => { setRecollectionOrder(orderedIds) saveOrder(orderedIds) }, []) const recordRecollectionAccess = useCallback((id: string) => { setRecentRecollectionIds((prev) => { const next = [id, ...prev.filter((x) => x !== id)].slice(0, RECENT_MAX) saveRecentIds(next) return next }) }, []) const restoreRecollection = useCallback( (recollection: Recollection, graphSnapshot: GraphSnapshot | null) => { persist([...recollections, recollection]) setRecollectionOrder((prev) => { const next = prev.includes(recollection.id) ? prev : [recollection.id, ...prev] saveOrder(next) return next }) if (graphSnapshot) { saveGraphToStorage(recollection.id, { version: RECOLLECTION_VERSION, nodes: graphSnapshot.nodes, edges: graphSnapshot.edges, }) } }, [recollections, persist] ) const orderedRecollections = useMemo( () => sortRecollectionsByOrder(recollections, recollectionOrder), [recollections, recollectionOrder] ) const value: KosmosContextValue = useMemo( () => ({ recollections, recollectionOrder, orderedRecollections, recentRecollectionIds, recordRecollectionAccess, persist, createRecollection, deleteRecollection, renameRecollection, updateLastEdited, reorderRecollections, restoreRecollection, showMinimap, setShowMinimap, aiConnection, setAiConnection, }), [ recollections, recollectionOrder, orderedRecollections, recentRecollectionIds, recordRecollectionAccess, persist, createRecollection, deleteRecollection, renameRecollection, updateLastEdited, reorderRecollections, restoreRecollection, showMinimap, setShowMinimap, aiConnection, setAiConnection, ] ) return {children} } export function usePlatform() { const ctx = useContext(KosmosContext) if (!ctx) throw new Error('usePlatform must be used within PlatformProvider') return ctx }