Skip to content

Commit e5e49cc

Browse files
authored
Merge pull request #6879 from janhq/chore/refactor-filereader
chore: refactor filereader into tauri dialog
2 parents 13d5e0f + 12e73ee commit e5e49cc

File tree

1 file changed

+209
-127
lines changed

1 file changed

+209
-127
lines changed

web-app/src/containers/ChatInput.tsx

Lines changed: 209 additions & 127 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import { useAttachments } from '@/hooks/useAttachments'
4747
import { toast } from 'sonner'
4848
import { PlatformFeatures } from '@/lib/platform/const'
4949
import { PlatformFeature } from '@/lib/platform/types'
50+
import { isPlatformTauri } from '@/lib/platform/utils'
5051

5152
import {
5253
Attachment,
@@ -322,10 +323,6 @@ const ChatInput = ({
322323

323324
const fileInputRef = useRef<HTMLInputElement>(null)
324325

325-
const handleAttachmentClick = () => {
326-
fileInputRef.current?.click()
327-
}
328-
329326
const handleAttachDocsIngest = async () => {
330327
try {
331328
if (!attachmentsEnabled) {
@@ -507,53 +504,52 @@ const ChatInput = ({
507504
return `${val.toFixed(i === 0 ? 0 : 1)} ${units[i]}`
508505
}
509506

510-
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
511-
const files = e.target.files
507+
const processImageFiles = async (files: File[]) => {
508+
const maxSize = 10 * 1024 * 1024 // 10MB in bytes
509+
const newFiles: Attachment[] = []
510+
const duplicates: string[] = []
511+
const oversizedFiles: string[] = []
512+
const invalidTypeFiles: string[] = []
513+
const existingImageNames = new Set(
514+
attachments.filter((a) => a.type === 'image').map((a) => a.name)
515+
)
512516

513-
if (files && files.length > 0) {
514-
const maxSize = 10 * 1024 * 1024 // 10MB in bytes
515-
const newFiles: Attachment[] = []
516-
const duplicates: string[] = []
517-
const existingImageNames = new Set(
518-
attachments.filter((a) => a.type === 'image').map((a) => a.name)
519-
)
517+
const allowedTypes = ['image/jpg', 'image/jpeg', 'image/png']
518+
const validFiles: File[] = []
520519

521-
Array.from(files).forEach((file) => {
522-
// Check for duplicate image names
523-
if (existingImageNames.has(file.name)) {
524-
duplicates.push(file.name)
525-
return
526-
}
520+
Array.from(files).forEach((file) => {
521+
// Check for duplicate image names
522+
if (existingImageNames.has(file.name)) {
523+
duplicates.push(file.name)
524+
return
525+
}
527526

528-
// Check file size
529-
if (file.size > maxSize) {
530-
setMessage(`File is too large. Maximum size is 10MB.`)
531-
// Reset file input to allow re-uploading
532-
if (fileInputRef.current) {
533-
fileInputRef.current.value = ''
534-
}
535-
return
536-
}
527+
// Check file size
528+
if (file.size > maxSize) {
529+
oversizedFiles.push(file.name)
530+
return
531+
}
537532

538-
// Get file type - use extension as fallback if MIME type is incorrect
539-
const detectedType = file.type || getFileTypeFromExtension(file.name)
540-
const actualType = getFileTypeFromExtension(file.name) || detectedType
533+
// Get file type - use extension as fallback if MIME type is incorrect
534+
const detectedType = file.type || getFileTypeFromExtension(file.name)
535+
const actualType = getFileTypeFromExtension(file.name) || detectedType
541536

542-
// Check file type - images only
543-
const allowedTypes = ['image/jpg', 'image/jpeg', 'image/png']
537+
// Check file type - images only
538+
if (!allowedTypes.includes(actualType)) {
539+
invalidTypeFiles.push(file.name)
540+
return
541+
}
544542

545-
if (!allowedTypes.includes(actualType)) {
546-
setMessage(
547-
`File attachments not supported currently. Only JPEG, JPG, and PNG files are allowed.`
548-
)
549-
// Reset file input to allow re-uploading
550-
if (fileInputRef.current) {
551-
fileInputRef.current.value = ''
552-
}
553-
return
554-
}
543+
validFiles.push(file)
544+
})
545+
546+
// Process valid files
547+
for (const file of validFiles) {
548+
const detectedType = file.type || getFileTypeFromExtension(file.name)
549+
const actualType = getFileTypeFromExtension(file.name) || detectedType
555550

556-
const reader = new FileReader()
551+
const reader = new FileReader()
552+
await new Promise<void>((resolve) => {
557553
reader.onload = () => {
558554
const result = reader.result
559555
if (typeof result === 'string') {
@@ -566,102 +562,188 @@ const ChatInput = ({
566562
dataUrl: result,
567563
})
568564
newFiles.push(att)
569-
// Update state
570-
if (
571-
newFiles.length ===
572-
Array.from(files).filter((f) => {
573-
const fType = getFileTypeFromExtension(f.name) || f.type
574-
return (
575-
f.size <= maxSize &&
576-
allowedTypes.includes(fType) &&
577-
!existingImageNames.has(f.name)
565+
}
566+
resolve()
567+
}
568+
reader.readAsDataURL(file)
569+
})
570+
}
571+
572+
// Update state and ingest
573+
if (newFiles.length > 0) {
574+
setAttachments((prev) => {
575+
const updated = [...prev, ...newFiles]
576+
return updated
577+
})
578+
579+
// If thread exists, ingest images immediately
580+
if (currentThreadId) {
581+
void (async () => {
582+
for (const img of newFiles) {
583+
try {
584+
// Mark as processing
585+
setAttachments((prev) =>
586+
prev.map((a) =>
587+
a.name === img.name && a.type === 'image'
588+
? { ...a, processing: true }
589+
: a
578590
)
579-
}).length
580-
) {
581-
if (newFiles.length > 0) {
582-
setAttachments((prev) => {
583-
const updated = [...prev, ...newFiles]
584-
return updated
585-
})
586-
587-
// If thread exists, ingest images immediately
588-
if (currentThreadId) {
589-
void (async () => {
590-
for (const img of newFiles) {
591-
try {
592-
// Mark as processing
593-
setAttachments((prev) =>
594-
prev.map((a) =>
595-
a.name === img.name && a.type === 'image'
596-
? { ...a, processing: true }
597-
: a
598-
)
599-
)
600-
601-
const result = await serviceHub
602-
.uploads()
603-
.ingestImage(currentThreadId, img)
604-
605-
if (result?.id) {
606-
// Mark as processed with ID
607-
setAttachments((prev) =>
608-
prev.map((a) =>
609-
a.name === img.name && a.type === 'image'
610-
? {
611-
...a,
612-
processing: false,
613-
processed: true,
614-
id: result.id,
615-
}
616-
: a
617-
)
618-
)
619-
} else {
620-
throw new Error('No ID returned from image ingestion')
621-
}
622-
} catch (error) {
623-
console.error('Failed to ingest image:', error)
624-
// Remove failed image
625-
setAttachments((prev) =>
626-
prev.filter(
627-
(a) => !(a.name === img.name && a.type === 'image')
628-
)
629-
)
630-
toast.error(`Failed to ingest ${img.name}`, {
631-
description:
632-
error instanceof Error
633-
? error.message
634-
: String(error),
635-
})
636-
}
637-
}
638-
})()
639-
}
640-
}
591+
)
641592

642-
if (duplicates.length > 0) {
643-
toast.warning('Some images already attached', {
644-
description: `${duplicates.join(', ')} ${duplicates.length === 1 ? 'is' : 'are'} already in the list`,
645-
})
646-
}
593+
const result = await serviceHub
594+
.uploads()
595+
.ingestImage(currentThreadId, img)
647596

648-
// Reset the file input value to allow re-uploading the same file
649-
if (fileInputRef.current) {
650-
fileInputRef.current.value = ''
651-
setMessage('')
597+
if (result?.id) {
598+
// Mark as processed with ID
599+
setAttachments((prev) =>
600+
prev.map((a) =>
601+
a.name === img.name && a.type === 'image'
602+
? {
603+
...a,
604+
processing: false,
605+
processed: true,
606+
id: result.id,
607+
}
608+
: a
609+
)
610+
)
611+
} else {
612+
throw new Error('No ID returned from image ingestion')
652613
}
614+
} catch (error) {
615+
console.error('Failed to ingest image:', error)
616+
// Remove failed image
617+
setAttachments((prev) =>
618+
prev.filter((a) => !(a.name === img.name && a.type === 'image'))
619+
)
620+
toast.error(`Failed to ingest ${img.name}`, {
621+
description:
622+
error instanceof Error ? error.message : String(error),
623+
})
653624
}
654625
}
655-
}
656-
reader.readAsDataURL(file)
626+
})()
627+
}
628+
}
629+
630+
// Display validation errors
631+
const errors: string[] = []
632+
633+
if (duplicates.length > 0) {
634+
toast.warning('Some images already attached', {
635+
description: `${duplicates.join(', ')} ${duplicates.length === 1 ? 'is' : 'are'} already in the list`,
657636
})
658637
}
659638

639+
if (oversizedFiles.length > 0) {
640+
errors.push(
641+
`File${oversizedFiles.length > 1 ? 's' : ''} too large (max 10MB): ${oversizedFiles.join(', ')}`
642+
)
643+
}
644+
645+
if (invalidTypeFiles.length > 0) {
646+
errors.push(
647+
`Invalid file type${invalidTypeFiles.length > 1 ? 's' : ''} (only JPEG, JPG, PNG allowed): ${invalidTypeFiles.join(', ')}`
648+
)
649+
}
650+
651+
if (errors.length > 0) {
652+
setMessage(errors.join(' | '))
653+
// Reset file input to allow re-uploading
654+
if (fileInputRef.current) {
655+
fileInputRef.current.value = ''
656+
}
657+
} else {
658+
setMessage('')
659+
}
660+
}
661+
662+
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
663+
const files = e.target.files
664+
665+
if (files && files.length > 0) {
666+
void processImageFiles(Array.from(files))
667+
668+
// Reset the file input value to allow re-uploading the same file
669+
if (fileInputRef.current) {
670+
fileInputRef.current.value = ''
671+
}
672+
}
673+
660674
if (textareaRef.current) {
661675
textareaRef.current.focus()
662676
}
663677
}
664678

679+
const handleImagePickerClick = async () => {
680+
if (isPlatformTauri()) {
681+
try {
682+
const selected = await serviceHub.dialog().open({
683+
multiple: true,
684+
filters: [
685+
{
686+
name: 'Images',
687+
extensions: ['jpg', 'jpeg', 'png'],
688+
},
689+
],
690+
})
691+
692+
if (selected) {
693+
const paths = Array.isArray(selected) ? selected : [selected]
694+
const files: File[] = []
695+
696+
for (const path of paths) {
697+
try {
698+
// Use Tauri's convertFileSrc to create a valid URL for the file
699+
const { convertFileSrc } = await import('@tauri-apps/api/core')
700+
const fileUrl = convertFileSrc(path)
701+
702+
// Fetch the file as blob
703+
const response = await fetch(fileUrl)
704+
if (!response.ok) {
705+
throw new Error(`Failed to fetch file: ${response.statusText}`)
706+
}
707+
708+
const blob = await response.blob()
709+
const fileName =
710+
path.split(/[\\/]/).filter(Boolean).pop() || 'image'
711+
const ext = fileName.toLowerCase().split('.').pop()
712+
const mimeType =
713+
ext === 'png'
714+
? 'image/png'
715+
: ext === 'jpg' || ext === 'jpeg'
716+
? 'image/jpeg'
717+
: 'image/jpeg'
718+
719+
const file = new File([blob], fileName, { type: mimeType })
720+
files.push(file)
721+
} catch (error) {
722+
console.error('Failed to read file:', error)
723+
toast.error('Failed to read file', {
724+
description:
725+
error instanceof Error ? error.message : String(error),
726+
})
727+
}
728+
}
729+
730+
if (files.length > 0) {
731+
await processImageFiles(files)
732+
}
733+
}
734+
} catch (error) {
735+
console.error('Failed to open file dialog:', error)
736+
}
737+
738+
if (textareaRef.current) {
739+
textareaRef.current.focus()
740+
}
741+
} else {
742+
// Fallback to input click for web
743+
fileInputRef.current?.click()
744+
}
745+
}
746+
665747
const handleDragEnter = (e: React.DragEvent) => {
666748
e.preventDefault()
667749
e.stopPropagation()
@@ -1026,7 +1108,7 @@ const ChatInput = ({
10261108
<TooltipTrigger asChild>
10271109
<div
10281110
className="h-7 p-1 flex items-center justify-center rounded-sm hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out gap-1"
1029-
onClick={handleAttachmentClick}
1111+
onClick={handleImagePickerClick}
10301112
>
10311113
<IconPhoto
10321114
size={18}

0 commit comments

Comments
 (0)