mirror of
https://github.com/stackblitz-labs/bolt.diy
synced 2025-06-26 18:26:38 +00:00
Merge pull request #127 from replayio/revert-126-revert-124-revert-122-revert-121-PRO-1364-Ux-and-codebase-improvements
Revert "Revert "Revert "Revert "Pro 1364 ux and codebase improvements""""
This commit is contained in:
commit
4b984dfafc
14
app/components/Header.tsx
Normal file
14
app/components/Header.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
|
||||
export const Header: React.FC = () => {
|
||||
return (
|
||||
<header className="w-full bg-white border-b border-gray-200 px-4 py-3">
|
||||
<nav className="max-w-7xl mx-auto flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<h1 className="text-xl font-semibold">Your App Name</h1>
|
||||
</div>
|
||||
{/* Add navigation items here as needed */}
|
||||
</nav>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
@ -1,6 +1,26 @@
|
||||
.container {
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
max-height: calc(100vh - 4rem);
|
||||
overflow-y: auto;
|
||||
scrollbar-gutter: stable;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: var(--bolt-elements-borderColor);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--bolt-elements-textTertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.grid {
|
||||
|
||||
@ -1,243 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { getSupabase } from '~/lib/supabase/client';
|
||||
import type { AuthError, Session, User, AuthChangeEvent } from '@supabase/supabase-js';
|
||||
|
||||
export function ClientAuth() {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showSignIn, setShowSignIn] = useState(false);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isSigningIn, setIsSigningIn] = useState(false);
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const [usageData, setUsageData] = useState<{ peanuts_used: number; peanuts_refunded: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function getUser() {
|
||||
try {
|
||||
const { data } = await getSupabase().auth.getUser();
|
||||
setUser(data.user);
|
||||
} catch (error) {
|
||||
console.error('Error fetching user:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
getUser();
|
||||
|
||||
const {
|
||||
data: { subscription },
|
||||
} = getSupabase().auth.onAuthStateChange((event: AuthChangeEvent, session: Session | null) => {
|
||||
setUser(session?.user ?? null);
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
async function updateUsageData() {
|
||||
try {
|
||||
const { data, error } = await getSupabase()
|
||||
.from('profiles')
|
||||
.select('peanuts_used, peanuts_refunded')
|
||||
.eq('id', user?.id)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
setUsageData(data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching usage data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (showDropdown) {
|
||||
updateUsageData();
|
||||
}
|
||||
}, [showDropdown]);
|
||||
|
||||
const handleSignIn = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSigningIn(true);
|
||||
|
||||
try {
|
||||
const { error } = await getSupabase().auth.signInWithPassword({ email, password });
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
setShowSignIn(false);
|
||||
toast.success('Successfully signed in!');
|
||||
} catch (error) {
|
||||
const authError = error as AuthError;
|
||||
toast.error(authError.message || 'Failed to sign in');
|
||||
} finally {
|
||||
setIsSigningIn(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSignUp = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSigningIn(true);
|
||||
|
||||
try {
|
||||
const { error } = await getSupabase().auth.signUp({ email, password });
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
toast.success('Check your email for the confirmation link!');
|
||||
setShowSignIn(false);
|
||||
} catch (error) {
|
||||
const authError = error as AuthError;
|
||||
toast.error(authError.message || 'Failed to sign up');
|
||||
} finally {
|
||||
setIsSigningIn(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSignOut = async () => {
|
||||
await getSupabase().auth.signOut();
|
||||
setShowDropdown(false);
|
||||
toast.success('Signed out successfully');
|
||||
};
|
||||
|
||||
const handleGoogleSignIn = async () => {
|
||||
const { error } = await getSupabase().auth.signInWithOAuth({
|
||||
provider: 'google',
|
||||
});
|
||||
console.log('GoogleSignIn', error);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="w-8 h-8 rounded-full bg-gray-300 animate-pulse" />;
|
||||
}
|
||||
|
||||
// Avatar URLs are disabled due to broken links from CORS issues.
|
||||
const useAvatarURL = false;
|
||||
|
||||
return (
|
||||
<>
|
||||
{user ? (
|
||||
<div className="relative">
|
||||
<button
|
||||
className="flex items-center justify-center w-8 h-8 rounded-full bg-green-500 text-white"
|
||||
onClick={() => setShowDropdown(!showDropdown)}
|
||||
>
|
||||
{useAvatarURL && user.user_metadata?.avatar_url ? (
|
||||
<img
|
||||
src={user.user_metadata.avatar_url}
|
||||
alt="User avatar"
|
||||
className="w-full h-full rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span>{user.email?.substring(0, 2).toUpperCase()}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{showDropdown && (
|
||||
<div className="absolute right-0 mt-2 py-2 whitespace-nowrap bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor rounded shadow-lg z-10">
|
||||
<div className="px-4 py-2 text-bolt-elements-textPrimary border-b border-bolt-elements-borderColor">
|
||||
{user.email}
|
||||
</div>
|
||||
<div className="px-4 py-2 text-bolt-elements-textPrimary border-b border-bolt-elements-borderColor">
|
||||
{`Peanuts used: ${usageData?.peanuts_used ?? '...'}`}
|
||||
</div>
|
||||
<div className="px-4 py-2 text-bolt-elements-textPrimary border-b border-bolt-elements-borderColor">
|
||||
{`Peanuts refunded: ${usageData?.peanuts_refunded ?? '...'}`}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSignOut}
|
||||
className="block w-full text-left px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600 font-bold"
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowSignIn(true)}
|
||||
className="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600 font-bold"
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showSignIn && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 flex justify-center items-center z-50"
|
||||
onClick={() => setShowSignIn(false)}
|
||||
>
|
||||
<div
|
||||
className="bg-bolt-elements-background-depth-1 p-6 rounded-lg max-w-md mx-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 className="text-2xl font-bold mb-4 text-bolt-elements-textPrimary">Sign In</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGoogleSignIn}
|
||||
disabled={isSigningIn}
|
||||
className="w-full mb-4 p-2 bg-green-500 text-white rounded hover:bg-green-600 disabled:opacity-50"
|
||||
>
|
||||
{isSigningIn ? 'Processing...' : 'Use Google'}
|
||||
</button>
|
||||
<form onSubmit={handleSignIn}>
|
||||
<div className="mb-4">
|
||||
<label htmlFor="email" className="block mb-1 text-bolt-elements-textPrimary">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full p-2 border rounded bg-bolt-elements-background-depth-2 text-bolt-elements-textPrimary border-bolt-elements-borderColor"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label htmlFor="password" className="block mb-1 text-bolt-elements-textPrimary">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full p-2 border rounded bg-bolt-elements-background-depth-2 text-bolt-elements-textPrimary border-bolt-elements-borderColor"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSigningIn}
|
||||
className="flex-1 py-2 bg-green-500 text-white rounded hover:bg-green-600 disabled:opacity-50"
|
||||
>
|
||||
{isSigningIn ? 'Processing...' : 'Sign In'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSignUp}
|
||||
disabled={isSigningIn}
|
||||
className="flex-1 py-2 bg-green-500 text-white rounded hover:bg-green-600 disabled:opacity-50"
|
||||
>
|
||||
{isSigningIn ? 'Processing...' : 'Sign Up'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
161
app/components/auth/ClientAuth/ClientAuth.tsx
Normal file
161
app/components/auth/ClientAuth/ClientAuth.tsx
Normal file
@ -0,0 +1,161 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { getSupabase } from '~/lib/supabase/client';
|
||||
import type { Session, User, AuthChangeEvent } from '@supabase/supabase-js';
|
||||
import { SignInForm } from './SignInForm';
|
||||
import { SignUpForm } from './SignUpForm';
|
||||
|
||||
export function ClientAuth() {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showAuthModal, setShowAuthModal] = useState(false);
|
||||
const [isSignUp, setIsSignUp] = useState(false);
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const [usageData, setUsageData] = useState<{ peanuts_used: number; peanuts_refunded: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function getUser() {
|
||||
try {
|
||||
const { data } = await getSupabase().auth.getUser();
|
||||
setUser(data.user);
|
||||
} catch (error) {
|
||||
console.error('Error fetching user:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
getUser();
|
||||
|
||||
const {
|
||||
data: { subscription },
|
||||
} = getSupabase().auth.onAuthStateChange((event: AuthChangeEvent, session: Session | null) => {
|
||||
setUser(session?.user ?? null);
|
||||
if (session?.user) {
|
||||
setShowAuthModal(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
async function updateUsageData() {
|
||||
try {
|
||||
const { data, error } = await getSupabase()
|
||||
.from('profiles')
|
||||
.select('peanuts_used, peanuts_refunded')
|
||||
.eq('id', user?.id)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
setUsageData(data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching usage data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (showDropdown) {
|
||||
updateUsageData();
|
||||
}
|
||||
}, [showDropdown]);
|
||||
|
||||
const handleSignOut = async () => {
|
||||
await getSupabase().auth.signOut();
|
||||
setShowDropdown(false);
|
||||
toast.success('Signed out successfully');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="w-8 h-8 rounded-full bg-gray-300 animate-pulse" />;
|
||||
}
|
||||
|
||||
// Avatar URLs are disabled due to broken links from CORS issues.
|
||||
const useAvatarURL = false;
|
||||
|
||||
return (
|
||||
<>
|
||||
{user ? (
|
||||
<div className="relative">
|
||||
<button
|
||||
className="flex items-center justify-center w-8 h-8 rounded-full bg-green-500 text-white"
|
||||
onClick={() => setShowDropdown(!showDropdown)}
|
||||
>
|
||||
{useAvatarURL && user.user_metadata?.avatar_url ? (
|
||||
<img
|
||||
src={user.user_metadata.avatar_url}
|
||||
alt="User avatar"
|
||||
className="w-full h-full rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span>{user.email?.substring(0, 2).toUpperCase()}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{showDropdown && (
|
||||
<div className="absolute right-0 mt-2 py-2 w-64 bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor rounded-lg shadow-lg z-10">
|
||||
<div className="px-4 py-3 text-bolt-elements-textPrimary border-b border-bolt-elements-borderColor">
|
||||
<div className="text-sm text-bolt-elements-textSecondary">Signed in as</div>
|
||||
<div className="font-medium truncate">{user.email}</div>
|
||||
</div>
|
||||
<div className="px-4 py-3 text-bolt-elements-textPrimary border-b border-bolt-elements-borderColor">
|
||||
<div className="text-sm text-bolt-elements-textSecondary">Usage</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between items-center">
|
||||
<span>Peanuts used</span>
|
||||
<span className="font-medium">{usageData?.peanuts_used ?? '...'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span>Peanuts refunded</span>
|
||||
<span className="font-medium">{usageData?.peanuts_refunded ?? '...'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-2 pt-2">
|
||||
<button
|
||||
onClick={handleSignOut}
|
||||
className="w-full px-4 py-2 text-left bg-green-500 text-white hover:bg-green-600 rounded-md transition-colors flex items-center justify-center"
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowAuthModal(true);
|
||||
setIsSignUp(false);
|
||||
}}
|
||||
className="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600 font-bold"
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showAuthModal && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 backdrop-blur-sm flex justify-center items-center z-50"
|
||||
onClick={() => setShowAuthModal(false)}
|
||||
>
|
||||
<div
|
||||
className="bg-bolt-elements-background-depth-1 p-8 rounded-lg w-full max-w-md mx-auto border border-bolt-elements-borderColor"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{isSignUp ? (
|
||||
<SignUpForm onToggleForm={() => setIsSignUp(false)} />
|
||||
) : (
|
||||
<SignInForm onToggleForm={() => setIsSignUp(true)} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
116
app/components/auth/ClientAuth/SignInForm.tsx
Normal file
116
app/components/auth/ClientAuth/SignInForm.tsx
Normal file
@ -0,0 +1,116 @@
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { getSupabase } from '~/lib/supabase/client';
|
||||
import type { AuthError } from '@supabase/supabase-js';
|
||||
import { GoogleIcon } from '~/components/icons/google-icon';
|
||||
|
||||
interface SignInFormProps {
|
||||
onToggleForm: () => void;
|
||||
}
|
||||
|
||||
export function SignInForm({ onToggleForm }: SignInFormProps) {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
const handleSignIn = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsProcessing(true);
|
||||
|
||||
try {
|
||||
const { error } = await getSupabase().auth.signInWithPassword({ email, password });
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
toast.success('Successfully signed in!');
|
||||
} catch (error) {
|
||||
const authError = error as AuthError;
|
||||
toast.error(authError.message || 'Failed to sign in');
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoogleSignIn = async () => {
|
||||
const { error } = await getSupabase().auth.signInWithOAuth({
|
||||
provider: 'google',
|
||||
});
|
||||
if (error) {
|
||||
toast.error(error.message || 'Failed to sign in with Google');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2 className="text-2xl font-bold mb-6 text-bolt-elements-textPrimary text-center">Welcome Back</h2>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGoogleSignIn}
|
||||
disabled={isProcessing}
|
||||
className="w-full mb-6 p-3 flex items-center justify-center gap-2 bg-white text-gray-800 rounded-lg hover:bg-gray-100 disabled:opacity-50 border border-gray-300"
|
||||
>
|
||||
<GoogleIcon />
|
||||
<span>{isProcessing ? 'Processing...' : 'Continue with Google'}</span>
|
||||
</button>
|
||||
|
||||
<div className="relative my-6">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-bolt-elements-borderColor"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-bolt-elements-background-depth-1 text-bolt-elements-textSecondary">
|
||||
Or continue with email
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSignIn}>
|
||||
<div className="mb-4">
|
||||
<label htmlFor="email" className="block mb-2 text-sm font-medium text-bolt-elements-textPrimary">
|
||||
Email Address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full p-3 border rounded-lg bg-bolt-elements-background-depth-2 text-bolt-elements-textPrimary border-bolt-elements-borderColor focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label htmlFor="password" className="block mb-2 text-sm font-medium text-bolt-elements-textPrimary">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full p-3 border rounded-lg bg-bolt-elements-background-depth-2 text-bolt-elements-textPrimary border-bolt-elements-borderColor focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isProcessing}
|
||||
className="w-full py-3 bg-green-500 text-white rounded-lg hover:bg-green-600 disabled:opacity-50 font-medium"
|
||||
>
|
||||
{isProcessing ? 'Processing...' : 'Sign In'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-6 text-center text-bolt-elements-textSecondary">
|
||||
Don't have an account?{' '}
|
||||
<button onClick={onToggleForm} className="text-green-500 hover:text-green-600 font-medium bg-transparent">
|
||||
Sign Up
|
||||
</button>
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
137
app/components/auth/ClientAuth/SignUpForm.tsx
Normal file
137
app/components/auth/ClientAuth/SignUpForm.tsx
Normal file
@ -0,0 +1,137 @@
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { getSupabase } from '~/lib/supabase/client';
|
||||
import type { AuthError } from '@supabase/supabase-js';
|
||||
import { GoogleIcon } from '~/components/icons/google-icon';
|
||||
|
||||
interface SignUpFormProps {
|
||||
onToggleForm: () => void;
|
||||
}
|
||||
|
||||
export function SignUpForm({ onToggleForm }: SignUpFormProps) {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
const handleSignUp = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
toast.error('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
|
||||
try {
|
||||
const { error } = await getSupabase().auth.signUp({ email, password });
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
toast.success('Check your email for the confirmation link!');
|
||||
} catch (error) {
|
||||
const authError = error as AuthError;
|
||||
toast.error(authError.message || 'Failed to sign up');
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoogleSignIn = async () => {
|
||||
const { error } = await getSupabase().auth.signInWithOAuth({
|
||||
provider: 'google',
|
||||
});
|
||||
if (error) {
|
||||
toast.error(error.message || 'Failed to sign in with Google');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2 className="text-2xl font-bold mb-6 text-bolt-elements-textPrimary text-center">Create Account</h2>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGoogleSignIn}
|
||||
disabled={isProcessing}
|
||||
className="w-full mb-6 p-3 flex items-center justify-center gap-2 bg-white text-gray-800 rounded-lg hover:bg-gray-100 disabled:opacity-50 border border-gray-300"
|
||||
>
|
||||
<GoogleIcon />
|
||||
<span>{isProcessing ? 'Processing...' : 'Continue with Google'}</span>
|
||||
</button>
|
||||
|
||||
<div className="relative my-6">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-bolt-elements-borderColor"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-bolt-elements-background-depth-1 text-bolt-elements-textSecondary">
|
||||
Or continue with email
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSignUp}>
|
||||
<div className="mb-4">
|
||||
<label htmlFor="email" className="block mb-2 text-sm font-medium text-bolt-elements-textPrimary">
|
||||
Email Address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full p-3 border rounded-lg bg-bolt-elements-background-depth-2 text-bolt-elements-textPrimary border-bolt-elements-borderColor focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label htmlFor="password" className="block mb-2 text-sm font-medium text-bolt-elements-textPrimary">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full p-3 border rounded-lg bg-bolt-elements-background-depth-2 text-bolt-elements-textPrimary border-bolt-elements-borderColor focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<label htmlFor="confirmPassword" className="block mb-2 text-sm font-medium text-bolt-elements-textPrimary">
|
||||
Confirm Password
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="w-full p-3 border rounded-lg bg-bolt-elements-background-depth-2 text-bolt-elements-textPrimary border-bolt-elements-borderColor focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isProcessing}
|
||||
className="w-full py-3 bg-green-500 text-white rounded-lg hover:bg-green-600 disabled:opacity-50 font-medium"
|
||||
>
|
||||
{isProcessing ? 'Processing...' : 'Create Account'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-6 text-center text-bolt-elements-textSecondary">
|
||||
Already have an account?{' '}
|
||||
<button onClick={onToggleForm} className="text-green-500 hover:text-green-600 font-medium bg-transparent">
|
||||
Sign In
|
||||
</button>
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { TEXTAREA_MIN_HEIGHT } from './BaseChat';
|
||||
import { TEXTAREA_MIN_HEIGHT } from './BaseChat/BaseChat';
|
||||
|
||||
export interface RejectChangeData {
|
||||
explanation: string;
|
||||
|
||||
@ -1,497 +0,0 @@
|
||||
/*
|
||||
* @ts-nocheck
|
||||
* Preventing TS checks with files presented in the video for a better presentation.
|
||||
*/
|
||||
import React, { type RefCallback, useEffect, useState } from 'react';
|
||||
import { ClientOnly } from 'remix-utils/client-only';
|
||||
import { Menu } from '~/components/sidebar/Menu.client';
|
||||
import { IconButton } from '~/components/ui/IconButton';
|
||||
import { Workbench } from '~/components/workbench/Workbench.client';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { Messages } from './Messages.client';
|
||||
import { type Message } from '~/lib/persistence/message';
|
||||
import { SendButton } from './SendButton.client';
|
||||
import * as Tooltip from '@radix-ui/react-tooltip';
|
||||
|
||||
import styles from './BaseChat.module.scss';
|
||||
import { ExamplePrompts } from '~/components/chat/ExamplePrompts';
|
||||
import { ExampleLibraryApps } from '~/components/app-library/ExampleLibraryApps';
|
||||
|
||||
import FilePreview from './FilePreview';
|
||||
import { SpeechRecognitionButton } from '~/components/chat/SpeechRecognition';
|
||||
import { ScreenshotStateManager } from './ScreenshotStateManager';
|
||||
import type { RejectChangeData } from './ApproveChange';
|
||||
import ApproveChange from './ApproveChange';
|
||||
|
||||
export const TEXTAREA_MIN_HEIGHT = 76;
|
||||
|
||||
interface BaseChatProps {
|
||||
textareaRef?: React.RefObject<HTMLTextAreaElement> | undefined;
|
||||
messageRef?: RefCallback<HTMLDivElement> | undefined;
|
||||
scrollRef?: RefCallback<HTMLDivElement> | undefined;
|
||||
showChat?: boolean;
|
||||
chatStarted?: boolean;
|
||||
hasPendingMessage?: boolean;
|
||||
pendingMessageStatus?: string;
|
||||
messages?: Message[];
|
||||
input?: string;
|
||||
handleStop?: () => void;
|
||||
sendMessage?: (messageInput?: string) => void;
|
||||
handleInputChange?: (event: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
||||
_enhancingPrompt?: boolean;
|
||||
_enhancePrompt?: () => void;
|
||||
uploadedFiles?: File[];
|
||||
setUploadedFiles?: (files: File[]) => void;
|
||||
imageDataList?: string[];
|
||||
setImageDataList?: (dataList: string[]) => void;
|
||||
onApproveChange?: (messageId: string) => void;
|
||||
onRejectChange?: (messageId: string, data: RejectChangeData) => void;
|
||||
}
|
||||
|
||||
export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
(
|
||||
{
|
||||
textareaRef,
|
||||
messageRef,
|
||||
scrollRef,
|
||||
showChat = true,
|
||||
chatStarted = false,
|
||||
hasPendingMessage = false,
|
||||
pendingMessageStatus = '',
|
||||
input = '',
|
||||
handleInputChange,
|
||||
|
||||
sendMessage,
|
||||
handleStop,
|
||||
uploadedFiles = [],
|
||||
setUploadedFiles,
|
||||
imageDataList = [],
|
||||
setImageDataList,
|
||||
messages,
|
||||
onApproveChange,
|
||||
onRejectChange,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200;
|
||||
const [isListening, setIsListening] = useState(false);
|
||||
const [recognition, setRecognition] = useState<SpeechRecognition | null>(null);
|
||||
const [transcript, setTranscript] = useState('');
|
||||
const [rejectFormOpen, setRejectFormOpen] = useState(false);
|
||||
const [pendingFilterText, setPendingFilterText] = useState('');
|
||||
const [filterText, setFilterText] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
console.log(transcript);
|
||||
}, [transcript]);
|
||||
|
||||
useEffect(() => {
|
||||
// Load API keys from cookies on component mount
|
||||
|
||||
if (typeof window !== 'undefined' && ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window)) {
|
||||
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
const recognition = new SpeechRecognition();
|
||||
recognition.continuous = true;
|
||||
recognition.interimResults = true;
|
||||
|
||||
recognition.onresult = (event) => {
|
||||
const transcript = Array.from(event.results)
|
||||
.map((result) => result[0])
|
||||
.map((result) => result.transcript)
|
||||
.join('');
|
||||
|
||||
setTranscript(transcript);
|
||||
|
||||
if (handleInputChange) {
|
||||
const syntheticEvent = {
|
||||
target: { value: transcript },
|
||||
} as React.ChangeEvent<HTMLTextAreaElement>;
|
||||
handleInputChange(syntheticEvent);
|
||||
}
|
||||
};
|
||||
|
||||
recognition.onerror = (event) => {
|
||||
console.error('Speech recognition error:', event.error);
|
||||
setIsListening(false);
|
||||
};
|
||||
|
||||
setRecognition(recognition);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startListening = () => {
|
||||
if (recognition) {
|
||||
recognition.start();
|
||||
setIsListening(true);
|
||||
}
|
||||
};
|
||||
|
||||
const stopListening = () => {
|
||||
if (recognition) {
|
||||
recognition.stop();
|
||||
setIsListening(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendMessage = (event: React.UIEvent, messageInput?: string) => {
|
||||
if (sendMessage) {
|
||||
sendMessage(messageInput);
|
||||
|
||||
if (recognition) {
|
||||
recognition.abort(); // Stop current recognition
|
||||
setTranscript(''); // Clear transcript
|
||||
setIsListening(false);
|
||||
|
||||
// Clear the input by triggering handleInputChange with empty value
|
||||
if (handleInputChange) {
|
||||
const syntheticEvent = {
|
||||
target: { value: '' },
|
||||
} as React.ChangeEvent<HTMLTextAreaElement>;
|
||||
handleInputChange(syntheticEvent);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = () => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'image/*';
|
||||
|
||||
input.onchange = async (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (e) => {
|
||||
const base64Image = e.target?.result as string;
|
||||
setUploadedFiles?.([...uploadedFiles, file]);
|
||||
setImageDataList?.([...imageDataList, base64Image]);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
input.click();
|
||||
};
|
||||
|
||||
const handlePaste = async (e: React.ClipboardEvent) => {
|
||||
const items = e.clipboardData?.items;
|
||||
|
||||
if (!items) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
if (item.type.startsWith('image/')) {
|
||||
e.preventDefault();
|
||||
|
||||
const file = item.getAsFile();
|
||||
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (e) => {
|
||||
const base64Image = e.target?.result as string;
|
||||
setUploadedFiles?.([...uploadedFiles, file]);
|
||||
setImageDataList?.([...imageDataList, base64Image]);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const approveChangeMessageId = (() => {
|
||||
if (hasPendingMessage || !messages) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const message = messages[i];
|
||||
if (message.repositoryId && message.peanuts) {
|
||||
return message.approved ? undefined : message.id;
|
||||
}
|
||||
if (message.role == 'user') {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
})();
|
||||
|
||||
let messageInput;
|
||||
|
||||
if (!rejectFormOpen) {
|
||||
messageInput = (
|
||||
<div
|
||||
className={classNames('relative shadow-xs border border-bolt-elements-borderColor backdrop-blur rounded-lg')}
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className={classNames(
|
||||
'w-full pl-4 pt-4 pr-25 outline-none resize-none text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary bg-transparent text-sm',
|
||||
'transition-all duration-200',
|
||||
'hover:border-bolt-elements-focus',
|
||||
)}
|
||||
onDragEnter={(e) => {
|
||||
e.preventDefault();
|
||||
e.currentTarget.style.border = '2px solid #1488fc';
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.currentTarget.style.border = '2px solid #1488fc';
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
e.preventDefault();
|
||||
e.currentTarget.style.border = '1px solid var(--bolt-elements-borderColor)';
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.currentTarget.style.border = '1px solid var(--bolt-elements-borderColor)';
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
files.forEach((file) => {
|
||||
if (file.type.startsWith('image/')) {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (e) => {
|
||||
const base64Image = e.target?.result as string;
|
||||
setUploadedFiles?.([...uploadedFiles, file]);
|
||||
setImageDataList?.([...imageDataList, base64Image]);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
});
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
if (event.shiftKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
if (hasPendingMessage) {
|
||||
handleStop?.();
|
||||
return;
|
||||
}
|
||||
|
||||
// ignore if using input method engine
|
||||
if (event.nativeEvent.isComposing) {
|
||||
return;
|
||||
}
|
||||
|
||||
handleSendMessage?.(event);
|
||||
}
|
||||
}}
|
||||
value={input}
|
||||
onChange={(event) => {
|
||||
handleInputChange?.(event);
|
||||
}}
|
||||
onPaste={handlePaste}
|
||||
style={{
|
||||
minHeight: TEXTAREA_MIN_HEIGHT,
|
||||
maxHeight: TEXTAREA_MAX_HEIGHT,
|
||||
}}
|
||||
placeholder={chatStarted ? 'How can we help you?' : 'What do you want to build?'}
|
||||
translate="no"
|
||||
/>
|
||||
<ClientOnly>
|
||||
{() => (
|
||||
<SendButton
|
||||
show={(hasPendingMessage || input.length > 0 || uploadedFiles.length > 0) && chatStarted}
|
||||
hasPendingMessage={hasPendingMessage}
|
||||
onClick={(event) => {
|
||||
if (hasPendingMessage) {
|
||||
handleStop?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (input.length > 0 || uploadedFiles.length > 0) {
|
||||
handleSendMessage?.(event);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ClientOnly>
|
||||
<div className="flex justify-between items-center text-sm p-4 pt-2">
|
||||
<div className="flex gap-1 items-center">
|
||||
<IconButton title="Upload file" className="transition-all" onClick={() => handleFileUpload()}>
|
||||
<div className="i-ph:paperclip text-xl"></div>
|
||||
</IconButton>
|
||||
|
||||
<SpeechRecognitionButton
|
||||
isListening={isListening}
|
||||
onStart={startListening}
|
||||
onStop={stopListening}
|
||||
disabled={hasPendingMessage}
|
||||
/>
|
||||
</div>
|
||||
{input.length > 3 ? (
|
||||
<div className="text-xs text-bolt-elements-textTertiary">
|
||||
Use <kbd className="kdb px-1.5 py-0.5 rounded bg-bolt-elements-background-depth-2">Shift</kbd> +{' '}
|
||||
<kbd className="kdb px-1.5 py-0.5 rounded bg-bolt-elements-background-depth-2">Return</kbd> a new line
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const baseChat = (
|
||||
<div
|
||||
ref={ref}
|
||||
className={classNames(styles.BaseChat, 'relative flex h-full w-full overflow-hidden')}
|
||||
data-chat-visible={showChat}
|
||||
>
|
||||
<ClientOnly>{() => <Menu />}</ClientOnly>
|
||||
<div ref={scrollRef} className="flex flex-col lg:flex-row overflow-y-auto w-full h-full">
|
||||
<div className={classNames(styles.Chat, 'flex flex-col flex-grow lg:min-w-[var(--chat-min-width)] h-full')}>
|
||||
{!chatStarted && (
|
||||
<div id="intro" className="mt-[16vh] max-w-chat mx-auto text-center px-4 lg:px-0">
|
||||
<h1 className="text-3xl lg:text-6xl font-bold text-bolt-elements-textPrimary mb-4 animate-fade-in">
|
||||
Get what you want
|
||||
</h1>
|
||||
<p className="text-md lg:text-xl mb-8 text-bolt-elements-textSecondary animate-fade-in animation-delay-200">
|
||||
Write, test, and fix your app all from one prompt
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={classNames('px-2 sm:px-6', {
|
||||
'h-full flex flex-col': chatStarted,
|
||||
})}
|
||||
>
|
||||
<ClientOnly>
|
||||
{() => {
|
||||
return chatStarted ? (
|
||||
<Messages
|
||||
ref={messageRef}
|
||||
className="flex flex-col w-full flex-1 max-w-chat pb-6 mx-auto z-1"
|
||||
messages={messages}
|
||||
hasPendingMessage={hasPendingMessage}
|
||||
pendingMessageStatus={pendingMessageStatus}
|
||||
/>
|
||||
) : null;
|
||||
}}
|
||||
</ClientOnly>
|
||||
<div
|
||||
className={classNames(
|
||||
'bg-bolt-elements-background-depth-2 p-3 rounded-lg border border-bolt-elements-borderColor relative w-full max-w-chat mx-auto z-prompt',
|
||||
{
|
||||
'sticky bottom-2': chatStarted,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<svg className={classNames(styles.PromptEffectContainer)}>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="line-gradient"
|
||||
x1="20%"
|
||||
y1="0%"
|
||||
x2="-14%"
|
||||
y2="10%"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="rotate(-45)"
|
||||
>
|
||||
<stop offset="0%" stopColor="#b44aff" stopOpacity="0%"></stop>
|
||||
<stop offset="40%" stopColor="#b44aff" stopOpacity="80%"></stop>
|
||||
<stop offset="50%" stopColor="#b44aff" stopOpacity="80%"></stop>
|
||||
<stop offset="100%" stopColor="#b44aff" stopOpacity="0%"></stop>
|
||||
</linearGradient>
|
||||
<linearGradient id="shine-gradient">
|
||||
<stop offset="0%" stopColor="white" stopOpacity="0%"></stop>
|
||||
<stop offset="40%" stopColor="#ffffff" stopOpacity="80%"></stop>
|
||||
<stop offset="50%" stopColor="#ffffff" stopOpacity="80%"></stop>
|
||||
<stop offset="100%" stopColor="white" stopOpacity="0%"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect className={classNames(styles.PromptEffectLine)} pathLength="100" strokeLinecap="round"></rect>
|
||||
<rect className={classNames(styles.PromptShine)} x="48" y="24" width="70" height="1"></rect>
|
||||
</svg>
|
||||
<FilePreview
|
||||
files={uploadedFiles}
|
||||
imageDataList={imageDataList}
|
||||
onRemove={(index) => {
|
||||
setUploadedFiles?.(uploadedFiles.filter((_, i) => i !== index));
|
||||
setImageDataList?.(imageDataList.filter((_, i) => i !== index));
|
||||
}}
|
||||
/>
|
||||
<ClientOnly>
|
||||
{() => (
|
||||
<ScreenshotStateManager
|
||||
setUploadedFiles={setUploadedFiles}
|
||||
setImageDataList={setImageDataList}
|
||||
uploadedFiles={uploadedFiles}
|
||||
imageDataList={imageDataList}
|
||||
/>
|
||||
)}
|
||||
</ClientOnly>
|
||||
{approveChangeMessageId && (
|
||||
<ApproveChange
|
||||
rejectFormOpen={rejectFormOpen}
|
||||
setRejectFormOpen={setRejectFormOpen}
|
||||
onApprove={() => onApproveChange?.(approveChangeMessageId)}
|
||||
onReject={(data) => onRejectChange?.(approveChangeMessageId, data)}
|
||||
/>
|
||||
)}
|
||||
{!rejectFormOpen && messageInput}
|
||||
</div>
|
||||
</div>
|
||||
{!chatStarted && (
|
||||
<>
|
||||
{ExamplePrompts((event: React.UIEvent, messageInput?: string) => {
|
||||
if (hasPendingMessage) {
|
||||
handleStop?.();
|
||||
return;
|
||||
}
|
||||
|
||||
handleSendMessage?.(event, messageInput);
|
||||
})}
|
||||
<div className="text-2xl lg:text-4xl font-bold text-bolt-elements-textPrimary mt-8 mb-4 animate-fade-in text-center max-w-chat mx-auto">
|
||||
Arboretum
|
||||
</div>
|
||||
<div className="text-bolt-elements-textSecondary text-center max-w-chat mx-auto">
|
||||
Browse these auto-generated apps for a place to start
|
||||
</div>
|
||||
<div
|
||||
className="placeholder-bolt-elements-textTertiary"
|
||||
style={{ display: 'flex', justifyContent: 'center', marginBottom: '1rem' }}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
setFilterText(pendingFilterText);
|
||||
}
|
||||
}}
|
||||
onChange={(event) => {
|
||||
setPendingFilterText(event.target.value);
|
||||
}}
|
||||
style={{
|
||||
width: '200px',
|
||||
padding: '0.5rem',
|
||||
marginTop: '0.5rem',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '0.9rem',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<ExampleLibraryApps filterText={filterText} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<ClientOnly>{() => <Workbench chatStarted={chatStarted} />}</ClientOnly>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return <Tooltip.Provider delayDuration={200}>{baseChat}</Tooltip.Provider>;
|
||||
},
|
||||
);
|
||||
215
app/components/chat/BaseChat/BaseChat.tsx
Normal file
215
app/components/chat/BaseChat/BaseChat.tsx
Normal file
@ -0,0 +1,215 @@
|
||||
/*
|
||||
* @ts-nocheck
|
||||
* Preventing TS checks with files presented in the video for a better presentation.
|
||||
*/
|
||||
import React, { type RefCallback, useCallback } from 'react';
|
||||
import { ClientOnly } from 'remix-utils/client-only';
|
||||
import { Menu } from '~/components/sidebar/Menu.client';
|
||||
import { Workbench } from '~/components/workbench/Workbench.client';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { Messages } from '~/components/chat/Messages.client';
|
||||
import { type Message } from '~/lib/persistence/message';
|
||||
import * as Tooltip from '@radix-ui/react-tooltip';
|
||||
import { IntroSection } from '~/components/chat/BaseChat/components/IntroSection/IntroSection';
|
||||
import { SearchInput } from '~/components/chat/SearchInput/SearchInput';
|
||||
import { ChatPromptContainer } from '~/components/chat/BaseChat/components/ChatPromptContainer/ChatPromptContainer';
|
||||
import { useSpeechRecognition } from '~/hooks/useSpeechRecognition';
|
||||
import styles from './BaseChat.module.scss';
|
||||
import { ExamplePrompts } from '~/components/chat/ExamplePrompts';
|
||||
import { ExampleLibraryApps } from '~/components/app-library/ExampleLibraryApps';
|
||||
import type { RejectChangeData } from '~/components/chat/ApproveChange';
|
||||
import { type MessageInputProps } from '~/components/chat/MessageInput/MessageInput';
|
||||
|
||||
export const TEXTAREA_MIN_HEIGHT = 76;
|
||||
|
||||
interface BaseChatProps {
|
||||
textareaRef?: React.RefObject<HTMLTextAreaElement>;
|
||||
messageRef?: RefCallback<HTMLDivElement>;
|
||||
scrollRef?: RefCallback<HTMLDivElement>;
|
||||
showChat?: boolean;
|
||||
chatStarted?: boolean;
|
||||
hasPendingMessage?: boolean;
|
||||
pendingMessageStatus?: string;
|
||||
messages?: Message[];
|
||||
input?: string;
|
||||
handleStop?: () => void;
|
||||
sendMessage?: (messageInput?: string) => void;
|
||||
handleInputChange?: (event: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
||||
uploadedFiles?: File[];
|
||||
setUploadedFiles?: (files: File[]) => void;
|
||||
imageDataList?: string[];
|
||||
setImageDataList?: (dataList: string[]) => void;
|
||||
onApproveChange?: (messageId: string) => void;
|
||||
onRejectChange?: (messageId: string, data: RejectChangeData) => void;
|
||||
}
|
||||
|
||||
type ExtendedMessage = Message & {
|
||||
repositoryId?: string;
|
||||
peanuts?: boolean;
|
||||
approved?: boolean;
|
||||
};
|
||||
|
||||
export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
(
|
||||
{
|
||||
textareaRef,
|
||||
messageRef,
|
||||
scrollRef,
|
||||
showChat = true,
|
||||
chatStarted = false,
|
||||
hasPendingMessage = false,
|
||||
pendingMessageStatus = '',
|
||||
input = '',
|
||||
handleInputChange,
|
||||
sendMessage,
|
||||
handleStop,
|
||||
uploadedFiles = [],
|
||||
setUploadedFiles,
|
||||
imageDataList = [],
|
||||
setImageDataList,
|
||||
messages,
|
||||
onApproveChange,
|
||||
onRejectChange,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200;
|
||||
const [rejectFormOpen, setRejectFormOpen] = React.useState(false);
|
||||
const [filterText, setFilterText] = React.useState('');
|
||||
|
||||
const onTranscriptChange = useCallback(
|
||||
(transcript: string) => {
|
||||
if (handleInputChange) {
|
||||
const syntheticEvent = {
|
||||
target: { value: transcript },
|
||||
} as React.ChangeEvent<HTMLTextAreaElement>;
|
||||
handleInputChange(syntheticEvent);
|
||||
}
|
||||
},
|
||||
[handleInputChange],
|
||||
);
|
||||
|
||||
const { isListening, startListening, stopListening, abortListening } = useSpeechRecognition({
|
||||
onTranscriptChange,
|
||||
});
|
||||
|
||||
const handleSendMessage = (event: React.UIEvent, messageInput?: string) => {
|
||||
if (sendMessage) {
|
||||
sendMessage(messageInput);
|
||||
abortListening();
|
||||
|
||||
if (handleInputChange) {
|
||||
const syntheticEvent = {
|
||||
target: { value: '' },
|
||||
} as React.ChangeEvent<HTMLTextAreaElement>;
|
||||
handleInputChange(syntheticEvent);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const approveChangeMessageId = (() => {
|
||||
if (hasPendingMessage || !messages) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const message = messages[i] as ExtendedMessage;
|
||||
if (message.repositoryId && message.peanuts) {
|
||||
return message.approved ? undefined : message.id;
|
||||
}
|
||||
if (message.role === 'user') {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
})();
|
||||
|
||||
const messageInputProps = {
|
||||
textareaRef,
|
||||
input,
|
||||
handleInputChange,
|
||||
handleSendMessage,
|
||||
handleStop,
|
||||
hasPendingMessage,
|
||||
chatStarted,
|
||||
uploadedFiles,
|
||||
setUploadedFiles,
|
||||
imageDataList,
|
||||
setImageDataList,
|
||||
isListening,
|
||||
onStartListening: startListening,
|
||||
onStopListening: stopListening,
|
||||
minHeight: TEXTAREA_MIN_HEIGHT,
|
||||
maxHeight: TEXTAREA_MAX_HEIGHT,
|
||||
};
|
||||
|
||||
const baseChat = (
|
||||
<div
|
||||
ref={ref}
|
||||
className={classNames(styles.BaseChat, 'relative flex h-full w-full overflow-hidden p-6')}
|
||||
data-chat-visible={showChat}
|
||||
>
|
||||
<ClientOnly>{() => <Menu />}</ClientOnly>
|
||||
<div ref={scrollRef} className="flex flex-col lg:flex-row w-full h-full">
|
||||
<div className={classNames(styles.Chat, 'flex flex-col flex-grow lg:min-w-[var(--chat-min-width)] h-full')}>
|
||||
{!chatStarted && <IntroSection />}
|
||||
<div
|
||||
className={classNames('px-2 sm:px-6', {
|
||||
'h-full flex flex-col': chatStarted,
|
||||
})}
|
||||
>
|
||||
<ClientOnly>
|
||||
{() => {
|
||||
return chatStarted ? (
|
||||
<Messages
|
||||
ref={messageRef}
|
||||
className="flex flex-col w-full flex-1 max-w-chat pb-6 mx-auto z-1 overflow-y-auto"
|
||||
messages={messages}
|
||||
hasPendingMessage={hasPendingMessage}
|
||||
pendingMessageStatus={pendingMessageStatus}
|
||||
/>
|
||||
) : null;
|
||||
}}
|
||||
</ClientOnly>
|
||||
<ChatPromptContainer
|
||||
chatStarted={chatStarted}
|
||||
uploadedFiles={uploadedFiles}
|
||||
setUploadedFiles={setUploadedFiles!}
|
||||
imageDataList={imageDataList}
|
||||
setImageDataList={setImageDataList!}
|
||||
approveChangeMessageId={approveChangeMessageId}
|
||||
rejectFormOpen={rejectFormOpen}
|
||||
setRejectFormOpen={setRejectFormOpen}
|
||||
onApproveChange={onApproveChange}
|
||||
onRejectChange={onRejectChange}
|
||||
messageInputProps={messageInputProps as MessageInputProps}
|
||||
/>
|
||||
</div>
|
||||
{!chatStarted && (
|
||||
<>
|
||||
{ExamplePrompts((event: React.UIEvent, messageInput?: string) => {
|
||||
if (hasPendingMessage) {
|
||||
handleStop?.();
|
||||
return;
|
||||
}
|
||||
handleSendMessage(event, messageInput);
|
||||
})}
|
||||
<div className="text-2xl lg:text-4xl font-bold text-bolt-elements-textPrimary mt-8 mb-4 animate-fade-in text-center max-w-chat mx-auto">
|
||||
Arboretum
|
||||
</div>
|
||||
<div className="text-bolt-elements-textSecondary text-center max-w-chat mx-auto">
|
||||
Browse these auto-generated apps for a place to start
|
||||
</div>
|
||||
<SearchInput onSearch={setFilterText} />
|
||||
<ExampleLibraryApps filterText={filterText} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<ClientOnly>{() => <Workbench chatStarted={chatStarted} />}</ClientOnly>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return <Tooltip.Provider delayDuration={200}>{baseChat}</Tooltip.Provider>;
|
||||
},
|
||||
);
|
||||
@ -0,0 +1,101 @@
|
||||
import React from 'react';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import FilePreview from '~/components/chat/FilePreview';
|
||||
import { ScreenshotStateManager } from '~/components/chat/ScreenshotStateManager';
|
||||
import { ClientOnly } from 'remix-utils/client-only';
|
||||
import ApproveChange from '~/components/chat/ApproveChange';
|
||||
import { MessageInput } from '~/components/chat/MessageInput/MessageInput';
|
||||
import styles from '~/components/chat/BaseChat/BaseChat.module.scss';
|
||||
|
||||
interface ChatPromptContainerProps {
|
||||
chatStarted: boolean;
|
||||
uploadedFiles: File[];
|
||||
setUploadedFiles: (files: File[]) => void;
|
||||
imageDataList: string[];
|
||||
setImageDataList: (dataList: string[]) => void;
|
||||
approveChangeMessageId?: string;
|
||||
rejectFormOpen: boolean;
|
||||
setRejectFormOpen: (open: boolean) => void;
|
||||
onApproveChange?: (messageId: string) => void;
|
||||
onRejectChange?: (messageId: string, data: any) => void;
|
||||
messageInputProps: Partial<React.ComponentProps<typeof MessageInput>>;
|
||||
}
|
||||
|
||||
export const ChatPromptContainer: React.FC<ChatPromptContainerProps> = ({
|
||||
chatStarted,
|
||||
uploadedFiles,
|
||||
setUploadedFiles,
|
||||
imageDataList,
|
||||
setImageDataList,
|
||||
approveChangeMessageId,
|
||||
rejectFormOpen,
|
||||
setRejectFormOpen,
|
||||
onApproveChange,
|
||||
onRejectChange,
|
||||
messageInputProps,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'bg-bolt-elements-background-depth-2 p-3 rounded-lg border border-bolt-elements-borderColor relative w-full max-w-chat mx-auto z-prompt',
|
||||
{
|
||||
'sticky bottom-2': chatStarted,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<svg className={classNames(styles.PromptEffectContainer)}>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="line-gradient"
|
||||
x1="20%"
|
||||
y1="0%"
|
||||
x2="-14%"
|
||||
y2="10%"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="rotate(-45)"
|
||||
>
|
||||
<stop offset="0%" stopColor="#b44aff" stopOpacity="0%"></stop>
|
||||
<stop offset="40%" stopColor="#b44aff" stopOpacity="80%"></stop>
|
||||
<stop offset="50%" stopColor="#b44aff" stopOpacity="80%"></stop>
|
||||
<stop offset="100%" stopColor="#b44aff" stopOpacity="0%"></stop>
|
||||
</linearGradient>
|
||||
<linearGradient id="shine-gradient">
|
||||
<stop offset="0%" stopColor="white" stopOpacity="0%"></stop>
|
||||
<stop offset="40%" stopColor="#ffffff" stopOpacity="80%"></stop>
|
||||
<stop offset="50%" stopColor="#ffffff" stopOpacity="80%"></stop>
|
||||
<stop offset="100%" stopColor="white" stopOpacity="0%"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect className={classNames(styles.PromptEffectLine)} pathLength="100" strokeLinecap="round"></rect>
|
||||
<rect className={classNames(styles.PromptShine)} x="48" y="24" width="70" height="1"></rect>
|
||||
</svg>
|
||||
<FilePreview
|
||||
files={uploadedFiles}
|
||||
imageDataList={imageDataList}
|
||||
onRemove={(index) => {
|
||||
setUploadedFiles(uploadedFiles.filter((_, i) => i !== index));
|
||||
setImageDataList(imageDataList.filter((_, i) => i !== index));
|
||||
}}
|
||||
/>
|
||||
<ClientOnly>
|
||||
{() => (
|
||||
<ScreenshotStateManager
|
||||
setUploadedFiles={setUploadedFiles}
|
||||
setImageDataList={setImageDataList}
|
||||
uploadedFiles={uploadedFiles}
|
||||
imageDataList={imageDataList}
|
||||
/>
|
||||
)}
|
||||
</ClientOnly>
|
||||
{approveChangeMessageId && (
|
||||
<ApproveChange
|
||||
rejectFormOpen={rejectFormOpen}
|
||||
setRejectFormOpen={setRejectFormOpen}
|
||||
onApprove={() => onApproveChange?.(approveChangeMessageId)}
|
||||
onReject={(data) => onRejectChange?.(approveChangeMessageId, data)}
|
||||
/>
|
||||
)}
|
||||
{!rejectFormOpen && <MessageInput {...messageInputProps} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
|
||||
export const IntroSection: React.FC = () => {
|
||||
return (
|
||||
<div id="intro" className="mt-[16vh] max-w-chat mx-auto text-center px-4 lg:px-0">
|
||||
<h1 className="text-3xl lg:text-6xl font-bold text-bolt-elements-textPrimary mb-4 animate-fade-in">
|
||||
Get what you want
|
||||
</h1>
|
||||
<p className="text-md lg:text-xl mb-8 text-bolt-elements-textSecondary animate-fade-in animation-delay-200">
|
||||
Write, test, and fix your app all from one prompt
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
60
app/components/chat/ChatComponent/Chat.client.tsx
Normal file
60
app/components/chat/ChatComponent/Chat.client.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
/*
|
||||
* @ts-nocheck
|
||||
* Preventing TS checks with files presented in the video for a better presentation.
|
||||
*/
|
||||
import { cssTransition, ToastContainer } from 'react-toastify';
|
||||
import { useChatHistory } from '~/lib/persistence';
|
||||
import { renderLogger } from '~/utils/logger';
|
||||
import ChatImplementer from './components/ChatImplementer/ChatImplementer';
|
||||
import flushSimulationData from './functions/flushSimulation';
|
||||
|
||||
const toastAnimation = cssTransition({
|
||||
enter: 'animated fadeInRight',
|
||||
exit: 'animated fadeOutRight',
|
||||
});
|
||||
|
||||
setInterval(async () => {
|
||||
flushSimulationData();
|
||||
}, 1000);
|
||||
|
||||
export function Chat() {
|
||||
renderLogger.trace('Chat');
|
||||
|
||||
const { ready, initialMessages, resumeChat, storeMessageHistory } = useChatHistory();
|
||||
|
||||
return (
|
||||
<>
|
||||
{ready && (
|
||||
<ChatImplementer
|
||||
initialMessages={initialMessages}
|
||||
resumeChat={resumeChat}
|
||||
storeMessageHistory={storeMessageHistory}
|
||||
/>
|
||||
)}
|
||||
<ToastContainer
|
||||
closeButton={({ closeToast }) => {
|
||||
return (
|
||||
<button className="Toastify__close-button" onClick={closeToast}>
|
||||
<div className="i-ph:x text-lg" />
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
icon={({ type }) => {
|
||||
switch (type) {
|
||||
case 'success': {
|
||||
return <div className="i-ph:check-bold text-bolt-elements-icon-success text-2xl" />;
|
||||
}
|
||||
case 'error': {
|
||||
return <div className="i-ph:warning-circle-bold text-bolt-elements-icon-error text-2xl" />;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}}
|
||||
position="bottom-right"
|
||||
pauseOnFocusLoss
|
||||
transition={toastAnimation}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,22 +1,16 @@
|
||||
/*
|
||||
* @ts-nocheck
|
||||
* Preventing TS checks with files presented in the video for a better presentation.
|
||||
*/
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { useAnimate } from 'framer-motion';
|
||||
import { memo, useEffect, useRef, useState } from 'react';
|
||||
import { cssTransition, toast, ToastContainer } from 'react-toastify';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useSnapScroll } from '~/lib/hooks';
|
||||
import { handleChatTitleUpdate, useChatHistory, type ResumeChatInfo } from '~/lib/persistence';
|
||||
import { handleChatTitleUpdate, type ResumeChatInfo } from '~/lib/persistence';
|
||||
import { database } from '~/lib/persistence/chats';
|
||||
import { chatStore } from '~/lib/stores/chat';
|
||||
import { cubicEasingFn } from '~/utils/easings';
|
||||
import { renderLogger } from '~/utils/logger';
|
||||
import { BaseChat } from './BaseChat';
|
||||
import { BaseChat } from '~/components/chat/BaseChat/BaseChat';
|
||||
import Cookies from 'js-cookie';
|
||||
import { useSearchParams } from '@remix-run/react';
|
||||
import {
|
||||
simulationAddData,
|
||||
simulationFinishData,
|
||||
simulationRepositoryUpdated,
|
||||
sendChatMessage,
|
||||
@ -24,95 +18,20 @@ import {
|
||||
abortChatMessage,
|
||||
resumeChatMessage,
|
||||
} from '~/lib/replay/ChatManager';
|
||||
import { getIFrameSimulationData } from '~/lib/replay/Recording';
|
||||
import { getCurrentIFrame } from '~/components/workbench/Preview';
|
||||
import { getCurrentMouseData } from '~/components/workbench/PointSelector';
|
||||
import { anthropicNumFreeUsesCookieName, maxFreeUses } from '~/utils/freeUses';
|
||||
import { ChatMessageTelemetry, pingTelemetry } from '~/lib/hooks/pingTelemetry';
|
||||
import type { RejectChangeData } from './ApproveChange';
|
||||
import { assert, generateRandomId } from '~/lib/replay/ReplayProtocolClient';
|
||||
import type { RejectChangeData } from '~/components/chat/ApproveChange';
|
||||
import { generateRandomId } from '~/lib/replay/ReplayProtocolClient';
|
||||
import { getMessagesRepositoryId, getPreviousRepositoryId, type Message } from '~/lib/persistence/message';
|
||||
import { useAuthStatus } from '~/lib/stores/auth';
|
||||
import { debounce } from '~/utils/debounce';
|
||||
import { supabaseSubmitFeedback } from '~/lib/supabase/feedback';
|
||||
import { supabaseAddRefund } from '~/lib/supabase/peanuts';
|
||||
|
||||
const toastAnimation = cssTransition({
|
||||
enter: 'animated fadeInRight',
|
||||
exit: 'animated fadeOutRight',
|
||||
});
|
||||
|
||||
let gLastChatMessages: Message[] | undefined;
|
||||
|
||||
export function getLastChatMessages() {
|
||||
return gLastChatMessages;
|
||||
}
|
||||
|
||||
async function flushSimulationData() {
|
||||
//console.log("FlushSimulationData");
|
||||
|
||||
const iframe = getCurrentIFrame();
|
||||
|
||||
if (!iframe) {
|
||||
return;
|
||||
}
|
||||
|
||||
const simulationData = await getIFrameSimulationData(iframe);
|
||||
|
||||
if (!simulationData.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
//console.log("HaveSimulationData", simulationData.length);
|
||||
|
||||
// Add the simulation data to the chat.
|
||||
simulationAddData(simulationData);
|
||||
}
|
||||
|
||||
setInterval(async () => {
|
||||
flushSimulationData();
|
||||
}, 1000);
|
||||
|
||||
export function Chat() {
|
||||
renderLogger.trace('Chat');
|
||||
|
||||
const { ready, initialMessages, resumeChat, storeMessageHistory } = useChatHistory();
|
||||
|
||||
return (
|
||||
<>
|
||||
{ready && (
|
||||
<ChatImpl initialMessages={initialMessages} resumeChat={resumeChat} storeMessageHistory={storeMessageHistory} />
|
||||
)}
|
||||
<ToastContainer
|
||||
closeButton={({ closeToast }) => {
|
||||
return (
|
||||
<button className="Toastify__close-button" onClick={closeToast}>
|
||||
<div className="i-ph:x text-lg" />
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
icon={({ type }) => {
|
||||
/**
|
||||
* @todo Handle more types if we need them. This may require extra color palettes.
|
||||
*/
|
||||
switch (type) {
|
||||
case 'success': {
|
||||
return <div className="i-ph:check-bold text-bolt-elements-icon-success text-2xl" />;
|
||||
}
|
||||
case 'error': {
|
||||
return <div className="i-ph:warning-circle-bold text-bolt-elements-icon-error text-2xl" />;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}}
|
||||
position="bottom-right"
|
||||
pauseOnFocusLoss
|
||||
transition={toastAnimation}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
import mergeResponseMessage from '~/components/chat/ChatComponent/functions/mergeResponseMessages';
|
||||
import flushSimulationData from '~/components/chat/ChatComponent/functions/flushSimulation';
|
||||
import getRewindMessageIndexAfterReject from '~/components/chat/ChatComponent/functions/getRewindMessageIndexAfterReject';
|
||||
import flashScreen from '~/components/chat/ChatComponent/functions/flashScreen';
|
||||
|
||||
interface ChatProps {
|
||||
initialMessages: Message[];
|
||||
@ -128,39 +47,13 @@ async function clearActiveChat() {
|
||||
gActiveChatMessageTelemetry = undefined;
|
||||
}
|
||||
|
||||
function mergeResponseMessage(msg: Message, messages: Message[]): Message[] {
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
if (lastMessage.id == msg.id) {
|
||||
messages.pop();
|
||||
assert(lastMessage.type == 'text', 'Last message must be a text message');
|
||||
assert(msg.type == 'text', 'Message must be a text message');
|
||||
messages.push({
|
||||
...msg,
|
||||
content: lastMessage.content + msg.content,
|
||||
});
|
||||
} else {
|
||||
messages.push(msg);
|
||||
}
|
||||
return messages;
|
||||
let gLastChatMessages: Message[] | undefined;
|
||||
|
||||
export function getLastChatMessages() {
|
||||
return gLastChatMessages;
|
||||
}
|
||||
|
||||
// Get the index of the last message that will be present after rewinding.
|
||||
// This is the last message which is either a user message or has a repository change.
|
||||
function getRewindMessageIndexAfterReject(messages: Message[], messageId: string): number {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const { id, role, repositoryId } = messages[i];
|
||||
if (role == 'user') {
|
||||
return i;
|
||||
}
|
||||
if (repositoryId && id != messageId) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
console.error('No rewind message found', messages, messageId);
|
||||
return -1;
|
||||
}
|
||||
|
||||
export const ChatImpl = memo((props: ChatProps) => {
|
||||
const ChatImplementer = memo((props: ChatProps) => {
|
||||
const { initialMessages, resumeChat: initialResumeChat, storeMessageHistory } = props;
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
@ -169,22 +62,12 @@ export const ChatImpl = memo((props: ChatProps) => {
|
||||
const [imageDataList, setImageDataList] = useState<string[]>([]); // Move here
|
||||
const [searchParams] = useSearchParams();
|
||||
const { isLoggedIn } = useAuthStatus();
|
||||
|
||||
// Input currently in the textarea.
|
||||
const [input, setInput] = useState('');
|
||||
|
||||
/*
|
||||
* This is set when the user has triggered a chat message and the response hasn't finished
|
||||
* being generated.
|
||||
*/
|
||||
const [pendingMessageId, setPendingMessageId] = useState<string | undefined>(undefined);
|
||||
|
||||
// Last status we heard for the pending message.
|
||||
const [pendingMessageStatus, setPendingMessageStatus] = useState('');
|
||||
|
||||
// If we are listening to responses from a chat started in an earlier session,
|
||||
// this will be set. This is equivalent to having a pending message but is
|
||||
// handled differently.
|
||||
const [resumeChat, setResumeChat] = useState<ResumeChatInfo | undefined>(initialResumeChat);
|
||||
|
||||
const [messages, setMessages] = useState<Message[]>(initialMessages);
|
||||
@ -201,7 +84,6 @@ export const ChatImpl = memo((props: ChatProps) => {
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
// Load any repository in the initial messages.
|
||||
useEffect(() => {
|
||||
const repositoryId = getMessagesRepositoryId(initialMessages);
|
||||
|
||||
@ -315,7 +197,6 @@ export const ChatImpl = memo((props: ChatProps) => {
|
||||
|
||||
setMessages(newMessages);
|
||||
|
||||
// Add file cleanup here
|
||||
setUploadedFiles([]);
|
||||
setImageDataList([]);
|
||||
|
||||
@ -336,7 +217,6 @@ export const ChatImpl = memo((props: ChatProps) => {
|
||||
newMessages = mergeResponseMessage(msg, [...newMessages]);
|
||||
setMessages(newMessages);
|
||||
|
||||
// Update the repository as soon as it has changed.
|
||||
const responseRepositoryId = getMessagesRepositoryId(newMessages);
|
||||
|
||||
if (responseRepositoryId && existingRepositoryId != responseRepositoryId) {
|
||||
@ -417,11 +297,6 @@ export const ChatImpl = memo((props: ChatProps) => {
|
||||
|
||||
let newMessages = messages;
|
||||
|
||||
// The response messages we get may overlap with the ones we already have.
|
||||
// Look for this and remove any existing message when we receive the first
|
||||
// piece of a response message.
|
||||
//
|
||||
// Messages we have already received a portion of a response for.
|
||||
const hasReceivedResponse = new Set<string>();
|
||||
|
||||
const addResponseMessage = (msg: Message) => {
|
||||
@ -439,7 +314,6 @@ export const ChatImpl = memo((props: ChatProps) => {
|
||||
newMessages = mergeResponseMessage(msg, [...newMessages]);
|
||||
setMessages(newMessages);
|
||||
|
||||
// Update the repository as soon as it has changed.
|
||||
const responseRepositoryId = getMessagesRepositoryId(newMessages);
|
||||
|
||||
if (responseRepositoryId && existingRepositoryId != responseRepositoryId) {
|
||||
@ -492,28 +366,6 @@ export const ChatImpl = memo((props: ChatProps) => {
|
||||
})();
|
||||
}, [initialResumeChat]);
|
||||
|
||||
const flashScreen = async () => {
|
||||
const flash = document.createElement('div');
|
||||
flash.style.position = 'fixed';
|
||||
flash.style.top = '0';
|
||||
flash.style.left = '0';
|
||||
flash.style.width = '100%';
|
||||
flash.style.height = '100%';
|
||||
flash.style.backgroundColor = 'rgba(0, 255, 0, 0.3)';
|
||||
flash.style.zIndex = '9999';
|
||||
flash.style.pointerEvents = 'none';
|
||||
document.body.appendChild(flash);
|
||||
|
||||
// Fade out and remove after 500ms
|
||||
setTimeout(() => {
|
||||
flash.style.transition = 'opacity 0.5s';
|
||||
flash.style.opacity = '0';
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(flash);
|
||||
}, 500);
|
||||
}, 200);
|
||||
};
|
||||
|
||||
const onApproveChange = async (messageId: string) => {
|
||||
console.log('ApproveChange', messageId);
|
||||
|
||||
@ -539,8 +391,6 @@ export const ChatImpl = memo((props: ChatProps) => {
|
||||
const onRejectChange = async (messageId: string, data: RejectChangeData) => {
|
||||
console.log('RejectChange', messageId, data);
|
||||
|
||||
// Find the last message that will be present after rewinding. This is the
|
||||
// last message which is either a user message or has a repository change.
|
||||
const messageIndex = getRewindMessageIndexAfterReject(messages, messageId);
|
||||
|
||||
if (messageIndex < 0) {
|
||||
@ -621,3 +471,5 @@ export const ChatImpl = memo((props: ChatProps) => {
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default ChatImplementer;
|
||||
22
app/components/chat/ChatComponent/functions/flashScreen.ts
Normal file
22
app/components/chat/ChatComponent/functions/flashScreen.ts
Normal file
@ -0,0 +1,22 @@
|
||||
const flashScreen = async () => {
|
||||
const flash = document.createElement('div');
|
||||
flash.style.position = 'fixed';
|
||||
flash.style.top = '0';
|
||||
flash.style.left = '0';
|
||||
flash.style.width = '100%';
|
||||
flash.style.height = '100%';
|
||||
flash.style.backgroundColor = 'rgba(0, 255, 0, 0.3)';
|
||||
flash.style.zIndex = '9999';
|
||||
flash.style.pointerEvents = 'none';
|
||||
document.body.appendChild(flash);
|
||||
|
||||
setTimeout(() => {
|
||||
flash.style.transition = 'opacity 0.5s';
|
||||
flash.style.opacity = '0';
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(flash);
|
||||
}, 500);
|
||||
}, 200);
|
||||
};
|
||||
|
||||
export default flashScreen;
|
||||
@ -0,0 +1,26 @@
|
||||
import { getIFrameSimulationData } from '~/lib/replay/Recording';
|
||||
import { simulationAddData } from '~/lib/replay/ChatManager';
|
||||
import { getCurrentIFrame } from '~/components/workbench/Preview';
|
||||
|
||||
async function flushSimulationData() {
|
||||
//console.log("FlushSimulationData");
|
||||
|
||||
const iframe = getCurrentIFrame();
|
||||
|
||||
if (!iframe) {
|
||||
return;
|
||||
}
|
||||
|
||||
const simulationData = await getIFrameSimulationData(iframe);
|
||||
|
||||
if (!simulationData.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
//console.log("HaveSimulationData", simulationData.length);
|
||||
|
||||
// Add the simulation data to the chat.
|
||||
simulationAddData(simulationData);
|
||||
}
|
||||
|
||||
export default flushSimulationData;
|
||||
@ -0,0 +1,18 @@
|
||||
import type { Message } from '~/lib/persistence/message';
|
||||
|
||||
function getRewindMessageIndexAfterReject(messages: Message[], messageId: string): number {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const { id, role, repositoryId } = messages[i];
|
||||
if (role == 'user') {
|
||||
return i;
|
||||
}
|
||||
if (repositoryId && id != messageId) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
console.error('No rewind message found', messages, messageId);
|
||||
return -1;
|
||||
}
|
||||
|
||||
export default getRewindMessageIndexAfterReject;
|
||||
@ -0,0 +1,20 @@
|
||||
import type { Message } from '~/lib/persistence/message';
|
||||
import { assert } from '~/lib/replay/ReplayProtocolClient';
|
||||
|
||||
function mergeResponseMessage(msg: Message, messages: Message[]): Message[] {
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
if (lastMessage.id == msg.id) {
|
||||
messages.pop();
|
||||
assert(lastMessage.type == 'text', 'Last message must be a text message');
|
||||
assert(msg.type == 'text', 'Message must be a text message');
|
||||
messages.push({
|
||||
...msg,
|
||||
content: lastMessage.content + msg.content,
|
||||
});
|
||||
} else {
|
||||
messages.push(msg);
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
export default mergeResponseMessage;
|
||||
206
app/components/chat/MessageInput/MessageInput.tsx
Normal file
206
app/components/chat/MessageInput/MessageInput.tsx
Normal file
@ -0,0 +1,206 @@
|
||||
import React from 'react';
|
||||
import { ClientOnly } from 'remix-utils/client-only';
|
||||
import { IconButton } from '~/components/ui/IconButton';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { SendButton } from '~/components/chat/SendButton.client';
|
||||
import { SpeechRecognitionButton } from '~/components/chat/SpeechRecognition';
|
||||
|
||||
export interface MessageInputProps {
|
||||
textareaRef?: React.RefObject<HTMLTextAreaElement>;
|
||||
input?: string;
|
||||
handleInputChange?: (event: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
||||
handleSendMessage?: (event: React.UIEvent) => void;
|
||||
handleStop?: () => void;
|
||||
hasPendingMessage?: boolean;
|
||||
chatStarted?: boolean;
|
||||
uploadedFiles?: File[];
|
||||
setUploadedFiles?: (files: File[]) => void;
|
||||
imageDataList?: string[];
|
||||
setImageDataList?: (dataList: string[]) => void;
|
||||
isListening?: boolean;
|
||||
onStartListening?: () => void;
|
||||
onStopListening?: () => void;
|
||||
minHeight?: number;
|
||||
maxHeight?: number;
|
||||
}
|
||||
|
||||
export const MessageInput: React.FC<MessageInputProps> = ({
|
||||
textareaRef,
|
||||
input = '',
|
||||
handleInputChange = () => {},
|
||||
handleSendMessage = () => {},
|
||||
handleStop = () => {},
|
||||
hasPendingMessage = false,
|
||||
chatStarted = false,
|
||||
uploadedFiles = [],
|
||||
setUploadedFiles = () => {},
|
||||
imageDataList = [],
|
||||
setImageDataList = () => {},
|
||||
isListening = false,
|
||||
onStartListening = () => {},
|
||||
onStopListening = () => {},
|
||||
minHeight = 76,
|
||||
maxHeight = 200,
|
||||
}) => {
|
||||
const handleFileUpload = () => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'image/*';
|
||||
|
||||
input.onchange = async (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (e) => {
|
||||
const base64Image = e.target?.result as string;
|
||||
setUploadedFiles([...uploadedFiles, file]);
|
||||
setImageDataList([...imageDataList, base64Image]);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
input.click();
|
||||
};
|
||||
|
||||
const handlePaste = async (e: React.ClipboardEvent) => {
|
||||
const items = e.clipboardData?.items;
|
||||
|
||||
if (!items) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
if (item.type.startsWith('image/')) {
|
||||
e.preventDefault();
|
||||
|
||||
const file = item.getAsFile();
|
||||
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (e) => {
|
||||
const base64Image = e.target?.result as string;
|
||||
setUploadedFiles([...uploadedFiles, file]);
|
||||
setImageDataList([...imageDataList, base64Image]);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classNames('relative shadow-xs border border-bolt-elements-borderColor backdrop-blur rounded-lg')}>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className={classNames(
|
||||
'w-full pl-4 pt-4 pr-25 outline-none resize-none text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary bg-transparent text-sm',
|
||||
'transition-all duration-200',
|
||||
'hover:border-bolt-elements-focus',
|
||||
)}
|
||||
onDragEnter={(e) => {
|
||||
e.preventDefault();
|
||||
e.currentTarget.style.border = '2px solid #1488fc';
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.currentTarget.style.border = '2px solid #1488fc';
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
e.preventDefault();
|
||||
e.currentTarget.style.border = '1px solid var(--bolt-elements-borderColor)';
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.currentTarget.style.border = '1px solid var(--bolt-elements-borderColor)';
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
files.forEach((file) => {
|
||||
if (file.type.startsWith('image/')) {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (e) => {
|
||||
const base64Image = e.target?.result as string;
|
||||
setUploadedFiles([...uploadedFiles, file]);
|
||||
setImageDataList([...imageDataList, base64Image]);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
});
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
if (event.shiftKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
if (hasPendingMessage) {
|
||||
handleStop();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.nativeEvent.isComposing) {
|
||||
return;
|
||||
}
|
||||
|
||||
handleSendMessage(event);
|
||||
}
|
||||
}}
|
||||
value={input}
|
||||
onChange={handleInputChange}
|
||||
onPaste={handlePaste}
|
||||
style={{
|
||||
minHeight,
|
||||
maxHeight,
|
||||
}}
|
||||
placeholder={chatStarted ? 'How can we help you?' : 'What do you want to build?'}
|
||||
translate="no"
|
||||
/>
|
||||
<ClientOnly>
|
||||
{() => (
|
||||
<SendButton
|
||||
show={(hasPendingMessage || input.length > 0 || uploadedFiles.length > 0) && chatStarted}
|
||||
hasPendingMessage={hasPendingMessage}
|
||||
onClick={(event) => {
|
||||
if (hasPendingMessage) {
|
||||
handleStop();
|
||||
return;
|
||||
}
|
||||
|
||||
if (input.length > 0 || uploadedFiles.length > 0) {
|
||||
handleSendMessage(event);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ClientOnly>
|
||||
<div className="flex justify-between items-center text-sm p-4 pt-2">
|
||||
<div className="flex gap-1 items-center">
|
||||
<IconButton title="Upload file" className="transition-all" onClick={handleFileUpload}>
|
||||
<div className="i-ph:paperclip text-xl"></div>
|
||||
</IconButton>
|
||||
|
||||
<SpeechRecognitionButton
|
||||
isListening={isListening}
|
||||
onStart={onStartListening}
|
||||
onStop={onStopListening}
|
||||
disabled={hasPendingMessage}
|
||||
/>
|
||||
</div>
|
||||
{input.length > 3 ? (
|
||||
<div className="text-xs text-bolt-elements-textTertiary">
|
||||
Use <kbd className="kdb px-1.5 py-0.5 rounded bg-bolt-elements-background-depth-2">Shift</kbd> +{' '}
|
||||
<kbd className="kdb px-1.5 py-0.5 rounded bg-bolt-elements-background-depth-2">Return</kbd> a new line
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
33
app/components/chat/SearchInput/SearchInput.tsx
Normal file
33
app/components/chat/SearchInput/SearchInput.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
|
||||
interface SearchInputProps {
|
||||
onSearch: (text: string) => void;
|
||||
}
|
||||
|
||||
export const SearchInput: React.FC<SearchInputProps> = ({ onSearch }) => {
|
||||
return (
|
||||
<div
|
||||
className="placeholder-bolt-elements-textTertiary"
|
||||
style={{ display: 'flex', justifyContent: 'center', marginBottom: '1rem' }}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
onSearch(event.currentTarget.value);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
width: '200px',
|
||||
padding: '0.5rem',
|
||||
marginTop: '0.5rem',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '0.9rem',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -2,7 +2,7 @@ import { toast } from 'react-toastify';
|
||||
import ReactModal from 'react-modal';
|
||||
import { useState } from 'react';
|
||||
import { supabaseSubmitFeedback } from '~/lib/supabase/feedback';
|
||||
import { getLastChatMessages } from '~/components/chat/Chat.client';
|
||||
import { getLastChatMessages } from '~/utils/chat/messageUtils';
|
||||
|
||||
ReactModal.setAppElement('#root');
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@ import { HeaderActionButtons } from './HeaderActionButtons.client';
|
||||
import { ChatDescription } from '~/lib/persistence/ChatDescription.client';
|
||||
import { Feedback } from './Feedback';
|
||||
import { Suspense } from 'react';
|
||||
import { ClientAuth } from '~/components/auth/ClientAuth';
|
||||
import { ClientAuth } from '~/components/auth/ClientAuth/ClientAuth';
|
||||
import { DeployChatButton } from './DeployChatButton';
|
||||
import { DownloadButton } from './DownloadButton';
|
||||
|
||||
@ -38,7 +38,7 @@ export function Header() {
|
||||
|
||||
<div className="flex-1 flex items-center ">
|
||||
{chatStarted && (
|
||||
<span className="flex-1 px-4 truncate text-center text-bolt-elements-textPrimary">
|
||||
<span className="px-4 truncate text-center text-bolt-elements-textPrimary">
|
||||
<ClientOnly>{() => <ChatDescription />}</ClientOnly>
|
||||
</span>
|
||||
)}
|
||||
|
||||
22
app/components/icons/google-icon.tsx
Normal file
22
app/components/icons/google-icon.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
export function GoogleIcon() {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M18.171 8.368h-.67v-.035H10v3.333h4.709A4.998 4.998 0 0 1 5 10a5 5 0 0 1 5-5c1.275 0 2.434.48 3.317 1.266l2.357-2.357A8.295 8.295 0 0 0 10 1.667a8.334 8.334 0 1 0 8.171 6.7z"
|
||||
fill="#FFC107"
|
||||
/>
|
||||
<path
|
||||
d="M2.628 6.121l2.734 2.008a4.998 4.998 0 0 1 4.638-3.13c1.275 0 2.434.482 3.317 1.267l2.357-2.357A8.295 8.295 0 0 0 10 1.667 8.329 8.329 0 0 0 2.628 6.12z"
|
||||
fill="#FF3D00"
|
||||
/>
|
||||
<path
|
||||
d="M10 18.333a8.294 8.294 0 0 0 5.587-2.163l-2.579-2.183A4.963 4.963 0 0 1 10 15a4.998 4.998 0 0 1-4.701-3.311L2.58 13.783A8.327 8.327 0 0 0 10 18.333z"
|
||||
fill="#4CAF50"
|
||||
/>
|
||||
<path
|
||||
d="M18.171 8.368H17.5v-.034H10v3.333h4.71a5.017 5.017 0 0 1-1.703 2.321l2.58 2.182c-.182.166 2.746-2.003 2.746-6.17 0-.559-.057-1.104-.162-1.632z"
|
||||
fill="#1976D2"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
67
app/hooks/useSpeechRecognition.ts
Normal file
67
app/hooks/useSpeechRecognition.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface UseSpeechRecognitionProps {
|
||||
onTranscriptChange: (transcript: string) => void;
|
||||
}
|
||||
|
||||
export const useSpeechRecognition = ({ onTranscriptChange }: UseSpeechRecognitionProps) => {
|
||||
const [isListening, setIsListening] = useState(false);
|
||||
const [recognition, setRecognition] = useState<SpeechRecognition | null>(null);
|
||||
const [transcript, setTranscript] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined' && ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window)) {
|
||||
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
const recognition = new SpeechRecognition();
|
||||
recognition.continuous = true;
|
||||
recognition.interimResults = true;
|
||||
|
||||
recognition.onresult = (event) => {
|
||||
const transcript = Array.from(event.results)
|
||||
.map((result) => result[0])
|
||||
.map((result) => result.transcript)
|
||||
.join('');
|
||||
|
||||
setTranscript(transcript);
|
||||
onTranscriptChange(transcript);
|
||||
};
|
||||
|
||||
recognition.onerror = (event) => {
|
||||
console.error('Speech recognition error:', event.error);
|
||||
setIsListening(false);
|
||||
};
|
||||
|
||||
setRecognition(recognition);
|
||||
}
|
||||
}, [onTranscriptChange]);
|
||||
|
||||
const startListening = () => {
|
||||
if (recognition) {
|
||||
recognition.start();
|
||||
setIsListening(true);
|
||||
}
|
||||
};
|
||||
|
||||
const stopListening = () => {
|
||||
if (recognition) {
|
||||
recognition.stop();
|
||||
setIsListening(false);
|
||||
}
|
||||
};
|
||||
|
||||
const abortListening = () => {
|
||||
if (recognition) {
|
||||
recognition.abort();
|
||||
setTranscript('');
|
||||
setIsListening(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
isListening,
|
||||
transcript,
|
||||
startListening,
|
||||
stopListening,
|
||||
abortListening,
|
||||
};
|
||||
};
|
||||
0
app/layout/ContentContainer.tsx
Normal file
0
app/layout/ContentContainer.tsx
Normal file
17
app/layout/PageContainer.tsx
Normal file
17
app/layout/PageContainer.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
import { Header } from '~/components/header/Header';
|
||||
import BackgroundRays from '~/components/ui/BackgroundRays';
|
||||
|
||||
interface PageContainerProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const PageContainer: React.FC<PageContainerProps> = ({ children }) => {
|
||||
return (
|
||||
<div className="h-screen w-full flex flex-col bg-bolt-elements-background-depth-1 dark:bg-black overflow-hidden">
|
||||
<Header />
|
||||
<BackgroundRays />
|
||||
<div className="flex-1 w-full overflow-y-auto page-content">{children}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -16,12 +16,12 @@ interface MessageBase {
|
||||
approved?: boolean;
|
||||
}
|
||||
|
||||
interface MessageText extends MessageBase {
|
||||
export interface MessageText extends MessageBase {
|
||||
type: 'text';
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface MessageImage extends MessageBase {
|
||||
export interface MessageImage extends MessageBase {
|
||||
type: 'image';
|
||||
dataURL: string;
|
||||
}
|
||||
|
||||
@ -171,7 +171,7 @@ export default function App() {
|
||||
<ClientOnly>
|
||||
<ThemeProvider />
|
||||
<AuthProvider data={data} />
|
||||
<main className="">{isLoading ? <div></div> : <Outlet />}</main>
|
||||
<main className="h-full min-h-screen">{isLoading ? <div></div> : <Outlet />}</main>
|
||||
<ToastContainer position="bottom-right" theme={theme} />
|
||||
<AuthModal />
|
||||
</ClientOnly>
|
||||
|
||||
@ -1,11 +1,9 @@
|
||||
import { json, type MetaFunction } from '~/lib/remix-types';
|
||||
import { Suspense } from 'react';
|
||||
import { ClientOnly } from 'remix-utils/client-only';
|
||||
import { BaseChat } from '~/components/chat/BaseChat';
|
||||
import { Chat } from '~/components/chat/Chat.client';
|
||||
import { Header } from '~/components/header/Header';
|
||||
import BackgroundRays from '~/components/ui/BackgroundRays';
|
||||
|
||||
import { BaseChat } from '~/components/chat/BaseChat/BaseChat';
|
||||
import { Chat } from '~/components/chat/ChatComponent/Chat.client';
|
||||
import { PageContainer } from '~/layout/PageContainer';
|
||||
export const meta: MetaFunction = () => {
|
||||
return [{ title: 'Nut' }];
|
||||
};
|
||||
@ -16,12 +14,10 @@ const Nothing = () => null;
|
||||
|
||||
export default function Index() {
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-screen w-full bg-bolt-elements-background-depth-1 dark:bg-black">
|
||||
<BackgroundRays />
|
||||
<Header />
|
||||
<PageContainer>
|
||||
<Suspense fallback={<Nothing />}>
|
||||
<ClientOnly fallback={<BaseChat />}>{() => <Chat />}</ClientOnly>
|
||||
</Suspense>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,15 +1,12 @@
|
||||
import { ClientOnly } from 'remix-utils/client-only';
|
||||
import { Header } from '~/components/header/Header';
|
||||
import { Menu } from '~/components/sidebar/Menu.client';
|
||||
import BackgroundRays from '~/components/ui/BackgroundRays';
|
||||
import { TooltipProvider } from '@radix-ui/react-tooltip';
|
||||
import { PageContainer } from '~/layout/PageContainer';
|
||||
|
||||
function AboutPage() {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="flex flex-col h-full min-h-screen w-full bg-bolt-elements-background-depth-1">
|
||||
<BackgroundRays />
|
||||
<Header />
|
||||
<PageContainer>
|
||||
<ClientOnly>{() => <Menu />}</ClientOnly>
|
||||
<div className="max-w-3xl mx-auto px-6 py-12 prose dark:text-gray-200 prose-p:text-gray-700 dark:prose-p:text-gray-300 prose-a:text-bolt-elements-accent">
|
||||
<h1 className="text-4xl font-bold mb-8 text-gray-900 dark:text-gray-200">About Nut</h1>
|
||||
@ -61,7 +58,7 @@ function AboutPage() {
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
27
app/types/chat.ts
Normal file
27
app/types/chat.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import type { Message, MessageImage, MessageText } from '~/lib/persistence/message';
|
||||
import type { ResumeChatInfo } from '~/lib/persistence/useChatHistory';
|
||||
import type { RejectChangeData } from '~/components/chat/ApproveChange';
|
||||
|
||||
export interface ChatProps {
|
||||
initialMessages: Message[];
|
||||
resumeChat: ResumeChatInfo | undefined;
|
||||
storeMessageHistory: (messages: Message[]) => void;
|
||||
}
|
||||
|
||||
export interface ChatImplProps extends ChatProps {
|
||||
onApproveChange?: (messageId: string) => Promise<void>;
|
||||
onRejectChange?: (messageId: string, data: RejectChangeData) => Promise<void>;
|
||||
}
|
||||
|
||||
// Re-export types we need
|
||||
export type { Message, MessageImage, MessageText, ResumeChatInfo };
|
||||
|
||||
export interface UserMessage extends MessageText {
|
||||
role: 'user';
|
||||
type: 'text';
|
||||
}
|
||||
|
||||
export interface UserImageMessage extends MessageImage {
|
||||
role: 'user';
|
||||
type: 'image';
|
||||
}
|
||||
49
app/utils/chat/messageUtils.ts
Normal file
49
app/utils/chat/messageUtils.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { assert } from '~/lib/replay/ReplayProtocolClient';
|
||||
import type { Message } from '~/lib/persistence/message';
|
||||
|
||||
let gLastChatMessages: Message[] | undefined;
|
||||
|
||||
export function getLastChatMessages() {
|
||||
return gLastChatMessages;
|
||||
}
|
||||
|
||||
export function setLastChatMessages(messages: Message[] | undefined) {
|
||||
gLastChatMessages = messages;
|
||||
}
|
||||
|
||||
export function mergeResponseMessage(msg: Message, messages: Message[]): Message[] {
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
|
||||
if (lastMessage.id == msg.id) {
|
||||
messages.pop();
|
||||
|
||||
assert(lastMessage.type == 'text', 'Last message must be a text message');
|
||||
assert(msg.type == 'text', 'Message must be a text message');
|
||||
|
||||
messages.push({
|
||||
...msg,
|
||||
content: lastMessage.content + msg.content,
|
||||
});
|
||||
} else {
|
||||
messages.push(msg);
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
export function getRewindMessageIndexAfterReject(messages: Message[], messageId: string): number {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const { id, role, repositoryId } = messages[i];
|
||||
|
||||
if (role == 'user') {
|
||||
return i;
|
||||
}
|
||||
|
||||
if (repositoryId && id != messageId) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
console.error('No rewind message found', messages, messageId);
|
||||
return -1;
|
||||
}
|
||||
25
app/utils/chat/simulationUtils.ts
Normal file
25
app/utils/chat/simulationUtils.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { getIFrameSimulationData } from '~/lib/replay/Recording';
|
||||
import { getCurrentIFrame } from '~/components/workbench/Preview';
|
||||
import { simulationAddData } from '~/lib/replay/ChatManager';
|
||||
|
||||
export async function flushSimulationData() {
|
||||
const iframe = getCurrentIFrame();
|
||||
|
||||
if (!iframe) {
|
||||
return;
|
||||
}
|
||||
|
||||
const simulationData = await getIFrameSimulationData(iframe);
|
||||
|
||||
if (!simulationData.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
simulationAddData(simulationData);
|
||||
}
|
||||
|
||||
export function setupSimulationInterval() {
|
||||
setInterval(async () => {
|
||||
flushSimulationData();
|
||||
}, 1000);
|
||||
}
|
||||
1
icons/google-icon.svg
Normal file
1
icons/google-icon.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48px" height="48px"><path fill="#FFC107" d="M43.611,20.083H42V20H24v8h11.303c-1.649,4.657-6.08,8-11.303,8c-6.627,0-12-5.373-12-12c0-6.627,5.373-12,12-12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C12.955,4,4,12.955,4,24c0,11.045,8.955,20,20,20c11.045,0,20-8.955,20-20C44,22.659,43.862,21.35,43.611,20.083z"/><path fill="#FF3D00" d="M6.306,14.691l6.571,4.819C14.655,15.108,18.961,12,24,12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C16.318,4,9.656,8.337,6.306,14.691z"/><path fill="#4CAF50" d="M24,44c5.166,0,9.86-1.977,13.409-5.192l-6.19-5.238C29.211,35.091,26.715,36,24,36c-5.202,0-9.619-3.317-11.283-7.946l-6.522,5.025C9.505,39.556,16.227,44,24,44z"/><path fill="#1976D2" d="M43.611,20.083H42V20H24v8h11.303c-0.792,2.237-2.231,4.166-4.087,5.571c0.001-0.001,0.002-0.001,0.003-0.002l6.19,5.238C36.971,39.205,44,34,44,24C44,22.659,43.862,21.35,43.611,20.083z"/></svg>
|
||||
|
After Width: | Height: | Size: 988 B |
Loading…
Reference in New Issue
Block a user