Integrate ModelProvider for enhanced model management and state synchronization

- Introduced a new `ModelProvider` component to manage model download and status synchronization with the Rust backend.
- Updated the `useModelStatus` hook to utilize Redux for state management, improving error handling and download control.
- Refactored the `App` component to include `ModelProvider`, ensuring proper context for model-related operations.
- Simplified the `Welcome` component by removing auto-trigger logic for model downloads, relying on the new provider for state management.
- Added a new `modelSlice` to the Redux store to handle model status and actions, enhancing overall state management.
This commit is contained in:
Steven Enamakel
2026-02-06 01:46:30 +05:30
parent 2309021bbd
commit 14b330a17d
6 changed files with 228 additions and 129 deletions
+21 -18
View File
@@ -5,6 +5,7 @@ import { PersistGate } from 'redux-persist/integration/react';
import AppRoutes from './AppRoutes';
import AIProvider from './providers/AIProvider';
import ModelProvider from './providers/ModelProvider';
import SkillProvider from './providers/SkillProvider';
import SocketProvider from './providers/SocketProvider';
import UserProvider from './providers/UserProvider';
@@ -15,26 +16,28 @@ function App() {
<Sentry.ErrorBoundary fallback={<div>Something went wrong.</div>}>
<Provider store={store}>
<PersistGate loading={null} persistor={persistor}>
<UserProvider>
<SocketProvider>
<AIProvider>
<SkillProvider>
<Router>
<div className="relative min-h-screen">
<div className="pointer-events-none fixed inset-x-0 top-0 flex justify-center z-50">
<div className="bg-black w-full px-3 py-1.5 text-[11px] uppercase tracking-[0.18em] text-white/40 text-center">
AlphaHuman is in early beta.
<ModelProvider>
<UserProvider>
<SocketProvider>
<AIProvider>
<SkillProvider>
<Router>
<div className="relative min-h-screen">
<div className="pointer-events-none fixed inset-x-0 top-0 flex justify-center z-50">
<div className="bg-black w-full px-3 py-1.5 text-[11px] uppercase tracking-[0.18em] text-white/40 text-center">
AlphaHuman is in early beta.
</div>
</div>
<div className="pt-7">
<AppRoutes />
</div>
</div>
<div className="pt-7">
<AppRoutes />
</div>
</div>
</Router>
</SkillProvider>
</AIProvider>
</SocketProvider>
</UserProvider>
</Router>
</SkillProvider>
</AIProvider>
</SocketProvider>
</UserProvider>
</ModelProvider>
</PersistGate>
</Provider>
</Sentry.ErrorBoundary>
+38 -101
View File
@@ -1,103 +1,62 @@
import { invoke } from '@tauri-apps/api/core';
import { useCallback, useEffect, useState } from 'react';
import { useCallback } from 'react';
import { useAppDispatch, useAppSelector } from '../store/hooks';
import {
setDownloadTriggered,
setModelError,
setModelLoading,
setModelStatus,
type ModelStatus,
} from '../store/modelSlice';
/**
* Model status from Rust backend
* Hook to read model status from Redux and provide control actions.
* Status polling and auto-download are handled by ModelProvider.
*/
export interface ModelStatus {
available: boolean;
loaded: boolean;
loading: boolean;
downloaded: boolean;
downloadProgress: number | null;
error: string | null;
modelPath: string | null;
}
export const useModelStatus = () => {
const dispatch = useAppDispatch();
const model = useAppSelector(state => state.model);
const DEFAULT_STATUS: ModelStatus = {
available: false,
loaded: false,
loading: false,
downloaded: false,
downloadProgress: null,
error: null,
modelPath: null,
};
/**
* Hook to monitor and control local AI model status
*/
export const useModelStatus = (pollInterval = 1000) => {
const [status, setStatus] = useState<ModelStatus>(DEFAULT_STATUS);
const [isPolling, setIsPolling] = useState(false);
// Fetch current status from backend
const fetchStatus = useCallback(async () => {
try {
const result = await invoke<ModelStatus>('model_get_status');
setStatus(result);
dispatch(setModelStatus(result));
return result;
} catch (error) {
console.error('[useModelStatus] Failed to fetch status:', error);
setStatus(prev => ({
...prev,
error: error instanceof Error ? error.message : 'Failed to fetch status',
}));
dispatch(setModelError(error instanceof Error ? error.message : 'Failed to fetch status'));
return null;
}
}, []);
}, [dispatch]);
// Check if model API is available
const checkAvailability = useCallback(async () => {
try {
const available = await invoke<boolean>('model_is_available');
setStatus(prev => ({ ...prev, available }));
return available;
} catch (error) {
console.error('[useModelStatus] Failed to check availability:', error);
return false;
}
}, []);
// Start downloading the model without loading into memory
const startDownload = useCallback(async () => {
try {
setStatus(prev => ({ ...prev, loading: true, error: null }));
setIsPolling(true);
dispatch(setModelLoading(true));
dispatch(setModelError(null));
dispatch(setDownloadTriggered(true));
await invoke('model_start_download');
await fetchStatus();
setIsPolling(false);
} catch (error) {
console.error('[useModelStatus] Failed to start download:', error);
setStatus(prev => ({
...prev,
loading: false,
error: error instanceof Error ? error.message : 'Failed to download model',
}));
setIsPolling(false);
dispatch(
setModelError(error instanceof Error ? error.message : 'Failed to download model')
);
}
}, [fetchStatus]);
}, [dispatch, fetchStatus]);
// Start loading/downloading the model
const ensureLoaded = useCallback(async () => {
try {
setStatus(prev => ({ ...prev, loading: true, error: null }));
setIsPolling(true);
dispatch(setModelLoading(true));
dispatch(setModelError(null));
await invoke('model_ensure_loaded');
await fetchStatus();
setIsPolling(false);
} catch (error) {
console.error('[useModelStatus] Failed to load model:', error);
setStatus(prev => ({
...prev,
loading: false,
error: error instanceof Error ? error.message : 'Failed to load model',
}));
setIsPolling(false);
dispatch(setModelError(error instanceof Error ? error.message : 'Failed to load model'));
}
}, [fetchStatus]);
}, [dispatch, fetchStatus]);
// Unload the model from memory
const unload = useCallback(async () => {
try {
await invoke('model_unload');
@@ -107,41 +66,19 @@ export const useModelStatus = (pollInterval = 1000) => {
}
}, [fetchStatus]);
// Initial check and polling setup
useEffect(() => {
// Initial fetch
fetchStatus();
// Check availability
checkAvailability();
}, [fetchStatus, checkAvailability]);
// Polling when loading/downloading
useEffect(() => {
if (!isPolling && !status.loading) return;
const interval = setInterval(async () => {
const newStatus = await fetchStatus();
// Stop polling when loading is done
if (newStatus && !newStatus.loading) {
setIsPolling(false);
}
}, pollInterval);
return () => clearInterval(interval);
}, [isPolling, status.loading, pollInterval, fetchStatus]);
return {
status,
isAvailable: status.available,
isLoaded: status.loaded,
isLoading: status.loading,
isDownloaded: status.downloaded,
downloadProgress: status.downloadProgress,
error: status.error,
status: model,
isAvailable: model.available,
isLoaded: model.loaded,
isLoading: model.loading,
isDownloaded: model.downloaded,
downloadProgress: model.downloadProgress,
error: model.error,
startDownload,
ensureLoaded,
unload,
refresh: fetchStatus,
};
};
export type { ModelStatus };
+1 -10
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef } from 'react';
import { useCallback } from 'react';
import DownloadScreen from '../components/DownloadScreen';
import TelegramLoginButton from '../components/TelegramLoginButton';
@@ -18,17 +18,8 @@ const Welcome = ({ isWeb }: WelcomeProps) => {
const { isAvailable, isDownloaded, isLoading, downloadProgress, error, startDownload } =
useModelStatus();
const downloadTriggered = useRef(false);
// Auto-trigger model download on desktop
useEffect(() => {
if (isWeb || !isAvailable || isDownloaded || isLoading || downloadTriggered.current) return;
downloadTriggered.current = true;
startDownload();
}, [isWeb, isAvailable, isDownloaded, isLoading, startDownload]);
const handleRetry = useCallback(() => {
downloadTriggered.current = false;
startDownload();
}, [startDownload]);
+106
View File
@@ -0,0 +1,106 @@
import { invoke } from '@tauri-apps/api/core';
import { platform } from '@tauri-apps/plugin-os';
import { useEffect, useRef } from 'react';
import { useAppDispatch, useAppSelector } from '../store/hooks';
import {
setDownloadTriggered,
setModelError,
setModelLoading,
setModelStatus,
type ModelStatus,
} from '../store/modelSlice';
const POLL_INTERVAL = 1000;
/**
* App-level provider that auto-starts model download on desktop
* and keeps Redux model state in sync with the Rust backend.
*/
const ModelProvider = ({ children }: { children: React.ReactNode }) => {
const dispatch = useAppDispatch();
const { loading, downloaded, available, downloadTriggered } = useAppSelector(
state => state.model
);
const pollingRef = useRef(false);
// Initial status fetch + availability check
useEffect(() => {
const init = async () => {
try {
const status = await invoke<ModelStatus>('model_get_status');
dispatch(setModelStatus(status));
} catch {
// Non-Tauri environment
}
try {
const avail = await invoke<boolean>('model_is_available');
if (avail) {
dispatch(setModelStatus({ ...(await invoke<ModelStatus>('model_get_status')) }));
}
} catch {
// Non-Tauri environment
}
};
init();
}, [dispatch]);
// Auto-trigger download on desktop when model is available but not downloaded
useEffect(() => {
if (downloadTriggered || !available || downloaded || loading) return;
const tryAutoDownload = async () => {
try {
const currentPlatform = await platform();
if (currentPlatform === 'android' || currentPlatform === 'ios') return;
} catch {
// Can't detect platform — skip auto-download (likely web)
return;
}
dispatch(setDownloadTriggered(true));
dispatch(setModelLoading(true));
dispatch(setModelError(null));
try {
await invoke('model_start_download');
const status = await invoke<ModelStatus>('model_get_status');
dispatch(setModelStatus(status));
} catch (err) {
dispatch(setModelError(err instanceof Error ? err.message : 'Failed to download model'));
}
};
tryAutoDownload();
}, [dispatch, downloadTriggered, available, downloaded, loading]);
// Poll status while loading/downloading
useEffect(() => {
if (!loading) {
pollingRef.current = false;
return;
}
pollingRef.current = true;
const interval = setInterval(async () => {
if (!pollingRef.current) return;
try {
const status = await invoke<ModelStatus>('model_get_status');
dispatch(setModelStatus(status));
if (!status.loading) {
pollingRef.current = false;
}
} catch {
// ignore
}
}, POLL_INTERVAL);
return () => clearInterval(interval);
}, [dispatch, loading]);
return <>{children}</>;
};
export default ModelProvider;
+2
View File
@@ -15,6 +15,7 @@ import storage from 'redux-persist/lib/storage';
import { IS_DEV } from '../utils/config';
import aiReducer from './aiSlice';
import authReducer, { setOnboardedForUser, setToken } from './authSlice';
import modelReducer from './modelSlice';
import skillsReducer from './skillsSlice';
import socketReducer from './socketSlice';
import teamReducer from './teamSlice';
@@ -45,6 +46,7 @@ export const store = configureStore({
ai: persistedAiReducer,
skills: persistedSkillsReducer,
team: teamReducer,
model: modelReducer,
},
middleware: getDefaultMiddleware => {
const middleware = getDefaultMiddleware({
+60
View File
@@ -0,0 +1,60 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
export interface ModelStatus {
available: boolean;
loaded: boolean;
loading: boolean;
downloaded: boolean;
downloadProgress: number | null;
error: string | null;
modelPath: string | null;
}
interface ModelState extends ModelStatus {
/** Whether auto-download has been triggered this session */
downloadTriggered: boolean;
}
const initialState: ModelState = {
available: false,
loaded: false,
loading: false,
downloaded: false,
downloadProgress: null,
error: null,
modelPath: null,
downloadTriggered: false,
};
const modelSlice = createSlice({
name: 'model',
initialState,
reducers: {
setModelStatus(state, action: PayloadAction<ModelStatus>) {
const s = action.payload;
state.available = s.available;
state.loaded = s.loaded;
state.loading = s.loading;
state.downloaded = s.downloaded;
state.downloadProgress = s.downloadProgress;
state.error = s.error;
state.modelPath = s.modelPath;
},
setDownloadTriggered(state, action: PayloadAction<boolean>) {
state.downloadTriggered = action.payload;
},
setModelLoading(state, action: PayloadAction<boolean>) {
state.loading = action.payload;
},
setModelError(state, action: PayloadAction<string | null>) {
state.error = action.payload;
if (action.payload) {
state.loading = false;
}
},
},
});
export const { setModelStatus, setDownloadTriggered, setModelLoading, setModelError } =
modelSlice.actions;
export default modelSlice.reducer;