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:
Steven Enamakel
2026-04-01 13:29:19 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 88a4647dae
commit dd2e33e1c6
21 changed files with 351 additions and 514 deletions
-2
View File
@@ -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>
+24 -38
View File
@@ -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
);
+2 -2
View File
@@ -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'
}`}
/>
))}
-70
View File
@@ -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');
});
});
+5 -8
View File
@@ -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();
+33 -32
View File
@@ -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>
+40 -73
View File
@@ -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">
&nbsp;- 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">
&nbsp;- 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">
&nbsp;- 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">
&nbsp;- 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">
&nbsp;- 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">
&nbsp;- 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>
);
};
+109 -118
View File
@@ -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);
+3 -2
View File
@@ -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>
+3 -3
View File
@@ -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({});
});
});
-6
View File
@@ -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];
};
-16
View File
@@ -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;
-1
View File
@@ -43,7 +43,6 @@ const authPersistConfig = {
'isAnalyticsEnabledByUser',
'encryptionKeyByUser',
'primaryWalletAddressByUser',
'onboardingDeferredByUser',
],
};
+23 -26
View File
@@ -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>> {
+18
View File
@@ -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}");
+5
View File
@@ -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,
}
}
}
+55
View File
@@ -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}"))
}