diff --git a/src/App.tsx b/src/App.tsx index 2e8c1df8b..9b2bca24e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { Something went wrong.}> - - - - - -
-
-
- AlphaHuman is in early beta. + + + + + + +
+
+
+ AlphaHuman is in early beta. +
+
+
+
-
- -
-
- - - - - + + + + + + diff --git a/src/hooks/useModelStatus.ts b/src/hooks/useModelStatus.ts index af0c6b3d9..4b661aa88 100644 --- a/src/hooks/useModelStatus.ts +++ b/src/hooks/useModelStatus.ts @@ -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(DEFAULT_STATUS); - const [isPolling, setIsPolling] = useState(false); - - // Fetch current status from backend const fetchStatus = useCallback(async () => { try { const result = await invoke('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('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 }; diff --git a/src/pages/Welcome.tsx b/src/pages/Welcome.tsx index aee72b3bd..72103f89f 100644 --- a/src/pages/Welcome.tsx +++ b/src/pages/Welcome.tsx @@ -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]); diff --git a/src/providers/ModelProvider.tsx b/src/providers/ModelProvider.tsx new file mode 100644 index 000000000..66639b729 --- /dev/null +++ b/src/providers/ModelProvider.tsx @@ -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('model_get_status'); + dispatch(setModelStatus(status)); + } catch { + // Non-Tauri environment + } + + try { + const avail = await invoke('model_is_available'); + if (avail) { + dispatch(setModelStatus({ ...(await invoke('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('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('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; diff --git a/src/store/index.ts b/src/store/index.ts index 970490ad4..859210ad7 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -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({ diff --git a/src/store/modelSlice.ts b/src/store/modelSlice.ts new file mode 100644 index 000000000..46dc9dc53 --- /dev/null +++ b/src/store/modelSlice.ts @@ -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) { + 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) { + state.downloadTriggered = action.payload; + }, + setModelLoading(state, action: PayloadAction) { + state.loading = action.payload; + }, + setModelError(state, action: PayloadAction) { + state.error = action.payload; + if (action.payload) { + state.loading = false; + } + }, + }, +}); + +export const { setModelStatus, setDownloadTriggered, setModelLoading, setModelError } = + modelSlice.actions; +export default modelSlice.reducer;