This commit is contained in:
2025-12-01 17:21:38 +08:00
parent 32fee2b8ab
commit fab8c13cb3
7511 changed files with 996300 additions and 0 deletions

View File

@@ -0,0 +1,37 @@
import type { FC } from 'react'
import { createPortal } from 'react-dom'
import { RiCloseLine } from '@remixicon/react'
type AudioPreviewProps = {
url: string
title: string
onCancel: () => void
}
const AudioPreview: FC<AudioPreviewProps> = ({
url,
title,
onCancel,
}) => {
return createPortal(
<div className='fixed inset-0 z-[1000] flex items-center justify-center bg-black/80 p-8' onClick={e => e.stopPropagation()}>
<div>
<audio controls title={title} autoPlay={false} preload="metadata">
<source
type="audio/mpeg"
src={url}
className='max-h-full max-w-full'
/>
</audio>
</div>
<div
className='absolute right-6 top-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-white/[0.08] backdrop-blur-[2px]'
onClick={onCancel}
>
<RiCloseLine className='h-4 w-4 text-gray-500'/>
</div>
</div>,
document.body,
)
}
export default AudioPreview

View File

@@ -0,0 +1,159 @@
import type { FC } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Uploader from './uploader'
import ImageLinkInput from './image-link-input'
import cn from '@/utils/classnames'
import { ImagePlus } from '@/app/components/base/icons/src/vender/line/images'
import { TransferMethod } from '@/types/app'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { Upload03 } from '@/app/components/base/icons/src/vender/line/general'
import type { ImageFile, VisionSettings } from '@/types/app'
type UploadOnlyFromLocalProps = {
onUpload: (imageFile: ImageFile) => void
disabled?: boolean
limit?: number
}
const UploadOnlyFromLocal: FC<UploadOnlyFromLocalProps> = ({
onUpload,
disabled,
limit,
}) => {
return (
<Uploader onUpload={onUpload} disabled={disabled} limit={limit}>
{hovering => (
<div
className={`
relative flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg
${hovering && 'bg-gray-100'}
`}
>
<ImagePlus className="h-4 w-4 text-gray-500" />
</div>
)}
</Uploader>
)
}
type UploaderButtonProps = {
methods: VisionSettings['transfer_methods']
onUpload: (imageFile: ImageFile) => void
disabled?: boolean
limit?: number
}
const UploaderButton: FC<UploaderButtonProps> = ({
methods,
onUpload,
disabled,
limit,
}) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const hasUploadFromLocal = methods.find(
method => method === TransferMethod.local_file,
)
const handleUpload = (imageFile: ImageFile) => {
onUpload(imageFile)
}
const closePopover = () => setOpen(false)
const handleToggle = () => {
if (disabled)
return
setOpen(v => !v)
}
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement="top-start"
>
<PortalToFollowElemTrigger onClick={handleToggle}>
<button
type="button"
disabled={disabled}
className="relative flex h-8 w-8 items-center justify-center rounded-lg enabled:hover:bg-gray-100 disabled:cursor-not-allowed"
>
<ImagePlus className="h-4 w-4 text-gray-500" />
</button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-50">
<div className="w-[260px] rounded-lg border-[0.5px] border-gray-200 bg-white p-2 shadow-lg">
<ImageLinkInput onUpload={handleUpload} disabled={disabled} />
{hasUploadFromLocal && (
<>
<div className="mt-2 flex items-center px-2 text-xs font-medium text-gray-400">
<div className="mr-3 h-px w-[93px] bg-gradient-to-l from-[#F3F4F6]" />
OR
<div className="ml-3 h-px w-[93px] bg-gradient-to-r from-[#F3F4F6]" />
</div>
<Uploader
onUpload={handleUpload}
limit={limit}
closePopover={closePopover}
>
{hovering => (
<div
className={cn(
'flex h-8 cursor-pointer items-center justify-center rounded-lg text-[13px] font-medium text-[#155EEF]',
hovering && 'bg-primary-50',
)}
>
<Upload03 className="mr-1 h-4 w-4" />
{t('common.imageUploader.uploadFromComputer')}
</div>
)}
</Uploader>
</>
)}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
type ChatImageUploaderProps = {
settings: VisionSettings
onUpload: (imageFile: ImageFile) => void
disabled?: boolean
}
const ChatImageUploader: FC<ChatImageUploaderProps> = ({
settings,
onUpload,
disabled,
}) => {
const onlyUploadLocal
= settings.transfer_methods.length === 1
&& settings.transfer_methods[0] === TransferMethod.local_file
if (onlyUploadLocal) {
return (
<UploadOnlyFromLocal
onUpload={onUpload}
disabled={disabled}
limit={+settings.image_file_size_limit!}
/>
)
}
return (
<UploaderButton
methods={settings.transfer_methods}
onUpload={onUpload}
disabled={disabled}
limit={+settings.image_file_size_limit!}
/>
)
}
export default ChatImageUploader

