feat(home): banners, welcome typewriter, conversation gating (#936)

This commit is contained in:
Steven Enamakel
2026-04-26 01:15:57 -07:00
committed by GitHub
parent bdcb425370
commit 7867496d86
19 changed files with 570 additions and 663 deletions
+5
View File
@@ -410,6 +410,10 @@ jobs:
# come from the static `app/src-tauri/tauri.conf.json`.
uses: actions/github-script@v7
env:
BASE_URL: ${{ needs.prepare-build.outputs.base_url }}
UPDATER_PUBLIC_KEY: ${{ secrets.UPDATER_PUBLIC_KEY || vars.UPDATER_PUBLIC_KEY }}
UPDATER_ENDPOINT: ${{ vars.UPDATER_ENDPOINT }}
UPDATER_REPO: tinyhumansai/openhuman
WITH_UPDATER: "true"
with:
script: |
@@ -522,6 +526,7 @@ jobs:
MACOSX_DEPLOYMENT_TARGET: ${{ matrix.settings.platform == 'macos-latest' && '10.15' || '' }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD || secrets.UPDATER_PRIVATE_KEY_PASSWORD }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY || secrets.UPDATER_PRIVATE_KEY }}
WITH_UPDATER: "true"
VITE_MINIMUM_SUPPORTED_APP_VERSION: ${{ vars.VITE_MINIMUM_SUPPORTED_APP_VERSION }}
VITE_LATEST_APP_DOWNLOAD_URL: ${{ vars.VITE_LATEST_APP_DOWNLOAD_URL }}
TAURI_CONFIG_OVERRIDE: ${{ steps.config-overrides.outputs.json }}
Generated
+1 -1
View File
@@ -4420,7 +4420,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "openhuman"
version = "0.53.0"
version = "0.53.1"
dependencies = [
"aes-gcm",
"anyhow",
+1 -1
View File
@@ -4,7 +4,7 @@ version = 4
[[package]]
name = "OpenHuman"
version = "0.53.0"
version = "0.53.1"
dependencies = [
"anyhow",
"async-trait",
@@ -384,25 +384,35 @@ export default function ComposioConnectModal({
<>
<div className="flex items-center gap-2 text-sm text-sage-700">
<div className="w-2 h-2 rounded-full bg-sage-500" />
{toolkit.name} is connected.
<div>
{toolkit.name} is connected. &nbsp;
{activeConnection && (
<span className="text-[11px] text-stone-400 font-mono">
(id: {activeConnection.id})
</span>
)}
</div>
</div>
{activeConnection && (
<p className="text-[11px] text-stone-400 font-mono break-all">
id: {activeConnection.id}
</p>
)}
<ScopeToggles
scopes={scopes}
savingScope={savingScope}
onToggle={handleToggleScope}
error={scopeError}
/>
<button
type="button"
onClick={() => void handleDisconnect()}
className="w-full rounded-xl border border-coral-200 bg-coral-50 text-coral-700 text-sm font-medium py-2.5 hover:bg-coral-100 transition-colors">
Disconnect
</button>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => void handleDisconnect()}
className="w-full rounded-xl border border-coral-200 bg-coral-50 text-coral-700 text-sm font-medium py-2.5 hover:bg-coral-100 transition-colors">
Disconnect
</button>
<button
type="button"
onClick={onClose}
className="w-full rounded-xl bg-primary-500 text-white text-sm font-medium py-2.5 hover:bg-primary-600 transition-colors">
Close
</button>
</div>
</>
)}
+115
View File
@@ -0,0 +1,115 @@
import { DISCORD_INVITE_URL } from '../../utils/links';
import { openUrl } from '../../utils/openUrl';
const BILLING_DASHBOARD_URL = 'https://tinyhumans.ai/dashboard';
function formatUsd(amount: number): string {
return `$${amount.toFixed(amount % 1 === 0 ? 0 : 2)}`;
}
export function UsageLimitBanner({
tone,
icon,
title,
message,
ctaLabel,
}: {
tone: 'warning' | 'danger';
icon: string;
title: string;
message: string;
ctaLabel: string;
}) {
const styles =
tone === 'danger'
? {
card: 'border-coral-200 bg-gradient-to-r from-coral-50 via-rose-50 to-orange-50',
title: 'text-coral-700',
body: 'text-coral-500',
button: 'border-coral-700 text-coral-700 hover:text-coral-800',
}
: {
card: 'border-amber-200 bg-gradient-to-r from-amber-50 via-orange-50 to-rose-50',
title: 'text-amber-700',
body: 'text-amber-600',
button: 'border-amber-700 text-amber-700 hover:text-amber-800',
};
return (
<div className={`mb-3 rounded-2xl border px-4 py-4 text-left shadow-soft ${styles.card}`}>
<div className="flex items-start gap-3">
<div className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-lg`}>
{icon}
</div>
<div className="min-w-0 flex-1">
<p className={`text-sm font-semibold ${styles.title}`}>{title}</p>
<p className={`mt-1 text-sm leading-relaxed ${styles.body}`}>
{message}&nbsp;
<button
type="button"
onClick={() => {
void openUrl(BILLING_DASHBOARD_URL);
}}
className={`cursor-pointer border-b border-dashed font-bold ${styles.button}`}>
{ctaLabel}
</button>
</p>
</div>
</div>
</div>
);
}
export function PromotionalCreditsBanner({ promoCredits }: { promoCredits: number }) {
return (
<div className="mb-3 rounded-2xl border border-amber-200 bg-gradient-to-r from-amber-50 via-orange-50 to-rose-50 px-4 py-4 text-left shadow-soft">
<div className="flex items-start gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-amber-100 text-lg">
🎉
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-amber-700">
You have {formatUsd(promoCredits)} of promotional credits.
</p>
<p className="mt-1 text-sm leading-relaxed text-amber-600">
Give OpenHuman a spin, and when you&apos;re ready for more,{' '}
<button
type="button"
onClick={() => {
void openUrl(BILLING_DASHBOARD_URL);
}}
className="cursor-pointer border-b border-amber-700 border-dashed font-bold text-amber-700 hover:text-amber-800">
get a subscription
</button>{' '}
and get 10x more usage.
</p>
</div>
</div>
</div>
);
}
export function DiscordBanner() {
return (
<button
type="button"
onClick={() => {
void openUrl(DISCORD_INVITE_URL);
}}
className="mb-3 text-left mt-3 block rounded-2xl border border-[#CDD2FF] bg-gradient-to-r from-[#F6F7FF] via-[#F1F3FF] to-[#ECEFFF] px-4 py-4 text-[#414AAE] shadow-soft transition-transform transition-colors hover:-translate-y-0.5 hover:border-[#BCC3FF] hover:from-[#EEF0FF] hover:to-[#E5E9FF]">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#5865F2]/12 text-[#5865F2]">
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M20.317 4.37A19.79 19.79 0 0 0 15.885 3c-.191.328-.403.775-.552 1.124a18.27 18.27 0 0 0-5.29 0A11.56 11.56 0 0 0 9.49 3a19.74 19.74 0 0 0-4.433 1.37C2.253 8.51 1.492 12.55 1.872 16.533a19.9 19.9 0 0 0 5.239 2.673c.423-.58.8-1.196 1.123-1.845a12.84 12.84 0 0 1-1.767-.85c.148-.106.292-.217.43-.332c3.408 1.6 7.104 1.6 10.472 0c.14.115.283.226.43.332c-.565.338-1.157.623-1.771.851c.322.648.698 1.264 1.123 1.844a19.84 19.84 0 0 0 5.241-2.673c.446-4.617-.761-8.621-3.787-12.164ZM9.46 14.088c-1.02 0-1.855-.936-1.855-2.084c0-1.148.82-2.084 1.855-2.084c1.044 0 1.87.944 1.855 2.084c0 1.148-.82 2.084-1.855 2.084Zm5.08 0c-1.02 0-1.855-.936-1.855-2.084c0-1.148.82-2.084 1.855-2.084c1.044 0 1.87.944 1.855 2.084c0 1.148-.812 2.084-1.855 2.084Z" />
</svg>
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold">Join Our Discord</div>
<div className="mt-0.5 text-sm text-[#5E66BC]">
Get updates, free merch, credits, report bugs, and be part of the OpenHuman community.
</div>
</div>
</div>
</button>
);
}
@@ -0,0 +1,46 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { DISCORD_INVITE_URL } from '../../../utils/links';
import { openUrl } from '../../../utils/openUrl';
import { DiscordBanner, PromotionalCreditsBanner, UsageLimitBanner } from '../HomeBanners';
vi.mock('../../../utils/openUrl', () => ({ openUrl: vi.fn() }));
describe('HomeBanners', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('opens the billing dashboard through openUrl from the usage limit banner', () => {
render(
<UsageLimitBanner
tone="warning"
icon="⏳"
title="Limit"
message="Usage is capped."
ctaLabel="Buy top-up credits"
/>
);
fireEvent.click(screen.getByRole('button', { name: 'Buy top-up credits' }));
expect(openUrl).toHaveBeenCalledWith('https://tinyhumans.ai/dashboard');
});
it('opens the billing dashboard through openUrl from the promotional credits banner', () => {
render(<PromotionalCreditsBanner promoCredits={12} />);
fireEvent.click(screen.getByRole('button', { name: 'get a subscription' }));
expect(openUrl).toHaveBeenCalledWith('https://tinyhumans.ai/dashboard');
});
it('opens the Discord invite through openUrl from the Discord banner', () => {
render(<DiscordBanner />);
fireEvent.click(screen.getByRole('button', { name: /join our discord/i }));
expect(openUrl).toHaveBeenCalledWith(DISCORD_INVITE_URL);
});
});
+44 -8
View File
@@ -78,6 +78,39 @@ interface ConversationsProps {
variant?: 'page' | 'sidebar';
}
export function isComposerInteractionBlocked(args: {
activeThreadId: string | null;
welcomePending: boolean;
rustChat: boolean;
}): boolean {
return !args.rustChat || Boolean(args.activeThreadId) || args.welcomePending;
}
function WelcomeThinkingTypewriter() {
const text = 'Your agent is thinking...';
const [visibleChars, setVisibleChars] = useState(0);
useEffect(() => {
const isComplete = visibleChars >= text.length;
const delayMs = isComplete ? 950 : 42;
const timeoutId = window.setTimeout(() => {
setVisibleChars(current => (current >= text.length ? 0 : current + 1));
}, delayMs);
return () => window.clearTimeout(timeoutId);
}, [text.length, visibleChars]);
return (
<p className="flex items-center text-sm text-stone-600 font-mono tracking-tight">
<span>{text.slice(0, visibleChars)}</span>
<span
aria-hidden="true"
className="ml-0.5 inline-block h-4 w-px bg-stone-400 animate-pulse"
/>
</p>
);
}
const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => {
const dispatch = useAppDispatch();
const navigate = useNavigate();
@@ -422,7 +455,7 @@ const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => {
const normalized = text ?? inputValue;
const trimmed = normalized.trim();
if (!trimmed || !selectedThreadId || composerBlocked) return;
if (!trimmed || !selectedThreadId || composerInteractionBlocked) return;
if (handleSlashCommand(trimmed)) return;
@@ -443,8 +476,6 @@ const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => {
return;
}
if (composerBlocked) return;
const sendingThreadId = selectedThreadId;
const userMessage: ThreadMessage = {
id: `msg_${globalThis.crypto.randomUUID()}`,
@@ -743,9 +774,14 @@ const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => {
? (streamingAssistantByThread[selectedThreadId] ?? null)
: null;
const inlineCompletionSuffix = getInlineCompletionSuffix(inputValue, inlineSuggestionValue);
// composerBlocked: any thread is in-flight (blocks ALL sends/voice actions).
// Blocks all composer interaction while a turn is in-flight, the
// proactive welcome opener is pending, or Rust chat is unavailable.
// isSending: the *selected* thread is in-flight (drives selected-thread UI only).
const composerBlocked = Boolean(activeThreadId);
const composerInteractionBlocked = isComposerInteractionBlocked({
activeThreadId,
welcomePending,
rustChat,
});
const isSending = Boolean(
selectedThreadId &&
(inferenceTurnLifecycleByThread[selectedThreadId] === 'started' ||
@@ -1232,7 +1268,7 @@ const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => {
<span className="w-2 h-2 rounded-full bg-stone-500 animate-bounce [animation-delay:150ms]" />
<span className="w-2 h-2 rounded-full bg-stone-500 animate-bounce [animation-delay:300ms]" />
</div>
<p className="text-sm text-stone-600">Your agent is thinking...</p>
<WelcomeThinkingTypewriter />
</div>
) : (
<div className="flex-1 flex items-center justify-center h-full">
@@ -1406,7 +1442,7 @@ const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => {
onKeyDown={handleInputKeyDown}
placeholder="Type a message..."
rows={1}
disabled={isSending || !rustChat}
disabled={composerInteractionBlocked}
className="relative z-10 w-full resize-none border-0 bg-transparent pl-4 pr-10 py-2.5 text-sm leading-normal whitespace-pre-wrap break-words font-sans text-stone-900 placeholder:text-stone-400 outline-none focus:outline-none focus-visible:outline-none focus:ring-0 focus-visible:ring-0 max-h-32 disabled:opacity-50 disabled:cursor-not-allowed"
/>
{/* Voice input mic hidden per #717 (inputMode='voice' path retained). */}
@@ -1415,7 +1451,7 @@ const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => {
onClick={() => {
void handleSendMessage();
}}
disabled={!inputValue.trim() || isSending || !rustChat}
disabled={!inputValue.trim() || composerInteractionBlocked}
className="w-10 h-10 flex items-center justify-center rounded-full bg-primary-500 hover:bg-primary-600 text-white disabled:opacity-40 disabled:cursor-not-allowed transition-colors flex-shrink-0">
{isSending ? (
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
+109 -356
View File
@@ -1,28 +1,59 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import ConnectionIndicator from '../components/ConnectionIndicator';
import {
DiscordBanner,
PromotionalCreditsBanner,
UsageLimitBanner,
} from '../components/home/HomeBanners';
import { useUsageState } from '../hooks/useUsageState';
import { useUser } from '../hooks/useUser';
import { useAppSelector } from '../store/hooks';
import { selectSocketStatus } from '../store/socketSelectors';
import {
bootstrapLocalAiWithRecommendedPreset,
ensureRecommendedLocalAiPresetIfNeeded,
triggerLocalAiAssetBootstrap,
} from '../utils/localAiBootstrap';
import { formatBytes, formatEta, progressFromStatus } from '../utils/localAiHelpers';
import {
isTauri,
type LocalAiAssetsStatus,
type LocalAiStatus,
openhumanLocalAiAssetsStatus,
openhumanLocalAiStatus,
} from '../utils/tauriCommands';
import { APP_VERSION } from '../utils/config';
export function resolveHomeUserName(user: unknown): string {
if (!user || typeof user !== 'object') return 'User';
const record = user as Record<string, unknown>;
const firstName =
(typeof record.firstName === 'string' && record.firstName.trim()) ||
(typeof record.first_name === 'string' && record.first_name.trim()) ||
'';
const lastName =
(typeof record.lastName === 'string' && record.lastName.trim()) ||
(typeof record.last_name === 'string' && record.last_name.trim()) ||
'';
const username = typeof record.username === 'string' ? record.username.trim() : '';
const email = typeof record.email === 'string' ? record.email.trim() : '';
const fullName = [firstName, lastName].filter(Boolean).join(' ').trim();
if (fullName) return fullName;
if (firstName) return firstName;
if (username) return username.startsWith('@') ? username : `@${username}`;
if (email) return email.split('@')[0] || 'User';
return 'User';
}
const Home = () => {
const { user } = useUser();
const navigate = useNavigate();
const userName = user?.firstName || 'User';
const { isRateLimited, shouldShowBudgetCompletedMessage } = useUsageState();
const _userName = resolveHomeUserName(user);
const userName = _userName.split(' ')[0]; // Get first name only
const promoCredits = user?.usage?.promotionBalanceUsd ?? 0;
const isFreeTier =
user?.subscription?.plan === 'FREE' || !user?.subscription?.hasActiveSubscription;
const showPromoBanner = isFreeTier && promoCredits > 0;
const welcomeVariants = useMemo(
() => [`Welcome, ${userName} 👋`, `Let's cook, ${userName} 🧑‍🍳.`, `Time to Zone In 🧘🏻`],
[userName]
);
const [welcomeVariantIndex, setWelcomeVariantIndex] = useState(0);
const [typedWelcome, setTypedWelcome] = useState('');
const [isDeletingWelcome, setIsDeletingWelcome] = useState(false);
// Mirror the same socket status the `ConnectionIndicator` pill consumes
// so the description copy below the pill never contradicts it (the old
// hard-coded "connected" message lied while the pill said "Connecting"
@@ -30,242 +61,92 @@ const Home = () => {
const socketStatus = useAppSelector(selectSocketStatus);
const statusCopy = {
connected:
'Your device is connected to OpenHuman AI. Keep the app running to keep the connection alive — message your assistant with the button below.',
connecting: 'Connecting to OpenHuman AI. Hang tight, this usually takes a second.',
'Your device is connected. Keep the app running to keep the connection alive. Message your assistant with the button below.',
connecting: 'Connecting. Hang tight, this usually takes a second.',
disconnected:
'Your device is offline right now. Check your network or restart the app to reconnect.',
}[socketStatus];
const [localAiStatus, setLocalAiStatus] = useState<LocalAiStatus | null>(null);
const [localAiAssets, setLocalAiAssets] = useState<LocalAiAssetsStatus | null>(null);
const [downloadBusy, setDownloadBusy] = useState(false);
const [bootstrapMessage, setBootstrapMessage] = useState<string>('');
const autoRetryDoneRef = useRef(false);
const initialBootstrapHandledRef = useRef(false);
const initialBootstrapInFlightRef = useRef(false);
const initialBootstrapPendingDownloadRef = useRef(false);
const initialBootstrapAttemptsRef = useRef(0);
// Get greeting based on time
const getGreeting = () => {
const hour = new Date().getHours();
if (hour < 12) return 'Good morning';
if (hour < 18) return 'Good afternoon';
return 'Good evening';
};
// Open in-app chat.
const handleStartCooking = async () => {
navigate('/chat');
};
const refreshLocalAiStatus = async () => {
const status = await openhumanLocalAiStatus();
setLocalAiStatus(status.result);
return status.result;
};
const runManualBootstrap = async (force: boolean) => {
setDownloadBusy(true);
setBootstrapMessage('');
try {
await bootstrapLocalAiWithRecommendedPreset(
force,
force ? '[Home re-bootstrap]' : '[Home manual bootstrap]'
);
const freshStatus = await refreshLocalAiStatus();
if (freshStatus?.state === 'ready') {
setBootstrapMessage(force ? 'Re-bootstrap complete' : 'Local AI is ready');
} else if (freshStatus?.state === 'degraded') {
setBootstrapMessage('Bootstrap failed — check warning below');
}
setTimeout(() => setBootstrapMessage(''), 3000);
} catch (error) {
console.warn('[Home] manual Local AI bootstrap failed:', error);
setBootstrapMessage('Bootstrap failed');
setTimeout(() => setBootstrapMessage(''), 3000);
} finally {
setDownloadBusy(false);
}
};
useEffect(() => {
if (!isTauri()) return;
const MAX_INITIAL_BOOTSTRAP_ATTEMPTS = 3;
let mounted = true;
const load = async () => {
try {
const [status, assets] = await Promise.all([
openhumanLocalAiStatus(),
openhumanLocalAiAssetsStatus().catch(err => {
console.warn('[Home] failed to load local AI assets status:', err);
return null;
}),
]);
if (mounted) {
setLocalAiStatus(status.result);
setLocalAiAssets(assets?.result ?? null);
const activeVariant = welcomeVariants[welcomeVariantIndex] ?? '';
const isFullyTyped = typedWelcome === activeVariant;
const isFullyDeleted = typedWelcome.length === 0;
// Auto-retry bootstrap once if Ollama is degraded (install/server issue).
if (status.result?.state === 'degraded' && !autoRetryDoneRef.current) {
autoRetryDoneRef.current = true;
console.debug('[Home] local AI is degraded; scheduling a one-time re-bootstrap');
void bootstrapLocalAiWithRecommendedPreset(true, '[Home degraded auto-retry]').catch(
error => {
autoRetryDoneRef.current = false;
console.warn('[Home] degraded local AI re-bootstrap failed:', error);
}
);
}
const delay = isDeletingWelcome
? 36
: isFullyTyped
? 1400
: typedWelcome.length === 0
? 250
: 55;
if (
status.result?.state === 'idle' &&
!initialBootstrapHandledRef.current &&
!initialBootstrapInFlightRef.current
) {
initialBootstrapInFlightRef.current = true;
console.debug('[Home] local AI is idle; checking first-run preset selection');
void ensureRecommendedLocalAiPresetIfNeeded('[Home first-run]')
.then(async preset => {
const shouldTriggerBootstrap =
!preset.hadSelectedTier || initialBootstrapPendingDownloadRef.current;
if (!shouldTriggerBootstrap) {
console.debug(
'[Home] skipping automatic first-run bootstrap because a tier is already selected'
);
initialBootstrapHandledRef.current = true;
return;
}
initialBootstrapPendingDownloadRef.current = true;
console.debug(
'[Home] selected recommended preset for first-run bootstrap',
JSON.stringify({
recommendedTier: preset.recommendedTier,
hadSelectedTier: preset.hadSelectedTier,
})
);
await triggerLocalAiAssetBootstrap(false, '[Home first-run]');
initialBootstrapPendingDownloadRef.current = false;
initialBootstrapHandledRef.current = true;
initialBootstrapAttemptsRef.current = 0;
})
.catch(error => {
initialBootstrapAttemptsRef.current += 1;
const attempts = initialBootstrapAttemptsRef.current;
if (attempts >= MAX_INITIAL_BOOTSTRAP_ATTEMPTS) {
initialBootstrapPendingDownloadRef.current = false;
initialBootstrapHandledRef.current = true;
console.warn(
'[Home] first-run local AI bootstrap failed permanently; stopping retries',
{ attempts, error }
);
return;
}
console.warn('[Home] first-run local AI bootstrap failed:', error);
})
.finally(() => {
initialBootstrapInFlightRef.current = false;
});
}
const timeoutId = window.setTimeout(() => {
if (!isDeletingWelcome) {
if (isFullyTyped) {
setIsDeletingWelcome(true);
return;
}
} catch (error) {
console.warn('[Home] failed to load local AI status:', error);
if (mounted) setLocalAiStatus(null);
setTypedWelcome(activeVariant.slice(0, typedWelcome.length + 1));
return;
}
};
void load();
const timer = setInterval(() => void load(), 2000);
return () => {
mounted = false;
clearInterval(timer);
};
}, []);
const modelProgress = useMemo(() => progressFromStatus(localAiStatus), [localAiStatus]);
// Hide the Local Model Runtime card once every capability's model file is
// present on disk. We use `assets_status` (which inspects the filesystem)
// instead of the in-memory `LocalAiStatus` sub-states, because the latter
// stay at `idle` until a capability is first exercised — even when the
// underlying model has already been downloaded.
//
// A capability is considered "done" when its asset state is:
// - `ready` → model file exists on disk
// - `disabled` → not applicable for the selected preset
// - `ondemand` → vision preset intentionally defers download until first use
const allModelsDownloaded = useMemo(() => {
if (!localAiStatus || !localAiAssets) return false;
if (localAiStatus.state !== 'ready') return false;
const isDone = (state: string | undefined | null): boolean =>
state === 'ready' || state === 'disabled' || state === 'ondemand';
if (!isFullyDeleted) {
setTypedWelcome(activeVariant.slice(0, typedWelcome.length - 1));
return;
}
return (
isDone(localAiAssets.chat?.state) &&
isDone(localAiAssets.vision?.state) &&
isDone(localAiAssets.embedding?.state) &&
isDone(localAiAssets.stt?.state) &&
isDone(localAiAssets.tts?.state)
);
}, [localAiStatus, localAiAssets]);
setIsDeletingWelcome(false);
setWelcomeVariantIndex(current => (current + 1) % welcomeVariants.length);
}, delay);
const isInstalling = localAiStatus?.state === 'installing';
const indeterminateDownload =
isInstalling ||
(localAiStatus?.state === 'downloading' && typeof localAiStatus.download_progress !== 'number');
const isInstallError =
localAiStatus?.state === 'degraded' && localAiStatus?.error_category === 'install';
const [showErrorDetail, setShowErrorDetail] = useState(false);
const downloadedText =
typeof localAiStatus?.downloaded_bytes === 'number'
? `${formatBytes(localAiStatus.downloaded_bytes)}${typeof localAiStatus?.total_bytes === 'number' ? ` / ${formatBytes(localAiStatus.total_bytes)}` : ''}`
: '';
const speedText =
typeof localAiStatus?.download_speed_bps === 'number' && localAiStatus.download_speed_bps > 0
? `${formatBytes(localAiStatus.download_speed_bps)}/s`
: '';
const etaText = formatEta(localAiStatus?.eta_seconds);
return () => window.clearTimeout(timeoutId);
}, [isDeletingWelcome, typedWelcome, welcomeVariantIndex, welcomeVariants]);
return (
<div className="min-h-full flex flex-col items-center justify-center p-4">
<div className="max-w-md w-full">
{isRateLimited && (
<UsageLimitBanner
tone="warning"
icon="⏳"
title="Youve Hit Your Limits"
message="Youve reached your short-term usage cap. Buy top-up credits to keep going right away."
ctaLabel="Buy top-up credits"
/>
)}
{!isRateLimited && shouldShowBudgetCompletedMessage && (
<UsageLimitBanner
tone="danger"
icon="⚠️"
title="Youve Exhausted Your Usage"
message="Youre out of included usage for now. Start a subscription to unlock more ongoing capacity."
ctaLabel="Get a subscription"
/>
)}
{showPromoBanner && <PromotionalCreditsBanner promoCredits={promoCredits} />}
{/* Main card */}
<div className="bg-white rounded-2xl shadow-soft border border-stone-200 p-6 animate-fade-up">
{/* Header row: logo + version + settings */}
<div className="flex items-center justify-between mb-8">
<div className="w-9 h-9 bg-stone-900 rounded-lg flex items-center justify-center">
<svg className="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
</svg>
</div>
<span className="text-xs text-stone-400">web, 01</span>
<button
onClick={() => navigate('/settings/messaging')}
className="w-9 h-9 rounded-full bg-stone-100 flex items-center justify-center hover:bg-stone-200 transition-colors"
aria-label="Notifications">
<svg
className="w-4 h-4 text-stone-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
</button>
<div className="flex items-center justify-center mb-4">
<span className="text-xs text-center text-stone-400">v{APP_VERSION}</span>
</div>
{/* Welcome title */}
<h1 className="text-3xl font-bold text-stone-900 text-center mb-6">Welcome Onboard</h1>
{/* Greeting */}
<div className="text-center mb-3">
<p className="text-lg font-medium text-stone-700">
{getGreeting()}, {userName}
</p>
</div>
<h1 className="min-h-[3.5rem] text-32l font-bold text-stone-900 text-center">
{typedWelcome}
<span aria-hidden="true" className="ml-0.5 inline-block text-primary-500 animate-pulse">
|
</span>
</h1>
{/* Connection status */}
<div className="flex justify-center mb-3">
@@ -285,8 +166,10 @@ const Home = () => {
</button>
</div>
<DiscordBanner />
{/* Next steps — compact directory of where to go next */}
<div className="mt-3 bg-white rounded-2xl shadow-soft border border-stone-200 p-4">
{/* <div className="mt-3 bg-white rounded-2xl shadow-soft border border-stone-200 p-4">
<div className="text-[11px] uppercase tracking-wide text-stone-400 mb-2">Next steps</div>
<div className="divide-y divide-stone-100">
<button
@@ -356,137 +239,7 @@ const Home = () => {
</svg>
</button>
</div>
</div>
{/* Local AI card (desktop only) — hidden once all models are fully downloaded */}
{isTauri() && !allModelsDownloaded && (
<div className="mt-3 bg-white rounded-2xl shadow-soft border border-stone-200 px-4 py-4 text-left">
<div className="flex items-center justify-between">
<div className="text-[11px] uppercase tracking-wide text-stone-400">
Local model runtime
</div>
<button
onClick={() => navigate('/settings/local-model')}
className="text-xs text-primary-500 hover:text-primary-600 transition-colors">
Manage
</button>
</div>
<div className="mt-2 flex items-center justify-between text-xs">
<span className="text-stone-600">
{localAiStatus?.model_id ?? 'gemma3:4b-it-qat'}
</span>
<span className="text-stone-700 capitalize">
{localAiStatus?.state ?? 'starting'}
</span>
</div>
<div className="mt-2 h-2 rounded-full bg-stone-100 overflow-hidden">
<div
className={`h-full bg-gradient-to-r from-primary-500 to-primary-400 transition-all duration-500 ${
indeterminateDownload ? 'animate-pulse' : ''
}`}
style={{
width: `${Math.round((indeterminateDownload ? 1 : modelProgress) * 100)}%`,
}}
/>
</div>
<div className="mt-2 flex items-center justify-between gap-2 text-[11px] text-stone-400">
<span>
{isInstalling
? 'Installing Ollama runtime...'
: indeterminateDownload
? 'Downloading...'
: `${Math.round(modelProgress * 100)}%`}
</span>
{downloadedText && (
<span className="truncate text-stone-500" title={downloadedText}>
{downloadedText}
</span>
)}
{speedText && <span className="text-primary-500">{speedText}</span>}
{etaText && <span className="text-primary-600">ETA {etaText}</span>}
</div>
{localAiStatus?.warning && (
<div
className="mt-1 text-[11px] text-stone-400 truncate"
title={localAiStatus.warning}>
{localAiStatus.warning}
</div>
)}
{isInstallError && localAiStatus?.error_detail && (
<div className="mt-2">
<button
onClick={() => setShowErrorDetail(v => !v)}
className="text-[11px] text-coral-500 hover:text-coral-600 underline">
{showErrorDetail ? 'Hide error details' : 'Show error details'}
</button>
{showErrorDetail && (
<pre className="mt-1 max-h-32 overflow-auto rounded bg-stone-50 p-2 text-[10px] text-coral-600 leading-tight whitespace-pre-wrap break-words">
{localAiStatus.error_detail}
</pre>
)}
<p className="mt-1 text-[11px] text-stone-400">
Install Ollama manually from{' '}
<a
href="https://ollama.com"
target="_blank"
rel="noopener noreferrer"
className="text-primary-500 hover:text-primary-600 underline">
ollama.com
</a>{' '}
then set its path in{' '}
<button
onClick={() => navigate('/settings/local-model')}
className="text-primary-500 hover:text-primary-600 underline">
Settings
</button>
.
</p>
</div>
)}
<div className="mt-2 flex items-center gap-2">
{localAiStatus?.state === 'ready' ? (
<span className="inline-flex items-center gap-1 rounded-md bg-green-50 px-2.5 py-1.5 text-[11px] font-medium text-green-700 border border-green-200">
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
Running
</span>
) : (
<button
onClick={() => void runManualBootstrap(false)}
disabled={downloadBusy}
className="rounded-md bg-primary-500 px-2.5 py-1.5 text-[11px] font-medium text-white hover:bg-primary-600 disabled:opacity-60">
{downloadBusy
? 'Working...'
: localAiStatus?.state === 'degraded'
? 'Retry'
: 'Bootstrap'}
</button>
)}
<button
onClick={() => void runManualBootstrap(true)}
disabled={downloadBusy}
className="rounded-md border border-stone-200 px-2.5 py-1.5 text-[11px] font-medium text-stone-600 hover:border-stone-300 disabled:opacity-60">
{downloadBusy ? 'Working...' : 'Re-bootstrap'}
</button>
{bootstrapMessage && (
<span className="text-[11px] text-green-600 animate-fade-up">
{bootstrapMessage}
</span>
)}
</div>
</div>
)}
</div> */}
</div>
</div>
);
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import { isComposerInteractionBlocked } from '../Conversations';
describe('isComposerInteractionBlocked', () => {
it('blocks composer interaction while the welcome agent loader is visible', () => {
expect(
isComposerInteractionBlocked({ activeThreadId: null, welcomePending: true, rustChat: true })
).toBe(true);
});
it('blocks composer interaction while a thread is actively running', () => {
expect(
isComposerInteractionBlocked({
activeThreadId: 'thread-1',
welcomePending: false,
rustChat: true,
})
).toBe(true);
});
it('allows composer interaction when chat is idle and ready', () => {
expect(
isComposerInteractionBlocked({ activeThreadId: null, welcomePending: false, rustChat: true })
).toBe(false);
});
it('blocks composer interaction when rust chat is unavailable', () => {
expect(
isComposerInteractionBlocked({ activeThreadId: null, welcomePending: false, rustChat: false })
).toBe(true);
});
});
+18 -119
View File
@@ -1,135 +1,34 @@
import { waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { renderWithProviders } from '../../test/test-utils';
import Home from '../Home';
import { resolveHomeUserName } from '../Home';
const mockUseUser = vi.fn(() => ({ user: { firstName: 'Shrey' } }));
vi.mock('../../components/ConnectionIndicator', () => ({
default: () => <div>Connection Indicator</div>,
}));
vi.mock('../../hooks/useUser', () => ({ useUser: () => ({ user: { firstName: 'Shrey' } }) }));
vi.mock('../../hooks/useUser', () => ({ useUser: () => mockUseUser() }));
vi.mock('../../utils/localAiBootstrap', () => ({
bootstrapLocalAiWithRecommendedPreset: vi.fn(),
ensureRecommendedLocalAiPresetIfNeeded: vi.fn(),
triggerLocalAiAssetBootstrap: vi.fn(),
}));
vi.mock('../../utils/config', async importOriginal => {
const actual = await importOriginal<typeof import('../../utils/config')>();
return { ...actual, APP_VERSION: '0.0.0-test' };
});
vi.mock('../../utils/tauriCommands', () => ({
isTauri: vi.fn(() => true),
openhumanLocalAiStatus: vi.fn(),
openhumanLocalAiAssetsStatus: vi.fn().mockResolvedValue({ result: null, logs: [] }),
}));
describe('Home local AI bootstrap', () => {
beforeEach(() => {
vi.clearAllMocks();
describe('resolveHomeUserName', () => {
it('uses camelCase name fields when present', () => {
expect(resolveHomeUserName({ firstName: 'Ada', lastName: 'Lovelace' })).toBe('Ada Lovelace');
});
it('auto-applies the recommended preset and starts bootstrap on first-run idle state', async () => {
const bootstrapUtils = await import('../../utils/localAiBootstrap');
const tauriCommands = await import('../../utils/tauriCommands');
vi.mocked(tauriCommands.openhumanLocalAiStatus).mockResolvedValue({
result: { state: 'idle', model_id: 'gemma3:4b-it-qat' } as never,
logs: [],
});
vi.mocked(bootstrapUtils.ensureRecommendedLocalAiPresetIfNeeded).mockResolvedValue({
presets: {} as never,
recommendedTier: 'high',
selectedTier: null,
hadSelectedTier: false,
appliedTier: 'high',
});
vi.mocked(bootstrapUtils.triggerLocalAiAssetBootstrap).mockResolvedValue({
result: { state: 'downloading', progress: 0 } as never,
logs: [],
});
renderWithProviders(<Home />);
await waitFor(() => {
expect(bootstrapUtils.ensureRecommendedLocalAiPresetIfNeeded).toHaveBeenCalledWith(
'[Home first-run]'
);
expect(bootstrapUtils.triggerLocalAiAssetBootstrap).toHaveBeenCalledWith(
false,
'[Home first-run]'
);
});
it('falls back to snake_case name fields from core snapshot payloads', () => {
expect(resolveHomeUserName({ first_name: 'Ada', last_name: 'Lovelace' })).toBe('Ada Lovelace');
});
it('does not auto-bootstrap when a tier is already selected', async () => {
const bootstrapUtils = await import('../../utils/localAiBootstrap');
const tauriCommands = await import('../../utils/tauriCommands');
vi.mocked(tauriCommands.openhumanLocalAiStatus).mockResolvedValue({
result: { state: 'idle', model_id: 'gemma3:12b-it-q4_K_M' } as never,
logs: [],
});
vi.mocked(bootstrapUtils.ensureRecommendedLocalAiPresetIfNeeded).mockResolvedValue({
presets: {} as never,
recommendedTier: 'high',
selectedTier: 'high',
hadSelectedTier: true,
appliedTier: null,
});
renderWithProviders(<Home />);
await waitFor(() => {
expect(bootstrapUtils.ensureRecommendedLocalAiPresetIfNeeded).toHaveBeenCalledWith(
'[Home first-run]'
);
});
expect(bootstrapUtils.triggerLocalAiAssetBootstrap).not.toHaveBeenCalled();
it('falls back to username when no name fields are present', () => {
expect(resolveHomeUserName({ username: 'openhuman' })).toBe('@openhuman');
});
it('retries the first-run bootstrap trigger after preset application if the first trigger attempt fails', async () => {
const bootstrapUtils = await import('../../utils/localAiBootstrap');
const tauriCommands = await import('../../utils/tauriCommands');
vi.mocked(tauriCommands.openhumanLocalAiStatus).mockResolvedValue({
result: { state: 'idle', model_id: 'gemma3:4b-it-qat' } as never,
logs: [],
});
vi.mocked(bootstrapUtils.ensureRecommendedLocalAiPresetIfNeeded)
.mockResolvedValueOnce({
presets: {} as never,
recommendedTier: 'high',
selectedTier: null,
hadSelectedTier: false,
appliedTier: 'high',
})
.mockResolvedValueOnce({
presets: {} as never,
recommendedTier: 'high',
selectedTier: 'high',
hadSelectedTier: true,
appliedTier: null,
});
vi.mocked(bootstrapUtils.triggerLocalAiAssetBootstrap)
.mockRejectedValueOnce(new Error('transient failure'))
.mockResolvedValueOnce({ result: { state: 'downloading', progress: 0 } as never, logs: [] });
renderWithProviders(<Home />);
await waitFor(
() => {
expect(bootstrapUtils.triggerLocalAiAssetBootstrap).toHaveBeenCalledTimes(2);
},
{ timeout: 3000 }
);
expect(bootstrapUtils.triggerLocalAiAssetBootstrap).toHaveBeenNthCalledWith(
1,
false,
'[Home first-run]'
);
expect(bootstrapUtils.triggerLocalAiAssetBootstrap).toHaveBeenNthCalledWith(
2,
false,
'[Home first-run]'
);
it('falls back to the email local-part when no explicit name exists', () => {
expect(resolveHomeUserName({ email: 'ada@example.com' })).toBe('ada');
});
});
@@ -1,153 +0,0 @@
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { renderWithProviders } from '../../test/test-utils';
import Home from '../Home';
vi.mock('../../components/ConnectionIndicator', () => ({
default: () => <div>Connection Indicator</div>,
}));
vi.mock('../../hooks/useUser', () => ({ useUser: () => ({ user: { firstName: 'Tester' } }) }));
vi.mock('../../utils/localAiBootstrap', () => ({
bootstrapLocalAiWithRecommendedPreset: vi.fn(),
ensureRecommendedLocalAiPresetIfNeeded: vi.fn(),
triggerLocalAiAssetBootstrap: vi.fn(),
}));
vi.mock('../../utils/tauriCommands', () => ({
isTauri: vi.fn(() => true),
openhumanLocalAiStatus: vi.fn(),
openhumanLocalAiAssetsStatus: vi.fn().mockResolvedValue({ result: null, logs: [] }),
}));
describe('Home bootstrap button states', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('shows "Running" badge instead of Bootstrap button when state is ready', async () => {
const tauriCommands = await import('../../utils/tauriCommands');
const bootstrapUtils = await import('../../utils/localAiBootstrap');
vi.mocked(tauriCommands.openhumanLocalAiStatus).mockResolvedValue({
result: { state: 'ready', model_id: 'gemma3:4b-it-qat' } as never,
logs: [],
});
vi.mocked(bootstrapUtils.ensureRecommendedLocalAiPresetIfNeeded).mockResolvedValue({
presets: {} as never,
recommendedTier: 'high',
selectedTier: 'high',
hadSelectedTier: true,
appliedTier: null,
});
renderWithProviders(<Home />);
await waitFor(() => {
expect(screen.getByText('Running')).toBeInTheDocument();
});
expect(screen.queryByRole('button', { name: 'Bootstrap' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Re-bootstrap' })).toBeInTheDocument();
});
it('shows "Retry" button when state is degraded', async () => {
const tauriCommands = await import('../../utils/tauriCommands');
const bootstrapUtils = await import('../../utils/localAiBootstrap');
// Keep returning degraded so the auto-retry doesn't change the visible state
vi.mocked(tauriCommands.openhumanLocalAiStatus).mockResolvedValue({
result: {
state: 'degraded',
model_id: 'gemma3:4b-it-qat',
warning: 'Ollama not found',
} as never,
logs: [],
});
vi.mocked(bootstrapUtils.ensureRecommendedLocalAiPresetIfNeeded).mockResolvedValue({
presets: {} as never,
recommendedTier: 'high',
selectedTier: 'high',
hadSelectedTier: true,
appliedTier: null,
});
// The Home component auto-retries on degraded — let it resolve without changing state
vi.mocked(bootstrapUtils.bootstrapLocalAiWithRecommendedPreset).mockResolvedValue({
preset: {} as never,
download: {} as never,
});
renderWithProviders(<Home />);
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument();
});
expect(screen.queryByText('Running')).not.toBeInTheDocument();
});
it('shows "Bootstrap" button when state is idle', async () => {
const tauriCommands = await import('../../utils/tauriCommands');
const bootstrapUtils = await import('../../utils/localAiBootstrap');
vi.mocked(tauriCommands.openhumanLocalAiStatus).mockResolvedValue({
result: { state: 'idle', model_id: 'gemma3:4b-it-qat' } as never,
logs: [],
});
vi.mocked(bootstrapUtils.ensureRecommendedLocalAiPresetIfNeeded).mockResolvedValue({
presets: {} as never,
recommendedTier: 'high',
selectedTier: 'high',
hadSelectedTier: true,
appliedTier: null,
});
renderWithProviders(<Home />);
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Bootstrap' })).toBeInTheDocument();
});
expect(screen.queryByText('Running')).not.toBeInTheDocument();
});
it('shows success message after re-bootstrap completes', async () => {
const tauriCommands = await import('../../utils/tauriCommands');
const bootstrapUtils = await import('../../utils/localAiBootstrap');
vi.mocked(tauriCommands.openhumanLocalAiStatus)
.mockResolvedValueOnce({
result: { state: 'ready', model_id: 'gemma3:4b-it-qat' } as never,
logs: [],
})
.mockResolvedValue({
result: { state: 'ready', model_id: 'gemma3:4b-it-qat' } as never,
logs: [],
});
vi.mocked(bootstrapUtils.ensureRecommendedLocalAiPresetIfNeeded).mockResolvedValue({
presets: {} as never,
recommendedTier: 'high',
selectedTier: 'high',
hadSelectedTier: true,
appliedTier: null,
});
vi.mocked(bootstrapUtils.bootstrapLocalAiWithRecommendedPreset).mockResolvedValue({
preset: {} as never,
download: {} as never,
});
renderWithProviders(<Home />);
await waitFor(() => {
expect(screen.getByText('Running')).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: 'Re-bootstrap' }));
await waitFor(() => {
expect(screen.getByText('Re-bootstrap complete')).toBeInTheDocument();
});
});
});
+5 -1
View File
@@ -78,10 +78,14 @@ function snapshotIdentity(snapshot: CoreAppSnapshot): string | null {
function normalizeSnapshot(
result: Awaited<ReturnType<typeof fetchCoreAppSnapshot>>
): CoreAppSnapshot {
const currentUser = (result.currentUser ??
result.auth.user ??
null) as CoreAppSnapshot['currentUser'];
return {
auth: result.auth,
sessionToken: result.sessionToken,
currentUser: result.currentUser,
currentUser,
onboardingCompleted: result.onboardingCompleted,
chatOnboardingCompleted: result.chatOnboardingCompleted,
analyticsEnabled: result.analyticsEnabled,
@@ -15,16 +15,18 @@ function makeSnapshot(overrides: {
userId?: string | null;
sessionToken?: string | null;
isAuthenticated?: boolean;
authUser?: unknown | null;
currentUser?: unknown | null;
}): Snapshot {
return {
auth: {
isAuthenticated: overrides.isAuthenticated ?? Boolean(overrides.userId),
userId: overrides.userId ?? null,
user: null,
user: (overrides.authUser ?? null) as never,
profileId: null,
},
sessionToken: overrides.sessionToken ?? null,
currentUser: null,
currentUser: (overrides.currentUser ?? null) as never,
onboardingCompleted: false,
chatOnboardingCompleted: false,
analyticsEnabled: false,
@@ -211,4 +213,30 @@ describe('CoreStateProvider — identity-change cache clearing', () => {
expect(screen.getByTestId('ready').textContent).toBe('boot');
await waitFor(() => expect(screen.getByTestId('ready').textContent).toBe('ready'));
});
it('backfills snapshot.currentUser from auth.user when currentUser is missing', async () => {
fetchSnapshot.mockResolvedValue(
makeSnapshot({
userId: 'u1',
sessionToken: 'tok1',
authUser: { first_name: 'Ada', username: 'ada' },
currentUser: null,
})
);
listTeams.mockResolvedValue([]);
let ctx: CoreStateContextValue | undefined;
render(
<CoreStateProvider>
<Consumer
captureCtx={next => {
ctx = next;
}}
/>
</CoreStateProvider>
);
await waitFor(() => expect(screen.getByTestId('ready').textContent).toBe('ready'));
expect(ctx?.snapshot.currentUser).toEqual({ first_name: 'Ada', username: 'ada' });
});
});
+1
View File
@@ -80,6 +80,7 @@ vi.mock('../utils/config', () => ({
BACKEND_URL: 'http://localhost:5005',
TELEGRAM_BOT_USERNAME: 'openhuman_bot',
LATEST_APP_DOWNLOAD_URL: 'https://github.com/tinyhumansai/openhuman/releases/latest',
APP_VERSION: '0.0.0-test',
DEV_JWT_TOKEN: undefined,
}));
+1
View File
@@ -22,6 +22,7 @@ export interface UserSubscription {
}
export interface IUserUsage {
promotionBalanceUsd?: number;
cycleBudgetUsd: number;
spentThisCycleUsd: number;
spentTodayUsd: number;
+81 -9
View File
@@ -3,7 +3,7 @@ use std::fs;
use std::fs::File;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use log::{debug, warn};
use once_cell::sync::Lazy;
@@ -26,7 +26,17 @@ use crate::rpc::RpcOutcome;
const LOG_PREFIX: &str = "[app_state]";
const APP_STATE_FILENAME: &str = "app-state.json";
const CURRENT_USER_REFRESH_TTL: Duration = Duration::from_secs(5);
static APP_STATE_FILE_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
static CURRENT_USER_CACHE: Lazy<Mutex<Option<CachedCurrentUser>>> = Lazy::new(|| Mutex::new(None));
#[derive(Debug, Clone)]
struct CachedCurrentUser {
api_base: String,
token: String,
fetched_at: Instant,
user: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
@@ -295,6 +305,58 @@ async fn fetch_current_user(config: &Config, token: &str) -> Result<Option<Value
Ok(Some(user))
}
fn sanitize_snapshot_user(user: Option<Value>) -> Option<Value> {
match user {
Some(Value::Object(map)) if map.is_empty() => None,
Some(Value::Null) => None,
other => other,
}
}
async fn fetch_current_user_cached(config: &Config, token: &str) -> Result<Option<Value>, String> {
let api_base = effective_api_url(&config.api_url)
.trim()
.trim_end_matches('/')
.to_string();
{
let cache = CURRENT_USER_CACHE.lock();
if let Some(entry) = cache.as_ref() {
if entry.api_base == api_base
&& entry.token == token
&& entry.fetched_at.elapsed() < CURRENT_USER_REFRESH_TTL
{
debug!(
"{LOG_PREFIX} using cached current user age_ms={}",
entry.fetched_at.elapsed().as_millis()
);
return Ok(Some(entry.user.clone()));
}
}
}
let fetched = sanitize_snapshot_user(fetch_current_user(config, token).await?);
let mut cache = CURRENT_USER_CACHE.lock();
match fetched.clone() {
Some(user) => {
debug!("{LOG_PREFIX} refreshed current user from backend");
*cache = Some(CachedCurrentUser {
api_base,
token: token.to_string(),
fetched_at: Instant::now(),
user,
});
}
None => {
debug!("{LOG_PREFIX} backend returned empty current user; clearing cache");
*cache = None;
}
}
Ok(fetched)
}
async fn build_runtime_snapshot(config: &Config) -> RuntimeSnapshot {
let screen_intelligence = {
let _ = crate::openhuman::screen_intelligence::global_engine()
@@ -341,15 +403,21 @@ async fn build_runtime_snapshot(config: &Config) -> RuntimeSnapshot {
pub async fn snapshot() -> Result<RpcOutcome<AppStateSnapshot>, String> {
let config = config_rpc::load_config_with_timeout().await?;
let auth = build_session_state(&config)?;
let mut auth = build_session_state(&config)?;
let session_token = get_session_token(&config)?;
let current_user = auth.user.clone().or(
if let Some(token) = session_token.clone().filter(|t| !t.trim().is_empty()) {
fetch_current_user(&config, &token).await?
} else {
None
},
);
let stored_user = sanitize_snapshot_user(auth.user.clone());
let current_user = if let Some(token) = session_token.clone().filter(|t| !t.trim().is_empty()) {
match fetch_current_user_cached(&config, &token).await {
Ok(fresh_user) => fresh_user.or(stored_user.clone()),
Err(error) => {
warn!("{LOG_PREFIX} current user refresh failed; using stored snapshot fallback: {error}");
stored_user.clone()
}
}
} else {
stored_user.clone()
};
auth.user = current_user.clone();
let local_state = load_stored_app_state(&config)?;
let runtime = build_runtime_snapshot(&config).await;
@@ -420,3 +488,7 @@ pub async fn update_local_state(
vec!["core local app state updated".to_string()],
))
}
#[cfg(test)]
#[path = "ops_tests.rs"]
mod tests;
+36
View File
@@ -0,0 +1,36 @@
use super::*;
use serde_json::json;
#[test]
fn sanitize_snapshot_user_drops_empty_payloads() {
assert_eq!(sanitize_snapshot_user(Some(json!({}))), None);
assert_eq!(sanitize_snapshot_user(Some(Value::Null)), None);
assert_eq!(
sanitize_snapshot_user(Some(json!({ "firstName": "steven" }))),
Some(json!({ "firstName": "steven" }))
);
}
fn make_cached_entry(age: Duration) -> CachedCurrentUser {
CachedCurrentUser {
api_base: "https://staging-api.tinyhumans.ai".to_string(),
token: "tok".to_string(),
fetched_at: Instant::now() - age,
user: json!({ "firstName": "steven" }),
}
}
// The freshness branch in `fetch_current_user_cached` is `elapsed() < TTL`.
// Lock that contract here so a future TTL change can't silently flip the
// cache from "hit" to "miss" without updating this test.
#[test]
fn cached_entry_is_considered_fresh_within_ttl() {
let fresh = make_cached_entry(Duration::from_millis(0));
assert!(fresh.fetched_at.elapsed() < CURRENT_USER_REFRESH_TTL);
}
#[test]
fn cached_entry_is_considered_expired_past_ttl() {
let expired = make_cached_entry(CURRENT_USER_REFRESH_TTL + Duration::from_millis(50));
assert!(expired.fetched_at.elapsed() >= CURRENT_USER_REFRESH_TTL);
}
+9 -1
View File
@@ -143,7 +143,7 @@ pub async fn store_session(
{
metadata.insert("user_id".to_string(), uid);
}
let user_for_store = user.unwrap_or(settings);
let user_for_store = sanitize_stored_session_user(user).unwrap_or(settings);
metadata.insert("user_json".to_string(), user_for_store.to_string());
// Determine user_id so we can scope the openhuman directory to this user.
@@ -228,6 +228,14 @@ pub async fn store_session(
Ok(RpcOutcome::new(summarize_auth_profile(&profile), logs))
}
fn sanitize_stored_session_user(user: Option<serde_json::Value>) -> Option<serde_json::Value> {
match user {
Some(serde_json::Value::Object(map)) if map.is_empty() => None,
Some(serde_json::Value::Null) => None,
other => other,
}
}
pub async fn clear_session(config: &Config) -> Result<RpcOutcome<serde_json::Value>, String> {
let auth = AuthService::from_config(config);
let removed = auth
+13
View File
@@ -60,6 +60,19 @@ async fn store_session_rejects_empty_or_whitespace_token() {
assert!(err.contains("token is required"));
}
#[test]
fn sanitize_stored_session_user_discards_empty_objects() {
assert_eq!(sanitize_stored_session_user(Some(json!({}))), None);
assert_eq!(
sanitize_stored_session_user(Some(serde_json::Value::Null)),
None
);
assert_eq!(
sanitize_stored_session_user(Some(json!({ "firstName": "steven" }))),
Some(json!({ "firstName": "steven" }))
);
}
// ── clear_session ──────────────────────────────────────────────
#[tokio::test]