Compare commits

...

96 Commits

Author SHA1 Message Date
kangfenmao
606a80d3ee Merge branch 'main' into feat/sora2
# Conflicts:
#	package.json
#	src/renderer/src/config/models/utils.ts
#	src/renderer/src/store/migrate.ts
2025-10-17 20:58:43 +08:00
icarus
b5f2c63396 chore: bump version to 1.7.0-sora.3 2025-10-13 23:09:05 +08:00
icarus
4e76806cc3 fix(video): prevent thumbnail update for queued/in_progress videos
When a video is in 'queued' or 'in_progress' status, setting a thumbnail is not meaningful and could cause issues. This change ensures thumbnail remains undefined for these states.
2025-10-13 23:08:44 +08:00
icarus
09ed82eb49 fix(hooks): add missing dependencies to hook dependency arrays
Add providerId to useAddOpenAIVideo and t to useProviderVideos dependency arrays to prevent stale closures
2025-10-13 23:08:31 +08:00
icarus
b068fc25da feat(i18n): add new translation keys for video features
Add new translation keys for video deletion, download, thumbnail and status messages
2025-10-13 22:23:00 +08:00
icarus
a0627f76d5 feat(video): add thumbnail retrieval functionality
- Add new translations for thumbnail operations
- Extend video types to support thumbnail operations
- Implement thumbnail retrieval hook with error handling
- Add thumbnail get action to video list items
- Update video page to handle thumbnail retrieval
- Enhance provider videos hook with thumbnail support
2025-10-13 22:21:26 +08:00
icarus
85daceb417 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
2025-10-13 21:52:48 +08:00
icarus
2fab33de41 refactor(video): split video hooks into provider-specific and global logic
Move provider-specific video management to useProviderVideos hook
Simplify useVideos to handle global video operations
2025-10-13 21:52:34 +08:00
icarus
e88b4c091d refactor(video): rename useRetrieveThumbnail to useVideoThumbnail and add remove functionality
The hook has been renamed to better reflect its purpose and expanded with a removeThumbnail function to provide complete thumbnail management capabilities
2025-10-13 21:51:44 +08:00
icarus
6c097e6733 feat(video): add delete video interfaces and thumbnail/fileId fields
Add new interfaces for handling video deletion operations and optional thumbnail/fileId fields to VideoBase interface
2025-10-13 21:50:14 +08:00
icarus
c9c859731f feat(hooks): add usePending hook to manage pending state
Add a new custom hook to track pending states with a map in the runtime store. The hook provides a way to set and clear pending flags by id.
2025-10-13 21:49:37 +08:00
icarus
c85fad90b5 feat(toast): add i18n support for loading toast title
Use i18next to translate the loading toast title for better localization support
2025-10-13 21:48:56 +08:00
icarus
261b79198a style(video): update video player background color for dark mode 2025-10-13 20:04:44 +08:00
icarus
81f186abd6 feat(video): add status label helper function for video items
Add getStatusLabel function to centralize status text display logic and improve consistency. The function handles all status cases including empty states for 'downloaded' status.
2025-10-13 20:04:37 +08:00
icarus
f44a4f7f96 fix(video): add aria-label to progress bar for accessibility 2025-10-13 17:48:00 +08:00
icarus
15b7eb78c1 feat(video): add thumbnail retrieval hook and update video interfaces
Implement useRetrieveThumbnail hook to handle thumbnail fetching and caching
Update video interfaces to clarify thumbnail field types and add missing documentation
Refactor useVideos to use new thumbnail hook instead of direct API calls
2025-10-13 17:46:24 +08:00
icarus
efd5e9dcf2 fix(video): reload video element when video id changes
Ensure the video element is properly reloaded when switching between different videos to prevent playback issues
2025-10-13 17:01:22 +08:00
icarus
3b69b2bc49 fix(video): filter openai videos by queued or in_progress status 2025-10-13 16:54:10 +08:00
icarus
c8dfae1d70 refactor(video): extract VideoListItem component from VideoList
Move VideoListItem implementation to a separate file to improve code organization and maintainability
2025-10-13 16:40:58 +08:00
icarus
2ab3ddd804 fix(video): ensure thumbnail is not null before showing it 2025-10-13 16:39:44 +08:00
icarus
7a62418f41 chore: bump version to 1.7.0-sora.2 2025-10-13 16:32:52 +08:00
icarus
58c5df9284 feat(video): implement video download functionality and improve viewer
- Add video download logic with progress tracking in VideoPanel
- Reset load state when video changes in VideoViewer
- Improve video player styling and loading state handling
- Add file upload and metadata handling for downloaded videos
2025-10-13 16:32:42 +08:00
icarus
c20394f460 feat(video): add VideoPlayer component with file loading
Implement VideoPlayer component that fetches video file path using FileManager and displays it with loading skeleton. This improves video loading reliability by handling file existence checks and error states.
2025-10-13 15:23:48 +08:00
icarus
8518734e48 feat(files): add video file type support
Add video file type metadata and UI components to support video files in the files page
2025-10-13 15:11:12 +08:00
icarus
29a01ef49a fix(video): pass onDownload callback to LoadFailedVideo component
Make onRedownload prop required in LoadFailedVideo and directly pass the onDownload callback instead of using optional chaining. This ensures the redownload functionality works consistently when video loading fails.
2025-10-13 15:04:25 +08:00
icarus
5e4b516402 feat(video): add expired state and regenerate button to video viewer
Implement expired video state handling with a regenerate button. The viewer now checks if the video has expired and shows appropriate UI with a regeneration option (currently unimplemented).
2025-10-13 14:58:45 +08:00
icarus
1c89262929 feat(video): add video download functionality
implement video download feature with progress tracking and error handling
update video status and thumbnail types to support null values
add download error message to i18n
2025-10-13 14:44:16 +08:00
icarus
b68a0ffaba feat(video): add name and providerId fields to video types
Add required name and providerId fields to video interfaces and update all related implementations including mock data and hook usage. This ensures consistent video object structure across the application.
2025-10-13 14:24:45 +08:00
icarus
41041fa296 feat(video): add download button for completed videos
Implement download button UI for completed videos. Currently shows a toast notification as the functionality is not yet implemented.
2025-10-13 14:20:07 +08:00
icarus
66b88aec74 refactor(video): add commented mock video code and fix dependency array 2025-10-13 14:05:02 +08:00
icarus
f54e583f34 refactor(video): extract video status components and fix type names
Extract inline video status rendering logic into separate components for better maintainability. Also fix inconsistent type naming (Videodownloaded -> VideoDownloaded, VideoFailed -> VideoFailedBase) and properly type VideoFailed as OpenAIVideoFailed.
2025-10-13 14:03:54 +08:00
icarus
1e1bfafb88 refactor(video): rename VideoProps to VideoViewerProps for clarity 2025-10-13 13:44:56 +08:00
icarus
63459e3ec4 feat(video): add error details modal for failed videos
Implement a modal dialog to display detailed error information when a video processing fails. This provides better visibility into failure reasons compared to just showing a generic error state.
2025-10-13 13:43:40 +08:00
icarus
de10a7fd6c refactor(video): improve video status update handling with ref
- Use useRef to track videos state to avoid stale closures
- Add error handling for video status updates
- Remove hardcoded openai provider check in favor of dynamic provider
2025-10-13 13:33:12 +08:00
icarus
dced99ce57 refactor(video): clean up unused imports and hooks in video components
Remove unused imports and hooks from VideoPage and useOpenAIVideo
Simplify useOpenAIVideo by removing unnecessary effect and dependencies
2025-10-13 13:33:00 +08:00
icarus
0cafdeb540 refactor(video): remove mock data and use real videos from provider 2025-10-13 13:24:43 +08:00
icarus
258666e382 refactor(video): remove unused useVideos hook import 2025-10-13 13:23:06 +08:00
icarus
8a45fe70d0 refactor(video): update video handling and type definitions
- Rename RetrieveVideoParams to RetrieveVideoContentParams for consistency
- Move video list management to parent component
- Add setVideo action and improve video state updates
- Implement video status polling and thumbnail fetching
2025-10-13 13:22:49 +08:00
icarus
d8363b5591 docs(video): add jsdoc comments for VideoStatus enum values
Explain the meaning of each VideoStatus value in the interface documentation
2025-10-12 21:33:53 +08:00
icarus
397a24b833 Merge branch 'main' of github.com:CherryHQ/cherry-studio into feat/sora2 2025-10-12 21:18:49 +08:00
icarus
ca53e5f0c7 build(deps): update openai dependency to forked version
Replace openai npm package with forked version @cherrystudio/openai@6.3.0-fork.1
Update package version to 1.7.0-sora.1
2025-10-12 18:13:48 +08:00
icarus
c50a574982 fix(video): handle queued status in video progress updates
Add 'queued' status to the SWR refresh conditions and restructure progress update logic to prevent potential race conditions when video status changes from queued to in_progress
2025-10-12 17:52:48 +08:00
icarus
c3c125f3a3 feat(video): add video status tracking and thumbnail handling
- Implement useVideo hook for single video retrieval
- Make thumbnail optional in VideoCompleted interface
- Add prompt parameter to addOpenAIVideo and handle progress updates
- Add auto-refresh for in-progress videos and update progress
2025-10-12 17:37:56 +08:00
icarus
eba370210f feat(video): add useOpenAIVideo hook for fetching video data
Implement a custom hook using SWR to fetch and manage OpenAI video data with revalidation capabilities
2025-10-12 17:17:35 +08:00
icarus
697ef22ab6 refactor(video): replace mock videos with real data from useVideos hook 2025-10-12 17:16:00 +08:00
icarus
33582a460b fix(video): set empty string as default prompt instead of undefined 2025-10-12 08:14:10 +08:00
icarus
d5078baa20 refactor(VideoViewer): remove unused imports and radio group component
Clean up code by removing unused imports and commented out radio group component
2025-10-12 08:11:23 +08:00
icarus
ae54d5d9b9 fix(video): handle undefined video case in VideoPanel
Add conditional check to handle undefined video case and show toast for unimplemented remix video feature.
2025-10-12 08:09:19 +08:00
icarus
7bde37680e fix(video): handle undefined video case and add new video button
Add fallback for undefined video case in VideoPanel to clear prompt params
Add PlusIcon button in VideoList to allow creating new videos by setting activeVideoId to undefined
2025-10-12 08:05:02 +08:00
icarus
942c239d14 feat(video): add mock data and improve video panel handling
- Add mock video data for testing purposes
- Improve video panel state management with useEffect
- Export video types from index file
2025-10-12 07:56:45 +08:00
icarus
83114ee0c1 feat(video): add active video selection to VideoList
- Introduce activeVideoId state to track selected video
- Update VideoList to highlight active video with border
- Pass click handler to set active video
2025-10-12 07:44:43 +08:00
icarus
0dd894c911 feat(i18n): update video status messages and add thumbnail placeholder
- Simplify "failed" status message across languages
- Add thumbnail placeholder text for all locales
- Add error messages for image reference uploads in zh-cn
2025-10-12 07:35:26 +08:00
icarus
e0cb39d00d feat(video): implement video list UI with status indicators and thumbnails
- Add mock data for testing video list display
- Implement status icons, progress bars, and thumbnail display
- Add hover effects and styling for video items
- Update video types to include thumbnail and prompt fields
2025-10-12 07:35:06 +08:00
icarus
12323375a5 feat(video): add image reference upload with validation
implement image reference upload functionality in video panel
add validation for file format and size (max 5MB)
replace lodash merge with custom deepUpdate utility
add error messages for invalid uploads
2025-10-12 07:00:23 +08:00
icarus
788b170f98 feat(video): pass params to VideoPanel for state management
Move prompt state management to parent component to maintain consistency across video creation flow
2025-10-12 06:00:42 +08:00
icarus
42015b51e3 feat(i18n): add new translations for video features and common actions
- Add complete Chinese translations for video-related terms and statuses
- Add new common action translations (redownload, retry, send) in multiple languages
- Mark video-related terms for translation in other languages
2025-10-12 05:52:53 +08:00
icarus
9997188f5e refactor(video): extract size update logic into separate callback
Improve code maintainability by separating size update logic into its own useCallback hook
2025-10-12 05:48:53 +08:00
icarus
1fd7b0b667 feat(video): add settings component for OpenAI video params
Add OpenAIParamSettings component to handle video duration and size selection
Include new i18n translations for seconds and size labels
2025-10-12 05:45:58 +08:00
icarus
1467493e1d refactor(video-settings): improve settings layout and remove unused components
- Remove SettingTitle component and move label to Select component
- Update SettingsGroup styling for better spacing and borders
- Clean up unused imports in shared components
2025-10-12 05:32:41 +08:00
icarus
f61cadd5b5 feat(video): add video model validation and settings improvements
- Introduce new utility and config files for video model validation
- Refactor ModelSetting component to use centralized video models config
- Update VideoPage to handle video params with proper model validation
2025-10-12 05:32:26 +08:00
icarus
377b2b796f feat(video-settings): add SettingsGroup component and update SettingItem divider default
Update SettingItem to have divider=false by default and introduce new SettingsGroup component for better organization
2025-10-12 04:54:17 +08:00
icarus
36df06db75 refactor(video): update import path for useAddOpenAIVideo hook 2025-10-12 04:53:59 +08:00
icarus
a901943675 refactor(video): rename useOpenAIVideos to useAddOpenAIVideo 2025-10-12 04:53:45 +08:00
icarus
953f0f4a2f fix(video-viewer): handle undefined video status in radio group 2025-10-12 04:33:23 +08:00
icarus
8b875935d0 feat(video): add onPress handler to video send button
Handle video creation when the send button is pressed
2025-10-12 04:32:31 +08:00
icarus
2f9b174095 feat(video): add image reference button and send tooltip
Add button for image reference with tooltip in video panel
Include send button tooltip using i18n translation
2025-10-12 04:31:20 +08:00
icarus
d80eac2fbe feat(video): add error handling and loading state for video creation
handle video creation errors by showing toast notifications and prevent multiple submissions by adding a loading state
2025-10-12 04:21:54 +08:00
icarus
5776512bf6 fix(ToastPortal): prevent text overflow by adjusting toast width styles 2025-10-12 04:13:32 +08:00
icarus
fd1a3faa69 feat(video): add video list component to display videos
Implement VideoList component to show videos from a provider. Replace placeholder div with the new component in VideoPage.
2025-10-12 03:49:25 +08:00
icarus
82ad9e15e2 feat(video): enhance video error handling with retry options
Add retry and redownload buttons for failed video loading states
Improve error message display with detailed failure reason
2025-10-12 03:44:16 +08:00
icarus
46221985bd feat(video): add video loading error handling and status display
- Add new error message for video loading failure in i18n
- Implement video loading state and error handling in VideoViewer
- Display appropriate UI for loading errors when video fails to load
2025-10-12 03:34:22 +08:00
icarus
d982c659d3 refactor(video): rename VideoPlayer to VideoViewer and improve layout
Move status radio group to absolute position and update background colors
2025-10-12 03:22:19 +08:00
icarus
dad9425b44 feat(video): pass provider prop to VideoPanel component
Add useProvider hook to fetch provider data and pass it to VideoPanel to enable provider-specific functionality
2025-10-12 03:15:22 +08:00
icarus
dc19c17526 feat(video): add status handling and error messages to video player
- Add new i18n strings for video status and error messages
- Implement status-based UI rendering with progress indicators
- Include test radio group for status simulation
2025-10-12 03:13:23 +08:00
icarus
85c8d5fca2 refactor(video): rename Video component to VideoPlayer and pass video prop
Update VideoPanel to use VideoPlayer component instead of Video and accept video as a prop
2025-10-12 02:49:15 +08:00
icarus
4cf4c1e946 feat(video): add OpenAI video creation support in VideoPanel
- Integrate OpenAI video creation API with proper provider handling
- Add keyboard event to trigger video creation on Enter key
2025-10-12 02:44:20 +08:00
icarus
00221471b8 feat(video): add hook for handling OpenAI video status updates 2025-10-12 02:40:12 +08:00
icarus
6d22a635f2 feat(video): add downloading status and metadata to video types
Add new video status types for downloading state and include metadata in OpenAIVideoBase. This allows better tracking of video processing stages and provides access to video metadata.
2025-10-12 02:40:04 +08:00
icarus
014247f983 refactor(videos): move useVideos to videos folder 2025-10-12 02:39:41 +08:00
icarus
7fe4524415 feat(hooks): add useVideos hook for video management
Implement custom hook to handle video operations including add, update, remove and set videos
2025-10-12 02:10:00 +08:00
icarus
0ada5656ad fix(video): make videoMap entries optional and handle undefined cases
Fix potential runtime errors by properly handling undefined videoMap entries. Update type definition and add null checks for videoMap operations.
2025-10-12 02:01:54 +08:00
icarus
c7c6561b77 Merge branch 'main' of github.com:CherryHQ/cherry-studio into feat/sora2 2025-10-12 01:51:51 +08:00
icarus
590d69cfba feat(video): add video store module and migration
- Initialize video store module with state management for video operations
- Add video state to root reducer
- Extend video types with id field and specific OpenAIVideo types
- Include video store in migration to initialize videoMap
2025-10-12 01:50:33 +08:00
icarus
9487eaf091 feat(video): add video status types for different processing states 2025-10-12 01:31:52 +08:00
icarus
1235362c82 feat(video): add support for retrieving video content from OpenAI
Implement new interfaces and methods to handle video content retrieval from OpenAI, including type definitions and API client integration
2025-10-12 01:12:01 +08:00
icarus
5db5d69cec feat(video): add retrieve video functionality for OpenAI
Implement video retrieval endpoint and integrate it through the API client stack. This enables fetching existing video resources from OpenAI's API.
2025-10-12 00:52:09 +08:00
icarus
9931856a1f refactor(video): restructure video types and add createVideo service
- Split video types into base interfaces and OpenAI-specific implementations
- Add new createVideo service function to handle video creation
2025-10-12 00:37:23 +08:00
icarus
833d2d9276 refactor(openai): remove unnecessary await in createVideo method 2025-10-12 00:16:15 +08:00
icarus
a1fde0db38 feat(video): implement OpenAI video creation support
Add video creation functionality using OpenAI SDK. Update types to match OpenAI's video API and implement the actual creation method in the OpenAI client.
2025-10-11 19:19:54 +08:00
icarus
612d3756cf feat(i18n): add video translation keys for multiple locales
Add new translation keys for video feature in zh-cn locale and placeholder keys in other locales
2025-10-11 19:11:57 +08:00
icarus
05ad98bb20 build: replace openai package with @cherrystudio/openai fork
Update all imports from 'openai' to '@cherrystudio/openai' across the codebase
Remove openai patch from package.json and add @cherrystudio/openai dependency
2025-10-11 19:11:37 +08:00
icarus
1c53222582 feat(video): add video creation types and stubs for future implementation 2025-10-11 17:57:19 +08:00
icarus
c6a0ad3fc0 feat(video): add model selection to video settings
Add ModelSetting component to allow selecting video generation models
2025-10-11 17:40:15 +08:00
icarus
ab2aa8380f feat(video): add video panel component with error handling
Implement video panel with placeholder prompt input and video display area
Add error states for invalid and undefined video cases
Update i18n strings for video related messages
2025-10-11 17:22:14 +08:00
icarus
45bdea5301 feat(video): add provider settings component and layout
Implement provider selection dropdown in video settings panel
Add shared setting components for consistent styling
Update video page layout to accommodate settings sidebar
2025-10-11 16:37:39 +08:00
icarus
0f14b1625f feat(video): add video page and sidebar integration
- Add new video page component with basic structure
- Include video icon in sidebar and launchpad
- Update i18n labels for video feature
- Increment store version and add migration for video icon
2025-10-11 15:39:38 +08:00
76 changed files with 2858 additions and 100 deletions

