Skip to content

Commit 80412c8

Browse files
Merge pull request #9 from TensorBlock/file-upload-support
File upload support
2 parents 2436edb + e336928 commit 80412c8

13 files changed

+886
-89
lines changed

package-lock.json

Lines changed: 20 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"i18next-browser-languagedetector": "^8.0.4",
3131
"katex": "^0.16.21",
3232
"lucide-react": "^0.344.0",
33+
"mime": "^4.0.7",
3334
"prism-themes": "^1.9.0",
3435
"prismjs": "^1.30.0",
3536
"react": "^18.3.1",

src/components/chat/ChatMessageArea.tsx

Lines changed: 98 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,15 @@ import { ChatService } from '../../services/chat-service';
1010
import { AIServiceCapability } from '../../types/capabilities';
1111
import ProviderIcon from '../ui/ProviderIcon';
1212
import { useTranslation } from '../../hooks/useTranslation';
13+
import FileUploadButton from './FileUploadButton';
14+
import FileAttachmentDisplay from './FileAttachmentDisplay';
1315

1416
interface ChatMessageAreaProps {
1517
activeConversation: Conversation | null;
1618
isLoading: boolean;
1719
error: string | null;
1820
onSendMessage: (content: string) => void;
21+
onSendMessageWithFiles?: (content: string, files: File[]) => void;
1922
onStopStreaming?: () => void;
2023
onRegenerateResponse?: (messageId: string) => void;
2124
onEditMessage?: (messageId: string, newContent: string) => void;
@@ -29,6 +32,7 @@ export const ChatMessageArea: React.FC<ChatMessageAreaProps> = ({
2932
isLoading,
3033
error,
3134
onSendMessage,
35+
onSendMessageWithFiles,
3236
onStopStreaming,
3337
onRegenerateResponse,
3438
onEditMessage,
@@ -48,6 +52,7 @@ export const ChatMessageArea: React.FC<ChatMessageAreaProps> = ({
4852
const [ableToWebSearch, setAbleToWebSearch] = useState(false);
4953
const [webSearchActive, setWebSearchActive] = useState(false);
5054
const [isWebSearchPreviewEnabled, setIsWebSearchPreviewEnabled] = useState(false);
55+
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
5156

5257
// Scroll to bottom when messages change
5358
useEffect(() => {
@@ -93,28 +98,31 @@ export const ChatMessageArea: React.FC<ChatMessageAreaProps> = ({
9398
}
9499
}, [isCurrentlyStreaming]);
95100

96-
const handleSubmit = (e: FormEvent) => {
97-
e.preventDefault();
98-
99-
if (!inputValue.trim() || isLoading || isCurrentlyStreaming) return;
100-
101-
onSendMessage(inputValue);
102-
103-
setInput('');
104-
105-
const textarea = inputRef.current;
106-
if(!textarea) return;
107-
// Calculate new height based on scrollHeight, with min and max constraints
108-
const minHeight = 36; // Approx height for 1 row
101+
// Handle file selection
102+
const handleFilesSelected = (files: File[]) => {
103+
setSelectedFiles([...selectedFiles, ...files]);
104+
};
109105

110-
textarea.style.height = `${minHeight}px`;
106+
// Remove a selected file
107+
const handleRemoveFile = (index: number) => {
108+
const newFiles = [...selectedFiles];
109+
newFiles.splice(index, 1);
110+
setSelectedFiles(newFiles);
111111
};
112112

113-
const handleStopStreaming = () => {
114-
if (onStopStreaming) {
115-
onStopStreaming();
116-
isCurrentlyStreaming = false;
113+
// Handle form submission with files
114+
const handleFormSubmit = (e: FormEvent) => {
115+
e.preventDefault();
116+
if (isLoading || isCurrentlyStreaming || !inputValue.trim()) return;
117+
118+
if (selectedFiles.length > 0 && onSendMessageWithFiles) {
119+
onSendMessageWithFiles(inputValue, selectedFiles);
120+
setSelectedFiles([]);
121+
} else {
122+
onSendMessage(inputValue);
117123
}
124+
125+
setInput('');
118126
};
119127

120128
// Handle regenerate response
@@ -161,6 +169,12 @@ export const ChatMessageArea: React.FC<ChatMessageAreaProps> = ({
161169
});
162170
};
163171

172+
const handleStopStreaming = () => {
173+
if (onStopStreaming) {
174+
onStopStreaming();
175+
}
176+
};
177+
164178
// Placeholder error handler for other actions
165179
// const handleActionError = (action: string) => {
166180
// console.error(`Function not implemented yet: ${action}`);
@@ -275,6 +289,35 @@ export const ChatMessageArea: React.FC<ChatMessageAreaProps> = ({
275289
// Check if there's a streaming message
276290
const hasStreamingMessage = Array.from(activeConversation.messages.values()).some(m => m.messageId.startsWith('streaming-'));
277291

292+
const webSearchElement = isWebSearchPreviewEnabled ? (
293+
ableToWebSearch ? (
294+
<button
295+
type="button"
296+
onClick={handleToggleWebSearch}
297+
className={`flex items-center justify-center w-fit h-8 p-2 transition-all duration-200 rounded-full outline outline-2 hover:outline
298+
${webSearchActive ? 'bg-blue-50 outline-blue-300 hover:bg-blue-200 hover:outline hover:outline-blue-500' : 'bg-white outline-gray-100 hover:bg-blue-50 hover:outline hover:outline-blue-300'}`}
299+
aria-label="Toggle Web Search"
300+
title="Toggle Web Search"
301+
>
302+
<Globe className={`mr-1 ${webSearchActive ? 'text-blue-500' : 'text-gray-400'} transition-all duration-200`} size={20} />
303+
<span className={`text-sm font-light ${webSearchActive ? 'text-blue-500' : 'text-gray-400'} transition-all duration-200`}>Web Search</span>
304+
</button>
305+
)
306+
:
307+
(
308+
<button
309+
type="button"
310+
className={`flex items-center justify-center bg-gray-100 w-fit h-8 p-2 ml-2 transition-all duration-200 rounded-full cursor-not-allowed`}
311+
aria-label="Toggle Web Search"
312+
title="Toggle Web Search"
313+
>
314+
<Globe className={`mr-1 text-gray-400 transition-all duration-200`} size={20} />
315+
<span className={`text-sm font-light text-gray-400 transition-all duration-200`}>Web Search (Not available)</span>
316+
</button>
317+
)
318+
)
319+
:<></>;
320+
278321
return (
279322
<div className="flex flex-col w-full h-full max-w-full">
280323
{/* Messages area */}
@@ -325,7 +368,7 @@ export const ChatMessageArea: React.FC<ChatMessageAreaProps> = ({
325368
return (
326369
<div
327370
key={message.messageId}
328-
className={`flex flex-col ${isUserMessage ? 'items-end' : 'items-start'}`}
371+
className={`flex flex-col h-fit ${isUserMessage ? 'items-end' : 'items-start'}`}
329372
onMouseEnter={() => setHoveredMessageId(message.messageId)}
330373
onMouseLeave={() => setHoveredMessageId(null)}
331374
>
@@ -388,12 +431,12 @@ export const ChatMessageArea: React.FC<ChatMessageAreaProps> = ({
388431
}`}
389432
>
390433
{isUserMessage ? (
391-
<MarkdownContent content={message.content} />
434+
<MarkdownContent content={message.content} isUserMessage={true} />
392435
) : (
393436
(message.content.length === 0 || MessageHelper.MessageContentToText(message.content).length === 0) ? (
394437
<div className="w-4 h-4 bg-blue-600 rounded-full animate-bounce"></div>
395438
) : (
396-
<MarkdownContent content={message.content} />
439+
<MarkdownContent content={message.content} isUserMessage={false} />
397440
)
398441
)}
399442
</div>
@@ -438,67 +481,66 @@ export const ChatMessageArea: React.FC<ChatMessageAreaProps> = ({
438481
</div>
439482

440483
{/* Input form */}
441-
<form onSubmit={handleSubmit}
484+
<form onSubmit={handleFormSubmit}
442485
onClick={() => {
443486
inputRef.current?.focus();
444487
}}
445488
onFocus={() => {
446489
inputRef.current?.focus();
447490
}}
448-
className={`relative flex ${isWebSearchPreviewEnabled ? 'flex-col' : 'flex-row justify-stretch items-center'} gap-2 px-4 pt-3 pb-2 m-2 mb-4 transition-all duration-200 rounded-lg form-textarea-border cursor-text`}
491+
className={`relative flex flex-col gap-2 h-fit px-4 pt-3 pb-2 m-2 mb-4 transition-all duration-200 rounded-lg form-textarea-border cursor-text`}
449492
>
493+
{/* Selected Files Display */}
494+
{selectedFiles.length > 0 && (
495+
<div className="flex flex-row w-full gap-1 mb-2">
496+
{selectedFiles.map((file, index) => (
497+
<FileAttachmentDisplay
498+
key={`${file.name}-${index}`}
499+
file={file}
500+
isUser={true}
501+
showRemoveButton={true}
502+
onRemove={() => handleRemoveFile(index)}
503+
/>
504+
))}
505+
</div>
506+
)}
507+
450508
<textarea
451509
ref={inputRef}
452510
value={inputValue}
453511
onChange={(e) => {handleInputChanged(e);}}
454512
onKeyDown={(e) => {
455513
if(e.key === 'Enter' && !e.shiftKey) {
456514
e.preventDefault();
457-
handleSubmit(e);
515+
handleFormSubmit(e);
458516
}
459517
}}
460518
placeholder={t('chat.typeMessage')}
461-
className="flex-1 w-[100%] px-2 pt-1 pb-2 resize-none focus:outline-none"
519+
className="w-[100%] resize-none focus:outline-none"
462520
disabled={isLoading}
463521
inputMode='text'
464522
rows={1}
465523
style={{ minHeight: '36px', maxHeight: '108px', height: '36px', overflow: 'auto' }}
466524
></textarea>
467525

468-
<div className="flex flex-row items-end justify-between h-full px-1">
526+
<div className="flex flex-row items-center justify-between flex-1 h-full gap-2 px-1">
527+
<div className='flex flex-row items-center h-full gap-2'>
528+
{/* File upload button */}
529+
{onSendMessageWithFiles && (
530+
<FileUploadButton
531+
onFilesSelected={handleFilesSelected}
532+
disabled={isLoading || isCurrentlyStreaming}
533+
/>
534+
)}
535+
</div>
536+
537+
{/* Web search element */}
469538
{
470-
isWebSearchPreviewEnabled ? (
471-
ableToWebSearch ? (
472-
<button
473-
type="button"
474-
onClick={handleToggleWebSearch}
475-
className={`flex items-center justify-center w-fit h-8 p-2 transition-all duration-200 rounded-full outline outline-2 hover:outline
476-
${webSearchActive ? 'bg-blue-50 outline-blue-300 hover:bg-blue-200 hover:outline hover:outline-blue-500' : 'bg-white outline-gray-100 hover:bg-blue-50 hover:outline hover:outline-blue-300'}`}
477-
aria-label="Toggle Web Search"
478-
title="Toggle Web Search"
479-
>
480-
<Globe className={`mr-1 ${webSearchActive ? 'text-blue-500' : 'text-gray-400'} transition-all duration-200`} size={20} />
481-
<span className={`text-sm font-light ${webSearchActive ? 'text-blue-500' : 'text-gray-400'} transition-all duration-200`}>Web Search</span>
482-
</button>
483-
)
484-
:
485-
(
486-
<button
487-
type="button"
488-
className={`flex items-center justify-center bg-gray-100 w-fit h-8 p-2 ml-2 transition-all duration-200 rounded-full cursor-not-allowed`}
489-
aria-label="Toggle Web Search"
490-
title="Toggle Web Search"
491-
>
492-
<Globe className={`mr-1 text-gray-400 transition-all duration-200`} size={20} />
493-
<span className={`text-sm font-light text-gray-400 transition-all duration-200`}>Web Search (Not available)</span>
494-
</button>
495-
)
496-
)
497-
:<></>
539+
webSearchElement
498540
}
499541

500-
<span className={`flex-1 hidden text-xs text-center pt-4 text-gray-300 md:block truncate ${isWebSearchPreviewEnabled ? 'pr-6 lg:pr-12' : ''}`}>
501-
{isWebSearchPreviewEnabled ? t('chat.pressShiftEnterToChangeLines') : ''}
542+
<span className={`flex-1 hidden text-xs text-center pt-4 text-gray-300 md:block truncate pr-6 lg:pr-12`}>
543+
{t('chat.pressShiftEnterToChangeLines')}
502544
</span>
503545

504546
{isCurrentlyStreaming || hasStreamingMessage ? (

0 commit comments

Comments
 (0)