@@ -47,6 +47,7 @@ import { useAttachments } from '@/hooks/useAttachments'
4747import { toast } from 'sonner'
4848import { PlatformFeatures } from '@/lib/platform/const'
4949import { PlatformFeature } from '@/lib/platform/types'
50+ import { isPlatformTauri } from '@/lib/platform/utils'
5051
5152import {
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