View File

@@ -0,0 +1,272 @@
import { useCallback, useMemo, useRef, useState } from 'react'
import type { ClipboardEvent } from 'react'
import { useParams } from 'next/navigation'
import { useTranslation } from 'react-i18next'
import { getImageUploadErrorMessage, imageUpload } from './utils'
import { useToastContext } from '@/app/components/base/toast'
import { ALLOW_FILE_EXTENSIONS, TransferMethod } from '@/types/app'
import type { ImageFile, VisionSettings } from '@/types/app'
export const useImageFiles = () => {
const params = useParams()
const { t } = useTranslation()
const { notify } = useToastContext()
const [files, setFiles] = useState<ImageFile[]>([])
const filesRef = useRef<ImageFile[]>([])
const handleUpload = (imageFile: ImageFile) => {
const files = filesRef.current
const index = files.findIndex(file => file._id === imageFile._id)
if (index > -1) {
const currentFile = files[index]
const newFiles = [...files.slice(0, index), { ...currentFile, ...imageFile }, ...files.slice(index + 1)]
setFiles(newFiles)
filesRef.current = newFiles
}
else {
const newFiles = [...files, imageFile]
setFiles(newFiles)
filesRef.current = newFiles
}
}
const handleRemove = (imageFileId: string) => {
const files = filesRef.current
const index = files.findIndex(file => file._id === imageFileId)
if (index > -1) {
const currentFile = files[index]
const newFiles = [...files.slice(0, index), { ...currentFile, deleted: true }, ...files.slice(index + 1)]
setFiles(newFiles)
filesRef.current = newFiles
}
}
const handleImageLinkLoadError = (imageFileId: string) => {
const files = filesRef.current
const index = files.findIndex(file => file._id === imageFileId)
if (index > -1) {
const currentFile = files[index]
const newFiles = [...files.slice(0, index), { ...currentFile, progress: -1 }, ...files.slice(index + 1)]
filesRef.current = newFiles
setFiles(newFiles)
}
}
const handleImageLinkLoadSuccess = (imageFileId: string) => {
const files = filesRef.current
const index = files.findIndex(file => file._id === imageFileId)
if (index > -1) {
const currentImageFile = files[index]
const newFiles = [...files.slice(0, index), { ...currentImageFile, progress: 100 }, ...files.slice(index + 1)]
filesRef.current = newFiles
setFiles(newFiles)
}
}
const handleReUpload = (imageFileId: string) => {
const files = filesRef.current
const index = files.findIndex(file => file._id === imageFileId)
if (index > -1) {
const currentImageFile = files[index]
imageUpload({
file: currentImageFile.file!,
onProgressCallback: (progress) => {
const newFiles = [...files.slice(0, index), { ...currentImageFile, progress }, ...files.slice(index + 1)]
filesRef.current = newFiles
setFiles(newFiles)
},
onSuccessCallback: (res) => {
const newFiles = [...files.slice(0, index), { ...currentImageFile, fileId: res.id, progress: 100 }, ...files.slice(index + 1)]
filesRef.current = newFiles
setFiles(newFiles)
},
onErrorCallback: (error?: any) => {
const errorMessage = getImageUploadErrorMessage(error, t('common.imageUploader.uploadFromComputerUploadError'), t)
notify({ type: 'error', message: errorMessage })
const newFiles = [...files.slice(0, index), { ...currentImageFile, progress: -1 }, ...files.slice(index + 1)]
filesRef.current = newFiles
setFiles(newFiles)
},
}, !!params.token)
}
}
const handleClear = () => {
setFiles([])
filesRef.current = []
}
const filteredFiles = useMemo(() => {
return files.filter(file => !file.deleted)
}, [files])
return {
files: filteredFiles,
onUpload: handleUpload,
onRemove: handleRemove,
onImageLinkLoadError: handleImageLinkLoadError,
onImageLinkLoadSuccess: handleImageLinkLoadSuccess,
onReUpload: handleReUpload,
onClear: handleClear,
}
}
type useLocalUploaderProps = {
disabled?: boolean
limit?: number
onUpload: (imageFile: ImageFile) => void
}
export const useLocalFileUploader = ({ limit, disabled = false, onUpload }: useLocalUploaderProps) => {
const { notify } = useToastContext()
const params = useParams()
const { t } = useTranslation()
const handleLocalFileUpload = useCallback((file: File) => {
if (disabled) {
// TODO: leave some warnings?
return
}
if (!ALLOW_FILE_EXTENSIONS.includes(file.type.split('/')[1]))
return
if (limit && 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: (error?: any) => {
const errorMessage = getImageUploadErrorMessage(error, t('common.imageUploader.uploadFromComputerUploadError'), t)
notify({ type: 'error', message: errorMessage })
onUpload({ ...imageFile, progress: -1 })
},
}, !!params.token)
},
false,
)
reader.addEventListener(
'error',
() => {
notify({ type: 'error', message: t('common.imageUploader.uploadFromComputerReadError') })
},
false,
)
reader.readAsDataURL(file)
}, [disabled, limit, notify, t, onUpload, params.token])
return { disabled, handleLocalFileUpload }
}
type useClipboardUploaderProps = {
files: ImageFile[]
visionConfig?: VisionSettings
onUpload: (imageFile: ImageFile) => void
}
export const useClipboardUploader = ({ visionConfig, onUpload, files }: useClipboardUploaderProps) => {
const allowLocalUpload = visionConfig?.transfer_methods?.includes(TransferMethod.local_file)
const disabled = useMemo(() =>
!visionConfig
|| !visionConfig?.enabled
|| !allowLocalUpload
|| files.length >= visionConfig.number_limits!,
[allowLocalUpload, files.length, visionConfig])
const limit = useMemo(() => visionConfig ? +visionConfig.image_file_size_limit! : 0, [visionConfig])
const { handleLocalFileUpload } = useLocalFileUploader({ limit, onUpload, disabled })
const handleClipboardPaste = useCallback((e: ClipboardEvent<HTMLTextAreaElement>) => {
// reserve native text copy behavior
const file = e.clipboardData?.files[0]
// when copied file, prevent default action
if (file) {
e.preventDefault()
handleLocalFileUpload(file)
}
}, [handleLocalFileUpload])
return {
onPaste: handleClipboardPaste,
}
}
type useDraggableUploaderProps = {
files: ImageFile[]
visionConfig?: VisionSettings
onUpload: (imageFile: ImageFile) => void
}
export const useDraggableUploader = <T extends HTMLElement>({ visionConfig, onUpload, files }: useDraggableUploaderProps) => {
const allowLocalUpload = visionConfig?.transfer_methods?.includes(TransferMethod.local_file)
const disabled = useMemo(() =>
!visionConfig
|| !visionConfig?.enabled
|| !allowLocalUpload
|| files.length >= visionConfig.number_limits!,
[allowLocalUpload, files.length, visionConfig])
const limit = useMemo(() => visionConfig ? +visionConfig.image_file_size_limit! : 0, [visionConfig])
const { handleLocalFileUpload } = useLocalFileUploader({ disabled, onUpload, limit })
const [isDragActive, setIsDragActive] = useState(false)
const handleDragEnter = useCallback((e: React.DragEvent<T>) => {
e.preventDefault()
e.stopPropagation()
if (!disabled)
setIsDragActive(true)
}, [disabled])
const handleDragOver = useCallback((e: React.DragEvent<T>) => {
e.preventDefault()
e.stopPropagation()
}, [])
const handleDragLeave = useCallback((e: React.DragEvent<T>) => {
e.preventDefault()
e.stopPropagation()
setIsDragActive(false)
}, [])
const handleDrop = useCallback((e: React.DragEvent<T>) => {
e.preventDefault()
e.stopPropagation()
setIsDragActive(false)
const file = e.dataTransfer.files[0]
if (!file)
return
handleLocalFileUpload(file)
}, [handleLocalFileUpload])
return {
onDragEnter: handleDragEnter,
onDragOver: handleDragOver,
onDragLeave: handleDragLeave,
onDrop: handleDrop,
isDragActive,
}
}

