bolt.diy/app/components/chat/ChatBox.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

311 lines
13 KiB
TypeScript

import React from 'react';
import { ClientOnly } from 'remix-utils/client-only';
import { classNames } from '~/utils/classNames';
import { PROVIDER_LIST } from '~/utils/constants';
import { ModelSelector } from '~/components/chat/ModelSelector';
import { APIKeyManager } from './APIKeyManager';
import { LOCAL_PROVIDERS } from '~/lib/stores/settings';
import FilePreview from './FilePreview';
import { ScreenshotStateManager } from './ScreenshotStateManager';
import { SendButton } from './SendButton.client';
import { IconButton } from '~/components/ui/IconButton';
import { toast } from 'react-toastify';
import { SpeechRecognitionButton } from '~/components/chat/SpeechRecognition';
import { ExportChatButton } from '~/components/chat/chatExportAndImport/ExportChatButton';
import { SupabaseConnection } from './SupabaseConnection';
import { ExpoQrModal } from '~/components/workbench/ExpoQrModal';
import styles from './BaseChat.module.scss';
import type { ProviderInfo } from '~/types/model';
interface ChatBoxProps {
isModelSettingsCollapsed: boolean;
setIsModelSettingsCollapsed: (collapsed: boolean) => void;
provider: any;
providerList: any[];
modelList: any[];
apiKeys: Record<string, string>;
isModelLoading: string | undefined;
onApiKeysChange: (providerName: string, apiKey: string) => void;
uploadedFiles: File[];
imageDataList: string[];
textareaRef: React.RefObject<HTMLTextAreaElement> | undefined;
input: string;
handlePaste: (e: React.ClipboardEvent) => void;
TEXTAREA_MIN_HEIGHT: number;
TEXTAREA_MAX_HEIGHT: number;
isStreaming: boolean;
handleSendMessage: (event: React.UIEvent, messageInput?: string) => void;
isListening: boolean;
startListening: () => void;
stopListening: () => void;
chatStarted: boolean;
exportChat?: () => void;
qrModalOpen: boolean;
setQrModalOpen: (open: boolean) => void;
handleFileUpload: () => void;
setProvider?: ((provider: ProviderInfo) => void) | undefined;
model?: string | undefined;
setModel?: ((model: string) => void) | undefined;
setUploadedFiles?: ((files: File[]) => void) | undefined;
setImageDataList?: ((dataList: string[]) => void) | undefined;
handleInputChange?: ((event: React.ChangeEvent<HTMLTextAreaElement>) => void) | undefined;
handleStop?: (() => void) | undefined;
enhancingPrompt?: boolean | undefined;
enhancePrompt?: (() => void) | undefined;
chatMode?: 'discuss' | 'build';
setChatMode?: (mode: 'discuss' | 'build') => void;
}
export const ChatBox: React.FC<ChatBoxProps> = (props) => {
return (
<div
className={classNames(
'relative 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>
<div>
<ClientOnly>
{() => (
<div className={props.isModelSettingsCollapsed ? 'hidden' : ''}>
<ModelSelector
key={props.provider?.name + ':' + props.modelList.length}
model={props.model}
setModel={props.setModel}
modelList={props.modelList}
provider={props.provider}
setProvider={props.setProvider}
providerList={props.providerList || (PROVIDER_LIST as ProviderInfo[])}
apiKeys={props.apiKeys}
modelLoading={props.isModelLoading}
/>
{(props.providerList || []).length > 0 &&
props.provider &&
(!LOCAL_PROVIDERS.includes(props.provider.name) || 'OpenAILike') && (
<APIKeyManager
provider={props.provider}
apiKey={props.apiKeys[props.provider.name] || ''}
setApiKey={(key) => {
props.onApiKeysChange(props.provider.name, key);
}}
/>
)}
</div>
)}
</ClientOnly>
</div>
<FilePreview
files={props.uploadedFiles}
imageDataList={props.imageDataList}
onRemove={(index) => {
props.setUploadedFiles?.(props.uploadedFiles.filter((_, i) => i !== index));
props.setImageDataList?.(props.imageDataList.filter((_, i) => i !== index));
}}
/>
<ClientOnly>
{() => (
<ScreenshotStateManager
setUploadedFiles={props.setUploadedFiles}
setImageDataList={props.setImageDataList}
uploadedFiles={props.uploadedFiles}
imageDataList={props.imageDataList}
/>
)}
</ClientOnly>
<div
className={classNames('relative shadow-xs border border-bolt-elements-borderColor backdrop-blur rounded-lg')}
>
<textarea
ref={props.textareaRef}
className={classNames(
'w-full pl-4 pt-4 pr-16 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;
props.setUploadedFiles?.([...props.uploadedFiles, file]);
props.setImageDataList?.([...props.imageDataList, base64Image]);
};
reader.readAsDataURL(file);
}
});
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
if (event.shiftKey) {
return;
}
event.preventDefault();
if (props.isStreaming) {
props.handleStop?.();
return;
}
// ignore if using input method engine
if (event.nativeEvent.isComposing) {
return;
}
props.handleSendMessage?.(event);
}
}}
value={props.input}
onChange={(event) => {
props.handleInputChange?.(event);
}}
onPaste={props.handlePaste}
style={{
minHeight: props.TEXTAREA_MIN_HEIGHT,
maxHeight: props.TEXTAREA_MAX_HEIGHT,
}}
placeholder={props.chatMode === 'build' ? 'How can Bolt help you today?' : 'What would you like to discuss?'}
translate="no"
/>
<ClientOnly>
{() => (
<SendButton
show={props.input.length > 0 || props.isStreaming || props.uploadedFiles.length > 0}
isStreaming={props.isStreaming}
disabled={!props.providerList || props.providerList.length === 0}
onClick={(event) => {
if (props.isStreaming) {
props.handleStop?.();
return;
}
if (props.input.length > 0 || props.uploadedFiles.length > 0) {
props.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={() => props.handleFileUpload()}>
<div className="i-ph:paperclip text-xl"></div>
</IconButton>
<IconButton
title="Enhance prompt"
disabled={props.input.length === 0 || props.enhancingPrompt}
className={classNames('transition-all', props.enhancingPrompt ? 'opacity-100' : '')}
onClick={() => {
props.enhancePrompt?.();
toast.success('Prompt enhanced!');
}}
>
{props.enhancingPrompt ? (
<div className="i-svg-spinners:90-ring-with-bg text-bolt-elements-loader-progress text-xl animate-spin"></div>
) : (
<div className="i-bolt:stars text-xl"></div>
)}
</IconButton>
<SpeechRecognitionButton
isListening={props.isListening}
onStart={props.startListening}
onStop={props.stopListening}
disabled={props.isStreaming}
/>
{props.chatStarted && (
<IconButton
title="Discuss"
className={classNames(
'transition-all flex items-center gap-1 px-1.5',
props.chatMode === 'discuss'
? '!bg-bolt-elements-item-backgroundAccent !text-bolt-elements-item-contentAccent'
: 'bg-bolt-elements-item-backgroundDefault text-bolt-elements-item-contentDefault',
)}
onClick={() => {
props.setChatMode?.(props.chatMode === 'discuss' ? 'build' : 'discuss');
}}
>
<div className={`i-ph:chats text-xl`} />
{props.chatMode === 'discuss' ? <span>Discuss</span> : <span />}
</IconButton>
)}
{props.chatStarted && <ClientOnly>{() => <ExportChatButton exportChat={props.exportChat} />}</ClientOnly>}
<IconButton
title="Model Settings"
className={classNames('transition-all flex items-center gap-1', {
'bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent':
props.isModelSettingsCollapsed,
'bg-bolt-elements-item-backgroundDefault text-bolt-elements-item-contentDefault':
!props.isModelSettingsCollapsed,
})}
onClick={() => props.setIsModelSettingsCollapsed(!props.isModelSettingsCollapsed)}
disabled={!props.providerList || props.providerList.length === 0}
>
<div className={`i-ph:caret-${props.isModelSettingsCollapsed ? 'right' : 'down'} text-lg`} />
{props.isModelSettingsCollapsed ? <span className="text-xs">{props.model}</span> : <span />}
</IconButton>
</div>
{props.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}
<SupabaseConnection />
<ExpoQrModal open={props.qrModalOpen} onClose={() => props.setQrModalOpen(false)} />
</div>
</div>
</div>
);
};