Compare commits
111 Commits
copilot/fi
...
v2-video-g
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e02ee50a1 | ||
|
|
ad939f4b77 | ||
|
|
6abe5ab8c3 | ||
|
|
4af4421795 | ||
|
|
d4df86e6bc | ||
|
|
ffc3b43dc5 | ||
|
|
999c077b3a | ||
|
|
a0abf7fe62 | ||
|
|
3c6f32dbac | ||
|
|
2015b2e58c | ||
|
|
3e9a1378ce | ||
|
|
a07f00bf96 | ||
|
|
03a603eb6d | ||
|
|
ca96b6fa7b | ||
|
|
cd2609068c | ||
|
|
fee8b7ba92 | ||
|
|
b5f2c63396 | ||
|
|
4e76806cc3 | ||
|
|
09ed82eb49 | ||
|
|
b068fc25da | ||
|
|
a0627f76d5 | ||
|
|
85daceb417 | ||
|
|
2fab33de41 | ||
|
|
e88b4c091d | ||
|
|
6c097e6733 | ||
|
|
c9c859731f | ||
|
|
c85fad90b5 | ||
|
|
261b79198a | ||
|
|
81f186abd6 | ||
|
|
f44a4f7f96 | ||
|
|
15b7eb78c1 | ||
|
|
efd5e9dcf2 | ||
|
|
3b69b2bc49 | ||
|
|
c8dfae1d70 | ||
|
|
2ab3ddd804 | ||
|
|
7a62418f41 | ||
|
|
58c5df9284 | ||
|
|
c20394f460 | ||
|
|
8518734e48 | ||
|
|
29a01ef49a | ||
|
|
5e4b516402 | ||
|
|
1c89262929 | ||
|
|
b68a0ffaba | ||
|
|
41041fa296 | ||
|
|
66b88aec74 | ||
|
|
f54e583f34 | ||
|
|
1e1bfafb88 | ||
|
|
63459e3ec4 | ||
|
|
de10a7fd6c | ||
|
|
dced99ce57 | ||
|
|
0cafdeb540 | ||
|
|
258666e382 | ||
|
|
8a45fe70d0 | ||
|
|
d8363b5591 | ||
|
|
397a24b833 | ||
|
|
ca53e5f0c7 | ||
|
|
c50a574982 | ||
|
|
c3c125f3a3 | ||
|
|
eba370210f | ||
|
|
697ef22ab6 | ||
|
|
33582a460b | ||
|
|
d5078baa20 | ||
|
|
ae54d5d9b9 | ||
|
|
7bde37680e | ||
|
|
942c239d14 | ||
|
|
83114ee0c1 | ||
|
|
0dd894c911 | ||
|
|
e0cb39d00d | ||
|
|
12323375a5 | ||
|
|
788b170f98 | ||
|
|
42015b51e3 | ||
|
|
9997188f5e | ||
|
|
1fd7b0b667 | ||
|
|
1467493e1d | ||
|
|
f61cadd5b5 | ||
|
|
377b2b796f | ||
|
|
36df06db75 | ||
|
|
a901943675 | ||
|
|
953f0f4a2f | ||
|
|
8b875935d0 | ||
|
|
2f9b174095 | ||
|
|
d80eac2fbe | ||
|
|
5776512bf6 | ||
|
|
fd1a3faa69 | ||
|
|
82ad9e15e2 | ||
|
|
46221985bd | ||
|
|
d982c659d3 | ||
|
|
dad9425b44 | ||
|
|
dc19c17526 | ||
|
|
85c8d5fca2 | ||
|
|
4cf4c1e946 | ||
|
|
00221471b8 | ||
|
|
6d22a635f2 | ||
|
|
014247f983 | ||
|
|
7fe4524415 | ||
|
|
0ada5656ad | ||
|
|
c7c6561b77 | ||
|
|
590d69cfba | ||
|
|
9487eaf091 | ||
|
|
1235362c82 | ||
|
|
5db5d69cec | ||
|
|
9931856a1f | ||
|
|
833d2d9276 | ||
|
|
a1fde0db38 | ||
|
|
612d3756cf | ||
|
|
05ad98bb20 | ||
|
|
1c53222582 | ||
|
|
c6a0ad3fc0 | ||
|
|
ab2aa8380f | ||
|
|
45bdea5301 | ||
|
|
0f14b1625f |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "CherryStudio",
|
||||
"version": "2.0.0-alpha",
|
||||
"version": "2.0.0-alpha.sora.1",
|
||||
"private": true,
|
||||
"description": "A powerful AI assistant for producer.",
|
||||
"main": "./out/main/index.js",
|
||||
|
||||
@@ -666,7 +666,8 @@ export const DefaultPreferences: PreferenceSchemas = {
|
||||
'knowledge',
|
||||
'files',
|
||||
'code_tools',
|
||||
'notes'
|
||||
'notes',
|
||||
'video'
|
||||
],
|
||||
'ui.theme_mode': PreferenceTypes.ThemeMode.system,
|
||||
'ui.theme_user.code_font_family': '',
|
||||
|
||||
@@ -73,6 +73,7 @@ export type SidebarIcon =
|
||||
| 'files'
|
||||
| 'code_tools'
|
||||
| 'notes'
|
||||
| 'video'
|
||||
|
||||
export type AssistantIconType = 'model' | 'emoji' | 'none'
|
||||
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
// Original path: src/renderer/src/components/Icons/FileIcons.tsx
|
||||
import type { CSSProperties, SVGProps } from 'react'
|
||||
|
||||
interface BaseFileIconProps extends SVGProps<SVGSVGElement> {
|
||||
size?: string | number
|
||||
text?: string
|
||||
}
|
||||
|
||||
const textStyle: CSSProperties = {
|
||||
fontStyle: 'italic',
|
||||
fontSize: '7.70985px',
|
||||
lineHeight: 0.8,
|
||||
fontFamily: "'Times New Roman'",
|
||||
textAlign: 'center',
|
||||
writingMode: 'horizontal-tb',
|
||||
direction: 'ltr',
|
||||
textAnchor: 'middle',
|
||||
fill: 'none',
|
||||
stroke: '#000000',
|
||||
strokeWidth: '0.289119',
|
||||
strokeLinejoin: 'round',
|
||||
strokeDasharray: 'none'
|
||||
}
|
||||
|
||||
const tspanStyle: CSSProperties = {
|
||||
fontStyle: 'normal',
|
||||
fontVariant: 'normal',
|
||||
fontWeight: 'normal',
|
||||
fontStretch: 'condensed',
|
||||
fontSize: '7.70985px',
|
||||
lineHeight: 0.8,
|
||||
fontFamily: 'Arial',
|
||||
fill: '#000000',
|
||||
fillOpacity: 1,
|
||||
strokeWidth: '0.289119',
|
||||
strokeDasharray: 'none'
|
||||
}
|
||||
|
||||
const BaseFileIcon = ({ size = '1.1em', text = 'SVG', ...props }: BaseFileIconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
version="1.1"
|
||||
id="svg4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}>
|
||||
<defs id="defs4" />
|
||||
<path d="m 14,2 v 4 a 2,2 0 0 0 2,2 h 4" id="path3" />
|
||||
<path d="M 15,2 H 6 A 2,2 0 0 0 4,4 v 16 a 2,2 0 0 0 2,2 h 12 a 2,2 0 0 0 2,-2 V 7 Z" id="path4" />
|
||||
<text
|
||||
xmlSpace="preserve"
|
||||
style={textStyle}
|
||||
x="12.478625"
|
||||
y="17.170216"
|
||||
id="text4"
|
||||
transform="scale(0.96196394,1.03954)">
|
||||
<tspan id="tspan4" x="12.478625" y="17.170216" style={tspanStyle}>
|
||||
{text}
|
||||
</tspan>
|
||||
</text>
|
||||
</svg>
|
||||
)
|
||||
|
||||
/**
|
||||
* @deprecated 此图标使用频率仅为 1 次,不符合 UI 库提取标准(需 ≥3 次)
|
||||
* 计划在未来版本中移除。
|
||||
*
|
||||
* This icon has only 1 usage and does not meet the UI library extraction criteria (requires ≥3 usages).
|
||||
* Planned for removal in future versions.
|
||||
*/
|
||||
export const FileSvgIcon = (props: Omit<BaseFileIconProps, 'text'>) => <BaseFileIcon text="SVG" {...props} />
|
||||
|
||||
/**
|
||||
* @deprecated 此图标使用频率仅为 2 次,不符合 UI 库提取标准(需 ≥3 次)
|
||||
* 计划在未来版本中移除。
|
||||
*
|
||||
* This icon has only 2 usages and does not meet the UI library extraction criteria (requires ≥3 usages).
|
||||
* Planned for removal in future versions.
|
||||
*/
|
||||
export const FilePngIcon = (props: Omit<BaseFileIconProps, 'text'>) => <BaseFileIcon text="PNG" {...props} />
|
||||
@@ -1,53 +0,0 @@
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import {
|
||||
AlignLeft,
|
||||
Copy,
|
||||
Eye,
|
||||
Pencil,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
ScanLine,
|
||||
Search,
|
||||
Trash,
|
||||
WrapText,
|
||||
Wrench
|
||||
} from 'lucide-react'
|
||||
import React from 'react'
|
||||
|
||||
// 创建一个 Icon 工厂函数
|
||||
export function createIcon(IconComponent: LucideIcon, defaultSize: string | number = '1rem') {
|
||||
const Icon = ({
|
||||
ref,
|
||||
...props
|
||||
}: React.ComponentProps<typeof IconComponent> & { ref?: React.RefObject<SVGSVGElement | null> }) => (
|
||||
<IconComponent ref={ref} size={defaultSize} {...props} />
|
||||
)
|
||||
Icon.displayName = `Icon(${IconComponent.displayName || IconComponent.name})`
|
||||
return Icon
|
||||
}
|
||||
|
||||
// 预定义的常用图标(向后兼容,只导入需要的图标)
|
||||
export const CopyIcon = createIcon(Copy)
|
||||
export const DeleteIcon = createIcon(Trash)
|
||||
export const EditIcon = createIcon(Pencil)
|
||||
export const RefreshIcon = createIcon(RefreshCw)
|
||||
export const ResetIcon = createIcon(RotateCcw)
|
||||
|
||||
/**
|
||||
* @deprecated 此组件使用频率为 0 次,不符合 UI 库提取标准(需 ≥3 次)
|
||||
* 计划在未来版本中移除。虽然主项目中有本地副本,但完全未被导入使用。
|
||||
*
|
||||
* This icon has 0 usages and does not meet the UI library extraction criteria (requires ≥3 usages).
|
||||
* Planned for removal in future versions.
|
||||
*/
|
||||
export const ToolIcon = createIcon(Wrench)
|
||||
|
||||
export const VisionIcon = createIcon(Eye)
|
||||
export const WebSearchIcon = createIcon(Search)
|
||||
export const WrapIcon = createIcon(WrapText)
|
||||
export const UnWrapIcon = createIcon(AlignLeft)
|
||||
export const OcrIcon = createIcon(ScanLine)
|
||||
|
||||
// 导出 createIcon 以便用户自行创建图标组件
|
||||
export type { LucideIcon }
|
||||
export type { LucideProps } from 'lucide-react'
|
||||
@@ -1,37 +0,0 @@
|
||||
/**
|
||||
* @deprecated 此组件使用频率为 0 次,不符合 UI 库提取标准(需 ≥3 次)
|
||||
* 计划在未来版本中移除。虽然主项目中有本地副本,但完全未被导入使用。
|
||||
*
|
||||
* This component has 0 usages and does not meet the UI library extraction criteria (requires ≥3 usages).
|
||||
* Planned for removal in future versions.
|
||||
*/
|
||||
|
||||
// Original path: src/renderer/src/components/Icons/SvgSpinners180Ring.tsx
|
||||
import type { SVGProps } from 'react'
|
||||
|
||||
import { cn } from '../../../utils'
|
||||
|
||||
interface SvgSpinners180RingProps extends SVGProps<SVGSVGElement> {
|
||||
size?: number | string
|
||||
}
|
||||
|
||||
export function SvgSpinners180Ring(props: SvgSpinners180RingProps) {
|
||||
const { size = '1em', className, ...svgProps } = props
|
||||
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
{...svgProps}
|
||||
className={cn('animate-spin', className)}>
|
||||
{/* Icon from SVG Spinners by Utkarsh Verma - https://github.com/n3r4zzurr0/svg-spinners/blob/main/LICENSE */}
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12,4a8,8,0,0,1,7.89,6.7A1.53,1.53,0,0,0,21.38,12h0a1.5,1.5,0,0,0,1.48-1.75,11,11,0,0,0-21.72,0A1.5,1.5,0,0,0,2.62,12h0a1.53,1.53,0,0,0,1.49-1.3A8,8,0,0,1,12,4Z"></path>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default SvgSpinners180Ring
|
||||
@@ -1,32 +0,0 @@
|
||||
/**
|
||||
* @deprecated 此组件使用频率仅为 1 次,不符合 UI 库提取标准(需 ≥3 次)
|
||||
* 计划在未来版本中移除。建议直接使用 lucide-react 的 Wrench 图标。
|
||||
*
|
||||
* This component has only 1 usage and does not meet the UI library extraction criteria (requires ≥3 usages).
|
||||
* Planned for removal in future versions. Consider using Wrench icon from lucide-react directly.
|
||||
*/
|
||||
|
||||
// Original: src/renderer/src/components/Icons/ToolsCallingIcon.tsx
|
||||
import { Tooltip, type TooltipProps } from '@heroui/react'
|
||||
import { Wrench } from 'lucide-react'
|
||||
import React from 'react'
|
||||
|
||||
import { cn } from '../../../utils'
|
||||
|
||||
interface ToolsCallingIconProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
className?: string
|
||||
iconClassName?: string
|
||||
TooltipProps?: TooltipProps
|
||||
}
|
||||
|
||||
const ToolsCallingIcon = ({ className, iconClassName, TooltipProps, ...props }: ToolsCallingIconProps) => {
|
||||
return (
|
||||
<div className={cn('flex justify-center items-center', className)} {...props}>
|
||||
<Tooltip {...TooltipProps}>
|
||||
<Wrench className={cn('w-4 h-4 mr-1.5 text-[#00b96b]', iconClassName)} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ToolsCallingIcon
|
||||
@@ -22,43 +22,11 @@ export { default as Scrollbar } from './composites/Scrollbar'
|
||||
export { default as ThinkingEffect } from './composites/ThinkingEffect'
|
||||
|
||||
// Icon Components
|
||||
export { FilePngIcon, FileSvgIcon } from './icons/FileIcons'
|
||||
// export type { LucideIcon, LucideProps } from './icons/Icon'
|
||||
// export {
|
||||
// CopyIcon,
|
||||
// createIcon,
|
||||
// DeleteIcon,
|
||||
// EditIcon,
|
||||
// OcrIcon,
|
||||
// RefreshIcon,
|
||||
// ResetIcon,
|
||||
// ToolIcon,
|
||||
// UnWrapIcon,
|
||||
// VisionIcon,
|
||||
// WebSearchIcon,
|
||||
// WrapIcon
|
||||
// } from './icons/Icon'
|
||||
export { default as SvgSpinners180Ring } from './icons/SvgSpinners180Ring'
|
||||
export { default as ToolsCallingIcon } from './icons/ToolsCallingIcon'
|
||||
|
||||
// Brand Logo Icons (彩色品牌 Logo 图标 - 84个)
|
||||
// 推荐使用 '@cherrystudio/ui/icons' 路径导入
|
||||
// Brand Logo Icons (Colorful brand logo icons - 81 items)
|
||||
// Recommended to import using '@cherrystudio/ui/icons' path
|
||||
export * from './icons'
|
||||
|
||||
// /* Selector Components */
|
||||
// export { default as Selector } from './primitives/select'
|
||||
// export { default as SearchableSelector } from './primitives/Selector/SearchableSelector'
|
||||
// export type {
|
||||
// MultipleSearchableSelectorProps,
|
||||
// MultipleSelectorProps,
|
||||
// SearchableSelectorItem,
|
||||
// SearchableSelectorProps,
|
||||
// SelectorItem,
|
||||
// SelectorProps,
|
||||
// SingleSearchableSelectorProps,
|
||||
// SingleSelectorProps
|
||||
// } from './primitives/Selector/types'
|
||||
|
||||
/* Additional Composite Components */
|
||||
// CodeEditor
|
||||
export {
|
||||
|
||||
@@ -1,270 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
|
||||
import { FilePngIcon, FileSvgIcon } from '../../../src/components/icons/FileIcons'
|
||||
|
||||
// Create a dummy component for the story
|
||||
const FileIconsShowcase = () => <div />
|
||||
|
||||
const meta: Meta<typeof FileIconsShowcase> = {
|
||||
title: 'Components/Icons/FileIcons',
|
||||
component: FileIconsShowcase,
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
size: {
|
||||
description: '图标大小',
|
||||
control: { type: 'text' },
|
||||
defaultValue: '1.1em'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Basic File Icons
|
||||
export const BasicFileIcons: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="mb-3 font-semibold">文件类型图标 (默认尺寸: 1.1em)</h3>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<FileSvgIcon />
|
||||
<span className="text-xs text-gray-600">SVG 文件</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<FilePngIcon />
|
||||
<span className="text-xs text-gray-600">PNG 文件</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Different Sizes
|
||||
export const DifferentSizes: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="mb-3 font-semibold">不同尺寸的 SVG 图标</h3>
|
||||
<div className="flex items-end gap-4">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<FileSvgIcon size="16" />
|
||||
<span className="text-xs text-gray-600">16px</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<FileSvgIcon size="24" />
|
||||
<span className="text-xs text-gray-600">24px</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<FileSvgIcon size="32" />
|
||||
<span className="text-xs text-gray-600">32px</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<FileSvgIcon size="48" />
|
||||
<span className="text-xs text-gray-600">48px</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<FileSvgIcon size="64" />
|
||||
<span className="text-xs text-gray-600">64px</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="mb-3 font-semibold">不同尺寸的 PNG 图标</h3>
|
||||
<div className="flex items-end gap-4">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<FilePngIcon size="16" />
|
||||
<span className="text-xs text-gray-600">16px</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<FilePngIcon size="24" />
|
||||
<span className="text-xs text-gray-600">24px</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<FilePngIcon size="32" />
|
||||
<span className="text-xs text-gray-600">32px</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<FilePngIcon size="48" />
|
||||
<span className="text-xs text-gray-600">48px</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<FilePngIcon size="64" />
|
||||
<span className="text-xs text-gray-600">64px</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Custom Colors
|
||||
export const CustomColors: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="mb-3 font-semibold">自定义颜色 - SVG 图标</h3>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<FileSvgIcon size="32" color="#3B82F6" />
|
||||
<span className="text-xs text-gray-600">蓝色</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<FileSvgIcon size="32" color="#10B981" />
|
||||
<span className="text-xs text-gray-600">绿色</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<FileSvgIcon size="32" color="#F59E0B" />
|
||||
<span className="text-xs text-gray-600">橙色</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<FileSvgIcon size="32" color="#EF4444" />
|
||||
<span className="text-xs text-gray-600">红色</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<FileSvgIcon size="32" color="#8B5CF6" />
|
||||
<span className="text-xs text-gray-600">紫色</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="mb-3 font-semibold">自定义颜色 - PNG 图标</h3>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<FilePngIcon size="32" color="#3B82F6" />
|
||||
<span className="text-xs text-gray-600">蓝色</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<FilePngIcon size="32" color="#10B981" />
|
||||
<span className="text-xs text-gray-600">绿色</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<FilePngIcon size="32" color="#F59E0B" />
|
||||
<span className="text-xs text-gray-600">橙色</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<FilePngIcon size="32" color="#EF4444" />
|
||||
<span className="text-xs text-gray-600">红色</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<FilePngIcon size="32" color="#8B5CF6" />
|
||||
<span className="text-xs text-gray-600">紫色</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// In File List Context
|
||||
export const InFileListContext: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-4">
|
||||
<h3 className="mb-3 font-semibold">文件列表中的使用示例</h3>
|
||||
|
||||
<div className="rounded-lg border border-gray-200 p-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3 rounded p-2 hover:bg-gray-50">
|
||||
<FileSvgIcon size="20" />
|
||||
<span className="flex-1">illustration.svg</span>
|
||||
<span className="text-xs text-gray-500">45 KB</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 rounded p-2 hover:bg-gray-50">
|
||||
<FilePngIcon size="20" />
|
||||
<span className="flex-1">screenshot.png</span>
|
||||
<span className="text-xs text-gray-500">1.2 MB</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 rounded p-2 hover:bg-gray-50">
|
||||
<FileSvgIcon size="20" />
|
||||
<span className="flex-1">logo.svg</span>
|
||||
<span className="text-xs text-gray-500">12 KB</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 rounded p-2 hover:bg-gray-50">
|
||||
<FilePngIcon size="20" />
|
||||
<span className="flex-1">background.png</span>
|
||||
<span className="text-xs text-gray-500">2.8 MB</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// File Type Grid
|
||||
export const FileTypeGrid: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-4">
|
||||
<h3 className="mb-3 font-semibold">文件类型网格展示</h3>
|
||||
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="flex flex-col items-center gap-2 rounded-lg border border-gray-200 p-4 hover:border-blue-500">
|
||||
<FileSvgIcon size="48" />
|
||||
<span className="text-sm font-medium">SVG</span>
|
||||
<span className="text-xs text-gray-600">矢量图形</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-2 rounded-lg border border-gray-200 p-4 hover:border-blue-500">
|
||||
<FilePngIcon size="48" />
|
||||
<span className="text-sm font-medium">PNG</span>
|
||||
<span className="text-xs text-gray-600">位图图像</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-2 rounded-lg border border-gray-200 p-4 hover:border-blue-500">
|
||||
<FileSvgIcon size="48" color="#10B981" />
|
||||
<span className="text-sm font-medium">SVG</span>
|
||||
<span className="text-xs text-gray-600">已处理</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-2 rounded-lg border border-gray-200 p-4 hover:border-blue-500">
|
||||
<FilePngIcon size="48" color="#EF4444" />
|
||||
<span className="text-sm font-medium">PNG</span>
|
||||
<span className="text-xs text-gray-600">错误状态</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Interactive Example
|
||||
export const InteractiveExample: Story = {
|
||||
render: () => {
|
||||
const fileTypes = [
|
||||
{ icon: FileSvgIcon, name: 'Vector Graphics', ext: 'SVG', color: '#3B82F6' },
|
||||
{ icon: FilePngIcon, name: 'Raster Image', ext: 'PNG', color: '#10B981' }
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="mb-3 font-semibold">交互式文件类型选择器</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{fileTypes.map((fileType, index) => {
|
||||
const IconComponent = fileType.icon
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
className="flex items-center gap-3 rounded-lg border border-gray-200 p-4 text-left transition-all hover:border-blue-500 hover:shadow-md focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20">
|
||||
<IconComponent size="32" color={fileType.color} />
|
||||
<div>
|
||||
<div className="font-medium">{fileType.ext} 文件</div>
|
||||
<div className="text-sm text-gray-600">{fileType.name}</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,339 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
|
||||
import SvgSpinners180Ring from '../../../src/components/icons/SvgSpinners180Ring'
|
||||
|
||||
const meta: Meta<typeof SvgSpinners180Ring> = {
|
||||
title: 'Components/Icons/SvgSpinners180Ring',
|
||||
component: SvgSpinners180Ring,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'⚠️ **已废弃** - 此组件使用频率为 0 次,不符合 UI 库提取标准(需 ≥3 次)。计划在未来版本中移除。虽然主项目中有本地副本,但完全未被导入使用。'
|
||||
}
|
||||
}
|
||||
},
|
||||
tags: ['autodocs', 'deprecated'],
|
||||
argTypes: {
|
||||
size: {
|
||||
description: '加载图标大小',
|
||||
control: { type: 'text' },
|
||||
defaultValue: '1em'
|
||||
},
|
||||
className: {
|
||||
description: '自定义 CSS 类名',
|
||||
control: { type: 'text' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Basic Spinner
|
||||
export const BasicSpinner: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="mb-3 font-semibold">基础加载动画</h3>
|
||||
<div className="flex items-center gap-4">
|
||||
<SvgSpinners180Ring />
|
||||
<span className="text-sm text-gray-600">默认尺寸 (1em)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Different Sizes
|
||||
export const DifferentSizes: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="mb-3 font-semibold">不同尺寸的加载动画</h3>
|
||||
<div className="flex items-end gap-6">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<SvgSpinners180Ring size="12" />
|
||||
<span className="text-xs text-gray-600">12px</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<SvgSpinners180Ring size="16" />
|
||||
<span className="text-xs text-gray-600">16px</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<SvgSpinners180Ring size="20" />
|
||||
<span className="text-xs text-gray-600">20px</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<SvgSpinners180Ring size="24" />
|
||||
<span className="text-xs text-gray-600">24px</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<SvgSpinners180Ring size="32" />
|
||||
<span className="text-xs text-gray-600">32px</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<SvgSpinners180Ring size="48" />
|
||||
<span className="text-xs text-gray-600">48px</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Different Colors
|
||||
export const DifferentColors: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="mb-3 font-semibold">不同颜色的加载动画</h3>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<SvgSpinners180Ring size="24" className="text-blue-500" />
|
||||
<span className="text-xs text-gray-600">蓝色</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<SvgSpinners180Ring size="24" className="text-green-500" />
|
||||
<span className="text-xs text-gray-600">绿色</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<SvgSpinners180Ring size="24" className="text-orange-500" />
|
||||
<span className="text-xs text-gray-600">橙色</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<SvgSpinners180Ring size="24" className="text-red-500" />
|
||||
<span className="text-xs text-gray-600">红色</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<SvgSpinners180Ring size="24" className="text-purple-500" />
|
||||
<span className="text-xs text-gray-600">紫色</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<SvgSpinners180Ring size="24" className="text-gray-500" />
|
||||
<span className="text-xs text-gray-600">灰色</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Loading States in Buttons
|
||||
export const LoadingStatesInButtons: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="mb-3 font-semibold">按钮中的加载状态</h3>
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600"
|
||||
disabled>
|
||||
<SvgSpinners180Ring size="16" />
|
||||
<span>加载中...</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 rounded bg-green-500 px-4 py-2 text-white hover:bg-green-600"
|
||||
disabled>
|
||||
<SvgSpinners180Ring size="16" />
|
||||
<span>保存中</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 rounded bg-orange-500 px-4 py-2 text-white hover:bg-orange-600"
|
||||
disabled>
|
||||
<SvgSpinners180Ring size="16" />
|
||||
<span>上传中</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 rounded border border-gray-300 bg-white px-4 py-2 text-gray-700 hover:bg-gray-50"
|
||||
disabled>
|
||||
<SvgSpinners180Ring size="16" className="text-gray-500" />
|
||||
<span>处理中</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Loading Cards
|
||||
export const LoadingCards: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="mb-3 font-semibold">加载状态卡片</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="rounded-lg border border-gray-200 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<SvgSpinners180Ring size="20" className="text-blue-500" />
|
||||
<div>
|
||||
<h4 className="font-medium">AI 模型响应中</h4>
|
||||
<p className="text-sm text-gray-600">正在生成回复...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-gray-200 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<SvgSpinners180Ring size="20" className="text-green-500" />
|
||||
<div>
|
||||
<h4 className="font-medium">文件上传中</h4>
|
||||
<p className="text-sm text-gray-600">75% 完成</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-gray-200 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<SvgSpinners180Ring size="20" className="text-orange-500" />
|
||||
<div>
|
||||
<h4 className="font-medium">数据同步中</h4>
|
||||
<p className="text-sm text-gray-600">请稍候...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-gray-200 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<SvgSpinners180Ring size="20" className="text-purple-500" />
|
||||
<div>
|
||||
<h4 className="font-medium">模型训练中</h4>
|
||||
<p className="text-sm text-gray-600">预计2分钟</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Inline Loading States
|
||||
export const InlineLoadingStates: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="mb-3 font-semibold">行内加载状态</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<SvgSpinners180Ring size="14" className="text-blue-500" />
|
||||
<span className="text-sm">正在检查网络连接...</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<SvgSpinners180Ring size="14" className="text-green-500" />
|
||||
<span className="text-sm">正在保存更改...</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<SvgSpinners180Ring size="14" className="text-orange-500" />
|
||||
<span className="text-sm">正在验证凭据...</span>
|
||||
</div>
|
||||
|
||||
<div className="rounded bg-blue-50 p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<SvgSpinners180Ring size="16" className="text-blue-600" />
|
||||
<span className="text-sm text-blue-800">系统正在处理您的请求,请稍候...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Loading States with Different Speeds
|
||||
export const LoadingStatesWithDifferentSpeeds: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="mb-3 font-semibold">不同速度的加载动画</h3>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<SvgSpinners180Ring size="24" className="animate-spin" style={{ animationDuration: '2s' }} />
|
||||
<span className="text-xs text-gray-600">慢速 (2s)</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<SvgSpinners180Ring size="24" />
|
||||
<span className="text-xs text-gray-600">默认速度</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<SvgSpinners180Ring size="24" className="animate-spin" style={{ animationDuration: '0.5s' }} />
|
||||
<span className="text-xs text-gray-600">快速 (0.5s)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Full Page Loading
|
||||
export const FullPageLoading: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="mb-3 font-semibold">全屏加载示例</h3>
|
||||
<div className="relative h-64 w-full overflow-hidden rounded-lg border border-gray-200 bg-white">
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-white/80">
|
||||
<SvgSpinners180Ring size="32" className="text-blue-500" />
|
||||
<p className="mt-4 text-sm text-gray-600">页面加载中,请稍候...</p>
|
||||
</div>
|
||||
|
||||
{/* 模拟页面内容 */}
|
||||
<div className="p-6 opacity-30">
|
||||
<div className="mb-4 h-6 w-1/3 rounded bg-gray-200"></div>
|
||||
<div className="mb-2 h-4 w-full rounded bg-gray-200"></div>
|
||||
<div className="mb-2 h-4 w-5/6 rounded bg-gray-200"></div>
|
||||
<div className="mb-4 h-4 w-4/6 rounded bg-gray-200"></div>
|
||||
<div className="mb-2 h-4 w-full rounded bg-gray-200"></div>
|
||||
<div className="h-4 w-3/4 rounded bg-gray-200"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Interactive Loading Demo
|
||||
export const InteractiveLoadingDemo: Story = {
|
||||
render: () => {
|
||||
const loadingStates = [
|
||||
{ text: '发送消息', color: 'text-blue-500', bgColor: 'bg-blue-500' },
|
||||
{ text: '上传文件', color: 'text-green-500', bgColor: 'bg-green-500' },
|
||||
{ text: '生成内容', color: 'text-purple-500', bgColor: 'bg-purple-500' },
|
||||
{ text: '搜索结果', color: 'text-orange-500', bgColor: 'bg-orange-500' }
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="mb-3 font-semibold">交互式加载演示</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{loadingStates.map((state, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
className={`flex items-center justify-center gap-2 rounded-lg ${state.bgColor} px-4 py-3 text-white transition-all hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-offset-2`}
|
||||
onClick={() => {
|
||||
// 演示用途 - 在实际应用中这里会触发真实的加载状态
|
||||
alert(`触发 ${state.text} 加载状态`)
|
||||
}}>
|
||||
<SvgSpinners180Ring size="16" />
|
||||
<span>{state.text}中...</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-500">点击按钮查看加载状态的交互效果</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,374 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
|
||||
import ToolsCallingIcon from '../../../src/components/icons/ToolsCallingIcon'
|
||||
|
||||
const meta: Meta<typeof ToolsCallingIcon> = {
|
||||
title: 'Components/Icons/ToolsCallingIcon',
|
||||
component: ToolsCallingIcon,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'⚠️ **已废弃** - 此组件使用频率仅为 1 次,不符合 UI 库提取标准(需 ≥3 次)。计划在未来版本中移除。建议直接使用 lucide-react 的 Wrench 图标。'
|
||||
}
|
||||
}
|
||||
},
|
||||
tags: ['autodocs', 'deprecated'],
|
||||
argTypes: {
|
||||
className: {
|
||||
description: '容器的自定义 CSS 类名',
|
||||
control: { type: 'text' }
|
||||
},
|
||||
iconClassName: {
|
||||
description: '图标的自定义 CSS 类名',
|
||||
control: { type: 'text' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Basic Tools Calling Icon
|
||||
export const BasicToolsCallingIcon: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="mb-3 font-semibold">基础工具调用图标</h3>
|
||||
<div className="flex items-center gap-4">
|
||||
<ToolsCallingIcon />
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-gray-600">悬停图标查看工具提示,显示"函数调用"文本</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Different Sizes
|
||||
export const DifferentSizes: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="mb-3 font-semibold">不同尺寸的工具调用图标</h3>
|
||||
<div className="flex items-end gap-6">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<ToolsCallingIcon iconClassName="w-3 h-3" />
|
||||
<span className="text-xs text-gray-600">小号</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<ToolsCallingIcon />
|
||||
<span className="text-xs text-gray-600">默认</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<ToolsCallingIcon iconClassName="w-5 h-5" />
|
||||
<span className="text-xs text-gray-600">中号</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<ToolsCallingIcon iconClassName="w-6 h-6" />
|
||||
<span className="text-xs text-gray-600">大号</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<ToolsCallingIcon iconClassName="w-8 h-8" />
|
||||
<span className="text-xs text-gray-600">特大号</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Different Colors
|
||||
export const DifferentColors: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="mb-3 font-semibold">不同颜色的工具调用图标</h3>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<ToolsCallingIcon />
|
||||
<span className="text-xs text-gray-600">默认绿色</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<ToolsCallingIcon iconClassName="w-4 h-4 mr-1.5 text-blue-500" />
|
||||
<span className="text-xs text-gray-600">蓝色</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<ToolsCallingIcon iconClassName="w-4 h-4 mr-1.5 text-orange-500" />
|
||||
<span className="text-xs text-gray-600">橙色</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<ToolsCallingIcon iconClassName="w-4 h-4 mr-1.5 text-red-500" />
|
||||
<span className="text-xs text-gray-600">红色</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<ToolsCallingIcon iconClassName="w-4 h-4 mr-1.5 text-purple-500" />
|
||||
<span className="text-xs text-gray-600">紫色</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<ToolsCallingIcon iconClassName="w-4 h-4 mr-1.5 text-gray-500" />
|
||||
<span className="text-xs text-gray-600">灰色</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Model Features Context
|
||||
export const ModelFeaturesContext: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-4">
|
||||
<h3 className="mb-3 font-semibold">在模型功能标识中的使用</h3>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<div className="rounded-lg border border-gray-200 p-4">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<h4 className="font-medium">GPT-4 Turbo</h4>
|
||||
<ToolsCallingIcon />
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">支持函数调用的先进模型,可以调用外部工具和API</p>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<span className="rounded bg-green-100 px-2 py-1 text-xs text-green-800">函数调用</span>
|
||||
<span className="rounded bg-blue-100 px-2 py-1 text-xs text-blue-800">多模态</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-gray-200 p-4">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<h4 className="font-medium">Claude 3.5 Sonnet</h4>
|
||||
<ToolsCallingIcon />
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">Anthropic的高性能模型,具备强大的工具使用能力</p>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<span className="rounded bg-green-100 px-2 py-1 text-xs text-green-800">函数调用</span>
|
||||
<span className="rounded bg-orange-100 px-2 py-1 text-xs text-orange-800">推理</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-gray-200 p-4">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<h4 className="font-medium">Llama 3.1 8B</h4>
|
||||
{/* 不支持函数调用 */}
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">Meta的开源模型,适用于基础对话任务</p>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<span className="rounded bg-gray-100 px-2 py-1 text-xs text-gray-800">文本生成</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Chat Message Context
|
||||
export const ChatMessageContext: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-4">
|
||||
<h3 className="mb-3 font-semibold">在聊天消息中的使用</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-lg bg-blue-50 p-3">
|
||||
<div className="mb-1 flex items-center gap-2 text-sm text-blue-800">
|
||||
<ToolsCallingIcon iconClassName="w-3.5 h-3.5 mr-1 text-[#00b96b]" />
|
||||
<span className="font-medium">调用工具: weather_api</span>
|
||||
</div>
|
||||
<p className="text-sm text-blue-700">正在获取北京的天气信息...</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-green-50 p-3">
|
||||
<div className="mb-1 flex items-center gap-2 text-sm text-green-800">
|
||||
<ToolsCallingIcon iconClassName="w-3.5 h-3.5 mr-1 text-[#00b96b]" />
|
||||
<span className="font-medium">调用工具: search_web</span>
|
||||
</div>
|
||||
<p className="text-sm text-green-700">正在搜索最新的AI新闻...</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-orange-50 p-3">
|
||||
<div className="mb-1 flex items-center gap-2 text-sm text-orange-800">
|
||||
<ToolsCallingIcon iconClassName="w-3.5 h-3.5 mr-1 text-[#00b96b]" />
|
||||
<span className="font-medium">调用工具: code_interpreter</span>
|
||||
</div>
|
||||
<p className="text-sm text-orange-700">正在执行Python代码计算结果...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Tool Availability Indicator
|
||||
export const ToolAvailabilityIndicator: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-4">
|
||||
<h3 className="mb-3 font-semibold">工具可用性指示器</h3>
|
||||
|
||||
<div className="rounded-lg border border-gray-200">
|
||||
<div className="border-b border-gray-200 p-3">
|
||||
<h4 className="font-medium text-gray-900">可用工具</h4>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-gray-200">
|
||||
<div className="flex items-center justify-between p-3 hover:bg-gray-50">
|
||||
<div className="flex items-center gap-2">
|
||||
<ToolsCallingIcon iconClassName="w-4 h-4 mr-1.5 text-[#00b96b]" />
|
||||
<span className="font-medium">天气查询</span>
|
||||
</div>
|
||||
<span className="rounded-full bg-green-100 px-2 py-1 text-xs text-green-800">可用</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 hover:bg-gray-50">
|
||||
<div className="flex items-center gap-2">
|
||||
<ToolsCallingIcon iconClassName="w-4 h-4 mr-1.5 text-[#00b96b]" />
|
||||
<span className="font-medium">网络搜索</span>
|
||||
</div>
|
||||
<span className="rounded-full bg-green-100 px-2 py-1 text-xs text-green-800">可用</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 hover:bg-gray-50 opacity-60">
|
||||
<div className="flex items-center gap-2">
|
||||
<ToolsCallingIcon iconClassName="w-4 h-4 mr-1.5 text-gray-400" />
|
||||
<span className="font-medium">代码执行</span>
|
||||
</div>
|
||||
<span className="rounded-full bg-gray-100 px-2 py-1 text-xs text-gray-800">不可用</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 hover:bg-gray-50">
|
||||
<div className="flex items-center gap-2">
|
||||
<ToolsCallingIcon iconClassName="w-4 h-4 mr-1.5 text-yellow-600" />
|
||||
<span className="font-medium">图像生成</span>
|
||||
</div>
|
||||
<span className="rounded-full bg-yellow-100 px-2 py-1 text-xs text-yellow-800">限制使用</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Interactive Tool Selection
|
||||
export const InteractiveToolSelection: Story = {
|
||||
render: () => {
|
||||
const tools = [
|
||||
{ name: '天气查询', description: '获取实时天气信息', available: true },
|
||||
{ name: '网络搜索', description: '搜索最新信息', available: true },
|
||||
{ name: '代码执行', description: '运行Python代码', available: false },
|
||||
{ name: '图像分析', description: '分析和描述图像', available: true },
|
||||
{ name: '数据可视化', description: '创建图表和图形', available: false }
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="mb-3 font-semibold">交互式工具选择器</h3>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
{tools.map((tool, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
className={`flex items-center gap-3 rounded-lg border p-3 text-left transition-all hover:shadow-md focus:outline-none focus:ring-2 focus:ring-blue-500/20 ${
|
||||
tool.available
|
||||
? 'border-gray-200 hover:border-blue-500'
|
||||
: 'border-gray-200 opacity-60 cursor-not-allowed'
|
||||
}`}
|
||||
disabled={!tool.available}>
|
||||
<ToolsCallingIcon
|
||||
iconClassName={`w-4 h-4 mr-1.5 ${tool.available ? 'text-[#00b96b]' : 'text-gray-400'}`}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{tool.name}</div>
|
||||
<div className="text-sm text-gray-600">{tool.description}</div>
|
||||
</div>
|
||||
<div className="text-xs">
|
||||
{tool.available ? (
|
||||
<span className="rounded bg-green-100 px-2 py-1 text-green-800">可用</span>
|
||||
) : (
|
||||
<span className="rounded bg-gray-100 px-2 py-1 text-gray-800">不可用</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Loading Tool Calls
|
||||
export const LoadingToolCalls: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-4">
|
||||
<h3 className="mb-3 font-semibold">工具调用加载状态</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-lg border border-gray-200 p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ToolsCallingIcon />
|
||||
<span className="font-medium">正在调用工具...</span>
|
||||
<div className="h-2 w-2 animate-pulse rounded-full bg-green-500"></div>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-600">weather_api(city="北京")</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-green-200 bg-green-50 p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ToolsCallingIcon iconClassName="w-4 h-4 mr-1.5 text-green-600" />
|
||||
<span className="font-medium text-green-800">工具调用完成</span>
|
||||
<span className="text-green-600">✓</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-green-700">已获取北京天气信息:晴天,温度 22°C</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ToolsCallingIcon iconClassName="w-4 h-4 mr-1.5 text-red-600" />
|
||||
<span className="font-medium text-red-800">工具调用失败</span>
|
||||
<span className="text-red-600">✗</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-red-700">API密钥无效,请检查配置</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Settings Panel
|
||||
export const SettingsPanel: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-4">
|
||||
<h3 className="mb-3 font-semibold">设置面板中的使用</h3>
|
||||
|
||||
<div className="rounded-lg border border-gray-200 p-4">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<ToolsCallingIcon />
|
||||
<h4 className="font-medium">函数调用设置</h4>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium">启用函数调用</div>
|
||||
<div className="text-sm text-gray-600">允许AI模型调用外部工具</div>
|
||||
</div>
|
||||
<input type="checkbox" className="rounded" defaultChecked />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium">自动确认调用</div>
|
||||
<div className="text-sm text-gray-600">自动执行安全的工具调用</div>
|
||||
</div>
|
||||
<input type="checkbox" className="rounded" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium">显示调用详情</div>
|
||||
<div className="text-sm text-gray-600">在聊天中显示工具调用过程</div>
|
||||
</div>
|
||||
<input type="checkbox" className="rounded" defaultChecked />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
28
src/main/data/db/schemas/video.ts
Normal file
28
src/main/data/db/schemas/video.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { VideoEndpointType, VideoStatus } from '@types'
|
||||
import { index, integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'
|
||||
|
||||
import { createUpdateTimestamps } from './columnHelpers'
|
||||
|
||||
export const videoTable = sqliteTable(
|
||||
'video',
|
||||
{
|
||||
id: text().primaryKey(),
|
||||
type: text('type').$type<VideoEndpointType>().notNull(),
|
||||
providerId: text('providerId').notNull(),
|
||||
name: text('name'),
|
||||
thumbnail: text('thumbnail'),
|
||||
fileId: text('fileId'),
|
||||
prompt: text('prompt').notNull(),
|
||||
status: text('status').$type<VideoStatus>().notNull(),
|
||||
progress: integer('progress'),
|
||||
metadata: text('metadata', { mode: 'json' }),
|
||||
error: text('error', { mode: 'json' }),
|
||||
...createUpdateTimestamps
|
||||
},
|
||||
(table) => [
|
||||
index('status_idx').on(table.status),
|
||||
index('provider_idx').on(table.providerId),
|
||||
index('type_idx').on(table.type),
|
||||
uniqueIndex('file_id_idx').on(table.fileId)
|
||||
]
|
||||
)
|
||||
@@ -21,6 +21,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()
|
||||
@@ -41,6 +42,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>
|
||||
)
|
||||
|
||||
@@ -517,6 +517,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()
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
import { isSupportDeveloperRoleProvider } from '@renderer/config/providers'
|
||||
import { estimateTextTokens } from '@renderer/services/TokenService'
|
||||
import type {
|
||||
CreateVideoParams,
|
||||
DeleteVideoParams,
|
||||
FileMetadata,
|
||||
MCPCallToolResponse,
|
||||
MCPTool,
|
||||
@@ -22,6 +24,8 @@ import type {
|
||||
Model,
|
||||
OpenAIServiceTier,
|
||||
Provider,
|
||||
RetrieveVideoContentParams,
|
||||
RetrieveVideoParams,
|
||||
ToolCallResponse
|
||||
} from '@renderer/types'
|
||||
import { FileTypes, WebSearchSource } from '@renderer/types'
|
||||
@@ -151,6 +155,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 {
|
||||
|
||||
@@ -5,7 +5,19 @@ import { isDedicatedImageGenerationModel, isFunctionCallingModel } from '@render
|
||||
import { getProviderByModel } from '@renderer/services/AssistantService'
|
||||
import { withSpanResult } from '@renderer/services/SpanManagerService'
|
||||
import type { StartSpanParams } from '@renderer/trace/types/ModelSpanEntity'
|
||||
import type { GenerateImageParams, Model, Provider } from '@renderer/types'
|
||||
import type {
|
||||
CreateVideoParams,
|
||||
CreateVideoResult,
|
||||
DeleteVideoParams,
|
||||
DeleteVideoResult,
|
||||
GenerateImageParams,
|
||||
Model,
|
||||
Provider,
|
||||
RetrieveVideoContentParams,
|
||||
RetrieveVideoContentResult,
|
||||
RetrieveVideoParams,
|
||||
RetrieveVideoResult
|
||||
} from '@renderer/types'
|
||||
import type { RequestOptions, SdkModel } from '@renderer/types/sdk'
|
||||
import { isSupportedToolUse } from '@renderer/utils/mcp-tools'
|
||||
|
||||
@@ -179,6 +191,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()
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
Sparkle,
|
||||
Sun,
|
||||
Terminal,
|
||||
Video,
|
||||
X
|
||||
} from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo } from 'react'
|
||||
@@ -107,6 +108,8 @@ const getTabIcon = (
|
||||
return <Settings size={14} />
|
||||
case 'code':
|
||||
return <Terminal size={14} />
|
||||
case 'video':
|
||||
return <Video size={14} />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -26,7 +26,8 @@ import {
|
||||
Palette,
|
||||
Settings,
|
||||
Sparkle,
|
||||
Sun
|
||||
Sun,
|
||||
Video
|
||||
} from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -140,7 +141,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 = {
|
||||
@@ -152,7 +154,8 @@ const MainMenus: FC = () => {
|
||||
knowledge: '/knowledge',
|
||||
files: '/files',
|
||||
code_tools: '/code',
|
||||
notes: '/notes'
|
||||
notes: '/notes',
|
||||
video: '/video'
|
||||
}
|
||||
|
||||
return visibleSidebarIcons.map((icon) => {
|
||||
|
||||
149
src/renderer/src/config/models/video.ts
Normal file
149
src/renderer/src/config/models/video.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import type { 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'
|
||||
}
|
||||
}
|
||||
]
|
||||
17
src/renderer/src/hooks/usePending.ts
Normal file
17
src/renderer/src/hooks/usePending.ts
Normal 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 }
|
||||
}
|
||||
65
src/renderer/src/hooks/video/useAddOpenAIVideo.ts
Normal file
65
src/renderer/src/hooks/video/useAddOpenAIVideo.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type 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
|
||||
}
|
||||
48
src/renderer/src/hooks/video/useOpenAIVideo.ts
Normal file
48
src/renderer/src/hooks/video/useOpenAIVideo.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { retrieveVideo } from '@renderer/services/ApiService'
|
||||
import type { SWRConfiguration } from 'swr'
|
||||
import useSWR, { 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
|
||||
}
|
||||
}
|
||||
175
src/renderer/src/hooks/video/useProviderVideos.ts
Normal file
175
src/renderer/src/hooks/video/useProviderVideos.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
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 type { Video } from '@renderer/types'
|
||||
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, isRetrieving } = 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
|
||||
if (isRetrieving(storeVideo.id)) return
|
||||
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
|
||||
}
|
||||
}
|
||||
7
src/renderer/src/hooks/video/useVideo.ts
Normal file
7
src/renderer/src/hooks/video/useVideo.ts
Normal 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
|
||||
}
|
||||
94
src/renderer/src/hooks/video/useVideoThumbnail.ts
Normal file
94
src/renderer/src/hooks/video/useVideoThumbnail.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { retrieveVideoContent } from '@renderer/services/ApiService'
|
||||
import ImageStorage from '@renderer/services/ImageStorage'
|
||||
import { getProviderById } from '@renderer/services/ProviderService'
|
||||
import type { 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 isRetrieving = useCallback(
|
||||
(id: string) => {
|
||||
const key = getThumbnailKey(id)
|
||||
return pendingSet.has(key)
|
||||
},
|
||||
[getThumbnailKey]
|
||||
)
|
||||
|
||||
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 (isRetrieving(video.id)) {
|
||||
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, isRetrieving }
|
||||
}
|
||||
48
src/renderer/src/hooks/video/useVideos.ts
Normal file
48
src/renderer/src/hooks/video/useVideos.ts
Normal 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 }
|
||||
}
|
||||
@@ -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 => {
|
||||
@@ -184,7 +185,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 => {
|
||||
|
||||
@@ -1113,6 +1113,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",
|
||||
@@ -1157,10 +1158,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",
|
||||
@@ -1168,6 +1171,7 @@
|
||||
"selected": "Selected",
|
||||
"selectedItems": "Selected {{count}} items",
|
||||
"selectedMessages": "Selected {{count}} messages",
|
||||
"send": "Send",
|
||||
"settings": "Settings",
|
||||
"sort": {
|
||||
"pinyin": {
|
||||
@@ -1227,6 +1231,9 @@
|
||||
},
|
||||
"content": "Content",
|
||||
"data": "Data",
|
||||
"delete": {
|
||||
"failed": "Failed to delete."
|
||||
},
|
||||
"detail": "Error Details",
|
||||
"details": "Details",
|
||||
"errors": "Errors",
|
||||
@@ -1334,7 +1341,8 @@
|
||||
"size": "Size",
|
||||
"text": "Text",
|
||||
"title": "Files",
|
||||
"type": "Type"
|
||||
"type": "Type",
|
||||
"video": "Video"
|
||||
},
|
||||
"gpustack": {
|
||||
"keep_alive_time": {
|
||||
@@ -4743,7 +4751,8 @@
|
||||
"paintings": "Paintings",
|
||||
"settings": "Settings",
|
||||
"store": "Assistant Library",
|
||||
"translate": "Translate"
|
||||
"translate": "Translate",
|
||||
"video": "Video"
|
||||
},
|
||||
"trace": {
|
||||
"backList": "Back To List",
|
||||
@@ -4901,6 +4910,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."
|
||||
},
|
||||
|
||||
@@ -1113,6 +1113,7 @@
|
||||
"delete_confirm": "确定要删除吗?",
|
||||
"delete_failed": "删除失败",
|
||||
"delete_success": "删除成功",
|
||||
"deleting": "删除中...",
|
||||
"description": "描述",
|
||||
"detail": "详情",
|
||||
"disabled": "已禁用",
|
||||
@@ -1157,10 +1158,12 @@
|
||||
"prompt": "提示词",
|
||||
"provider": "提供商",
|
||||
"reasoning_content": "已深度思考",
|
||||
"redownload": "重新下载",
|
||||
"refresh": "刷新",
|
||||
"regenerate": "重新生成",
|
||||
"rename": "重命名",
|
||||
"reset": "重置",
|
||||
"retry": "重试",
|
||||
"save": "保存",
|
||||
"saved": "已保存",
|
||||
"search": "搜索",
|
||||
@@ -1168,6 +1171,7 @@
|
||||
"selected": "已选择",
|
||||
"selectedItems": "已选择 {{count}} 项",
|
||||
"selectedMessages": "选中 {{count}} 条消息",
|
||||
"send": "发送",
|
||||
"settings": "设置",
|
||||
"sort": {
|
||||
"pinyin": {
|
||||
@@ -1227,6 +1231,9 @@
|
||||
},
|
||||
"content": "内容",
|
||||
"data": "数据",
|
||||
"delete": {
|
||||
"failed": "删除失败"
|
||||
},
|
||||
"detail": "错误详情",
|
||||
"details": "详细信息",
|
||||
"errors": "错误",
|
||||
@@ -1334,7 +1341,8 @@
|
||||
"size": "大小",
|
||||
"text": "文本",
|
||||
"title": "文件",
|
||||
"type": "类型"
|
||||
"type": "类型",
|
||||
"video": "视频"
|
||||
},
|
||||
"gpustack": {
|
||||
"keep_alive_time": {
|
||||
@@ -4743,7 +4751,8 @@
|
||||
"paintings": "绘画",
|
||||
"settings": "设置",
|
||||
"store": "助手库",
|
||||
"translate": "翻译"
|
||||
"translate": "翻译",
|
||||
"video": "视频"
|
||||
},
|
||||
"trace": {
|
||||
"backList": "返回列表",
|
||||
@@ -4901,6 +4910,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": "获取缩略图失败"
|
||||
},
|
||||
"get": "获取缩略图",
|
||||
"placeholder": "无缩略图"
|
||||
},
|
||||
"title": "视频",
|
||||
"undefined": "无可用视频"
|
||||
},
|
||||
"warning": {
|
||||
"missing_provider": "供应商不存在,已回退到默认供应商 {{provider}}。这可能导致问题。"
|
||||
},
|
||||
|
||||
@@ -1113,6 +1113,7 @@
|
||||
"delete_confirm": "確定要刪除嗎?",
|
||||
"delete_failed": "刪除失敗",
|
||||
"delete_success": "刪除成功",
|
||||
"deleting": "[to be translated]:Deleting...",
|
||||
"description": "描述",
|
||||
"detail": "詳情",
|
||||
"disabled": "已停用",
|
||||
@@ -1157,10 +1158,12 @@
|
||||
"prompt": "提示詞",
|
||||
"provider": "供應商",
|
||||
"reasoning_content": "已深度思考",
|
||||
"redownload": "[to be translated]:Redownload",
|
||||
"refresh": "重新整理",
|
||||
"regenerate": "重新生成",
|
||||
"rename": "重新命名",
|
||||
"reset": "重設",
|
||||
"retry": "[to be translated]:Retry",
|
||||
"save": "儲存",
|
||||
"saved": "已儲存",
|
||||
"search": "搜尋",
|
||||
@@ -1168,6 +1171,7 @@
|
||||
"selected": "已選擇",
|
||||
"selectedItems": "已選擇 {{count}} 項",
|
||||
"selectedMessages": "選中 {{count}} 條訊息",
|
||||
"send": "[to be translated]:Send",
|
||||
"settings": "設定",
|
||||
"sort": {
|
||||
"pinyin": {
|
||||
@@ -1227,6 +1231,9 @@
|
||||
},
|
||||
"content": "內容",
|
||||
"data": "数据",
|
||||
"delete": {
|
||||
"failed": "[to be translated]:Failed to delete."
|
||||
},
|
||||
"detail": "錯誤詳情",
|
||||
"details": "詳細信息",
|
||||
"errors": "錯誤",
|
||||
@@ -1334,7 +1341,8 @@
|
||||
"size": "大小",
|
||||
"text": "文字",
|
||||
"title": "檔案",
|
||||
"type": "類型"
|
||||
"type": "類型",
|
||||
"video": "[to be translated]:Video"
|
||||
},
|
||||
"gpustack": {
|
||||
"keep_alive_time": {
|
||||
@@ -4743,7 +4751,8 @@
|
||||
"paintings": "繪畫",
|
||||
"settings": "設定",
|
||||
"store": "助手庫",
|
||||
"translate": "翻譯"
|
||||
"translate": "翻譯",
|
||||
"video": "[to be translated]:Video"
|
||||
},
|
||||
"trace": {
|
||||
"backList": "返回清單",
|
||||
@@ -4901,6 +4910,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}}。這可能導致問題。"
|
||||
},
|
||||
|
||||
@@ -1113,6 +1113,7 @@
|
||||
"delete_confirm": "Είστε βέβαιοι ότι θέλετε να διαγράψετε;",
|
||||
"delete_failed": "Αποτυχία διαγραφής",
|
||||
"delete_success": "Η διαγραφή ήταν επιτυχής",
|
||||
"deleting": "[to be translated]:Deleting...",
|
||||
"description": "Περιγραφή",
|
||||
"detail": "Λεπτομέρειες",
|
||||
"disabled": "Απενεργοποιημένο",
|
||||
@@ -1157,10 +1158,12 @@
|
||||
"prompt": "Ενδεικτικός ρήματος",
|
||||
"provider": "Παρέχων",
|
||||
"reasoning_content": "Έχει σκεφτεί πολύ καλά",
|
||||
"redownload": "[to be translated]:Redownload",
|
||||
"refresh": "Ανανέωση",
|
||||
"regenerate": "Ξαναπαραγωγή",
|
||||
"rename": "Μετονομασία",
|
||||
"reset": "Επαναφορά",
|
||||
"retry": "[to be translated]:Retry",
|
||||
"save": "Αποθήκευση",
|
||||
"saved": "Αποθηκεύτηκε",
|
||||
"search": "Αναζήτηση",
|
||||
@@ -1168,6 +1171,7 @@
|
||||
"selected": "Επιλεγμένο",
|
||||
"selectedItems": "Επιλέχθηκαν {{count}} αντικείμενα",
|
||||
"selectedMessages": "Επιλέχθηκαν {{count}} μηνύματα",
|
||||
"send": "[to be translated]:Send",
|
||||
"settings": "Ρυθμίσεις",
|
||||
"sort": {
|
||||
"pinyin": {
|
||||
@@ -1227,6 +1231,9 @@
|
||||
},
|
||||
"content": "Περιεχόμενο",
|
||||
"data": "δεδομένα",
|
||||
"delete": {
|
||||
"failed": "[to be translated]:Failed to delete."
|
||||
},
|
||||
"detail": "Λεπτομέρειες σφάλματος",
|
||||
"details": "Λεπτομέρειες",
|
||||
"errors": "Λάθος",
|
||||
@@ -1334,7 +1341,8 @@
|
||||
"size": "Μέγεθος",
|
||||
"text": "Κείμενο",
|
||||
"title": "Αρχεία",
|
||||
"type": "Τύπος"
|
||||
"type": "Τύπος",
|
||||
"video": "[to be translated]:Video"
|
||||
},
|
||||
"gpustack": {
|
||||
"keep_alive_time": {
|
||||
@@ -4743,7 +4751,8 @@
|
||||
"paintings": "Ζωγραφική",
|
||||
"settings": "Ρυθμίσεις",
|
||||
"store": "Βιβλιοθήκη βοηθών",
|
||||
"translate": "Μετάφραση"
|
||||
"translate": "Μετάφραση",
|
||||
"video": "[to be translated]:Video"
|
||||
},
|
||||
"trace": {
|
||||
"backList": "Επιστροφή στη λίστα",
|
||||
@@ -4901,6 +4910,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}}. Αυτό μπορεί να προκαλέσει προβλήματα."
|
||||
},
|
||||
|
||||
@@ -1113,6 +1113,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",
|
||||
@@ -1157,10 +1158,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",
|
||||
@@ -1168,6 +1171,7 @@
|
||||
"selected": "Seleccionado",
|
||||
"selectedItems": "{{count}} elementos seleccionados",
|
||||
"selectedMessages": "{{count}} mensajes seleccionados",
|
||||
"send": "[to be translated]:Send",
|
||||
"settings": "Configuración",
|
||||
"sort": {
|
||||
"pinyin": {
|
||||
@@ -1227,6 +1231,9 @@
|
||||
},
|
||||
"content": "contenido",
|
||||
"data": "datos",
|
||||
"delete": {
|
||||
"failed": "[to be translated]:Failed to delete."
|
||||
},
|
||||
"detail": "Detalles del error",
|
||||
"details": "Detalles",
|
||||
"errors": "error",
|
||||
@@ -1334,7 +1341,8 @@
|
||||
"size": "Tamaño",
|
||||
"text": "Texto",
|
||||
"title": "Archivo",
|
||||
"type": "Tipo"
|
||||
"type": "Tipo",
|
||||
"video": "[to be translated]:Video"
|
||||
},
|
||||
"gpustack": {
|
||||
"keep_alive_time": {
|
||||
@@ -4743,7 +4751,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",
|
||||
@@ -4901,6 +4910,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."
|
||||
},
|
||||
|
||||
@@ -1113,6 +1113,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é",
|
||||
@@ -1157,10 +1158,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",
|
||||
@@ -1168,6 +1171,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": {
|
||||
@@ -1227,6 +1231,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",
|
||||
@@ -1334,7 +1341,8 @@
|
||||
"size": "Taille",
|
||||
"text": "Texte",
|
||||
"title": "Fichier",
|
||||
"type": "Type"
|
||||
"type": "Type",
|
||||
"video": "[to be translated]:Video"
|
||||
},
|
||||
"gpustack": {
|
||||
"keep_alive_time": {
|
||||
@@ -4743,7 +4751,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",
|
||||
@@ -4901,6 +4910,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 n’existe pas, retour au fournisseur par défaut {{provider}}. Cela peut entraîner des problèmes."
|
||||
},
|
||||
|
||||
@@ -1113,6 +1113,7 @@
|
||||
"delete_confirm": "削除してもよろしいですか?",
|
||||
"delete_failed": "削除に失敗しました",
|
||||
"delete_success": "削除に成功しました",
|
||||
"deleting": "[to be translated]:Deleting...",
|
||||
"description": "説明",
|
||||
"detail": "詳細",
|
||||
"disabled": "無効",
|
||||
@@ -1157,10 +1158,12 @@
|
||||
"prompt": "プロンプト",
|
||||
"provider": "プロバイダー",
|
||||
"reasoning_content": "深く考察済み",
|
||||
"redownload": "[to be translated]:Redownload",
|
||||
"refresh": "更新",
|
||||
"regenerate": "再生成",
|
||||
"rename": "名前を変更",
|
||||
"reset": "リセット",
|
||||
"retry": "[to be translated]:Retry",
|
||||
"save": "保存",
|
||||
"saved": "保存されました",
|
||||
"search": "検索",
|
||||
@@ -1168,6 +1171,7 @@
|
||||
"selected": "選択済み",
|
||||
"selectedItems": "{{count}}件の項目を選択しました",
|
||||
"selectedMessages": "{{count}}件のメッセージを選択しました",
|
||||
"send": "[to be translated]:Send",
|
||||
"settings": "設定",
|
||||
"sort": {
|
||||
"pinyin": {
|
||||
@@ -1227,6 +1231,9 @@
|
||||
},
|
||||
"content": "内容",
|
||||
"data": "データ",
|
||||
"delete": {
|
||||
"failed": "[to be translated]:Failed to delete."
|
||||
},
|
||||
"detail": "エラーの詳細",
|
||||
"details": "詳細",
|
||||
"errors": "エラー",
|
||||
@@ -1334,7 +1341,8 @@
|
||||
"size": "サイズ",
|
||||
"text": "テキスト",
|
||||
"title": "ファイル",
|
||||
"type": "タイプ"
|
||||
"type": "タイプ",
|
||||
"video": "[to be translated]:Video"
|
||||
},
|
||||
"gpustack": {
|
||||
"keep_alive_time": {
|
||||
@@ -4743,7 +4751,8 @@
|
||||
"paintings": "ペインティング",
|
||||
"settings": "設定",
|
||||
"store": "アシスタントライブラリ",
|
||||
"translate": "翻訳"
|
||||
"translate": "翻訳",
|
||||
"video": "[to be translated]:Video"
|
||||
},
|
||||
"trace": {
|
||||
"backList": "リストに戻る",
|
||||
@@ -4901,6 +4910,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}} にロールバックされました。これにより問題が発生する可能性があります。"
|
||||
},
|
||||
|
||||
@@ -1113,6 +1113,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",
|
||||
@@ -1157,10 +1158,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",
|
||||
@@ -1168,6 +1171,7 @@
|
||||
"selected": "Selecionado",
|
||||
"selectedItems": "{{count}} itens selecionados",
|
||||
"selectedMessages": "{{count}} mensagens selecionadas",
|
||||
"send": "[to be translated]:Send",
|
||||
"settings": "Configurações",
|
||||
"sort": {
|
||||
"pinyin": {
|
||||
@@ -1227,6 +1231,9 @@
|
||||
},
|
||||
"content": "conteúdo",
|
||||
"data": "dados",
|
||||
"delete": {
|
||||
"failed": "[to be translated]:Failed to delete."
|
||||
},
|
||||
"detail": "Detalhes do erro",
|
||||
"details": "Detalhes",
|
||||
"errors": "erro",
|
||||
@@ -1334,7 +1341,8 @@
|
||||
"size": "Tamanho",
|
||||
"text": "Texto",
|
||||
"title": "Arquivo",
|
||||
"type": "Tipo"
|
||||
"type": "Tipo",
|
||||
"video": "[to be translated]:Video"
|
||||
},
|
||||
"gpustack": {
|
||||
"keep_alive_time": {
|
||||
@@ -4743,7 +4751,8 @@
|
||||
"paintings": "Pinturas",
|
||||
"settings": "Configurações",
|
||||
"store": "Biblioteca de assistentes",
|
||||
"translate": "Traduzir"
|
||||
"translate": "Traduzir",
|
||||
"video": "[to be translated]:Video"
|
||||
},
|
||||
"trace": {
|
||||
"backList": "Voltar à lista",
|
||||
@@ -4901,6 +4910,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."
|
||||
},
|
||||
|
||||
@@ -1113,6 +1113,7 @@
|
||||
"delete_confirm": "Вы уверены, что хотите удалить?",
|
||||
"delete_failed": "Не удалось удалить",
|
||||
"delete_success": "Удаление выполнено успешно",
|
||||
"deleting": "[to be translated]:Deleting...",
|
||||
"description": "Описание",
|
||||
"detail": "Подробности",
|
||||
"disabled": "Отключено",
|
||||
@@ -1157,10 +1158,12 @@
|
||||
"prompt": "Промпт",
|
||||
"provider": "Провайдер",
|
||||
"reasoning_content": "Глубокий анализ",
|
||||
"redownload": "[to be translated]:Redownload",
|
||||
"refresh": "Обновить",
|
||||
"regenerate": "Пересоздать",
|
||||
"rename": "Переименовать",
|
||||
"reset": "Сбросить",
|
||||
"retry": "[to be translated]:Retry",
|
||||
"save": "Сохранить",
|
||||
"saved": "Сохранено",
|
||||
"search": "Поиск",
|
||||
@@ -1168,6 +1171,7 @@
|
||||
"selected": "Выбрано",
|
||||
"selectedItems": "Выбрано {{count}} элементов",
|
||||
"selectedMessages": "Выбрано {{count}} сообщений",
|
||||
"send": "[to be translated]:Send",
|
||||
"settings": "Настройки",
|
||||
"sort": {
|
||||
"pinyin": {
|
||||
@@ -1227,6 +1231,9 @@
|
||||
},
|
||||
"content": "Содержание",
|
||||
"data": "данные",
|
||||
"delete": {
|
||||
"failed": "[to be translated]:Failed to delete."
|
||||
},
|
||||
"detail": "Детали ошибки",
|
||||
"details": "Подробности",
|
||||
"errors": "ошибка",
|
||||
@@ -1334,7 +1341,8 @@
|
||||
"size": "Размер",
|
||||
"text": "Текст",
|
||||
"title": "Файлы",
|
||||
"type": "Тип"
|
||||
"type": "Тип",
|
||||
"video": "[to be translated]:Video"
|
||||
},
|
||||
"gpustack": {
|
||||
"keep_alive_time": {
|
||||
@@ -4743,7 +4751,8 @@
|
||||
"paintings": "Рисунки",
|
||||
"settings": "Настройки",
|
||||
"store": "Библиотека помощников",
|
||||
"translate": "Перевод"
|
||||
"translate": "Перевод",
|
||||
"video": "[to be translated]:Video"
|
||||
},
|
||||
"trace": {
|
||||
"backList": "Вернуться к списку",
|
||||
@@ -4901,6 +4910,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}}. Это может привести к проблемам."
|
||||
},
|
||||
|
||||
@@ -22,7 +22,8 @@ import {
|
||||
File as FileIcon,
|
||||
FileImage,
|
||||
FileText,
|
||||
FileType as FileTypeIcon
|
||||
FileType as FileTypeIcon,
|
||||
FileVideo
|
||||
} from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
@@ -146,6 +147,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} /> }
|
||||
]
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import App from '@renderer/components/MinApp/MinApp'
|
||||
import { useMinapps } from '@renderer/hooks/useMinapps'
|
||||
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 type { FC } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -62,6 +62,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
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
MessageSquareQuote,
|
||||
NotepadText,
|
||||
Palette,
|
||||
Sparkle
|
||||
Sparkle,
|
||||
Video
|
||||
} from 'lucide-react'
|
||||
import type { FC, ReactNode } from 'react'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
@@ -113,7 +114,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} />
|
||||
}) satisfies Record<SidebarIcon, ReactNode>,
|
||||
[]
|
||||
)
|
||||
|
||||
35
src/renderer/src/pages/video/VideoList.tsx
Normal file
35
src/renderer/src/pages/video/VideoList.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { 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>
|
||||
)
|
||||
}
|
||||
158
src/renderer/src/pages/video/VideoListItem.tsx
Normal file
158
src/renderer/src/pages/video/VideoListItem.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { cn, Progress, Spinner } from '@heroui/react'
|
||||
import { DeleteIcon } from '@renderer/components/Icons'
|
||||
import type { Video } from '@renderer/types'
|
||||
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>
|
||||
)
|
||||
}
|
||||
166
src/renderer/src/pages/video/VideoPage.tsx
Normal file
166
src/renderer/src/pages/video/VideoPage.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
// 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 type { CreateVideoParams } from '@renderer/types'
|
||||
import { SystemProviderIds } from '@renderer/types'
|
||||
import { getErrorMessage } from '@renderer/utils'
|
||||
import { deepUpdate } from '@renderer/utils/deepUpdate'
|
||||
import { isVideoModel } from '@renderer/utils/model/video'
|
||||
import type { 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 flex-1 overflow-hidden">
|
||||
{/* 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}
|
||||
setActiveVideoId={setActiveVideoId}
|
||||
video={activeVideo}
|
||||
/>
|
||||
<Divider orientation="vertical" />
|
||||
{/* Video list */}
|
||||
<VideoList
|
||||
videos={videos}
|
||||
activeVideoId={activeVideoId}
|
||||
setActiveVideoId={setActiveVideoId}
|
||||
onDelete={handleDeleteVideo}
|
||||
onGetThumbnail={handleGetThumbnail}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
303
src/renderer/src/pages/video/VideoPanel.tsx
Normal file
303
src/renderer/src/pages/video/VideoPanel.tsx
Normal file
@@ -0,0 +1,303 @@
|
||||
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 type { CreateVideoParams, Provider, Video, VideoFileMetadata } from '@renderer/types'
|
||||
import { FileTypes } from '@renderer/types'
|
||||
import { getErrorMessage } from '@renderer/utils'
|
||||
import { MB } from '@shared/config/constant'
|
||||
import type { 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, useState } 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
|
||||
setActiveVideoId: (id: string) => void
|
||||
}
|
||||
|
||||
const logger = loggerService.withContext('VideoPanel')
|
||||
|
||||
export const VideoPanel = ({ provider, video, params, updateParams, setActiveVideoId }: VideoPanelProps) => {
|
||||
const { t } = useTranslation()
|
||||
const addOpenAIVideo = useAddOpenAIVideo(provider.id)
|
||||
const { setVideo } = useProviderVideos(provider.id)
|
||||
|
||||
const { pendingMap, setPending: setPendingById } = usePending()
|
||||
const [isProcessing, setIsProcessing] = useState<boolean | undefined>()
|
||||
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) || isProcessing
|
||||
const setPending = useCallback(
|
||||
(value: boolean) => {
|
||||
if (video) {
|
||||
setPendingById(video.id, value ? value : undefined)
|
||||
} else {
|
||||
setIsProcessing(value)
|
||||
}
|
||||
},
|
||||
[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}.`)
|
||||
}
|
||||
setActiveVideoId(result.video.id)
|
||||
} 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 overflow-hidden 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>
|
||||
)
|
||||
}
|
||||
233
src/renderer/src/pages/video/VideoViewer.tsx
Normal file
233
src/renderer/src/pages/video/VideoViewer.tsx
Normal 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 type { Video, VideoDownloaded, VideoFailed } from '@renderer/types'
|
||||
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>
|
||||
)
|
||||
}
|
||||
44
src/renderer/src/pages/video/settings/ModelSetting.tsx
Normal file
44
src/renderer/src/pages/video/settings/ModelSetting.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Select, SelectItem } from '@heroui/react'
|
||||
import { videoModelsMap } from '@renderer/config/models/video'
|
||||
import type { 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { VideoSeconds, VideoSize } from '@cherrystudio/openai/resources'
|
||||
import { Select, SelectItem } from '@heroui/react'
|
||||
import type { OpenAICreateVideoParams } from '@renderer/types'
|
||||
import type { 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>
|
||||
)
|
||||
}
|
||||
60
src/renderer/src/pages/video/settings/ProviderSetting.tsx
Normal file
60
src/renderer/src/pages/video/settings/ProviderSetting.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Select, SelectItem } from '@heroui/react'
|
||||
import { ProviderAvatar } from '@renderer/components/ProviderAvatar'
|
||||
import { useProviders } from '@renderer/hooks/useProvider'
|
||||
import type { Provider, SystemProviderId } from '@renderer/types'
|
||||
import { getFancyProviderName } from '@renderer/utils'
|
||||
import type { 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>
|
||||
)
|
||||
}
|
||||
15
src/renderer/src/pages/video/settings/shared.tsx
Normal file
15
src/renderer/src/pages/video/settings/shared.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Divider } from '@heroui/react'
|
||||
import type { 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" />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -10,8 +10,22 @@ import { buildStreamTextParams } from '@renderer/aiCore/prepareParams'
|
||||
import { isDedicatedImageGenerationModel, isEmbeddingModel } from '@renderer/config/models'
|
||||
import i18n from '@renderer/i18n'
|
||||
import store from '@renderer/store'
|
||||
import type { FetchChatCompletionParams } from '@renderer/types'
|
||||
import type { Assistant, MCPServer, MCPTool, Model, Provider } from '@renderer/types'
|
||||
import type {
|
||||
Assistant,
|
||||
CreateVideoParams,
|
||||
CreateVideoResult,
|
||||
DeleteVideoParams,
|
||||
DeleteVideoResult,
|
||||
FetchChatCompletionParams,
|
||||
MCPServer,
|
||||
MCPTool,
|
||||
Model,
|
||||
Provider,
|
||||
RetrieveVideoContentParams,
|
||||
RetrieveVideoContentResult,
|
||||
RetrieveVideoParams,
|
||||
RetrieveVideoResult
|
||||
} from '@renderer/types'
|
||||
import type { StreamTextParams } from '@renderer/types/aiCoreTypes'
|
||||
import { type Chunk, ChunkType } from '@renderer/types/chunk'
|
||||
import type { Message } from '@renderer/types/newMessage'
|
||||
@@ -399,6 +413,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
|
||||
|
||||
@@ -35,6 +35,7 @@ import shortcuts from './shortcuts'
|
||||
import tabs from './tabs'
|
||||
import toolPermissions from './toolPermissions'
|
||||
import translate from './translate'
|
||||
import video from './video'
|
||||
import websearch from './websearch'
|
||||
|
||||
const logger = loggerService.withContext('Store')
|
||||
@@ -64,7 +65,8 @@ const rootReducer = combineReducers({
|
||||
translate,
|
||||
ocr,
|
||||
note,
|
||||
toolPermissions
|
||||
toolPermissions,
|
||||
video
|
||||
})
|
||||
|
||||
const persistedReducer = persistReducer(
|
||||
|
||||
@@ -2824,6 +2824,20 @@ const migrateConfig = {
|
||||
logger.error('migrate 174 error', error as Error)
|
||||
return state
|
||||
}
|
||||
},
|
||||
'163': (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 161 error', error as Error)
|
||||
return state
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -192,6 +192,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]
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
86
src/renderer/src/store/video.ts
Normal file
86
src/renderer/src/store/video.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { loggerService } from '@logger'
|
||||
import type { PayloadAction } from '@reduxjs/toolkit'
|
||||
import { createSlice } from '@reduxjs/toolkit'
|
||||
import type { Video } from '@renderer/types'
|
||||
|
||||
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
|
||||
@@ -127,6 +127,10 @@ export type ImageFileMetadata = FileMetadata & {
|
||||
type: FileTypes.IMAGE
|
||||
}
|
||||
|
||||
export type VideoFileMetadata = FileMetadata & {
|
||||
type: FileTypes.VIDEO
|
||||
}
|
||||
|
||||
export type PdfFileMetadata = FileMetadata & {
|
||||
ext: '.pdf'
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ export * from './notification'
|
||||
export * from './ocr'
|
||||
export * from './plugin'
|
||||
export * from './provider'
|
||||
export * from './video'
|
||||
|
||||
export type Assistant = {
|
||||
id: string
|
||||
@@ -549,6 +550,7 @@ export const isAutoDetectionMethod = (method: string): method is AutoDetectionMe
|
||||
// | 'files'
|
||||
// | 'code_tools'
|
||||
// | 'notes'
|
||||
// | 'video'
|
||||
|
||||
export type ExternalToolResult = {
|
||||
mcpTools?: MCPTool[]
|
||||
|
||||
194
src/renderer/src/types/video.ts
Normal file
194
src/renderer/src/types/video.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import type OpenAI from '@cherrystudio/openai'
|
||||
|
||||
import type { 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
|
||||
35
src/renderer/src/utils/deepUpdate.ts
Normal file
35
src/renderer/src/utils/deepUpdate.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { 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
|
||||
}
|
||||
7
src/renderer/src/utils/model/video.ts
Normal file
7
src/renderer/src/utils/model/video.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { 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)
|
||||
}
|
||||
Reference in New Issue
Block a user