diff --git a/src/renderer/src/aiCore/index_new.ts b/src/renderer/src/aiCore/index_new.ts index 2a07d92045..e89a8ffe88 100644 --- a/src/renderer/src/aiCore/index_new.ts +++ b/src/renderer/src/aiCore/index_new.ts @@ -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 { + return this.legacyProvider.deleteVideo(params) + } + public getBaseURL(): string { return this.legacyProvider.getBaseURL() } diff --git a/src/renderer/src/aiCore/legacy/clients/openai/OpenAIResponseAPIClient.ts b/src/renderer/src/aiCore/legacy/clients/openai/OpenAIResponseAPIClient.ts index 766e1455e3..a35b68a701 100644 --- a/src/renderer/src/aiCore/legacy/clients/openai/OpenAIResponseAPIClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/openai/OpenAIResponseAPIClient.ts @@ -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 { + const sdk = await this.getSdkInstance() + return sdk.videos.delete(params.videoId, params.options) + } + private async handlePdfFile(file: FileMetadata): Promise { if (file.size > 32 * MB) return undefined try { diff --git a/src/renderer/src/aiCore/legacy/index.ts b/src/renderer/src/aiCore/legacy/index.ts index bbb4c7af8b..67f2ae13fe 100644 --- a/src/renderer/src/aiCore/legacy/index.ts +++ b/src/renderer/src/aiCore/legacy/index.ts @@ -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 { + 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() } diff --git a/src/renderer/src/hooks/video/useAddOpenAIVideo.ts b/src/renderer/src/hooks/video/useAddOpenAIVideo.ts index 8c47cbc6c6..243e62fa43 100644 --- a/src/renderer/src/hooks/video/useAddOpenAIVideo.ts +++ b/src/renderer/src/hooks/video/useAddOpenAIVideo.ts @@ -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) => { diff --git a/src/renderer/src/hooks/video/useOpenAIVideo.ts b/src/renderer/src/hooks/video/useOpenAIVideo.ts index 8e4c746d8b..1ebef9d541 100644 --- a/src/renderer/src/hooks/video/useOpenAIVideo.ts +++ b/src/renderer/src/hooks/video/useOpenAIVideo.ts @@ -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 = {} diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index a591bae1de..b39a455e13 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -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.", diff --git a/src/renderer/src/pages/video/VideoList.tsx b/src/renderer/src/pages/video/VideoList.tsx index 14cc530c37..5b9ba8bac1 100644 --- a/src/renderer/src/pages/video/VideoList.tsx +++ b/src/renderer/src/pages/video/VideoList.tsx @@ -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 ( -
+
setActiveVideoId(undefined)}>
@@ -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)} /> ))}
diff --git a/src/renderer/src/pages/video/VideoListItem.tsx b/src/renderer/src/pages/video/VideoListItem.tsx index 985812a265..31e935f27f 100644 --- a/src/renderer/src/pages/video/VideoListItem.tsx +++ b/src/renderer/src/pages/video/VideoListItem.tsx @@ -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 ( -
- {/* Thumbnail placeholder */} -
- {showThumbnail ? ( - Video thumbnail - ) : ( -
-
🎬
+ + +
+ {/* Thumbnail placeholder */} +
+ {showThumbnail ? ( + Video thumbnail + ) : ( +
+
🎬
+
+ )}
- )} -
- {/* Status overlay */} -
+ {/* Status overlay */} +
- {/* Status indicator */} - {getStatusIcon() && ( -
- {getStatusIcon()} - {getStatusLabel()} + {/* Status indicator */} + {getStatusIcon() && ( +
+ {getStatusIcon()} + {getStatusLabel()} +
+ )} + + {/* Progress bar for in_progress and downloading states */} + {showProgress && ( +
+ +
+ )} + + {/* Video info overlay */} +
+
+

{video.metadata.id}

+ {video.prompt &&

{video.prompt}

} +
+
+ + {/* Failed state overlay */} + {video.status === 'failed' && ( +
+ )}
- )} - - {/* Progress bar for in_progress and downloading states */} - {showProgress && ( -
- -
- )} - - {/* Video info overlay */} -
-
-

{video.metadata.id}

- {video.prompt &&

{video.prompt}

} -
-
- - {/* Failed state overlay */} - {video.status === 'failed' && ( -
- )} -
+ + + + + {t('common.delete')} + + + ) } diff --git a/src/renderer/src/pages/video/VideoPage.tsx b/src/renderer/src/pages/video/VideoPage.tsx index 6cfae2da73..ba47e647ec 100644 --- a/src/renderer/src/pages/video/VideoPage.tsx +++ b/src/renderer/src/pages/video/VideoPage.tsx @@ -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() + const activeVideo = useMemo(() => videos.find((v) => v.id === activeVideoId), [activeVideoId, videos]) + const { setPending } = usePending() + const { removeThumbnail } = useVideoThumbnail() const updateParams = useCallback((update: DeepPartial>) => { setParams((prev) => deepUpdate(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 (
@@ -73,7 +128,12 @@ export const VideoPage = () => { {/* Video list */} - +
) diff --git a/src/renderer/src/pages/video/VideoPanel.tsx b/src/renderer/src/pages/video/VideoPanel.tsx index 8d6d7ba8f7..14c0a65f20 100644 --- a/src/renderer/src/pages/video/VideoPanel.tsx +++ b/src/renderer/src/pages/video/VideoPanel.tsx @@ -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(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={} isIconOnly className="h-6 w-6 min-w-0" - isDisabled={isProcessing} + isDisabled={isPending} onPress={handleUploadFile} /> ) - }, [handleUploadFile, isProcessing, inputReference, t, updateParams]) + }, [handleUploadFile, inputReference, isPending, t, updateParams]) return (
@@ -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}> diff --git a/src/renderer/src/pages/video/VideoViewer.tsx b/src/renderer/src/pages/video/VideoViewer.tsx index 5520d40013..21d8629fcf 100644 --- a/src/renderer/src/pages/video/VideoViewer.tsx +++ b/src/renderer/src/pages/video/VideoViewer.tsx @@ -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(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' && } {video && video.status === 'in_progress' && } {video && video.status === 'completed' && ( - + )} {video && video.status === 'downloading' && } {video && video.status === 'downloaded' && loadSuccess !== false && ( @@ -51,7 +54,7 @@ export const VideoViewer = ({ video, onDownload, onRegenerate }: VideoViewerProp )} {video && video.status === 'failed' && } {video && video.status === 'downloaded' && loadSuccess === false && ( - + )}
@@ -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 = ({
{t('video.expired')} - +
) } @@ -109,7 +116,9 @@ const CompletedVideo = ({
{t('video.status.completed')} - +
) } @@ -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 (
@@ -171,7 +180,9 @@ const LoadFailedVideo = ({ onRedownload }: { onRedownload: () => void }) => { {t('video.error.load.message')} {t('video.error.load.reason')}
- +
) diff --git a/src/renderer/src/services/ApiService.ts b/src/renderer/src/services/ApiService.ts index a7a2e674ac..800f02a684 100644 --- a/src/renderer/src/services/ApiService.ts +++ b/src/renderer/src/services/ApiService.ts @@ -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 { + 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