mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-11 08:19:01 +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 { 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()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
@ -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 = {}
|
||||||
|
|||||||
@ -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.",
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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" />
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user