View File

@@ -0,0 +1,56 @@
import type { FC } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import type { ImageFile } from '@/types/app'
import { TransferMethod } from '@/types/app'
type ImageLinkInputProps = {
onUpload: (imageFile: ImageFile) => void
disabled?: boolean
}
const regex = /^(https?|ftp):\/\//
const ImageLinkInput: FC<ImageLinkInputProps> = ({
onUpload,
disabled,
}) => {
const { t } = useTranslation()
const [imageLink, setImageLink] = useState('')
const handleClick = () => {
if (disabled)
return
const imageFile = {
type: TransferMethod.remote_url,
_id: `${Date.now()}`,
fileId: '',
progress: regex.test(imageLink) ? 0 : -1,
url: imageLink,
}
onUpload(imageFile)
}
return (
<div className='flex h-8 items-center rounded-lg border border-components-panel-border bg-components-panel-bg pl-1.5 pr-1 shadow-xs'>
<input
type="text"
className='mr-0.5 h-[18px] grow appearance-none bg-transparent px-1 text-[13px] text-text-primary outline-none'
value={imageLink}
onChange={e => setImageLink(e.target.value)}
placeholder={t('common.imageUploader.pasteImageLinkInputPlaceholder') || ''}
/>
<Button
variant='primary'
size='small'
disabled={!imageLink || disabled}
onClick={handleClick}
>
{t('common.operation.ok')}
</Button>
</div>
)
}
export default ImageLinkInput

