bolt.diy/app/components/chat/BaseChat.tsx
KevIsDev 2e7b626b00 feat: add discuss mode and quick actions
- 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.
2025-05-26 16:05:51 +01:00

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>
)
);
}