mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-28 21:42:27 +08:00
feat(video): add video deletion functionality
implement video deletion for openai provider add i18n strings for deletion states and errors update video list ui to support deletion handle pending states during deletion
This commit is contained in:
parent
2fab33de41
commit
85daceb417
@ -12,7 +12,15 @@ import { loggerService } from '@logger'
|
||||
import { getEnableDeveloperMode } from '@renderer/hooks/useSettings'
|
||||
import { addSpan, endSpan } from '@renderer/services/SpanManagerService'
|
||||
import { StartSpanParams } from '@renderer/trace/types/ModelSpanEntity'
|
||||
import type { Assistant, GenerateImageParams, Model, Provider, RetrieveVideoContentParams } from '@renderer/types'
|
||||
import type {
|
||||
Assistant,
|
||||
DeleteVideoParams,
|
||||
DeleteVideoResult,
|
||||
GenerateImageParams,
|
||||
Model,
|
||||
Provider,
|
||||
RetrieveVideoContentParams
|
||||
} from '@renderer/types'
|
||||
import type { AiSdkModel, StreamTextParams } from '@renderer/types/aiCoreTypes'
|
||||
import {
|
||||
CreateVideoParams,
|
||||
@ -528,6 +536,13 @@ export default class ModernAiProvider {
|
||||
return this.legacyProvider.retrieveVideoContent(params)
|
||||
}
|
||||
|
||||
/**
|
||||
* We manually implement this method before aisdk supports it well
|
||||
*/
|
||||
public async deleteVideo(params: DeleteVideoParams): Promise<DeleteVideoResult> {
|
||||
return this.legacyProvider.deleteVideo(params)
|
||||
}
|
||||
|
||||
public getBaseURL(): string {
|
||||
return this.legacyProvider.getBaseURL()
|
||||
}
|
||||
|
||||
@ -36,7 +36,12 @@ import {
|
||||
OpenAIResponseSdkTool,
|
||||
OpenAIResponseSdkToolCall
|
||||
} from '@renderer/types/sdk'
|
||||
import { CreateVideoParams, RetrieveVideoContentParams, RetrieveVideoParams } from '@renderer/types/video'
|
||||
import {
|
||||
CreateVideoParams,
|
||||
DeleteVideoParams,
|
||||
RetrieveVideoContentParams,
|
||||
RetrieveVideoParams
|
||||
} from '@renderer/types/video'
|
||||
import { addImageFileToContents } from '@renderer/utils/formats'
|
||||
import {
|
||||
isSupportedToolUse,
|
||||
@ -168,6 +173,11 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
return sdk.videos.downloadContent(params.videoId, params.query, params.options)
|
||||
}
|
||||
|
||||
public async deleteVideo(params: DeleteVideoParams): Promise<OpenAI.Videos.VideoDeleteResponse> {
|
||||
const sdk = await this.getSdkInstance()
|
||||
return sdk.videos.delete(params.videoId, params.options)
|
||||
}
|
||||
|
||||
private async handlePdfFile(file: FileMetadata): Promise<OpenAI.Responses.ResponseInputFile | undefined> {
|
||||
if (file.size > 32 * MB) return undefined
|
||||
try {
|
||||
|
||||
@ -5,7 +5,14 @@ import { isDedicatedImageGenerationModel, isFunctionCallingModel } from '@render
|
||||
import { getProviderByModel } from '@renderer/services/AssistantService'
|
||||
import { withSpanResult } from '@renderer/services/SpanManagerService'
|
||||
import { StartSpanParams } from '@renderer/trace/types/ModelSpanEntity'
|
||||
import type { GenerateImageParams, Model, Provider, RetrieveVideoContentParams } from '@renderer/types'
|
||||
import type {
|
||||
DeleteVideoParams,
|
||||
DeleteVideoResult,
|
||||
GenerateImageParams,
|
||||
Model,
|
||||
Provider,
|
||||
RetrieveVideoContentParams
|
||||
} from '@renderer/types'
|
||||
import type { RequestOptions, SdkModel } from '@renderer/types/sdk'
|
||||
import {
|
||||
CreateVideoParams,
|
||||
@ -222,6 +229,18 @@ export default class AiProvider {
|
||||
}
|
||||
}
|
||||
|
||||
public async deleteVideo(params: DeleteVideoParams): Promise<DeleteVideoResult> {
|
||||
if (this.apiClient instanceof OpenAIResponseAPIClient && params.type === 'openai') {
|
||||
const result = await this.apiClient.deleteVideo(params)
|
||||
return {
|
||||
type: 'openai',
|
||||
result
|
||||
}
|
||||
} else {
|
||||
throw new Error('Video deletion is not supported by this provider')
|
||||
}
|
||||
}
|
||||
|
||||
public getBaseURL(): string {
|
||||
return this.apiClient.getBaseURL()
|
||||
}
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import OpenAI from '@cherrystudio/openai'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import { useVideos } from './useVideos'
|
||||
import { useProviderVideos } from './useProviderVideos'
|
||||
|
||||
export const useAddOpenAIVideo = (providerId: string) => {
|
||||
const { addVideo } = useVideos(providerId)
|
||||
const { addVideo } = useProviderVideos(providerId)
|
||||
|
||||
const addOpenAIVideo = useCallback(
|
||||
(video: OpenAI.Videos.Video, prompt: string) => {
|
||||
|
||||
@ -1,19 +1,23 @@
|
||||
import { retrieveVideo } from '@renderer/services/ApiService'
|
||||
import { SystemProviderIds } from '@renderer/types'
|
||||
import useSWR, { SWRConfiguration, useSWRConfig } from 'swr'
|
||||
|
||||
import { useProvider } from '../useProvider'
|
||||
import { useVideo } from './useVideo'
|
||||
|
||||
export const useOpenAIVideo = (id: string) => {
|
||||
const providerId = SystemProviderIds.openai
|
||||
const { provider: openai } = useProvider(providerId)
|
||||
export const useOpenAIVideo = (providerId: string, id: string) => {
|
||||
const { provider } = useProvider(providerId)
|
||||
const fetcher = async () => {
|
||||
return retrieveVideo({
|
||||
type: 'openai',
|
||||
videoId: id,
|
||||
provider: openai
|
||||
})
|
||||
switch (provider.type) {
|
||||
case 'openai-response':
|
||||
return retrieveVideo({
|
||||
type: 'openai',
|
||||
videoId: id,
|
||||
provider
|
||||
})
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported provider type: ${provider.type}`)
|
||||
}
|
||||
}
|
||||
const video = useVideo(providerId, id)
|
||||
let options: SWRConfiguration = {}
|
||||
|
||||
@ -970,6 +970,7 @@
|
||||
"delete_confirm": "Are you sure you want to delete?",
|
||||
"delete_failed": "Failed to delete",
|
||||
"delete_success": "Deleted successfully",
|
||||
"deleting": "Deleting...",
|
||||
"description": "Description",
|
||||
"detail": "Detail",
|
||||
"disabled": "Disabled",
|
||||
@ -1087,6 +1088,9 @@
|
||||
},
|
||||
"content": "Content",
|
||||
"data": "Data",
|
||||
"delete": {
|
||||
"failed": "Failed to delete."
|
||||
},
|
||||
"detail": "Error Details",
|
||||
"details": "Details",
|
||||
"errors": "Errors",
|
||||
@ -4650,6 +4654,14 @@
|
||||
"title": "Update"
|
||||
},
|
||||
"video": {
|
||||
"delete": {
|
||||
"error": {
|
||||
"not_found": {
|
||||
"description": "The video was not found remotely. It will only be deleted locally.",
|
||||
"title": "Video not found."
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"create": "Failed to create video",
|
||||
"download": "Failed to download video.",
|
||||
|
||||
@ -7,13 +7,14 @@ export type VideoListProps = {
|
||||
videos: Video[]
|
||||
activeVideoId?: string
|
||||
setActiveVideoId: (id: string | undefined) => void
|
||||
onDelete: (id: string) => void
|
||||
}
|
||||
|
||||
export const VideoList = ({ videos, activeVideoId, setActiveVideoId }: VideoListProps) => {
|
||||
export const VideoList = ({ videos, activeVideoId, setActiveVideoId, onDelete }: VideoListProps) => {
|
||||
return (
|
||||
<div className="w-40 space-y-3 overflow-auto p-2">
|
||||
<div className="flex w-40 flex-col gap-1 space-y-3 overflow-auto p-2">
|
||||
<div
|
||||
className="group relative flex aspect-square cursor-pointer items-center justify-center overflow-hidden rounded-xl border-2 transition-all hover:scale-105 hover:shadow-lg"
|
||||
className="group relative flex aspect-square cursor-pointer items-center justify-center rounded-xl border-2 transition-all hover:scale-105 hover:shadow-lg"
|
||||
onClick={() => setActiveVideoId(undefined)}>
|
||||
<PlusIcon size={24} />
|
||||
</div>
|
||||
@ -24,6 +25,7 @@ export const VideoList = ({ videos, activeVideoId, setActiveVideoId }: VideoList
|
||||
video={video}
|
||||
isActive={activeVideoId === video.id}
|
||||
onClick={() => setActiveVideoId(video.id)}
|
||||
onDelete={() => onDelete(video.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -1,16 +1,20 @@
|
||||
import { cn, Progress, Spinner } from '@heroui/react'
|
||||
import { DeleteIcon } from '@renderer/components/Icons'
|
||||
import { Video } from '@renderer/types/video'
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@renderer/ui/context-menu'
|
||||
import { CheckCircleIcon, CircleXIcon, ClockIcon, DownloadIcon } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export const VideoListItem = ({
|
||||
video,
|
||||
isActive,
|
||||
onClick
|
||||
onClick,
|
||||
onDelete
|
||||
}: {
|
||||
video: Video
|
||||
isActive: boolean
|
||||
onClick: () => void
|
||||
onDelete: () => void
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
@ -77,60 +81,70 @@ export const VideoListItem = ({
|
||||
video.thumbnail !== null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
`group relative aspect-square cursor-pointer overflow-hidden rounded-xl border-2 transition-all hover:scale-105 hover:shadow-lg ${getStatusColor()}`,
|
||||
isActive ? 'border-primary' : undefined
|
||||
)}
|
||||
onClick={onClick}>
|
||||
{/* Thumbnail placeholder */}
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-default-100 to-default-200">
|
||||
{showThumbnail ? (
|
||||
<img src={video.thumbnail ?? ''} alt="Video thumbnail" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-2 text-default-400">
|
||||
<div className="text-2xl">🎬</div>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger>
|
||||
<div
|
||||
className={cn(
|
||||
`group relative aspect-square cursor-pointer overflow-hidden rounded-xl border-2 transition-all hover:scale-105 hover:shadow-lg ${getStatusColor()}`,
|
||||
isActive ? 'border-primary' : undefined
|
||||
)}
|
||||
onClick={onClick}>
|
||||
{/* Thumbnail placeholder */}
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-default-100 to-default-200">
|
||||
{showThumbnail ? (
|
||||
<img src={video.thumbnail ?? ''} alt="Video thumbnail" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-2 text-default-400">
|
||||
<div className="text-2xl">🎬</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status overlay */}
|
||||
<div className="absolute inset-0 bg-black/20 opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
{/* Status overlay */}
|
||||
<div className="absolute inset-0 bg-black/20 opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
|
||||
{/* Status indicator */}
|
||||
{getStatusIcon() && (
|
||||
<div className="absolute top-2 right-2 flex items-center gap-1 rounded-full bg-white/90 px-2 py-1 backdrop-blur-sm">
|
||||
{getStatusIcon()}
|
||||
<span className="font-medium text-black text-xs">{getStatusLabel()}</span>
|
||||
{/* Status indicator */}
|
||||
{getStatusIcon() && (
|
||||
<div className="absolute top-2 right-2 flex items-center gap-1 rounded-full bg-white/90 px-2 py-1 backdrop-blur-sm">
|
||||
{getStatusIcon()}
|
||||
<span className="font-medium text-black text-xs">{getStatusLabel()}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress bar for in_progress and downloading states */}
|
||||
{showProgress && (
|
||||
<div className="absolute right-0 bottom-0 left-0 p-2">
|
||||
<Progress
|
||||
aria-label="progress bar"
|
||||
size="sm"
|
||||
value={video.progress}
|
||||
color={video.status === 'downloading' ? 'primary' : 'primary'}
|
||||
className="w-full"
|
||||
showValueLabel={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Video info overlay */}
|
||||
<div className="absolute right-0 bottom-0 left-0 bg-gradient-to-t from-black/60 to-transparent p-3 pt-6 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<div className="text-white">
|
||||
<p className="truncate font-medium text-sm">{video.metadata.id}</p>
|
||||
{video.prompt && <p className="mt-1 line-clamp-2 text-xs opacity-80">{video.prompt}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Failed state overlay */}
|
||||
{video.status === 'failed' && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-danger/10"></div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress bar for in_progress and downloading states */}
|
||||
{showProgress && (
|
||||
<div className="absolute right-0 bottom-0 left-0 p-2">
|
||||
<Progress
|
||||
aria-label="progress bar"
|
||||
size="sm"
|
||||
value={video.progress}
|
||||
color={video.status === 'downloading' ? 'primary' : 'primary'}
|
||||
className="w-full"
|
||||
showValueLabel={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Video info overlay */}
|
||||
<div className="absolute right-0 bottom-0 left-0 bg-gradient-to-t from-black/60 to-transparent p-3 pt-6 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<div className="text-white">
|
||||
<p className="truncate font-medium text-sm">{video.metadata.id}</p>
|
||||
{video.prompt && <p className="mt-1 line-clamp-2 text-xs opacity-80">{video.prompt}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Failed state overlay */}
|
||||
{video.status === 'failed' && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-danger/10"></div>
|
||||
)}
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onSelect={onDelete}>
|
||||
<DeleteIcon className="text-danger" />
|
||||
<span className="text-danger">{t('common.delete')}</span>
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
@ -2,10 +2,14 @@
|
||||
|
||||
import { Divider } from '@heroui/react'
|
||||
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
|
||||
import { usePending } from '@renderer/hooks/usePending'
|
||||
import { useProvider } from '@renderer/hooks/useProvider'
|
||||
import { useVideos } from '@renderer/hooks/video/useVideos'
|
||||
import { useProviderVideos } from '@renderer/hooks/video/useProviderVideos'
|
||||
import { useVideoThumbnail } from '@renderer/hooks/video/useVideoThumbnail'
|
||||
import { deleteVideo } from '@renderer/services/ApiService'
|
||||
import { SystemProviderIds } from '@renderer/types'
|
||||
import { CreateVideoParams } from '@renderer/types/video'
|
||||
import { getErrorMessage } from '@renderer/utils'
|
||||
import { deepUpdate } from '@renderer/utils/deepUpdate'
|
||||
import { isVideoModel } from '@renderer/utils/model/video'
|
||||
import { DeepPartial } from 'ai'
|
||||
@ -32,7 +36,12 @@ export const VideoPage = () => {
|
||||
},
|
||||
options: {}
|
||||
})
|
||||
const { videos, removeVideo } = useProviderVideos(providerId)
|
||||
// const activeVideo = useMemo(() => mockVideos.find((v) => v.id === activeVideoId), [activeVideoId])
|
||||
const [activeVideoId, setActiveVideoId] = useState<string>()
|
||||
const activeVideo = useMemo(() => videos.find((v) => v.id === activeVideoId), [activeVideoId, videos])
|
||||
const { setPending } = usePending()
|
||||
const { removeThumbnail } = useVideoThumbnail()
|
||||
|
||||
const updateParams = useCallback((update: DeepPartial<Omit<CreateVideoParams, 'type'>>) => {
|
||||
setParams((prev) => deepUpdate<CreateVideoParams>(prev, update))
|
||||
@ -47,9 +56,55 @@ export const VideoPage = () => {
|
||||
[updateParams]
|
||||
)
|
||||
|
||||
const { videos } = useVideos(providerId)
|
||||
// const activeVideo = useMemo(() => mockVideos.find((v) => v.id === activeVideoId), [activeVideoId])
|
||||
const activeVideo = useMemo(() => videos.find((v) => v.id === activeVideoId), [activeVideoId, videos])
|
||||
const afterDeleteVideo = useCallback(
|
||||
(id: string) => {
|
||||
removeVideo(id)
|
||||
removeThumbnail(id)
|
||||
},
|
||||
[removeThumbnail, removeVideo]
|
||||
)
|
||||
|
||||
const handleDeleteVideo = useCallback(
|
||||
async (id: string) => {
|
||||
switch (provider.type) {
|
||||
case 'openai-response':
|
||||
try {
|
||||
setPending(id, true)
|
||||
const promise = deleteVideo({
|
||||
type: 'openai',
|
||||
videoId: id,
|
||||
provider
|
||||
})
|
||||
window.toast.loading({
|
||||
title: t('common.deleting'),
|
||||
promise
|
||||
})
|
||||
const result = await promise
|
||||
if (result.result.deleted) {
|
||||
afterDeleteVideo(id)
|
||||
} else {
|
||||
window.toast.error(t('error.delete.failed'))
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message.includes('404')) {
|
||||
window.toast.warning({
|
||||
title: t('video.delete.error.not_found.title'),
|
||||
description: t('video.delete.error.not_found.description')
|
||||
})
|
||||
afterDeleteVideo(id)
|
||||
} else {
|
||||
window.toast.error({ title: t('error.delete.failed'), description: getErrorMessage(e) })
|
||||
}
|
||||
} finally {
|
||||
setPending(id, undefined)
|
||||
}
|
||||
break
|
||||
default:
|
||||
throw new Error(`Provider type "${provider.type}" is not supported for video deletion`)
|
||||
}
|
||||
},
|
||||
[afterDeleteVideo, provider, setPending, t]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col">
|
||||
@ -73,7 +128,12 @@ export const VideoPage = () => {
|
||||
<VideoPanel provider={provider} params={params} updateParams={updateParams} video={activeVideo} />
|
||||
<Divider orientation="vertical" />
|
||||
{/* Video list */}
|
||||
<VideoList videos={videos} activeVideoId={activeVideoId} setActiveVideoId={setActiveVideoId} />
|
||||
<VideoList
|
||||
videos={videos}
|
||||
activeVideoId={activeVideoId}
|
||||
setActiveVideoId={setActiveVideoId}
|
||||
onDelete={handleDeleteVideo}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import { Button, cn, Image, Skeleton, Textarea, Tooltip } from '@heroui/react'
|
||||
import { loggerService } from '@logger'
|
||||
import { usePending } from '@renderer/hooks/usePending'
|
||||
import { useAddOpenAIVideo } from '@renderer/hooks/video/useAddOpenAIVideo'
|
||||
import { useVideos } from '@renderer/hooks/video/useVideos'
|
||||
import { useProviderVideos } from '@renderer/hooks/video/useProviderVideos'
|
||||
import { createVideo, retrieveVideoContent } from '@renderer/services/ApiService'
|
||||
import FileManager from '@renderer/services/FileManager'
|
||||
import { FileTypes, Provider, VideoFileMetadata } from '@renderer/types'
|
||||
@ -13,7 +14,7 @@ import dayjs from 'dayjs'
|
||||
import { isEmpty } from 'lodash'
|
||||
import { ArrowUp, CircleXIcon, ImageIcon } from 'lucide-react'
|
||||
import mime from 'mime-types'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { VideoViewer } from './VideoViewer'
|
||||
@ -30,20 +31,20 @@ const logger = loggerService.withContext('VideoPanel')
|
||||
export const VideoPanel = ({ provider, video, params, updateParams }: VideoPanelProps) => {
|
||||
const { t } = useTranslation()
|
||||
const addOpenAIVideo = useAddOpenAIVideo(provider.id)
|
||||
const { setVideo } = useVideos(provider.id)
|
||||
const { setVideo } = useProviderVideos(provider.id)
|
||||
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const { pendingMap, setPending: setPendingById } = usePending()
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const inputReference = params.params.input_reference
|
||||
|
||||
const couldCreateVideo = useMemo(
|
||||
() =>
|
||||
!isProcessing &&
|
||||
!isEmpty(params.params.prompt) &&
|
||||
video?.status !== 'queued' &&
|
||||
video?.status !== 'downloading' &&
|
||||
video?.status !== 'in_progress',
|
||||
[isProcessing, params.params.prompt, video?.status]
|
||||
video?.status !== 'in_progress' &&
|
||||
(video === undefined || pendingMap[video.id] !== true),
|
||||
[params.params.prompt, pendingMap, video]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@ -54,9 +55,19 @@ export const VideoPanel = ({ provider, video, params, updateParams }: VideoPanel
|
||||
}
|
||||
}, [updateParams, video])
|
||||
|
||||
const isPending = video ? pendingMap[video.id] : false
|
||||
const setPending = useCallback(
|
||||
(value: boolean) => {
|
||||
if (video) {
|
||||
setPendingById(video.id, value ? value : undefined)
|
||||
}
|
||||
},
|
||||
[setPendingById, video]
|
||||
)
|
||||
|
||||
const handleCreateVideo = useCallback(async () => {
|
||||
if (!couldCreateVideo) return
|
||||
setIsProcessing(true)
|
||||
setPending(true)
|
||||
try {
|
||||
if (video === undefined) {
|
||||
const result = await createVideo(params)
|
||||
@ -75,9 +86,9 @@ export const VideoPanel = ({ provider, video, params, updateParams }: VideoPanel
|
||||
} catch (e) {
|
||||
window.toast.error({ title: t('video.error.create'), description: getErrorMessage(e), timeout: 5000 })
|
||||
} finally {
|
||||
setIsProcessing(false)
|
||||
setPending(false)
|
||||
}
|
||||
}, [addOpenAIVideo, couldCreateVideo, params, t, video])
|
||||
}, [addOpenAIVideo, couldCreateVideo, params, setPending, t, video])
|
||||
|
||||
const handleRegenerateVideo = useCallback(() => {
|
||||
window.toast.info('Not implemented')
|
||||
@ -208,13 +219,13 @@ export const VideoPanel = ({ provider, video, params, updateParams }: VideoPanel
|
||||
startContent={<ImageIcon size={16} className={cn(inputReference ? 'text-primary' : undefined)} />}
|
||||
isIconOnly
|
||||
className="h-6 w-6 min-w-0"
|
||||
isDisabled={isProcessing}
|
||||
isDisabled={isPending}
|
||||
onPress={handleUploadFile}
|
||||
/>
|
||||
</Tooltip>
|
||||
</>
|
||||
)
|
||||
}, [handleUploadFile, isProcessing, inputReference, t, updateParams])
|
||||
}, [handleUploadFile, inputReference, isPending, t, updateParams])
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col p-2">
|
||||
@ -231,7 +242,7 @@ export const VideoPanel = ({ provider, video, params, updateParams }: VideoPanel
|
||||
value={params.params.prompt}
|
||||
onValueChange={setPrompt}
|
||||
isClearable
|
||||
isDisabled={isProcessing}
|
||||
isDisabled={isPending}
|
||||
classNames={{ inputWrapper: 'pb-8' }}
|
||||
onKeyDown={(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
@ -274,7 +285,7 @@ export const VideoPanel = ({ provider, video, params, updateParams }: VideoPanel
|
||||
radius="full"
|
||||
isIconOnly
|
||||
isDisabled={!couldCreateVideo}
|
||||
isLoading={isProcessing}
|
||||
isLoading={isPending}
|
||||
className="h-6 w-6 min-w-0"
|
||||
onPress={handleCreateVideo}>
|
||||
<ArrowUp size={16} className="text-primary-foreground" />
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
Spinner,
|
||||
useDisclosure
|
||||
} from '@heroui/react'
|
||||
import { usePending } from '@renderer/hooks/usePending'
|
||||
import FileManager from '@renderer/services/FileManager'
|
||||
import { Video, VideoDownloaded, VideoFailed } from '@renderer/types/video'
|
||||
import dayjs from 'dayjs'
|
||||
@ -33,6 +34,8 @@ export type VideoViewerProps =
|
||||
export const VideoViewer = ({ video, onDownload, onRegenerate }: VideoViewerProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [loadSuccess, setLoadSuccess] = useState<boolean | undefined>(undefined)
|
||||
const { pendingMap } = usePending()
|
||||
const isPending = video ? pendingMap[video.id] : false
|
||||
useEffect(() => {
|
||||
setLoadSuccess(undefined)
|
||||
}, [video?.id])
|
||||
@ -43,7 +46,7 @@ export const VideoViewer = ({ video, onDownload, onRegenerate }: VideoViewerProp
|
||||
{video && video.status === 'queued' && <QueuedVideo />}
|
||||
{video && video.status === 'in_progress' && <InProgressVideo progress={video.progress} />}
|
||||
{video && video.status === 'completed' && (
|
||||
<CompletedVideo video={video} onDownload={onDownload} onRegenerate={onRegenerate} />
|
||||
<CompletedVideo video={video} isDisabled={isPending} onDownload={onDownload} onRegenerate={onRegenerate} />
|
||||
)}
|
||||
{video && video.status === 'downloading' && <DownloadingVideo progress={video.progress} />}
|
||||
{video && video.status === 'downloaded' && loadSuccess !== false && (
|
||||
@ -51,7 +54,7 @@ export const VideoViewer = ({ video, onDownload, onRegenerate }: VideoViewerProp
|
||||
)}
|
||||
{video && video.status === 'failed' && <FailedVideo error={video.error} />}
|
||||
{video && video.status === 'downloaded' && loadSuccess === false && (
|
||||
<LoadFailedVideo onRedownload={onDownload} />
|
||||
<LoadFailedVideo isDisabled={isPending} onRedownload={onDownload} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
@ -87,10 +90,12 @@ const InProgressVideo = ({ progress }: { progress: number }) => {
|
||||
|
||||
const CompletedVideo = ({
|
||||
video,
|
||||
isDisabled,
|
||||
onDownload,
|
||||
onRegenerate
|
||||
}: {
|
||||
video: Video
|
||||
isDisabled?: boolean
|
||||
onDownload: () => void
|
||||
onRegenerate: () => void
|
||||
}) => {
|
||||
@ -101,7 +106,9 @@ const CompletedVideo = ({
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 rounded-2xl bg-warning-200">
|
||||
<Clock9Icon size={64} className="text-warning" />
|
||||
<span className="font-bold text-2xl">{t('video.expired')}</span>
|
||||
<Button onPress={onRegenerate}>{t('common.regenerate')}</Button>
|
||||
<Button onPress={onRegenerate} isDisabled={isDisabled}>
|
||||
{t('common.regenerate')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -109,7 +116,9 @@ const CompletedVideo = ({
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 rounded-2xl bg-success-200">
|
||||
<CheckCircleIcon size={64} className="text-success" />
|
||||
<span className="font-bold text-2xl">{t('video.status.completed')}</span>
|
||||
<Button onPress={onDownload}>{t('common.download')}</Button>
|
||||
<Button onPress={onDownload} isDisabled={isDisabled}>
|
||||
{t('common.download')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -163,7 +172,7 @@ const FailedVideo = ({ error }: { error: VideoFailed['error'] }) => {
|
||||
)
|
||||
}
|
||||
|
||||
const LoadFailedVideo = ({ onRedownload }: { onRedownload: () => void }) => {
|
||||
const LoadFailedVideo = ({ isDisabled, onRedownload }: { isDisabled?: boolean; onRedownload: () => void }) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center rounded-2xl bg-danger-200">
|
||||
@ -171,7 +180,9 @@ const LoadFailedVideo = ({ onRedownload }: { onRedownload: () => void }) => {
|
||||
<span className="font-bold text-2xl">{t('video.error.load.message')}</span>
|
||||
<span>{t('video.error.load.reason')}</span>
|
||||
<div className="my-2 flex justify-between gap-2">
|
||||
<Button onPress={onRedownload}>{t('common.redownload')}</Button>
|
||||
<Button onPress={onRedownload} isDisabled={isDisabled}>
|
||||
{t('common.redownload')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -10,7 +10,12 @@ import { isDedicatedImageGenerationModel, isEmbeddingModel } from '@renderer/con
|
||||
import { getStoreSetting } from '@renderer/hooks/useSettings'
|
||||
import i18n from '@renderer/i18n'
|
||||
import store from '@renderer/store'
|
||||
import type { FetchChatCompletionParams, RetrieveVideoContentParams } from '@renderer/types'
|
||||
import type {
|
||||
DeleteVideoParams,
|
||||
DeleteVideoResult,
|
||||
FetchChatCompletionParams,
|
||||
RetrieveVideoContentParams
|
||||
} from '@renderer/types'
|
||||
import { Assistant, MCPServer, MCPTool, Model, Provider } from '@renderer/types'
|
||||
import type { StreamTextParams } from '@renderer/types/aiCoreTypes'
|
||||
import { type Chunk, ChunkType } from '@renderer/types/chunk'
|
||||
@ -419,6 +424,11 @@ export async function retrieveVideoContent(params: RetrieveVideoContentParams):
|
||||
return ai.retrieveVideoContent(params)
|
||||
}
|
||||
|
||||
export async function deleteVideo(params: DeleteVideoParams): Promise<DeleteVideoResult> {
|
||||
const ai = new AiProviderNew(params.provider)
|
||||
return ai.deleteVideo(params)
|
||||
}
|
||||
|
||||
export function hasApiKey(provider: Provider) {
|
||||
if (!provider) return false
|
||||
if (['ollama', 'lmstudio', 'vertexai', 'cherryai'].includes(provider.id)) return true
|
||||
|
||||
Loading…
Reference in New Issue
Block a user