|
| 1 | +import React from 'react'; |
| 2 | +import { ClientOnly } from 'remix-utils/client-only'; |
| 3 | +import { classNames } from '~/utils/classNames'; |
| 4 | +import { PROVIDER_LIST } from '~/utils/constants'; |
| 5 | +import { ModelSelector } from '~/components/chat/ModelSelector'; |
| 6 | +import { APIKeyManager } from './APIKeyManager'; |
| 7 | +import { LOCAL_PROVIDERS } from '~/lib/stores/settings'; |
| 8 | +import FilePreview from './FilePreview'; |
| 9 | +import { ScreenshotStateManager } from './ScreenshotStateManager'; |
| 10 | +import { SendButton } from './SendButton.client'; |
| 11 | +import { IconButton } from '~/components/ui/IconButton'; |
| 12 | +import { toast } from 'react-toastify'; |
| 13 | +import { SpeechRecognitionButton } from '~/components/chat/SpeechRecognition'; |
| 14 | +import { SupabaseConnection } from './SupabaseConnection'; |
| 15 | +import { ExpoQrModal } from '~/components/workbench/ExpoQrModal'; |
| 16 | +import styles from './BaseChat.module.scss'; |
| 17 | +import type { ProviderInfo } from '~/types/model'; |
| 18 | +import { ColorSchemeDialog } from '~/components/chat/ColorSchemeDialog'; |
| 19 | +import type { DesignScheme } from '~/types/design-scheme'; |
| 20 | +import type { ElementInfo } from '~/components/workbench/Inspector'; |
| 21 | +import { McpTools } from './MCPTools'; |
| 22 | + |
| 23 | +interface ChatBoxProps { |
| 24 | + isModelSettingsCollapsed: boolean; |
| 25 | + setIsModelSettingsCollapsed: (collapsed: boolean) => void; |
| 26 | + provider: any; |
| 27 | + providerList: any[]; |
| 28 | + modelList: any[]; |
| 29 | + apiKeys: Record<string, string>; |
| 30 | + isModelLoading: string | undefined; |
| 31 | + onApiKeysChange: (providerName: string, apiKey: string) => void; |
| 32 | + uploadedFiles: File[]; |
| 33 | + imageDataList: string[]; |
| 34 | + textareaRef: React.RefObject<HTMLTextAreaElement> | undefined; |
| 35 | + input: string; |
| 36 | + handlePaste: (e: React.ClipboardEvent) => void; |
| 37 | + TEXTAREA_MIN_HEIGHT: number; |
| 38 | + TEXTAREA_MAX_HEIGHT: number; |
| 39 | + isStreaming: boolean; |
| 40 | + handleSendMessage: (event: React.UIEvent, messageInput?: string) => void; |
| 41 | + isListening: boolean; |
| 42 | + startListening: () => void; |
| 43 | + stopListening: () => void; |
| 44 | + chatStarted: boolean; |
| 45 | + exportChat?: () => void; |
| 46 | + qrModalOpen: boolean; |
| 47 | + setQrModalOpen: (open: boolean) => void; |
| 48 | + handleFileUpload: () => void; |
| 49 | + setProvider?: ((provider: ProviderInfo) => void) | undefined; |
| 50 | + model?: string | undefined; |
| 51 | + setModel?: ((model: string) => void) | undefined; |
| 52 | + setUploadedFiles?: ((files: File[]) => void) | undefined; |
| 53 | + setImageDataList?: ((dataList: string[]) => void) | undefined; |
| 54 | + handleInputChange?: ((event: React.ChangeEvent<HTMLTextAreaElement>) => void) | undefined; |
| 55 | + handleStop?: (() => void) | undefined; |
| 56 | + enhancingPrompt?: boolean | undefined; |
| 57 | + enhancePrompt?: (() => void) | undefined; |
| 58 | + chatMode?: 'discuss' | 'build'; |
| 59 | + setChatMode?: (mode: 'discuss' | 'build') => void; |
| 60 | + designScheme?: DesignScheme; |
| 61 | + setDesignScheme?: (scheme: DesignScheme) => void; |
| 62 | + selectedElement?: ElementInfo | null; |
| 63 | + setSelectedElement?: ((element: ElementInfo | null) => void) | undefined; |
| 64 | +} |
| 65 | + |
| 66 | +export const ChatBox: React.FC<ChatBoxProps> = (props) => { |
| 67 | + return ( |
| 68 | + <div |
| 69 | + className={classNames( |
| 70 | + 'relative bg-bolt-elements-background-depth-2 backdrop-blur p-3 rounded-lg border border-bolt-elements-borderColor relative w-full max-w-chat mx-auto z-prompt', |
| 71 | + |
| 72 | + /* |
| 73 | + * { |
| 74 | + * 'sticky bottom-2': chatStarted, |
| 75 | + * }, |
| 76 | + */ |
| 77 | + )} |
| 78 | + > |
| 79 | + <svg className={classNames(styles.PromptEffectContainer)}> |
| 80 | + <defs> |
| 81 | + <linearGradient |
| 82 | + id="line-gradient" |
| 83 | + x1="20%" |
| 84 | + y1="0%" |
| 85 | + x2="-14%" |
| 86 | + y2="10%" |
| 87 | + gradientUnits="userSpaceOnUse" |
| 88 | + gradientTransform="rotate(-45)" |
| 89 | + > |
| 90 | + <stop offset="0%" stopColor="#b44aff" stopOpacity="0%"></stop> |
| 91 | + <stop offset="40%" stopColor="#b44aff" stopOpacity="80%"></stop> |
| 92 | + <stop offset="50%" stopColor="#b44aff" stopOpacity="80%"></stop> |
| 93 | + <stop offset="100%" stopColor="#b44aff" stopOpacity="0%"></stop> |
| 94 | + </linearGradient> |
| 95 | + <linearGradient id="shine-gradient"> |
| 96 | + <stop offset="0%" stopColor="white" stopOpacity="0%"></stop> |
| 97 | + <stop offset="40%" stopColor="#ffffff" stopOpacity="80%"></stop> |
| 98 | + <stop offset="50%" stopColor="#ffffff" stopOpacity="80%"></stop> |
| 99 | + <stop offset="100%" stopColor="white" stopOpacity="0%"></stop> |
| 100 | + </linearGradient> |
| 101 | + </defs> |
| 102 | + <rect className={classNames(styles.PromptEffectLine)} pathLength="100" strokeLinecap="round"></rect> |
| 103 | + <rect className={classNames(styles.PromptShine)} x="48" y="24" width="70" height="1"></rect> |
| 104 | + </svg> |
| 105 | + <div> |
| 106 | + <ClientOnly> |
| 107 | + {() => ( |
| 108 | + <div className={props.isModelSettingsCollapsed ? 'hidden' : ''}> |
| 109 | + <ModelSelector |
| 110 | + key={props.provider?.name + ':' + props.modelList.length} |
| 111 | + model={props.model} |
| 112 | + setModel={props.setModel} |
| 113 | + modelList={props.modelList} |
| 114 | + provider={props.provider} |
| 115 | + setProvider={props.setProvider} |
| 116 | + providerList={props.providerList || (PROVIDER_LIST as ProviderInfo[])} |
| 117 | + apiKeys={props.apiKeys} |
| 118 | + modelLoading={props.isModelLoading} |
| 119 | + /> |
| 120 | + {(props.providerList || []).length > 0 && |
| 121 | + props.provider && |
| 122 | + !LOCAL_PROVIDERS.includes(props.provider.name) && ( |
| 123 | + <APIKeyManager |
| 124 | + provider={props.provider} |
| 125 | + apiKey={props.apiKeys[props.provider.name] || ''} |
| 126 | + setApiKey={(key) => { |
| 127 | + props.onApiKeysChange(props.provider.name, key); |
| 128 | + }} |
| 129 | + /> |
| 130 | + )} |
| 131 | + </div> |
| 132 | + )} |
| 133 | + </ClientOnly> |
| 134 | + </div> |
| 135 | + <FilePreview |
| 136 | + files={props.uploadedFiles} |
| 137 | + imageDataList={props.imageDataList} |
| 138 | + onRemove={(index) => { |
| 139 | + props.setUploadedFiles?.(props.uploadedFiles.filter((_, i) => i !== index)); |
| 140 | + props.setImageDataList?.(props.imageDataList.filter((_, i) => i !== index)); |
| 141 | + }} |
| 142 | + /> |
| 143 | + <ClientOnly> |
| 144 | + {() => ( |
| 145 | + <ScreenshotStateManager |
| 146 | + setUploadedFiles={props.setUploadedFiles} |
| 147 | + setImageDataList={props.setImageDataList} |
| 148 | + uploadedFiles={props.uploadedFiles} |
| 149 | + imageDataList={props.imageDataList} |
| 150 | + /> |
| 151 | + )} |
| 152 | + </ClientOnly> |
| 153 | + {props.selectedElement && ( |
| 154 | + <div className="flex mx-1.5 gap-2 items-center justify-between rounded-lg rounded-b-none border border-b-none border-bolt-elements-borderColor text-bolt-elements-textPrimary flex py-1 px-2.5 font-medium text-xs"> |
| 155 | + <div className="flex gap-2 items-center lowercase"> |
| 156 | + <code className="bg-accent-500 rounded-4px px-1.5 py-1 mr-0.5 text-white"> |
| 157 | + {props?.selectedElement?.tagName} |
| 158 | + </code> |
| 159 | + selected for inspection |
| 160 | + </div> |
| 161 | + <button |
| 162 | + className="bg-transparent text-accent-500 pointer-auto" |
| 163 | + onClick={() => props.setSelectedElement?.(null)} |
| 164 | + > |
| 165 | + Clear |
| 166 | + </button> |
| 167 | + </div> |
| 168 | + )} |
| 169 | + <div |
| 170 | + className={classNames('relative shadow-xs border border-bolt-elements-borderColor backdrop-blur rounded-lg')} |
| 171 | + > |
| 172 | + <textarea |
| 173 | + ref={props.textareaRef} |
| 174 | + className={classNames( |
| 175 | + 'w-full pl-4 pt-4 pr-16 outline-none resize-none text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary bg-transparent text-sm', |
| 176 | + 'transition-all duration-200', |
| 177 | + 'hover:border-bolt-elements-focus', |
| 178 | + )} |
| 179 | + onDragEnter={(e) => { |
| 180 | + e.preventDefault(); |
| 181 | + e.currentTarget.style.border = '2px solid #1488fc'; |
| 182 | + }} |
| 183 | + onDragOver={(e) => { |
| 184 | + e.preventDefault(); |
| 185 | + e.currentTarget.style.border = '2px solid #1488fc'; |
| 186 | + }} |
| 187 | + onDragLeave={(e) => { |
| 188 | + e.preventDefault(); |
| 189 | + e.currentTarget.style.border = '1px solid var(--bolt-elements-borderColor)'; |
| 190 | + }} |
| 191 | + onDrop={(e) => { |
| 192 | + e.preventDefault(); |
| 193 | + e.currentTarget.style.border = '1px solid var(--bolt-elements-borderColor)'; |
| 194 | + |
| 195 | + const files = Array.from(e.dataTransfer.files); |
| 196 | + files.forEach((file) => { |
| 197 | + if (file.type.startsWith('image/')) { |
| 198 | + const reader = new FileReader(); |
| 199 | + |
| 200 | + reader.onload = (e) => { |
| 201 | + const base64Image = e.target?.result as string; |
| 202 | + props.setUploadedFiles?.([...props.uploadedFiles, file]); |
| 203 | + props.setImageDataList?.([...props.imageDataList, base64Image]); |
| 204 | + }; |
| 205 | + reader.readAsDataURL(file); |
| 206 | + } |
| 207 | + }); |
| 208 | + }} |
| 209 | + onKeyDown={(event) => { |
| 210 | + if (event.key === 'Enter') { |
| 211 | + if (event.shiftKey) { |
| 212 | + return; |
| 213 | + } |
| 214 | + |
| 215 | + event.preventDefault(); |
| 216 | + |
| 217 | + if (props.isStreaming) { |
| 218 | + props.handleStop?.(); |
| 219 | + return; |
| 220 | + } |
| 221 | + |
| 222 | + // ignore if using input method engine |
| 223 | + if (event.nativeEvent.isComposing) { |
| 224 | + return; |
| 225 | + } |
| 226 | + |
| 227 | + props.handleSendMessage?.(event); |
| 228 | + } |
| 229 | + }} |
| 230 | + value={props.input} |
| 231 | + onChange={(event) => { |
| 232 | + props.handleInputChange?.(event); |
| 233 | + }} |
| 234 | + onPaste={props.handlePaste} |
| 235 | + style={{ |
| 236 | + minHeight: props.TEXTAREA_MIN_HEIGHT, |
| 237 | + maxHeight: props.TEXTAREA_MAX_HEIGHT, |
| 238 | + }} |
| 239 | + placeholder={props.chatMode === 'build' ? 'How can Bolt help you today?' : 'What would you like to discuss?'} |
| 240 | + translate="no" |
| 241 | + /> |
| 242 | + <ClientOnly> |
| 243 | + {() => ( |
| 244 | + <SendButton |
| 245 | + show={props.input.length > 0 || props.isStreaming || props.uploadedFiles.length > 0} |
| 246 | + isStreaming={props.isStreaming} |
| 247 | + disabled={!props.providerList || props.providerList.length === 0} |
| 248 | + onClick={(event) => { |
| 249 | + if (props.isStreaming) { |
| 250 | + props.handleStop?.(); |
| 251 | + return; |
| 252 | + } |
| 253 | + |
| 254 | + if (props.input.length > 0 || props.uploadedFiles.length > 0) { |
| 255 | + props.handleSendMessage?.(event); |
| 256 | + } |
| 257 | + }} |
| 258 | + /> |
| 259 | + )} |
| 260 | + </ClientOnly> |
| 261 | + <div className="flex justify-between items-center text-sm p-4 pt-2"> |
| 262 | + <div className="flex gap-1 items-center"> |
| 263 | + <ColorSchemeDialog designScheme={props.designScheme} setDesignScheme={props.setDesignScheme} /> |
| 264 | + <McpTools |
| 265 | + onOpenPanel={function (): void { |
| 266 | + throw new Error('Function not implemented.'); |
| 267 | + }} |
| 268 | + /> |
| 269 | + <IconButton title="Upload file" className="transition-all" onClick={() => props.handleFileUpload()}> |
| 270 | + <div className="i-ph:paperclip text-xl"></div> |
| 271 | + </IconButton> |
| 272 | + <IconButton |
| 273 | + title="Enhance prompt" |
| 274 | + disabled={props.input.length === 0 || props.enhancingPrompt} |
| 275 | + className={classNames('transition-all', props.enhancingPrompt ? 'opacity-100' : '')} |
| 276 | + onClick={() => { |
| 277 | + props.enhancePrompt?.(); |
| 278 | + toast.success('Prompt enhanced!'); |
| 279 | + }} |
| 280 | + > |
| 281 | + {props.enhancingPrompt ? ( |
| 282 | + <div className="i-svg-spinners:90-ring-with-bg text-bolt-elements-loader-progress text-xl animate-spin"></div> |
| 283 | + ) : ( |
| 284 | + <div className="i-bolt:stars text-xl"></div> |
| 285 | + )} |
| 286 | + </IconButton> |
| 287 | + |
| 288 | + <SpeechRecognitionButton |
| 289 | + isListening={props.isListening} |
| 290 | + onStart={props.startListening} |
| 291 | + onStop={props.stopListening} |
| 292 | + disabled={props.isStreaming} |
| 293 | + /> |
| 294 | + {props.chatStarted && ( |
| 295 | + <IconButton |
| 296 | + title="Discuss" |
| 297 | + className={classNames( |
| 298 | + 'transition-all flex items-center gap-1 px-1.5', |
| 299 | + props.chatMode === 'discuss' |
| 300 | + ? '!bg-bolt-elements-item-backgroundAccent !text-bolt-elements-item-contentAccent' |
| 301 | + : 'bg-bolt-elements-item-backgroundDefault text-bolt-elements-item-contentDefault', |
| 302 | + )} |
| 303 | + onClick={() => { |
| 304 | + props.setChatMode?.(props.chatMode === 'discuss' ? 'build' : 'discuss'); |
| 305 | + }} |
| 306 | + > |
| 307 | + <div className={`i-ph:chats text-xl`} /> |
| 308 | + {props.chatMode === 'discuss' ? <span>Discuss</span> : <span />} |
| 309 | + </IconButton> |
| 310 | + )} |
| 311 | + <IconButton |
| 312 | + title="Model Settings" |
| 313 | + className={classNames('transition-all flex items-center gap-1', { |
| 314 | + 'bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent': |
| 315 | + props.isModelSettingsCollapsed, |
| 316 | + 'bg-bolt-elements-item-backgroundDefault text-bolt-elements-item-contentDefault': |
| 317 | + !props.isModelSettingsCollapsed, |
| 318 | + })} |
| 319 | + onClick={() => props.setIsModelSettingsCollapsed(!props.isModelSettingsCollapsed)} |
| 320 | + disabled={!props.providerList || props.providerList.length === 0} |
| 321 | + > |
| 322 | + <div className={`i-ph:caret-${props.isModelSettingsCollapsed ? 'right' : 'down'} text-lg`} /> |
| 323 | + {props.isModelSettingsCollapsed ? <span className="text-xs">{props.model}</span> : <span />} |
| 324 | + </IconButton> |
| 325 | + </div> |
| 326 | + {props.input.length > 3 ? ( |
| 327 | + <div className="text-xs text-bolt-elements-textTertiary"> |
| 328 | + Use <kbd className="kdb px-1.5 py-0.5 rounded bg-bolt-elements-background-depth-2">Shift</kbd> +{' '} |
| 329 | + <kbd className="kdb px-1.5 py-0.5 rounded bg-bolt-elements-background-depth-2">Return</kbd> a new line |
| 330 | + </div> |
| 331 | + ) : null} |
| 332 | + <SupabaseConnection /> |
| 333 | + <ExpoQrModal open={props.qrModalOpen} onClose={() => props.setQrModalOpen(false)} /> |
| 334 | + </div> |
| 335 | + </div> |
| 336 | + </div> |
| 337 | + ); |
| 338 | +}; |
0 commit comments