bolt.diy/app/components/chat/Chat.client.tsx
KevIsDev bc7e2c5821 feat(supabase): add credentials handling for Supabase API keys and URL
This commit introduces the ability to fetch and store Supabase API keys and URL credentials when a project is selected. This enables the application to dynamically configure the Supabase connection environment variables, improving the integration with Supabase services. The changes include updates to the Supabase connection logic, new API endpoints, and modifications to the chat and prompt components to utilize the new credentials.
2025-03-20 11:17:27 +00:00

568 lines
17 KiB
TypeScript

/*
* @ts-nocheck
* Preventing TS checks with files presented in the video for a better presentation.
*/
import { useStore } from '@nanostores/react';
import type { Message } from 'ai';
import { useChat } from 'ai/react';
import { useAnimate } from 'framer-motion';
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { cssTransition, toast, ToastContainer } from 'react-toastify';
import { useMessageParser, usePromptEnhancer, useShortcuts, useSnapScroll } from '~/lib/hooks';
import { description, useChatHistory } from '~/lib/persistence';
import { chatStore } from '~/lib/stores/chat';
import { workbenchStore } from '~/lib/stores/workbench';
import { DEFAULT_MODEL, DEFAULT_PROVIDER, PROMPT_COOKIE_KEY, PROVIDER_LIST } from '~/utils/constants';
import { cubicEasingFn } from '~/utils/easings';
import { createScopedLogger, renderLogger } from '~/utils/logger';
import { BaseChat } from './BaseChat';
import Cookies from 'js-cookie';
import { debounce } from '~/utils/debounce';
import { useSettings } from '~/lib/hooks/useSettings';
import type { ProviderInfo } from '~/types/model';
import { useSearchParams } from '@remix-run/react';
import { createSampler } from '~/utils/sampler';
import { getTemplates, selectStarterTemplate } from '~/utils/selectStarterTemplate';
import { logStore } from '~/lib/stores/logs';
import { streamingState } from '~/lib/stores/streaming';
import { filesToArtifacts } from '~/utils/fileUtils';
import { supabaseConnection } from '~/lib/stores/supabase';
const toastAnimation = cssTransition({
enter: 'animated fadeInRight',
exit: 'animated fadeOutRight',
});
const logger = createScopedLogger('Chat');
export function Chat() {
renderLogger.trace('Chat');
const { ready, initialMessages, storeMessageHistory, importChat, exportChat } = useChatHistory();
const title = useStore(description);
useEffect(() => {
workbenchStore.setReloadedMessages(initialMessages.map((m) => m.id));
}, [initialMessages]);
return (
<>
{ready && (
<ChatImpl
description={title}
initialMessages={initialMessages}
exportChat={exportChat}
storeMessageHistory={storeMessageHistory}
importChat={importChat}
/>
)}
<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}
/>
</>
);
}
const processSampledMessages = createSampler(
(options: {
messages: Message[];
initialMessages: Message[];
isLoading: boolean;
parseMessages: (messages: Message[], isLoading: boolean) => void;
storeMessageHistory: (messages: Message[]) => Promise<void>;
}) => {
const { messages, initialMessages, isLoading, parseMessages, storeMessageHistory } = options;
parseMessages(messages, isLoading);
if (messages.length > initialMessages.length) {
storeMessageHistory(messages).catch((error) => toast.error(error.message));
}
},
50,
);
interface ChatProps {
initialMessages: Message[];
storeMessageHistory: (messages: Message[]) => Promise<void>;
importChat: (description: string, messages: Message[]) => Promise<void>;
exportChat: () => void;
description?: string;
}
export const ChatImpl = memo(
({ description, initialMessages, storeMessageHistory, importChat, exportChat }: ChatProps) => {
useShortcuts();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [chatStarted, setChatStarted] = useState(initialMessages.length > 0);
const [uploadedFiles, setUploadedFiles] = useState<File[]>([]);
const [imageDataList, setImageDataList] = useState<string[]>([]);
const [searchParams, setSearchParams] = useSearchParams();
const [fakeLoading, setFakeLoading] = useState(false);
const files = useStore(workbenchStore.files);
const actionAlert = useStore(workbenchStore.alert);
const supabaseConn = useStore(supabaseConnection); // Add this line to get Supabase connection
const selectedProject = supabaseConn.stats?.projects?.find(
(project) => project.id === supabaseConn.selectedProjectId,
);
const supabaseAlert = useStore(workbenchStore.supabaseAlert);
const { activeProviders, promptId, autoSelectTemplate, contextOptimizationEnabled } = useSettings();
const [model, setModel] = useState(() => {
const savedModel = Cookies.get('selectedModel');
return savedModel || DEFAULT_MODEL;
});
const [provider, setProvider] = useState(() => {
const savedProvider = Cookies.get('selectedProvider');
return (PROVIDER_LIST.find((p) => p.name === savedProvider) || DEFAULT_PROVIDER) as ProviderInfo;
});
const { showChat } = useStore(chatStore);
const [animationScope, animate] = useAnimate();
const [apiKeys, setApiKeys] = useState<Record<string, string>>({});
const {
messages,
isLoading,
input,
handleInputChange,
setInput,
stop,
append,
setMessages,
reload,
error,
data: chatData,
setData,
} = useChat({
api: '/api/chat',
body: {
apiKeys,
files,
promptId,
contextOptimization: contextOptimizationEnabled,
supabase: {
isConnected: supabaseConn.isConnected,
hasSelectedProject: !!selectedProject,
credentials: {
supabaseUrl: supabaseConn?.credentials?.supabaseUrl,
anonKey: supabaseConn?.credentials?.anonKey,
},
},
},
sendExtraMessageFields: true,
onError: (e) => {
logger.error('Request failed\n\n', e, error);
logStore.logError('Chat request failed', e, {
component: 'Chat',
action: 'request',
error: e.message,
});
toast.error(
'There was an error processing your request: ' + (e.message ? e.message : 'No details were returned'),
);
},
onFinish: (message, response) => {
const usage = response.usage;
setData(undefined);
if (usage) {
console.log('Token usage:', usage);
logStore.logProvider('Chat response completed', {
component: 'Chat',
action: 'response',
model,
provider: provider.name,
usage,
messageLength: message.content.length,
});
}
logger.debug('Finished streaming');
},
initialMessages,
initialInput: Cookies.get(PROMPT_COOKIE_KEY) || '',
});
useEffect(() => {
const prompt = searchParams.get('prompt');
// console.log(prompt, searchParams, model, provider);
if (prompt) {
setSearchParams({});
runAnimation();
append({
role: 'user',
content: [
{
type: 'text',
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${prompt}`,
},
] as any, // Type assertion to bypass compiler check
});
}
}, [model, provider, searchParams]);
const { enhancingPrompt, promptEnhanced, enhancePrompt, resetEnhancer } = usePromptEnhancer();
const { parsedMessages, parseMessages } = useMessageParser();
const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200;
useEffect(() => {
chatStore.setKey('started', initialMessages.length > 0);
}, []);
useEffect(() => {
processSampledMessages({
messages,
initialMessages,
isLoading,
parseMessages,
storeMessageHistory,
});
}, [messages, isLoading, parseMessages]);
const scrollTextArea = () => {
const textarea = textareaRef.current;
if (textarea) {
textarea.scrollTop = textarea.scrollHeight;
}
};
const abort = () => {
stop();
chatStore.setKey('aborted', true);
workbenchStore.abortAllActions();
logStore.logProvider('Chat response aborted', {
component: 'Chat',
action: 'abort',
model,
provider: provider.name,
});
};
useEffect(() => {
const textarea = textareaRef.current;
if (textarea) {
textarea.style.height = 'auto';
const scrollHeight = textarea.scrollHeight;
textarea.style.height = `${Math.min(scrollHeight, TEXTAREA_MAX_HEIGHT)}px`;
textarea.style.overflowY = scrollHeight > TEXTAREA_MAX_HEIGHT ? 'auto' : 'hidden';
}
}, [input, textareaRef]);
const runAnimation = async () => {
if (chatStarted) {
return;
}
await Promise.all([
animate('#examples', { opacity: 0, display: 'none' }, { duration: 0.1 }),
animate('#intro', { opacity: 0, flex: 1 }, { duration: 0.2, ease: cubicEasingFn }),
]);
chatStore.setKey('started', true);
setChatStarted(true);
};
const sendMessage = async (_event: React.UIEvent, messageInput?: string) => {
const messageContent = messageInput || input;
if (!messageContent?.trim()) {
return;
}
if (isLoading) {
abort();
return;
}
runAnimation();
if (!chatStarted) {
setFakeLoading(true);
if (autoSelectTemplate) {
const { template, title } = await selectStarterTemplate({
message: messageContent,
model,
provider,
});
if (template !== 'blank') {
const temResp = await getTemplates(template, title).catch((e) => {
if (e.message.includes('rate limit')) {
toast.warning('Rate limit exceeded. Skipping starter template\n Continuing with blank template');
} else {
toast.warning('Failed to import starter template\n Continuing with blank template');
}
return null;
});
if (temResp) {
const { assistantMessage, userMessage } = temResp;
setMessages([
{
id: `1-${new Date().getTime()}`,
role: 'user',
content: [
{
type: 'text',
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${messageContent}`,
},
...imageDataList.map((imageData) => ({
type: 'image',
image: imageData,
})),
] as any,
},
{
id: `2-${new Date().getTime()}`,
role: 'assistant',
content: assistantMessage,
},
{
id: `3-${new Date().getTime()}`,
role: 'user',
content: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${userMessage}`,
annotations: ['hidden'],
},
]);
reload();
setInput('');
Cookies.remove(PROMPT_COOKIE_KEY);
setUploadedFiles([]);
setImageDataList([]);
resetEnhancer();
textareaRef.current?.blur();
setFakeLoading(false);
return;
}
}
}
// If autoSelectTemplate is disabled or template selection failed, proceed with normal message
setMessages([
{
id: `${new Date().getTime()}`,
role: 'user',
content: [
{
type: 'text',
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${messageContent}`,
},
...imageDataList.map((imageData) => ({
type: 'image',
image: imageData,
})),
] as any,
},
]);
reload();
setFakeLoading(false);
setInput('');
Cookies.remove(PROMPT_COOKIE_KEY);
setUploadedFiles([]);
setImageDataList([]);
resetEnhancer();
textareaRef.current?.blur();
return;
}
if (error != null) {
setMessages(messages.slice(0, -1));
}
const modifiedFiles = workbenchStore.getModifiedFiles();
chatStore.setKey('aborted', false);
if (modifiedFiles !== undefined) {
const userUpdateArtifact = filesToArtifacts(modifiedFiles, `${Date.now()}`);
append({
role: 'user',
content: [
{
type: 'text',
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${userUpdateArtifact}${messageContent}`,
},
...imageDataList.map((imageData) => ({
type: 'image',
image: imageData,
})),
] as any,
});
workbenchStore.resetAllFileModifications();
} else {
append({
role: 'user',
content: [
{
type: 'text',
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${messageContent}`,
},
...imageDataList.map((imageData) => ({
type: 'image',
image: imageData,
})),
] as any,
});
}
setInput('');
Cookies.remove(PROMPT_COOKIE_KEY);
setUploadedFiles([]);
setImageDataList([]);
resetEnhancer();
textareaRef.current?.blur();
};
/**
* Handles the change event for the textarea and updates the input state.
* @param event - The change event from the textarea.
*/
const onTextareaChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
handleInputChange(event);
};
/**
* Debounced function to cache the prompt in cookies.
* Caches the trimmed value of the textarea input after a delay to optimize performance.
*/
const debouncedCachePrompt = useCallback(
debounce((event: React.ChangeEvent<HTMLTextAreaElement>) => {
const trimmedValue = event.target.value.trim();
Cookies.set(PROMPT_COOKIE_KEY, trimmedValue, { expires: 30 });
}, 1000),
[],
);
const [messageRef, scrollRef] = useSnapScroll();
useEffect(() => {
const storedApiKeys = Cookies.get('apiKeys');
if (storedApiKeys) {
setApiKeys(JSON.parse(storedApiKeys));
}
}, []);
const handleModelChange = (newModel: string) => {
setModel(newModel);
Cookies.set('selectedModel', newModel, { expires: 30 });
};
const handleProviderChange = (newProvider: ProviderInfo) => {
setProvider(newProvider);
Cookies.set('selectedProvider', newProvider.name, { expires: 30 });
};
return (
<BaseChat
ref={animationScope}
textareaRef={textareaRef}
input={input}
showChat={showChat}
chatStarted={chatStarted}
isStreaming={isLoading || fakeLoading}
onStreamingChange={(streaming) => {
streamingState.set(streaming);
}}
enhancingPrompt={enhancingPrompt}
promptEnhanced={promptEnhanced}
sendMessage={sendMessage}
model={model}
setModel={handleModelChange}
provider={provider}
setProvider={handleProviderChange}
providerList={activeProviders}
messageRef={messageRef}
scrollRef={scrollRef}
handleInputChange={(e) => {
onTextareaChange(e);
debouncedCachePrompt(e);
}}
handleStop={abort}
description={description}
importChat={importChat}
exportChat={exportChat}
messages={messages.map((message, i) => {
if (message.role === 'user') {
return message;
}
return {
...message,
content: parsedMessages[i] || '',
};
})}
enhancePrompt={() => {
enhancePrompt(
input,
(input) => {
setInput(input);
scrollTextArea();
},
model,
provider,
apiKeys,
);
}}
uploadedFiles={uploadedFiles}
setUploadedFiles={setUploadedFiles}
imageDataList={imageDataList}
setImageDataList={setImageDataList}
actionAlert={actionAlert}
clearAlert={() => workbenchStore.clearAlert()}
supabaseAlert={supabaseAlert}
clearSupabaseAlert={() => workbenchStore.clearSupabaseAlert()}
data={chatData}
/>
);
},
);