mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 06:32:24 +00:00
fix: onboarding flow - registry skills, config-based flag, dead code cleanup (#177)
* feat(onboarding): enhance onboarding steps with back navigation and UI improvements - Added back navigation functionality to all onboarding steps (LocalAIStep, ScreenPermissionsStep, SkillsStep, ToolsStep, MnemonicStep) for improved user experience. - Updated Onboarding component to adjust total steps from 7 to 6. - Modified ProgressIndicator to reflect the change in total steps. - Enhanced LocalAIStep UI by removing unnecessary state and improving layout. - Improved error handling and user feedback in MnemonicStep and ScreenPermissionsStep. - General UI refinements across onboarding steps for better consistency and usability. * refactor(onboarding): update skip button and UI elements for improved user experience - Replaced "Set up later" button with a "Skip" button in the Onboarding component for better clarity. - Removed unnecessary back navigation buttons from LocalAIStep to streamline the UI. - Enhanced messaging in ScreenPermissionsStep to clarify data processing and permissions. - Adjusted button layouts for improved accessibility and consistency across onboarding steps. * refactor(onboarding): streamline button layout and styling in onboarding steps - Updated button styles in ScreenPermissionsStep and ToolsStep for consistency and improved user experience. - Replaced the back navigation button in ToolsStep with a full-width continue button, enhancing layout and accessibility. * refactor(onboarding): update ProgressIndicator and button styles for improved consistency - Adjusted ProgressIndicator component to use 'bg-sage-500' for active steps and increased height for better visibility. - Streamlined button styles in LocalAIStep and SkillsStep for a more cohesive user experience, including removing unnecessary margins and ensuring full-width buttons where applicable. - Enhanced ToolsStep to focus on skill installation, updating UI elements and descriptions for clarity and improved user engagement. * refactor(onboarding): update SkillsStep title and description for clarity - Changed the title from "Install Skills" to "Connect Skills" to better reflect the action. - Revised the description to clarify that skills interact with the user's workflow and that all data is processed locally, enhancing user understanding of the feature. * refactor(onboarding): enhance onboarding steps with UI improvements and functionality updates - Integrated useUser hook in Onboarding component for better user state management. - Updated MnemonicStep title and messaging for clarity, emphasizing the importance of the recovery phrase. - Streamlined button layout in MnemonicStep and ScreenPermissionsStep for improved accessibility and consistency. - Refactored SkillsStep to utilize available skills more effectively, enhancing the user experience with clearer skill descriptions and connection statuses. - Transitioned ToolsStep to focus on enabling tools, updating UI elements and descriptions for better user guidance. * refactor(onboarding): replace workspace flag checks with onboarding_completed config - Removed Redux state checks for onboarding status and workspace flags. - Integrated new core config methods to read and write onboarding completion status. - Updated OnboardingOverlay and Onboarding components to utilize onboarding_completed for flow control. - Enhanced error handling and user feedback during onboarding state persistence. * refactor(onboarding): remove deferred flow and dead workspace flag code The onboarding_completed config field is now the sole source of truth. Skip and complete both write the same flag. Remove SetupBanner, onboardingDeferredByUser, selectOnboardingDeferred, and the old workspace flag file functions (openhumanWorkspaceOnboardingFlagExists/Set). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: resolve lint errors for unused onBack params in onboarding steps Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: remove unused handleSkip in LocalAIStep Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
88a4647dae
commit
dd2e33e1c6
@@ -9,7 +9,6 @@ import ErrorFallbackScreen from './components/ErrorFallbackScreen';
|
||||
import LocalAIDownloadSnackbar from './components/LocalAIDownloadSnackbar';
|
||||
import MiniSidebar from './components/MiniSidebar';
|
||||
import OnboardingOverlay from './components/OnboardingOverlay';
|
||||
import SetupBanner from './components/SetupBanner';
|
||||
import SocketProvider from './providers/SocketProvider';
|
||||
import UserProvider from './providers/UserProvider';
|
||||
import { tagErrorSource } from './services/errorReportQueue';
|
||||
@@ -45,7 +44,6 @@ function App() {
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
<MiniSidebar />
|
||||
<div className="flex flex-col flex-1 relative overflow-hidden">
|
||||
<SetupBanner />
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<AppRoutes />
|
||||
</div>
|
||||
|
||||
@@ -2,31 +2,26 @@ import { useCallback, useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import Onboarding from '../pages/onboarding/Onboarding';
|
||||
import { selectIsOnboarded, selectOnboardingDeferred } from '../store/authSelectors';
|
||||
import { setOnboardingDeferredForUser } from '../store/authSlice';
|
||||
import { useAppDispatch, useAppSelector } from '../store/hooks';
|
||||
import { useAppSelector } from '../store/hooks';
|
||||
import { DEV_FORCE_ONBOARDING } from '../utils/config';
|
||||
import {
|
||||
DEFAULT_WORKSPACE_ONBOARDING_FLAG,
|
||||
openhumanWorkspaceOnboardingFlagExists,
|
||||
getOnboardingCompleted,
|
||||
setOnboardingCompleted as persistOnboardingCompleted,
|
||||
} from '../utils/tauriCommands';
|
||||
|
||||
/**
|
||||
* Full-screen overlay that renders the onboarding flow on top of any page
|
||||
* when the user has not completed onboarding.
|
||||
*
|
||||
* Checks both Redux `isOnboarded` and the workspace flag file.
|
||||
* Waits for the user profile to load before making a decision.
|
||||
* Reads `onboarding_completed` from the core config (persisted in config.toml).
|
||||
*/
|
||||
const OnboardingOverlay = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const token = useAppSelector(state => state.auth.token);
|
||||
const isAuthBootstrapComplete = useAppSelector(state => state.auth.isAuthBootstrapComplete);
|
||||
const user = useAppSelector(state => state.user.user);
|
||||
const isOnboarded = useAppSelector(selectIsOnboarded);
|
||||
const isDeferred = useAppSelector(selectOnboardingDeferred);
|
||||
const [hasWorkspaceFlag, setHasWorkspaceFlag] = useState<boolean | null>(null);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
/** null = still loading, true/false = resolved from core config */
|
||||
const [onboardingCompleted, setOnboardingCompleted] = useState<boolean | null>(null);
|
||||
const [userLoadTimedOut, setUserLoadTimedOut] = useState(false);
|
||||
|
||||
// Timeout: if user profile hasn't loaded after 3s but we have token + bootstrap,
|
||||
@@ -38,58 +33,49 @@ const OnboardingOverlay = () => {
|
||||
return () => clearTimeout(timer);
|
||||
}, [token, isAuthBootstrapComplete, user?._id]);
|
||||
|
||||
// User is ready when profile loaded or timeout elapsed.
|
||||
// Note: userLoadTimedOut is sticky across sessions but harmless — when token
|
||||
// is null (logged out) the early-return guard prevents any visible effect,
|
||||
// and the workspace flag check doesn't require userId.
|
||||
const userReady = !!user?._id || userLoadTimedOut;
|
||||
|
||||
// Read onboarding_completed from core config.
|
||||
useEffect(() => {
|
||||
if (!token || !isAuthBootstrapComplete || !userReady) return;
|
||||
|
||||
let mounted = true;
|
||||
const check = async () => {
|
||||
try {
|
||||
const exists = await openhumanWorkspaceOnboardingFlagExists(
|
||||
DEFAULT_WORKSPACE_ONBOARDING_FLAG
|
||||
);
|
||||
if (mounted) setHasWorkspaceFlag(exists);
|
||||
const completed = await getOnboardingCompleted();
|
||||
if (mounted) setOnboardingCompleted(completed);
|
||||
} catch {
|
||||
if (mounted) setHasWorkspaceFlag(false);
|
||||
if (mounted) setOnboardingCompleted(false);
|
||||
}
|
||||
};
|
||||
void check();
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [token, isAuthBootstrapComplete, userReady, isOnboarded]);
|
||||
}, [token, isAuthBootstrapComplete, userReady]);
|
||||
|
||||
const handleComplete = useCallback(() => {
|
||||
setDismissed(true);
|
||||
}, []);
|
||||
|
||||
const handleDefer = useCallback(() => {
|
||||
if (user?._id) {
|
||||
dispatch(setOnboardingDeferredForUser({ userId: user._id, deferred: true }));
|
||||
const handleDone = useCallback(async () => {
|
||||
setOnboardingCompleted(true);
|
||||
try {
|
||||
await persistOnboardingCompleted(true);
|
||||
} catch {
|
||||
console.warn('[onboarding] Failed to persist onboarding_completed');
|
||||
}
|
||||
setDismissed(true);
|
||||
}, [dispatch, user]);
|
||||
}, []);
|
||||
|
||||
// Don't show if not logged in, bootstrap not complete, or user not ready
|
||||
if (!token || !isAuthBootstrapComplete || !userReady) return null;
|
||||
|
||||
// Still loading workspace flag
|
||||
if (hasWorkspaceFlag === null) return null;
|
||||
// Still loading the flag from core
|
||||
if (onboardingCompleted === null) return null;
|
||||
|
||||
// Determine if onboarding should show
|
||||
const shouldShow = DEV_FORCE_ONBOARDING
|
||||
? !dismissed
|
||||
: !isOnboarded && !hasWorkspaceFlag && !isDeferred && !dismissed;
|
||||
const shouldShow = DEV_FORCE_ONBOARDING ? !onboardingCompleted : !onboardingCompleted;
|
||||
|
||||
if (!shouldShow) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[9999] bg-canvas-900/95 backdrop-blur-md flex items-center justify-center">
|
||||
<Onboarding onComplete={handleComplete} onDefer={handleDefer} />
|
||||
<Onboarding onComplete={handleDone} onDefer={handleDone} />
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
|
||||
@@ -9,8 +9,8 @@ const ProgressIndicator = ({ currentStep, totalSteps }: ProgressIndicatorProps)
|
||||
{Array.from({ length: totalSteps }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`w-8 h-0.5 rounded-full ${
|
||||
index < currentStep ? 'bg-primary-500' : 'bg-stone-700'
|
||||
className={`w-8 h-1 rounded-full ${
|
||||
index <= currentStep ? 'bg-sage-500' : 'bg-stone-700'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { selectIsOnboarded, selectOnboardingDeferred } from '../store/authSelectors';
|
||||
import { setOnboardingDeferredForUser } from '../store/authSlice';
|
||||
import { useAppDispatch, useAppSelector } from '../store/hooks';
|
||||
|
||||
const SESSION_KEY = 'setupBannerDismissed';
|
||||
|
||||
/**
|
||||
* Non-intrusive banner shown when a user has deferred onboarding but hasn't completed it.
|
||||
* Provides a clear path to resume setup without blocking the app.
|
||||
*/
|
||||
const SetupBanner = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const isOnboarded = useAppSelector(selectIsOnboarded);
|
||||
const isDeferred = useAppSelector(selectOnboardingDeferred);
|
||||
const userId = useAppSelector(state => state.user.user?._id);
|
||||
|
||||
const [sessionDismissed, setSessionDismissed] = useState(
|
||||
() => sessionStorage.getItem(SESSION_KEY) === 'true'
|
||||
);
|
||||
|
||||
const handleResume = useCallback(() => {
|
||||
if (userId) {
|
||||
dispatch(setOnboardingDeferredForUser({ userId, deferred: false }));
|
||||
}
|
||||
}, [dispatch, userId]);
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
sessionStorage.setItem(SESSION_KEY, 'true');
|
||||
setSessionDismissed(true);
|
||||
}, []);
|
||||
|
||||
if (!isDeferred || isOnboarded || sessionDismissed || !userId) return null;
|
||||
|
||||
return (
|
||||
<div className="mx-4 mt-3 mb-1 flex items-center justify-between gap-3 rounded-xl border border-primary-500/20 bg-primary-500/5 px-4 py-2.5">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<svg
|
||||
className="w-4 h-4 text-primary-400 flex-shrink-0"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm text-stone-300 truncate">Finish setting up OpenHuman</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<button
|
||||
onClick={handleResume}
|
||||
className="text-xs font-medium text-primary-400 hover:text-primary-300 transition-colors">
|
||||
Continue Setup
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="p-0.5 text-stone-500 hover:text-stone-300 transition-colors"
|
||||
aria-label="Dismiss setup banner">
|
||||
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M4.28 3.22a.75.75 0 00-1.06 1.06L6.94 8l-3.72 3.72a.75.75 0 101.06 1.06L8 9.06l3.72 3.72a.75.75 0 101.06-1.06L9.06 8l3.72-3.72a.75.75 0 00-1.06-1.06L8 6.94 4.28 3.22z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SetupBanner;
|
||||
@@ -1,82 +0,0 @@
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { renderWithProviders } from '../../test/test-utils';
|
||||
import SetupBanner from '../SetupBanner';
|
||||
|
||||
const baseAuth = {
|
||||
token: 'test-jwt',
|
||||
isAuthBootstrapComplete: true,
|
||||
isOnboardedByUser: {} as Record<string, boolean>,
|
||||
onboardingDeferredByUser: {} as Record<string, number>,
|
||||
isAnalyticsEnabledByUser: {},
|
||||
onboardingTasksByUser: {},
|
||||
hasIncompleteOnboardingByUser: {},
|
||||
encryptionKeyByUser: {},
|
||||
primaryWalletAddressByUser: {},
|
||||
};
|
||||
|
||||
const baseUser = { user: { _id: 'user-1', username: 'tester', firstName: 'Test' } };
|
||||
|
||||
describe('SetupBanner', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
it('does not render when user is onboarded', () => {
|
||||
renderWithProviders(<SetupBanner />, {
|
||||
preloadedState: {
|
||||
auth: { ...baseAuth, isOnboardedByUser: { 'user-1': true } },
|
||||
user: baseUser,
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Finish setting up OpenHuman')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render when not deferred', () => {
|
||||
renderWithProviders(<SetupBanner />, { preloadedState: { auth: baseAuth, user: baseUser } });
|
||||
|
||||
expect(screen.queryByText('Finish setting up OpenHuman')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders when deferred and not onboarded', () => {
|
||||
renderWithProviders(<SetupBanner />, {
|
||||
preloadedState: {
|
||||
auth: { ...baseAuth, onboardingDeferredByUser: { 'user-1': Date.now() } },
|
||||
user: baseUser,
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByText('Finish setting up OpenHuman')).toBeInTheDocument();
|
||||
expect(screen.getByText('Continue Setup')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clears deferred state when Continue Setup is clicked', () => {
|
||||
const { store } = renderWithProviders(<SetupBanner />, {
|
||||
preloadedState: {
|
||||
auth: { ...baseAuth, onboardingDeferredByUser: { 'user-1': Date.now() } },
|
||||
user: baseUser,
|
||||
},
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Continue Setup'));
|
||||
|
||||
const state = store.getState();
|
||||
expect(state.auth.onboardingDeferredByUser['user-1']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('hides banner for session when dismissed', () => {
|
||||
renderWithProviders(<SetupBanner />, {
|
||||
preloadedState: {
|
||||
auth: { ...baseAuth, onboardingDeferredByUser: { 'user-1': Date.now() } },
|
||||
user: baseUser,
|
||||
},
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Dismiss setup banner'));
|
||||
|
||||
expect(screen.queryByText('Finish setting up OpenHuman')).not.toBeInTheDocument();
|
||||
expect(sessionStorage.getItem('setupBannerDismissed')).toBe('true');
|
||||
});
|
||||
});
|
||||
@@ -4,10 +4,7 @@ import { skillManager } from '../../lib/skills/manager';
|
||||
import { persistor } from '../../store';
|
||||
import { clearToken } from '../../store/authSlice';
|
||||
import { useAppDispatch } from '../../store/hooks';
|
||||
import {
|
||||
openhumanWorkspaceOnboardingFlagSet,
|
||||
logout as tauriLogout,
|
||||
} from '../../utils/tauriCommands';
|
||||
import { setOnboardingCompleted, logout as tauriLogout } from '../../utils/tauriCommands';
|
||||
import SettingsHeader from './components/SettingsHeader';
|
||||
import SettingsMenuItem from './components/SettingsMenuItem';
|
||||
import { useSettingsNavigation } from './hooks/useSettingsNavigation';
|
||||
@@ -22,9 +19,9 @@ const SettingsHome = () => {
|
||||
const handleLogout = async () => {
|
||||
await dispatch(clearToken());
|
||||
try {
|
||||
await openhumanWorkspaceOnboardingFlagSet(false);
|
||||
await setOnboardingCompleted(false);
|
||||
} catch (err) {
|
||||
console.warn('[Settings] Failed to clear workspace onboarding flag:', err);
|
||||
console.warn('[Settings] Failed to clear onboarding_completed in config:', err);
|
||||
}
|
||||
try {
|
||||
await tauriLogout();
|
||||
@@ -37,9 +34,9 @@ const SettingsHome = () => {
|
||||
const clearAllAppData = async () => {
|
||||
await dispatch(clearToken());
|
||||
try {
|
||||
await openhumanWorkspaceOnboardingFlagSet(false);
|
||||
await setOnboardingCompleted(false);
|
||||
} catch (err) {
|
||||
console.warn('[Settings] Failed to clear workspace onboarding flag:', err);
|
||||
console.warn('[Settings] Failed to clear onboarding_completed in config:', err);
|
||||
}
|
||||
try {
|
||||
await tauriLogout();
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import ProgressIndicator from '../../components/ProgressIndicator';
|
||||
import { useUser } from '../../hooks/useUser';
|
||||
import { userApi } from '../../services/api/userApi';
|
||||
import { setOnboardedForUser, setOnboardingTasksForUser } from '../../store/authSlice';
|
||||
import { useAppDispatch, useAppSelector } from '../../store/hooks';
|
||||
import { openhumanWorkspaceOnboardingFlagSet } from '../../utils/tauriCommands';
|
||||
import { useAppDispatch } from '../../store/hooks';
|
||||
import { setOnboardingCompleted } from '../../utils/tauriCommands';
|
||||
import LocalAIStep from './steps/LocalAIStep';
|
||||
import MnemonicStep from './steps/MnemonicStep';
|
||||
import ScreenPermissionsStep from './steps/ScreenPermissionsStep';
|
||||
@@ -27,7 +28,7 @@ interface OnboardingDraft {
|
||||
|
||||
const Onboarding = ({ onComplete, onDefer }: OnboardingProps) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const user = useAppSelector(state => state.user.user);
|
||||
const { user } = useUser();
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [draft, setDraft] = useState<OnboardingDraft>({
|
||||
localModelConsentGiven: false,
|
||||
@@ -36,7 +37,7 @@ const Onboarding = ({ onComplete, onDefer }: OnboardingProps) => {
|
||||
enabledTools: [],
|
||||
connectedSources: [],
|
||||
});
|
||||
const totalSteps = 7;
|
||||
const totalSteps = 6;
|
||||
|
||||
const handleNext = () => {
|
||||
if (currentStep < totalSteps - 1) {
|
||||
@@ -44,6 +45,12 @@ const Onboarding = ({ onComplete, onDefer }: OnboardingProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (currentStep > 0) {
|
||||
setCurrentStep(currentStep - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLocalAINext = (result: { consentGiven: boolean; downloadStarted: boolean }) => {
|
||||
setDraft(prev => ({
|
||||
...prev,
|
||||
@@ -82,18 +89,11 @@ const Onboarding = ({ onComplete, onDefer }: OnboardingProps) => {
|
||||
);
|
||||
}
|
||||
|
||||
// Notify backend
|
||||
// Notify backend (best-effort — don't block onboarding completion)
|
||||
try {
|
||||
await userApi.onboardingComplete();
|
||||
} catch (e) {
|
||||
const msg =
|
||||
e &&
|
||||
typeof e === 'object' &&
|
||||
'error' in e &&
|
||||
typeof (e as { error: unknown }).error === 'string'
|
||||
? (e as { error: string }).error
|
||||
: 'Failed to complete onboarding. Please try again.';
|
||||
throw new Error(msg);
|
||||
} catch {
|
||||
console.warn('[onboarding] Failed to notify backend of onboarding completion');
|
||||
}
|
||||
|
||||
// Advance to mnemonic step
|
||||
@@ -101,16 +101,16 @@ const Onboarding = ({ onComplete, onDefer }: OnboardingProps) => {
|
||||
};
|
||||
|
||||
const handleMnemonicComplete = async () => {
|
||||
// Mark onboarded in Redux
|
||||
// Mark onboarded in Redux (belt-and-suspenders alongside config)
|
||||
if (user?._id) {
|
||||
dispatch(setOnboardedForUser({ userId: user._id, value: true }));
|
||||
}
|
||||
|
||||
// Write workspace flag so the overlay won't show again
|
||||
// Write onboarding_completed to core config (source of truth)
|
||||
try {
|
||||
await openhumanWorkspaceOnboardingFlagSet(true);
|
||||
await setOnboardingCompleted(true);
|
||||
} catch {
|
||||
// Non-critical — Redux state is the primary gate
|
||||
console.warn('[onboarding] Failed to persist onboarding_completed to core config');
|
||||
}
|
||||
|
||||
onComplete?.();
|
||||
@@ -121,15 +121,15 @@ const Onboarding = ({ onComplete, onDefer }: OnboardingProps) => {
|
||||
case 0:
|
||||
return <WelcomeStep onNext={handleNext} />;
|
||||
case 1:
|
||||
return <LocalAIStep onNext={handleLocalAINext} />;
|
||||
return <LocalAIStep onNext={handleLocalAINext} onBack={handleBack} />;
|
||||
case 2:
|
||||
return <ScreenPermissionsStep onNext={handleAccessibilityNext} />;
|
||||
return <ScreenPermissionsStep onNext={handleAccessibilityNext} onBack={handleBack} />;
|
||||
case 3:
|
||||
return <ToolsStep onNext={handleToolsNext} />;
|
||||
return <ToolsStep onNext={handleToolsNext} onBack={handleBack} />;
|
||||
case 4:
|
||||
return <SkillsStep onComplete={handleSkillsNext} />;
|
||||
return <SkillsStep onComplete={handleSkillsNext} onBack={handleBack} />;
|
||||
case 5:
|
||||
return <MnemonicStep onComplete={handleMnemonicComplete} />;
|
||||
return <MnemonicStep onComplete={handleMnemonicComplete} onBack={handleBack} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -137,16 +137,17 @@ const Onboarding = ({ onComplete, onDefer }: OnboardingProps) => {
|
||||
|
||||
return (
|
||||
<div className="min-h-full relative flex items-center justify-center">
|
||||
{onDefer && (
|
||||
<div className="fixed top-4 right-0 z-20 sm:top-6 sm:right-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDefer}
|
||||
className="text-sm text-white hover:text-stone-200 transition-colors">
|
||||
Skip
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative z-10 max-w-lg w-full mx-4">
|
||||
{onDefer && (
|
||||
<div className="flex justify-end mb-2">
|
||||
<button
|
||||
onClick={onDefer}
|
||||
className="text-sm text-stone-400 hover:text-stone-200 transition-colors">
|
||||
Set up later
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<ProgressIndicator currentStep={currentStep} totalSteps={totalSteps} />
|
||||
{renderStep()}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useCallback, useRef } from 'react';
|
||||
|
||||
import {
|
||||
openhumanLocalAiDownload,
|
||||
@@ -9,16 +9,15 @@ import {
|
||||
|
||||
interface LocalAIStepProps {
|
||||
onNext: (result: { consentGiven: boolean; downloadStarted: boolean }) => void;
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
const LocalAIStep = ({ onNext }: LocalAIStepProps) => {
|
||||
const [consent, setConsent] = useState<boolean | null>(null);
|
||||
const LocalAIStep = ({ onNext, onBack: _onBack }: LocalAIStepProps) => {
|
||||
const downloadStartedRef = useRef(false);
|
||||
|
||||
const handleConsent = useCallback(() => {
|
||||
if (downloadStartedRef.current) return;
|
||||
downloadStartedRef.current = true;
|
||||
setConsent(true);
|
||||
|
||||
// Fire-and-forget: start downloads in the background — the global snackbar tracks progress
|
||||
void openhumanLocalAiDownload(false).catch(() => {});
|
||||
@@ -28,83 +27,51 @@ const LocalAIStep = ({ onNext }: LocalAIStepProps) => {
|
||||
onNext({ consentGiven: true, downloadStarted: true });
|
||||
}, [onNext]);
|
||||
|
||||
/* ---------- Phase 1: consent ---------- */
|
||||
if (consent === null) {
|
||||
return (
|
||||
<div className="rounded-3xl border border-stone-700 bg-stone-900 p-8 shadow-large animate-fade-up relative">
|
||||
<button
|
||||
onClick={() => setConsent(false)}
|
||||
className="absolute top-4 right-4 text-sm font-medium text-white/90 hover:text-white transition-colors">
|
||||
Setup later
|
||||
</button>
|
||||
return (
|
||||
<div className="rounded-3xl border border-stone-700 bg-stone-900 p-8 shadow-large animate-fade-up">
|
||||
<div className="flex flex-col items-center mb-5">
|
||||
<img src="/ollama.svg" alt="Ollama" className="w-16 h-16 mb-3" />
|
||||
<h1 className="text-xl font-bold mb-2">Run AI Models Locally with Ollama</h1>
|
||||
<p className="opacity-70 text-sm text-center">
|
||||
OpenHuman will auto-install Ollama for you so that you can download and run AI models
|
||||
locally on your device.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center mb-5">
|
||||
<img src="/ollama.svg" alt="Ollama" className="w-16 h-16 mb-3" />
|
||||
<h1 className="text-xl font-bold mb-2">Run AI Models Locally with Ollama</h1>
|
||||
<p className="opacity-70 text-sm text-center">
|
||||
OpenHuman will auto-install Ollama for you so that you can download and run AI models
|
||||
locally on your device.
|
||||
<div className="space-y-2 mb-5">
|
||||
<div className="rounded-xl border border-sage-500/30 bg-sage-500/10 px-3 py-2">
|
||||
<p className="text-xs">
|
||||
<span className="font-semibold">Complete Privacy</span>
|
||||
<span className="opacity-80">
|
||||
- all data stays on your device. Nothing is sent to any third party or cloud.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 mb-5">
|
||||
<div className="rounded-xl border border-sage-500/30 bg-sage-500/10 px-3 py-2">
|
||||
<p className="text-xs">
|
||||
<span className="font-semibold">Complete Privacy</span>
|
||||
<span className="opacity-80">
|
||||
- all data stays on your device. Nothing is sent to any third party or cloud.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-sage-500/30 bg-sage-500/10 px-3 py-2">
|
||||
<p className="text-xs">
|
||||
<span className="font-semibold">Absolutely Free</span>
|
||||
<span className="opacity-80">
|
||||
- Ollama and the AI models are open-source. No subscription needed.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 px-3 py-2">
|
||||
<p className="text-xs">
|
||||
<span className="font-semibold">Resource impact</span>
|
||||
<span className="opacity-80">
|
||||
- uses some disk space and RAM. We will optimize this for your device.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-sage-500/30 bg-sage-500/10 px-3 py-2">
|
||||
<p className="text-xs">
|
||||
<span className="font-semibold">Absolutely Free</span>
|
||||
<span className="opacity-80">
|
||||
- Ollama and the AI models are open-source. No subscription needed.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleConsent}
|
||||
className="w-full py-2.5 btn-primary text-sm font-medium rounded-xl border transition-colors border-stone-600 hover:border-sage-500 hover:bg-sage-500/10">
|
||||
Use Local Models
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Phase 2: consent=false, skip ---------- */
|
||||
if (consent === false) {
|
||||
return (
|
||||
<div className="rounded-3xl border border-stone-700 bg-stone-900 p-8 shadow-large animate-fade-up">
|
||||
<div className="flex flex-col items-center mb-5">
|
||||
<img src="/ollama.svg" alt="Ollama" className="w-12 h-12 mb-3 opacity-50" />
|
||||
<h1 className="text-xl font-bold mb-2">Ollama Skipped</h1>
|
||||
<p className="opacity-70 text-sm text-center">
|
||||
No worries — you can download Ollama and set up local models anytime in Settings.
|
||||
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 px-3 py-2">
|
||||
<p className="text-xs">
|
||||
<span className="font-semibold">Resource impact</span>
|
||||
<span className="opacity-80">
|
||||
- uses some disk space and RAM. We will optimize this for your device.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onNext({ consentGiven: false, downloadStarted: false })}
|
||||
className="btn-primary w-full py-2.5 text-sm font-medium rounded-xl">
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* consent=true triggers download + advance via handleConsent — render nothing */
|
||||
return null;
|
||||
<button
|
||||
onClick={handleConsent}
|
||||
className="w-full py-2.5 btn-primary text-sm font-medium rounded-xl border transition-colors border-stone-600 hover:border-sage-500 hover:bg-sage-500/10">
|
||||
Use Local Models
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LocalAIStep;
|
||||
|
||||
@@ -17,9 +17,10 @@ const IMPORT_SLOTS_INITIAL = MNEMONIC_GENERATE_WORD_COUNT;
|
||||
|
||||
interface MnemonicStepProps {
|
||||
onComplete: () => void | Promise<void>;
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
const MnemonicStep = ({ onComplete }: MnemonicStepProps) => {
|
||||
const MnemonicStep = ({ onComplete, onBack: _onBack }: MnemonicStepProps) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const user = useAppSelector(state => state.user.user);
|
||||
const [mode, setMode] = useState<'generate' | 'import'>('generate');
|
||||
@@ -183,10 +184,11 @@ const MnemonicStep = ({ onComplete }: MnemonicStepProps) => {
|
||||
{mode === 'generate' ? (
|
||||
<>
|
||||
<div className="text-center mb-4">
|
||||
<h1 className="text-xl font-bold mb-2">Your Recovery Phrase</h1>
|
||||
<h1 className="text-xl font-bold mb-2">Lastly, your Recovery Phrase</h1>
|
||||
<p className="opacity-70 text-sm">
|
||||
Write down these {MNEMONIC_GENERATE_WORD_COUNT} words in order and store them
|
||||
somewhere safe. This phrase encrypts your data and can never be recovered if lost.
|
||||
somewhere safe. This phrase encrypts your data and can never be recovered if lost. You
|
||||
can always back up later.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -324,12 +326,12 @@ const MnemonicStep = ({ onComplete }: MnemonicStepProps) => {
|
||||
<button
|
||||
onClick={handleContinue}
|
||||
disabled={!canContinue || loading}
|
||||
className="btn-primary w-full py-2.5 text-sm font-medium rounded-xl disabled:opacity-60 disabled:cursor-not-allowed">
|
||||
className="w-full py-2.5 btn-primary text-sm font-medium rounded-xl border transition-colors border-stone-600 hover:border-sage-500 hover:bg-sage-500/10">
|
||||
{loading
|
||||
? 'Securing Your Data...'
|
||||
: mode === 'import'
|
||||
? 'Import & Finish Setup'
|
||||
: 'Finish Setup'}
|
||||
: "I'm Ready! Let's Go!"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -9,9 +9,10 @@ import { useAppDispatch, useAppSelector } from '../../../store/hooks';
|
||||
|
||||
interface ScreenPermissionsStepProps {
|
||||
onNext: (accessibilityPermissionGranted: boolean) => void;
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
const ScreenPermissionsStep = ({ onNext }: ScreenPermissionsStepProps) => {
|
||||
const ScreenPermissionsStep = ({ onNext, onBack: _onBack }: ScreenPermissionsStepProps) => {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useAppDispatch();
|
||||
const { status, isLoading, isRequestingPermissions } = useAppSelector(
|
||||
@@ -40,13 +41,7 @@ const ScreenPermissionsStep = ({ onNext }: ScreenPermissionsStepProps) => {
|
||||
<p className="text-sm font-medium mb-1">Complete Privacy</p>
|
||||
<p className="text-xs opacity-80">
|
||||
All screenshots and accessibility information gets processed locally by your local AI
|
||||
model. No data is sent to any third party.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-sage-500/30 bg-sage-500/10 p-3">
|
||||
<p className="text-sm font-medium mb-1">Absolutely Free</p>
|
||||
<p className="text-xs opacity-80">
|
||||
Processing uses your local AI model and hence remains free.
|
||||
model. No data is sent to any third party or cloud.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-stone-700 bg-stone-900 p-3">
|
||||
@@ -67,27 +62,29 @@ const ScreenPermissionsStep = ({ onNext }: ScreenPermissionsStepProps) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 mb-4">
|
||||
{!isGranted ? (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void dispatch(requestAccessibilityPermission('accessibility'))}
|
||||
disabled={isRequestingPermissions || isLoading}
|
||||
className="btn-primary w-full py-2.5 text-sm font-medium rounded-xl disabled:opacity-60">
|
||||
{isRequestingPermissions ? 'Requesting...' : 'Request Permissions'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/settings/accessibility')}
|
||||
className="w-full py-2.5 text-sm font-medium rounded-xl border border-stone-600 hover:border-stone-500 transition-colors">
|
||||
Open Accessibility
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void dispatch(requestAccessibilityPermission('accessibility'))}
|
||||
disabled={isRequestingPermissions || isLoading}
|
||||
className="btn-primary w-full py-2.5 text-sm font-medium rounded-xl disabled:opacity-60">
|
||||
{isRequestingPermissions ? 'Requesting...' : 'Request Permission'}
|
||||
onClick={() => onNext(isGranted)}
|
||||
className="w-full py-2.5 btn-primary text-sm font-medium rounded-xl border transition-colors border-stone-600 hover:border-sage-500 hover:bg-sage-500/10">
|
||||
Continue
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/settings/accessibility')}
|
||||
className="w-full py-2.5 text-sm font-medium rounded-xl border border-stone-600 hover:border-stone-500 transition-colors">
|
||||
Open Accessibility
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => onNext(isGranted)}
|
||||
className="w-full py-2.5 text-sm font-medium rounded-xl bg-stone-800 hover:bg-stone-700 transition-colors">
|
||||
{isGranted ? 'Continue' : 'Continue Without Permission'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,105 +1,111 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import GoogleIcon from '../../../assets/icons/GoogleIcon';
|
||||
import MetamaskIcon from '../../../assets/icons/metamask.svg';
|
||||
import NotionIcon from '../../../assets/icons/notion.svg';
|
||||
import TelegramIcon from '../../../assets/icons/telegram.svg';
|
||||
import {
|
||||
DefaultIcon,
|
||||
SKILL_ICONS,
|
||||
SkillActionButton,
|
||||
type SkillListEntry,
|
||||
STATUS_DISPLAY,
|
||||
} from '../../../components/skills/shared';
|
||||
import SkillSetupModal from '../../../components/skills/SkillSetupModal';
|
||||
import { useAllSkillSnapshots } from '../../../lib/skills/hooks';
|
||||
import { useAvailableSkills, useSkillConnectionStatus } from '../../../lib/skills/hooks';
|
||||
import { installSkill } from '../../../lib/skills/skillsApi';
|
||||
import type { SkillConnectionStatus } from '../../../lib/skills/types';
|
||||
import { IS_DEV } from '../../../utils/config';
|
||||
|
||||
interface SkillsStepProps {
|
||||
onComplete: (connectedSources: string[]) => void | Promise<void>;
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
interface SourceOption {
|
||||
id: string;
|
||||
skillId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: React.ReactElement;
|
||||
/** Status dot color for skill connection status */
|
||||
function statusDotClass(status: SkillConnectionStatus): string {
|
||||
switch (status) {
|
||||
case 'connected':
|
||||
return 'bg-sage-400';
|
||||
case 'connecting':
|
||||
return 'bg-amber-400 animate-pulse';
|
||||
case 'error':
|
||||
return 'bg-coral-400';
|
||||
default:
|
||||
return 'bg-stone-600';
|
||||
}
|
||||
}
|
||||
|
||||
const STATUS_BADGE: Record<SkillConnectionStatus, { label: string; classes: string }> = {
|
||||
connected: { label: 'Connected', classes: 'bg-sage-500/20 text-sage-400 border-sage-500/30' },
|
||||
connecting: {
|
||||
label: 'Connecting',
|
||||
classes: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
|
||||
},
|
||||
not_authenticated: {
|
||||
label: 'Not Authenticated',
|
||||
classes: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
|
||||
},
|
||||
disconnected: {
|
||||
label: 'Disconnected',
|
||||
classes: 'bg-stone-500/20 text-stone-400 border-stone-500/30',
|
||||
},
|
||||
error: { label: 'Error', classes: 'bg-coral-500/20 text-coral-400 border-coral-500/30' },
|
||||
offline: { label: 'Unavailable', classes: 'bg-stone-500/20 text-stone-400 border-stone-500/30' },
|
||||
setup_required: {
|
||||
label: 'Setup Required',
|
||||
classes: 'bg-primary-500/20 text-primary-400 border-primary-500/30',
|
||||
},
|
||||
};
|
||||
function SkillRow({ skill, onSetup }: { skill: SkillListEntry; onSetup: () => void }) {
|
||||
const connectionStatus = useSkillConnectionStatus(skill.id);
|
||||
const statusDisplay = STATUS_DISPLAY[connectionStatus] || STATUS_DISPLAY.offline;
|
||||
|
||||
const SOURCE_OPTIONS: SourceOption[] = [
|
||||
{
|
||||
id: 'telegram',
|
||||
skillId: 'telegram',
|
||||
name: 'Telegram',
|
||||
description: 'Sync chats and message context for faster assistant responses.',
|
||||
icon: <img src={TelegramIcon} alt="Telegram" className="w-5 h-5" />,
|
||||
},
|
||||
{
|
||||
id: 'email',
|
||||
skillId: 'email',
|
||||
name: 'Google Email',
|
||||
description: 'Connect inbox workflows, summaries, and follow-up reminders.',
|
||||
icon: <GoogleIcon className="w-5 h-5" />,
|
||||
},
|
||||
{
|
||||
id: 'notion',
|
||||
skillId: 'notion',
|
||||
name: 'Notion',
|
||||
description: 'Bring docs and tasks into assistant context.',
|
||||
icon: <img src={NotionIcon} alt="Notion" className="w-5 h-5" />,
|
||||
},
|
||||
{
|
||||
id: 'wallet',
|
||||
skillId: 'wallet',
|
||||
name: 'Wallet',
|
||||
description: 'Enable secure on-chain workflows and portfolio actions.',
|
||||
icon: <img src={MetamaskIcon} alt="Wallet" className="w-5 h-5" />,
|
||||
},
|
||||
];
|
||||
return (
|
||||
<div className="flex items-center gap-3 p-3 rounded-xl border border-stone-700 bg-stone-900 hover:border-stone-600 transition-colors">
|
||||
<div className="w-6 h-6 flex items-center justify-center text-white opacity-70 flex-shrink-0">
|
||||
{skill.icon || <DefaultIcon />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-white truncate">{skill.name}</span>
|
||||
<div
|
||||
className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${statusDotClass(connectionStatus)}`}
|
||||
/>
|
||||
<span className={`text-xs flex-shrink-0 ${statusDisplay.color}`}>
|
||||
{statusDisplay.text}
|
||||
</span>
|
||||
</div>
|
||||
{skill.description && (
|
||||
<p className="text-xs opacity-50 mt-0.5 truncate">{skill.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<SkillActionButton skill={skill} connectionStatus={connectionStatus} onOpenModal={onSetup} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SkillsStep = ({ onComplete }: SkillsStepProps) => {
|
||||
const SkillsStep = ({ onComplete, onBack: _onBack }: SkillsStepProps) => {
|
||||
const { skills: availableSkills, loading: skillsLoading } = useAvailableSkills();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [installing, setInstalling] = useState<string | null>(null);
|
||||
const [setupModalOpen, setSetupModalOpen] = useState(false);
|
||||
const [activeSkillId, setActiveSkillId] = useState<string | null>(null);
|
||||
const [activeSkillName, setActiveSkillName] = useState('');
|
||||
const [activeSkillDescription, setActiveSkillDescription] = useState('');
|
||||
const snapshots = useAllSkillSnapshots();
|
||||
const [activeSkillHasSetup, setActiveSkillHasSetup] = useState(false);
|
||||
|
||||
const sources = useMemo(() => {
|
||||
return SOURCE_OPTIONS.map(option => {
|
||||
const snap = snapshots.find(s => s.skill_id === option.skillId);
|
||||
const connectionStatus: SkillConnectionStatus = snap
|
||||
? (snap.connection_status as SkillConnectionStatus) || 'offline'
|
||||
: 'offline';
|
||||
return { ...option, snap, connectionStatus };
|
||||
});
|
||||
}, [snapshots]);
|
||||
const skillsList: SkillListEntry[] = useMemo(() => {
|
||||
return availableSkills
|
||||
.filter(e => {
|
||||
if (e.id.includes('_')) return false;
|
||||
if (!IS_DEV && e.ignore_in_production) return false;
|
||||
return true;
|
||||
})
|
||||
.map(e => ({
|
||||
id: e.id,
|
||||
name: e.name || e.id.charAt(0).toUpperCase() + e.id.slice(1),
|
||||
description: e.description || '',
|
||||
icon: SKILL_ICONS[e.id],
|
||||
ignoreInProduction: e.ignore_in_production,
|
||||
hasSetup: !!(e.setup && e.setup.required),
|
||||
}));
|
||||
}, [availableSkills]);
|
||||
|
||||
const connectedSources = sources
|
||||
.filter(source => source.connectionStatus === 'connected')
|
||||
.map(source => source.id);
|
||||
const sortedSkills = useMemo(() => {
|
||||
return [...skillsList].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [skillsList]);
|
||||
|
||||
const handleConnect = (source: (typeof sources)[number]) => {
|
||||
setActiveSkillId(source.skillId);
|
||||
setActiveSkillName(source.snap?.name || source.name);
|
||||
setActiveSkillDescription(source.description);
|
||||
const openSkillSetup = async (skill: SkillListEntry) => {
|
||||
try {
|
||||
setInstalling(skill.id);
|
||||
await installSkill(skill.id);
|
||||
} catch (err) {
|
||||
console.warn(`[SkillsStep] install failed for ${skill.id}, continuing:`, err);
|
||||
} finally {
|
||||
setInstalling(null);
|
||||
}
|
||||
|
||||
setActiveSkillId(skill.id);
|
||||
setActiveSkillName(skill.name);
|
||||
setActiveSkillDescription(skill.description);
|
||||
setActiveSkillHasSetup(skill.hasSetup);
|
||||
setSetupModalOpen(true);
|
||||
};
|
||||
|
||||
@@ -107,7 +113,8 @@ const SkillsStep = ({ onComplete }: SkillsStepProps) => {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
await onComplete(connectedSources);
|
||||
const connectedIds = sortedSkills.map(s => s.id);
|
||||
await onComplete(connectedIds);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Something went wrong. Please try again.');
|
||||
} finally {
|
||||
@@ -120,53 +127,36 @@ const SkillsStep = ({ onComplete }: SkillsStepProps) => {
|
||||
<div className="text-center mb-4">
|
||||
<h1 className="text-xl font-bold mb-2">Install Skills</h1>
|
||||
<p className="opacity-70 text-sm">
|
||||
Connect integrations to give OpenHuman richer context. You can skip this and set them up
|
||||
later.
|
||||
Skills give OpenHuman richer context & access to your workflow. All data consumed by
|
||||
skills is saved and processed locally. You can connect as many skills as you want.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 mb-4">
|
||||
{sources.map(source => {
|
||||
const badge = STATUS_BADGE[source.connectionStatus];
|
||||
return (
|
||||
<button
|
||||
key={source.id}
|
||||
type="button"
|
||||
onClick={() => handleConnect(source)}
|
||||
disabled={!source.snap}
|
||||
className={`w-full flex items-start space-x-3 p-3 bg-black/50 border border-stone-700 rounded-xl text-left transition-all ${
|
||||
source.snap
|
||||
? 'hover:border-stone-600 hover:shadow-medium'
|
||||
: 'opacity-60 cursor-not-allowed'
|
||||
}`}>
|
||||
<div className="flex-shrink-0 mt-0.5">{source.icon}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium text-sm">{source.name}</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded border ${badge.classes}`}>
|
||||
{badge.label}
|
||||
</span>
|
||||
</div>
|
||||
<p className="opacity-70 text-xs mt-1">{source.description}</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div className="space-y-2 mb-4 max-h-[380px] overflow-y-auto pr-1">
|
||||
{skillsLoading || installing ? (
|
||||
<div className="rounded-2xl p-6 text-center">
|
||||
<p className="text-sm text-stone-500">
|
||||
{installing ? `Installing ${installing}...` : 'Loading skills...'}
|
||||
</p>
|
||||
</div>
|
||||
) : sortedSkills.length === 0 ? (
|
||||
<div className="rounded-2xl p-6 text-center">
|
||||
<p className="text-sm text-stone-500">No skills discovered</p>
|
||||
</div>
|
||||
) : (
|
||||
sortedSkills.map(skill => (
|
||||
<SkillRow key={skill.id} skill={skill} onSetup={() => openSkillSetup(skill)} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{connectedSources.length === 0 && (
|
||||
<p className="text-xs text-amber-300 mb-3">
|
||||
No skills connected yet. You can finish and configure later.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && <p className="text-coral-400 text-sm mb-3 text-center">{error}</p>}
|
||||
|
||||
<button
|
||||
onClick={handleFinish}
|
||||
disabled={loading}
|
||||
className="btn-primary w-full py-2.5 text-sm font-medium rounded-xl disabled:opacity-60 disabled:cursor-not-allowed">
|
||||
{loading ? 'Finishing...' : 'Finish Setup'}
|
||||
className="w-full py-2.5 btn-primary text-sm font-medium rounded-xl border transition-colors border-stone-600 hover:border-sage-500 hover:bg-sage-500/10">
|
||||
{loading ? 'Loading...' : 'Continue'}
|
||||
</button>
|
||||
|
||||
{setupModalOpen && activeSkillId && (
|
||||
@@ -174,6 +164,7 @@ const SkillsStep = ({ onComplete }: SkillsStepProps) => {
|
||||
skillId={activeSkillId}
|
||||
skillName={activeSkillName}
|
||||
skillDescription={activeSkillDescription}
|
||||
hasSetup={activeSkillHasSetup}
|
||||
onClose={() => {
|
||||
setSetupModalOpen(false);
|
||||
setActiveSkillId(null);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
|
||||
interface ToolsStepProps {
|
||||
onNext: (enabledTools: string[]) => void;
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
const CATEGORY_DESCRIPTIONS: Record<ToolCategory, string> = {
|
||||
@@ -20,7 +21,7 @@ const CATEGORY_DESCRIPTIONS: Record<ToolCategory, string> = {
|
||||
Automation: 'Cron jobs and scheduled tasks',
|
||||
};
|
||||
|
||||
const ToolsStep = ({ onNext }: ToolsStepProps) => {
|
||||
const ToolsStep = ({ onNext, onBack: _onBack }: ToolsStepProps) => {
|
||||
const toolsByCategory = getToolsByCategory();
|
||||
const [enabled, setEnabled] = useState<Record<string, boolean>>(() => {
|
||||
const defaults: Record<string, boolean> = {};
|
||||
@@ -93,7 +94,7 @@ const ToolsStep = ({ onNext }: ToolsStepProps) => {
|
||||
|
||||
<button
|
||||
onClick={() => onNext(enabledList)}
|
||||
className="btn-primary w-full py-2.5 text-sm font-medium rounded-xl">
|
||||
className="w-full py-2.5 btn-primary text-sm font-medium rounded-xl border transition-colors border-stone-600 hover:border-sage-500 hover:bg-sage-500/10">
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
getAuthState,
|
||||
getSessionToken,
|
||||
isTauri,
|
||||
openhumanWorkspaceOnboardingFlagSet,
|
||||
setOnboardingCompleted,
|
||||
} from '../utils/tauriCommands';
|
||||
|
||||
const AUTH_BOOTSTRAP_TIMEOUT_MS = 5000;
|
||||
@@ -56,9 +56,9 @@ const UserProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
} else if (!authState.is_authenticated && token) {
|
||||
await dispatch(clearToken());
|
||||
try {
|
||||
await openhumanWorkspaceOnboardingFlagSet(false);
|
||||
await setOnboardingCompleted(false);
|
||||
} catch (err) {
|
||||
console.warn('[auth] Failed to clear workspace onboarding flag:', err);
|
||||
console.warn('[auth] Failed to clear onboarding_completed in config:', err);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -6,7 +6,6 @@ import authReducer, {
|
||||
setAnalyticsForUser,
|
||||
setEncryptionKeyForUser,
|
||||
setOnboardedForUser,
|
||||
setOnboardingDeferredForUser,
|
||||
setOnboardingTasksForUser,
|
||||
setPrimaryWalletAddressForUser,
|
||||
setToken,
|
||||
@@ -101,7 +100,6 @@ describe('authSlice', () => {
|
||||
store.dispatch(setAnalyticsForUser({ userId: 'u1', enabled: true }));
|
||||
store.dispatch(setEncryptionKeyForUser({ userId: 'u1', key: 'aes-hex' }));
|
||||
store.dispatch(setPrimaryWalletAddressForUser({ userId: 'u1', address: '0xabc' }));
|
||||
store.dispatch(setOnboardingDeferredForUser({ userId: 'u1', deferred: true }));
|
||||
store.dispatch(
|
||||
setOnboardingTasksForUser({
|
||||
userId: 'u1',
|
||||
@@ -134,6 +132,5 @@ describe('authSlice', () => {
|
||||
expect(after.isAnalyticsEnabledByUser).toEqual({});
|
||||
expect(after.encryptionKeyByUser).toEqual({});
|
||||
expect(after.primaryWalletAddressByUser).toEqual({});
|
||||
expect(after.onboardingDeferredByUser).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,9 +17,3 @@ export const selectHasIncompleteOnboarding = (state: RootState): boolean => {
|
||||
if (!userId) return false;
|
||||
return state.auth.hasIncompleteOnboardingByUser[userId] ?? false;
|
||||
};
|
||||
|
||||
export const selectOnboardingDeferred = (state: RootState): boolean => {
|
||||
const userId = state.user.user?._id;
|
||||
if (!userId) return false;
|
||||
return !!state.auth.onboardingDeferredByUser?.[userId];
|
||||
};
|
||||
|
||||
@@ -19,8 +19,6 @@ export interface AuthState {
|
||||
encryptionKeyByUser: Record<string, string>;
|
||||
/** Primary EVM wallet address (0x...) derived from mnemonic, per user id */
|
||||
primaryWalletAddressByUser: Record<string, string>;
|
||||
/** Timestamp when user deferred onboarding (value = epoch ms), per user id */
|
||||
onboardingDeferredByUser: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface UserOnboardingTasks {
|
||||
@@ -41,7 +39,6 @@ const initialState: AuthState = {
|
||||
isAnalyticsEnabledByUser: {},
|
||||
encryptionKeyByUser: {},
|
||||
primaryWalletAddressByUser: {},
|
||||
onboardingDeferredByUser: {},
|
||||
};
|
||||
|
||||
const authSlice = createSlice({
|
||||
@@ -62,7 +59,6 @@ const authSlice = createSlice({
|
||||
state.isAnalyticsEnabledByUser = {};
|
||||
state.encryptionKeyByUser = {};
|
||||
state.primaryWalletAddressByUser = {};
|
||||
state.onboardingDeferredByUser = {};
|
||||
},
|
||||
setOnboardedForUser: (state, action: PayloadAction<{ userId: string; value: boolean }>) => {
|
||||
const { userId, value } = action.payload;
|
||||
@@ -94,17 +90,6 @@ const authSlice = createSlice({
|
||||
const { userId, address } = action.payload;
|
||||
state.primaryWalletAddressByUser[userId] = address;
|
||||
},
|
||||
setOnboardingDeferredForUser: (
|
||||
state,
|
||||
action: PayloadAction<{ userId: string; deferred: boolean }>
|
||||
) => {
|
||||
const { userId, deferred } = action.payload;
|
||||
if (deferred) {
|
||||
state.onboardingDeferredByUser[userId] = Date.now();
|
||||
} else {
|
||||
delete state.onboardingDeferredByUser[userId];
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -123,6 +108,5 @@ export const {
|
||||
setOnboardingTasksForUser,
|
||||
setEncryptionKeyForUser,
|
||||
setPrimaryWalletAddressForUser,
|
||||
setOnboardingDeferredForUser,
|
||||
} = authSlice.actions;
|
||||
export default authSlice.reducer;
|
||||
|
||||
@@ -43,7 +43,6 @@ const authPersistConfig = {
|
||||
'isAnalyticsEnabledByUser',
|
||||
'encryptionKeyByUser',
|
||||
'primaryWalletAddressByUser',
|
||||
'onboardingDeferredByUser',
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -836,7 +836,29 @@ export interface RuntimeFlags {
|
||||
log_prompts: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_WORKSPACE_ONBOARDING_FLAG = '.skip_onboarding';
|
||||
/** Read onboarding_completed from core config. */
|
||||
export async function getOnboardingCompleted(): Promise<boolean> {
|
||||
if (!isTauri()) return false;
|
||||
const res = await callCoreRpc<boolean | { result: boolean }>({
|
||||
method: 'openhuman.config_get_onboarding_completed',
|
||||
});
|
||||
// RpcOutcome may wrap value in { result, logs } when logs are present
|
||||
if (typeof res === 'boolean') return res;
|
||||
if (res && typeof res === 'object' && 'result' in res) return res.result;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Write onboarding_completed to core config. */
|
||||
export async function setOnboardingCompleted(value: boolean): Promise<boolean> {
|
||||
if (!isTauri()) return false;
|
||||
const res = await callCoreRpc<boolean | { result: boolean }>({
|
||||
method: 'openhuman.config_set_onboarding_completed',
|
||||
params: { value },
|
||||
});
|
||||
if (typeof res === 'boolean') return res;
|
||||
if (res && typeof res === 'object' && 'result' in res) return res.result;
|
||||
return false;
|
||||
}
|
||||
|
||||
export interface LocalAiStatus {
|
||||
state: string;
|
||||
@@ -1108,31 +1130,6 @@ export async function openhumanGetRuntimeFlags(): Promise<CommandResponse<Runtim
|
||||
});
|
||||
}
|
||||
|
||||
export async function openhumanWorkspaceOnboardingFlagExists(
|
||||
flagName = DEFAULT_WORKSPACE_ONBOARDING_FLAG
|
||||
): Promise<boolean> {
|
||||
if (!isTauri()) {
|
||||
return false;
|
||||
}
|
||||
return await callCoreRpc<boolean>({
|
||||
method: 'openhuman.workspace_onboarding_flag_exists',
|
||||
params: { flag_name: flagName },
|
||||
});
|
||||
}
|
||||
|
||||
export async function openhumanWorkspaceOnboardingFlagSet(
|
||||
value: boolean,
|
||||
flagName = DEFAULT_WORKSPACE_ONBOARDING_FLAG
|
||||
): Promise<boolean> {
|
||||
if (!isTauri()) {
|
||||
return false;
|
||||
}
|
||||
return await callCoreRpc<boolean>({
|
||||
method: 'openhuman.workspace_onboarding_flag_set',
|
||||
params: { flag_name: flagName, value },
|
||||
});
|
||||
}
|
||||
|
||||
export async function openhumanSetBrowserAllowAll(
|
||||
enabled: boolean
|
||||
): Promise<CommandResponse<RuntimeFlags>> {
|
||||
|
||||
@@ -432,6 +432,24 @@ pub async fn workspace_onboarding_flag_set(
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn get_onboarding_completed() -> Result<RpcOutcome<bool>, String> {
|
||||
let config = load_config_with_timeout().await?;
|
||||
Ok(RpcOutcome::single_log(
|
||||
config.onboarding_completed,
|
||||
"onboarding_completed read from config",
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn set_onboarding_completed(value: bool) -> Result<RpcOutcome<bool>, String> {
|
||||
let mut config = load_config_with_timeout().await?;
|
||||
config.onboarding_completed = value;
|
||||
config.save().await.map_err(|e| e.to_string())?;
|
||||
Ok(RpcOutcome::single_log(
|
||||
config.onboarding_completed,
|
||||
"onboarding_completed saved to config",
|
||||
))
|
||||
}
|
||||
|
||||
pub fn agent_server_status() -> RpcOutcome<serde_json::Value> {
|
||||
let running = crate::openhuman::service::mock::mock_agent_running().unwrap_or(true);
|
||||
log::info!("[config] agent_server_status requested: running={running}");
|
||||
|
||||
@@ -117,6 +117,10 @@ pub struct Config {
|
||||
|
||||
#[serde(default)]
|
||||
pub orchestrator: OrchestratorConfig,
|
||||
|
||||
/// Whether the user has completed the onboarding flow.
|
||||
#[serde(default)]
|
||||
pub onboarding_completed: bool,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
@@ -163,6 +167,7 @@ impl Default for Config {
|
||||
query_classification: QueryClassificationConfig::default(),
|
||||
learning: LearningConfig::default(),
|
||||
orchestrator: OrchestratorConfig::default(),
|
||||
onboarding_completed: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,11 @@ struct WorkspaceOnboardingFlagSetParams {
|
||||
value: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OnboardingCompletedSetParams {
|
||||
value: bool,
|
||||
}
|
||||
|
||||
pub fn all_controller_schemas() -> Vec<ControllerSchema> {
|
||||
vec![
|
||||
schemas("get_config"),
|
||||
@@ -86,6 +91,8 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
|
||||
schemas("update_analytics_settings"),
|
||||
schemas("get_analytics_settings"),
|
||||
schemas("agent_server_status"),
|
||||
schemas("get_onboarding_completed"),
|
||||
schemas("set_onboarding_completed"),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -147,6 +154,14 @@ pub fn all_registered_controllers() -> Vec<RegisteredController> {
|
||||
schema: schemas("agent_server_status"),
|
||||
handler: handle_agent_server_status,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("get_onboarding_completed"),
|
||||
handler: handle_get_onboarding_completed,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("set_onboarding_completed"),
|
||||
handler: handle_set_onboarding_completed,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -368,6 +383,35 @@ pub fn schemas(function: &str) -> ControllerSchema {
|
||||
inputs: vec![],
|
||||
outputs: vec![json_output("status", "Agent server status payload.")],
|
||||
},
|
||||
"get_onboarding_completed" => ControllerSchema {
|
||||
namespace: "config",
|
||||
function: "get_onboarding_completed",
|
||||
description: "Read whether the user has completed the onboarding flow.",
|
||||
inputs: vec![],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "completed",
|
||||
ty: TypeSchema::Bool,
|
||||
comment: "True when onboarding has been completed.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"set_onboarding_completed" => ControllerSchema {
|
||||
namespace: "config",
|
||||
function: "set_onboarding_completed",
|
||||
description: "Mark the onboarding flow as completed or reset it.",
|
||||
inputs: vec![FieldSchema {
|
||||
name: "value",
|
||||
ty: TypeSchema::Bool,
|
||||
comment: "True to mark completed, false to reset.",
|
||||
required: true,
|
||||
}],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "completed",
|
||||
ty: TypeSchema::Bool,
|
||||
comment: "Updated onboarding completed state.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
_ => ControllerSchema {
|
||||
namespace: "config",
|
||||
function: "unknown",
|
||||
@@ -521,6 +565,17 @@ fn handle_agent_server_status(_params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async { to_json(config_rpc::agent_server_status()) })
|
||||
}
|
||||
|
||||
fn handle_get_onboarding_completed(_params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async { to_json(config_rpc::get_onboarding_completed().await?) })
|
||||
}
|
||||
|
||||
fn handle_set_onboarding_completed(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let payload = deserialize_params::<OnboardingCompletedSetParams>(params)?;
|
||||
to_json(config_rpc::set_onboarding_completed(payload.value).await?)
|
||||
})
|
||||
}
|
||||
|
||||
fn deserialize_params<T: DeserializeOwned>(params: Map<String, Value>) -> Result<T, String> {
|
||||
serde_json::from_value(Value::Object(params)).map_err(|e| format!("invalid params: {e}"))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user