mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-28 21:42:27 +08:00
feat(video): add image reference upload with validation
implement image reference upload functionality in video panel add validation for file format and size (max 5MB) replace lodash merge with custom deepUpdate utility add error messages for invalid uploads
This commit is contained in:
parent
788b170f98
commit
12323375a5
@ -4659,6 +4659,10 @@
|
||||
},
|
||||
"input_reference": {
|
||||
"add": {
|
||||
"error": {
|
||||
"format": "Not a image",
|
||||
"size": "This image is too large. It should be under 5MB."
|
||||
},
|
||||
"tooltip": "Add image reference"
|
||||
}
|
||||
},
|
||||
|
||||
@ -5,9 +5,9 @@ import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
|
||||
import { useProvider } from '@renderer/hooks/useProvider'
|
||||
import { SystemProviderIds } from '@renderer/types'
|
||||
import { CreateVideoParams } from '@renderer/types/video'
|
||||
import { deepUpdate } from '@renderer/utils/deepUpdate'
|
||||
import { isVideoModel } from '@renderer/utils/model/video'
|
||||
import { DeepPartial } from 'ai'
|
||||
import { merge } from 'lodash'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
@ -33,7 +33,7 @@ export const VideoPage = () => {
|
||||
})
|
||||
|
||||
const updateParams = useCallback((update: DeepPartial<Omit<CreateVideoParams, 'type'>>) => {
|
||||
setParams((prev) => merge({}, prev, update))
|
||||
setParams((prev) => deepUpdate<CreateVideoParams>(prev, update))
|
||||
}, [])
|
||||
|
||||
const updateModelId = useCallback(
|
||||
|
||||
@ -1,13 +1,15 @@
|
||||
import { Button, Skeleton, Textarea, Tooltip } from '@heroui/react'
|
||||
import { Button, cn, Image, Skeleton, Textarea, Tooltip } from '@heroui/react'
|
||||
import { loggerService } from '@logger'
|
||||
import { useAddOpenAIVideo } from '@renderer/hooks/video/useAddOpenAIVideo'
|
||||
import { createVideo } from '@renderer/services/ApiService'
|
||||
import { Provider } from '@renderer/types'
|
||||
import { CreateVideoParams, Video } from '@renderer/types/video'
|
||||
import { getErrorMessage } from '@renderer/utils'
|
||||
import { MB } from '@shared/config/constant'
|
||||
import { DeepPartial } from 'ai'
|
||||
import { ArrowUp, ImageIcon } from 'lucide-react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { isEmpty } from 'lodash'
|
||||
import { ArrowUp, CircleXIcon, ImageIcon } from 'lucide-react'
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { VideoViewer } from './VideoViewer'
|
||||
@ -23,11 +25,18 @@ const logger = loggerService.withContext('VideoPanel')
|
||||
|
||||
export const VideoPanel = ({ provider, video, params, updateParams }: VideoPanelProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const addOpenAIVideo = useAddOpenAIVideo(provider.id)
|
||||
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const inputReference = params.params.input_reference
|
||||
|
||||
const couldCreateVideo = useMemo(
|
||||
() => !isProcessing && !isEmpty(params.params.prompt),
|
||||
[isProcessing, params.params.prompt]
|
||||
)
|
||||
const handleCreateVideo = useCallback(async () => {
|
||||
if (isProcessing) return
|
||||
if (!couldCreateVideo) return
|
||||
setIsProcessing(true)
|
||||
try {
|
||||
const result = await createVideo(params)
|
||||
@ -44,10 +53,49 @@ export const VideoPanel = ({ provider, video, params, updateParams }: VideoPanel
|
||||
} finally {
|
||||
setIsProcessing(false)
|
||||
}
|
||||
}, [addOpenAIVideo, isProcessing, params, t])
|
||||
}, [addOpenAIVideo, couldCreateVideo, params, t])
|
||||
|
||||
const handleUploadFile = useCallback(() => {
|
||||
fileInputRef.current?.click()
|
||||
}, [])
|
||||
|
||||
const setPrompt = useCallback((value: string) => updateParams({ params: { prompt: value } }), [updateParams])
|
||||
|
||||
const UploadImageReferenceButton = useCallback(() => {
|
||||
const content = inputReference ? (
|
||||
<div className="group">
|
||||
<Image
|
||||
className="aspect-square max-h-50 max-w-50 object-contain"
|
||||
src={URL.createObjectURL(inputReference as File)}
|
||||
/>
|
||||
<Button
|
||||
variant="light"
|
||||
color="danger"
|
||||
className="absolute top-1 right-1 z-100 h-6 w-6 min-w-0 opacity-0 group-hover:opacity-100"
|
||||
isIconOnly
|
||||
startContent={<CircleXIcon size={16} className="text-danger" />}
|
||||
onPress={() => updateParams({ params: { input_reference: undefined } })}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
t('video.input_reference.add.tooltip')
|
||||
)
|
||||
return (
|
||||
<>
|
||||
<Tooltip content={content} closeDelay={0}>
|
||||
<Button
|
||||
variant="light"
|
||||
startContent={<ImageIcon size={16} className={cn(inputReference ? 'text-primary' : undefined)} />}
|
||||
isIconOnly
|
||||
className="h-6 w-6 min-w-0"
|
||||
isDisabled={isProcessing}
|
||||
onPress={handleUploadFile}
|
||||
/>
|
||||
</Tooltip>
|
||||
</>
|
||||
)
|
||||
}, [handleUploadFile, isProcessing, inputReference, t, updateParams])
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col p-2">
|
||||
<div className="m-8 flex-1">
|
||||
@ -73,18 +121,30 @@ export const VideoPanel = ({ provider, video, params, updateParams }: VideoPanel
|
||||
/>
|
||||
<div className="absolute bottom-0 flex w-full items-end justify-between p-2">
|
||||
<div className="flex">
|
||||
<Tooltip content={t('video.input_reference.add.tooltip')} closeDelay={0}>
|
||||
<Button
|
||||
variant="light"
|
||||
startContent={<ImageIcon size={16} />}
|
||||
isIconOnly
|
||||
className="h-6 w-6 min-w-0"
|
||||
isDisabled={isProcessing}
|
||||
onPress={() => {
|
||||
window.toast.info('Not implemented')
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<UploadImageReferenceButton />
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
const files = e.target.files
|
||||
if (files && files.length > 0) {
|
||||
const file = files[0]
|
||||
if (!file.type.startsWith('image/')) {
|
||||
window.toast.error(t('video.input_reference.add.error.format'))
|
||||
return
|
||||
}
|
||||
const maxSize = 5 * MB
|
||||
if (file.size > maxSize) {
|
||||
window.toast.error(t('video.input_reference.add.error.size'))
|
||||
return
|
||||
}
|
||||
updateParams({ params: { input_reference: file } })
|
||||
} else {
|
||||
updateParams({ params: { input_reference: undefined } })
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Tooltip content={t('common.send')} closeDelay={0}>
|
||||
@ -92,6 +152,7 @@ export const VideoPanel = ({ provider, video, params, updateParams }: VideoPanel
|
||||
color="primary"
|
||||
radius="full"
|
||||
isIconOnly
|
||||
isDisabled={!couldCreateVideo}
|
||||
isLoading={isProcessing}
|
||||
className="h-6 w-6 min-w-0"
|
||||
onPress={handleCreateVideo}>
|
||||
|
||||
35
src/renderer/src/utils/deepUpdate.ts
Normal file
35
src/renderer/src/utils/deepUpdate.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { DeepPartial } from 'ai'
|
||||
import { cloneDeep } from 'lodash'
|
||||
|
||||
/**
|
||||
* Deeply updates an object, allowing undefined to overwrite existing properties, without using `any`
|
||||
* @param target Original object
|
||||
* @param update Update object (may contain undefined)
|
||||
* @returns New object
|
||||
*/
|
||||
export function deepUpdate<T extends object>(target: T, update: DeepPartial<T>): T {
|
||||
const result = cloneDeep(target)
|
||||
for (const key in update) {
|
||||
if (Object.hasOwn(update, key)) {
|
||||
// @ts-ignore it's runtime safe
|
||||
const prev = result[key]
|
||||
const next = update[key]
|
||||
|
||||
if (
|
||||
next &&
|
||||
typeof next === 'object' &&
|
||||
!Array.isArray(next) &&
|
||||
prev &&
|
||||
typeof prev === 'object' &&
|
||||
!Array.isArray(prev)
|
||||
) {
|
||||
// @ts-ignore it's runtime safe
|
||||
result[key] = deepUpdate(prev, next as any)
|
||||
} else {
|
||||
// @ts-ignore it's runtime safe
|
||||
result[key] = next
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user