Merge pull request #332 from atrokhym/main
Browse files- app/components/chat/BaseChat.tsx +125 -17
- app/components/chat/Chat.client.tsx +37 -6
- app/components/chat/FilePreview.tsx +35 -0
- app/components/chat/SendButton.client.tsx +3 -2
- app/components/chat/UserMessage.tsx +34 -8
- app/components/sidebar/Menu.client.tsx +2 -2
- app/lib/.server/llm/model.ts +6 -1
- app/lib/.server/llm/stream-text.ts +28 -7
- app/lib/stores/files.ts +1 -5
- app/routes/api.chat.ts +3 -1
- app/utils/logger.ts +1 -1
- vite.config.ts +1 -2
app/components/chat/BaseChat.tsx
CHANGED
|
@@ -22,6 +22,8 @@ import { ExportChatButton } from '~/components/chat/chatExportAndImport/ExportCh
|
|
| 22 |
import { ImportButtons } from '~/components/chat/chatExportAndImport/ImportButtons';
|
| 23 |
import { ExamplePrompts } from '~/components/chat/ExamplePrompts';
|
| 24 |
|
|
|
|
|
|
|
| 25 |
// @ts-ignore TODO: Introduce proper types
|
| 26 |
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
| 27 |
const ModelSelector = ({ model, setModel, provider, setProvider, modelList, providerList, apiKeys }) => {
|
|
@@ -85,8 +87,11 @@ interface BaseChatProps {
|
|
| 85 |
enhancePrompt?: () => void;
|
| 86 |
importChat?: (description: string, messages: Message[]) => Promise<void>;
|
| 87 |
exportChat?: () => void;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
}
|
| 89 |
-
|
| 90 |
export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
| 91 |
(
|
| 92 |
{
|
|
@@ -96,20 +101,24 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
|
| 96 |
showChat = true,
|
| 97 |
chatStarted = false,
|
| 98 |
isStreaming = false,
|
| 99 |
-
enhancingPrompt = false,
|
| 100 |
-
promptEnhanced = false,
|
| 101 |
-
messages,
|
| 102 |
-
input = '',
|
| 103 |
model,
|
| 104 |
setModel,
|
| 105 |
provider,
|
| 106 |
setProvider,
|
| 107 |
-
|
|
|
|
| 108 |
handleInputChange,
|
|
|
|
| 109 |
enhancePrompt,
|
|
|
|
| 110 |
handleStop,
|
| 111 |
importChat,
|
| 112 |
exportChat,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
},
|
| 114 |
ref,
|
| 115 |
) => {
|
|
@@ -159,6 +168,58 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
|
| 159 |
}
|
| 160 |
};
|
| 161 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
const baseChat = (
|
| 163 |
<div
|
| 164 |
ref={ref}
|
|
@@ -276,7 +337,14 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
|
| 276 |
)}
|
| 277 |
</div>
|
| 278 |
</div>
|
| 279 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 280 |
<div
|
| 281 |
className={classNames(
|
| 282 |
'relative shadow-xs border border-bolt-elements-borderColor backdrop-blur rounded-lg',
|
|
@@ -284,9 +352,41 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
|
| 284 |
>
|
| 285 |
<textarea
|
| 286 |
ref={textareaRef}
|
| 287 |
-
className={
|
| 288 |
-
'w-full pl-4 pt-4 pr-16 focus:outline-none resize-none text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary bg-transparent text-sm'
|
| 289 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
onKeyDown={(event) => {
|
| 291 |
if (event.key === 'Enter') {
|
| 292 |
if (event.shiftKey) {
|
|
@@ -302,6 +402,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
|
| 302 |
onChange={(event) => {
|
| 303 |
handleInputChange?.(event);
|
| 304 |
}}
|
|
|
|
| 305 |
style={{
|
| 306 |
minHeight: TEXTAREA_MIN_HEIGHT,
|
| 307 |
maxHeight: TEXTAREA_MAX_HEIGHT,
|
|
@@ -312,7 +413,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
|
| 312 |
<ClientOnly>
|
| 313 |
{() => (
|
| 314 |
<SendButton
|
| 315 |
-
show={input.length > 0 || isStreaming}
|
| 316 |
isStreaming={isStreaming}
|
| 317 |
onClick={(event) => {
|
| 318 |
if (isStreaming) {
|
|
@@ -320,21 +421,28 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
|
| 320 |
return;
|
| 321 |
}
|
| 322 |
|
| 323 |
-
|
|
|
|
|
|
|
| 324 |
}}
|
| 325 |
/>
|
| 326 |
)}
|
| 327 |
</ClientOnly>
|
| 328 |
<div className="flex justify-between items-center text-sm p-4 pt-2">
|
| 329 |
<div className="flex gap-1 items-center">
|
|
|
|
|
|
|
|
|
|
| 330 |
<IconButton
|
| 331 |
title="Enhance prompt"
|
| 332 |
disabled={input.length === 0 || enhancingPrompt}
|
| 333 |
-
className={classNames(
|
| 334 |
-
'
|
| 335 |
-
'
|
| 336 |
-
|
| 337 |
-
|
|
|
|
|
|
|
| 338 |
onClick={() => enhancePrompt?.()}
|
| 339 |
>
|
| 340 |
{enhancingPrompt ? (
|
|
|
|
| 22 |
import { ImportButtons } from '~/components/chat/chatExportAndImport/ImportButtons';
|
| 23 |
import { ExamplePrompts } from '~/components/chat/ExamplePrompts';
|
| 24 |
|
| 25 |
+
import FilePreview from './FilePreview';
|
| 26 |
+
|
| 27 |
// @ts-ignore TODO: Introduce proper types
|
| 28 |
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
| 29 |
const ModelSelector = ({ model, setModel, provider, setProvider, modelList, providerList, apiKeys }) => {
|
|
|
|
| 87 |
enhancePrompt?: () => void;
|
| 88 |
importChat?: (description: string, messages: Message[]) => Promise<void>;
|
| 89 |
exportChat?: () => void;
|
| 90 |
+
uploadedFiles?: File[];
|
| 91 |
+
setUploadedFiles?: (files: File[]) => void;
|
| 92 |
+
imageDataList?: string[];
|
| 93 |
+
setImageDataList?: (dataList: string[]) => void;
|
| 94 |
}
|
|
|
|
| 95 |
export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
| 96 |
(
|
| 97 |
{
|
|
|
|
| 101 |
showChat = true,
|
| 102 |
chatStarted = false,
|
| 103 |
isStreaming = false,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
model,
|
| 105 |
setModel,
|
| 106 |
provider,
|
| 107 |
setProvider,
|
| 108 |
+
input = '',
|
| 109 |
+
enhancingPrompt,
|
| 110 |
handleInputChange,
|
| 111 |
+
promptEnhanced,
|
| 112 |
enhancePrompt,
|
| 113 |
+
sendMessage,
|
| 114 |
handleStop,
|
| 115 |
importChat,
|
| 116 |
exportChat,
|
| 117 |
+
uploadedFiles = [],
|
| 118 |
+
setUploadedFiles,
|
| 119 |
+
imageDataList = [],
|
| 120 |
+
setImageDataList,
|
| 121 |
+
messages,
|
| 122 |
},
|
| 123 |
ref,
|
| 124 |
) => {
|
|
|
|
| 168 |
}
|
| 169 |
};
|
| 170 |
|
| 171 |
+
const handleFileUpload = () => {
|
| 172 |
+
const input = document.createElement('input');
|
| 173 |
+
input.type = 'file';
|
| 174 |
+
input.accept = 'image/*';
|
| 175 |
+
|
| 176 |
+
input.onchange = async (e) => {
|
| 177 |
+
const file = (e.target as HTMLInputElement).files?.[0];
|
| 178 |
+
|
| 179 |
+
if (file) {
|
| 180 |
+
const reader = new FileReader();
|
| 181 |
+
|
| 182 |
+
reader.onload = (e) => {
|
| 183 |
+
const base64Image = e.target?.result as string;
|
| 184 |
+
setUploadedFiles?.([...uploadedFiles, file]);
|
| 185 |
+
setImageDataList?.([...imageDataList, base64Image]);
|
| 186 |
+
};
|
| 187 |
+
reader.readAsDataURL(file);
|
| 188 |
+
}
|
| 189 |
+
};
|
| 190 |
+
|
| 191 |
+
input.click();
|
| 192 |
+
};
|
| 193 |
+
|
| 194 |
+
const handlePaste = async (e: React.ClipboardEvent) => {
|
| 195 |
+
const items = e.clipboardData?.items;
|
| 196 |
+
|
| 197 |
+
if (!items) {
|
| 198 |
+
return;
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
for (const item of items) {
|
| 202 |
+
if (item.type.startsWith('image/')) {
|
| 203 |
+
e.preventDefault();
|
| 204 |
+
|
| 205 |
+
const file = item.getAsFile();
|
| 206 |
+
|
| 207 |
+
if (file) {
|
| 208 |
+
const reader = new FileReader();
|
| 209 |
+
|
| 210 |
+
reader.onload = (e) => {
|
| 211 |
+
const base64Image = e.target?.result as string;
|
| 212 |
+
setUploadedFiles?.([...uploadedFiles, file]);
|
| 213 |
+
setImageDataList?.([...imageDataList, base64Image]);
|
| 214 |
+
};
|
| 215 |
+
reader.readAsDataURL(file);
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
break;
|
| 219 |
+
}
|
| 220 |
+
}
|
| 221 |
+
};
|
| 222 |
+
|
| 223 |
const baseChat = (
|
| 224 |
<div
|
| 225 |
ref={ref}
|
|
|
|
| 337 |
)}
|
| 338 |
</div>
|
| 339 |
</div>
|
| 340 |
+
<FilePreview
|
| 341 |
+
files={uploadedFiles}
|
| 342 |
+
imageDataList={imageDataList}
|
| 343 |
+
onRemove={(index) => {
|
| 344 |
+
setUploadedFiles?.(uploadedFiles.filter((_, i) => i !== index));
|
| 345 |
+
setImageDataList?.(imageDataList.filter((_, i) => i !== index));
|
| 346 |
+
}}
|
| 347 |
+
/>
|
| 348 |
<div
|
| 349 |
className={classNames(
|
| 350 |
'relative shadow-xs border border-bolt-elements-borderColor backdrop-blur rounded-lg',
|
|
|
|
| 352 |
>
|
| 353 |
<textarea
|
| 354 |
ref={textareaRef}
|
| 355 |
+
className={classNames(
|
| 356 |
+
'w-full pl-4 pt-4 pr-16 focus:outline-none resize-none text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary bg-transparent text-sm',
|
| 357 |
+
'transition-all duration-200',
|
| 358 |
+
'hover:border-bolt-elements-focus',
|
| 359 |
+
)}
|
| 360 |
+
onDragEnter={(e) => {
|
| 361 |
+
e.preventDefault();
|
| 362 |
+
e.currentTarget.style.border = '2px solid #1488fc';
|
| 363 |
+
}}
|
| 364 |
+
onDragOver={(e) => {
|
| 365 |
+
e.preventDefault();
|
| 366 |
+
e.currentTarget.style.border = '2px solid #1488fc';
|
| 367 |
+
}}
|
| 368 |
+
onDragLeave={(e) => {
|
| 369 |
+
e.preventDefault();
|
| 370 |
+
e.currentTarget.style.border = '1px solid var(--bolt-elements-borderColor)';
|
| 371 |
+
}}
|
| 372 |
+
onDrop={(e) => {
|
| 373 |
+
e.preventDefault();
|
| 374 |
+
e.currentTarget.style.border = '1px solid var(--bolt-elements-borderColor)';
|
| 375 |
+
|
| 376 |
+
const files = Array.from(e.dataTransfer.files);
|
| 377 |
+
files.forEach((file) => {
|
| 378 |
+
if (file.type.startsWith('image/')) {
|
| 379 |
+
const reader = new FileReader();
|
| 380 |
+
|
| 381 |
+
reader.onload = (e) => {
|
| 382 |
+
const base64Image = e.target?.result as string;
|
| 383 |
+
setUploadedFiles?.([...uploadedFiles, file]);
|
| 384 |
+
setImageDataList?.([...imageDataList, base64Image]);
|
| 385 |
+
};
|
| 386 |
+
reader.readAsDataURL(file);
|
| 387 |
+
}
|
| 388 |
+
});
|
| 389 |
+
}}
|
| 390 |
onKeyDown={(event) => {
|
| 391 |
if (event.key === 'Enter') {
|
| 392 |
if (event.shiftKey) {
|
|
|
|
| 402 |
onChange={(event) => {
|
| 403 |
handleInputChange?.(event);
|
| 404 |
}}
|
| 405 |
+
onPaste={handlePaste}
|
| 406 |
style={{
|
| 407 |
minHeight: TEXTAREA_MIN_HEIGHT,
|
| 408 |
maxHeight: TEXTAREA_MAX_HEIGHT,
|
|
|
|
| 413 |
<ClientOnly>
|
| 414 |
{() => (
|
| 415 |
<SendButton
|
| 416 |
+
show={input.length > 0 || isStreaming || uploadedFiles.length > 0}
|
| 417 |
isStreaming={isStreaming}
|
| 418 |
onClick={(event) => {
|
| 419 |
if (isStreaming) {
|
|
|
|
| 421 |
return;
|
| 422 |
}
|
| 423 |
|
| 424 |
+
if (input.length > 0 || uploadedFiles.length > 0) {
|
| 425 |
+
sendMessage?.(event);
|
| 426 |
+
}
|
| 427 |
}}
|
| 428 |
/>
|
| 429 |
)}
|
| 430 |
</ClientOnly>
|
| 431 |
<div className="flex justify-between items-center text-sm p-4 pt-2">
|
| 432 |
<div className="flex gap-1 items-center">
|
| 433 |
+
<IconButton title="Upload file" className="transition-all" onClick={() => handleFileUpload()}>
|
| 434 |
+
<div className="i-ph:paperclip text-xl"></div>
|
| 435 |
+
</IconButton>
|
| 436 |
<IconButton
|
| 437 |
title="Enhance prompt"
|
| 438 |
disabled={input.length === 0 || enhancingPrompt}
|
| 439 |
+
className={classNames(
|
| 440 |
+
'transition-all',
|
| 441 |
+
enhancingPrompt ? 'opacity-100' : '',
|
| 442 |
+
promptEnhanced ? 'text-bolt-elements-item-contentAccent' : '',
|
| 443 |
+
promptEnhanced ? 'pr-1.5' : '',
|
| 444 |
+
promptEnhanced ? 'enabled:hover:bg-bolt-elements-item-backgroundAccent' : '',
|
| 445 |
+
)}
|
| 446 |
onClick={() => enhancePrompt?.()}
|
| 447 |
>
|
| 448 |
{enhancingPrompt ? (
|
app/components/chat/Chat.client.tsx
CHANGED
|
@@ -12,7 +12,6 @@ import { useMessageParser, usePromptEnhancer, useShortcuts, useSnapScroll } from
|
|
| 12 |
import { description, useChatHistory } from '~/lib/persistence';
|
| 13 |
import { chatStore } from '~/lib/stores/chat';
|
| 14 |
import { workbenchStore } from '~/lib/stores/workbench';
|
| 15 |
-
import { fileModificationsToHTML } from '~/utils/diff';
|
| 16 |
import { DEFAULT_MODEL, DEFAULT_PROVIDER, PROMPT_COOKIE_KEY, PROVIDER_LIST } from '~/utils/constants';
|
| 17 |
import { cubicEasingFn } from '~/utils/easings';
|
| 18 |
import { createScopedLogger, renderLogger } from '~/utils/logger';
|
|
@@ -89,8 +88,10 @@ export const ChatImpl = memo(
|
|
| 89 |
useShortcuts();
|
| 90 |
|
| 91 |
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
| 92 |
-
|
| 93 |
const [chatStarted, setChatStarted] = useState(initialMessages.length > 0);
|
|
|
|
|
|
|
|
|
|
| 94 |
const [model, setModel] = useState(() => {
|
| 95 |
const savedModel = Cookies.get('selectedModel');
|
| 96 |
return savedModel || DEFAULT_MODEL;
|
|
@@ -206,8 +207,6 @@ export const ChatImpl = memo(
|
|
| 206 |
runAnimation();
|
| 207 |
|
| 208 |
if (fileModifications !== undefined) {
|
| 209 |
-
const diff = fileModificationsToHTML(fileModifications);
|
| 210 |
-
|
| 211 |
/**
|
| 212 |
* If we have file modifications we append a new user message manually since we have to prefix
|
| 213 |
* the user input with the file modifications and we don't want the new user input to appear
|
|
@@ -215,7 +214,19 @@ export const ChatImpl = memo(
|
|
| 215 |
* manually reset the input and we'd have to manually pass in file attachments. However, those
|
| 216 |
* aren't relevant here.
|
| 217 |
*/
|
| 218 |
-
append({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
|
| 220 |
/**
|
| 221 |
* After sending a new message we reset all modifications since the model
|
|
@@ -223,12 +234,28 @@ export const ChatImpl = memo(
|
|
| 223 |
*/
|
| 224 |
workbenchStore.resetAllFileModifications();
|
| 225 |
} else {
|
| 226 |
-
append({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
}
|
| 228 |
|
| 229 |
setInput('');
|
| 230 |
Cookies.remove(PROMPT_COOKIE_KEY);
|
| 231 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
resetEnhancer();
|
| 233 |
|
| 234 |
textareaRef.current?.blur();
|
|
@@ -321,6 +348,10 @@ export const ChatImpl = memo(
|
|
| 321 |
apiKeys,
|
| 322 |
);
|
| 323 |
}}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 324 |
/>
|
| 325 |
);
|
| 326 |
},
|
|
|
|
| 12 |
import { description, useChatHistory } from '~/lib/persistence';
|
| 13 |
import { chatStore } from '~/lib/stores/chat';
|
| 14 |
import { workbenchStore } from '~/lib/stores/workbench';
|
|
|
|
| 15 |
import { DEFAULT_MODEL, DEFAULT_PROVIDER, PROMPT_COOKIE_KEY, PROVIDER_LIST } from '~/utils/constants';
|
| 16 |
import { cubicEasingFn } from '~/utils/easings';
|
| 17 |
import { createScopedLogger, renderLogger } from '~/utils/logger';
|
|
|
|
| 88 |
useShortcuts();
|
| 89 |
|
| 90 |
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
|
|
| 91 |
const [chatStarted, setChatStarted] = useState(initialMessages.length > 0);
|
| 92 |
+
const [uploadedFiles, setUploadedFiles] = useState<File[]>([]); // Move here
|
| 93 |
+
const [imageDataList, setImageDataList] = useState<string[]>([]); // Move here
|
| 94 |
+
|
| 95 |
const [model, setModel] = useState(() => {
|
| 96 |
const savedModel = Cookies.get('selectedModel');
|
| 97 |
return savedModel || DEFAULT_MODEL;
|
|
|
|
| 207 |
runAnimation();
|
| 208 |
|
| 209 |
if (fileModifications !== undefined) {
|
|
|
|
|
|
|
| 210 |
/**
|
| 211 |
* If we have file modifications we append a new user message manually since we have to prefix
|
| 212 |
* the user input with the file modifications and we don't want the new user input to appear
|
|
|
|
| 214 |
* manually reset the input and we'd have to manually pass in file attachments. However, those
|
| 215 |
* aren't relevant here.
|
| 216 |
*/
|
| 217 |
+
append({
|
| 218 |
+
role: 'user',
|
| 219 |
+
content: [
|
| 220 |
+
{
|
| 221 |
+
type: 'text',
|
| 222 |
+
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${_input}`,
|
| 223 |
+
},
|
| 224 |
+
...imageDataList.map((imageData) => ({
|
| 225 |
+
type: 'image',
|
| 226 |
+
image: imageData,
|
| 227 |
+
})),
|
| 228 |
+
] as any, // Type assertion to bypass compiler check
|
| 229 |
+
});
|
| 230 |
|
| 231 |
/**
|
| 232 |
* After sending a new message we reset all modifications since the model
|
|
|
|
| 234 |
*/
|
| 235 |
workbenchStore.resetAllFileModifications();
|
| 236 |
} else {
|
| 237 |
+
append({
|
| 238 |
+
role: 'user',
|
| 239 |
+
content: [
|
| 240 |
+
{
|
| 241 |
+
type: 'text',
|
| 242 |
+
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${_input}`,
|
| 243 |
+
},
|
| 244 |
+
...imageDataList.map((imageData) => ({
|
| 245 |
+
type: 'image',
|
| 246 |
+
image: imageData,
|
| 247 |
+
})),
|
| 248 |
+
] as any, // Type assertion to bypass compiler check
|
| 249 |
+
});
|
| 250 |
}
|
| 251 |
|
| 252 |
setInput('');
|
| 253 |
Cookies.remove(PROMPT_COOKIE_KEY);
|
| 254 |
|
| 255 |
+
// Add file cleanup here
|
| 256 |
+
setUploadedFiles([]);
|
| 257 |
+
setImageDataList([]);
|
| 258 |
+
|
| 259 |
resetEnhancer();
|
| 260 |
|
| 261 |
textareaRef.current?.blur();
|
|
|
|
| 348 |
apiKeys,
|
| 349 |
);
|
| 350 |
}}
|
| 351 |
+
uploadedFiles={uploadedFiles}
|
| 352 |
+
setUploadedFiles={setUploadedFiles}
|
| 353 |
+
imageDataList={imageDataList}
|
| 354 |
+
setImageDataList={setImageDataList}
|
| 355 |
/>
|
| 356 |
);
|
| 357 |
},
|
app/components/chat/FilePreview.tsx
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
|
| 3 |
+
interface FilePreviewProps {
|
| 4 |
+
files: File[];
|
| 5 |
+
imageDataList: string[];
|
| 6 |
+
onRemove: (index: number) => void;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
const FilePreview: React.FC<FilePreviewProps> = ({ files, imageDataList, onRemove }) => {
|
| 10 |
+
if (!files || files.length === 0) {
|
| 11 |
+
return null;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
return (
|
| 15 |
+
<div className="flex flex-row overflow-x-auto -mt-2">
|
| 16 |
+
{files.map((file, index) => (
|
| 17 |
+
<div key={file.name + file.size} className="mr-2 relative">
|
| 18 |
+
{imageDataList[index] && (
|
| 19 |
+
<div className="relative pt-4 pr-4">
|
| 20 |
+
<img src={imageDataList[index]} alt={file.name} className="max-h-20" />
|
| 21 |
+
<button
|
| 22 |
+
onClick={() => onRemove(index)}
|
| 23 |
+
className="absolute top-1 right-1 z-10 bg-black rounded-full w-5 h-5 shadow-md hover:bg-gray-900 transition-colors flex items-center justify-center"
|
| 24 |
+
>
|
| 25 |
+
<div className="i-ph:x w-3 h-3 text-gray-200" />
|
| 26 |
+
</button>
|
| 27 |
+
</div>
|
| 28 |
+
)}
|
| 29 |
+
</div>
|
| 30 |
+
))}
|
| 31 |
+
</div>
|
| 32 |
+
);
|
| 33 |
+
};
|
| 34 |
+
|
| 35 |
+
export default FilePreview;
|
app/components/chat/SendButton.client.tsx
CHANGED
|
@@ -4,11 +4,12 @@ interface SendButtonProps {
|
|
| 4 |
show: boolean;
|
| 5 |
isStreaming?: boolean;
|
| 6 |
onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
|
|
|
| 7 |
}
|
| 8 |
|
| 9 |
const customEasingFn = cubicBezier(0.4, 0, 0.2, 1);
|
| 10 |
|
| 11 |
-
export
|
| 12 |
return (
|
| 13 |
<AnimatePresence>
|
| 14 |
{show ? (
|
|
@@ -30,4 +31,4 @@ export function SendButton({ show, isStreaming, onClick }: SendButtonProps) {
|
|
| 30 |
) : null}
|
| 31 |
</AnimatePresence>
|
| 32 |
);
|
| 33 |
-
}
|
|
|
|
| 4 |
show: boolean;
|
| 5 |
isStreaming?: boolean;
|
| 6 |
onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
| 7 |
+
onImagesSelected?: (images: File[]) => void;
|
| 8 |
}
|
| 9 |
|
| 10 |
const customEasingFn = cubicBezier(0.4, 0, 0.2, 1);
|
| 11 |
|
| 12 |
+
export const SendButton = ({ show, isStreaming, onClick }: SendButtonProps) => {
|
| 13 |
return (
|
| 14 |
<AnimatePresence>
|
| 15 |
{show ? (
|
|
|
|
| 31 |
) : null}
|
| 32 |
</AnimatePresence>
|
| 33 |
);
|
| 34 |
+
};
|
app/components/chat/UserMessage.tsx
CHANGED
|
@@ -2,26 +2,52 @@
|
|
| 2 |
* @ts-nocheck
|
| 3 |
* Preventing TS checks with files presented in the video for a better presentation.
|
| 4 |
*/
|
| 5 |
-
import { modificationsRegex } from '~/utils/diff';
|
| 6 |
import { MODEL_REGEX, PROVIDER_REGEX } from '~/utils/constants';
|
| 7 |
import { Markdown } from './Markdown';
|
| 8 |
|
| 9 |
interface UserMessageProps {
|
| 10 |
-
content: string;
|
| 11 |
}
|
| 12 |
|
| 13 |
export function UserMessage({ content }: UserMessageProps) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
return (
|
| 15 |
<div className="overflow-hidden pt-[4px]">
|
| 16 |
-
<Markdown limitedMarkdown>{
|
| 17 |
</div>
|
| 18 |
);
|
| 19 |
}
|
| 20 |
|
| 21 |
function sanitizeUserMessage(content: string) {
|
| 22 |
-
return content
|
| 23 |
-
.replace(modificationsRegex, '')
|
| 24 |
-
.replace(MODEL_REGEX, 'Using: $1')
|
| 25 |
-
.replace(PROVIDER_REGEX, ' ($1)\n\n')
|
| 26 |
-
.trim();
|
| 27 |
}
|
|
|
|
| 2 |
* @ts-nocheck
|
| 3 |
* Preventing TS checks with files presented in the video for a better presentation.
|
| 4 |
*/
|
|
|
|
| 5 |
import { MODEL_REGEX, PROVIDER_REGEX } from '~/utils/constants';
|
| 6 |
import { Markdown } from './Markdown';
|
| 7 |
|
| 8 |
interface UserMessageProps {
|
| 9 |
+
content: string | Array<{ type: string; text?: string; image?: string }>;
|
| 10 |
}
|
| 11 |
|
| 12 |
export function UserMessage({ content }: UserMessageProps) {
|
| 13 |
+
if (Array.isArray(content)) {
|
| 14 |
+
const textItem = content.find((item) => item.type === 'text');
|
| 15 |
+
const textContent = sanitizeUserMessage(textItem?.text || '');
|
| 16 |
+
const images = content.filter((item) => item.type === 'image' && item.image);
|
| 17 |
+
|
| 18 |
+
return (
|
| 19 |
+
<div className="overflow-hidden pt-[4px]">
|
| 20 |
+
<div className="flex items-start gap-4">
|
| 21 |
+
<div className="flex-1">
|
| 22 |
+
<Markdown limitedMarkdown>{textContent}</Markdown>
|
| 23 |
+
</div>
|
| 24 |
+
{images.length > 0 && (
|
| 25 |
+
<div className="flex-shrink-0 w-[160px]">
|
| 26 |
+
{images.map((item, index) => (
|
| 27 |
+
<div key={index} className="relative">
|
| 28 |
+
<img
|
| 29 |
+
src={item.image}
|
| 30 |
+
alt={`Uploaded image ${index + 1}`}
|
| 31 |
+
className="w-full h-[160px] rounded-lg object-cover border border-bolt-elements-borderColor"
|
| 32 |
+
/>
|
| 33 |
+
</div>
|
| 34 |
+
))}
|
| 35 |
+
</div>
|
| 36 |
+
)}
|
| 37 |
+
</div>
|
| 38 |
+
</div>
|
| 39 |
+
);
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
const textContent = sanitizeUserMessage(content);
|
| 43 |
+
|
| 44 |
return (
|
| 45 |
<div className="overflow-hidden pt-[4px]">
|
| 46 |
+
<Markdown limitedMarkdown>{textContent}</Markdown>
|
| 47 |
</div>
|
| 48 |
);
|
| 49 |
}
|
| 50 |
|
| 51 |
function sanitizeUserMessage(content: string) {
|
| 52 |
+
return content.replace(MODEL_REGEX, '').replace(PROVIDER_REGEX, '');
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
}
|
app/components/sidebar/Menu.client.tsx
CHANGED
|
@@ -33,7 +33,7 @@ const menuVariants = {
|
|
| 33 |
|
| 34 |
type DialogContent = { type: 'delete'; item: ChatHistoryItem } | null;
|
| 35 |
|
| 36 |
-
export
|
| 37 |
const { duplicateCurrentChat, exportChat } = useChatHistory();
|
| 38 |
const menuRef = useRef<HTMLDivElement>(null);
|
| 39 |
const [list, setList] = useState<ChatHistoryItem[]>([]);
|
|
@@ -206,4 +206,4 @@ export function Menu() {
|
|
| 206 |
</div>
|
| 207 |
</motion.div>
|
| 208 |
);
|
| 209 |
-
}
|
|
|
|
| 33 |
|
| 34 |
type DialogContent = { type: 'delete'; item: ChatHistoryItem } | null;
|
| 35 |
|
| 36 |
+
export const Menu = () => {
|
| 37 |
const { duplicateCurrentChat, exportChat } = useChatHistory();
|
| 38 |
const menuRef = useRef<HTMLDivElement>(null);
|
| 39 |
const [list, setList] = useState<ChatHistoryItem[]>([]);
|
|
|
|
| 206 |
</div>
|
| 207 |
</motion.div>
|
| 208 |
);
|
| 209 |
+
};
|
app/lib/.server/llm/model.ts
CHANGED
|
@@ -128,7 +128,12 @@ export function getXAIModel(apiKey: OptionalApiKey, model: string) {
|
|
| 128 |
}
|
| 129 |
|
| 130 |
export function getModel(provider: string, model: string, env: Env, apiKeys?: Record<string, string>) {
|
| 131 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
const baseURL = getBaseURL(env, provider);
|
| 133 |
|
| 134 |
switch (provider) {
|
|
|
|
| 128 |
}
|
| 129 |
|
| 130 |
export function getModel(provider: string, model: string, env: Env, apiKeys?: Record<string, string>) {
|
| 131 |
+
/*
|
| 132 |
+
* let apiKey; // Declare first
|
| 133 |
+
* let baseURL;
|
| 134 |
+
*/
|
| 135 |
+
|
| 136 |
+
const apiKey = getAPIKey(env, provider, apiKeys); // Then assign
|
| 137 |
const baseURL = getBaseURL(env, provider);
|
| 138 |
|
| 139 |
switch (provider) {
|
app/lib/.server/llm/stream-text.ts
CHANGED
|
@@ -26,16 +26,37 @@ export type Messages = Message[];
|
|
| 26 |
export type StreamingOptions = Omit<Parameters<typeof _streamText>[0], 'model'>;
|
| 27 |
|
| 28 |
function extractPropertiesFromMessage(message: Message): { model: string; provider: string; content: string } {
|
| 29 |
-
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
const model = modelMatch ? modelMatch[1] : DEFAULT_MODEL;
|
| 32 |
|
| 33 |
-
|
| 34 |
-
|
|
|
|
|
|
|
| 35 |
const provider = providerMatch ? providerMatch[1] : DEFAULT_PROVIDER;
|
| 36 |
|
| 37 |
-
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
return { model, provider, content: cleanedContent };
|
| 41 |
}
|
|
@@ -65,10 +86,10 @@ export function streamText(messages: Messages, env: Env, options?: StreamingOpti
|
|
| 65 |
const dynamicMaxTokens = modelDetails && modelDetails.maxTokenAllowed ? modelDetails.maxTokenAllowed : MAX_TOKENS;
|
| 66 |
|
| 67 |
return _streamText({
|
|
|
|
| 68 |
model: getModel(currentProvider, currentModel, env, apiKeys),
|
| 69 |
system: getSystemPrompt(),
|
| 70 |
maxTokens: dynamicMaxTokens,
|
| 71 |
messages: convertToCoreMessages(processedMessages),
|
| 72 |
-
...options,
|
| 73 |
});
|
| 74 |
}
|
|
|
|
| 26 |
export type StreamingOptions = Omit<Parameters<typeof _streamText>[0], 'model'>;
|
| 27 |
|
| 28 |
function extractPropertiesFromMessage(message: Message): { model: string; provider: string; content: string } {
|
| 29 |
+
const textContent = Array.isArray(message.content)
|
| 30 |
+
? message.content.find((item) => item.type === 'text')?.text || ''
|
| 31 |
+
: message.content;
|
| 32 |
+
|
| 33 |
+
const modelMatch = textContent.match(MODEL_REGEX);
|
| 34 |
+
const providerMatch = textContent.match(PROVIDER_REGEX);
|
| 35 |
+
|
| 36 |
+
/*
|
| 37 |
+
* Extract model
|
| 38 |
+
* const modelMatch = message.content.match(MODEL_REGEX);
|
| 39 |
+
*/
|
| 40 |
const model = modelMatch ? modelMatch[1] : DEFAULT_MODEL;
|
| 41 |
|
| 42 |
+
/*
|
| 43 |
+
* Extract provider
|
| 44 |
+
* const providerMatch = message.content.match(PROVIDER_REGEX);
|
| 45 |
+
*/
|
| 46 |
const provider = providerMatch ? providerMatch[1] : DEFAULT_PROVIDER;
|
| 47 |
|
| 48 |
+
const cleanedContent = Array.isArray(message.content)
|
| 49 |
+
? message.content.map((item) => {
|
| 50 |
+
if (item.type === 'text') {
|
| 51 |
+
return {
|
| 52 |
+
type: 'text',
|
| 53 |
+
text: item.text?.replace(MODEL_REGEX, '').replace(PROVIDER_REGEX, ''),
|
| 54 |
+
};
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
return item; // Preserve image_url and other types as is
|
| 58 |
+
})
|
| 59 |
+
: textContent.replace(MODEL_REGEX, '').replace(PROVIDER_REGEX, '');
|
| 60 |
|
| 61 |
return { model, provider, content: cleanedContent };
|
| 62 |
}
|
|
|
|
| 86 |
const dynamicMaxTokens = modelDetails && modelDetails.maxTokenAllowed ? modelDetails.maxTokenAllowed : MAX_TOKENS;
|
| 87 |
|
| 88 |
return _streamText({
|
| 89 |
+
...options,
|
| 90 |
model: getModel(currentProvider, currentModel, env, apiKeys),
|
| 91 |
system: getSystemPrompt(),
|
| 92 |
maxTokens: dynamicMaxTokens,
|
| 93 |
messages: convertToCoreMessages(processedMessages),
|
|
|
|
| 94 |
});
|
| 95 |
}
|
app/lib/stores/files.ts
CHANGED
|
@@ -212,9 +212,5 @@ function isBinaryFile(buffer: Uint8Array | undefined) {
|
|
| 212 |
* array buffer.
|
| 213 |
*/
|
| 214 |
function convertToBuffer(view: Uint8Array): Buffer {
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
Object.setPrototypeOf(buffer, Buffer.prototype);
|
| 218 |
-
|
| 219 |
-
return buffer as Buffer;
|
| 220 |
}
|
|
|
|
| 212 |
* array buffer.
|
| 213 |
*/
|
| 214 |
function convertToBuffer(view: Uint8Array): Buffer {
|
| 215 |
+
return Buffer.from(view.buffer, view.byteOffset, view.byteLength);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
}
|
app/routes/api.chat.ts
CHANGED
|
@@ -32,8 +32,9 @@ function parseCookies(cookieHeader) {
|
|
| 32 |
}
|
| 33 |
|
| 34 |
async function chatAction({ context, request }: ActionFunctionArgs) {
|
| 35 |
-
const { messages } = await request.json<{
|
| 36 |
messages: Messages;
|
|
|
|
| 37 |
}>();
|
| 38 |
|
| 39 |
const cookieHeader = request.headers.get('Cookie');
|
|
@@ -47,6 +48,7 @@ async function chatAction({ context, request }: ActionFunctionArgs) {
|
|
| 47 |
const options: StreamingOptions = {
|
| 48 |
toolChoice: 'none',
|
| 49 |
apiKeys,
|
|
|
|
| 50 |
onFinish: async ({ text: content, finishReason }) => {
|
| 51 |
if (finishReason !== 'length') {
|
| 52 |
return stream.close();
|
|
|
|
| 32 |
}
|
| 33 |
|
| 34 |
async function chatAction({ context, request }: ActionFunctionArgs) {
|
| 35 |
+
const { messages, model } = await request.json<{
|
| 36 |
messages: Messages;
|
| 37 |
+
model: string;
|
| 38 |
}>();
|
| 39 |
|
| 40 |
const cookieHeader = request.headers.get('Cookie');
|
|
|
|
| 48 |
const options: StreamingOptions = {
|
| 49 |
toolChoice: 'none',
|
| 50 |
apiKeys,
|
| 51 |
+
model,
|
| 52 |
onFinish: async ({ text: content, finishReason }) => {
|
| 53 |
if (finishReason !== 'length') {
|
| 54 |
return stream.close();
|
app/utils/logger.ts
CHANGED
|
@@ -11,7 +11,7 @@ interface Logger {
|
|
| 11 |
setLevel: (level: DebugLevel) => void;
|
| 12 |
}
|
| 13 |
|
| 14 |
-
let currentLevel: DebugLevel =
|
| 15 |
|
| 16 |
const isWorker = 'HTMLRewriter' in globalThis;
|
| 17 |
const supportsColor = !isWorker;
|
|
|
|
| 11 |
setLevel: (level: DebugLevel) => void;
|
| 12 |
}
|
| 13 |
|
| 14 |
+
let currentLevel: DebugLevel = import.meta.env.VITE_LOG_LEVEL ?? import.meta.env.DEV ? 'debug' : 'info';
|
| 15 |
|
| 16 |
const isWorker = 'HTMLRewriter' in globalThis;
|
| 17 |
const supportsColor = !isWorker;
|
vite.config.ts
CHANGED
|
@@ -19,8 +19,7 @@ export default defineConfig((config) => {
|
|
| 19 |
future: {
|
| 20 |
v3_fetcherPersist: true,
|
| 21 |
v3_relativeSplatPath: true,
|
| 22 |
-
v3_throwAbortReason: true
|
| 23 |
-
v3_lazyRouteDiscovery: true,
|
| 24 |
},
|
| 25 |
}),
|
| 26 |
UnoCSS(),
|
|
|
|
| 19 |
future: {
|
| 20 |
v3_fetcherPersist: true,
|
| 21 |
v3_relativeSplatPath: true,
|
| 22 |
+
v3_throwAbortReason: true
|
|
|
|
| 23 |
},
|
| 24 |
}),
|
| 25 |
UnoCSS(),
|