mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
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:
+21
-18
@@ -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
@@ -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
@@ -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]);
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user