Selaa lähdekoodia

feat: clipboard paste (#1663)

Yuhao 1 vuosi sitten
vanhempi
commit
faa88aafe8

+ 3 - 1
web/app/components/app/chat/index.tsx

@@ -23,7 +23,7 @@ import type { DataSet } from '@/models/datasets'
 import ChatImageUploader from '@/app/components/base/image-uploader/chat-image-uploader'
 import ImageList from '@/app/components/base/image-uploader/image-list'
 import { TransferMethod, type VisionFile, type VisionSettings } from '@/types/app'
-import { useImageFiles } from '@/app/components/base/image-uploader/hooks'
+import { useClipboardUploader, useImageFiles } from '@/app/components/base/image-uploader/hooks'
 
 export type IChatProps = {
   configElem?: React.ReactNode
@@ -101,6 +101,7 @@ const Chat: FC<IChatProps> = ({
     onImageLinkLoadSuccess,
     onClear,
   } = useImageFiles()
+  const { onPaste } = useClipboardUploader({ onUpload, visionConfig, files })
   const isUseInputMethod = useRef(false)
 
   const [query, setQuery] = React.useState('')
@@ -305,6 +306,7 @@ const Chat: FC<IChatProps> = ({
                 onChange={handleContentChange}
                 onKeyUp={handleKeyUp}
                 onKeyDown={handleKeyDown}
+                onPaste={onPaste}
                 autoSize
               />
               <div className="absolute bottom-2 right-2 flex items-center h-8">

+ 82 - 2
web/app/components/base/image-uploader/hooks.ts

@@ -1,9 +1,11 @@
-import { useMemo, useRef, useState } from 'react'
+import { useCallback, useMemo, useRef, useState } from 'react'
+import type { ClipboardEvent } from 'react'
 import { useParams } from 'next/navigation'
 import { useTranslation } from 'react-i18next'
 import { imageUpload } from './utils'
 import { useToastContext } from '@/app/components/base/toast'
-import type { ImageFile } from '@/types/app'
+import { ALLOW_FILE_EXTENSIONS, TransferMethod } from '@/types/app'
+import type { ImageFile, VisionSettings } from '@/types/app'
 
 export const useImageFiles = () => {
   const params = useParams()
@@ -108,3 +110,81 @@ export const useImageFiles = () => {
     onClear: handleClear,
   }
 }
+
+type useClipboardUploaderProps = {
+  files: ImageFile[]
+  visionConfig?: VisionSettings
+  onUpload: (imageFile: ImageFile) => void
+}
+
+export const useClipboardUploader = ({ visionConfig, onUpload, files }: useClipboardUploaderProps) => {
+  const { notify } = useToastContext()
+  const params = useParams()
+  const { t } = useTranslation()
+
+  const handleClipboardPaste = useCallback((e: ClipboardEvent<HTMLTextAreaElement>) => {
+    if (!visionConfig || !visionConfig.enabled)
+      return
+
+    const disabled = files.length >= visionConfig.number_limits
+
+    if (disabled)
+      // TODO: leave some warnings?
+      return
+
+    const file = e.clipboardData?.files[0]
+
+    if (!file || !ALLOW_FILE_EXTENSIONS.includes(file.type.split('/')[1]))
+      return
+
+    const limit = +visionConfig.image_file_size_limit!
+
+    if (file.size > limit * 1024 * 1024) {
+      notify({ type: 'error', message: t('common.imageUploader.uploadFromComputerLimit', { size: limit }) })
+      return
+    }
+
+    const reader = new FileReader()
+    reader.addEventListener(
+      'load',
+      () => {
+        const imageFile = {
+          type: TransferMethod.local_file,
+          _id: `${Date.now()}`,
+          fileId: '',
+          file,
+          url: reader.result as string,
+          base64Url: reader.result as string,
+          progress: 0,
+        }
+        onUpload(imageFile)
+        imageUpload({
+          file: imageFile.file,
+          onProgressCallback: (progress) => {
+            onUpload({ ...imageFile, progress })
+          },
+          onSuccessCallback: (res) => {
+            onUpload({ ...imageFile, fileId: res.id, progress: 100 })
+          },
+          onErrorCallback: () => {
+            notify({ type: 'error', message: t('common.imageUploader.uploadFromComputerUploadError') })
+            onUpload({ ...imageFile, progress: -1 })
+          },
+        }, !!params.token)
+      },
+      false,
+    )
+    reader.addEventListener(
+      'error',
+      () => {
+        notify({ type: 'error', message: t('common.imageUploader.uploadFromComputerReadError') })
+      },
+      false,
+    )
+    reader.readAsDataURL(file)
+  }, [visionConfig, files.length, notify, t, onUpload, params.token])
+
+  return {
+    onPaste: handleClipboardPaste,
+  }
+}

+ 2 - 2
web/app/components/base/image-uploader/uploader.tsx

@@ -4,7 +4,7 @@ import { useParams } from 'next/navigation'
 import { useTranslation } from 'react-i18next'
 import { imageUpload } from './utils'
 import type { ImageFile } from '@/types/app'
-import { TransferMethod } from '@/types/app'
+import { ALLOW_FILE_EXTENSIONS, TransferMethod } from '@/types/app'
 import { useToastContext } from '@/app/components/base/toast'
 
 type UploaderProps = {
@@ -90,7 +90,7 @@ const Uploader: FC<UploaderProps> = ({
         `}
         onClick={e => (e.target as HTMLInputElement).value = ''}
         type='file'
-        accept='.png, .jpg, .jpeg, .webp, .gif'
+        accept={ALLOW_FILE_EXTENSIONS.map(ext => `.${ext}`).join(',')}
         onChange={handleChange}
         disabled={disabled}
       />

+ 2 - 0
web/types/app.ts

@@ -297,6 +297,8 @@ export enum TransferMethod {
   remote_url = 'remote_url',
 }
 
+export const ALLOW_FILE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'webp', 'gif']
+
 export type VisionSettings = {
   enabled: boolean
   number_limits: number