mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
Merge remote-tracking branch 'upstream/develop' into feat/final-flow-check
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
import OAuthProviderButton from './OAuthProviderButton';
|
||||
import TelegramLoginButton from '../TelegramLoginButton';
|
||||
import { oauthProviderConfigs } from './providerConfigs';
|
||||
|
||||
interface OAuthLoginSectionProps {
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
showTelegram?: boolean;
|
||||
}
|
||||
|
||||
const OAuthLoginSection = ({
|
||||
className = '',
|
||||
disabled = false,
|
||||
showTelegram = true
|
||||
}: OAuthLoginSectionProps) => {
|
||||
return (
|
||||
<div className={`space-y-4 ${className}`}>
|
||||
{/* OAuth Providers */}
|
||||
<div className="space-y-3">
|
||||
{oauthProviderConfigs.map((provider) => (
|
||||
<OAuthProviderButton
|
||||
key={provider.id}
|
||||
provider={provider}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
{showTelegram && (
|
||||
<>
|
||||
<div className="relative flex items-center">
|
||||
<div className="flex-1 border-t border-white/20"></div>
|
||||
<div className="px-4 text-sm text-white/50 bg-transparent">or</div>
|
||||
<div className="flex-1 border-t border-white/20"></div>
|
||||
</div>
|
||||
|
||||
{/* Telegram Login */}
|
||||
<TelegramLoginButton disabled={disabled} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OAuthLoginSection;
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useState } from 'react';
|
||||
import type { OAuthProviderConfig } from '../../types/oauth';
|
||||
import { openUrl } from '../../utils/openUrl';
|
||||
import { isTauri } from '../../utils/tauriCommands';
|
||||
import { IS_DEV } from '../../utils/config';
|
||||
|
||||
interface OAuthProviderButtonProps {
|
||||
provider: OAuthProviderConfig;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const OAuthProviderButton = ({
|
||||
provider,
|
||||
className = '',
|
||||
disabled: externalDisabled = false,
|
||||
}: OAuthProviderButtonProps) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleOAuthLogin = async () => {
|
||||
if (externalDisabled || isLoading) return;
|
||||
|
||||
console.log(`Starting ${provider.name} OAuth login`, isTauri());
|
||||
|
||||
if (IS_DEV) {
|
||||
console.log(`[dev] OAuth debug mode enabled. OAuth URL: ${provider.loginUrl}`);
|
||||
console.log('[dev] In debug mode, OAuth will return JSON response instead of redirect.');
|
||||
console.log('[dev] After OAuth completion, copy the loginToken and use: window.__simulateDeepLink("alphahuman://auth?token=YOUR_TOKEN")');
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Desktop (Tauri): use system browser → backend OAuth → deep link back to app
|
||||
if (isTauri()) {
|
||||
await openUrl(provider.loginUrl);
|
||||
} else {
|
||||
// Web fallback: direct OAuth flow in current window
|
||||
window.location.href = provider.loginUrl;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to initiate ${provider.name} OAuth login:`, error);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isDisabled = externalDisabled || isLoading;
|
||||
const IconComponent = provider.icon;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleOAuthLogin}
|
||||
disabled={isDisabled}
|
||||
className={`w-full flex items-center justify-center space-x-3 ${provider.color} ${provider.hoverColor} ${provider.textColor} font-semibold py-4 rounded-xl transition-all duration-300 hover:shadow-medium hover:scale-[1.02] active:scale-[0.98] disabled:hover:scale-100 disabled:opacity-50 disabled:cursor-not-allowed ${className}`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-current"></div>
|
||||
) : (
|
||||
<IconComponent className="w-5 h-5" />
|
||||
)}
|
||||
<span>
|
||||
{isLoading ? 'Connecting...' : `Continue with ${provider.name}`}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default OAuthProviderButton;
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* OAuth provider configurations with brand colors and icons
|
||||
*/
|
||||
import type { OAuthProviderConfig } from '../../types/oauth';
|
||||
import { BACKEND_URL, IS_DEV } from '../../utils/config';
|
||||
|
||||
// Provider Icons
|
||||
const GoogleIcon = ({ className = '' }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
|
||||
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
|
||||
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
|
||||
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const TwitterIcon = ({ className = '' }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const GitHubIcon = ({ className = '' }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const DiscordIcon = ({ className = '' }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419-.0189 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1568 2.4189Z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const oauthProviderConfigs: OAuthProviderConfig[] = [
|
||||
{
|
||||
id: 'google',
|
||||
name: 'Google',
|
||||
icon: GoogleIcon,
|
||||
color: 'bg-white border border-gray-200',
|
||||
hoverColor: 'hover:bg-gray-50 hover:border-gray-300',
|
||||
textColor: 'text-gray-900',
|
||||
loginUrl: `${BACKEND_URL}/auth/google/login${IS_DEV ? '?debug=true' : ''}`,
|
||||
},
|
||||
{
|
||||
id: 'github',
|
||||
name: 'GitHub',
|
||||
icon: GitHubIcon,
|
||||
color: 'bg-gray-900 border border-gray-800',
|
||||
hoverColor: 'hover:bg-gray-800 hover:border-gray-700',
|
||||
textColor: 'text-white',
|
||||
loginUrl: `${BACKEND_URL}/auth/github/login${IS_DEV ? '?debug=true' : ''}`,
|
||||
},
|
||||
{
|
||||
id: 'twitter',
|
||||
name: 'Twitter',
|
||||
icon: TwitterIcon,
|
||||
color: 'bg-black border border-gray-800',
|
||||
hoverColor: 'hover:bg-gray-900 hover:border-gray-700',
|
||||
textColor: 'text-white',
|
||||
loginUrl: `${BACKEND_URL}/auth/twitter/login${IS_DEV ? '?debug=true' : ''}`,
|
||||
},
|
||||
{
|
||||
id: 'discord',
|
||||
name: 'Discord',
|
||||
icon: DiscordIcon,
|
||||
color: 'bg-indigo-600 border border-indigo-500',
|
||||
hoverColor: 'hover:bg-indigo-700 hover:border-indigo-600',
|
||||
textColor: 'text-white',
|
||||
loginUrl: `${BACKEND_URL}/auth/discord/login${IS_DEV ? '?debug=true' : ''}`,
|
||||
},
|
||||
];
|
||||
|
||||
export const getProviderConfig = (provider: string): OAuthProviderConfig | undefined => {
|
||||
return oauthProviderConfigs.find(config => config.id === provider);
|
||||
};
|
||||
@@ -78,10 +78,12 @@ const PrimaryButton: React.FC<PrimaryButtonProps> = ({
|
||||
className={`${baseClasses} ${variantClasses[variant]} ${className} flex items-center justify-center`}
|
||||
onClick={onClick}
|
||||
disabled={disabled || loading}>
|
||||
{loading && (
|
||||
<div className="h-4 w-4 border-2 border-white/20 border-t-white rounded-full animate-spin mr-2" />
|
||||
)}
|
||||
{children}
|
||||
<div className="flex items-center gap-2">
|
||||
{loading && (
|
||||
<div className="h-4 w-4 border-2 border-white/20 border-t-white rounded-full animate-spin" />
|
||||
)}
|
||||
<span>{children}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
+31
-95
@@ -14,15 +14,12 @@ import { useAppDispatch, useAppSelector } from '../store/hooks';
|
||||
import {
|
||||
addInferenceResponse,
|
||||
addOptimisticMessage,
|
||||
clearCreateStatus,
|
||||
clearDeleteStatus,
|
||||
clearPurgeStatus,
|
||||
clearSelectedThread,
|
||||
createThread,
|
||||
deleteThread,
|
||||
createThreadLocal,
|
||||
deleteThreadLocal,
|
||||
fetchSuggestedQuestions,
|
||||
fetchThreadMessages,
|
||||
fetchThreads,
|
||||
purgeThreads,
|
||||
removeOptimisticMessages,
|
||||
setLastViewed,
|
||||
@@ -52,13 +49,10 @@ const Conversations = () => {
|
||||
const { threadId: urlThreadId } = useParams<{ threadId?: string }>();
|
||||
const {
|
||||
threads,
|
||||
isLoading,
|
||||
error,
|
||||
selectedThreadId,
|
||||
messages,
|
||||
isLoadingMessages,
|
||||
messagesError,
|
||||
createStatus,
|
||||
deleteStatus,
|
||||
purgeStatus,
|
||||
panelWidth,
|
||||
@@ -157,10 +151,7 @@ const Conversations = () => {
|
||||
.finally(() => setIsLoadingModels(false));
|
||||
}, []);
|
||||
|
||||
// Fetch threads on mount
|
||||
useEffect(() => {
|
||||
dispatch(fetchThreads());
|
||||
}, [dispatch]);
|
||||
// Remove thread fetching - threads are now loaded from Redux persist
|
||||
|
||||
// Sync URL → Redux: when URL has a threadId param, select that thread
|
||||
useEffect(() => {
|
||||
@@ -176,12 +167,7 @@ const Conversations = () => {
|
||||
if (selectedThreadId) dispatch(setLastViewed(selectedThreadId));
|
||||
}, [selectedThreadId, dispatch]);
|
||||
|
||||
// Fetch messages when a thread is selected
|
||||
useEffect(() => {
|
||||
if (selectedThreadId) {
|
||||
dispatch(fetchThreadMessages(selectedThreadId));
|
||||
}
|
||||
}, [dispatch, selectedThreadId]);
|
||||
// Remove message fetching - messages load from local storage automatically
|
||||
|
||||
// Fetch suggested questions when thread is empty (beginning of new thread)
|
||||
useEffect(() => {
|
||||
@@ -197,12 +183,7 @@ const Conversations = () => {
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
// Clear transient status flags after they settle
|
||||
useEffect(() => {
|
||||
if (createStatus === 'success' || createStatus === 'error') {
|
||||
dispatch(clearCreateStatus());
|
||||
}
|
||||
}, [createStatus, dispatch]);
|
||||
// Remove create status handling - using local thread creation
|
||||
|
||||
useEffect(() => {
|
||||
if (deleteStatus === 'success' || deleteStatus === 'error') {
|
||||
@@ -228,17 +209,22 @@ const Conversations = () => {
|
||||
navigate(`/conversations/${threadId}`, { replace: true });
|
||||
};
|
||||
|
||||
const handleNewThread = async () => {
|
||||
const result = await dispatch(createThread(undefined));
|
||||
if (createThread.fulfilled.match(result)) {
|
||||
navigate(`/conversations/${result.payload.id}`, { replace: true });
|
||||
}
|
||||
const handleNewThread = () => {
|
||||
const threadId = crypto.randomUUID();
|
||||
dispatch(
|
||||
createThreadLocal({
|
||||
id: threadId,
|
||||
title: 'New Conversation',
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
);
|
||||
navigate(`/conversations/${threadId}`, { replace: true });
|
||||
};
|
||||
|
||||
const handleDeleteThread = async (threadId: string) => {
|
||||
const result = await dispatch(deleteThread(threadId));
|
||||
const handleDeleteThread = (threadId: string) => {
|
||||
dispatch(deleteThreadLocal(threadId));
|
||||
setConfirmDeleteId(null);
|
||||
if (deleteThread.fulfilled.match(result) && threadId === selectedThreadId) {
|
||||
if (threadId === selectedThreadId) {
|
||||
navigate('/conversations', { replace: true });
|
||||
}
|
||||
};
|
||||
@@ -332,35 +318,16 @@ const Conversations = () => {
|
||||
<h2 className="text-sm font-semibold">Conversations</h2>
|
||||
<button
|
||||
onClick={handleNewThread}
|
||||
disabled={createStatus === 'loading'}
|
||||
className="p-1.5 rounded-lg hover:bg-white/10 transition-colors text-stone-400 hover:text-stone-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="p-1.5 rounded-lg hover:bg-white/10 transition-colors text-stone-400 hover:text-stone-200"
|
||||
title="New Thread">
|
||||
{createStatus === 'loading' ? (
|
||||
<svg className="w-4.5 h-4.5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4.5 h-4.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
<svg className="w-4.5 h-4.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -407,35 +374,7 @@ const Conversations = () => {
|
||||
|
||||
{/* Thread list */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="space-y-1 p-2">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="h-16 bg-white/5 rounded-xl animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center py-16 px-4">
|
||||
<svg
|
||||
className="w-8 h-8 text-coral-500/70 mb-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-sm text-stone-400 mb-1">Failed to load conversations</p>
|
||||
<p className="text-xs text-stone-600 mb-3 text-center">{error}</p>
|
||||
<button
|
||||
onClick={() => dispatch(fetchThreads())}
|
||||
className="text-xs text-primary-400 hover:text-primary-300 transition-colors">
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
) : filteredThreads.length > 0 ? (
|
||||
{filteredThreads.length > 0 ? (
|
||||
<div className="py-1">
|
||||
{filteredThreads.map(thread => (
|
||||
<div
|
||||
@@ -545,8 +484,7 @@ const Conversations = () => {
|
||||
<p className="text-sm text-stone-500 mb-3">No conversations yet</p>
|
||||
<button
|
||||
onClick={handleNewThread}
|
||||
disabled={createStatus === 'loading'}
|
||||
className="text-xs text-primary-400 hover:text-primary-300 transition-colors disabled:opacity-50">
|
||||
className="text-xs text-primary-400 hover:text-primary-300 transition-colors">
|
||||
Start a new conversation
|
||||
</button>
|
||||
</div>
|
||||
@@ -665,11 +603,9 @@ const Conversations = () => {
|
||||
<p className="text-sm text-stone-400 mb-1">Failed to load messages</p>
|
||||
<p className="text-xs text-stone-600 mb-3 text-center">{messagesError}</p>
|
||||
<button
|
||||
onClick={() =>
|
||||
selectedThreadId && dispatch(fetchThreadMessages(selectedThreadId))
|
||||
}
|
||||
onClick={() => window.location.reload()}
|
||||
className="text-xs text-primary-400 hover:text-primary-300 transition-colors">
|
||||
Retry
|
||||
Reload
|
||||
</button>
|
||||
</div>
|
||||
) : messages.length > 0 ? (
|
||||
|
||||
+2
-2
@@ -11,7 +11,7 @@ const Login = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const [consumeError, setConsumeError] = useState<string | null>(null);
|
||||
|
||||
// Handle login token from URL (e.g. from Telegram bot "Open AlphaHuman" button)
|
||||
// Handle login token from URL (e.g. from Telegram bot or OAuth provider callback)
|
||||
// Consume the token with the backend and store the returned JWT
|
||||
useEffect(() => {
|
||||
const loginToken = searchParams.get('token');
|
||||
@@ -46,7 +46,7 @@ const Login = () => {
|
||||
<div className="glass rounded-3xl p-8 shadow-large animate-fade-up">
|
||||
<p className="opacity-90 text-coral mb-4">{consumeError}</p>
|
||||
<p className="text-sm opacity-70">
|
||||
Get a new link by sending '/start login' to the AlphaHuman bot on Telegram.
|
||||
Please try logging in again with your preferred method.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import DownloadScreen from '../components/DownloadScreen';
|
||||
import TelegramLoginButton from '../components/TelegramLoginButton';
|
||||
import OAuthLoginSection from '../components/oauth/OAuthLoginSection';
|
||||
import TypewriterGreeting from '../components/TypewriterGreeting';
|
||||
|
||||
interface WelcomeProps {
|
||||
@@ -25,10 +25,10 @@ const Welcome = ({ isWeb }: WelcomeProps) => {
|
||||
|
||||
<p className="opacity-70 leading-relaxed">Are you ready for this?</p>
|
||||
|
||||
{/* Show Telegram login button in Tauri app, download screen on web */}
|
||||
{/* Show OAuth login options in Tauri app, download screen on web */}
|
||||
{!isWeb && (
|
||||
<div className="mt-6">
|
||||
<TelegramLoginButton />
|
||||
<OAuthLoginSection />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@ interface IntegrationTokensResponse {
|
||||
|
||||
/**
|
||||
* Consume a verified login token and return the JWT.
|
||||
* Works for both Telegram and OAuth login tokens.
|
||||
* POST /telegram/login-tokens/:token/consume (no auth required)
|
||||
*/
|
||||
export async function consumeLoginToken(loginToken: string): Promise<string> {
|
||||
|
||||
+6
-2
@@ -46,8 +46,12 @@ const aiPersistConfig = { key: 'ai', storage, whitelist: ['config'] };
|
||||
// Persist config for skills state (setupComplete per skill)
|
||||
const skillsPersistConfig = { key: 'skills', storage, whitelist: ['skills'] };
|
||||
|
||||
// Persist config for thread UI prefs only (panel width, last viewed for unread)
|
||||
const threadPersistConfig = { key: 'thread', storage, whitelist: ['panelWidth', 'lastViewedAt'] };
|
||||
// Persist config for thread data and UI prefs (includes threads and messages)
|
||||
const threadPersistConfig = {
|
||||
key: 'thread',
|
||||
storage,
|
||||
whitelist: ['panelWidth', 'lastViewedAt', 'threads', 'messagesByThreadId', 'selectedThreadId'],
|
||||
};
|
||||
|
||||
const persistedAuthReducer = persistReducer(authPersistConfig, authReducer);
|
||||
const persistedAiReducer = persistReducer(aiPersistConfig, aiReducer);
|
||||
|
||||
+167
-161
@@ -4,23 +4,25 @@ import { threadApi } from '../services/api/threadApi';
|
||||
import type { Thread, ThreadMessage } from '../types/thread';
|
||||
|
||||
interface ThreadState {
|
||||
// Existing local data (will be persisted)
|
||||
threads: Thread[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
selectedThreadId: string | null;
|
||||
panelWidth: number;
|
||||
lastViewedAt: Record<string, number>;
|
||||
|
||||
// NEW: Add efficient message storage
|
||||
messagesByThreadId: Record<string, ThreadMessage[]>;
|
||||
|
||||
// Current messages view (not persisted)
|
||||
messages: ThreadMessage[];
|
||||
isLoadingMessages: boolean;
|
||||
messagesError: string | null;
|
||||
createStatus: 'idle' | 'loading' | 'success' | 'error';
|
||||
deleteStatus: 'idle' | 'loading' | 'success' | 'error';
|
||||
deleteError: string | null;
|
||||
purgeStatus: 'idle' | 'loading' | 'success' | 'error';
|
||||
|
||||
// Keep these API states (NOT persisted)
|
||||
isLoadingMessages: boolean; // For AI response waiting
|
||||
messagesError: string | null; // For send API errors
|
||||
sendStatus: 'idle' | 'loading' | 'success' | 'error';
|
||||
sendError: string | null;
|
||||
panelWidth: number;
|
||||
/** threadId -> timestamp when user last viewed that thread (for unread indicators) */
|
||||
lastViewedAt: Record<string, number>;
|
||||
/** Suggested starter questions for empty threads (from GET /chat/autocomplete) */
|
||||
deleteStatus: 'idle' | 'loading' | 'success' | 'error';
|
||||
purgeStatus: 'idle' | 'loading' | 'success' | 'error';
|
||||
suggestedQuestions: Array<{ text: string; confidence: number }>;
|
||||
isLoadingSuggestions: boolean;
|
||||
suggestError: string | null;
|
||||
@@ -28,93 +30,29 @@ interface ThreadState {
|
||||
|
||||
const initialState: ThreadState = {
|
||||
threads: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
selectedThreadId: null,
|
||||
panelWidth: 320,
|
||||
lastViewedAt: {},
|
||||
messagesByThreadId: {},
|
||||
messages: [],
|
||||
isLoadingMessages: false,
|
||||
messagesError: null,
|
||||
createStatus: 'idle',
|
||||
deleteStatus: 'idle',
|
||||
deleteError: null,
|
||||
purgeStatus: 'idle',
|
||||
sendStatus: 'idle',
|
||||
sendError: null,
|
||||
panelWidth: 320,
|
||||
lastViewedAt: {},
|
||||
deleteStatus: 'idle',
|
||||
purgeStatus: 'idle',
|
||||
suggestedQuestions: [],
|
||||
isLoadingSuggestions: false,
|
||||
suggestError: null,
|
||||
};
|
||||
|
||||
export const fetchThreads = createAsyncThunk(
|
||||
'thread/fetchThreads',
|
||||
async (_, { rejectWithValue }) => {
|
||||
try {
|
||||
const data = await threadApi.getThreads();
|
||||
return data.threads;
|
||||
} catch (error) {
|
||||
const msg =
|
||||
error && typeof error === 'object' && 'error' in error
|
||||
? String(error.error)
|
||||
: 'Failed to fetch threads';
|
||||
return rejectWithValue(msg);
|
||||
}
|
||||
}
|
||||
);
|
||||
// Removed fetchThreads - threads are now managed locally
|
||||
|
||||
export const fetchThreadMessages = createAsyncThunk(
|
||||
'thread/fetchThreadMessages',
|
||||
async (threadId: string, { rejectWithValue }) => {
|
||||
try {
|
||||
const data = await threadApi.getThreadMessages(threadId);
|
||||
return data.messages;
|
||||
} catch (error) {
|
||||
const msg =
|
||||
error && typeof error === 'object' && 'error' in error
|
||||
? String(error.error)
|
||||
: 'Failed to fetch messages';
|
||||
return rejectWithValue(msg);
|
||||
}
|
||||
}
|
||||
);
|
||||
// Removed fetchThreadMessages - messages are now managed locally
|
||||
|
||||
export const createThread = createAsyncThunk(
|
||||
'thread/createThread',
|
||||
async (chatId: number | undefined, { dispatch, rejectWithValue }) => {
|
||||
try {
|
||||
const data = await threadApi.createThread(chatId);
|
||||
dispatch(fetchThreads());
|
||||
return data;
|
||||
} catch (error) {
|
||||
const msg =
|
||||
error && typeof error === 'object' && 'error' in error
|
||||
? String(error.error)
|
||||
: 'Failed to create thread';
|
||||
return rejectWithValue(msg);
|
||||
}
|
||||
}
|
||||
);
|
||||
// Removed createThread - using local thread creation
|
||||
|
||||
export const deleteThread = createAsyncThunk(
|
||||
'thread/deleteThread',
|
||||
async (threadId: string, { dispatch, getState, rejectWithValue }) => {
|
||||
try {
|
||||
await threadApi.deleteThread(threadId);
|
||||
const state = (getState() as { thread: ThreadState }).thread;
|
||||
if (state.selectedThreadId === threadId) {
|
||||
dispatch(clearSelectedThread());
|
||||
}
|
||||
return threadId;
|
||||
} catch (error) {
|
||||
const msg =
|
||||
error && typeof error === 'object' && 'error' in error
|
||||
? String(error.error)
|
||||
: 'Failed to delete thread';
|
||||
return rejectWithValue(msg);
|
||||
}
|
||||
}
|
||||
);
|
||||
// Removed deleteThread - using local thread deletion
|
||||
|
||||
export const purgeThreads = createAsyncThunk(
|
||||
'thread/purgeThreads',
|
||||
@@ -125,7 +63,8 @@ export const purgeThreads = createAsyncThunk(
|
||||
agentThreads: true,
|
||||
deleteEverything: true,
|
||||
});
|
||||
dispatch(fetchThreads());
|
||||
// Clear local threads after successful purge
|
||||
dispatch({ type: 'thread/clearAllThreads' });
|
||||
return data;
|
||||
} catch (error) {
|
||||
const msg =
|
||||
@@ -141,19 +80,38 @@ export const sendMessage = createAsyncThunk(
|
||||
'thread/sendMessage',
|
||||
async (
|
||||
{ threadId, message }: { threadId: string; message: string },
|
||||
{ dispatch, rejectWithValue }
|
||||
{ dispatch, getState, rejectWithValue }
|
||||
) => {
|
||||
// 1. Add user message locally immediately (optimistic update)
|
||||
const userMessage: ThreadMessage = {
|
||||
id: `msg_${Date.now()}_${Math.random()}`,
|
||||
content: message,
|
||||
type: 'text',
|
||||
extraMetadata: {},
|
||||
sender: 'user',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
try {
|
||||
dispatch(addMessageLocal({ threadId, message: userMessage }));
|
||||
|
||||
// 2. Send to API (existing logic)
|
||||
const data = await threadApi.sendMessage(message, threadId);
|
||||
// Re-fetch messages to get the stored user message + agent response
|
||||
dispatch(fetchThreadMessages(threadId));
|
||||
// Re-fetch threads to update lastMessageAt / messageCount in the list
|
||||
dispatch(fetchThreads());
|
||||
|
||||
// 3. For now, we'll handle AI response via the existing inference API
|
||||
// The AI response will be added separately via addInferenceResponse
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
// Remove optimistic user message on failure
|
||||
const state = (getState() as { thread: ThreadState }).thread;
|
||||
const messages = state.messagesByThreadId[threadId] || [];
|
||||
const filteredMessages = messages.filter(m => m.id !== userMessage.id);
|
||||
dispatch(updateMessagesForThread({ threadId, messages: filteredMessages }));
|
||||
|
||||
const msg =
|
||||
error && typeof error === 'object' && 'error' in error
|
||||
? String(error.error)
|
||||
? String((error as { error: unknown }).error)
|
||||
: 'Failed to send message';
|
||||
return rejectWithValue(msg);
|
||||
}
|
||||
@@ -182,7 +140,8 @@ const threadSlice = createSlice({
|
||||
reducers: {
|
||||
setSelectedThread: (state, action: { payload: string }) => {
|
||||
state.selectedThreadId = action.payload;
|
||||
state.messages = [];
|
||||
// Load messages from local storage instead of clearing
|
||||
state.messages = state.messagesByThreadId[action.payload] || [];
|
||||
state.messagesError = null;
|
||||
state.suggestedQuestions = [];
|
||||
state.suggestError = null;
|
||||
@@ -198,12 +157,8 @@ const threadSlice = createSlice({
|
||||
state.suggestedQuestions = [];
|
||||
state.suggestError = null;
|
||||
},
|
||||
clearCreateStatus: state => {
|
||||
state.createStatus = 'idle';
|
||||
},
|
||||
clearDeleteStatus: state => {
|
||||
state.deleteStatus = 'idle';
|
||||
state.deleteError = null;
|
||||
},
|
||||
clearPurgeStatus: state => {
|
||||
state.purgeStatus = 'idle';
|
||||
@@ -219,14 +174,48 @@ const threadSlice = createSlice({
|
||||
});
|
||||
},
|
||||
addInferenceResponse: (state, action: { payload: { content: string } }) => {
|
||||
state.messages.push({
|
||||
const aiMessage: ThreadMessage = {
|
||||
id: `inference-${Date.now()}`,
|
||||
content: action.payload.content,
|
||||
type: 'text',
|
||||
extraMetadata: {},
|
||||
sender: 'agent',
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
|
||||
// Add to current messages view
|
||||
state.messages.push(aiMessage);
|
||||
|
||||
// Also add to persistent storage
|
||||
if (state.selectedThreadId) {
|
||||
if (!state.messagesByThreadId[state.selectedThreadId]) {
|
||||
state.messagesByThreadId[state.selectedThreadId] = [];
|
||||
}
|
||||
|
||||
// CRITICAL FIX: Ensure the preceding user message is also persisted
|
||||
// Find the last user message that might not be in persistent storage yet
|
||||
const lastUserMessage = state.messages.filter(m => m.sender === 'user').pop();
|
||||
|
||||
if (lastUserMessage) {
|
||||
const persistedMessages = state.messagesByThreadId[state.selectedThreadId];
|
||||
const userMessageExists = persistedMessages.some(m => m.id === lastUserMessage.id);
|
||||
|
||||
// If user message isn't persisted yet, add it first
|
||||
if (!userMessageExists) {
|
||||
persistedMessages.push(lastUserMessage);
|
||||
}
|
||||
}
|
||||
|
||||
// Now add the AI response
|
||||
state.messagesByThreadId[state.selectedThreadId].push(aiMessage);
|
||||
|
||||
// Update thread metadata
|
||||
const thread = state.threads.find(t => t.id === state.selectedThreadId);
|
||||
if (thread) {
|
||||
thread.messageCount = state.messagesByThreadId[state.selectedThreadId].length;
|
||||
thread.lastMessageAt = aiMessage.createdAt;
|
||||
}
|
||||
}
|
||||
},
|
||||
removeOptimisticMessages: state => {
|
||||
state.messages = state.messages.filter(m => !m.id.startsWith('optimistic-'));
|
||||
@@ -241,80 +230,93 @@ const threadSlice = createSlice({
|
||||
const ts = Date.now();
|
||||
state.lastViewedAt[action.payload] = ts;
|
||||
},
|
||||
// Local thread management
|
||||
createThreadLocal: (
|
||||
state,
|
||||
action: { payload: { id: string; title: string; createdAt: string } }
|
||||
) => {
|
||||
const newThread: Thread = {
|
||||
id: action.payload.id,
|
||||
title: action.payload.title,
|
||||
chatId: null,
|
||||
isActive: true,
|
||||
messageCount: 0,
|
||||
lastMessageAt: action.payload.createdAt,
|
||||
createdAt: action.payload.createdAt,
|
||||
};
|
||||
state.threads.unshift(newThread);
|
||||
state.messagesByThreadId[action.payload.id] = [];
|
||||
},
|
||||
addMessageLocal: (state, action: { payload: { threadId: string; message: ThreadMessage } }) => {
|
||||
const { threadId, message } = action.payload;
|
||||
if (!state.messagesByThreadId[threadId]) {
|
||||
state.messagesByThreadId[threadId] = [];
|
||||
}
|
||||
state.messagesByThreadId[threadId].push(message);
|
||||
|
||||
// Update thread metadata
|
||||
const thread = state.threads.find(t => t.id === threadId);
|
||||
if (thread) {
|
||||
thread.messageCount = state.messagesByThreadId[threadId].length;
|
||||
thread.lastMessageAt = message.createdAt;
|
||||
}
|
||||
},
|
||||
deleteThreadLocal: (state, action: { payload: string }) => {
|
||||
const threadId = action.payload;
|
||||
state.threads = state.threads.filter(t => t.id !== threadId);
|
||||
delete state.messagesByThreadId[threadId];
|
||||
delete state.lastViewedAt[threadId];
|
||||
if (state.selectedThreadId === threadId) {
|
||||
state.selectedThreadId = null;
|
||||
}
|
||||
},
|
||||
updateMessagesForThread: (
|
||||
state,
|
||||
action: { payload: { threadId: string; messages: ThreadMessage[] } }
|
||||
) => {
|
||||
const { threadId, messages } = action.payload;
|
||||
state.messagesByThreadId[threadId] = messages;
|
||||
|
||||
// Update thread metadata
|
||||
const thread = state.threads.find(t => t.id === threadId);
|
||||
if (thread) {
|
||||
thread.messageCount = messages.length;
|
||||
thread.lastMessageAt =
|
||||
messages.length > 0 ? messages[messages.length - 1].createdAt : thread.createdAt;
|
||||
}
|
||||
},
|
||||
clearAllThreads: state => {
|
||||
state.threads = [];
|
||||
state.messagesByThreadId = {};
|
||||
state.selectedThreadId = null;
|
||||
state.messages = [];
|
||||
state.lastViewedAt = {};
|
||||
},
|
||||
},
|
||||
extraReducers: builder => {
|
||||
builder
|
||||
// fetchThreads — only show skeleton on initial load (stale-while-revalidate)
|
||||
.addCase(fetchThreads.pending, state => {
|
||||
if (state.threads.length === 0) {
|
||||
state.isLoading = true;
|
||||
}
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(fetchThreads.fulfilled, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.threads = action.payload;
|
||||
})
|
||||
.addCase(fetchThreads.rejected, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.error = action.payload as string;
|
||||
})
|
||||
// fetchThreadMessages
|
||||
.addCase(fetchThreadMessages.pending, state => {
|
||||
state.isLoadingMessages = true;
|
||||
state.messagesError = null;
|
||||
})
|
||||
.addCase(fetchThreadMessages.fulfilled, (state, action) => {
|
||||
state.isLoadingMessages = false;
|
||||
state.messages = action.payload;
|
||||
// Hide suggestions once thread has messages
|
||||
if (action.payload.length > 0) {
|
||||
state.suggestedQuestions = [];
|
||||
state.suggestError = null;
|
||||
}
|
||||
})
|
||||
.addCase(fetchThreadMessages.rejected, (state, action) => {
|
||||
state.isLoadingMessages = false;
|
||||
state.messagesError = action.payload as string;
|
||||
})
|
||||
// createThread
|
||||
.addCase(createThread.pending, state => {
|
||||
state.createStatus = 'loading';
|
||||
})
|
||||
.addCase(createThread.fulfilled, (state, action) => {
|
||||
state.createStatus = 'success';
|
||||
state.selectedThreadId = action.payload.id;
|
||||
state.messages = [];
|
||||
state.messagesError = null;
|
||||
})
|
||||
.addCase(createThread.rejected, state => {
|
||||
state.createStatus = 'error';
|
||||
})
|
||||
// deleteThread
|
||||
.addCase(deleteThread.pending, state => {
|
||||
state.deleteStatus = 'loading';
|
||||
state.deleteError = null;
|
||||
})
|
||||
.addCase(deleteThread.fulfilled, (state, action) => {
|
||||
state.deleteStatus = 'success';
|
||||
state.threads = state.threads.filter(t => t.id !== action.payload);
|
||||
})
|
||||
.addCase(deleteThread.rejected, (state, action) => {
|
||||
state.deleteStatus = 'error';
|
||||
state.deleteError = action.payload as string;
|
||||
})
|
||||
// Removed fetchThreads and fetchThreadMessages cases
|
||||
// Removed createThread cases - using local thread creation
|
||||
// Removed deleteThread cases - using local thread deletion
|
||||
// purgeThreads
|
||||
.addCase(purgeThreads.pending, state => {
|
||||
state.purgeStatus = 'loading';
|
||||
})
|
||||
.addCase(purgeThreads.fulfilled, state => {
|
||||
state.purgeStatus = 'success';
|
||||
state.selectedThreadId = null;
|
||||
state.messages = [];
|
||||
// clearAllThreads is dispatched from the thunk
|
||||
})
|
||||
.addCase(purgeThreads.rejected, state => {
|
||||
state.purgeStatus = 'error';
|
||||
})
|
||||
// clearAllThreads action
|
||||
.addCase('thread/clearAllThreads', state => {
|
||||
state.threads = [];
|
||||
state.messagesByThreadId = {};
|
||||
state.selectedThreadId = null;
|
||||
state.messages = [];
|
||||
state.lastViewedAt = {};
|
||||
})
|
||||
// sendMessage
|
||||
.addCase(sendMessage.pending, state => {
|
||||
state.sendStatus = 'loading';
|
||||
@@ -351,7 +353,6 @@ const threadSlice = createSlice({
|
||||
export const {
|
||||
setSelectedThread,
|
||||
clearSelectedThread,
|
||||
clearCreateStatus,
|
||||
clearDeleteStatus,
|
||||
clearPurgeStatus,
|
||||
addOptimisticMessage,
|
||||
@@ -361,5 +362,10 @@ export const {
|
||||
clearSuggestedQuestions,
|
||||
setPanelWidth,
|
||||
setLastViewed,
|
||||
createThreadLocal,
|
||||
addMessageLocal,
|
||||
deleteThreadLocal,
|
||||
updateMessagesForThread,
|
||||
clearAllThreads,
|
||||
} = threadSlice.actions;
|
||||
export default threadSlice.reducer;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* OAuth provider types and interfaces
|
||||
*/
|
||||
|
||||
export type OAuthProvider = 'google' | 'twitter' | 'github' | 'discord';
|
||||
|
||||
export interface OAuthProviderConfig {
|
||||
id: OAuthProvider;
|
||||
name: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
color: string;
|
||||
hoverColor: string;
|
||||
textColor: string;
|
||||
loginUrl: string;
|
||||
}
|
||||
|
||||
export interface OAuthLoginResponse {
|
||||
success: boolean;
|
||||
data: { jwtToken: string };
|
||||
}
|
||||
|
||||
export interface OAuthError {
|
||||
provider: OAuthProvider;
|
||||
message: string;
|
||||
code?: string;
|
||||
}
|
||||
Reference in New Issue
Block a user