View File

@@ -0,0 +1,182 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useMemo, useState } from 'react'
import ImageList from './image-list'
import ImageLinkInput from './image-link-input'
import type { ImageFile } from '@/types/app'
import { TransferMethod } from '@/types/app'
const SAMPLE_BASE64
= 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAADICAYAAACtWK6eAAAACXBIWXMAAAsSAAALEgHS3X78AAABbElEQVR4nO3SsQkAIBDARMT+V20sTg6LXhWEATnnMHDx4sWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFu2r/H3n4BG518Gr4AAAAASUVORK5CYII='
const createRemoteImage = (
id: string,
progress: number,
url: string,
): ImageFile => ({
type: TransferMethod.remote_url,
_id: id,
fileId: `remote-${id}`,
progress,
url,
})
const createLocalImage = (id: string, progress: number): ImageFile => ({
type: TransferMethod.local_file,
_id: id,
fileId: `local-${id}`,
progress,
url: SAMPLE_BASE64,
base64Url: SAMPLE_BASE64,
})
const initialImages: ImageFile[] = [
createLocalImage('local-initial', 100),
createRemoteImage(
'remote-loading',
40,
'https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=300&q=80',
),
{
...createRemoteImage(
'remote-error',
-1,
'https://example.com/not-an-image.jpg',
),
url: 'https://example.com/not-an-image.jpg',
},
]
const meta = {
title: 'Base/Data Entry/ImageList',
component: ImageList,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Renders thumbnails for uploaded images and manages their states like uploading, error, and deletion.',
},
},
},
argTypes: {
list: { control: false },
onRemove: { control: false },
onReUpload: { control: false },
onImageLinkLoadError: { control: false },
onImageLinkLoadSuccess: { control: false },
},
tags: ['autodocs'],
} satisfies Meta<typeof ImageList>
export default meta
type Story = StoryObj<typeof meta>
const ImageUploaderPlayground = ({ readonly }: Story['args']) => {
const [images, setImages] = useState<ImageFile[]>(() => initialImages)
const activeImages = useMemo(() => images.filter(item => !item.deleted), [images])
const handleRemove = (id: string) => {
setImages(prev => prev.map(item => (item._id === id ? { ...item, deleted: true } : item)))
}
const handleReUpload = (id: string) => {
setImages(prev => prev.map((item) => {
if (item._id !== id)
return item
return {
...item,
progress: 60,
}
}))
setTimeout(() => {
setImages(prev => prev.map((item) => {
if (item._id !== id)
return item
return {
...item,
progress: 100,
}
}))
}, 1200)
}
const handleImageLinkLoadSuccess = (id: string) => {
setImages(prev => prev.map(item => (item._id === id ? { ...item, progress: 100 } : item)))
}
const handleImageLinkLoadError = (id: string) => {
setImages(prev => prev.map(item => (item._id === id ? { ...item, progress: -1 } : item)))
}
const handleUploadFromLink = (imageFile: ImageFile) => {
setImages(prev => [
...prev,
{
...imageFile,
fileId: `remote-${imageFile._id}`,
},
])
}
const handleAddLocalImage = () => {
const id = `local-${Date.now()}`
setImages(prev => [
...prev,
createLocalImage(id, 100),
])
}
return (
<div className="flex w-[360px] flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-4">
<div className="flex flex-col gap-2">
<span className="text-xs font-medium uppercase tracking-[0.18em] text-text-tertiary">Add images</span>
<div className="flex items-center gap-2">
<ImageLinkInput onUpload={handleUploadFromLink} disabled={readonly} />
<button
type="button"
className="rounded-md border border-divider-subtle px-2 py-1 text-xs font-medium text-text-secondary hover:bg-state-base-hover disabled:cursor-not-allowed disabled:text-text-tertiary"
onClick={handleAddLocalImage}
disabled={readonly}
>
Simulate local
</button>
</div>
</div>
<ImageList
list={activeImages}
readonly={readonly}
onRemove={handleRemove}
onReUpload={handleReUpload}
onImageLinkLoadSuccess={handleImageLinkLoadSuccess}
onImageLinkLoadError={handleImageLinkLoadError}
/>
<div className="rounded-lg border border-divider-subtle bg-background-default p-2">
<span className="mb-1 block text-[11px] font-semibold uppercase tracking-[0.1em] text-text-tertiary">
Files state
</span>
<pre className="max-h-40 overflow-auto text-[11px] leading-relaxed text-text-tertiary">
{JSON.stringify(activeImages, null, 2)}
</pre>
</div>
</div>
)
}
export const Playground: Story = {
render: args => <ImageUploaderPlayground {...args} />,
args: {
list: [],
},
}
export const ReadonlyList: Story = {
render: args => <ImageUploaderPlayground {...args} />,
args: {
list: [],
},
}

