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.
311 lines
13 KiB
TypeScript
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>
|
|
);
|
|
};
|