diff --git a/web/app/components/base/features/new-feature-panel/text-to-speech.tsx b/web/app/components/base/features/new-feature-panel/text-to-speech/index.tsx
similarity index 87%
rename from web/app/components/base/features/new-feature-panel/text-to-speech.tsx
rename to web/app/components/base/features/new-feature-panel/text-to-speech/index.tsx
index 5924bb769e..9f101a3ab0 100644
--- a/web/app/components/base/features/new-feature-panel/text-to-speech.tsx
+++ b/web/app/components/base/features/new-feature-panel/text-to-speech/index.tsx
@@ -5,6 +5,7 @@ import { RiEqualizer2Line } from '@remixicon/react'
import { TextToAudio } from '@/app/components/base/icons/src/vender/features'
import FeatureCard from '@/app/components/base/features/new-feature-panel/feature-card'
import Button from '@/app/components/base/button'
+import VoiceSettings from '@/app/components/base/features/new-feature-panel/text-to-speech/voice-settings'
import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks'
import type { OnFeaturesChange } from '@/app/components/base/features/types'
import { FeatureEnum } from '@/app/components/base/features/types'
@@ -21,6 +22,7 @@ const TextToSpeech = ({
const { t } = useTranslation()
const textToSpeech = useFeatures(s => s.features.text2speech) // .language .voice .autoPlay
const languageInfo = languages.find(i => i.value === textToSpeech?.language)
+ const [modalOpen, setModalOpen] = useState(false)
const [isHovering, setIsHovering] = useState(false)
const features = useFeatures(s => s.features)
const featuresStore = useFeaturesStore()
@@ -61,7 +63,7 @@ const TextToSpeech = ({
)}
{!!features.text2speech?.enabled && (
<>
- {!isHovering && (
+ {!isHovering && !modalOpen && (
{t('appDebug.voice.voiceSettings.language')}
@@ -79,11 +81,13 @@ const TextToSpeech = ({
)}
- {isHovering && (
-
+ {(isHovering || modalOpen) && (
+
+
+
)}
>
)}
diff --git a/web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx b/web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx
new file mode 100644
index 0000000000..bd279b5cf3
--- /dev/null
+++ b/web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx
@@ -0,0 +1,242 @@
+'use client'
+import useSWR from 'swr'
+import produce from 'immer'
+import React, { Fragment } from 'react'
+import { usePathname } from 'next/navigation'
+import { useTranslation } from 'react-i18next'
+import { RiCloseLine } from '@remixicon/react'
+import { Listbox, Transition } from '@headlessui/react'
+import { CheckIcon, ChevronDownIcon } from '@heroicons/react/20/solid'
+import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks'
+import type { Item } from '@/app/components/base/select'
+import { fetchAppVoices } from '@/service/apps'
+import Tooltip from '@/app/components/base/tooltip'
+import Switch from '@/app/components/base/switch'
+import AudioBtn from '@/app/components/base/audio-btn'
+import { languages } from '@/i18n/language'
+import { TtsAutoPlay } from '@/types/app'
+import type { OnFeaturesChange } from '@/app/components/base/features/types'
+import classNames from '@/utils/classnames'
+
+type VoiceParamConfigProps = {
+ onClose: () => void
+ onChange?: OnFeaturesChange
+}
+const VoiceParamConfig = ({
+ onClose,
+ onChange,
+}: VoiceParamConfigProps) => {
+ const { t } = useTranslation()
+ const pathname = usePathname()
+ const matched = pathname.match(/\/app\/([^/]+)/)
+ const appId = (matched?.length && matched[1]) ? matched[1] : ''
+ const text2speech = useFeatures(state => state.features.text2speech)
+ const featuresStore = useFeaturesStore()
+
+ let languageItem = languages.find(item => item.value === text2speech?.language)
+ if (languages && !languageItem)
+ languageItem = languages[0]
+ const localLanguagePlaceholder = languageItem?.name || t('common.placeholder.select')
+
+ const language = languageItem?.value
+ const voiceItems = useSWR({ appId, language }, fetchAppVoices).data
+ let voiceItem = voiceItems?.find(item => item.value === text2speech?.voice)
+ if (voiceItems && !voiceItem)
+ voiceItem = voiceItems[0]
+ const localVoicePlaceholder = voiceItem?.name || t('common.placeholder.select')
+
+ const handleChange = (value: Record) => {
+ const {
+ features,
+ setFeatures,
+ } = featuresStore!.getState()
+
+ const newFeatures = produce(features, (draft) => {
+ draft.text2speech = {
+ ...draft.text2speech,
+ ...value,
+ }
+ })
+
+ setFeatures(newFeatures)
+ if (onChange)
+ onChange(newFeatures)
+ }
+
+ return (
+ <>
+
+
{t('appDebug.voice.voiceSettings.title')}
+
+
+
+
+ {t('appDebug.voice.voiceSettings.language')}
+
+ {t('appDebug.voice.voiceSettings.resolutionTooltip').split('\n').map(item => (
+ {item}
+
+ ))}
+
+ }
+ />
+
+ {
+ handleChange({
+ language: String(value.value),
+ })
+ }}
+ >
+
+
+
+ {languageItem?.name ? t(`common.voice.language.${languageItem?.value.replace('-', '')}`) : localLanguagePlaceholder}
+
+
+
+
+
+
+
+
+ {languages.map((item: Item) => (
+
+ `relative cursor-pointer select-none py-2 pl-3 pr-9 rounded-lg hover:bg-gray-100 text-gray-700 ${active ? 'bg-gray-100' : ''
+ }`
+ }
+ value={item}
+ disabled={false}
+ >
+ {({ /* active, */ selected }) => (
+ <>
+ {t(`common.voice.language.${(item.value).toString().replace('-', '')}`)}
+ {(selected || item.value === text2speech?.language) && (
+
+
+
+ )}
+ >
+ )}
+
+ ))}
+
+
+
+
+
+
+
+ {t('appDebug.voice.voiceSettings.voice')}
+
+
+
{
+ handleChange({
+ voice: String(value.value),
+ })
+ }}
+ >
+
+
+ {voiceItem?.name ?? localVoicePlaceholder}
+
+
+
+
+
+
+
+ {voiceItems?.map((item: Item) => (
+
+ `relative cursor-pointer select-none py-2 pl-3 pr-9 rounded-lg hover:bg-gray-100 text-gray-700 ${active ? 'bg-gray-100' : ''
+ }`
+ }
+ value={item}
+ disabled={false}
+ >
+ {({ /* active, */ selected }) => (
+ <>
+ {item.name}
+ {(selected || item.value === text2speech?.voice) && (
+
+
+
+ )}
+ >
+ )}
+
+ ))}
+
+
+
+
+ {languageItem?.example && (
+
+ )}
+
+
+
+
+ {t('appDebug.voice.voiceSettings.autoPlay')}
+
+
{
+ handleChange({
+ autoPlay: value ? TtsAutoPlay.enabled : TtsAutoPlay.disabled,
+ })
+ }}
+ />
+
+ >
+ )
+}
+
+export default React.memo(VoiceParamConfig)
diff --git a/web/app/components/base/features/new-feature-panel/text-to-speech/voice-settings.tsx b/web/app/components/base/features/new-feature-panel/text-to-speech/voice-settings.tsx
new file mode 100644
index 0000000000..076f06e6e7
--- /dev/null
+++ b/web/app/components/base/features/new-feature-panel/text-to-speech/voice-settings.tsx
@@ -0,0 +1,47 @@
+'use client'
+import { memo } from 'react'
+import {
+ PortalToFollowElem,
+ PortalToFollowElemContent,
+ PortalToFollowElemTrigger,
+} from '@/app/components/base/portal-to-follow-elem'
+import ParamConfigContent from '@/app/components/base/features/new-feature-panel/text-to-speech/param-config-content'
+import type { OnFeaturesChange } from '@/app/components/base/features/types'
+
+type VoiceSettingsProps = {
+ open: boolean
+ onOpen: (state: any) => void
+ onChange?: OnFeaturesChange
+ disabled?: boolean
+ children?: React.ReactNode
+ placementLeft?: boolean
+}
+const VoiceSettings = ({
+ open,
+ onOpen,
+ onChange,
+ disabled,
+ children,
+ placementLeft = true,
+}: VoiceSettingsProps) => {
+ return (
+
+ !disabled && onOpen((open: boolean) => !open)}>
+ {children}
+
+
+
+
onOpen(false)} onChange={onChange} />
+
+
+
+ )
+}
+export default memo(VoiceSettings)
diff --git a/web/app/components/workflow/features.tsx b/web/app/components/workflow/features.tsx
index 80421dfba1..4ce6c045b0 100644
--- a/web/app/components/workflow/features.tsx
+++ b/web/app/components/workflow/features.tsx
@@ -28,39 +28,6 @@ const Features = () => {
onChange={handleFeaturesChange}
onClose={() => setShowFeaturesPanel(false)}
/>
- //
- //
- // {t('workflow.common.features')}
- //
- // {
- // isChatMode && (
- // <>
- //
- //
- // >
- // )
- // }
- //
setShowFeaturesPanel(false)}
- // >
- //
- //
- //
- //
- //
- // {},
- // }}
- // />
- //
- //
)
}