View File

@@ -0,0 +1,143 @@
import type { FC } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
RiCloseLine,
RiLoader2Line,
} from '@remixicon/react'
import cn from '@/utils/classnames'
import { RefreshCcw01 } from '@/app/components/base/icons/src/vender/line/arrows'
import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
import Tooltip from '@/app/components/base/tooltip'
import type { ImageFile } from '@/types/app'
import { TransferMethod } from '@/types/app'
import ImagePreview from '@/app/components/base/image-uploader/image-preview'
type ImageListProps = {
list: ImageFile[]
readonly?: boolean
onRemove?: (imageFileId: string) => void
onReUpload?: (imageFileId: string) => void
onImageLinkLoadSuccess?: (imageFileId: string) => void
onImageLinkLoadError?: (imageFileId: string) => void
}
const ImageList: FC<ImageListProps> = ({
list,
readonly,
onRemove,
onReUpload,
onImageLinkLoadSuccess,
onImageLinkLoadError,
}) => {
const { t } = useTranslation()
const [imagePreviewUrl, setImagePreviewUrl] = useState('')
const handleImageLinkLoadSuccess = (item: ImageFile) => {
if (
item.type === TransferMethod.remote_url
&& onImageLinkLoadSuccess
&& item.progress !== -1
)
onImageLinkLoadSuccess(item._id)
}
const handleImageLinkLoadError = (item: ImageFile) => {
if (item.type === TransferMethod.remote_url && onImageLinkLoadError)
onImageLinkLoadError(item._id)
}
return (
<div className="flex flex-wrap">
{list.map(item => (
<div
key={item._id}
className="group relative mr-1 rounded-lg border-[0.5px] border-black/5"
>
{item.type === TransferMethod.local_file && item.progress !== 100 && (
<>
<div
className="absolute inset-0 z-[1] flex items-center justify-center bg-black/30"
style={{ left: item.progress > -1 ? `${item.progress}%` : 0 }}
>
{item.progress === -1 && (
<RefreshCcw01
className="h-5 w-5 text-white"
onClick={() => onReUpload?.(item._id)}
/>
)}
</div>
{item.progress > -1 && (
<span className="absolute left-[50%] top-[50%] z-[1] translate-x-[-50%] translate-y-[-50%] text-sm text-white mix-blend-lighten">
{item.progress}%
</span>
)}
</>
)}
{item.type === TransferMethod.remote_url && item.progress !== 100 && (
<div
className={`
absolute inset-0 z-[1] flex items-center justify-center rounded-lg border
${item.progress === -1
? 'border-[#DC6803] bg-[#FEF0C7]'
: 'border-transparent bg-black/[0.16]'
}
`}
>
{item.progress > -1 && (
<RiLoader2Line className="h-5 w-5 animate-spin text-white" />
)}
{item.progress === -1 && (
<Tooltip
popupContent={t('common.imageUploader.pasteImageLinkInvalid')}
>
<AlertTriangle className="h-4 w-4 text-[#DC6803]" />
</Tooltip>
)}
</div>
)}
<img
className="h-16 w-16 cursor-pointer rounded-lg border-[0.5px] border-black/5 object-cover"
alt={item.file?.name}
onLoad={() => handleImageLinkLoadSuccess(item)}
onError={() => handleImageLinkLoadError(item)}
src={
item.type === TransferMethod.remote_url
? item.url
: item.base64Url
}
onClick={() =>
item.progress === 100
&& setImagePreviewUrl(
(item.type === TransferMethod.remote_url
? item.url
: item.base64Url) as string,
)
}
/>
{!readonly && (
<button
type="button"
className={cn(
'absolute -right-[9px] -top-[9px] z-10 h-[18px] w-[18px] items-center justify-center',
'rounded-2xl shadow-lg hover:bg-state-base-hover',
item.progress === -1 ? 'flex' : 'hidden group-hover:flex',
)}
onClick={() => onRemove?.(item._id)}
>
<RiCloseLine className="h-3 w-3 text-text-tertiary" />
</button>
)}
</div>
))}
{imagePreviewUrl && (
<ImagePreview
url={imagePreviewUrl}
onCancel={() => setImagePreviewUrl('')}
title=''
/>
)}
</div>
)
}
export default ImageList

View File

