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:
icarus 2025-10-13 21:52:48 +08:00
parent 2fab33de41
commit 85daceb417
12 changed files with 263 additions and 95 deletions

View File

@ -12,7 +12,15 @@ import { loggerService } from '@logger'
import { getEnableDeveloperMode } from '@renderer/hooks/useSettings' import { getEnableDeveloperMode } from '@renderer/hooks/useSettings'
import { addSpan, endSpan } from '@renderer/services/SpanManagerService' import { addSpan, endSpan } from '@renderer/services/SpanManagerService'
import { StartSpanParams } from '@renderer/trace/types/ModelSpanEntity' 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 type { AiSdkModel, StreamTextParams } from '@renderer/types/aiCoreTypes'
import { import {
CreateVideoParams, CreateVideoParams,
@ -528,6 +536,13 @@ export default class ModernAiProvider {
return this.legacyProvider.retrieveVideoContent(params) 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 { public getBaseURL(): string {
return this.legacyProvider.getBaseURL() return this.legacyProvider.getBaseURL()
} }

View File

@ -36,7 +36,12 @@ import {
OpenAIResponseSdkTool, OpenAIResponseSdkTool,
OpenAIResponseSdkToolCall OpenAIResponseSdkToolCall
} from '@renderer/types/sdk' } 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 { addImageFileToContents } from '@renderer/utils/formats'
import { import {
isSupportedToolUse, isSupportedToolUse,
@ -168,6 +173,11 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
return sdk.videos.downloadContent(params.videoId, params.query, params.options) 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> { private async handlePdfFile(file: FileMetadata): Promise<OpenAI.Responses.ResponseInputFile | undefined> {
if (file.size > 32 * MB) return undefined if (file.size > 32 * MB) return undefined
try { try {

View File

@ -5,7 +5,14 @@ import { isDedicatedImageGenerationModel, isFunctionCallingModel } from '@render
import { getProviderByModel } from '@renderer/services/AssistantService' import { getProviderByModel } from '@renderer/services/AssistantService'
import { withSpanResult } from '@renderer/services/SpanManagerService' import { withSpanResult } from '@renderer/services/SpanManagerService'
import { StartSpanParams } from '@renderer/trace/types/ModelSpanEntity' 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 type { RequestOptions, SdkModel } from '@renderer/types/sdk'
import { import {
CreateVideoParams, 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 { public getBaseURL(): string {
return this.apiClient.getBaseURL() return this.apiClient.getBaseURL()
} }

View File

@ -1,10 +1,10 @@
import OpenAI from '@cherrystudio/openai' import OpenAI from '@cherrystudio/openai'
import { useCallback } from 'react' import { useCallback } from 'react'
import { useVideos } from './useVideos' import { useProviderVideos } from './useProviderVideos'
export const useAddOpenAIVideo = (providerId: string) => { export const useAddOpenAIVideo = (providerId: string) => {
const { addVideo } = useVideos(providerId) const { addVideo } = useProviderVideos(providerId)
const addOpenAIVideo = useCallback( const addOpenAIVideo = useCallback(
(video: OpenAI.Videos.Video, prompt: string) => { (video: OpenAI.Videos.Video, prompt: string) => {

View File

@ -1,19 +1,23 @@
import { retrieveVideo } from '@renderer/services/ApiService' import { retrieveVideo } from '@renderer/services/ApiService'
import { SystemProviderIds } from '@renderer/types'
import useSWR, { SWRConfiguration, useSWRConfig } from 'swr' import useSWR, { SWRConfiguration, useSWRConfig } from 'swr'
import { useProvider } from '../useProvider' import { useProvider } from '../useProvider'
import { useVideo } from './useVideo' import { useVideo } from './useVideo'
export const useOpenAIVideo = (id: string) => { export const useOpenAIVideo = (providerId: string, id: string) => {
const providerId = SystemProviderIds.openai const { provider } = useProvider(providerId)
const { provider: openai } = useProvider(providerId)
const fetcher = async () => { const fetcher = async () => {
return retrieveVideo({ switch (provider.type) {
type: 'openai', case 'openai-response':
videoId: id, return retrieveVideo({
provider: openai type: 'openai',
}) videoId: id,
provider
})
default:
throw new Error(`Unsupported provider type: ${provider.type}`)
}
} }
const video = useVideo(providerId, id) const video = useVideo(providerId, id)
let options: SWRConfiguration = {} let options: SWRConfiguration = {}

View File

@ -970,6 +970,7 @@
"delete_confirm": "Are you sure you want to delete?", "delete_confirm": "Are you sure you want to delete?",
"delete_failed": "Failed to delete", "delete_failed": "Failed to delete",
"delete_success": "Deleted successfully", "delete_success": "Deleted successfully",
"deleting": "Deleting...",
"description": "Description", "description": "Description",
"detail": "Detail", "detail": "Detail",
"disabled": "Disabled", "disabled": "Disabled",
@ -1087,6 +1088,9 @@
}, },
"content": "Content", "content": "Content",
"data": "Data", "data": "Data",
"delete": {
"failed": "Failed to delete."
},
"detail": "Error Details", "detail": "Error Details",
"details": "Details", "details": "Details",
"errors": "Errors", "errors": "Errors",
@ -4650,6 +4654,14 @@
"title": "Update" "title": "Update"
}, },
"video": { "video": {
"delete": {
"error": {
"not_found": {
"description": "The video was not found remotely. It will only be deleted locally.",
"title": "Video not found."
}
}
},
"error": { "error": {
"create": "Failed to create video", "create": "Failed to create video",
"download": "Failed to download video.", "download": "Failed to download video.",

View File

@ -7,13 +7,14 @@ export type VideoListProps = {
videos: Video[] videos: Video[]
activeVideoId?: string activeVideoId?: string
setActiveVideoId: (id: string | undefined) => void 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 ( 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 <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)}> onClick={() => setActiveVideoId(undefined)}>
<PlusIcon size={24} /> <PlusIcon size={24} />
</div> </div>
@ -24,6 +25,7 @@ export const VideoList = ({ videos, activeVideoId, setActiveVideoId }: VideoList
video={video} video={video}
isActive={activeVideoId === video.id} isActive={activeVideoId === video.id}
onClick={() => setActiveVideoId(video.id)} onClick={() => setActiveVideoId(video.id)}
onDelete={() => onDelete(video.id)}
/> />
))} ))}
</div> </div>

View File

@ -1,16 +1,20 @@
import { cn, Progress, Spinner } from '@heroui/react' import { cn, Progress, Spinner } from '@heroui/react'
import { DeleteIcon } from '@renderer/components/Icons'
import { Video } from '@renderer/types/video' 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 { CheckCircleIcon, CircleXIcon, ClockIcon, DownloadIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
export const VideoListItem = ({ export const VideoListItem = ({
video, video,
isActive, isActive,
onClick onClick,
onDelete
}: { }: {
video: Video video: Video
isActive: boolean isActive: boolean
onClick: () => void onClick: () => void
onDelete: () => void
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
@ -77,60 +81,70 @@ export const VideoListItem = ({
video.thumbnail !== null video.thumbnail !== null
return ( return (
<div <ContextMenu>
className={cn( <ContextMenuTrigger>
`group relative aspect-square cursor-pointer overflow-hidden rounded-xl border-2 transition-all hover:scale-105 hover:shadow-lg ${getStatusColor()}`, <div
isActive ? 'border-primary' : undefined className={cn(
)} `group relative aspect-square cursor-pointer overflow-hidden rounded-xl border-2 transition-all hover:scale-105 hover:shadow-lg ${getStatusColor()}`,
onClick={onClick}> isActive ? 'border-primary' : undefined
{/* Thumbnail placeholder */} )}
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-default-100 to-default-200"> onClick={onClick}>
{showThumbnail ? ( {/* Thumbnail placeholder */}
<img src={video.thumbnail ?? ''} alt="Video thumbnail" className="h-full w-full object-cover" /> <div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-default-100 to-default-200">
) : ( {showThumbnail ? (
<div className="flex flex-col items-center gap-2 text-default-400"> <img src={video.thumbnail ?? ''} alt="Video thumbnail" className="h-full w-full object-cover" />
<div className="text-2xl">🎬</div> ) : (
<div className="flex flex-col items-center gap-2 text-default-400">
<div className="text-2xl">🎬</div>
</div>
)}
</div> </div>
)}
</div>
{/* Status overlay */} {/* Status overlay */}
<div className="absolute inset-0 bg-black/20 opacity-0 transition-opacity group-hover:opacity-100" /> <div className="absolute inset-0 bg-black/20 opacity-0 transition-opacity group-hover:opacity-100" />
{/* Status indicator */} {/* Status indicator */}
{getStatusIcon() && ( {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"> <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()} {getStatusIcon()}
<span className="font-medium text-black text-xs">{getStatusLabel()}</span> <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> </div>
)} </ContextMenuTrigger>
<ContextMenuContent>
{/* Progress bar for in_progress and downloading states */} <ContextMenuItem onSelect={onDelete}>
{showProgress && ( <DeleteIcon className="text-danger" />
<div className="absolute right-0 bottom-0 left-0 p-2"> <span className="text-danger">{t('common.delete')}</span>
<Progress </ContextMenuItem>
aria-label="progress bar" </ContextMenuContent>
size="sm" </ContextMenu>
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>
) )
} }

View File

@ -2,10 +2,14 @@
import { Divider } from '@heroui/react' import { Divider } from '@heroui/react'
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar' import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
import { usePending } from '@renderer/hooks/usePending'
import { useProvider } from '@renderer/hooks/useProvider' 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 { SystemProviderIds } from '@renderer/types'
import { CreateVideoParams } from '@renderer/types/video' import { CreateVideoParams } from '@renderer/types/video'
import { getErrorMessage } from '@renderer/utils'
import { deepUpdate } from '@renderer/utils/deepUpdate' import { deepUpdate } from '@renderer/utils/deepUpdate'
import { isVideoModel } from '@renderer/utils/model/video' import { isVideoModel } from '@renderer/utils/model/video'
import { DeepPartial } from 'ai' import { DeepPartial } from 'ai'
@ -32,7 +36,12 @@ export const VideoPage = () => {
}, },
options: {} options: {}
}) })
const { videos, removeVideo } = useProviderVideos(providerId)
// const activeVideo = useMemo(() => mockVideos.find((v) => v.id === activeVideoId), [activeVideoId])
const [activeVideoId, setActiveVideoId] = useState<string>() 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'>>) => { const updateParams = useCallback((update: DeepPartial<Omit<CreateVideoParams, 'type'>>) => {
setParams((prev) => deepUpdate<CreateVideoParams>(prev, update)) setParams((prev) => deepUpdate<CreateVideoParams>(prev, update))
@ -47,9 +56,55 @@ export const VideoPage = () => {
[updateParams] [updateParams]
) )
const { videos } = useVideos(providerId) const afterDeleteVideo = useCallback(
// const activeVideo = useMemo(() => mockVideos.find((v) => v.id === activeVideoId), [activeVideoId]) (id: string) => {
const activeVideo = useMemo(() => videos.find((v) => v.id === activeVideoId), [activeVideoId, videos]) 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 ( return (
<div className="flex flex-1 flex-col"> <div className="flex flex-1 flex-col">
@ -73,7 +128,12 @@ export const VideoPage = () => {
<VideoPanel provider={provider} params={params} updateParams={updateParams} video={activeVideo} /> <VideoPanel provider={provider} params={params} updateParams={updateParams} video={activeVideo} />
<Divider orientation="vertical" /> <Divider orientation="vertical" />
{/* Video list */} {/* Video list */}
<VideoList videos={videos} activeVideoId={activeVideoId} setActiveVideoId={setActiveVideoId} /> <VideoList
videos={videos}
activeVideoId={activeVideoId}
setActiveVideoId={setActiveVideoId}
onDelete={handleDeleteVideo}
/>
</div> </div>
</div> </div>
) )

View File

@ -1,7 +1,8 @@
import { Button, cn, Image, Skeleton, Textarea, Tooltip } from '@heroui/react' import { Button, cn, Image, Skeleton, Textarea, Tooltip } from '@heroui/react'
import { loggerService } from '@logger' import { loggerService } from '@logger'
import { usePending } from '@renderer/hooks/usePending'
import { useAddOpenAIVideo } from '@renderer/hooks/video/useAddOpenAIVideo' 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 { createVideo, retrieveVideoContent } from '@renderer/services/ApiService'
import FileManager from '@renderer/services/FileManager' import FileManager from '@renderer/services/FileManager'
import { FileTypes, Provider, VideoFileMetadata } from '@renderer/types' import { FileTypes, Provider, VideoFileMetadata } from '@renderer/types'
@ -13,7 +14,7 @@ import dayjs from 'dayjs'
import { isEmpty } from 'lodash' import { isEmpty } from 'lodash'
import { ArrowUp, CircleXIcon, ImageIcon } from 'lucide-react' import { ArrowUp, CircleXIcon, ImageIcon } from 'lucide-react'
import mime from 'mime-types' 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 { useTranslation } from 'react-i18next'
import { VideoViewer } from './VideoViewer' import { VideoViewer } from './VideoViewer'
@ -30,20 +31,20 @@ const logger = loggerService.withContext('VideoPanel')
export const VideoPanel = ({ provider, video, params, updateParams }: VideoPanelProps) => { export const VideoPanel = ({ provider, video, params, updateParams }: VideoPanelProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const addOpenAIVideo = useAddOpenAIVideo(provider.id) 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 fileInputRef = useRef<HTMLInputElement>(null)
const inputReference = params.params.input_reference const inputReference = params.params.input_reference
const couldCreateVideo = useMemo( const couldCreateVideo = useMemo(
() => () =>
!isProcessing &&
!isEmpty(params.params.prompt) && !isEmpty(params.params.prompt) &&
video?.status !== 'queued' && video?.status !== 'queued' &&
video?.status !== 'downloading' && video?.status !== 'downloading' &&
video?.status !== 'in_progress', video?.status !== 'in_progress' &&
[isProcessing, params.params.prompt, video?.status] (video === undefined || pendingMap[video.id] !== true),
[params.params.prompt, pendingMap, video]
) )
useEffect(() => { useEffect(() => {
@ -54,9 +55,19 @@ export const VideoPanel = ({ provider, video, params, updateParams }: VideoPanel
} }
}, [updateParams, video]) }, [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 () => { const handleCreateVideo = useCallback(async () => {
if (!couldCreateVideo) return if (!couldCreateVideo) return
setIsProcessing(true) setPending(true)
try { try {
if (video === undefined) { if (video === undefined) {
const result = await createVideo(params) const result = await createVideo(params)
@ -75,9 +86,9 @@ export const VideoPanel = ({ provider, video, params, updateParams }: VideoPanel
} catch (e) { } catch (e) {
window.toast.error({ title: t('video.error.create'), description: getErrorMessage(e), timeout: 5000 }) window.toast.error({ title: t('video.error.create'), description: getErrorMessage(e), timeout: 5000 })
} finally { } finally {
setIsProcessing(false) setPending(false)
} }
}, [addOpenAIVideo, couldCreateVideo, params, t, video]) }, [addOpenAIVideo, couldCreateVideo, params, setPending, t, video])
const handleRegenerateVideo = useCallback(() => { const handleRegenerateVideo = useCallback(() => {
window.toast.info('Not implemented') 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)} />} startContent={<ImageIcon size={16} className={cn(inputReference ? 'text-primary' : undefined)} />}
isIconOnly isIconOnly
className="h-6 w-6 min-w-0" className="h-6 w-6 min-w-0"
isDisabled={isProcessing} isDisabled={isPending}
onPress={handleUploadFile} onPress={handleUploadFile}
/> />
</Tooltip> </Tooltip>
</> </>
) )
}, [handleUploadFile, isProcessing, inputReference, t, updateParams]) }, [handleUploadFile, inputReference, isPending, t, updateParams])
return ( return (
<div className="flex flex-1 flex-col p-2"> <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} value={params.params.prompt}
onValueChange={setPrompt} onValueChange={setPrompt}
isClearable isClearable
isDisabled={isProcessing} isDisabled={isPending}
classNames={{ inputWrapper: 'pb-8' }} classNames={{ inputWrapper: 'pb-8' }}
onKeyDown={(e: React.KeyboardEvent) => { onKeyDown={(e: React.KeyboardEvent) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
@ -274,7 +285,7 @@ export const VideoPanel = ({ provider, video, params, updateParams }: VideoPanel
radius="full" radius="full"
isIconOnly isIconOnly
isDisabled={!couldCreateVideo} isDisabled={!couldCreateVideo}
isLoading={isProcessing} isLoading={isPending}
className="h-6 w-6 min-w-0" className="h-6 w-6 min-w-0"
onPress={handleCreateVideo}> onPress={handleCreateVideo}>
<ArrowUp size={16} className="text-primary-foreground" /> <ArrowUp size={16} className="text-primary-foreground" />

View File

@ -10,6 +10,7 @@ import {
Spinner, Spinner,
useDisclosure useDisclosure
} from '@heroui/react' } from '@heroui/react'
import { usePending } from '@renderer/hooks/usePending'
import FileManager from '@renderer/services/FileManager' import FileManager from '@renderer/services/FileManager'
import { Video, VideoDownloaded, VideoFailed } from '@renderer/types/video' import { Video, VideoDownloaded, VideoFailed } from '@renderer/types/video'
import dayjs from 'dayjs' import dayjs from 'dayjs'
@ -33,6 +34,8 @@ export type VideoViewerProps =
export const VideoViewer = ({ video, onDownload, onRegenerate }: VideoViewerProps) => { export const VideoViewer = ({ video, onDownload, onRegenerate }: VideoViewerProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const [loadSuccess, setLoadSuccess] = useState<boolean | undefined>(undefined) const [loadSuccess, setLoadSuccess] = useState<boolean | undefined>(undefined)
const { pendingMap } = usePending()
const isPending = video ? pendingMap[video.id] : false
useEffect(() => { useEffect(() => {
setLoadSuccess(undefined) setLoadSuccess(undefined)
}, [video?.id]) }, [video?.id])
@ -43,7 +46,7 @@ export const VideoViewer = ({ video, onDownload, onRegenerate }: VideoViewerProp
{video && video.status === 'queued' && <QueuedVideo />} {video && video.status === 'queued' && <QueuedVideo />}
{video && video.status === 'in_progress' && <InProgressVideo progress={video.progress} />} {video && video.status === 'in_progress' && <InProgressVideo progress={video.progress} />}
{video && video.status === 'completed' && ( {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 === 'downloading' && <DownloadingVideo progress={video.progress} />}
{video && video.status === 'downloaded' && loadSuccess !== false && ( {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 === 'failed' && <FailedVideo error={video.error} />}
{video && video.status === 'downloaded' && loadSuccess === false && ( {video && video.status === 'downloaded' && loadSuccess === false && (
<LoadFailedVideo onRedownload={onDownload} /> <LoadFailedVideo isDisabled={isPending} onRedownload={onDownload} />
)} )}
</div> </div>
</> </>
@ -87,10 +90,12 @@ const InProgressVideo = ({ progress }: { progress: number }) => {
const CompletedVideo = ({ const CompletedVideo = ({
video, video,
isDisabled,
onDownload, onDownload,
onRegenerate onRegenerate
}: { }: {
video: Video video: Video
isDisabled?: boolean
onDownload: () => void onDownload: () => void
onRegenerate: () => 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"> <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" /> <Clock9Icon size={64} className="text-warning" />
<span className="font-bold text-2xl">{t('video.expired')}</span> <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> </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"> <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" /> <CheckCircleIcon size={64} className="text-success" />
<span className="font-bold text-2xl">{t('video.status.completed')}</span> <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> </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() const { t } = useTranslation()
return ( return (
<div className="flex h-full w-full flex-col items-center justify-center rounded-2xl bg-danger-200"> <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 className="font-bold text-2xl">{t('video.error.load.message')}</span>
<span>{t('video.error.load.reason')}</span> <span>{t('video.error.load.reason')}</span>
<div className="my-2 flex justify-between gap-2"> <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>
</div> </div>
) )

View File

@ -10,7 +10,12 @@ import { isDedicatedImageGenerationModel, isEmbeddingModel } from '@renderer/con
import { getStoreSetting } from '@renderer/hooks/useSettings' import { getStoreSetting } from '@renderer/hooks/useSettings'
import i18n from '@renderer/i18n' import i18n from '@renderer/i18n'
import store from '@renderer/store' 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 { Assistant, MCPServer, MCPTool, Model, Provider } from '@renderer/types'
import type { StreamTextParams } from '@renderer/types/aiCoreTypes' import type { StreamTextParams } from '@renderer/types/aiCoreTypes'
import { type Chunk, ChunkType } from '@renderer/types/chunk' import { type Chunk, ChunkType } from '@renderer/types/chunk'
@ -419,6 +424,11 @@ export async function retrieveVideoContent(params: RetrieveVideoContentParams):
return ai.retrieveVideoContent(params) 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) { export function hasApiKey(provider: Provider) {
if (!provider) return false if (!provider) return false
if (['ollama', 'lmstudio', 'vertexai', 'cherryai'].includes(provider.id)) return true if (['ollama', 'lmstudio', 'vertexai', 'cherryai'].includes(provider.id)) return true