mirror of
https://github.com/stackblitz-labs/bolt.diy
synced 2025-06-26 18:26:38 +00:00
- Implement discuss mode toggle and UI in chat box - Add quick action buttons for file, message, implement and link actions - Extend markdown parser to handle quick action elements - Update message components to support discuss mode and quick actions - Add discuss prompt for technical consulting responses - Refactor chat components to support new functionality The changes introduce a new discuss mode that allows users to switch between code implementation and technical discussion modes. Quick action buttons provide immediate interaction options like opening files, sending messages, or switching modes.
499 lines
17 KiB
TypeScript
499 lines
17 KiB
TypeScript
/*
|
|
* @ts-nocheck
|
|
* Preventing TS checks with files presented in the video for a better presentation.
|
|
*/
|
|
import type { JSONValue, Message } from 'ai';
|
|
import React, { type RefCallback, useEffect, useState } 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 { PROVIDER_LIST } from '~/utils/constants';
|
|
import { Messages } from './Messages.client';
|
|
import { getApiKeysFromCookies } from './APIKeyManager';
|
|
import Cookies from 'js-cookie';
|
|
import * as Tooltip from '@radix-ui/react-tooltip';
|
|
import styles from './BaseChat.module.scss';
|
|
import { ImportButtons } from '~/components/chat/chatExportAndImport/ImportButtons';
|
|
import { ExamplePrompts } from '~/components/chat/ExamplePrompts';
|
|
import GitCloneButton from './GitCloneButton';
|
|
import type { ProviderInfo } from '~/types/model';
|
|
import StarterTemplates from './StarterTemplates';
|
|
import type { ActionAlert, SupabaseAlert, DeployAlert } from '~/types/actions';
|
|
import DeployChatAlert from '~/components/deploy/DeployAlert';
|
|
import ChatAlert from './ChatAlert';
|
|
import type { ModelInfo } from '~/lib/modules/llm/types';
|
|
import ProgressCompilation from './ProgressCompilation';
|
|
import type { ProgressAnnotation } from '~/types/context';
|
|
import type { ActionRunner } from '~/lib/runtime/action-runner';
|
|
import { SupabaseChatAlert } from '~/components/chat/SupabaseAlert';
|
|
import { expoUrlAtom } from '~/lib/stores/qrCodeStore';
|
|
import { useStore } from '@nanostores/react';
|
|
import { StickToBottom, useStickToBottomContext } from '~/lib/hooks';
|
|
import { ChatBox } from './ChatBox';
|
|
|
|
const TEXTAREA_MIN_HEIGHT = 76;
|
|
|
|
interface BaseChatProps {
|
|
textareaRef?: React.RefObject<HTMLTextAreaElement> | undefined;
|
|
messageRef?: RefCallback<HTMLDivElement> | undefined;
|
|
scrollRef?: RefCallback<HTMLDivElement> | undefined;
|
|
showChat?: boolean;
|
|
chatStarted?: boolean;
|
|
isStreaming?: boolean;
|
|
onStreamingChange?: (streaming: boolean) => void;
|
|
messages?: Message[];
|
|
description?: string;
|
|
enhancingPrompt?: boolean;
|
|
promptEnhanced?: boolean;
|
|
input?: string;
|
|
model?: string;
|
|
setModel?: (model: string) => void;
|
|
provider?: ProviderInfo;
|
|
setProvider?: (provider: ProviderInfo) => void;
|
|
providerList?: ProviderInfo[];
|
|
handleStop?: () => void;
|
|
sendMessage?: (event: React.UIEvent, messageInput?: string) => void;
|
|
handleInputChange?: (event: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
|
enhancePrompt?: () => void;
|
|
importChat?: (description: string, messages: Message[]) => Promise<void>;
|
|
exportChat?: () => void;
|
|
uploadedFiles?: File[];
|
|
setUploadedFiles?: (files: File[]) => void;
|
|
imageDataList?: string[];
|
|
setImageDataList?: (dataList: string[]) => void;
|
|
actionAlert?: ActionAlert;
|
|
clearAlert?: () => void;
|
|
supabaseAlert?: SupabaseAlert;
|
|
clearSupabaseAlert?: () => void;
|
|
deployAlert?: DeployAlert;
|
|
clearDeployAlert?: () => void;
|
|
data?: JSONValue[] | undefined;
|
|
actionRunner?: ActionRunner;
|
|
chatMode?: 'discuss' | 'build';
|
|
setChatMode?: (mode: 'discuss' | 'build') => void;
|
|
append?: (message: Message) => void;
|
|
}
|
|
|
|
export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
|
(
|
|
{
|
|
textareaRef,
|
|
showChat = true,
|
|
chatStarted = false,
|
|
isStreaming = false,
|
|
onStreamingChange,
|
|
model,
|
|
setModel,
|
|
provider,
|
|
setProvider,
|
|
providerList,
|
|
input = '',
|
|
enhancingPrompt,
|
|
handleInputChange,
|
|
|
|
// promptEnhanced,
|
|
enhancePrompt,
|
|
sendMessage,
|
|
handleStop,
|
|
importChat,
|
|
exportChat,
|
|
uploadedFiles = [],
|
|
setUploadedFiles,
|
|
imageDataList = [],
|
|
setImageDataList,
|
|
messages,
|
|
actionAlert,
|
|
clearAlert,
|
|
deployAlert,
|
|
clearDeployAlert,
|
|
supabaseAlert,
|
|
clearSupabaseAlert,
|
|
data,
|
|
actionRunner,
|
|
chatMode,
|
|
setChatMode,
|
|
append,
|
|
},
|
|
ref,
|
|
) => {
|
|
const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200;
|
|
const [apiKeys, setApiKeys] = useState<Record<string, string>>(getApiKeysFromCookies());
|
|
const [modelList, setModelList] = useState<ModelInfo[]>([]);
|
|
const [isModelSettingsCollapsed, setIsModelSettingsCollapsed] = useState(false);
|
|
const [isListening, setIsListening] = useState(false);
|
|
const [recognition, setRecognition] = useState<SpeechRecognition | null>(null);
|
|
const [transcript, setTranscript] = useState('');
|
|
const [isModelLoading, setIsModelLoading] = useState<string | undefined>('all');
|
|
const [progressAnnotations, setProgressAnnotations] = useState<ProgressAnnotation[]>([]);
|
|
const expoUrl = useStore(expoUrlAtom);
|
|
const [qrModalOpen, setQrModalOpen] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (expoUrl) {
|
|
setQrModalOpen(true);
|
|
}
|
|
}, [expoUrl]);
|
|
|
|
useEffect(() => {
|
|
if (data) {
|
|
const progressList = data.filter(
|
|
(x) => typeof x === 'object' && (x as any).type === 'progress',
|
|
) as ProgressAnnotation[];
|
|
setProgressAnnotations(progressList);
|
|
}
|
|
}, [data]);
|
|
useEffect(() => {
|
|
console.log(transcript);
|
|
}, [transcript]);
|
|
|
|
useEffect(() => {
|
|
onStreamingChange?.(isStreaming);
|
|
}, [isStreaming, onStreamingChange]);
|
|
|
|
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);
|
|
|
|
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);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (typeof window !== 'undefined') {
|
|
let parsedApiKeys: Record<string, string> | undefined = {};
|
|
|
|
try {
|
|
parsedApiKeys = getApiKeysFromCookies();
|
|
setApiKeys(parsedApiKeys);
|
|
} catch (error) {
|
|
console.error('Error loading API keys from cookies:', error);
|
|
Cookies.remove('apiKeys');
|
|
}
|
|
|
|
setIsModelLoading('all');
|
|
fetch('/api/models')
|
|
.then((response) => response.json())
|
|
.then((data) => {
|
|
const typedData = data as { modelList: ModelInfo[] };
|
|
setModelList(typedData.modelList);
|
|
})
|
|
.catch((error) => {
|
|
console.error('Error fetching model list:', error);
|
|
})
|
|
.finally(() => {
|
|
setIsModelLoading(undefined);
|
|
});
|
|
}
|
|
}, [providerList, provider]);
|
|
|
|
const onApiKeysChange = async (providerName: string, apiKey: string) => {
|
|
const newApiKeys = { ...apiKeys, [providerName]: apiKey };
|
|
setApiKeys(newApiKeys);
|
|
Cookies.set('apiKeys', JSON.stringify(newApiKeys));
|
|
|
|
setIsModelLoading(providerName);
|
|
|
|
let providerModels: ModelInfo[] = [];
|
|
|
|
try {
|
|
const response = await fetch(`/api/models/${encodeURIComponent(providerName)}`);
|
|
const data = await response.json();
|
|
providerModels = (data as { modelList: ModelInfo[] }).modelList;
|
|
} catch (error) {
|
|
console.error('Error loading dynamic models for:', providerName, error);
|
|
}
|
|
|
|
// Only update models for the specific provider
|
|
setModelList((prevModels) => {
|
|
const otherModels = prevModels.filter((model) => model.provider !== providerName);
|
|
return [...otherModels, ...providerModels];
|
|
});
|
|
setIsModelLoading(undefined);
|
|
};
|
|
|
|
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(event, 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 baseChat = (
|
|
<div
|
|
ref={ref}
|
|
className={classNames(styles.BaseChat, 'relative flex h-full w-full overflow-hidden')}
|
|
data-chat-visible={showChat}
|
|
>
|
|
<ClientOnly>{() => <Menu />}</ClientOnly>
|
|
<div 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">
|
|
Where ideas begin
|
|
</h1>
|
|
<p className="text-md lg:text-xl mb-8 text-bolt-elements-textSecondary animate-fade-in animation-delay-200">
|
|
Bring ideas to life in seconds or get help on existing projects.
|
|
</p>
|
|
</div>
|
|
)}
|
|
<StickToBottom
|
|
className={classNames('pt-6 px-2 sm:px-6 relative', {
|
|
'h-full flex flex-col modern-scrollbar': chatStarted,
|
|
})}
|
|
resize="smooth"
|
|
initial="smooth"
|
|
>
|
|
<StickToBottom.Content className="flex flex-col gap-4">
|
|
<ClientOnly>
|
|
{() => {
|
|
return chatStarted ? (
|
|
<Messages
|
|
className="flex flex-col w-full flex-1 max-w-chat pb-6 mx-auto z-1"
|
|
messages={messages}
|
|
isStreaming={isStreaming}
|
|
append={append}
|
|
chatMode={chatMode}
|
|
setChatMode={setChatMode}
|
|
/>
|
|
) : null;
|
|
}}
|
|
</ClientOnly>
|
|
</StickToBottom.Content>
|
|
<div
|
|
className={classNames('my-auto flex flex-col gap-2 w-full max-w-chat mx-auto z-prompt mb-6', {
|
|
'sticky bottom-2': chatStarted,
|
|
})}
|
|
>
|
|
<div className="flex flex-col gap-2">
|
|
{deployAlert && (
|
|
<DeployChatAlert
|
|
alert={deployAlert}
|
|
clearAlert={() => clearDeployAlert?.()}
|
|
postMessage={(message: string | undefined) => {
|
|
sendMessage?.({} as any, message);
|
|
clearSupabaseAlert?.();
|
|
}}
|
|
/>
|
|
)}
|
|
{supabaseAlert && (
|
|
<SupabaseChatAlert
|
|
alert={supabaseAlert}
|
|
clearAlert={() => clearSupabaseAlert?.()}
|
|
postMessage={(message) => {
|
|
sendMessage?.({} as any, message);
|
|
clearSupabaseAlert?.();
|
|
}}
|
|
/>
|
|
)}
|
|
{actionAlert && (
|
|
<ChatAlert
|
|
alert={actionAlert}
|
|
clearAlert={() => clearAlert?.()}
|
|
postMessage={(message) => {
|
|
sendMessage?.({} as any, message);
|
|
clearAlert?.();
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
<ScrollToBottom />
|
|
{progressAnnotations && <ProgressCompilation data={progressAnnotations} />}
|
|
<ChatBox
|
|
isModelSettingsCollapsed={isModelSettingsCollapsed}
|
|
setIsModelSettingsCollapsed={setIsModelSettingsCollapsed}
|
|
provider={provider}
|
|
setProvider={setProvider}
|
|
providerList={providerList || (PROVIDER_LIST as ProviderInfo[])}
|
|
model={model}
|
|
setModel={setModel}
|
|
modelList={modelList}
|
|
apiKeys={apiKeys}
|
|
isModelLoading={isModelLoading}
|
|
onApiKeysChange={onApiKeysChange}
|
|
uploadedFiles={uploadedFiles}
|
|
setUploadedFiles={setUploadedFiles}
|
|
imageDataList={imageDataList}
|
|
setImageDataList={setImageDataList}
|
|
textareaRef={textareaRef}
|
|
input={input}
|
|
handleInputChange={handleInputChange}
|
|
handlePaste={handlePaste}
|
|
TEXTAREA_MIN_HEIGHT={TEXTAREA_MIN_HEIGHT}
|
|
TEXTAREA_MAX_HEIGHT={TEXTAREA_MAX_HEIGHT}
|
|
isStreaming={isStreaming}
|
|
handleStop={handleStop}
|
|
handleSendMessage={handleSendMessage}
|
|
enhancingPrompt={enhancingPrompt}
|
|
enhancePrompt={enhancePrompt}
|
|
isListening={isListening}
|
|
startListening={startListening}
|
|
stopListening={stopListening}
|
|
chatStarted={chatStarted}
|
|
exportChat={exportChat}
|
|
qrModalOpen={qrModalOpen}
|
|
setQrModalOpen={setQrModalOpen}
|
|
handleFileUpload={handleFileUpload}
|
|
chatMode={chatMode}
|
|
setChatMode={setChatMode}
|
|
/>
|
|
</div>
|
|
</StickToBottom>
|
|
<div className="flex flex-col justify-center">
|
|
{!chatStarted && (
|
|
<div className="flex justify-center gap-2">
|
|
{ImportButtons(importChat)}
|
|
<GitCloneButton importChat={importChat} />
|
|
</div>
|
|
)}
|
|
<div className="flex flex-col gap-5">
|
|
{!chatStarted &&
|
|
ExamplePrompts((event, messageInput) => {
|
|
if (isStreaming) {
|
|
handleStop?.();
|
|
return;
|
|
}
|
|
|
|
handleSendMessage?.(event, messageInput);
|
|
})}
|
|
{!chatStarted && <StarterTemplates />}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<ClientOnly>
|
|
{() => (
|
|
<Workbench
|
|
actionRunner={actionRunner ?? ({} as ActionRunner)}
|
|
chatStarted={chatStarted}
|
|
isStreaming={isStreaming}
|
|
/>
|
|
)}
|
|
</ClientOnly>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
return <Tooltip.Provider delayDuration={200}>{baseChat}</Tooltip.Provider>;
|
|
},
|
|
);
|
|
|
|
function ScrollToBottom() {
|
|
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
|
|
|
|
return (
|
|
!isAtBottom && (
|
|
<button
|
|
className="absolute z-50 top-[0%] translate-y-[-100%] text-4xl rounded-lg left-[50%] translate-x-[-50%] px-1.5 py-0.5 flex items-center gap-2 bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor text-bolt-elements-textPrimary text-sm"
|
|
onClick={() => scrollToBottom()}
|
|
>
|
|
Go to last message
|
|
<span className="i-ph:arrow-down animate-bounce" />
|
|
</button>
|
|
)
|
|
);
|
|
}
|