@@ -0,0 +1,269 @@
import type { FC } from 'react'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { t } from 'i18next'
import { createPortal } from 'react-dom'
import { RiAddBoxLine, RiCloseLine, RiDownloadCloud2Line, RiFileCopyLine, RiZoomInLine, RiZoomOutLine } from '@remixicon/react'
import { useHotkeys } from 'react-hotkeys-hook'
import Tooltip from '@/app/components/base/tooltip'
import Toast from '@/app/components/base/toast'
import { noop } from 'lodash-es'
type ImagePreviewProps = {
url: string
title: string
onCancel: () => void
onPrev?: () => void
onNext?: () => void
}
const isBase64 = (str: string): boolean => {
try {
return btoa(atob(str)) === str
}
catch {
return false
}
}
const ImagePreview: FC<ImagePreviewProps> = ({
url,
title,
onCancel,
onPrev,
onNext,
}) => {
const [scale, setScale] = useState(1)
const [position, setPosition] = useState({ x: 0, y: 0 })
const [isDragging, setIsDragging] = useState(false)
const imgRef = useRef<HTMLImageElement>(null)
const dragStartRef = useRef({ x: 0, y: 0 })
const [isCopied, setIsCopied] = useState(false)
const openInNewTab = () => {
// Open in a new window, considering the case when the page is inside an iframe
if (url.startsWith('http') || url.startsWith('https')) {
window.open(url, '_blank')
}
else if (url.startsWith('data:image')) {
// Base64 image
const win = window.open()
win?.document.write(`<img src="${url}" alt="${title}" />`)
}
else {
Toast.notify({
type: 'error',
message: `Unable to open image: ${url}`,
})
}
}
const downloadImage = () => {
// Open in a new window, considering the case when the page is inside an iframe
if (url.startsWith('http') || url.startsWith('https')) {
const a = document.createElement('a')
a.href = url
a.target = '_blank'
a.download = title
a.click()
}
else if (url.startsWith('data:image')) {
// Base64 image
const a = document.createElement('a')
a.href = url
a.target = '_blank'
a.download = title
a.click()
}
else {
Toast.notify({
type: 'error',
message: `Unable to open image: ${url}`,
})
}
}
const zoomIn = () => {
setScale(prevScale => Math.min(prevScale * 1.2, 15))
}
const zoomOut = () => {
setScale((prevScale) => {
const newScale = Math.max(prevScale / 1.2, 0.5)
if (newScale === 1)
setPosition({ x: 0, y: 0 }) // Reset position when fully zoomed out
return newScale
})
}
const imageBase64ToBlob = (base64: string, type = 'image/png'): Blob => {
const byteCharacters = atob(base64)
const byteArrays = []
for (let offset = 0; offset < byteCharacters.length; offset += 512) {
const slice = byteCharacters.slice(offset, offset + 512)
const byteNumbers = Array.from({ length: slice.length })
for (let i = 0; i < slice.length; i++)
byteNumbers[i] = slice.charCodeAt(i)
const byteArray = new Uint8Array(byteNumbers as any)
byteArrays.push(byteArray)
}
return new Blob(byteArrays, { type })
}
const imageCopy = useCallback(() => {
const shareImage = async () => {
try {
const base64Data = url.split(',')[1]
const blob = imageBase64ToBlob(base64Data, 'image/png')
await navigator.clipboard.write([
new ClipboardItem({
[blob.type]: blob,
}),
])
setIsCopied(true)
Toast.notify({
type: 'success',
message: t('common.operation.imageCopied'),
})
}
catch (err) {
console.error('Failed to copy image:', err)
const link = document.createElement('a')
link.href = url
link.download = `${title}.png`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
Toast.notify({
type: 'info',
message: t('common.operation.imageDownloaded'),
})
}
}
shareImage()
}, [title, url])
const handleWheel = useCallback((e: React.WheelEvent<HTMLDivElement>) => {
if (e.deltaY < 0)
zoomIn()
else
zoomOut()
}, [])
const handleMouseDown = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (scale > 1) {
setIsDragging(true)
dragStartRef.current = { x: e.clientX - position.x, y: e.clientY - position.y }
}
}, [scale, position])
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (isDragging && scale > 1) {
const deltaX = e.clientX - dragStartRef.current.x
const deltaY = e.clientY - dragStartRef.current.y
// Calculate boundaries
const imgRect = imgRef.current?.getBoundingClientRect()
const containerRect = imgRef.current?.parentElement?.getBoundingClientRect()
if (imgRect && containerRect) {
const maxX = (imgRect.width * scale - containerRect.width) / 2
const maxY = (imgRect.height * scale - containerRect.height) / 2
setPosition({
x: Math.max(-maxX, Math.min(maxX, deltaX)),
y: Math.max(-maxY, Math.min(maxY, deltaY)),
})
}
}
}, [isDragging, scale])
const handleMouseUp = useCallback(() => {
setIsDragging(false)
}, [])
useEffect(() => {
document.addEventListener('mouseup', handleMouseUp)
return () => {
document.removeEventListener('mouseup', handleMouseUp)
}
}, [handleMouseUp])
useHotkeys('esc', onCancel)
useHotkeys('up', zoomIn)
useHotkeys('down', zoomOut)
useHotkeys('left', onPrev || noop)
useHotkeys('right', onNext || noop)
return createPortal(
<div className='image-preview-container fixed inset-0 z-[1000] flex items-center justify-center bg-black/80 p-8'
onClick={e => e.stopPropagation()}
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
style={{ cursor: scale > 1 ? 'move' : 'default' }}
tabIndex={-1}>
{ }
<img
ref={imgRef}
alt={title}
src={isBase64(url) ? `data:image/png;base64,${url}` : url}
className='max-h-full max-w-full'
style={{
transform: `scale(${scale}) translate(${position.x}px, ${position.y}px)`,
transition: isDragging ? 'none' : 'transform 0.2s ease-in-out',
}}
/>
<Tooltip popupContent={t('common.operation.copyImage')}>
<div className='absolute right-48 top-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg'
onClick={imageCopy}>
{isCopied
? <RiFileCopyLine className='h-4 w-4 text-green-500' />
: <RiFileCopyLine className='h-4 w-4 text-gray-500' />}
</div>
</Tooltip>
<Tooltip popupContent={t('common.operation.zoomOut')}>
<div className='absolute right-40 top-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg'
onClick={zoomOut}>
<RiZoomOutLine className='h-4 w-4 text-gray-500' />
</div>
</Tooltip>
<Tooltip popupContent={t('common.operation.zoomIn')}>
<div className='absolute right-32 top-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg'
onClick={zoomIn}>
<RiZoomInLine className='h-4 w-4 text-gray-500' />
</div>
</Tooltip>
<Tooltip popupContent={t('common.operation.download')}>
<div className='absolute right-24 top-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg'
onClick={downloadImage}>
<RiDownloadCloud2Line className='h-4 w-4 text-gray-500' />
</div>
</Tooltip>
<Tooltip popupContent={t('common.operation.openInNewTab')}>
<div className='absolute right-16 top-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg'
onClick={openInNewTab}>
<RiAddBoxLine className='h-4 w-4 text-gray-500' />
</div>
</Tooltip>
<Tooltip popupContent={t('common.operation.cancel')}>
<div
className='absolute right-6 top-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-white/8 backdrop-blur-[2px]'
onClick={onCancel}>
<RiCloseLine className='h-4 w-4 text-gray-500' />
</div>
</Tooltip>
</div>,
document.body,
)
}
export default ImagePreview

