355 lines
11 KiB
TypeScript
355 lines
11 KiB
TypeScript
/**
|
|
* 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<string, unknown>
|
|
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<string>()
|
|
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<KosmosContextValue | null>(null)
|
|
|
|
export function KosmosProvider({ children }: { children: React.ReactNode }) {
|
|
const [recollections, setRecollections] = useState<Recollection[]>(loadRecollections)
|
|
const [recollectionOrder, setRecollectionOrder] = useState<string[]>(loadOrder)
|
|
const [recentRecollectionIds, setRecentRecollectionIds] = useState<string[]>(loadRecentIds)
|
|
const [showMinimap, setShowMinimapState] = useState<boolean>(loadShowMinimap)
|
|
const [aiConnection, setAiConnectionState] = useState<AiConnection>(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 <KosmosContext.Provider value={value}>{children}</KosmosContext.Provider>
|
|
}
|
|
|
|
export function usePlatform() {
|
|
const ctx = useContext(KosmosContext)
|
|
if (!ctx) throw new Error('usePlatform must be used within PlatformProvider')
|
|
return ctx
|
|
}
|