Binary file not shown.

View File

@@ -1,6 +1,6 @@
{
"name": "CherryStudio",
"version": "1.7.0-beta.1",
"version": "1.7.0-sora.3",
"private": true,
"description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js",
@@ -126,6 +126,7 @@
"@cherrystudio/embedjs-ollama": "^0.1.31",
"@cherrystudio/embedjs-openai": "^0.1.31",
"@cherrystudio/extension-table-plus": "workspace:^",
"@cherrystudio/openai": "6.3.0-fork.1",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
@@ -296,7 +297,6 @@
"motion": "^12.10.5",
"notion-helper": "^1.3.22",
"npx-scope-finder": "^1.2.0",
"openai": "patch:openai@npm%3A5.12.2#~/.yarn/patches/openai-npm-5.12.2-30b075401c.patch",
"oxlint": "^1.22.0",
"oxlint-tsgolint": "^0.2.0",
"p-queue": "^8.1.0",
@@ -376,8 +376,8 @@
"file-stream-rotator@npm:^0.6.1": "patch:file-stream-rotator@npm%3A0.6.1#~/.yarn/patches/file-stream-rotator-npm-0.6.1-eab45fb13d.patch",
"libsql@npm:^0.4.4": "patch:libsql@npm%3A0.4.7#~/.yarn/patches/libsql-npm-0.4.7-444e260fb1.patch",
"node-abi": "4.12.0",
"openai@npm:^4.77.0": "patch:openai@npm%3A5.12.2#~/.yarn/patches/openai-npm-5.12.2-30b075401c.patch",
"openai@npm:^4.87.3": "patch:openai@npm%3A5.12.2#~/.yarn/patches/openai-npm-5.12.2-30b075401c.patch",
"openai@npm:^4.77.0": "npm:@cherrystudio/openai@6.3.0-fork.1",
"openai@npm:^4.87.3": "npm:@cherrystudio/openai@6.3.0-fork.1",
"pdf-parse@npm:1.1.1": "patch:pdf-parse@npm%3A1.1.1#~/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch",
"pkce-challenge@npm:^4.1.0": "patch:pkce-challenge@npm%3A4.1.0#~/.yarn/patches/pkce-challenge-npm-4.1.0-fbc51695a3.patch",
"tar-fs": "^2.1.4",

View File

@@ -2,9 +2,9 @@
* 该脚本用于少量自动翻译所有baseLocale以外的文本。待翻译文案必须以[to be translated]开头
*
*/
import OpenAI from '@cherrystudio/openai'
import cliProgress from 'cli-progress'
import * as fs from 'fs'
import OpenAI from 'openai'
import * as path from 'path'
const localesDir = path.join(__dirname, '../src/renderer/src/i18n/locales')

View File

@@ -4,9 +4,9 @@
* API_KEY=sk-xxxx BASE_URL=xxxx MODEL=xxxx ts-node scripts/update-i18n.ts
*/
import OpenAI from '@cherrystudio/openai'
import cliProgress from 'cli-progress'
import fs from 'fs'
import OpenAI from 'openai'
type I18NValue = string | { [key: string]: I18NValue }
type I18N = { [key: string]: I18NValue }

View File

@@ -1,5 +1,5 @@
import { ChatCompletionCreateParams } from '@cherrystudio/openai/resources'
import express, { Request, Response } from 'express'
import { ChatCompletionCreateParams } from 'openai/resources'
import { loggerService } from '../../services/LoggerService'
import {

View File

@@ -1,6 +1,6 @@
import OpenAI from '@cherrystudio/openai'
import { ChatCompletionCreateParams, ChatCompletionCreateParamsStreaming } from '@cherrystudio/openai/resources'
import { Provider } from '@types'
import OpenAI from 'openai'
import { ChatCompletionCreateParams, ChatCompletionCreateParamsStreaming } from 'openai/resources'
import { loggerService } from '../../services/LoggerService'
import { ModelValidationError, validateModelId } from '../utils'

View File

@@ -1,8 +1,8 @@
import OpenAI from '@cherrystudio/openai'
import { loggerService } from '@logger'
import { fileStorage } from '@main/services/FileStorage'
import { FileListResponse, FileMetadata, FileUploadResponse, Provider } from '@types'
import * as fs from 'fs'
import OpenAI from 'openai'
import { CacheService } from '../CacheService'
import { BaseFileService } from './BaseFileService'

View File

@@ -20,6 +20,7 @@ import PaintingsRoutePage from './pages/paintings/PaintingsRoutePage'
import SettingsPage from './pages/settings/SettingsPage'
import AssistantPresetsPage from './pages/store/assistants/presets/AssistantPresetsPage'
import TranslatePage from './pages/translate/TranslatePage'
import { VideoPage } from './pages/video/VideoPage'
const Router: FC = () => {
const { navbarPosition } = useNavbarPosition()
@@ -40,6 +41,7 @@ const Router: FC = () => {
<Route path="/code" element={<CodeToolsPage />} />
<Route path="/settings/*" element={<SettingsPage />} />
<Route path="/launchpad" element={<LaunchpadPage />} />
<Route path="/video" element={<VideoPage />} />
</Routes>
</ErrorBoundary>
)

View File

@@ -12,8 +12,23 @@ 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 } 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,
CreateVideoResult,
RetrieveVideoContentResult,
RetrieveVideoParams,
RetrieveVideoResult
} from '@renderer/types/video'
import { buildClaudeCodeSystemModelMessage } from '@shared/anthropic'
import { type ImageModel, type LanguageModel, type Provider as AiSdkProvider, wrapLanguageModel } from 'ai'
@@ -498,6 +513,34 @@ export default class ModernAiProvider {
return images
}
/**
* We manually implement this method before aisdk supports it well
*/
public async createVideo(params: CreateVideoParams): Promise<CreateVideoResult> {
return this.legacyProvider.createVideo(params)
}
/**
* We manually implement this method before aisdk supports it well
*/
public async retrieveVideo(params: RetrieveVideoParams): Promise<RetrieveVideoResult> {
return this.legacyProvider.retrieveVideo(params)
}
/**
* We manually implement this method before aisdk supports it well
*/
public async retrieveVideoContent(params: RetrieveVideoContentParams): Promise<RetrieveVideoContentResult> {
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()
}

View File

@@ -1,6 +1,6 @@
import OpenAI from '@cherrystudio/openai'
import { Provider } from '@renderer/types'
import { OpenAISdkParams, OpenAISdkRawOutput } from '@renderer/types/sdk'
import OpenAI from 'openai'
import { OpenAIAPIClient } from '../openai/OpenAIApiClient'

View File

@@ -1,3 +1,9 @@
import OpenAI, { AzureOpenAI } from '@cherrystudio/openai'
import {
ChatCompletionContentPart,
ChatCompletionContentPartRefusal,
ChatCompletionTool
} from '@cherrystudio/openai/resources'
import { loggerService } from '@logger'
import { DEFAULT_MAX_TOKENS } from '@renderer/config/constant'
import {
@@ -78,8 +84,6 @@ import {
} from '@renderer/utils/mcp-tools'
import { findFileBlocks, findImageBlocks } from '@renderer/utils/messageUtils/find'
import { t } from 'i18next'
import OpenAI, { AzureOpenAI } from 'openai'
import { ChatCompletionContentPart, ChatCompletionContentPartRefusal, ChatCompletionTool } from 'openai/resources'
import { GenericChunk } from '../../middleware/schemas'
import { RequestTransformer, ResponseChunkTransformer, ResponseChunkTransformerContext } from '../types'

View File

@@ -1,3 +1,4 @@
import OpenAI, { AzureOpenAI } from '@cherrystudio/openai'
import { loggerService } from '@logger'
import { COPILOT_DEFAULT_HEADERS } from '@renderer/aiCore/provider/constants'
import {
@@ -25,7 +26,6 @@ import {
ReasoningEffortOptionalParams
} from '@renderer/types/sdk'
import { formatApiHost } from '@renderer/utils/api'
import OpenAI, { AzureOpenAI } from 'openai'
import { BaseApiClient } from '../BaseApiClient'

View File

@@ -1,3 +1,5 @@
import OpenAI, { AzureOpenAI } from '@cherrystudio/openai'
import { ResponseInput } from '@cherrystudio/openai/resources/responses/responses'
import { loggerService } from '@logger'
import { GenericChunk } from '@renderer/aiCore/legacy/middleware/schemas'
import { CompletionsContext } from '@renderer/aiCore/legacy/middleware/types'
@@ -34,6 +36,12 @@ import {
OpenAIResponseSdkTool,
OpenAIResponseSdkToolCall
} from '@renderer/types/sdk'
import {
CreateVideoParams,
DeleteVideoParams,
RetrieveVideoContentParams,
RetrieveVideoParams
} from '@renderer/types/video'
import { addImageFileToContents } from '@renderer/utils/formats'
import {
isSupportedToolUse,
@@ -45,8 +53,6 @@ import { findFileBlocks, findImageBlocks } from '@renderer/utils/messageUtils/fi
import { MB } from '@shared/config/constant'
import { t } from 'i18next'
import { isEmpty } from 'lodash'
import OpenAI, { AzureOpenAI } from 'openai'
import { ResponseInput } from 'openai/resources/responses/responses'
import { RequestTransformer, ResponseChunkTransformer } from '../types'
import { OpenAIAPIClient } from './OpenAIApiClient'
@@ -152,6 +158,26 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
return await sdk.responses.create(payload, options)
}
public async createVideo(params: CreateVideoParams): Promise<OpenAI.Videos.Video> {
const sdk = await this.getSdkInstance()
return sdk.videos.create(params.params, params.options)
}
public async retrieveVideo(params: RetrieveVideoParams): Promise<OpenAI.Videos.Video> {
const sdk = await this.getSdkInstance()
return sdk.videos.retrieve(params.videoId, params.options)
}
public async retrieveVideoContent(params: RetrieveVideoContentParams): Promise<Response> {
const sdk = await this.getSdkInstance()
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 {
@@ -343,7 +369,14 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
}
switch (message.type) {
case 'function_call_output':
sum += estimateTextTokens(message.output)
if (typeof message.output === 'string') {
sum += estimateTextTokens(message.output)
} else {
sum += message.output
.filter((item) => item.type === 'input_text')
.map((item) => estimateTextTokens(item.text))
.reduce((prev, cur) => prev + cur, 0)
}
break
case 'function_call':
sum += estimateTextTokens(message.arguments)

View File

@@ -1,7 +1,7 @@
import OpenAI from '@cherrystudio/openai'
import { loggerService } from '@logger'
import { isSupportedModel } from '@renderer/config/models'
import { objectKeys, Provider } from '@renderer/types'
import OpenAI from 'openai'
import { OpenAIAPIClient } from '../openai/OpenAIApiClient'

View File

@@ -1,7 +1,7 @@
import OpenAI from '@cherrystudio/openai'
import { loggerService } from '@logger'
import { isSupportedModel } from '@renderer/config/models'
import { Model, Provider } from '@renderer/types'
import OpenAI from 'openai'
import { OpenAIAPIClient } from '../openai/OpenAIApiClient'

View File

@@ -1,4 +1,5 @@
import Anthropic from '@anthropic-ai/sdk'
import OpenAI from '@cherrystudio/openai'
import { Assistant, MCPTool, MCPToolResponse, Model, ToolCallResponse } from '@renderer/types'
import { Provider } from '@renderer/types'
import {
@@ -13,7 +14,6 @@ import {
SdkTool,
SdkToolCall
} from '@renderer/types/sdk'
import OpenAI from 'openai'
import { CompletionsParams, GenericChunk } from '../middleware/schemas'
import { CompletionsContext } from '../middleware/types'

View File

@@ -1,7 +1,7 @@
import OpenAI from '@cherrystudio/openai'
import { loggerService } from '@logger'
import { Provider } from '@renderer/types'
import { GenerateImageParams } from '@renderer/types'
import OpenAI from 'openai'
import { OpenAIAPIClient } from '../openai/OpenAIApiClient'

View File

@@ -5,8 +5,22 @@ 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 } from '@renderer/types'
import type {
DeleteVideoParams,
DeleteVideoResult,
GenerateImageParams,
Model,
Provider,
RetrieveVideoContentParams
} from '@renderer/types'
import type { RequestOptions, SdkModel } from '@renderer/types/sdk'
import {
CreateVideoParams,
CreateVideoResult,
RetrieveVideoContentResult,
RetrieveVideoParams,
RetrieveVideoResult
} from '@renderer/types/video'
import { isSupportedToolUse } from '@renderer/utils/mcp-tools'
import { AihubmixAPIClient } from './clients/aihubmix/AihubmixAPIClient'
@@ -179,6 +193,54 @@ export default class AiProvider {
return this.apiClient.generateImage(params)
}
public async createVideo(params: CreateVideoParams): Promise<CreateVideoResult> {
if (this.apiClient instanceof OpenAIResponseAPIClient && params.type === 'openai') {
const video = await this.apiClient.createVideo(params)
return {
type: 'openai',
video
}
} else {
throw new Error('Video generation is not supported by this provider')
}
}
public async retrieveVideo(params: RetrieveVideoParams): Promise<RetrieveVideoResult> {
if (this.apiClient instanceof OpenAIResponseAPIClient && params.type === 'openai') {
const video = await this.apiClient.retrieveVideo(params)
return {
type: 'openai',
video
}
} else {
throw new Error('Video generation is not supported by this provider')
}
}
public async retrieveVideoContent(params: RetrieveVideoContentParams): Promise<RetrieveVideoContentResult> {
if (this.apiClient instanceof OpenAIResponseAPIClient && params.type === 'openai') {
const response = await this.apiClient.retrieveVideoContent(params)
return {
type: 'openai',
response
}
} else {
throw new Error('Video generation is not supported by this provider')
}
}
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()
}

View File

@@ -1,10 +1,10 @@
import OpenAI from '@cherrystudio/openai'
import { toFile } from '@cherrystudio/openai/uploads'
import { isDedicatedImageGenerationModel } from '@renderer/config/models'
import FileManager from '@renderer/services/FileManager'
import { ChunkType } from '@renderer/types/chunk'
import { findImageBlocks, getMainTextContent } from '@renderer/utils/messageUtils/find'
import { defaultTimeout } from '@shared/config/constant'
import OpenAI from 'openai'
import { toFile } from 'openai/uploads'
import { BaseApiClient } from '../../clients/BaseApiClient'
import { CompletionsParams, CompletionsResult, GenericChunk } from '../schemas'

View File

@@ -3,6 +3,7 @@
* 处理文件内容提取、文件格式转换、文件上传等逻辑
*/
import type OpenAI from '@cherrystudio/openai'
import { loggerService } from '@logger'
import { getProviderByModel } from '@renderer/services/AssistantService'
import type { FileMetadata, Message, Model } from '@renderer/types'
@@ -10,7 +11,6 @@ import { FileTypes } from '@renderer/types'
import { FileMessageBlock } from '@renderer/types/newMessage'
import { findFileBlocks } from '@renderer/utils/messageUtils/find'
import type { FilePart, TextPart } from 'ai'
import type OpenAI from 'openai'
import { getAiSdkProviderId } from '../provider/factory'
import { getFileSizeLimit, supportsImageInput, supportsLargeFileUpload, supportsPdfInput } from './modelCapabilities'

View File

@@ -32,6 +32,7 @@ import {
Sparkle,
Sun,
Terminal,
Video,
X
} from 'lucide-react'
import { useCallback, useEffect, useMemo } from 'react'
@@ -106,6 +107,8 @@ const getTabIcon = (
return <Settings size={14} />
case 'code':
return <Terminal size={14} />
case 'video':
return <Video size={14} />
default:
return null
}

View File

@@ -23,7 +23,8 @@ export const ToastPortal = () => {
timeout: 3000,
classNames: {
// This setting causes the 'hero-toast' class to be applied twice to the toast element. This is weird and I don't know why, but it works.
base: 'hero-toast'
// `w-auto` would not overwrite default style, which set the width to a fixed value and causes text overflow.
base: 'hero-toast w-auto! max-w-[50vw]'
}
}}
/>,

View File

@@ -1,5 +1,6 @@
import { addToast, closeAll, closeToast, getToastQueue, isToastClosing } from '@heroui/toast'
import { RequireSome } from '@renderer/types'
import { t } from 'i18next'
type AddToastProps = Parameters<typeof addToast>[0]
type ToastPropsColored = Omit<AddToastProps, 'color'>
@@ -54,7 +55,7 @@ export const loading = (args: RequireSome<AddToastProps, 'promise'>) => {
if (args.timeout === undefined) {
args.timeout = 1
}
return addToast(args)
return addToast({ title: t('common.loading'), ...args })
}
export const getToastUtilities = () =>

View File

@@ -26,7 +26,8 @@ import {
Palette,
Settings,
Sparkle,
Sun
Sun,
Video
} from 'lucide-react'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
@@ -139,7 +140,8 @@ const MainMenus: FC = () => {
knowledge: <FileSearch size={18} className="icon" />,
files: <Folder size={18} className="icon" />,
notes: <NotepadText size={18} className="icon" />,
code_tools: <Code size={18} className="icon" />
code_tools: <Code size={18} className="icon" />,
video: <Video size={18} className="icon" />
}
const pathMap = {
@@ -151,7 +153,8 @@ const MainMenus: FC = () => {
knowledge: '/knowledge',
files: '/files',
code_tools: '/code',
notes: '/notes'
notes: '/notes',
video: '/video'
}
return sidebarIcons.visible.map((icon) => {

View File

@@ -1,7 +1,7 @@
import OpenAI from '@cherrystudio/openai'
import { isEmbeddingModel, isRerankModel } from '@renderer/config/models/embedding'
import { Model } from '@renderer/types'
import { getLowerBaseModelName } from '@renderer/utils'
import OpenAI from 'openai'
import { WEB_SEARCH_PROMPT_FOR_OPENROUTER } from '../prompts'
import { getWebSearchTools } from '../tools'

View File

@@ -0,0 +1,149 @@
import { SystemProviderId, Video } from '@renderer/types'
// Hard-encoded for now. We may implement a function to filter video generation model from provider.models.
export const videoModelsMap = {
openai: ['sora-2', 'sora-2-pro'] as const
} as const satisfies Partial<Record<SystemProviderId, string[]>>
// Mock data for testing
export const mockVideos: Video[] = [
{
id: '1',
type: 'openai',
status: 'downloaded',
prompt: 'A beautiful sunset over the ocean with waves crashing',
thumbnail: 'https://picsum.photos/200/200?random=1',
fileId: 'file-001',
providerId: 'openai',
name: 'video-001',
metadata: {
id: 'video-001',
object: 'video',
created_at: Math.floor(Date.now() / 1000),
completed_at: Math.floor(Date.now() / 1000),
expires_at: null,
error: null,
model: 'sora-2',
progress: 100,
remixed_from_video_id: null,
seconds: '4',
size: '1280x720',
status: 'completed'
}
},
{
id: '2',
type: 'openai',
status: 'in_progress',
prompt: 'A cat playing with a ball of yarn in slow motion',
progress: 65,
providerId: 'openai',
name: 'video-002',
metadata: {
id: 'video-002',
object: 'video',
created_at: Math.floor(Date.now() / 1000),
completed_at: null,
expires_at: null,
error: null,
model: 'sora-2-pro',
progress: 65,
remixed_from_video_id: null,
seconds: '8',
size: '1792x1024',
status: 'in_progress'
}
},
{
id: '3',
type: 'openai',
status: 'queued',
prompt: 'Time-lapse of flowers blooming in a garden',
providerId: 'openai',
name: 'video-003',
metadata: {
id: 'video-003',
object: 'video',
created_at: Math.floor(Date.now() / 1000),
completed_at: null,
expires_at: null,
error: null,
model: 'sora-2',
progress: 0,
remixed_from_video_id: null,
seconds: '12',
size: '1280x720',
status: 'queued'
}
},
{
id: '4',
type: 'openai',
prompt: 'Birds flying in formation against blue sky',
status: 'downloading',
progress: 80,
thumbnail: 'https://picsum.photos/200/200?random=4',
providerId: 'openai',
name: 'video-004',
metadata: {
id: 'video-004',
object: 'video',
created_at: Math.floor(Date.now() / 1000),
completed_at: Math.floor(Date.now() / 1000),
expires_at: null,
error: null,
model: 'sora-2-pro',
progress: 100,
remixed_from_video_id: null,
seconds: '8',
size: '1792x1024',
status: 'completed'
}
},
{
id: '5',
type: 'openai',
status: 'failed',
error: { code: '400', message: 'Video generation failed' },
prompt: 'Mountain landscape with snow peaks and forest',
providerId: 'openai',
name: 'video-005',
metadata: {
id: 'video-005',
object: 'video',
created_at: Math.floor(Date.now() / 1000),
completed_at: Math.floor(Date.now() / 1000),
expires_at: null,
error: { code: '400', message: 'Video generation failed' },
model: 'sora-2',
progress: 0,
remixed_from_video_id: null,
seconds: '4',
size: '1280x720',
status: 'failed'
}
},
{
id: '6',
type: 'openai',
status: 'completed',
thumbnail: 'https://picsum.photos/200/200?random=6',
prompt: 'City street at night with neon lights reflecting on wet pavement',
providerId: 'openai',
name: 'video-006',
metadata: {
id: 'video-006',
object: 'video',
created_at: Math.floor(Date.now() / 1000),
completed_at: Math.floor(Date.now() / 1000),
expires_at: null,
error: null,
model: 'sora-2-pro',
progress: 100,
remixed_from_video_id: null,
seconds: '12',
size: '1024x1792',
status: 'completed'
}
}
]

View File

@@ -1,5 +1,5 @@
import { ChatCompletionTool } from '@cherrystudio/openai/resources'
import { Model } from '@renderer/types'
import { ChatCompletionTool } from 'openai/resources'
import { WEB_SEARCH_PROMPT_FOR_ZHIPU } from './prompts'

View File

@@ -0,0 +1,17 @@
import { useAppDispatch } from '@renderer/store'
import { setPendingAction } from '@renderer/store/runtime'
import { useCallback } from 'react'
import { useRuntime } from './useRuntime'
export const usePending = () => {
const { pendingMap } = useRuntime()
const dispatch = useAppDispatch()
const setPending = useCallback(
(id: string, value: boolean | undefined) => {
dispatch(setPendingAction({ id, value }))
},
[dispatch]
)
return { pendingMap, setPending }
}

View File

@@ -0,0 +1,65 @@
import OpenAI from '@cherrystudio/openai'
import { useCallback } from 'react'
import { useProviderVideos } from './useProviderVideos'
export const useAddOpenAIVideo = (providerId: string) => {
const { addVideo } = useProviderVideos(providerId)
const addOpenAIVideo = useCallback(
(video: OpenAI.Videos.Video, prompt: string) => {
switch (video.status) {
case 'queued':
addVideo({
id: video.id,
name: video.id,
providerId,
status: video.status,
type: 'openai',
metadata: video,
prompt
})
break
case 'in_progress':
addVideo({
id: video.id,
name: video.id,
providerId,
status: 'in_progress',
type: 'openai',
progress: video.progress,
metadata: video,
prompt
})
break
case 'completed':
addVideo({
id: video.id,
name: video.id,
providerId,
status: 'completed',
type: 'openai',
metadata: video,
prompt,
thumbnail: null
})
break
case 'failed':
addVideo({
id: video.id,
name: video.id,
providerId,
status: 'failed',
type: 'openai',
error: video.error,
metadata: video,
prompt
})
break
}
},
[addVideo, providerId]
)
return addOpenAIVideo
}

View File

@@ -0,0 +1,47 @@
import { retrieveVideo } from '@renderer/services/ApiService'
import useSWR, { SWRConfiguration, useSWRConfig } from 'swr'
import { useProvider } from '../useProvider'
import { useVideo } from './useVideo'
export const useOpenAIVideo = (providerId: string, id: string) => {
const { provider } = useProvider(providerId)
const fetcher = async () => {
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 = {}
switch (video?.status) {
case 'queued':
case 'in_progress':
options = {
refreshInterval: 3000
}
break
default:
options = {
revalidateOnFocus: false,
revalidateOnMount: true
}
}
const { data, isLoading, error } = useSWR(`video/openai/${id}`, fetcher, options)
const { mutate } = useSWRConfig()
const revalidate = () => mutate(`video/openai/${id}`)
return {
video: data,
isLoading,
error,
revalidate
}
}

View File

@@ -0,0 +1,174 @@
import { loggerService } from '@logger'
import { retrieveVideo } from '@renderer/services/ApiService'
import { getProviderById } from '@renderer/services/ProviderService'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { addVideoAction, setVideoAction, setVideosAction, updateVideoAction } from '@renderer/store/video'
import { Video } from '@renderer/types/video'
import { getErrorMessage } from '@renderer/utils'
import { useCallback, useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import useSWR from 'swr'
import { useVideos } from './useVideos'
import { useVideoThumbnail } from './useVideoThumbnail'
const logger = loggerService.withContext('useVideo')
export const useProviderVideos = (providerId: string) => {
const { removeVideo } = useVideos()
const videos = useAppSelector((state) => state.video.videoMap[providerId])
const videosRef = useRef(videos)
const dispatch = useAppDispatch()
const { t } = useTranslation()
useEffect(() => {
videosRef.current = videos
}, [videos])
const getVideo = useCallback(
(id: string) => {
return videos?.find((v) => v.id === id)
},
[videos]
)
const addVideo = useCallback(
(video: Video) => {
if (videos && videos.every((v) => v.id !== video.id)) {
dispatch(addVideoAction({ providerId, video }))
}
},
[dispatch, providerId, videos]
)
const updateVideo = useCallback(
(update: Partial<Omit<Video, 'status'>> & { id: string }) => {
dispatch(updateVideoAction({ providerId, update }))
},
[dispatch, providerId]
)
const setVideo = useCallback(
(video: Video) => {
dispatch(setVideoAction({ providerId, video }))
},
[dispatch, providerId]
)
const setVideos = useCallback(
(newVideos: Video[]) => {
dispatch(setVideosAction({ providerId, videos: newVideos }))
},
[dispatch, providerId]
)
const removeProviderVideo = useCallback(
(videoId: string) => {
removeVideo(videoId, providerId)
},
[providerId, removeVideo]
)
useEffect(() => {
if (!videos) {
setVideos([])
}
}, [setVideos, videos])
// update videos from api
// NOTE: This provider should support openai videos endpoint. No runtime check here.
const provider = getProviderById(providerId)
const fetcher = async () => {
if (!videos || !provider) return []
if (provider.type === 'openai-response') {
const openaiVideos = videos
.filter((v) => v.type === 'openai')
.filter((v) => v.status === 'queued' || v.status === 'in_progress')
const jobs = openaiVideos.map((v) => retrieveVideo({ type: 'openai', videoId: v.id, provider }))
const result = await Promise.allSettled(jobs)
return result.filter((p) => p.status === 'fulfilled').map((p) => p.value)
} else {
throw new Error(`Provider type ${provider.type} is not supported for video status polling`)
}
}
const { data, error } = useSWR('video/openai/videos', fetcher, { refreshInterval: 3000 })
const { retrieveThumbnail } = useVideoThumbnail()
useEffect(() => {
if (error) {
logger.error('Failed to fetch video status updates', error)
return
}
if (!provider) {
logger.warn(`Provider ${providerId} not found.`)
return
}
const videos = videosRef.current
if (!data || !videos) return
data.forEach((v) => {
const retrievedVideo = v.video
const storeVideo = videos.find((v) => v.id === retrievedVideo.id)
if (!storeVideo) {
logger.warn(`Try to update video ${retrievedVideo.id}, but it's not in the store.`)
return
}
switch (retrievedVideo.status) {
case 'in_progress':
if (storeVideo.status === 'queued' || storeVideo.status === 'in_progress') {
setVideo({
...storeVideo,
status: 'in_progress',
progress: retrievedVideo.progress,
metadata: retrievedVideo
})
}
break
case 'completed': {
if (storeVideo.status === 'in_progress' || storeVideo.status === 'queued') {
const newVideo = { ...storeVideo, status: 'completed', thumbnail: null, metadata: retrievedVideo } as const
setVideo(newVideo)
// Try to get thumbnail
retrieveThumbnail(newVideo)
.then((thumbnail) => {
const latestVideo = videosRef.current?.find((v) => v.id === newVideo.id)
if (
thumbnail !== null &&
latestVideo &&
latestVideo.status !== 'queued' &&
latestVideo.status !== 'in_progress' &&
latestVideo.status !== 'failed'
) {
setVideo({
...latestVideo,
thumbnail
})
}
})
.catch((e) => {
logger.error('Failed to get thumbnail', e as Error)
window.toast.error({ title: t('video.thumbnail.error.get'), description: getErrorMessage(e) })
})
}
break
}
case 'failed':
setVideo({
...storeVideo,
status: 'failed',
error: retrievedVideo.error,
metadata: retrievedVideo
})
}
})
}, [data, error, provider, providerId, retrieveThumbnail, setVideo, t])
return {
videos: videos ?? [],
getVideo,
addVideo,
updateVideo,
setVideos,
setVideo,
removeVideo: removeProviderVideo
}
}

View File

@@ -0,0 +1,7 @@
import { useProviderVideos } from './useProviderVideos'
export const useVideo = (providerId: string, id: string) => {
const { videos } = useProviderVideos(providerId)
const video = videos.find((v) => v.id === id)
return video
}

View File

@@ -0,0 +1,86 @@
import { loggerService } from '@logger'
import { retrieveVideoContent } from '@renderer/services/ApiService'
import ImageStorage from '@renderer/services/ImageStorage'
import { getProviderById } from '@renderer/services/ProviderService'
import { Video } from '@renderer/types'
import { useCallback } from 'react'
const logger = loggerService.withContext('useRetrieveThumbnail')
const pendingSet = new Set<string>()
export const useVideoThumbnail = () => {
const getThumbnailKey = useCallback((id: string) => {
return `video-thumbnail-${id}`
}, [])
const retrieveThumbnail = useCallback(
async (video: Video): Promise<string> => {
const provider = getProviderById(video.providerId)
if (!provider) {
throw new Error(`Provider not found for id ${video.providerId}`)
}
const thumbnailKey = getThumbnailKey(video.id)
if (pendingSet.has(thumbnailKey)) {
throw new Error('Thumbnail retrieval already pending')
}
pendingSet.add(thumbnailKey)
try {
const cachedThumbnail = await ImageStorage.get(thumbnailKey)
if (cachedThumbnail) {
return cachedThumbnail
}
const result = await retrieveVideoContent({
type: 'openai',
provider,
videoId: video.id,
query: { variant: 'thumbnail' }
})
const { response } = result
if (!response.ok) {
throw new Error(`Unexpected thumbnail status: ${response.status}`)
}
const blob = await response.blob()
if (!blob || blob.size === 0) {
throw new Error('Thumbnail response body is empty')
}
const base64 = await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onloadend = () => {
if (typeof reader.result === 'string') {
resolve(reader.result)
} else {
reject(new Error('Failed to convert thumbnail to base64'))
}
}
reader.onerror = () => reject(reader.error ?? new Error('Failed to read thumbnail blob'))
reader.readAsDataURL(blob)
})
await ImageStorage.set(thumbnailKey, base64)
return base64
} catch (e) {
logger.error(`Failed to get thumbnail for video ${video.id}`, e as Error)
throw e
} finally {
pendingSet.delete(thumbnailKey)
}
},
[getThumbnailKey]
)
const removeThumbnail = useCallback(
async (id: string) => {
const key = getThumbnailKey(id)
return ImageStorage.remove(key)
},
[getThumbnailKey]
)
return { getThumbnailKey, retrieveThumbnail, removeThumbnail }
}

View File

@@ -0,0 +1,48 @@
import FileManager from '@renderer/services/FileManager'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { removeVideoAction } from '@renderer/store/video'
import { objectValues } from '@renderer/types'
import { useCallback } from 'react'
import { useVideoThumbnail } from './useVideoThumbnail'
export const useVideos = () => {
const videoMap = useAppSelector((state) => state.video.videoMap)
const dispatch = useAppDispatch()
const { removeThumbnail } = useVideoThumbnail()
const videos = objectValues(videoMap)
.flat()
.filter((v) => v !== undefined)
const getVideo = useCallback(
(videoId: string) => {
return videos.find((v) => v.id === videoId)
},
[videos]
)
const removeVideo = useCallback(
(videoId: string, providerId?: string) => {
const video = getVideo(videoId)
if (!video) {
return
}
if (!providerId) {
providerId = video.providerId
}
// should delete from redux state, and related thumbnail image, video file
if (video.thumbnail) {
removeThumbnail(videoId)
}
if (video.fileId) {
FileManager.deleteFile(video.fileId)
}
dispatch(removeVideoAction({ providerId, videoId }))
},
[dispatch, getVideo, removeThumbnail]
)
return { videos, getVideo, removeVideo }
}

View File

@@ -146,7 +146,8 @@ const titleKeyMap = {
notes: 'title.notes',
paintings: 'title.paintings',
settings: 'title.settings',
translate: 'title.translate'
translate: 'title.translate',
video: 'title.video'
} as const
export const getTitleLabel = (key: string): string => {
@@ -172,7 +173,8 @@ const sidebarIconKeyMap = {
knowledge: 'knowledge.title',
files: 'files.title',
code_tools: 'code.title',
notes: 'notes.title'
notes: 'notes.title',
video: 'video.title'
} as const
export const getSidebarIconLabel = (key: string): string => {

View File

@@ -978,6 +978,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",
@@ -1022,10 +1023,12 @@
"prompt": "Prompt",
"provider": "Provider",
"reasoning_content": "Deep reasoning",
"redownload": "Redownload",
"refresh": "Refresh",
"regenerate": "Regenerate",
"rename": "Rename",
"reset": "Reset",
"retry": "Retry",
"save": "Save",
"saved": "Saved",
"search": "Search",
@@ -1033,6 +1036,7 @@
"selected": "Selected",
"selectedItems": "Selected {{count}} items",
"selectedMessages": "Selected {{count}} messages",
"send": "Send",
"settings": "Settings",
"sort": {
"pinyin": {
@@ -1092,6 +1096,9 @@
},
"content": "Content",
"data": "Data",
"delete": {
"failed": "Failed to delete."
},
"detail": "Error Details",
"details": "Details",
"errors": "Errors",
@@ -1199,7 +1206,8 @@
"size": "Size",
"text": "Text",
"title": "Files",
"type": "Type"
"type": "Type",
"video": "Video"
},
"gpustack": {
"keep_alive_time": {
@@ -4506,7 +4514,8 @@
"paintings": "Paintings",
"settings": "Settings",
"store": "Assistant Library",
"translate": "Translate"
"translate": "Translate",
"video": "Video"
},
"trace": {
"backList": "Back To List",
@@ -4664,6 +4673,56 @@
"saveDataError": "Failed to save data, please try again.",
"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.",
"invalid": "Invalid video",
"load": {
"message": "Failed to load the video",
"reason": "The file may be corrupted or has been deleted externally."
}
},
"expired": "Expired",
"input_reference": {
"add": {
"error": {
"format": "Not a image",
"size": "This image is too large. It should be under 5MB."
},
"tooltip": "Add image reference"
}
},
"prompt": {
"placeholder": "describes the video to generate"
},
"seconds": "Seconds",
"size": "Size",
"status": {
"completed": "Generation Completed",
"downloading": "Downloading",
"failed": "Generation Failed",
"in_progress": "Generating",
"queued": "Queued"
},
"thumbnail": {
"error": {
"get": "Failed to get thumbnail"
},
"get": "Get thumbnail",
"placeholder": "No thumbnail"
},
"title": "Video",
"undefined": "No available video"
},
"warning": {
"missing_provider": "The supplier does not exist; reverted to the default supplier {{provider}}. This may cause issues."
},

View File

@@ -978,6 +978,7 @@
"delete_confirm": "确定要删除吗?",
"delete_failed": "删除失败",
"delete_success": "删除成功",
"deleting": "删除中...",
"description": "描述",
"detail": "详情",
"disabled": "已禁用",
@@ -1022,10 +1023,12 @@
"prompt": "提示词",
"provider": "提供商",
"reasoning_content": "已深度思考",
"redownload": "重新下载",
"refresh": "刷新",
"regenerate": "重新生成",
"rename": "重命名",
"reset": "重置",
"retry": "重试",
"save": "保存",
"saved": "已保存",
"search": "搜索",
@@ -1033,6 +1036,7 @@
"selected": "已选择",
"selectedItems": "已选择 {{count}} 项",
"selectedMessages": "选中 {{count}} 条消息",
"send": "发送",
"settings": "设置",
"sort": {
"pinyin": {
@@ -1092,6 +1096,9 @@
},
"content": "内容",
"data": "数据",
"delete": {
"failed": "删除失败"
},
"detail": "错误详情",
"details": "详细信息",
"errors": "错误",
@@ -1199,7 +1206,8 @@
"size": "大小",
"text": "文本",
"title": "文件",
"type": "类型"
"type": "类型",
"video": "视频"
},
"gpustack": {
"keep_alive_time": {
@@ -4506,7 +4514,8 @@
"paintings": "绘画",
"settings": "设置",
"store": "助手库",
"translate": "翻译"
"translate": "翻译",
"video": "视频"
},
"trace": {
"backList": "返回列表",
@@ -4664,6 +4673,56 @@
"saveDataError": "保存数据失败,请重试",
"title": "更新提示"
},
"video": {
"delete": {
"error": {
"not_found": {
"description": "远程未找到该视频,仅会删除本地记录。",
"title": "视频未找到"
}
}
},
"error": {
"create": "创建视频失败",
"download": "视频下载失败",
"invalid": "无效的视频",
"load": {
"message": "加载视频失败",
"reason": "文件可能已损坏或已被外部删除。"
}
},
"expired": "已过期",
"input_reference": {
"add": {
"error": {
"format": "需要上传图片格式的文件",
"size": "图片过大,应小于 5MB"
},
"tooltip": "添加图像参考"
}
},
"prompt": {
"placeholder": "描述要生成的视频"
},
"seconds": "秒数",
"size": "尺寸",
"status": {
"completed": "生成完成",
"downloading": "下载中",
"failed": "生成失败",
"in_progress": "生成中",
"queued": "排队中"
},
"thumbnail": {
"error": {
"get": "[to be translated]:Failed to get thumbnail"
},
"get": "[to be translated]:Get thumbnail",
"placeholder": "无缩略图"
},
"title": "视频",
"undefined": "无可用视频"
},
"warning": {
"missing_provider": "供应商不存在,已回退到默认供应商 {{provider}}。这可能导致问题。"
},

View File

@@ -978,6 +978,7 @@
"delete_confirm": "確定要刪除嗎?",
"delete_failed": "刪除失敗",
"delete_success": "刪除成功",
"deleting": "[to be translated]:Deleting...",
"description": "描述",
"detail": "詳情",
"disabled": "已停用",
@@ -1022,10 +1023,12 @@
"prompt": "提示詞",
"provider": "供應商",
"reasoning_content": "已深度思考",
"redownload": "[to be translated]:Redownload",
"refresh": "重新整理",
"regenerate": "重新生成",
"rename": "重新命名",
"reset": "重設",
"retry": "[to be translated]:Retry",
"save": "儲存",
"saved": "已儲存",
"search": "搜尋",
@@ -1033,6 +1036,7 @@
"selected": "已選擇",
"selectedItems": "已選擇 {{count}} 項",
"selectedMessages": "選中 {{count}} 條訊息",
"send": "[to be translated]:Send",
"settings": "設定",
"sort": {
"pinyin": {
@@ -1092,6 +1096,9 @@
},
"content": "內容",
"data": "数据",
"delete": {
"failed": "[to be translated]:Failed to delete."
},
"detail": "錯誤詳情",
"details": "詳細信息",
"errors": "錯誤",
@@ -1199,7 +1206,8 @@
"size": "大小",
"text": "文字",
"title": "檔案",
"type": "類型"
"type": "類型",
"video": "[to be translated]:Video"
},
"gpustack": {
"keep_alive_time": {
@@ -4506,7 +4514,8 @@
"paintings": "繪畫",
"settings": "設定",
"store": "助手庫",
"translate": "翻譯"
"translate": "翻譯",
"video": "[to be translated]:Video"
},
"trace": {
"backList": "返回清單",
@@ -4664,6 +4673,56 @@
"saveDataError": "保存數據失敗,請重試",
"title": "更新提示"
},
"video": {
"delete": {
"error": {
"not_found": {
"description": "[to be translated]:The video was not found remotely. It will only be deleted locally.",
"title": "[to be translated]:Video not found."
}
}
},
"error": {
"create": "[to be translated]:Failed to create video",
"download": "[to be translated]:Failed to download video.",
"invalid": "[to be translated]:Invalid video",
"load": {
"message": "[to be translated]:Failed to load the video",
"reason": "[to be translated]:The file may be corrupted or has been deleted externally."
}
},
"expired": "[to be translated]:Expired",
"input_reference": {
"add": {
"error": {
"format": "[to be translated]:Not a image",
"size": "[to be translated]:This image is too large. It should be under 5MB."
},
"tooltip": "[to be translated]:Add image reference"
}
},
"prompt": {
"placeholder": "[to be translated]:describes the video to generate"
},
"seconds": "[to be translated]:Seconds",
"size": "[to be translated]:Size",
"status": {
"completed": "[to be translated]:Generation Completed",
"downloading": "[to be translated]:Downloading",
"failed": "[to be translated]:Failed to generate video",
"in_progress": "[to be translated]:Generating",
"queued": "[to be translated]:Queued"
},
"thumbnail": {
"error": {
"get": "[to be translated]:Failed to get thumbnail"
},
"get": "[to be translated]:Get thumbnail",
"placeholder": "[to be translated]:No thumbnail"
},
"title": "[to be translated]:Video",
"undefined": "[to be translated]:No available video"
},
"warning": {
"missing_provider": "供應商不存在,已回退到預設供應商 {{provider}}。這可能導致問題。"
},

View File

@@ -978,6 +978,7 @@
"delete_confirm": "Είστε βέβαιοι ότι θέλετε να διαγράψετε;",
"delete_failed": "Αποτυχία διαγραφής",
"delete_success": "Η διαγραφή ήταν επιτυχής",
"deleting": "[to be translated]:Deleting...",
"description": "Περιγραφή",
"detail": "Λεπτομέρειες",
"disabled": "Απενεργοποιημένο",
@@ -1022,10 +1023,12 @@
"prompt": "Ενδεικτικός ρήματος",
"provider": "Παρέχων",
"reasoning_content": "Έχει σκεφτεί πολύ καλά",
"redownload": "[to be translated]:Redownload",
"refresh": "Ανανέωση",
"regenerate": "Ξαναπαραγωγή",
"rename": "Μετονομασία",
"reset": "Επαναφορά",
"retry": "[to be translated]:Retry",
"save": "Αποθήκευση",
"saved": "Αποθηκεύτηκε",
"search": "Αναζήτηση",
@@ -1033,6 +1036,7 @@
"selected": "Επιλεγμένο",
"selectedItems": "Επιλέχθηκαν {{count}} αντικείμενα",
"selectedMessages": "Επιλέχθηκαν {{count}} μηνύματα",
"send": "[to be translated]:Send",
"settings": "Ρυθμίσεις",
"sort": {
"pinyin": {
@@ -1092,6 +1096,9 @@
},
"content": "Περιεχόμενο",
"data": "δεδομένα",
"delete": {
"failed": "[to be translated]:Failed to delete."
},
"detail": "Λεπτομέρειες σφάλματος",
"details": "Λεπτομέρειες",
"errors": "Λάθος",
@@ -1199,7 +1206,8 @@
"size": "Μέγεθος",
"text": "Κείμενο",
"title": "Αρχεία",
"type": "Τύπος"
"type": "Τύπος",
"video": "[to be translated]:Video"
},
"gpustack": {
"keep_alive_time": {
@@ -4496,7 +4504,8 @@
"paintings": "Ζωγραφική",
"settings": "Ρυθμίσεις",
"store": "Βιβλιοθήκη βοηθών",
"translate": "Μετάφραση"
"translate": "Μετάφραση",
"video": "[to be translated]:Video"
},
"trace": {
"backList": "Επιστροφή στη λίστα",
@@ -4654,6 +4663,56 @@
"saveDataError": "Η αποθήκευση των δεδομένων απέτυχε, δοκιμάστε ξανά",
"title": "Ενημέρωση"
},
"video": {
"delete": {
"error": {
"not_found": {
"description": "[to be translated]:The video was not found remotely. It will only be deleted locally.",
"title": "[to be translated]:Video not found."
}
}
},
"error": {
"create": "[to be translated]:Failed to create video",
"download": "[to be translated]:Failed to download video.",
"invalid": "[to be translated]:Invalid video",
"load": {
"message": "[to be translated]:Failed to load the video",
"reason": "[to be translated]:The file may be corrupted or has been deleted externally."
}
},
"expired": "[to be translated]:Expired",
"input_reference": {
"add": {
"error": {
"format": "[to be translated]:Not a image",
"size": "[to be translated]:This image is too large. It should be under 5MB."
},
"tooltip": "[to be translated]:Add image reference"
}
},
"prompt": {
"placeholder": "[to be translated]:describes the video to generate"
},
"seconds": "[to be translated]:Seconds",
"size": "[to be translated]:Size",
"status": {
"completed": "[to be translated]:Generation Completed",
"downloading": "[to be translated]:Downloading",
"failed": "[to be translated]:Failed to generate video",
"in_progress": "[to be translated]:Generating",
"queued": "[to be translated]:Queued"
},
"thumbnail": {
"error": {
"get": "[to be translated]:Failed to get thumbnail"
},
"get": "[to be translated]:Get thumbnail",
"placeholder": "[to be translated]:No thumbnail"
},
"title": "[to be translated]:Video",
"undefined": "[to be translated]:No available video"
},
"warning": {
"missing_provider": "Ο προμηθευτής δεν υπάρχει, έγινε επαναφορά στον προεπιλεγμένο προμηθευτή {{provider}}. Αυτό μπορεί να προκαλέσει προβλήματα."
},

View File

@@ -978,6 +978,7 @@
"delete_confirm": "¿Está seguro de que desea eliminarlo?",
"delete_failed": "Error al eliminar",
"delete_success": "Eliminación exitosa",
"deleting": "[to be translated]:Deleting...",
"description": "Descripción",
"detail": "Detalles",
"disabled": "Desactivado",
@@ -1022,10 +1023,12 @@
"prompt": "Prompt",
"provider": "Proveedor",
"reasoning_content": "Pensamiento profundo",
"redownload": "[to be translated]:Redownload",
"refresh": "Actualizar",
"regenerate": "Regenerar",
"rename": "Renombrar",
"reset": "Restablecer",
"retry": "[to be translated]:Retry",
"save": "Guardar",
"saved": "Guardado",
"search": "Buscar",
@@ -1033,6 +1036,7 @@
"selected": "Seleccionado",
"selectedItems": "{{count}} elementos seleccionados",
"selectedMessages": "{{count}} mensajes seleccionados",
"send": "[to be translated]:Send",
"settings": "Configuración",
"sort": {
"pinyin": {
@@ -1092,6 +1096,9 @@
},
"content": "contenido",
"data": "datos",
"delete": {
"failed": "[to be translated]:Failed to delete."
},
"detail": "Detalles del error",
"details": "Detalles",
"errors": "error",
@@ -1199,7 +1206,8 @@
"size": "Tamaño",
"text": "Texto",
"title": "Archivo",
"type": "Tipo"
"type": "Tipo",
"video": "[to be translated]:Video"
},
"gpustack": {
"keep_alive_time": {
@@ -4496,7 +4504,8 @@
"paintings": "Pinturas",
"settings": "Configuración",
"store": "Biblioteca de asistentes",
"translate": "Traducir"
"translate": "Traducir",
"video": "[to be translated]:Video"
},
"trace": {
"backList": "Volver a la lista",
@@ -4654,6 +4663,56 @@
"saveDataError": "Error al guardar los datos, inténtalo de nuevo",
"title": "Actualización"
},
"video": {
"delete": {
"error": {
"not_found": {
"description": "[to be translated]:The video was not found remotely. It will only be deleted locally.",
"title": "[to be translated]:Video not found."
}
}
},
"error": {
"create": "[to be translated]:Failed to create video",
"download": "[to be translated]:Failed to download video.",
"invalid": "[to be translated]:Invalid video",
"load": {
"message": "[to be translated]:Failed to load the video",
"reason": "[to be translated]:The file may be corrupted or has been deleted externally."
}
},
"expired": "[to be translated]:Expired",
"input_reference": {
"add": {
"error": {
"format": "[to be translated]:Not a image",
"size": "[to be translated]:This image is too large. It should be under 5MB."
},
"tooltip": "[to be translated]:Add image reference"
}
},
"prompt": {
"placeholder": "[to be translated]:describes the video to generate"
},
"seconds": "[to be translated]:Seconds",
"size": "[to be translated]:Size",
"status": {
"completed": "[to be translated]:Generation Completed",
"downloading": "[to be translated]:Downloading",
"failed": "[to be translated]:Failed to generate video",
"in_progress": "[to be translated]:Generating",
"queued": "[to be translated]:Queued"
},
"thumbnail": {
"error": {
"get": "[to be translated]:Failed to get thumbnail"
},
"get": "[to be translated]:Get thumbnail",
"placeholder": "[to be translated]:No thumbnail"
},
"title": "[to be translated]:Video",
"undefined": "[to be translated]:No available video"
},
"warning": {
"missing_provider": "El proveedor no existe, se ha revertido al proveedor predeterminado {{provider}}. Esto podría causar problemas."
},

View File

@@ -978,6 +978,7 @@
"delete_confirm": "Êtes-vous sûr de vouloir supprimer ?",
"delete_failed": "Échec de la suppression",
"delete_success": "Suppression réussie",
"deleting": "[to be translated]:Deleting...",
"description": "Description",
"detail": "détails",
"disabled": "Désactivé",
@@ -1022,10 +1023,12 @@
"prompt": "Prompt",
"provider": "Fournisseur",
"reasoning_content": "Réflexion approfondie",
"redownload": "[to be translated]:Redownload",
"refresh": "Actualiser",
"regenerate": "Regénérer",
"rename": "Renommer",
"reset": "Réinitialiser",
"retry": "[to be translated]:Retry",
"save": "Enregistrer",
"saved": "enregistré",
"search": "Rechercher",
@@ -1033,6 +1036,7 @@
"selected": "Sélectionné",
"selectedItems": "{{count}} éléments sélectionnés",
"selectedMessages": "{{count}} messages sélectionnés",
"send": "[to be translated]:Send",
"settings": "Paramètres",
"sort": {
"pinyin": {
@@ -1092,6 +1096,9 @@
},
"content": "suivre l'instruction du système",
"data": "données",
"delete": {
"failed": "[to be translated]:Failed to delete."
},
"detail": "Détails de l'erreur",
"details": "Informations détaillées",
"errors": "erreur",
@@ -1199,7 +1206,8 @@
"size": "Taille",
"text": "Texte",
"title": "Fichier",
"type": "Type"
"type": "Type",
"video": "[to be translated]:Video"
},
"gpustack": {
"keep_alive_time": {
@@ -4496,7 +4504,8 @@
"paintings": "Peintures",
"settings": "Paramètres",
"store": "Bibliothèque d'assistants",
"translate": "Traduire"
"translate": "Traduire",
"video": "[to be translated]:Video"
},
"trace": {
"backList": "Retour à la liste",
@@ -4654,6 +4663,56 @@
"saveDataError": "Échec de la sauvegarde des données, veuillez réessayer",
"title": "Mise à jour"
},
"video": {
"delete": {
"error": {
"not_found": {
"description": "[to be translated]:The video was not found remotely. It will only be deleted locally.",
"title": "[to be translated]:Video not found."
}
}
},
"error": {
"create": "[to be translated]:Failed to create video",
"download": "[to be translated]:Failed to download video.",
"invalid": "[to be translated]:Invalid video",
"load": {
"message": "[to be translated]:Failed to load the video",
"reason": "[to be translated]:The file may be corrupted or has been deleted externally."
}
},
"expired": "[to be translated]:Expired",
"input_reference": {
"add": {
"error": {
"format": "[to be translated]:Not a image",
"size": "[to be translated]:This image is too large. It should be under 5MB."
},
"tooltip": "[to be translated]:Add image reference"
}
},
"prompt": {
"placeholder": "[to be translated]:describes the video to generate"
},
"seconds": "[to be translated]:Seconds",
"size": "[to be translated]:Size",
"status": {
"completed": "[to be translated]:Generation Completed",
"downloading": "[to be translated]:Downloading",
"failed": "[to be translated]:Failed to generate video",
"in_progress": "[to be translated]:Generating",
"queued": "[to be translated]:Queued"
},
"thumbnail": {
"error": {
"get": "[to be translated]:Failed to get thumbnail"
},
"get": "[to be translated]:Get thumbnail",
"placeholder": "[to be translated]:No thumbnail"
},
"title": "[to be translated]:Video",
"undefined": "[to be translated]:No available video"
},
"warning": {
"missing_provider": "Le fournisseur nexiste pas, retour au fournisseur par défaut {{provider}}. Cela peut entraîner des problèmes."
},

View File

@@ -978,6 +978,7 @@
"delete_confirm": "削除してもよろしいですか?",
"delete_failed": "削除に失敗しました",
"delete_success": "削除に成功しました",
"deleting": "[to be translated]:Deleting...",
"description": "説明",
"detail": "詳細",
"disabled": "無効",
@@ -1022,10 +1023,12 @@
"prompt": "プロンプト",
"provider": "プロバイダー",
"reasoning_content": "深く考察済み",
"redownload": "[to be translated]:Redownload",
"refresh": "更新",
"regenerate": "再生成",
"rename": "名前を変更",
"reset": "リセット",
"retry": "[to be translated]:Retry",
"save": "保存",
"saved": "保存されました",
"search": "検索",
@@ -1033,6 +1036,7 @@
"selected": "選択済み",
"selectedItems": "{{count}}件の項目を選択しました",
"selectedMessages": "{{count}}件のメッセージを選択しました",
"send": "[to be translated]:Send",
"settings": "設定",
"sort": {
"pinyin": {
@@ -1092,6 +1096,9 @@
},
"content": "内容",
"data": "データ",
"delete": {
"failed": "[to be translated]:Failed to delete."
},
"detail": "エラーの詳細",
"details": "詳細",
"errors": "エラー",
@@ -1199,7 +1206,8 @@
"size": "サイズ",
"text": "テキスト",
"title": "ファイル",
"type": "タイプ"
"type": "タイプ",
"video": "[to be translated]:Video"
},
"gpustack": {
"keep_alive_time": {
@@ -4496,7 +4504,8 @@
"paintings": "ペインティング",
"settings": "設定",
"store": "アシスタントライブラリ",
"translate": "翻訳"
"translate": "翻訳",
"video": "[to be translated]:Video"
},
"trace": {
"backList": "リストに戻る",
@@ -4654,6 +4663,56 @@
"saveDataError": "データの保存に失敗しました。もう一度お試しください。",
"title": "更新"
},
"video": {
"delete": {
"error": {
"not_found": {
"description": "[to be translated]:The video was not found remotely. It will only be deleted locally.",
"title": "[to be translated]:Video not found."
}
}
},
"error": {
"create": "[to be translated]:Failed to create video",
"download": "[to be translated]:Failed to download video.",
"invalid": "[to be translated]:Invalid video",
"load": {
"message": "[to be translated]:Failed to load the video",
"reason": "[to be translated]:The file may be corrupted or has been deleted externally."
}
},
"expired": "[to be translated]:Expired",
"input_reference": {
"add": {
"error": {
"format": "[to be translated]:Not a image",
"size": "[to be translated]:This image is too large. It should be under 5MB."
},
"tooltip": "[to be translated]:Add image reference"
}
},
"prompt": {
"placeholder": "[to be translated]:describes the video to generate"
},
"seconds": "[to be translated]:Seconds",
"size": "[to be translated]:Size",
"status": {
"completed": "[to be translated]:Generation Completed",
"downloading": "[to be translated]:Downloading",
"failed": "[to be translated]:Failed to generate video",
"in_progress": "[to be translated]:Generating",
"queued": "[to be translated]:Queued"
},
"thumbnail": {
"error": {
"get": "[to be translated]:Failed to get thumbnail"
},
"get": "[to be translated]:Get thumbnail",
"placeholder": "[to be translated]:No thumbnail"
},
"title": "[to be translated]:Video",
"undefined": "[to be translated]:No available video"
},
"warning": {
"missing_provider": "サプライヤーが存在しないため、デフォルトのサプライヤー {{provider}} にロールバックされました。これにより問題が発生する可能性があります。"
},

View File

@@ -978,6 +978,7 @@
"delete_confirm": "Tem certeza de que deseja excluir?",
"delete_failed": "Falha ao excluir",
"delete_success": "Excluído com sucesso",
"deleting": "[to be translated]:Deleting...",
"description": "Descrição",
"detail": "detalhes",
"disabled": "Desativado",
@@ -1022,10 +1023,12 @@
"prompt": "Prompt",
"provider": "Fornecedor",
"reasoning_content": "Pensamento profundo concluído",
"redownload": "[to be translated]:Redownload",
"refresh": "Atualizar",
"regenerate": "Regenerar",
"rename": "Renomear",
"reset": "Redefinir",
"retry": "[to be translated]:Retry",
"save": "Salvar",
"saved": "Guardado",
"search": "Pesquisar",
@@ -1033,6 +1036,7 @@
"selected": "Selecionado",
"selectedItems": "{{count}} itens selecionados",
"selectedMessages": "{{count}} mensagens selecionadas",
"send": "[to be translated]:Send",
"settings": "Configurações",
"sort": {
"pinyin": {
@@ -1092,6 +1096,9 @@
},
"content": "conteúdo",
"data": "dados",
"delete": {
"failed": "[to be translated]:Failed to delete."
},
"detail": "Detalhes do erro",
"details": "Detalhes",
"errors": "erro",
@@ -1199,7 +1206,8 @@
"size": "Tamanho",
"text": "Texto",
"title": "Arquivo",
"type": "Tipo"
"type": "Tipo",
"video": "[to be translated]:Video"
},
"gpustack": {
"keep_alive_time": {
@@ -4496,7 +4504,8 @@
"paintings": "Pinturas",
"settings": "Configurações",
"store": "Biblioteca de assistentes",
"translate": "Traduzir"
"translate": "Traduzir",
"video": "[to be translated]:Video"
},
"trace": {
"backList": "Voltar à lista",
@@ -4654,6 +4663,56 @@
"saveDataError": "Falha ao salvar os dados, tente novamente",
"title": "Atualização"
},
"video": {
"delete": {
"error": {
"not_found": {
"description": "[to be translated]:The video was not found remotely. It will only be deleted locally.",
"title": "[to be translated]:Video not found."
}
}
},
"error": {
"create": "[to be translated]:Failed to create video",
"download": "[to be translated]:Failed to download video.",
"invalid": "[to be translated]:Invalid video",
"load": {
"message": "[to be translated]:Failed to load the video",
"reason": "[to be translated]:The file may be corrupted or has been deleted externally."
}
},
"expired": "[to be translated]:Expired",
"input_reference": {
"add": {
"error": {
"format": "[to be translated]:Not a image",
"size": "[to be translated]:This image is too large. It should be under 5MB."
},
"tooltip": "[to be translated]:Add image reference"
}
},
"prompt": {
"placeholder": "[to be translated]:describes the video to generate"
},
"seconds": "[to be translated]:Seconds",
"size": "[to be translated]:Size",
"status": {
"completed": "[to be translated]:Generation Completed",
"downloading": "[to be translated]:Downloading",
"failed": "[to be translated]:Failed to generate video",
"in_progress": "[to be translated]:Generating",
"queued": "[to be translated]:Queued"
},
"thumbnail": {
"error": {
"get": "[to be translated]:Failed to get thumbnail"
},
"get": "[to be translated]:Get thumbnail",
"placeholder": "[to be translated]:No thumbnail"
},
"title": "[to be translated]:Video",
"undefined": "[to be translated]:No available video"
},
"warning": {
"missing_provider": "O fornecedor não existe; foi revertido para o fornecedor predefinido {{provider}}. Isto pode causar problemas."
},

View File

@@ -978,6 +978,7 @@
"delete_confirm": "Вы уверены, что хотите удалить?",
"delete_failed": "Не удалось удалить",
"delete_success": "Удаление выполнено успешно",
"deleting": "[to be translated]:Deleting...",
"description": "Описание",
"detail": "Подробности",
"disabled": "Отключено",
@@ -1022,10 +1023,12 @@
"prompt": "Промпт",
"provider": "Провайдер",
"reasoning_content": "Глубокий анализ",
"redownload": "[to be translated]:Redownload",
"refresh": "Обновить",
"regenerate": "Пересоздать",
"rename": "Переименовать",
"reset": "Сбросить",
"retry": "[to be translated]:Retry",
"save": "Сохранить",
"saved": "Сохранено",
"search": "Поиск",
@@ -1033,6 +1036,7 @@
"selected": "Выбрано",
"selectedItems": "Выбрано {{count}} элементов",
"selectedMessages": "Выбрано {{count}} сообщений",
"send": "[to be translated]:Send",
"settings": "Настройки",
"sort": {
"pinyin": {
@@ -1092,6 +1096,9 @@
},
"content": "Содержание",
"data": "данные",
"delete": {
"failed": "[to be translated]:Failed to delete."
},
"detail": "Детали ошибки",
"details": "Подробности",
"errors": "ошибка",
@@ -1199,7 +1206,8 @@
"size": "Размер",
"text": "Текст",
"title": "Файлы",
"type": "Тип"
"type": "Тип",
"video": "[to be translated]:Video"
},
"gpustack": {
"keep_alive_time": {
@@ -4496,7 +4504,8 @@
"paintings": "Рисунки",
"settings": "Настройки",
"store": "Библиотека помощников",
"translate": "Перевод"
"translate": "Перевод",
"video": "[to be translated]:Video"
},
"trace": {
"backList": "Вернуться к списку",
@@ -4654,6 +4663,56 @@
"saveDataError": "Ошибка сохранения данных, повторите попытку",
"title": "Обновление"
},
"video": {
"delete": {
"error": {
"not_found": {
"description": "[to be translated]:The video was not found remotely. It will only be deleted locally.",
"title": "[to be translated]:Video not found."
}
}
},
"error": {
"create": "[to be translated]:Failed to create video",
"download": "[to be translated]:Failed to download video.",
"invalid": "[to be translated]:Invalid video",
"load": {
"message": "[to be translated]:Failed to load the video",
"reason": "[to be translated]:The file may be corrupted or has been deleted externally."
}
},
"expired": "[to be translated]:Expired",
"input_reference": {
"add": {
"error": {
"format": "[to be translated]:Not a image",
"size": "[to be translated]:This image is too large. It should be under 5MB."
},
"tooltip": "[to be translated]:Add image reference"
}
},
"prompt": {
"placeholder": "[to be translated]:describes the video to generate"
},
"seconds": "[to be translated]:Seconds",
"size": "[to be translated]:Size",
"status": {
"completed": "[to be translated]:Generation Completed",
"downloading": "[to be translated]:Downloading",
"failed": "[to be translated]:Failed to generate video",
"in_progress": "[to be translated]:Generating",
"queued": "[to be translated]:Queued"
},
"thumbnail": {
"error": {
"get": "[to be translated]:Failed to get thumbnail"
},
"get": "[to be translated]:Get thumbnail",
"placeholder": "[to be translated]:No thumbnail"
},
"title": "[to be translated]:Video",
"undefined": "[to be translated]:No available video"
},
"warning": {
"missing_provider": "Поставщик не существует, возвращение к поставщику по умолчанию {{provider}}. Это может привести к проблемам."
},

View File

@@ -19,7 +19,8 @@ import {
File as FileIcon,
FileImage,
FileText,
FileType as FileTypeIcon
FileType as FileTypeIcon,
FileVideo
} from 'lucide-react'
import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -138,6 +139,7 @@ const FilesPage: FC = () => {
{ key: FileTypes.DOCUMENT, label: t('files.document'), icon: <FileIcon size={16} /> },
{ key: FileTypes.IMAGE, label: t('files.image'), icon: <FileImage size={16} /> },
{ key: FileTypes.TEXT, label: t('files.text'), icon: <FileTypeIcon size={16} /> },
{ key: FileTypes.VIDEO, label: t('files.video'), icon: <FileVideo size={16} /> },
{ key: 'all', label: t('files.all'), icon: <FileText size={16} /> }
]

View File

@@ -2,7 +2,7 @@ import App from '@renderer/components/MinApp/MinApp'
import { useMinapps } from '@renderer/hooks/useMinapps'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings'
import { Code, FileSearch, Folder, Languages, LayoutGrid, NotepadText, Palette, Sparkle } from 'lucide-react'
import { Code, FileSearch, Folder, Languages, LayoutGrid, NotepadText, Palette, Sparkle, Video } from 'lucide-react'
import { FC, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
@@ -63,6 +63,12 @@ const LaunchpadPage: FC = () => {
text: t('title.notes'),
path: '/notes',
bgColor: 'linear-gradient(135deg, #F97316, #FB923C)' // 笔记:橙色,代表活力和清晰思路
},
{
icon: <Video size={32} className="icon" />,
text: t('title.video'),
path: '/video',
bgColor: 'linear-gradient(135deg, #7C3AED, #A78BFA)' // Video Generation: deep purple, representing creativity and dynamic media
}
]

View File

@@ -21,7 +21,8 @@ import {
MessageSquareQuote,
NotepadText,
Palette,
Sparkle
Sparkle,
Video
} from 'lucide-react'
import { FC, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
@@ -127,7 +128,8 @@ const SidebarIconsManager: FC<SidebarIconsManagerProps> = ({
knowledge: <FileSearch size={16} />,
files: <Folder size={16} />,
notes: <NotepadText size={16} />,
code_tools: <Code size={16} />
code_tools: <Code size={16} />,
video: <Video size={16} />
}),
[]
)

View File

@@ -0,0 +1,35 @@
import { Video } from '@renderer/types'
import { PlusIcon } from 'lucide-react'
import { VideoListItem } from './VideoListItem'
export type VideoListProps = {
videos: Video[]
activeVideoId?: string
setActiveVideoId: (id: string | undefined) => void
onDelete: (id: string) => void
onGetThumbnail: (id: string) => void
}
export const VideoList = ({ videos, activeVideoId, setActiveVideoId, onDelete, onGetThumbnail }: VideoListProps) => {
return (
<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 rounded-xl border-2 transition-all hover:scale-105 hover:shadow-lg"
onClick={() => setActiveVideoId(undefined)}>
<PlusIcon size={24} />
</div>
{/* {mockVideos.map((video) => ( */}
{videos.map((video) => (
<VideoListItem
key={video.id}
video={video}
isActive={activeVideoId === video.id}
onClick={() => setActiveVideoId(video.id)}
onDelete={() => onDelete(video.id)}
onGetThhumbnail={() => onGetThumbnail(video.id)}
/>
))}
</div>
)
}

View File

@@ -0,0 +1,158 @@
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, ImageDownIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
export const VideoListItem = ({
video,
isActive,
onClick,
onDelete,
onGetThhumbnail
}: {
video: Video
isActive: boolean
onClick: () => void
onDelete: () => void
onGetThhumbnail: () => void
}) => {
const { t } = useTranslation()
const getStatusIcon = () => {
switch (video.status) {
case 'queued':
return <ClockIcon size={20} className="text-default-500" />
case 'in_progress':
return <Spinner size="sm" color="primary" />
case 'completed':
return <CheckCircleIcon size={20} className="text-success" />
case 'downloading':
return <DownloadIcon size={20} className="text-primary" />
case 'downloaded':
return null // No indicator for downloaded state
case 'failed':
return <CircleXIcon size={20} className="text-danger" />
default:
return null
}
}
const getStatusColor = () => {
switch (video.status) {
case 'queued':
return 'bg-default-100'
case 'in_progress':
return 'bg-primary-50'
case 'completed':
return 'bg-success-50'
case 'downloading':
return 'bg-primary-50'
case 'downloaded':
return 'bg-success-50'
case 'failed':
return 'bg-danger-50'
default:
return 'bg-default-50'
}
}
const getStatusLabel = () => {
switch (video.status) {
case 'queued':
return t('video.status.queued')
case 'in_progress':
return t('video.status.in_progress')
case 'completed':
return t('video.status.completed')
case 'downloading':
return t('video.status.downloading')
case 'downloaded':
return ''
case 'failed':
return t('video.status.failed')
default:
return ''
}
}
const showProgress = video.status === 'in_progress' || video.status === 'downloading'
const showThumbnail =
(video.status === 'completed' || video.status === 'downloading' || video.status === 'downloaded') &&
video.thumbnail !== null
return (
<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>
{/* 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>
</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>
{video.thumbnail === null && (
<ContextMenuItem onSelect={onGetThhumbnail}>
<ImageDownIcon />
<span>{t('video.thumbnail.get')}</span>
</ContextMenuItem>
)}
<ContextMenuItem onSelect={onDelete}>
<DeleteIcon className="text-danger" />
<span className="text-danger">{t('common.delete')}</span>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
}

View File

@@ -0,0 +1,160 @@
// interface VideoPageProps {}
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 { 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'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ModelSetting } from './settings/ModelSetting'
import { OpenAIParamSettings } from './settings/OpenAIParamSettings'
import { ProviderSetting } from './settings/ProviderSetting'
import { SettingsGroup } from './settings/shared'
import { VideoList } from './VideoList'
import { VideoPanel } from './VideoPanel'
export const VideoPage = () => {
const { t } = useTranslation()
const [providerId, setProviderId] = useState<string>(SystemProviderIds.openai)
const { provider } = useProvider(providerId)
const [params, setParams] = useState<CreateVideoParams>({
type: 'openai',
provider,
params: {
model: 'sora-2',
prompt: ''
},
options: {}
})
const { videos, removeVideo, getVideo, updateVideo } = 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, retrieveThumbnail } = useVideoThumbnail()
const updateParams = useCallback((update: DeepPartial<Omit<CreateVideoParams, 'type'>>) => {
setParams((prev) => deepUpdate<CreateVideoParams>(prev, update))
}, [])
const updateModelId = useCallback(
(id: string) => {
if (isVideoModel(id)) {
updateParams({ params: { model: id } })
}
},
[updateParams]
)
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]
)
const handleGetThumbnail = useCallback(
async (id: string) => {
const video = getVideo(id)
if (video && video.thumbnail === null) {
try {
const promise = retrieveThumbnail(video)
window.toast.loading({ title: t('video.thumbnail.get'), promise })
const thumbnail = await promise
if (thumbnail) {
updateVideo({ id: video.id, thumbnail })
}
} catch (e) {
window.toast.error({ title: t('video.thumbnail.error.get'), description: getErrorMessage(e) })
}
}
},
[getVideo, retrieveThumbnail, t, updateVideo]
)
return (
<div className="flex flex-1 flex-col">
<Navbar>
<NavbarCenter style={{ borderRight: 'none' }}>{t('video.title')}</NavbarCenter>
</Navbar>
<div id="content-container" className="flex max-h-full flex-1">
{/* Settings */}
<div className="flex w-70 flex-col p-2">
<SettingsGroup>
<ProviderSetting providerId={providerId} setProviderId={setProviderId} />
<ModelSetting
providerId={providerId}
modelId={params.params.model ?? 'sora-2'}
setModelId={updateModelId}
/>
</SettingsGroup>
{provider.type === 'openai-response' && <OpenAIParamSettings params={params} updateParams={updateParams} />}
</div>
<Divider orientation="vertical" />
<VideoPanel provider={provider} params={params} updateParams={updateParams} video={activeVideo} />
<Divider orientation="vertical" />
{/* Video list */}
<VideoList
videos={videos}
activeVideoId={activeVideoId}
setActiveVideoId={setActiveVideoId}
onDelete={handleDeleteVideo}
onGetThumbnail={handleGetThumbnail}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,298 @@
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 { 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'
import { CreateVideoParams, Video } from '@renderer/types/video'
import { getErrorMessage } from '@renderer/utils'
import { MB } from '@shared/config/constant'
import { DeepPartial } from 'ai'
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 } from 'react'
import { useTranslation } from 'react-i18next'
import { VideoViewer } from './VideoViewer'
export type VideoPanelProps = {
provider: Provider
video?: Video
params: CreateVideoParams
updateParams: (upadte: DeepPartial<Omit<CreateVideoParams, 'type'>>) => void
}
const logger = loggerService.withContext('VideoPanel')
export const VideoPanel = ({ provider, video, params, updateParams }: VideoPanelProps) => {
const { t } = useTranslation()
const addOpenAIVideo = useAddOpenAIVideo(provider.id)
const { setVideo } = useProviderVideos(provider.id)
const { pendingMap, setPending: setPendingById } = usePending()
const fileInputRef = useRef<HTMLInputElement>(null)
const inputReference = params.params.input_reference
const couldCreateVideo = useMemo(
() =>
!isEmpty(params.params.prompt) &&
video?.status !== 'queued' &&
video?.status !== 'downloading' &&
video?.status !== 'in_progress' &&
(video === undefined || pendingMap[video.id] !== true),
[params.params.prompt, pendingMap, video]
)
useEffect(() => {
if (video) {
updateParams({ params: { prompt: video.prompt } })
} else {
updateParams({ params: { prompt: '' } })
}
}, [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
setPending(true)
try {
if (video === undefined) {
const result = await createVideo(params)
const video = result.video
switch (result.type) {
case 'openai':
addOpenAIVideo(video, params.params.prompt)
break
default:
logger.error(`Invalid video type ${result.type}.`)
}
} else {
// TODO: remix video
window.toast.info('Remix video is not implemented.')
}
} catch (e) {
window.toast.error({ title: t('video.error.create'), description: getErrorMessage(e), timeout: 5000 })
} finally {
setPending(false)
}
}, [addOpenAIVideo, couldCreateVideo, params, setPending, t, video])
const handleRegenerateVideo = useCallback(() => {
window.toast.info('Not implemented')
}, [])
const handleDownloadVideo = useCallback(async () => {
if (!video) return
if (video.status !== 'completed' && video.status !== 'downloaded') return
const baseVideo: Video = {
...video,
status: 'downloading',
progress: 0,
thumbnail: video.thumbnail
}
setVideo(baseVideo)
try {
const { response } = await retrieveVideoContent({ type: 'openai', videoId: video.id, provider })
if (!response.body) {
throw new Error('Video response body is empty')
}
const reader = response.body.getReader()
const contentLengthHeader = response.headers.get('content-length')
const totalSize = contentLengthHeader ? Number(contentLengthHeader) : undefined
const chunks: Uint8Array[] = []
let receivedLength = 0
let progressValue = 0
while (true) {
const { done, value } = await reader.read()
if (done) break
if (!value) continue
chunks.push(value)
receivedLength += value.length
if (totalSize && Number.isFinite(totalSize) && totalSize > 0) {
progressValue = Math.floor((receivedLength / totalSize) * 100)
} else {
progressValue = Math.min(progressValue + 1, 99)
}
setVideo({
...baseVideo,
progress: Math.min(progressValue, 99)
})
}
const fileData = new Uint8Array(receivedLength)
let offset = 0
for (const chunk of chunks) {
fileData.set(chunk, offset)
offset += chunk.length
}
const contentType = response.headers.get('content-type') ?? 'video/mp4'
const normalizedContentType = contentType.split(';')[0]?.trim() || 'video/mp4'
const extension = (() => {
const ext = mime.extension(normalizedContentType)
return ext ? `.${ext}` : '.mp4'
})()
const fileName = `${video.id}${extension}`.toLowerCase()
const tempFilePath = await window.api.file.createTempFile(fileName)
await window.api.file.write(tempFilePath, fileData)
const tempFileMetadata = {
id: crypto.randomUUID(),
name: fileName,
origin_name: fileName,
path: tempFilePath,
size: receivedLength,
ext: extension,
type: FileTypes.VIDEO,
created_at: dayjs().toISOString(),
count: 1
} satisfies VideoFileMetadata
const uploadedFile = await FileManager.uploadFile(tempFileMetadata)
setVideo({
...video,
status: 'downloaded',
thumbnail: video.thumbnail,
fileId: uploadedFile.id,
name: uploadedFile.origin_name
})
} catch (error) {
logger.error(`Failed to download video ${video.id}.`, error as Error)
window.toast.error(t('video.error.download'))
setVideo(video)
}
}, [provider, setVideo, t, video])
const handleUploadFile = useCallback(() => {
fileInputRef.current?.click()
}, [])
const setPrompt = useCallback((value: string) => updateParams({ params: { prompt: value } }), [updateParams])
const UploadImageReferenceButton = useCallback(() => {
const content = inputReference ? (
<div className="group">
<Image
className="aspect-square max-h-50 max-w-50 object-contain"
src={URL.createObjectURL(inputReference as File)}
/>
<Button
variant="light"
color="danger"
className="absolute top-1 right-1 z-100 h-6 w-6 min-w-0 opacity-0 group-hover:opacity-100"
isIconOnly
startContent={<CircleXIcon size={16} className="text-danger" />}
onPress={() => updateParams({ params: { input_reference: undefined } })}
/>
</div>
) : (
t('video.input_reference.add.tooltip')
)
return (
<>
<Tooltip content={content} closeDelay={0}>
<Button
variant="light"
startContent={<ImageIcon size={16} className={cn(inputReference ? 'text-primary' : undefined)} />}
isIconOnly
className="h-6 w-6 min-w-0"
isDisabled={isPending}
onPress={handleUploadFile}
/>
</Tooltip>
</>
)
}, [handleUploadFile, inputReference, isPending, t, updateParams])
return (
<div className="flex flex-1 flex-col p-2">
<div className="m-8 flex-1 overflow-hidden">
<Skeleton className="h-full w-full rounded-2xl" classNames={{ content: 'h-full w-full' }} isLoaded={true}>
{video && <VideoViewer video={video} onDownload={handleDownloadVideo} onRegenerate={handleRegenerateVideo} />}
{!video && <VideoViewer video={video} />}
</Skeleton>
</div>
<div className="relative">
<Textarea
label={t('common.prompt')}
placeholder={t('video.prompt.placeholder')}
value={params.params.prompt}
onValueChange={setPrompt}
isClearable
isDisabled={isPending}
classNames={{ inputWrapper: 'pb-8' }}
onKeyDown={(e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
handleCreateVideo()
}
}}
/>
<div className="absolute bottom-0 flex w-full items-end justify-between p-2">
<div className="flex">
<UploadImageReferenceButton />
<input
ref={fileInputRef}
type="file"
hidden
onChange={(e) => {
const files = e.target.files
if (files && files.length > 0) {
const file = files[0]
if (!file.type.startsWith('image/')) {
window.toast.error(t('video.input_reference.add.error.format'))
return
}
const maxSize = 5 * MB
if (file.size > maxSize) {
window.toast.error(t('video.input_reference.add.error.size'))
return
}
updateParams({ params: { input_reference: file } })
} else {
updateParams({ params: { input_reference: undefined } })
}
}}
/>
</div>
<Tooltip content={t('common.send')} closeDelay={0}>
<Button
color="primary"
radius="full"
isIconOnly
isDisabled={!couldCreateVideo}
isLoading={isPending}
className="h-6 w-6 min-w-0"
onPress={handleCreateVideo}>
<ArrowUp size={16} className="text-primary-foreground" />
</Button>
</Tooltip>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,233 @@
import {
Alert,
Button,
Modal,
ModalBody,
ModalContent,
ModalFooter,
Progress,
Skeleton,
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'
import { CheckCircleIcon, CircleXIcon, Clock9Icon } from 'lucide-react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import useSWRImmutable from 'swr/immutable'
export type VideoViewerProps =
| {
video: undefined
onDownload?: never
onRegenerate?: never
}
| {
video: Video
onDownload: () => void
onRegenerate: () => void
}
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])
return (
<>
<div className="flex h-full max-h-full w-full items-center justify-center rounded-2xl bg-foreground-200">
{video === undefined && t('video.undefined')}
{video && video.status === 'queued' && <QueuedVideo />}
{video && video.status === 'in_progress' && <InProgressVideo progress={video.progress} />}
{video && video.status === 'completed' && (
<CompletedVideo video={video} isDisabled={isPending} onDownload={onDownload} onRegenerate={onRegenerate} />
)}
{video && video.status === 'downloading' && <DownloadingVideo progress={video.progress} />}
{video && video.status === 'downloaded' && loadSuccess !== false && (
<VideoPlayer video={video} setLoadSuccess={setLoadSuccess} />
)}
{video && video.status === 'failed' && <FailedVideo error={video.error} />}
{video && video.status === 'downloaded' && loadSuccess === false && (
<LoadFailedVideo isDisabled={isPending} onRedownload={onDownload} />
)}
</div>
</>
)
}
const QueuedVideo = () => {
const { t } = useTranslation()
return (
<div className="flex h-full w-full flex-col items-center justify-center rounded-2xl">
<Spinner variant="dots" />
<span>{t('video.status.queued')}</span>
</div>
)
}
const InProgressVideo = ({ progress }: { progress: number }) => {
const { t } = useTranslation()
return (
<div className="flex h-full w-full flex-col items-center justify-center rounded-2xl">
<Progress
label={t('video.status.in_progress')}
aria-label={t('video.status.in_progress')}
className="max-w-md"
color="primary"
showValueLabel={true}
size="md"
value={progress}
/>
</div>
)
}
const CompletedVideo = ({
video,
isDisabled,
onDownload,
onRegenerate
}: {
video: Video
isDisabled?: boolean
onDownload: () => void
onRegenerate: () => void
}) => {
const { t } = useTranslation()
const isExpired = video.metadata.expires_at !== null && video.metadata.expires_at < dayjs().unix()
if (isExpired) {
return (
<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} isDisabled={isDisabled}>
{t('common.regenerate')}
</Button>
</div>
)
}
return (
<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} isDisabled={isDisabled}>
{t('common.download')}
</Button>
</div>
)
}
const DownloadingVideo = ({ progress }: { progress?: number }) => {
const { t } = useTranslation()
return (
<div className="flex h-full w-full flex-col items-center justify-center rounded-2xl">
<Progress
label={t('video.status.downloading')}
aria-label={t('video.status.downloading')}
className="max-w-md"
color="primary"
showValueLabel={true}
size="md"
value={progress}
/>
</div>
)
}
const FailedVideo = ({ error }: { error: VideoFailed['error'] }) => {
const { t } = useTranslation()
const { isOpen, onOpen, onClose } = useDisclosure()
const alert = useMemo(() => {
if (error === null) {
return <Alert color="danger" title={t('error.unknown')} />
} else {
return <Alert color="danger" title={error.code} description={error.message} />
}
}, [error, t])
return (
<div className="flex h-full w-full flex-col items-center justify-center rounded-2xl bg-danger-200">
<CircleXIcon size={64} className="fill-danger text-danger-200" />
<span className="font-bold text-2xl">{t('video.status.failed')}</span>
<div className="my-2 flex justify-between gap-2">
<Button onPress={onOpen}>{t('common.detail')}</Button>
<Modal isOpen={isOpen} onClose={onClose}>
<ModalBody>
<ModalContent>
<div className="p-4">{alert}</div>
</ModalContent>
</ModalBody>
<ModalFooter></ModalFooter>
</Modal>
<Button onPress={() => window.toast.info('Not implemented')}>{t('common.retry')}</Button>
</div>
</div>
)
}
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">
<CircleXIcon size={64} className="fill-danger text-danger-200" />
<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} isDisabled={isDisabled}>
{t('common.redownload')}
</Button>
</div>
</div>
)
}
const VideoPlayer = ({
video,
setLoadSuccess
}: {
video: VideoDownloaded
setLoadSuccess: (value: boolean) => void
}) => {
const videoRef = useRef<HTMLVideoElement>(null)
const fetcher = async () => {
const file = await FileManager.getFile(video.fileId)
if (!file) {
throw new Error(`Video file ${video.fileId} not exist.`)
}
return FileManager.getFilePath(file)
}
const { data: src, isLoading, error } = useSWRImmutable(`video/file/${video.id}`, fetcher)
useEffect(() => {
const videoElement = videoRef.current
if (videoElement) {
videoElement.load()
}
}, [video?.id])
if (error) {
setLoadSuccess(false)
}
if (isLoading) {
return <Skeleton />
}
return (
<video
ref={videoRef}
controls
className="h-full w-full rounded-2xl bg-content2 object-contain dark:bg-background"
onLoadedData={() => setLoadSuccess(true)}
onError={() => setLoadSuccess(false)}>
<source src={`file://${src}`} type="video/mp4" />
</video>
)
}

View File

@@ -0,0 +1,44 @@
import { Select, SelectItem } from '@heroui/react'
import { videoModelsMap } from '@renderer/config/models/video'
import { Model } from '@renderer/types'
import { useTranslation } from 'react-i18next'
import { SettingItem } from './shared'
export interface ModelSettingProps {
providerId: string
modelId: string
setModelId: (id: string) => void
}
interface ModelSelectItem extends Model {
key: string
label: string
}
export const ModelSetting = ({ providerId, modelId, setModelId }: ModelSettingProps) => {
const { t } = useTranslation()
const items: ModelSelectItem[] = videoModelsMap[providerId]?.map((m: string) => ({ key: m, label: m })) ?? []
return (
<SettingItem>
<Select
label={t('common.model')}
labelPlacement="outside"
selectionMode="single"
items={items}
defaultSelectedKeys={[modelId]}
disallowEmptySelection
onSelectionChange={(keys) => {
if (keys.currentKey) setModelId(keys.currentKey)
}}>
{(model) => (
<SelectItem textValue={model.label}>
<span>{model.label}</span>
</SelectItem>
)}
</Select>
</SettingItem>
)
}

View File

@@ -0,0 +1,80 @@
import { VideoSeconds, VideoSize } from '@cherrystudio/openai/resources'
import { Select, SelectItem } from '@heroui/react'
import { OpenAICreateVideoParams } from '@renderer/types/video'
import { DeepPartial } from 'ai'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { SettingItem, SettingsGroup } from './shared'
export type OpenAIParamSettingsProps = {
params: OpenAICreateVideoParams
updateParams: (update: DeepPartial<Omit<OpenAICreateVideoParams, 'type'>>) => void
}
export const OpenAIParamSettings = ({ params, updateParams }: OpenAIParamSettingsProps) => {
const { t } = useTranslation()
const secondItems = [{ key: '4' }, { key: '8' }, { key: '12' }] as const satisfies { key: VideoSeconds }[]
const sizeItems = [
{ key: '720x1280' },
{ key: '1280x720' },
{ key: '1024x1792' },
{ key: '1792x1024' }
] as const satisfies { key: VideoSize }[]
const updateSeconds = useCallback(
(seconds: VideoSeconds) => {
updateParams({ params: { seconds } })
},
[updateParams]
)
const updateSize = useCallback(
(size: VideoSize) => {
updateParams({ params: { size } })
},
[updateParams]
)
return (
<SettingsGroup>
<SettingItem>
<Select
label={t('video.seconds')}
labelPlacement="outside"
selectedKeys={[params.params.seconds ?? '4']}
onSelectionChange={(keys) => {
if (keys.currentKey) updateSeconds(keys.currentKey as VideoSeconds)
}}
items={secondItems}
selectionMode="single"
disallowEmptySelection>
{(item) => (
<SelectItem key={item.key} textValue={item.key}>
<span>{item.key}</span>
</SelectItem>
)}
</Select>
</SettingItem>
<SettingItem>
<Select
label={t('video.size')}
labelPlacement="outside"
selectedKeys={[params.params.size ?? '720x1280']}
onSelectionChange={(keys) => {
if (keys.currentKey) updateSize(keys.currentKey as VideoSize)
}}
items={sizeItems}
selectionMode="single"
disallowEmptySelection>
{(item) => (
<SelectItem key={item.key} textValue={item.key}>
<span>{item.key}</span>
</SelectItem>
)}
</Select>
</SettingItem>
</SettingsGroup>
)
}

View File

@@ -0,0 +1,60 @@
import { Select, SelectItem } from '@heroui/react'
import { ProviderAvatar } from '@renderer/components/ProviderAvatar'
import { useProviders } from '@renderer/hooks/useProvider'
import { Provider, SystemProviderId } from '@renderer/types'
import { getFancyProviderName } from '@renderer/utils'
import { Dispatch, SetStateAction } from 'react'
import { useTranslation } from 'react-i18next'
import { SettingItem } from './shared'
export interface ProviderSettingProps {
providerId: string
setProviderId: Dispatch<SetStateAction<string>>
}
interface ProviderSelectItem extends Provider {
key: string
label: string
}
export const ProviderSetting = ({ providerId, setProviderId }: ProviderSettingProps) => {
const { t } = useTranslation()
// Support limited providers.
const supportedProviderIds = ['openai'] satisfies SystemProviderId[]
const { providers } = useProviders()
const items: ProviderSelectItem[] = providers
.filter((p) => supportedProviderIds.some((id) => id === p.id))
.map((p) => ({ ...p, key: p.id, label: getFancyProviderName(p) }))
return (
<SettingItem>
<Select
label={t('common.provider')}
labelPlacement="outside"
selectionMode="single"
items={items}
defaultSelectedKeys={[providerId]}
disallowEmptySelection
onSelectionChange={(keys) => {
if (keys.currentKey) setProviderId(keys.currentKey)
}}
renderValue={(items) => {
const provider = items[0].data
if (!provider) return null
return (
<div className="flex items-center gap-2">
<ProviderAvatar provider={provider} size={16} />
<span>{provider.label}</span>
</div>
)
}}>
{(provider) => (
<SelectItem textValue={provider.label} startContent={<ProviderAvatar provider={provider} size={16} />}>
<span>{provider.label}</span>
</SelectItem>
)}
</Select>
</SettingItem>
)
}

View File

@@ -0,0 +1,15 @@
import { Divider } from '@heroui/react'
import { PropsWithChildren } from 'react'
export const SettingsGroup = ({ children }: PropsWithChildren) => {
return <div className="mb-4 flex flex-col rounded-2xl border border-foreground-200 p-3">{children}</div>
}
export const SettingItem = ({ children, divider = false }: PropsWithChildren<{ divider?: boolean }>) => {
return (
<>
<div className="mb-2">{children}</div>
{divider && <Divider className="my-2" />}
</>
)
}

View File

@@ -10,12 +10,24 @@ 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 } 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'
import { Message } from '@renderer/types/newMessage'
import { SdkModel } from '@renderer/types/sdk'
import {
CreateVideoParams,
CreateVideoResult,
RetrieveVideoContentResult,
RetrieveVideoParams,
RetrieveVideoResult
} from '@renderer/types/video'
import { removeSpecialCharactersForTopicName, uuid } from '@renderer/utils'
import { abortCompletion, readyToAbort } from '@renderer/utils/abortController'
import { isAbortError } from '@renderer/utils/error'
@@ -397,6 +409,26 @@ export async function fetchGenerate({
}
}
export async function createVideo(params: CreateVideoParams): Promise<CreateVideoResult> {
const ai = new AiProviderNew(params.provider)
return ai.createVideo(params)
}
export async function retrieveVideo(params: RetrieveVideoParams): Promise<RetrieveVideoResult> {
const ai = new AiProviderNew(params.provider)
return ai.retrieveVideo(params)
}
export async function retrieveVideoContent(params: RetrieveVideoContentParams): Promise<RetrieveVideoContentResult> {
const ai = new AiProviderNew(params.provider)
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

View File

@@ -1,6 +1,6 @@
import { ChatCompletionContentPart, ChatCompletionMessageParam } from '@cherrystudio/openai/resources'
import { Model } from '@renderer/types'
import { findLast } from 'lodash'
import { ChatCompletionContentPart, ChatCompletionMessageParam } from 'openai/resources'
export function processReqMessages(
model: Model,

View File

@@ -1,4 +1,5 @@
import { MessageStream } from '@anthropic-ai/sdk/resources/messages/messages'
import { Stream } from '@cherrystudio/openai/streaming'
import { loggerService } from '@logger'
import { SpanEntity, TokenUsage } from '@mcp-trace/trace-core'
import { cleanContext, endContext, getContext, startContext } from '@mcp-trace/trace-web'
@@ -16,7 +17,6 @@ import { Model, Topic } from '@renderer/types'
import type { Message } from '@renderer/types/newMessage'
import { MessageBlockType } from '@renderer/types/newMessage'
import { SdkRawChunk } from '@renderer/types/sdk'
import { Stream } from 'openai/streaming'
const logger = loggerService.withContext('SpanManagerService')

View File

@@ -6,6 +6,8 @@ import {
WebSearchResultBlock,
WebSearchToolResultError
} from '@anthropic-ai/sdk/resources/messages'
import OpenAI from '@cherrystudio/openai'
import { ChatCompletionChunk } from '@cherrystudio/openai/resources'
import { FinishReason, MediaModality } from '@google/genai'
import { FunctionCall } from '@google/genai'
import AiProvider from '@renderer/aiCore'
@@ -38,8 +40,6 @@ import {
import { mcpToolCallResponseToGeminiMessage } from '@renderer/utils/mcp-tools'
import * as McpToolsModule from '@renderer/utils/mcp-tools'
import { cloneDeep } from 'lodash'
import OpenAI from 'openai'
import { ChatCompletionChunk } from 'openai/resources'
import { beforeEach, describe, expect, it, vi } from 'vitest'
// Mock the ApiClientFactory
vi.mock('@renderer/aiCore/legacy/clients/ApiClientFactory', () => ({

View File

@@ -1,5 +1,5 @@
import { ChatCompletionMessageParam } from '@cherrystudio/openai/resources'
import type { Model } from '@renderer/types'
import { ChatCompletionMessageParam } from 'openai/resources'
import { describe, expect, it } from 'vitest'
import { processReqMessages } from '../ModelMessageService'

View File

@@ -30,6 +30,7 @@ import settings from './settings'
import shortcuts from './shortcuts'
import tabs from './tabs'
import translate from './translate'
import video from './video'
import websearch from './websearch'
const logger = loggerService.withContext('Store')
@@ -58,14 +59,15 @@ const rootReducer = combineReducers({
inputTools: inputToolsReducer,
translate,
ocr,
note
note,
video
})
const persistedReducer = persistReducer(
{
key: 'cherry-studio',
storage,
version: 163,
version: 164,
blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs'],
migrate
},

View File

@@ -1,10 +1,10 @@
import { WebSearchResultBlock } from '@anthropic-ai/sdk/resources'
import type OpenAI from '@cherrystudio/openai'
import type { GroundingMetadata } from '@google/genai'
import { createEntityAdapter, createSelector, createSlice, type PayloadAction } from '@reduxjs/toolkit'
import { AISDKWebSearchResult, Citation, WebSearchProviderResponse, WebSearchSource } from '@renderer/types'
import type { CitationMessageBlock, MessageBlock } from '@renderer/types/newMessage'
import { MessageBlockType } from '@renderer/types/newMessage'
import type OpenAI from 'openai'
import type { RootState } from './index' // 确认 RootState 从 store/index.ts 导出

View File

@@ -2681,6 +2681,20 @@ const migrateConfig = {
logger.error('migrate 163 error', error as Error)
return state
}
},
'164': (state: RootState) => {
try {
if (state.settings && state.settings.sidebarIcons) {
if (!state.settings.sidebarIcons.visible.includes('video')) {
state.settings.sidebarIcons.visible = [...state.settings.sidebarIcons.visible, 'video']
}
}
state.video.videoMap = {}
return state
} catch (error) {
logger.error('migrate 164 error', error as Error)
return state
}
}
}

View File

@@ -56,6 +56,8 @@ export interface RuntimeState {
chat: ChatState
websearch: WebSearchState
iknow: Record<string, boolean>
/** To indicate something is pending. */
pendingMap: Record<string, boolean | undefined>
}
export interface ExportState {
@@ -98,7 +100,8 @@ const initialState: RuntimeState = {
websearch: {
activeSearches: {}
},
iknow: {}
iknow: {},
pendingMap: {}
}
const runtimeSlice = createSlice({
@@ -191,6 +194,14 @@ const runtimeSlice = createSlice({
setSessionWaitingAction: (state, action: PayloadAction<{ id: string; value: boolean }>) => {
const { id, value } = action.payload
state.chat.sessionWaiting[id] = value
},
setPendingAction: (state, action: PayloadAction<{ id: string; value: boolean | undefined }>) => {
const { id, value } = action.payload
if (value) {
state.pendingMap[id] = value
} else {
delete state.pendingMap[id]
}
}
}
})
@@ -210,6 +221,7 @@ export const {
setUpdateState,
setExportState,
addIknowAction,
setPendingAction,
// Chat related actions
toggleMultiSelectMode,
setSelectedMessageIds,

View File

@@ -0,0 +1,85 @@
import { loggerService } from '@logger'
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import { Video } from '@renderer/types/video'
const logger = loggerService.withContext('Store:video')
export interface VideoState {
/** Provider ID to videos */
videoMap: Record<string, Video[] | undefined>
}
const initialState: VideoState = {
videoMap: {}
}
const videoSlice = createSlice({
name: 'video',
initialState,
reducers: {
addVideo: (state: VideoState, action: PayloadAction<{ providerId: string; video: Video }>) => {
const { providerId, video } = action.payload
if (state.videoMap[providerId]) {
state.videoMap[providerId].unshift(video)
} else {
state.videoMap[providerId] = [video]
}
},
removeVideo: (state: VideoState, action: PayloadAction<{ providerId: string; videoId: string }>) => {
const { providerId, videoId } = action.payload
const videos = state.videoMap[providerId]
state.videoMap[providerId] = videos?.filter((c) => c.id !== videoId)
},
updateVideo: (
state: VideoState,
action: PayloadAction<{ providerId: string; update: Partial<Omit<Video, 'status'>> & { id: string } }>
) => {
const { providerId, update } = action.payload
const videos = state.videoMap[providerId]
if (videos) {
let video = videos.find((v) => v.id === update.id)
if (video) {
switch (video.status) {
case 'queued':
case 'in_progress':
video = { ...video, ...update, thumbnail: undefined }
break
default:
video = { ...video, ...update }
}
} else {
logger.error(`Video with id ${update.id} not found in ${providerId}`)
}
} else {
logger.error(`Videos with Provider ${providerId} is undefined.`)
}
},
setVideo: (state: VideoState, action: PayloadAction<{ providerId: string; video: Video }>) => {
const { providerId, video } = action.payload
if (state.videoMap[providerId]) {
const index = state.videoMap[providerId].findIndex((v) => v.id === video.id)
if (index !== -1) {
state.videoMap[providerId][index] = video
} else {
state.videoMap[providerId].push(video)
}
} else {
state.videoMap[providerId] = [video]
}
},
setVideos: (state: VideoState, action: PayloadAction<{ providerId: string; videos: Video[] }>) => {
const { providerId, videos } = action.payload
state.videoMap[providerId] = videos
}
}
})
export const {
addVideo: addVideoAction,
removeVideo: removeVideoAction,
updateVideo: updateVideoAction,
setVideo: setVideoAction,
setVideos: setVideosAction
} = videoSlice.actions
export default videoSlice.reducer

View File

@@ -1,8 +1,8 @@
import { OpenAI } from '@cherrystudio/openai'
import { Stream } from '@cherrystudio/openai/streaming'
import { TokenUsage } from '@mcp-trace/trace-core'
import { Span } from '@opentelemetry/api'
import { endSpan } from '@renderer/services/SpanManagerService'
import { OpenAI } from 'openai'
import { Stream } from 'openai/streaming'
export class StreamHandler {
private topicId: string

View File

@@ -1,6 +1,6 @@
import type OpenAI from '@cherrystudio/openai'
import type { File } from '@google/genai'
import type { FileSchema } from '@mistralai/mistralai/models/components'
import type OpenAI from 'openai'
export type RemoteFile =
| {
@@ -127,6 +127,10 @@ export type ImageFileMetadata = FileMetadata & {
type: FileTypes.IMAGE
}
export type VideoFileMetadata = FileMetadata & {
type: FileTypes.VIDEO
}
export type PdfFileMetadata = FileMetadata & {
ext: '.pdf'
}

View File

@@ -1,7 +1,7 @@
import type { LanguageModelV2Source } from '@ai-sdk/provider'
import type { WebSearchResultBlock } from '@anthropic-ai/sdk/resources'
import type OpenAI from '@cherrystudio/openai'
import type { GenerateImagesConfig, GroundingMetadata, PersonGeneration } from '@google/genai'
import type OpenAI from 'openai'
import type { CSSProperties } from 'react'
export * from './file'
@@ -23,6 +23,7 @@ export * from './mcp'
export * from './notification'
export * from './ocr'
export * from './provider'
export * from './video'
export type Assistant = {
id: string
@@ -538,6 +539,7 @@ export type SidebarIcon =
| 'files'
| 'code_tools'
| 'notes'
| 'video'
export type ExternalToolResult = {
mcpTools?: MCPTool[]

View File

@@ -1,5 +1,5 @@
import type { CompletionUsage } from '@cherrystudio/openai/resources'
import type { ProviderMetadata } from 'ai'
import type { CompletionUsage } from 'openai/resources'
import type {
Assistant,

View File

@@ -11,6 +11,9 @@ import { MessageStream } from '@anthropic-ai/sdk/resources/messages/messages'
import AnthropicVertex from '@anthropic-ai/vertex-sdk'
import type { BedrockClient } from '@aws-sdk/client-bedrock'
import type { BedrockRuntimeClient } from '@aws-sdk/client-bedrock-runtime'
import OpenAI, { AzureOpenAI } from '@cherrystudio/openai'
import { ChatCompletionContentPartImage } from '@cherrystudio/openai/resources'
import { Stream } from '@cherrystudio/openai/streaming'
import {
Content,
CreateChatParameters,
@@ -21,9 +24,6 @@ import {
SendMessageParameters,
Tool
} from '@google/genai'
import OpenAI, { AzureOpenAI } from 'openai'
import { ChatCompletionContentPartImage } from 'openai/resources'
import { Stream } from 'openai/streaming'
import { EndpointType } from './index'

View File

@@ -0,0 +1,194 @@
import OpenAI from '@cherrystudio/openai'
import { Provider } from './provider'
// Only OpenAI (Responses) is supported for now.
export type VideoEndpointType = 'openai'
export type VideoStatus = 'queued' | 'in_progress' | 'completed' | 'downloading' | 'downloaded' | 'failed'
interface VideoBase {
readonly id: string
readonly type: VideoEndpointType
readonly providerId: string
name: string
thumbnail?: string | null
fileId?: string
prompt: string
/**
* Represents the possible states of a video generation or download process.
*
* - `queued`: The video task has been submitted and is waiting to be processed.
* - `in_progress`: The video is currently being generated.
* - `completed`: The video has been successfully generated and is ready for download.
* - `downloading`: The video content is being downloaded to local storage.
* - `downloaded`: The video has been fully downloaded and is available locally.
* - `failed`: The video task encountered an error and could not be completed.
*/
readonly status: VideoStatus
}
interface OpenAIVideoBase {
readonly type: 'openai'
metadata: OpenAI.Videos.Video
}
export interface VideoQueued extends VideoBase {
readonly status: 'queued'
thumbnail?: never
}
export interface VideoInProgress extends VideoBase {
readonly status: 'in_progress'
/** integer percent */
progress: number
thumbnail?: never
}
export interface VideoCompleted extends VideoBase {
readonly status: 'completed'
/** Base64 image string. When generation completed, firstly try to retrieve thumbnail. */
thumbnail: string | null
}
export interface VideoDownloading extends VideoBase {
readonly status: 'downloading'
/** Base64 image string */
thumbnail: string | null
/** integer percent */
progress: number
}
export interface VideoDownloaded extends VideoBase {
readonly status: 'downloaded'
/** Base64 image string */
thumbnail: string | null
/** Managed by fileManager */
fileId: string
}
export interface VideoFailedBase extends VideoBase {
readonly status: 'failed'
error: unknown
}
export interface OpenAIVideoQueued extends VideoQueued, OpenAIVideoBase {}
export interface OpenAIVideoInProgress extends VideoInProgress, OpenAIVideoBase {}
export interface OpenAIVideoCompleted extends VideoCompleted, OpenAIVideoBase {}
export interface OpenAIVideoDownloading extends VideoDownloading, OpenAIVideoBase {}
export interface OpenAIVideoDownloaded extends VideoDownloaded, OpenAIVideoBase {}
export interface OpenAIVideoFailed extends VideoFailedBase, OpenAIVideoBase {
error: OpenAI.Videos.Video['error']
}
export type VideoFailed = OpenAIVideoFailed
export type OpenAIVideo =
| OpenAIVideoQueued
| OpenAIVideoInProgress
| OpenAIVideoCompleted
| OpenAIVideoDownloading
| OpenAIVideoDownloaded
| OpenAIVideoFailed
export type Video = OpenAIVideo
// Create Video
interface CreateVideoBaseParams {
type: VideoEndpointType
provider: Provider
}
export interface OpenAICreateVideoParams extends CreateVideoBaseParams {
type: 'openai'
params: OpenAI.VideoCreateParams
options?: OpenAI.RequestOptions
}
export type CreateVideoParams = OpenAICreateVideoParams
interface CreateVideoBaseResult {
type: VideoEndpointType
video: unknown
}
export interface OpenAICreateVideoResult extends CreateVideoBaseResult {
type: 'openai'
video: OpenAI.Videos.Video
}
export type CreateVideoResult = OpenAICreateVideoResult
// Retrieve Video
interface RetrieveVideoBaseParams {
type: VideoEndpointType
provider: Provider
}
export interface OpenAIRetrieveVideoParams extends RetrieveVideoBaseParams {
type: 'openai'
videoId: string
options?: OpenAI.RequestOptions
}
export type RetrieveVideoParams = OpenAIRetrieveVideoParams
interface RetrieveVideoBaseResult {
type: VideoEndpointType
}
export interface OpenAIRetrieveVideoResult extends RetrieveVideoBaseResult {
type: 'openai'
video: OpenAI.Videos.Video
}
export type RetrieveVideoResult = OpenAIRetrieveVideoResult
// Retrieve Video Content
interface RetrieveVideoContentBaseParams {
type: VideoEndpointType
provider: Provider
}
export interface OpenAIRetrieveVideoContentParams extends RetrieveVideoContentBaseParams {
type: 'openai'
videoId: string
query?: OpenAI.Videos.VideoDownloadContentParams
options?: OpenAI.RequestOptions
}
export type RetrieveVideoContentParams = OpenAIRetrieveVideoContentParams
interface RetrieveVideoContentBaseResult {
type: VideoEndpointType
}
export interface OpenAIRetrieveVideoContentResult extends RetrieveVideoContentBaseResult {
type: 'openai'
response: Response
}
export type RetrieveVideoContentResult = OpenAIRetrieveVideoContentResult
// Delete Video
export interface DeleteVideoBaseParams {
type: VideoEndpointType
provider: Provider
}
export interface OpenAIDeleteVideoParams extends DeleteVideoBaseParams {
type: 'openai'
videoId: string
options?: OpenAI.RequestOptions
}
export type DeleteVideoParams = OpenAIDeleteVideoParams
interface DeleteVideoBaseResult {
type: VideoEndpointType
result: unknown
}
export interface OpenAIDeleteVideoResult extends DeleteVideoBaseResult {
type: 'openai'
result: OpenAI.Videos.VideoDeleteResponse
}
export type DeleteVideoResult = OpenAIDeleteVideoResult

View File

@@ -0,0 +1,35 @@
import { DeepPartial } from 'ai'
import { cloneDeep } from 'lodash'
/**
* Deeply updates an object, allowing undefined to overwrite existing properties, without using `any`
* @param target Original object
* @param update Update object (may contain undefined)
* @returns New object
*/
export function deepUpdate<T extends object>(target: T, update: DeepPartial<T>): T {
const result = cloneDeep(target)
for (const key in update) {
if (Object.hasOwn(update, key)) {
// @ts-ignore it's runtime safe
const prev = result[key]
const next = update[key]
if (
next &&
typeof next === 'object' &&
!Array.isArray(next) &&
prev &&
typeof prev === 'object' &&
!Array.isArray(prev)
) {
// @ts-ignore it's runtime safe
result[key] = deepUpdate(prev, next as any)
} else {
// @ts-ignore it's runtime safe
result[key] = next
}
}
}
return result
}

View File

@@ -1,4 +1,11 @@
import { ContentBlockParam, MessageParam, ToolUnion, ToolUseBlock } from '@anthropic-ai/sdk/resources'
import OpenAI from '@cherrystudio/openai'
import {
ChatCompletionContentPart,
ChatCompletionMessageParam,
ChatCompletionMessageToolCall,
ChatCompletionTool
} from '@cherrystudio/openai/resources'
import { Content, FunctionCall, Part, Tool, Type as GeminiSchemaType } from '@google/genai'
import { loggerService } from '@logger'
import { isFunctionCallingModel, isVisionModel } from '@renderer/config/models'
@@ -21,13 +28,6 @@ import { ChunkType } from '@renderer/types/chunk'
import { AwsBedrockSdkMessageParam, AwsBedrockSdkTool, AwsBedrockSdkToolCall } from '@renderer/types/sdk'
import { t } from 'i18next'
import { nanoid } from 'nanoid'
import OpenAI from 'openai'
import {
ChatCompletionContentPart,
ChatCompletionMessageParam,
ChatCompletionMessageToolCall,
ChatCompletionTool
} from 'openai/resources'
import { isToolUseModeFunction } from './assistant'
import { convertBase64ImageToAwsBedrockFormat } from './aws-bedrock-utils'

View File

@@ -0,0 +1,7 @@
import { VideoModel } from '@cherrystudio/openai/resources'
import { videoModelsMap } from '@renderer/config/models/video'
// Only for openai, use hard-encoded values
export const isVideoModel = (modelId: string): modelId is VideoModel => {
return videoModelsMap.openai.some((v) => v === modelId)
}

View File

@@ -2677,6 +2677,23 @@ __metadata:
languageName: unknown
linkType: soft
"@cherrystudio/openai@npm:6.3.0-fork.1, openai@npm:@cherrystudio/openai@6.3.0-fork.1":
version: 6.3.0-fork.1
resolution: "@cherrystudio/openai@npm:6.3.0-fork.1"
peerDependencies:
ws: ^8.18.0
zod: ^3.25 || ^4.0
peerDependenciesMeta:
ws:
optional: true
zod:
optional: true
bin:
openai: bin/cli
checksum: 10c0/dc8c5555aa6d12cd47586efc70175ec1bf7112c1f737b28d86d26e9666535d9e87d1c4811a069d11c324bab56f4dae242d7266efa88359d0926c7059240d24cc
languageName: node
linkType: hard
"@chevrotain/cst-dts-gen@npm:11.0.3":
version: 11.0.3
resolution: "@chevrotain/cst-dts-gen@npm:11.0.3"
@@ -13974,6 +13991,7 @@ __metadata:
"@cherrystudio/embedjs-ollama": "npm:^0.1.31"
"@cherrystudio/embedjs-openai": "npm:^0.1.31"
"@cherrystudio/extension-table-plus": "workspace:^"
"@cherrystudio/openai": "npm:6.3.0-fork.1"
"@dnd-kit/core": "npm:^6.3.1"
"@dnd-kit/modifiers": "npm:^9.0.0"
"@dnd-kit/sortable": "npm:^10.0.0"
@@ -14154,7 +14172,6 @@ __metadata:
notion-helper: "npm:^1.3.22"
npx-scope-finder: "npm:^1.2.0"
officeparser: "npm:^4.2.0"
openai: "patch:openai@npm%3A5.12.2#~/.yarn/patches/openai-npm-5.12.2-30b075401c.patch"
os-proxy-config: "npm:^1.1.2"
oxlint: "npm:^1.22.0"
oxlint-tsgolint: "npm:^0.2.0"
@@ -24013,23 +24030,6 @@ __metadata:
languageName: node
linkType: hard
"openai@patch:openai@npm%3A5.12.2#~/.yarn/patches/openai-npm-5.12.2-30b075401c.patch":
version: 5.12.2
resolution: "openai@patch:openai@npm%3A5.12.2#~/.yarn/patches/openai-npm-5.12.2-30b075401c.patch::version=5.12.2&hash=ad5d10"
peerDependencies:
ws: ^8.18.0
zod: ^3.23.8
peerDependenciesMeta:
ws:
optional: true
zod:
optional: true
bin:
openai: bin/cli
checksum: 10c0/2964a1c88a98cf169c9b73e8cd6776c03c8f3103fee30961c6953e5d995ad57a697e2179615999356809349186df6496abae105928ff7ce0229e5016dec87cb3
languageName: node
linkType: hard
"openapi-types@npm:^12.1.3":
version: 12.1.3
resolution: "openapi-types@npm:12.1.3"