View File

@@ -0,0 +1,148 @@
import type { FC } from 'react'
import {
Fragment,
useEffect,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import Uploader from './uploader'
import ImageLinkInput from './image-link-input'
import ImageList from './image-list'
import { useImageFiles } from './hooks'
import { ImagePlus } from '@/app/components/base/icons/src/vender/line/images'
import { Link03 } from '@/app/components/base/icons/src/vender/line/general'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import type { ImageFile, VisionSettings } from '@/types/app'
import { TransferMethod } from '@/types/app'
type PasteImageLinkButtonProps = {
onUpload: (imageFile: ImageFile) => void
disabled?: boolean
}
const PasteImageLinkButton: FC<PasteImageLinkButtonProps> = ({
onUpload,
disabled,
}) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const handleUpload = (imageFile: ImageFile) => {
setOpen(false)
onUpload(imageFile)
}
const handleToggle = () => {
if (disabled)
return
setOpen(v => !v)
}
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='top-start'
>
<PortalToFollowElemTrigger onClick={handleToggle}>
<div className={`
relative flex h-8 items-center justify-center rounded-lg bg-components-button-tertiary-bg px-3 text-xs text-text-tertiary hover:bg-components-button-tertiary-bg-hover
${disabled ? 'cursor-not-allowed' : 'cursor-pointer'}
`}>
<Link03 className='mr-2 h-4 w-4' />
{t('common.imageUploader.pasteImageLink')}
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-10'>
<div className='w-[320px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg p-2 shadow-lg'>
<ImageLinkInput onUpload={handleUpload} />
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
type TextGenerationImageUploaderProps = {
settings: VisionSettings
onFilesChange: (files: ImageFile[]) => void
}
const TextGenerationImageUploader: FC<TextGenerationImageUploaderProps> = ({
settings,
onFilesChange,
}) => {
const { t } = useTranslation()
const {
files,
onUpload,
onRemove,
onImageLinkLoadError,
onImageLinkLoadSuccess,
onReUpload,
} = useImageFiles()
useEffect(() => {
onFilesChange(files)
}, [files])
const localUpload = (
<Uploader
onUpload={onUpload}
disabled={files.length >= settings.number_limits}
limit={+settings.image_file_size_limit!}
>
{
hovering => (
<div className={`
flex h-8 cursor-pointer items-center justify-center rounded-lg
bg-components-button-tertiary-bg px-3 text-xs text-text-tertiary
${hovering && 'hover:bg-components-button-tertiary-bg-hover'}
`}>
<ImagePlus className='mr-2 h-4 w-4' />
{t('common.imageUploader.uploadFromComputer')}
</div>
)
}
</Uploader>
)
const urlUpload = (
<PasteImageLinkButton
onUpload={onUpload}
disabled={files.length >= settings.number_limits}
/>
)
return (
<div>
<div className='mb-1'>
<ImageList
list={files}
onRemove={onRemove}
onReUpload={onReUpload}
onImageLinkLoadError={onImageLinkLoadError}
onImageLinkLoadSuccess={onImageLinkLoadSuccess}
/>
</div>
<div className={`grid gap-1 ${settings.transfer_methods.length === 2 ? 'grid-cols-2' : 'grid-cols-1'}`}>
{
settings.transfer_methods.map((method) => {
if (method === TransferMethod.local_file)
return <Fragment key={TransferMethod.local_file}>{localUpload}</Fragment>
if (method === TransferMethod.remote_url)
return <Fragment key={TransferMethod.remote_url}>{urlUpload}</Fragment>
return null
})
}
</div>
</div>
)
}
export default TextGenerationImageUploader

View File

@@ -0,0 +1,58 @@
import type { ChangeEvent, FC } from 'react'
import { useState } from 'react'
import { useLocalFileUploader } from './hooks'
import type { ImageFile } from '@/types/app'
import { ALLOW_FILE_EXTENSIONS } from '@/types/app'
type UploaderProps = {
children: (hovering: boolean) => React.JSX.Element
onUpload: (imageFile: ImageFile) => void
closePopover?: () => void
limit?: number
disabled?: boolean
}
const Uploader: FC<UploaderProps> = ({
children,
onUpload,
closePopover,
limit,
disabled,
}) => {
const [hovering, setHovering] = useState(false)
const { handleLocalFileUpload } = useLocalFileUploader({
limit,
onUpload,
disabled,
})
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file)
return
handleLocalFileUpload(file)
closePopover?.()
}
return (
<div
className='relative'
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
>
{children(hovering)}
<input
className='absolute inset-0 block w-full cursor-pointer text-[0] opacity-0 disabled:cursor-not-allowed'
onClick={e => ((e.target as HTMLInputElement).value = '')}
type='file'
accept={ALLOW_FILE_EXTENSIONS.map(ext => `.${ext}`).join(',')}
onChange={handleChange}
disabled={disabled}
/>
</div>
)
}
export default Uploader

