Files
fusion/packages/dashboard/app/hooks/useModelsCache.ts
Fusion (runfusion.ai) cfc70b21bd feat(FN-5202): add SWR caching for chat agents, skills, and models
This merge introduces SWR-style caching across the dashboard to eliminate redundant fetches for models, agents, and skills, significantly refactoring ChatView.tsx (reduced by ~140 lines) by offloading cache coordination to three new dedicated hooks (`useAgentsMapCache`, `useDiscoveredSkillsCache`, `

Fusion-Task-Id: FN-5202
2026-05-20 03:19:44 -07:00

107 lines
2.8 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from "react";
import { fetchModels, type ModelInfo, type ModelsResponse } from "../api";
import { clearCache, readCache, SWR_CACHE_KEYS, SWR_DEFAULT_MAX_AGE_MS, writeCache } from "../utils/swrCache";
interface ModelsCacheState {
models: ModelInfo[];
favoriteProviders: string[];
favoriteModels: string[];
defaultProvider: string | null;
defaultModelId: string | null;
}
export interface UseModelsCacheResult extends ModelsCacheState {
loading: boolean;
refresh: () => Promise<void>;
}
const EMPTY_MODELS_STATE: ModelsCacheState = {
models: [],
favoriteProviders: [],
favoriteModels: [],
defaultProvider: null,
defaultModelId: null,
};
let inflight: Promise<ModelsResponse> | null = null;
const listeners = new Set<(state: ModelsCacheState) => void>();
function toModelsCacheState(response: ModelsResponse | null | undefined): ModelsCacheState {
if (!response) {
return EMPTY_MODELS_STATE;
}
return {
models: response.models ?? [],
favoriteProviders: response.favoriteProviders ?? [],
favoriteModels: response.favoriteModels ?? [],
defaultProvider: response.defaultProvider ?? null,
defaultModelId: response.defaultModelId ?? null,
};
}
function readCachedModelsState(): ModelsCacheState | null {
const cached = readCache<ModelsResponse>(SWR_CACHE_KEYS.MODELS, { maxAgeMs: SWR_DEFAULT_MAX_AGE_MS });
return cached ? toModelsCacheState(cached) : null;
}
function notifyListeners(state: ModelsCacheState): void {
for (const listener of listeners) {
listener(state);
}
}
async function fetchModelsShared(): Promise<ModelsResponse> {
if (!inflight) {
inflight = fetchModels().finally(() => {
inflight = null;
});
}
return inflight;
}
export function useModelsCache(): UseModelsCacheResult {
const cachedState = readCachedModelsState();
const [state, setState] = useState<ModelsCacheState>(() => cachedState ?? EMPTY_MODELS_STATE);
const [loading, setLoading] = useState(() => cachedState === null);
const hasCachedStateRef = useRef(cachedState !== null);
useEffect(() => {
listeners.add(setState);
return () => {
listeners.delete(setState);
};
}, []);
const load = useCallback(async () => {
try {
const response = await fetchModelsShared();
const nextState = toModelsCacheState(response);
hasCachedStateRef.current = true;
writeCache(SWR_CACHE_KEYS.MODELS, response, { maxBytes: 500_000 });
notifyListeners(nextState);
} catch {
if (!hasCachedStateRef.current) {
clearCache(SWR_CACHE_KEYS.MODELS);
}
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const refresh = useCallback(async () => {
setLoading(true);
await load();
}, [load]);
return {
...state,
loading,
refresh,
};
}