View File

@@ -0,0 +1,55 @@
import { upload } from '@/service/base'
/**
* Get appropriate error message for image upload errors
* @param error - The error object from upload failure
* @param defaultMessage - Default error message to use if no specific error is matched
* @param t - Translation function
* @returns Localized error message
*/
export const getImageUploadErrorMessage = (error: any, defaultMessage: string, t: (key: string) => string): string => {
const errorCode = error?.response?.code
if (errorCode === 'forbidden')
return error?.response?.message
if (errorCode === 'file_extension_blocked')
return t('common.fileUploader.fileExtensionBlocked')
return defaultMessage
}
type ImageUploadParams = {
file: File
onProgressCallback: (progress: number) => void
onSuccessCallback: (res: { id: string }) => void
onErrorCallback: (error?: any) => void
}
type ImageUpload = (v: ImageUploadParams, isPublic?: boolean, url?: string) => void
export const imageUpload: ImageUpload = ({
file,
onProgressCallback,
onSuccessCallback,
onErrorCallback,
}, isPublic, url) => {
const formData = new FormData()
formData.append('file', file)
const onProgress = (e: ProgressEvent) => {
if (e.lengthComputable) {
const percent = Math.floor(e.loaded / e.total * 100)
onProgressCallback(percent)
}
}
upload({
xhr: new XMLHttpRequest(),
data: formData,
onprogress: onProgress,
}, isPublic, url)
.then((res: { id: string }) => {
onSuccessCallback(res)
})
.catch((error) => {
onErrorCallback(error)
})
}

View File

@@ -0,0 +1,37 @@
import type { FC } from 'react'
import { createPortal } from 'react-dom'
import { RiCloseLine } from '@remixicon/react'
type VideoPreviewProps = {
url: string
title: string
onCancel: () => void
}
const VideoPreview: FC<VideoPreviewProps> = ({
url,
title,
onCancel,
}) => {
return createPortal(
<div className='fixed inset-0 z-[1000] flex items-center justify-center bg-black/80 p-8' onClick={e => e.stopPropagation()}>
<div>
<video controls title={title} autoPlay={false} preload="metadata">
<source
type="video/mp4"
src={url}
className='max-h-full max-w-full'
/>
</video>
</div>
<div
className='absolute right-6 top-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-white/[0.08] backdrop-blur-[2px]'
onClick={onCancel}
>
<RiCloseLine className='h-4 w-4 text-gray-500'/>
</div>
</div>,
document.body,
)
}
export default VideoPreview