merge main

This commit is contained in:
Joel
2024-10-25 11:26:41 +08:00
parent bdb990eb90
commit 3e011109ad
124 changed files with 9664 additions and 0 deletions

View File

@ -0,0 +1,40 @@
.item {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 58px;
width: 98px;
border-radius: 8px;
border: 1px solid #EAECF0;
box-shadow: 0px 1px 2px rgba(16, 24, 40, 0.05);
background-color: #fff;
cursor: pointer;
}
.item:not(.selected):hover {
border-color: #B2CCFF;
background-color: #F5F8FF;
box-shadow: 0px 4px 8px -2px rgba(16, 24, 40, 0.1), 0px 2px 4px -2px rgba(16, 24, 40, 0.06);
}
.item.selected {
color: #155EEF;
border-color: #528BFF;
background-color: #F5F8FF;
box-shadow: 0px 1px 3px rgba(16, 24, 40, 0.1), 0px 1px 2px rgba(16, 24, 40, 0.06);
}
.text {
font-size: 13px;
color: #667085;
font-weight: 500;
}
.item.selected .text {
color: #155EEF;
}
.item:not(.selected):hover {
color: #344054;
}

View File

@ -0,0 +1,40 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import s from './style.module.css'
import cn from '@/utils/classnames'
type OPTION = {
label: string
value: any
}
type Props = {
className?: string
options: OPTION[]
value: any
onChange: (value: any) => void
}
const RadioGroup: FC<Props> = ({
className = '',
options,
value,
onChange,
}) => {
return (
<div className={cn(className, 'flex')}>
{options.map(item => (
<div
key={item.value}
className={cn(s.item, item.value === value && s.checked)}
onClick={() => onChange(item.value)}
>
<div className={s.radio}></div>
<div className='text-[13px] font-medium text-gray-900'>{item.label}</div>
</div>
))}
</div>
)
}
export default React.memo(RadioGroup)

View File

@ -0,0 +1,24 @@
.item {
@apply grow flex items-center h-8 px-2.5 rounded-lg bg-gray-25 border border-gray-100 cursor-pointer space-x-2;
}
.item:hover {
background-color: #ffffff;
border-color: #B2CCFF;
box-shadow: 0px 12px 16px -4px rgba(16, 24, 40, 0.08), 0px 4px 6px -2px rgba(16, 24, 40, 0.03);
}
.item.checked {
background-color: #ffffff;
border-color: #528BFF;
box-shadow: 0px 1px 2px 0px rgba(16, 24, 40, 0.06), 0px 1px 3px 0px rgba(16, 24, 40, 0.10);
}
.radio {
@apply w-4 h-4 border-[2px] border-gray-200 rounded-full;
}
.item.checked .radio {
border-width: 5px;
border-color: #155eef;
}

View File

@ -0,0 +1,220 @@
'use client'
import useSWR from 'swr'
import type { FC } from 'react'
import { useContext } from 'use-context-selector'
import React, { Fragment } from 'react'
import { usePathname } from 'next/navigation'
import { useTranslation } from 'react-i18next'
import { Listbox, Transition } from '@headlessui/react'
import { CheckIcon, ChevronDownIcon } from '@heroicons/react/20/solid'
import classNames from '@/utils/classnames'
import RadioGroup from '@/app/components/app/configuration/config-vision/radio-group'
import type { Item } from '@/app/components/base/select'
import ConfigContext from '@/context/debug-configuration'
import { fetchAppVoices } from '@/service/apps'
import Tooltip from '@/app/components/base/tooltip'
import { languages } from '@/i18n/language'
import { TtsAutoPlay } from '@/types/app'
const VoiceParamConfig: FC = () => {
const { t } = useTranslation()
const pathname = usePathname()
const matched = pathname.match(/\/app\/([^/]+)/)
const appId = (matched?.length && matched[1]) ? matched[1] : ''
const {
textToSpeechConfig,
setTextToSpeechConfig,
} = useContext(ConfigContext)
let languageItem = languages.find(item => item.value === textToSpeechConfig.language)
const localLanguagePlaceholder = languageItem?.name || t('common.placeholder.select')
if (languages && !languageItem && languages.length > 0)
languageItem = languages[0]
const language = languageItem?.value
const voiceItems = useSWR({ appId, language }, fetchAppVoices).data
let voiceItem = voiceItems?.find(item => item.value === textToSpeechConfig.voice)
if (voiceItems && !voiceItem && voiceItems.length > 0)
voiceItem = voiceItems[0]
const localVoicePlaceholder = voiceItem?.name || t('common.placeholder.select')
return (
<div>
<div>
<div className='leading-6 text-base font-semibold text-gray-800'>{t('appDebug.voice.voiceSettings.title')}</div>
<div className='pt-3 space-y-6'>
<div>
<div className='mb-2 flex items-center space-x-1'>
<div
className='leading-[18px] text-[13px] font-semibold text-gray-800'>{t('appDebug.voice.voiceSettings.language')}</div>
<Tooltip
popupContent={
<div className='w-[180px]'>
{t('appDebug.voice.voiceSettings.resolutionTooltip').split('\n').map(item => (
<div key={item}>{item}</div>
))}
</div>
}
/>
</div>
<Listbox
value={languageItem}
onChange={(value: Item) => {
setTextToSpeechConfig({
...textToSpeechConfig,
language: String(value.value),
})
}}
>
<div className={'relative h-9'}>
<Listbox.Button
className={'w-full h-full rounded-lg border-0 bg-gray-100 py-1.5 pl-3 pr-10 sm:text-sm sm:leading-6 focus-visible:outline-none focus-visible:bg-gray-200 group-hover:bg-gray-200 cursor-pointer'}>
<span className={classNames('block truncate text-left', !languageItem?.name && 'text-gray-400')}>
{languageItem?.name ? t(`common.voice.language.${languageItem?.value.replace('-', '')}`) : localLanguagePlaceholder}
</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronDownIcon
className="h-5 w-5 text-gray-400"
aria-hidden="true"
/>
</span>
</Listbox.Button>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options
className="absolute z-10 mt-1 px-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg border-gray-200 border-[0.5px] focus:outline-none sm:text-sm">
{languages.map((item: Item) => (
<Listbox.Option
key={item.value}
className={({ active }) =>
`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 }) => (
<>
<span
className={classNames('block', selected && 'font-normal')}>{t(`common.voice.language.${(item.value).toString().replace('-', '')}`)}</span>
{(selected || item.value === textToSpeechConfig.language) && (
<span
className={classNames(
'absolute inset-y-0 right-0 flex items-center pr-4 text-gray-700',
)}
>
<CheckIcon className="h-5 w-5" aria-hidden="true" />
</span>
)}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</Listbox>
</div>
<div>
<div
className='mb-2 leading-[18px] text-[13px] font-semibold text-gray-800'>{t('appDebug.voice.voiceSettings.voice')}</div>
<Listbox
value={voiceItem ?? {}}
disabled={!languageItem}
onChange={(value: Item) => {
if (!value.value)
return
setTextToSpeechConfig({
...textToSpeechConfig,
voice: String(value.value),
})
}}
>
<div className={'relative h-9'}>
<Listbox.Button
className={'w-full h-full rounded-lg border-0 bg-gray-100 py-1.5 pl-3 pr-10 sm:text-sm sm:leading-6 focus-visible:outline-none focus-visible:bg-gray-200 group-hover:bg-gray-200 cursor-pointer'}>
<span
className={classNames('block truncate text-left', !voiceItem?.name && 'text-gray-400')}>{voiceItem?.name ?? localVoicePlaceholder}</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronDownIcon
className="h-5 w-5 text-gray-400"
aria-hidden="true"
/>
</span>
</Listbox.Button>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options
className="absolute z-10 mt-1 px-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg border-gray-200 border-[0.5px] focus:outline-none sm:text-sm">
{voiceItems?.map((item: Item) => (
<Listbox.Option
key={item.value}
className={({ active }) =>
`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 }) => (
<>
<span className={classNames('block', selected && 'font-normal')}>{item.name}</span>
{(selected || item.value === textToSpeechConfig.voice) && (
<span
className={classNames(
'absolute inset-y-0 right-0 flex items-center pr-4 text-gray-700',
)}
>
<CheckIcon className="h-5 w-5" aria-hidden="true" />
</span>
)}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</Listbox>
</div>
<div>
<div
className='mb-2 leading-[18px] text-[13px] font-semibold text-gray-800'>{t('appDebug.voice.voiceSettings.autoPlay')}</div>
<RadioGroup
className='space-x-3'
options={[
{
label: t('appDebug.voice.voiceSettings.autoPlayEnabled'),
value: TtsAutoPlay.enabled,
},
{
label: t('appDebug.voice.voiceSettings.autoPlayDisabled'),
value: TtsAutoPlay.disabled,
},
]}
value={textToSpeechConfig.autoPlay ? textToSpeechConfig.autoPlay : TtsAutoPlay.disabled}
onChange={(value: TtsAutoPlay) => {
setTextToSpeechConfig({
...textToSpeechConfig,
autoPlay: value,
})
}}
/>
</div>
</div>
</div>
</div>
)
}
export default React.memo(VoiceParamConfig)

View File

@ -0,0 +1,41 @@
'use client'
import type { FC } from 'react'
import { memo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import VoiceParamConfig from './param-config-content'
import cn from '@/utils/classnames'
import { Settings01 } from '@/app/components/base/icons/src/vender/line/general'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
const ParamsConfig: FC = () => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='bottom-end'
offset={{
mainAxis: 4,
}}
>
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
<div className={cn('flex items-center rounded-md h-7 px-3 space-x-1 text-gray-700 cursor-pointer hover:bg-gray-200', open && 'bg-gray-200')}>
<Settings01 className='w-3.5 h-3.5 ' />
<div className='ml-1 leading-[18px] text-xs font-medium '>{t('appDebug.voice.settings')}</div>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent style={{ zIndex: 50 }}>
<div className='w-80 sm:w-[412px] p-4 bg-white rounded-lg border-[0.5px] border-gray-200 shadow-lg space-y-3'>
<VoiceParamConfig />
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default memo(ParamsConfig)

View File

@ -0,0 +1,40 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { PlusIcon } from '@heroicons/react/24/solid'
export type IAddFeatureBtnProps = {
toBottomHeight: number
onClick: () => void
}
const ITEM_HEIGHT = 48
const AddFeatureBtn: FC<IAddFeatureBtnProps> = ({
toBottomHeight,
onClick,
}) => {
const { t } = useTranslation()
return (
<div
className='absolute z-[9] left-0 right-0 flex justify-center pb-4'
style={{
top: toBottomHeight - ITEM_HEIGHT,
background: 'linear-gradient(180deg, rgba(255, 255, 255, 0.00) 0%, #FFF 100%)',
}}
>
<div
className='flex items-center h-8 space-x-2 px-3
border border-primary-100 rounded-lg bg-primary-25 hover:bg-primary-50 cursor-pointer
text-xs font-semibold text-primary-600 uppercase
'
onClick={onClick}
>
<PlusIcon className='w-4 h-4 font-semibold' />
<div>{t('appDebug.operation.addFeature')}</div>
</div>
</div>
)
}
export default React.memo(AddFeatureBtn)

View File

@ -0,0 +1,52 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import s from './style.module.css'
import cn from '@/utils/classnames'
import Switch from '@/app/components/base/switch'
export type IFeatureItemProps = {
icon: React.ReactNode
previewImgClassName?: string
title: string
description: string
value: boolean
onChange: (value: boolean) => void
}
const FeatureItem: FC<IFeatureItemProps> = ({
icon,
previewImgClassName,
title,
description,
value,
onChange,
}) => {
return (
<div className={cn(s.wrap, 'relative flex justify-between p-3 rounded-xl border border-transparent bg-gray-50 hover:border-gray-200 cursor-pointer')}>
<div className='flex space-x-3 mr-2'>
{/* icon */}
<div
className='shrink-0 flex items-center justify-center w-8 h-8 rounded-lg border border-gray-200 bg-white'
style={{
boxShadow: '0px 1px 2px rgba(16, 24, 40, 0.05)',
}}
>
{icon}
</div>
<div>
<div className='text-sm font-semibold text-gray-800'>{title}</div>
<div className='text-xs font-normal text-gray-500'>{description}</div>
</div>
</div>
<Switch onChange={onChange} defaultValue={value} />
{
previewImgClassName && (
<div className={cn(s.preview, s[previewImgClassName])}>
</div>)
}
</div>
)
}
export default React.memo(FeatureItem)

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 84 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 211 KiB

View File

@ -0,0 +1,41 @@
.preview {
display: none;
position: absolute;
top: 0;
left: 100%;
transform: translate(32px, -54px);
width: 280px;
height: 360px;
background: center center no-repeat;
background-size: contain;
border-radius: 8px;
}
.wrap:hover .preview {
display: block;
}
.openingStatementPreview {
background-image: url(./preview-imgs/opening-statement.png);
}
.suggestedQuestionsAfterAnswerPreview {
background-image: url(./preview-imgs/suggested-questions-after-answer.png);
}
.moreLikeThisPreview {
background-image: url(./preview-imgs/more-like-this.png);
}
.speechToTextPreview {
background-image: url(./preview-imgs/speech-to-text.png);
}
.textToSpeechPreview {
@apply shadow-lg rounded-lg;
background-image: url(./preview-imgs/text-to-audio-preview-assistant@2x.png);
}
.citationPreview {
background-image: url(./preview-imgs/citation.png);
}

View File

@ -0,0 +1,172 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import FeatureGroup from '../feature-group'
import MoreLikeThisIcon from '../../../base/icons/more-like-this-icon'
import FeatureItem from './feature-item'
import Modal from '@/app/components/base/modal'
import SuggestedQuestionsAfterAnswerIcon from '@/app/components/app/configuration/base/icons/suggested-questions-after-answer-icon'
import { Microphone01, Speaker } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices'
import { Citations } from '@/app/components/base/icons/src/vender/solid/editor'
import { FileSearch02 } from '@/app/components/base/icons/src/vender/solid/files'
import { MessageFast } from '@/app/components/base/icons/src/vender/solid/communication'
type IConfig = {
openingStatement: boolean
moreLikeThis: boolean
suggestedQuestionsAfterAnswer: boolean
speechToText: boolean
textToSpeech: boolean
citation: boolean
moderation: boolean
annotation: boolean
}
export type IChooseFeatureProps = {
isShow: boolean
onClose: () => void
config: IConfig
isChatApp: boolean
onChange: (key: string, value: boolean) => void
showTextToSpeechItem?: boolean
showSpeechToTextItem?: boolean
}
const OpeningStatementIcon = (
<svg width="15" height="13" viewBox="0 0 15 13" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fillRule="evenodd" clipRule="evenodd" d="M8.33328 0.333252C4.83548 0.333252 1.99995 3.16878 1.99995 6.66659C1.99995 7.37325 2.11594 8.05419 2.33045 8.6906C2.36818 8.80254 2.39039 8.86877 2.40482 8.91762L2.40955 8.93407L2.40705 8.93928C2.38991 8.97462 2.36444 9.02207 2.31681 9.11025L1.21555 11.1486C1.1473 11.2749 1.07608 11.4066 1.02711 11.5212C0.978424 11.6351 0.899569 11.844 0.938369 12.0916C0.98385 12.3819 1.15471 12.6375 1.40556 12.7905C1.61957 12.9211 1.84276 12.9281 1.96659 12.9267C2.09117 12.9252 2.24012 12.9098 2.3829 12.895L5.81954 12.5397C5.87458 12.534 5.90335 12.5311 5.92443 12.5295L5.92715 12.5293L5.93539 12.5322C5.96129 12.5415 5.99642 12.555 6.05705 12.5784C6.76435 12.8509 7.53219 12.9999 8.33328 12.9999C11.8311 12.9999 14.6666 10.1644 14.6666 6.66659C14.6666 3.16878 11.8311 0.333252 8.33328 0.333252ZM5.97966 4.7214C6.73118 4.08722 7.73139 4.27352 8.3312 4.96609C8.931 4.27352 9.9183 4.09389 10.6827 4.7214C11.4472 5.34892 11.5401 6.41591 10.9499 7.16596C10.5843 7.63065 9.66655 8.47935 9.02117 9.05789C8.78411 9.2704 8.66558 9.37666 8.52332 9.41947C8.40129 9.4562 8.2611 9.4562 8.13907 9.41947C7.99682 9.37666 7.87829 9.2704 7.64122 9.05789C6.99584 8.47935 6.07814 7.63065 5.71251 7.16596C5.12234 6.41591 5.22815 5.35559 5.97966 4.7214Z" fill="#DD2590" />
</svg>
)
const ChooseFeature: FC<IChooseFeatureProps> = ({
isShow,
onClose,
isChatApp,
config,
onChange,
showTextToSpeechItem,
showSpeechToTextItem,
}) => {
const { t } = useTranslation()
return (
<Modal
isShow={isShow}
onClose={onClose}
className='w-[400px]'
title={t('appDebug.operation.addFeature')}
closable
overflowVisible
>
<div className='pt-5 pb-10'>
{/* Chat Feature */}
{isChatApp && (
<FeatureGroup
title={t('appDebug.feature.groupChat.title')}
description={t('appDebug.feature.groupChat.description') as string}
>
<>
<FeatureItem
icon={OpeningStatementIcon}
previewImgClassName='openingStatementPreview'
title={t('appDebug.feature.conversationOpener.title')}
description={t('appDebug.feature.conversationOpener.description')}
value={config.openingStatement}
onChange={value => onChange('openingStatement', value)}
/>
<FeatureItem
icon={<SuggestedQuestionsAfterAnswerIcon />}
previewImgClassName='suggestedQuestionsAfterAnswerPreview'
title={t('appDebug.feature.suggestedQuestionsAfterAnswer.title')}
description={t('appDebug.feature.suggestedQuestionsAfterAnswer.description')}
value={config.suggestedQuestionsAfterAnswer}
onChange={value => onChange('suggestedQuestionsAfterAnswer', value)}
/>
{
showTextToSpeechItem && (
<FeatureItem
icon={<Speaker className='w-4 h-4 text-[#7839EE]' />}
previewImgClassName='textToSpeechPreview'
title={t('appDebug.feature.textToSpeech.title')}
description={t('appDebug.feature.textToSpeech.description')}
value={config.textToSpeech}
onChange={value => onChange('textToSpeech', value)}
/>
)
}
{
showSpeechToTextItem && (
<FeatureItem
icon={<Microphone01 className='w-4 h-4 text-[#7839EE]' />}
previewImgClassName='speechToTextPreview'
title={t('appDebug.feature.speechToText.title')}
description={t('appDebug.feature.speechToText.description')}
value={config.speechToText}
onChange={value => onChange('speechToText', value)}
/>
)
}
<FeatureItem
icon={<Citations className='w-4 h-4 text-[#FD853A]' />}
previewImgClassName='citationPreview'
title={t('appDebug.feature.citation.title')}
description={t('appDebug.feature.citation.description')}
value={config.citation}
onChange={value => onChange('citation', value)}
/>
</>
</FeatureGroup>
)}
{/* Text Generation Feature */}
{!isChatApp && (
<FeatureGroup title={t('appDebug.feature.groupExperience.title')}>
<>
<FeatureItem
icon={<MoreLikeThisIcon />}
previewImgClassName='moreLikeThisPreview'
title={t('appDebug.feature.moreLikeThis.title')}
description={t('appDebug.feature.moreLikeThis.description')}
value={config.moreLikeThis}
onChange={value => onChange('moreLikeThis', value)}
/>
{
showTextToSpeechItem && (
<FeatureItem
icon={<Speaker className='w-4 h-4 text-[#7839EE]' />}
previewImgClassName='textToSpeechPreview'
title={t('appDebug.feature.textToSpeech.title')}
description={t('appDebug.feature.textToSpeech.description')}
value={config.textToSpeech}
onChange={value => onChange('textToSpeech', value)}
/>
)
}
</>
</FeatureGroup>
)}
<FeatureGroup title={t('appDebug.feature.toolbox.title')}>
<>
<FeatureItem
icon={<FileSearch02 className='w-4 h-4 text-[#039855]' />}
previewImgClassName=''
title={t('appDebug.feature.moderation.title')}
description={t('appDebug.feature.moderation.description')}
value={config.moderation}
onChange={value => onChange('moderation', value)}
/>
{isChatApp && (
<FeatureItem
icon={<MessageFast className='w-4 h-4 text-[#444CE7]' />}
title={t('appDebug.feature.annotation.title')}
description={t('appDebug.feature.annotation.description')}
value={config.annotation}
onChange={value => onChange('annotation', value)}
/>
)}
</>
</FeatureGroup>
</div>
</Modal>
)
}
export default React.memo(ChooseFeature)

View File

@ -0,0 +1,31 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import GroupName from '@/app/components/app/configuration/base/group-name'
export type IFeatureGroupProps = {
title: string
description?: string
children: React.ReactNode
}
const FeatureGroup: FC<IFeatureGroupProps> = ({
title,
description,
children,
}) => {
return (
<div className='mb-6'>
<div className='mb-2'>
<GroupName name={title} />
{description && (
<div className='text-xs font-normal text-gray-500'>{description}</div>
)}
</div>
<div className='space-y-2'>
{children}
</div>
</div>
)
}
export default React.memo(FeatureGroup)

View File

@ -0,0 +1,25 @@
'use client'
import React, { type FC } from 'react'
import { useTranslation } from 'react-i18next'
import Panel from '@/app/components/app/configuration/base/feature-panel'
import { Citations } from '@/app/components/base/icons/src/vender/solid/editor'
const Citation: FC = () => {
const { t } = useTranslation()
return (
<Panel
title={
<div className='flex items-center gap-2'>
<div>{t('appDebug.feature.citation.title')}</div>
</div>
}
headerIcon={<Citations className='w-4 h-4 text-[#FD853A]' />}
headerRight={
<div className='text-xs text-gray-500'>{t('appDebug.feature.citation.resDes')}</div>
}
noBodySpacing
/>
)
}
export default React.memo(Citation)

View File

@ -0,0 +1,65 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import GroupName from '../../base/group-name'
import type { IOpeningStatementProps } from './opening-statement'
import OpeningStatement from './opening-statement'
import SuggestedQuestionsAfterAnswer from './suggested-questions-after-answer'
import SpeechToText from './speech-to-text'
import TextToSpeech from './text-to-speech'
import Citation from './citation'
/*
* Include
* 1. Conversation Opener
* 2. Opening Suggestion
* 3. Next question suggestion
*/
type ChatGroupProps = {
isShowOpeningStatement: boolean
openingStatementConfig: IOpeningStatementProps
isShowSuggestedQuestionsAfterAnswer: boolean
isShowSpeechText: boolean
isShowTextToSpeech: boolean
isShowCitation: boolean
}
const ChatGroup: FC<ChatGroupProps> = ({
isShowOpeningStatement,
openingStatementConfig,
isShowSuggestedQuestionsAfterAnswer,
isShowSpeechText,
isShowTextToSpeech,
isShowCitation,
}) => {
const { t } = useTranslation()
return (
<div className='mt-7'>
<GroupName name={t('appDebug.feature.groupChat.title')} />
<div className='space-y-3'>
{isShowOpeningStatement && (
<OpeningStatement {...openingStatementConfig} />
)}
{isShowSuggestedQuestionsAfterAnswer && (
<SuggestedQuestionsAfterAnswer />
)}
{
isShowTextToSpeech && (
<TextToSpeech />
)
}
{
isShowSpeechText && (
<SpeechToText />
)
}
{
isShowCitation && (
<Citation />
)
}
</div>
</div>
)
}
export default React.memo(ChatGroup)

View File

@ -0,0 +1,25 @@
'use client'
import React, { type FC } from 'react'
import { useTranslation } from 'react-i18next'
import Panel from '@/app/components/app/configuration/base/feature-panel'
import { Microphone01 } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices'
const SpeechToTextConfig: FC = () => {
const { t } = useTranslation()
return (
<Panel
title={
<div className='flex items-center gap-2'>
<div>{t('appDebug.feature.speechToText.title')}</div>
</div>
}
headerIcon={<Microphone01 className='w-4 h-4 text-[#7839EE]' />}
headerRight={
<div className='text-xs text-gray-500'>{t('appDebug.feature.speechToText.resDes')}</div>
}
noBodySpacing
/>
)
}
export default React.memo(SpeechToTextConfig)

View File

@ -0,0 +1,34 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import Panel from '@/app/components/app/configuration/base/feature-panel'
import SuggestedQuestionsAfterAnswerIcon from '@/app/components/app/configuration/base/icons/suggested-questions-after-answer-icon'
import Tooltip from '@/app/components/base/tooltip'
const SuggestedQuestionsAfterAnswer: FC = () => {
const { t } = useTranslation()
return (
<Panel
title={
<div className='flex items-center gap-1'>
<div>{t('appDebug.feature.suggestedQuestionsAfterAnswer.title')}</div>
<Tooltip
popupContent={
<div className='w-[180px]'>
{t('appDebug.feature.suggestedQuestionsAfterAnswer.description')}
</div>
}
/>
</div>
}
headerIcon={<SuggestedQuestionsAfterAnswerIcon />}
headerRight={
<div className='text-xs text-gray-500'>{t('appDebug.feature.suggestedQuestionsAfterAnswer.resDes')}</div>
}
noBodySpacing
/>
)
}
export default React.memo(SuggestedQuestionsAfterAnswer)

View File

@ -0,0 +1,55 @@
'use client'
import useSWR from 'swr'
import React, { type FC } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { usePathname } from 'next/navigation'
import Panel from '@/app/components/app/configuration/base/feature-panel'
import { Speaker } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices'
import ConfigContext from '@/context/debug-configuration'
import { languages } from '@/i18n/language'
import { fetchAppVoices } from '@/service/apps'
import AudioBtn from '@/app/components/base/audio-btn'
const TextToSpeech: FC = () => {
const { t } = useTranslation()
const {
textToSpeechConfig,
} = useContext(ConfigContext)
const pathname = usePathname()
const matched = pathname.match(/\/app\/([^/]+)/)
const appId = (matched?.length && matched[1]) ? matched[1] : ''
const language = textToSpeechConfig.language
const languageInfo = languages.find(i => i.value === textToSpeechConfig.language)
const voiceItems = useSWR({ appId, language }, fetchAppVoices).data
const voiceItem = voiceItems?.find(item => item.value === textToSpeechConfig.voice)
return (
<Panel
title={
<div className='flex items-center'>
<div>{t('appDebug.feature.textToSpeech.title')}</div>
</div>
}
headerIcon={<Speaker className='w-4 h-4 text-[#7839EE]' />}
headerRight={
<div className='text-xs text-gray-500 inline-flex items-center gap-2'>
{languageInfo && (`${languageInfo?.name} - `)}{voiceItem?.name ?? t('appDebug.voice.defaultDisplay')}
{ languageInfo?.example && (
<AudioBtn
value={languageInfo?.example}
isAudition
voice={textToSpeechConfig.voice}
noCache
/>
)}
</div>
}
noBodySpacing
isShowTextToSpeech
/>
)
}
export default React.memo(TextToSpeech)

View File

@ -0,0 +1,135 @@
'use client'
import type { FC } from 'react'
import React, { useRef, useState } from 'react'
import { useHover } from 'ahooks'
import { useTranslation } from 'react-i18next'
import cn from '@/utils/classnames'
import { MessageCheckRemove, MessageFastPlus } from '@/app/components/base/icons/src/vender/line/communication'
import { MessageFast } from '@/app/components/base/icons/src/vender/solid/communication'
import { Edit04 } from '@/app/components/base/icons/src/vender/line/general'
import RemoveAnnotationConfirmModal from '@/app/components/app/annotation/remove-annotation-confirm-modal'
import Tooltip from '@/app/components/base/tooltip'
import { addAnnotation, delAnnotation } from '@/service/annotation'
import Toast from '@/app/components/base/toast'
import { useProviderContext } from '@/context/provider-context'
import { useModalContext } from '@/context/modal-context'
type Props = {
appId: string
messageId?: string
annotationId?: string
className?: string
cached: boolean
query: string
answer: string
onAdded: (annotationId: string, authorName: string) => void
onEdit: () => void
onRemoved: () => void
}
const CacheCtrlBtn: FC<Props> = ({
className,
cached,
query,
answer,
appId,
messageId,
annotationId,
onAdded,
onEdit,
onRemoved,
}) => {
const { t } = useTranslation()
const { plan, enableBilling } = useProviderContext()
const isAnnotationFull = (enableBilling && plan.usage.annotatedResponse >= plan.total.annotatedResponse)
const { setShowAnnotationFullModal } = useModalContext()
const [showModal, setShowModal] = useState(false)
const cachedBtnRef = useRef<HTMLDivElement>(null)
const isCachedBtnHovering = useHover(cachedBtnRef)
const handleAdd = async () => {
if (isAnnotationFull) {
setShowAnnotationFullModal()
return
}
const res: any = await addAnnotation(appId, {
message_id: messageId,
question: query,
answer,
})
Toast.notify({
message: t('common.api.actionSuccess') as string,
type: 'success',
})
onAdded(res.id, res.account?.name)
}
const handleRemove = async () => {
await delAnnotation(appId, annotationId!)
Toast.notify({
message: t('common.api.actionSuccess') as string,
type: 'success',
})
onRemoved()
setShowModal(false)
}
return (
<div className={cn('inline-block', className)}>
<div className='inline-flex p-0.5 space-x-0.5 rounded-lg bg-white border border-gray-100 shadow-md text-gray-500 cursor-pointer'>
{cached
? (
<div>
<div
ref={cachedBtnRef}
className={cn(isCachedBtnHovering ? 'bg-[#FEF3F2] text-[#D92D20]' : 'bg-[#EEF4FF] text-[#444CE7]', 'flex p-1 space-x-1 items-center rounded-md leading-4 text-xs font-medium')}
onClick={() => setShowModal(true)}
>
{!isCachedBtnHovering
? (
<>
<MessageFast className='w-4 h-4' />
<div>{t('appDebug.feature.annotation.cached')}</div>
</>
)
: <>
<MessageCheckRemove className='w-4 h-4' />
<div>{t('appDebug.feature.annotation.remove')}</div>
</>}
</div>
</div>
)
: answer
? (
<Tooltip
popupContent={t('appDebug.feature.annotation.add')}
>
<div
className='p-1 rounded-md hover:bg-[#EEF4FF] hover:text-[#444CE7] cursor-pointer'
onClick={handleAdd}
>
<MessageFastPlus className='w-4 h-4' />
</div>
</Tooltip>
)
: null
}
<Tooltip
popupContent={t('appDebug.feature.annotation.edit')}
>
<div
className='p-1 cursor-pointer rounded-md hover:bg-black/5'
onClick={onEdit}
>
<Edit04 className='w-4 h-4' />
</div>
</Tooltip>
</div>
<RemoveAnnotationConfirmModal
isShow={showModal}
onHide={() => setShowModal(false)}
onRemove={handleRemove}
/>
</div>
)
}
export default React.memo(CacheCtrlBtn)

View File

@ -0,0 +1,139 @@
'use client'
import type { FC } from 'react'
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import ScoreSlider from '../score-slider'
import { Item } from './config-param'
import Modal from '@/app/components/base/modal'
import Button from '@/app/components/base/button'
import Toast from '@/app/components/base/toast'
import type { AnnotationReplyConfig } from '@/models/debug'
import { ANNOTATION_DEFAULT } from '@/config'
import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector'
import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
type Props = {
appId: string
isShow: boolean
onHide: () => void
onSave: (embeddingModel: {
embedding_provider_name: string
embedding_model_name: string
}, score: number) => void
isInit?: boolean
annotationConfig: AnnotationReplyConfig
}
const ConfigParamModal: FC<Props> = ({
isShow,
onHide: doHide,
onSave,
isInit,
annotationConfig: oldAnnotationConfig,
}) => {
const { t } = useTranslation()
const {
modelList: embeddingsModelList,
defaultModel: embeddingsDefaultModel,
currentModel: isEmbeddingsDefaultModelValid,
} = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.textEmbedding)
const [annotationConfig, setAnnotationConfig] = useState(oldAnnotationConfig)
const [isLoading, setLoading] = useState(false)
const [embeddingModel, setEmbeddingModel] = useState(oldAnnotationConfig.embedding_model
? {
providerName: oldAnnotationConfig.embedding_model.embedding_provider_name,
modelName: oldAnnotationConfig.embedding_model.embedding_model_name,
}
: (embeddingsDefaultModel
? {
providerName: embeddingsDefaultModel.provider.provider,
modelName: embeddingsDefaultModel.model,
}
: undefined))
const onHide = () => {
if (!isLoading)
doHide()
}
const handleSave = async () => {
if (!embeddingModel || !embeddingModel.modelName || (embeddingModel.modelName === embeddingsDefaultModel?.model && !isEmbeddingsDefaultModelValid)) {
Toast.notify({
message: t('common.modelProvider.embeddingModel.required'),
type: 'error',
})
return
}
setLoading(true)
await onSave({
embedding_provider_name: embeddingModel.providerName,
embedding_model_name: embeddingModel.modelName,
}, annotationConfig.score_threshold)
setLoading(false)
}
return (
<Modal
isShow={isShow}
onClose={onHide}
className='!p-8 !pb-6 !mt-14 !max-w-none !w-[640px]'
>
<div className='mb-2 text-xl font-semibold text-[#1D2939]'>
{t(`appAnnotation.initSetup.${isInit ? 'title' : 'configTitle'}`)}
</div>
<div className='mt-6 space-y-3'>
<Item
title={t('appDebug.feature.annotation.scoreThreshold.title')}
tooltip={t('appDebug.feature.annotation.scoreThreshold.description')}
>
<ScoreSlider
className='mt-1'
value={(annotationConfig.score_threshold || ANNOTATION_DEFAULT.score_threshold) * 100}
onChange={(val) => {
setAnnotationConfig({
...annotationConfig,
score_threshold: val / 100,
})
}}
/>
</Item>
<Item
title={t('common.modelProvider.embeddingModel.key')}
tooltip={t('appAnnotation.embeddingModelSwitchTip')}
>
<div className='pt-1'>
<ModelSelector
defaultModel={embeddingModel && {
provider: embeddingModel.providerName,
model: embeddingModel.modelName,
}}
modelList={embeddingsModelList}
onSelect={(val) => {
setEmbeddingModel({
providerName: val.provider,
modelName: val.model,
})
}}
/>
</div>
</Item>
</div>
<div className='mt-6 flex gap-2 justify-end'>
<Button onClick={onHide}>{t('common.operation.cancel')}</Button>
<Button
variant='primary'
onClick={handleSave}
loading={isLoading}
>
<div></div>
<div>{t(`appAnnotation.initSetup.${isInit ? 'confirmBtn' : 'configConfirmBtn'}`)}</div>
</Button >
</div >
</Modal >
)
}
export default React.memo(ConfigParamModal)

View File

@ -0,0 +1,4 @@
export enum PageType {
log = 'log',
annotation = 'annotation',
}

View File

@ -0,0 +1,89 @@
import React, { useState } from 'react'
import produce from 'immer'
import type { AnnotationReplyConfig } from '@/models/debug'
import { queryAnnotationJobStatus, updateAnnotationStatus } from '@/service/annotation'
import type { EmbeddingModelConfig } from '@/app/components/app/annotation/type'
import { AnnotationEnableStatus, JobStatus } from '@/app/components/app/annotation/type'
import { sleep } from '@/utils'
import { ANNOTATION_DEFAULT } from '@/config'
import { useProviderContext } from '@/context/provider-context'
type Params = {
appId: string
annotationConfig: AnnotationReplyConfig
setAnnotationConfig: (annotationConfig: AnnotationReplyConfig) => void
}
const useAnnotationConfig = ({
appId,
annotationConfig,
setAnnotationConfig,
}: Params) => {
const { plan, enableBilling } = useProviderContext()
const isAnnotationFull = (enableBilling && plan.usage.annotatedResponse >= plan.total.annotatedResponse)
const [isShowAnnotationFullModal, setIsShowAnnotationFullModal] = useState(false)
const [isShowAnnotationConfigInit, doSetIsShowAnnotationConfigInit] = React.useState(false)
const setIsShowAnnotationConfigInit = (isShow: boolean) => {
if (isShow) {
if (isAnnotationFull) {
setIsShowAnnotationFullModal(true)
return
}
}
doSetIsShowAnnotationConfigInit(isShow)
}
const ensureJobCompleted = async (jobId: string, status: AnnotationEnableStatus) => {
let isCompleted = false
while (!isCompleted) {
const res: any = await queryAnnotationJobStatus(appId, status, jobId)
isCompleted = res.job_status === JobStatus.completed
if (isCompleted)
break
await sleep(2000)
}
}
const handleEnableAnnotation = async (embeddingModel: EmbeddingModelConfig, score?: number) => {
if (isAnnotationFull)
return
const { job_id: jobId }: any = await updateAnnotationStatus(appId, AnnotationEnableStatus.enable, embeddingModel, score)
await ensureJobCompleted(jobId, AnnotationEnableStatus.enable)
setAnnotationConfig(produce(annotationConfig, (draft: AnnotationReplyConfig) => {
draft.enabled = true
draft.embedding_model = embeddingModel
if (!draft.score_threshold)
draft.score_threshold = ANNOTATION_DEFAULT.score_threshold
}))
}
const setScore = (score: number, embeddingModel?: EmbeddingModelConfig) => {
setAnnotationConfig(produce(annotationConfig, (draft: AnnotationReplyConfig) => {
draft.score_threshold = score
if (embeddingModel)
draft.embedding_model = embeddingModel
}))
}
const handleDisableAnnotation = async (embeddingModel: EmbeddingModelConfig) => {
if (!annotationConfig.enabled)
return
await updateAnnotationStatus(appId, AnnotationEnableStatus.disable, embeddingModel)
setAnnotationConfig(produce(annotationConfig, (draft: AnnotationReplyConfig) => {
draft.enabled = false
}))
}
return {
handleEnableAnnotation,
handleDisableAnnotation,
isShowAnnotationConfigInit,
setIsShowAnnotationConfigInit,
isShowAnnotationFullModal,
setIsShowAnnotationFullModal,
setScore,
}
}
export default useAnnotationConfig

View File

@ -0,0 +1,79 @@
import type { FC } from 'react'
import { useContext } from 'use-context-selector'
import type { CodeBasedExtensionForm } from '@/models/common'
import I18n from '@/context/i18n'
import { PortalSelect } from '@/app/components/base/select'
import type { ModerationConfig } from '@/models/debug'
type FormGenerationProps = {
forms: CodeBasedExtensionForm[]
value: ModerationConfig['config']
onChange: (v: Record<string, string>) => void
}
const FormGeneration: FC<FormGenerationProps> = ({
forms,
value,
onChange,
}) => {
const { locale } = useContext(I18n)
const handleFormChange = (type: string, v: string) => {
onChange({ ...value, [type]: v })
}
return (
<>
{
forms.map((form, index) => (
<div
key={index}
className='py-2'
>
<div className='flex items-center h-9 text-sm font-medium text-gray-900'>
{locale === 'zh-Hans' ? form.label['zh-Hans'] : form.label['en-US']}
</div>
{
form.type === 'text-input' && (
<input
value={value?.[form.variable] || ''}
className='block px-3 w-full h-9 bg-gray-100 rounded-lg text-sm text-gray-900 outline-none appearance-none'
placeholder={form.placeholder}
onChange={e => handleFormChange(form.variable, e.target.value)}
/>
)
}
{
form.type === 'paragraph' && (
<div className='relative px-3 py-2 h-[88px] bg-gray-100 rounded-lg'>
<textarea
value={value?.[form.variable] || ''}
className='block w-full h-full bg-transparent text-sm outline-none appearance-none resize-none'
placeholder={form.placeholder}
onChange={e => handleFormChange(form.variable, e.target.value)}
/>
</div>
)
}
{
form.type === 'select' && (
<PortalSelect
value={value?.[form.variable]}
items={form.options.map((option) => {
return {
name: option.label[locale === 'zh-Hans' ? 'zh-Hans' : 'en-US'],
value: option.value,
}
})}
onSelect={item => handleFormChange(form.variable, item.value as string)}
popupClassName='w-[576px] !z-[102]'
/>
)
}
</div>
))
}
</>
)
}
export default FormGeneration

View File

@ -0,0 +1,80 @@
import { useTranslation } from 'react-i18next'
import useSWR from 'swr'
import { useContext } from 'use-context-selector'
import { FileSearch02 } from '@/app/components/base/icons/src/vender/solid/files'
import { Settings01 } from '@/app/components/base/icons/src/vender/line/general'
import { useModalContext } from '@/context/modal-context'
import ConfigContext from '@/context/debug-configuration'
import { fetchCodeBasedExtensionList } from '@/service/common'
import I18n from '@/context/i18n'
const Moderation = () => {
const { t } = useTranslation()
const { setShowModerationSettingModal } = useModalContext()
const { locale } = useContext(I18n)
const {
moderationConfig,
setModerationConfig,
} = useContext(ConfigContext)
const { data: codeBasedExtensionList } = useSWR(
'/code-based-extension?module=moderation',
fetchCodeBasedExtensionList,
)
const handleOpenModerationSettingModal = () => {
setShowModerationSettingModal({
payload: moderationConfig,
onSaveCallback: setModerationConfig,
})
}
const renderInfo = () => {
let prefix = ''
let suffix = ''
if (moderationConfig.type === 'openai_moderation')
prefix = t('appDebug.feature.moderation.modal.provider.openai')
else if (moderationConfig.type === 'keywords')
prefix = t('appDebug.feature.moderation.modal.provider.keywords')
else if (moderationConfig.type === 'api')
prefix = t('common.apiBasedExtension.selector.title')
else
prefix = codeBasedExtensionList?.data.find(item => item.name === moderationConfig.type)?.label[locale] || ''
if (moderationConfig.config?.inputs_config?.enabled && moderationConfig.config?.outputs_config?.enabled)
suffix = t('appDebug.feature.moderation.allEnabled')
else if (moderationConfig.config?.inputs_config?.enabled)
suffix = t('appDebug.feature.moderation.inputEnabled')
else if (moderationConfig.config?.outputs_config?.enabled)
suffix = t('appDebug.feature.moderation.outputEnabled')
return `${prefix} · ${suffix}`
}
return (
<div className='flex items-center px-3 h-12 bg-gray-50 rounded-xl overflow-hidden'>
<div className='shrink-0 flex items-center justify-center mr-1 w-6 h-6'>
<FileSearch02 className='shrink-0 w-4 h-4 text-[#039855]' />
</div>
<div className='shrink-0 mr-2 whitespace-nowrap text-sm text-gray-800 font-semibold'>
{t('appDebug.feature.moderation.title')}
</div>
<div
className='grow block w-0 text-right text-xs text-gray-500 truncate'
title={renderInfo()}>
{renderInfo()}
</div>
<div className='shrink-0 ml-4 mr-1 w-[1px] h-3.5 bg-gray-200'></div>
<div
className={`
shrink-0 flex items-center px-3 h-7 cursor-pointer rounded-md
text-xs text-gray-700 font-medium hover:bg-gray-200
`}
onClick={handleOpenModerationSettingModal}
>
<Settings01 className='mr-[5px] w-3.5 h-3.5' />
{t('common.operation.settings')}
</div>
</div>
)
}
export default Moderation

View File

@ -0,0 +1,72 @@
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import Switch from '@/app/components/base/switch'
import type { ModerationContentConfig } from '@/models/debug'
type ModerationContentProps = {
title: string
info?: string
showPreset?: boolean
config: ModerationContentConfig
onConfigChange: (config: ModerationContentConfig) => void
}
const ModerationContent: FC<ModerationContentProps> = ({
title,
info,
showPreset = true,
config,
onConfigChange,
}) => {
const { t } = useTranslation()
const handleConfigChange = (field: string, value: boolean | string) => {
if (field === 'preset_response' && typeof value === 'string')
value = value.slice(0, 100)
onConfigChange({ ...config, [field]: value })
}
return (
<div className='py-2'>
<div className='rounded-lg bg-gray-50 border border-gray-200'>
<div className='flex items-center justify-between px-3 h-10 rounded-lg'>
<div className='shrink-0 text-sm font-medium text-gray-900'>{title}</div>
<div className='grow flex items-center justify-end'>
{
info && (
<div className='mr-2 text-xs text-gray-500 truncate' title={info}>{info}</div>
)
}
<Switch
size='l'
defaultValue={config.enabled}
onChange={v => handleConfigChange('enabled', v)}
/>
</div>
</div>
{
config.enabled && showPreset && (
<div className='px-3 pt-1 pb-3 bg-white rounded-lg'>
<div className='flex items-center justify-between h-8 text-[13px] font-medium text-gray-700'>
{t('appDebug.feature.moderation.modal.content.preset')}
<span className='text-xs font-normal text-gray-500'>{t('appDebug.feature.moderation.modal.content.supportMarkdown')}</span>
</div>
<div className='relative px-3 py-2 h-20 rounded-lg bg-gray-100'>
<textarea
value={config.preset_response || ''}
className='block w-full h-full bg-transparent text-sm outline-none appearance-none resize-none'
placeholder={t('appDebug.feature.moderation.modal.content.placeholder') || ''}
onChange={e => handleConfigChange('preset_response', e.target.value)}
/>
<div className='absolute bottom-2 right-2 flex items-center px-1 h-5 rounded-md bg-gray-50 text-xs font-medium text-gray-300'>
<span>{(config.preset_response || '').length}</span>/<span className='text-gray-500'>100</span>
</div>
</div>
</div>
)
}
</div>
</div>
)
}
export default ModerationContent

View File

@ -0,0 +1,373 @@
import type { ChangeEvent, FC } from 'react'
import { useState } from 'react'
import useSWR from 'swr'
import { useContext } from 'use-context-selector'
import { useTranslation } from 'react-i18next'
import ModerationContent from './moderation-content'
import FormGeneration from './form-generation'
import ApiBasedExtensionSelector from '@/app/components/header/account-setting/api-based-extension-page/selector'
import Modal from '@/app/components/base/modal'
import Button from '@/app/components/base/button'
import { BookOpen01 } from '@/app/components/base/icons/src/vender/line/education'
import type { ModerationConfig, ModerationContentConfig } from '@/models/debug'
import { useToastContext } from '@/app/components/base/toast'
import {
fetchCodeBasedExtensionList,
fetchModelProviders,
} from '@/service/common'
import type { CodeBasedExtensionItem } from '@/models/common'
import I18n from '@/context/i18n'
import { LanguagesSupported } from '@/i18n/language'
import { InfoCircle } from '@/app/components/base/icons/src/vender/line/general'
import { useModalContext } from '@/context/modal-context'
import { CustomConfigurationStatusEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
const systemTypes = ['openai_moderation', 'keywords', 'api']
type Provider = {
key: string
name: string
form_schema?: CodeBasedExtensionItem['form_schema']
}
type ModerationSettingModalProps = {
data: ModerationConfig
onCancel: () => void
onSave: (moderationConfig: ModerationConfig) => void
}
const ModerationSettingModal: FC<ModerationSettingModalProps> = ({
data,
onCancel,
onSave,
}) => {
const { t } = useTranslation()
const { notify } = useToastContext()
const { locale } = useContext(I18n)
const { data: modelProviders, isLoading, mutate } = useSWR('/workspaces/current/model-providers', fetchModelProviders)
const [localeData, setLocaleData] = useState<ModerationConfig>(data)
const { setShowAccountSettingModal } = useModalContext()
const handleOpenSettingsModal = () => {
setShowAccountSettingModal({
payload: 'provider',
onCancelCallback: () => {
mutate()
},
})
}
const { data: codeBasedExtensionList } = useSWR(
'/code-based-extension?module=moderation',
fetchCodeBasedExtensionList,
)
const openaiProvider = modelProviders?.data.find(item => item.provider === 'openai')
const systemOpenaiProviderEnabled = openaiProvider?.system_configuration.enabled
const systemOpenaiProviderQuota = systemOpenaiProviderEnabled ? openaiProvider?.system_configuration.quota_configurations.find(item => item.quota_type === openaiProvider.system_configuration.current_quota_type) : undefined
const systemOpenaiProviderCanUse = systemOpenaiProviderQuota?.is_valid
const customOpenaiProvidersCanUse = openaiProvider?.custom_configuration.status === CustomConfigurationStatusEnum.active
const isOpenAIProviderConfigured = customOpenaiProvidersCanUse || systemOpenaiProviderCanUse
const providers: Provider[] = [
{
key: 'openai_moderation',
name: t('appDebug.feature.moderation.modal.provider.openai'),
},
{
key: 'keywords',
name: t('appDebug.feature.moderation.modal.provider.keywords'),
},
{
key: 'api',
name: t('common.apiBasedExtension.selector.title'),
},
...(
codeBasedExtensionList
? codeBasedExtensionList.data.map((item) => {
return {
key: item.name,
name: locale === 'zh-Hans' ? item.label['zh-Hans'] : item.label['en-US'],
form_schema: item.form_schema,
}
})
: []
),
]
const currentProvider = providers.find(provider => provider.key === localeData.type)
const handleDataTypeChange = (type: string) => {
let config: undefined | Record<string, any>
const currProvider = providers.find(provider => provider.key === type)
if (systemTypes.findIndex(t => t === type) < 0 && currProvider?.form_schema) {
config = currProvider?.form_schema.reduce((prev, next) => {
prev[next.variable] = next.default
return prev
}, {} as Record<string, any>)
}
setLocaleData({
...localeData,
type,
config,
})
}
const handleDataKeywordsChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value
const arr = value.split('\n').reduce((prev: string[], next: string) => {
if (next !== '')
prev.push(next.slice(0, 100))
if (next === '' && prev[prev.length - 1] !== '')
prev.push(next)
return prev
}, [])
setLocaleData({
...localeData,
config: {
...localeData.config,
keywords: arr.slice(0, 100).join('\n'),
},
})
}
const handleDataContentChange = (contentType: string, contentConfig: ModerationContentConfig) => {
setLocaleData({
...localeData,
config: {
...localeData.config,
[contentType]: contentConfig,
},
})
}
const handleDataApiBasedChange = (apiBasedExtensionId: string) => {
setLocaleData({
...localeData,
config: {
...localeData.config,
api_based_extension_id: apiBasedExtensionId,
},
})
}
const handleDataExtraChange = (extraValue: Record<string, string>) => {
setLocaleData({
...localeData,
config: {
...localeData.config,
...extraValue,
},
})
}
const formatData = (originData: ModerationConfig) => {
const { enabled, type, config } = originData
const { inputs_config, outputs_config } = config!
const params: Record<string, string | undefined> = {}
if (type === 'keywords')
params.keywords = config?.keywords
if (type === 'api')
params.api_based_extension_id = config?.api_based_extension_id
if (systemTypes.findIndex(t => t === type) < 0 && currentProvider?.form_schema) {
currentProvider.form_schema.forEach((form) => {
params[form.variable] = config?.[form.variable]
})
}
return {
type,
enabled,
config: {
inputs_config: inputs_config || { enabled: false },
outputs_config: outputs_config || { enabled: false },
...params,
},
}
}
const handleSave = () => {
if (localeData.type === 'openai_moderation' && !isOpenAIProviderConfigured)
return
if (!localeData.config?.inputs_config?.enabled && !localeData.config?.outputs_config?.enabled) {
notify({ type: 'error', message: t('appDebug.feature.moderation.modal.content.condition') })
return
}
if (localeData.type === 'keywords' && !localeData.config.keywords) {
notify({ type: 'error', message: t('appDebug.errorMessage.valueOfVarRequired', { key: locale !== LanguagesSupported[1] ? 'keywords' : '关键词' }) })
return
}
if (localeData.type === 'api' && !localeData.config.api_based_extension_id) {
notify({ type: 'error', message: t('appDebug.errorMessage.valueOfVarRequired', { key: locale !== LanguagesSupported[1] ? 'API Extension' : 'API 扩展' }) })
return
}
if (systemTypes.findIndex(t => t === localeData.type) < 0 && currentProvider?.form_schema) {
for (let i = 0; i < currentProvider.form_schema.length; i++) {
if (!localeData.config?.[currentProvider.form_schema[i].variable] && currentProvider.form_schema[i].required) {
notify({
type: 'error',
message: t('appDebug.errorMessage.valueOfVarRequired', { key: locale !== LanguagesSupported[1] ? currentProvider.form_schema[i].label['en-US'] : currentProvider.form_schema[i].label['zh-Hans'] }),
})
return
}
}
}
if (localeData.config.inputs_config?.enabled && !localeData.config.inputs_config.preset_response && localeData.type !== 'api') {
notify({ type: 'error', message: t('appDebug.feature.moderation.modal.content.errorMessage') })
return
}
if (localeData.config.outputs_config?.enabled && !localeData.config.outputs_config.preset_response && localeData.type !== 'api') {
notify({ type: 'error', message: t('appDebug.feature.moderation.modal.content.errorMessage') })
return
}
onSave(formatData(localeData))
}
return (
<Modal
isShow
onClose={() => { }}
className='!p-8 !pb-6 !mt-14 !max-w-none !w-[640px]'
>
<div className='mb-2 text-xl font-semibold text-[#1D2939]'>
{t('appDebug.feature.moderation.modal.title')}
</div>
<div className='py-2'>
<div className='leading-9 text-sm font-medium text-gray-900'>
{t('appDebug.feature.moderation.modal.provider.title')}
</div>
<div className='grid gap-2.5 grid-cols-3'>
{
providers.map(provider => (
<div
key={provider.key}
className={`
flex items-center px-3 py-2 rounded-lg text-sm text-gray-900 cursor-pointer
${localeData.type === provider.key ? 'bg-white border-[1.5px] border-primary-400 shadow-sm' : 'border border-gray-100 bg-gray-25'}
${localeData.type === 'openai_moderation' && provider.key === 'openai_moderation' && !isOpenAIProviderConfigured && 'opacity-50'}
`}
onClick={() => handleDataTypeChange(provider.key)}
>
<div className={`
mr-2 w-4 h-4 rounded-full border
${localeData.type === provider.key ? 'border-[5px] border-primary-600' : 'border border-gray-300'}`} />
{provider.name}
</div>
))
}
</div>
{
!isLoading && !isOpenAIProviderConfigured && localeData.type === 'openai_moderation' && (
<div className='flex items-center mt-2 px-3 py-2 bg-[#FFFAEB] rounded-lg border border-[#FEF0C7]'>
<InfoCircle className='mr-1 w-4 h-4 text-[#F79009]' />
<div className='flex items-center text-xs font-medium text-gray-700'>
{t('appDebug.feature.moderation.modal.openaiNotConfig.before')}
<span
className='text-primary-600 cursor-pointer'
onClick={handleOpenSettingsModal}
>
&nbsp;{t('common.settings.provider')}&nbsp;
</span>
{t('appDebug.feature.moderation.modal.openaiNotConfig.after')}
</div>
</div>
)
}
</div>
{
localeData.type === 'keywords' && (
<div className='py-2'>
<div className='mb-1 text-sm font-medium text-gray-900'>{t('appDebug.feature.moderation.modal.provider.keywords')}</div>
<div className='mb-2 text-xs text-gray-500'>{t('appDebug.feature.moderation.modal.keywords.tip')}</div>
<div className='relative px-3 py-2 h-[88px] bg-gray-100 rounded-lg'>
<textarea
value={localeData.config?.keywords || ''}
onChange={handleDataKeywordsChange}
className='block w-full h-full bg-transparent text-sm outline-none appearance-none resize-none'
placeholder={t('appDebug.feature.moderation.modal.keywords.placeholder') || ''}
/>
<div className='absolute bottom-2 right-2 flex items-center px-1 h-5 rounded-md bg-gray-50 text-xs font-medium text-gray-300'>
<span>{(localeData.config?.keywords || '').split('\n').filter(Boolean).length}</span>/<span className='text-gray-500'>100 {t('appDebug.feature.moderation.modal.keywords.line')}</span>
</div>
</div>
</div>
)
}
{
localeData.type === 'api' && (
<div className='py-2'>
<div className='flex items-center justify-between h-9'>
<div className='text-sm font-medium text-gray-900'>{t('common.apiBasedExtension.selector.title')}</div>
<a
href={t('common.apiBasedExtension.linkUrl') || '/'}
target='_blank' rel='noopener noreferrer'
className='group flex items-center text-xs text-gray-500 hover:text-primary-600'
>
<BookOpen01 className='mr-1 w-3 h-3 text-gray-500 group-hover:text-primary-600' />
{t('common.apiBasedExtension.link')}
</a>
</div>
<ApiBasedExtensionSelector
value={localeData.config?.api_based_extension_id || ''}
onChange={handleDataApiBasedChange}
/>
</div>
)
}
{
systemTypes.findIndex(t => t === localeData.type) < 0
&& currentProvider?.form_schema
&& (
<FormGeneration
forms={currentProvider?.form_schema}
value={localeData.config}
onChange={handleDataExtraChange}
/>
)
}
<div className='my-3 h-[1px] bg-gradient-to-r from-[#F3F4F6]'></div>
<ModerationContent
title={t('appDebug.feature.moderation.modal.content.input') || ''}
config={localeData.config?.inputs_config || { enabled: false, preset_response: '' }}
onConfigChange={config => handleDataContentChange('inputs_config', config)}
info={(localeData.type === 'api' && t('appDebug.feature.moderation.modal.content.fromApi')) || ''}
showPreset={!(localeData.type === 'api')}
/>
<ModerationContent
title={t('appDebug.feature.moderation.modal.content.output') || ''}
config={localeData.config?.outputs_config || { enabled: false, preset_response: '' }}
onConfigChange={config => handleDataContentChange('outputs_config', config)}
info={(localeData.type === 'api' && t('appDebug.feature.moderation.modal.content.fromApi')) || ''}
showPreset={!(localeData.type === 'api')}
/>
<div className='mt-1 mb-8 text-xs font-medium text-gray-500'>{t('appDebug.feature.moderation.modal.content.condition')}</div>
<div className='flex items-center justify-end'>
<Button
onClick={onCancel}
className='mr-2'
>
{t('common.operation.cancel')}
</Button>
<Button
variant='primary'
onClick={handleSave}
disabled={localeData.type === 'openai_moderation' && !isOpenAIProviderConfigured}
>
{t('common.operation.save')}
</Button>
</div>
</Modal>
)
}
export default ModerationSettingModal

View File

@ -0,0 +1,38 @@
import ReactSlider from 'react-slider'
import s from './style.module.css'
import cn from '@/utils/classnames'
type ISliderProps = {
className?: string
value: number
max?: number
min?: number
step?: number
disabled?: boolean
onChange: (value: number) => void
}
const Slider: React.FC<ISliderProps> = ({ className, max, min, step, value, disabled, onChange }) => {
return <ReactSlider
disabled={disabled}
value={isNaN(value) ? 0 : value}
min={min || 0}
max={max || 100}
step={step || 1}
className={cn(className, s.slider)}
thumbClassName={cn(s['slider-thumb'], 'top-[-7px] w-2 h-[18px] bg-white border !border-black/8 rounded-[36px] shadow-md cursor-pointer')}
trackClassName={s['slider-track']}
onChange={onChange}
renderThumb={(props, state) => (
<div {...props}>
<div className='relative w-full h-full'>
<div className='absolute top-[-16px] left-[50%] translate-x-[-50%] leading-[18px] text-xs font-medium text-gray-900'>
{(state.valueNow / 100).toFixed(2)}
</div>
</div>
</div>
)}
/>
}
export default Slider

View File

@ -0,0 +1,20 @@
.slider {
position: relative;
}
.slider.disabled {
opacity: 0.6;
}
.slider-thumb:focus {
outline: none;
}
.slider-track {
background-color: #528BFF;
height: 2px;
}
.slider-track-1 {
background-color: #E5E7EB;
}

View File

@ -0,0 +1,46 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import Slider from '@/app/components/app/configuration/toolbox/score-slider/base-slider'
type Props = {
className?: string
value: number
onChange: (value: number) => void
}
const ScoreSlider: FC<Props> = ({
className,
value,
onChange,
}) => {
const { t } = useTranslation()
return (
<div className={className}>
<div className='h-[1px] mt-[14px]'>
<Slider
max={100}
min={80}
step={1}
value={value}
onChange={onChange}
/>
</div>
<div className='mt-[10px] flex justify-between items-center leading-4 text-xs font-normal '>
<div className='flex space-x-1 text-[#00A286]'>
<div>0.8</div>
<div>·</div>
<div>{t('appDebug.feature.annotation.scoreThreshold.easyMatch')}</div>
</div>
<div className='flex space-x-1 text-[#0057D8]'>
<div>1.0</div>
<div>·</div>
<div>{t('appDebug.feature.annotation.scoreThreshold.accurateMatch')}</div>
</div>
</div>
</div>
)
}
export default React.memo(ScoreSlider)

View File

@ -0,0 +1,258 @@
import type { FC } from 'react'
import {
memo,
useRef,
useState,
} from 'react'
import { useContext } from 'use-context-selector'
import Recorder from 'js-audio-recorder'
import { useTranslation } from 'react-i18next'
import Textarea from 'rc-textarea'
import type {
EnableType,
OnSend,
VisionConfig,
} from '../types'
import { TransferMethod } from '../types'
import { useChatWithHistoryContext } from '../chat-with-history/context'
import type { Theme } from '../embedded-chatbot/theme/theme-context'
import { CssTransform } from '../embedded-chatbot/theme/utils'
import Tooltip from '@/app/components/base/tooltip'
import { ToastContext } from '@/app/components/base/toast'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import VoiceInput from '@/app/components/base/voice-input'
import { Microphone01 } from '@/app/components/base/icons/src/vender/line/mediaAndDevices'
import { Microphone01 as Microphone01Solid } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices'
import { XCircle } from '@/app/components/base/icons/src/vender/solid/general'
import { Send03 } from '@/app/components/base/icons/src/vender/solid/communication'
import ChatImageUploader from '@/app/components/base/image-uploader/chat-image-uploader'
import ImageList from '@/app/components/base/image-uploader/image-list'
import {
useClipboardUploader,
useDraggableUploader,
useImageFiles,
} from '@/app/components/base/image-uploader/hooks'
import cn from '@/utils/classnames'
type ChatInputProps = {
visionConfig?: VisionConfig
speechToTextConfig?: EnableType
onSend?: OnSend
theme?: Theme | null
noSpacing?: boolean
}
const ChatInput: FC<ChatInputProps> = ({
visionConfig,
speechToTextConfig,
onSend,
theme,
noSpacing,
}) => {
const { appData } = useChatWithHistoryContext()
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const [voiceInputShow, setVoiceInputShow] = useState(false)
const textAreaRef = useRef<HTMLTextAreaElement>(null)
const {
files,
onUpload,
onRemove,
onReUpload,
onImageLinkLoadError,
onImageLinkLoadSuccess,
onClear,
} = useImageFiles()
const { onPaste } = useClipboardUploader({ onUpload, visionConfig, files })
const { onDragEnter, onDragLeave, onDragOver, onDrop, isDragActive } = useDraggableUploader<HTMLTextAreaElement>({ onUpload, files, visionConfig })
const isUseInputMethod = useRef(false)
const [query, setQuery] = useState('')
const handleContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value
setQuery(value)
}
const handleSend = () => {
if (onSend) {
if (files.find(item => item.type === TransferMethod.local_file && !item.fileId)) {
notify({ type: 'info', message: t('appDebug.errorMessage.waitForImgUpload') })
return
}
if (!query || !query.trim()) {
notify({ type: 'info', message: t('appAnnotation.errorMessage.queryRequired') })
return
}
onSend(query, files.filter(file => file.progress !== -1).map(fileItem => ({
type: 'image',
transfer_method: fileItem.type,
url: fileItem.url,
upload_file_id: fileItem.fileId,
})))
setQuery('')
onClear()
}
}
const handleKeyUp = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter') {
e.preventDefault()
// prevent send message when using input method enter
if (!e.shiftKey && !isUseInputMethod.current)
handleSend()
}
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
isUseInputMethod.current = e.nativeEvent.isComposing
if (e.key === 'Enter' && !e.shiftKey) {
setQuery(query.replace(/\n$/, ''))
e.preventDefault()
}
}
const logError = (message: string) => {
notify({ type: 'error', message })
}
const handleVoiceInputShow = () => {
(Recorder as any).getPermission().then(() => {
setVoiceInputShow(true)
}, () => {
logError(t('common.voiceInput.notAllow'))
})
}
const [isActiveIconFocused, setActiveIconFocused] = useState(false)
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
const sendIconThemeStyle = theme
? {
color: (isActiveIconFocused || query || (query.trim() !== '')) ? theme.primaryColor : '#d1d5db',
}
: {}
const sendBtn = (
<div
className='group flex items-center justify-center w-8 h-8 rounded-lg hover:bg-[#EBF5FF] cursor-pointer'
onMouseEnter={() => setActiveIconFocused(true)}
onMouseLeave={() => setActiveIconFocused(false)}
onClick={handleSend}
style={isActiveIconFocused ? CssTransform(theme?.chatBubbleColorStyle ?? '') : {}}
>
<Send03
style={sendIconThemeStyle}
className={`
w-5 h-5 text-gray-300 group-hover:text-primary-600
${!!query.trim() && 'text-primary-600'}
`}
/>
</div>
)
return (
<>
<div className={cn('relative', !noSpacing && 'px-8')}>
<div
className={`
p-[5.5px] max-h-[150px] bg-white border-[1.5px] border-gray-200 rounded-xl overflow-y-auto
${isDragActive && 'border-primary-600'} mb-2
`}
>
{
visionConfig?.enabled && (
<>
<div className={cn('absolute bottom-2 flex items-center', noSpacing ? 'left-2' : 'left-10')}>
<ChatImageUploader
settings={visionConfig}
onUpload={onUpload}
disabled={files.length >= visionConfig.number_limits}
/>
<div className='mx-1 w-[1px] h-4 bg-black/5' />
</div>
<div className='pl-[52px]'>
<ImageList
list={files}
onRemove={onRemove}
onReUpload={onReUpload}
onImageLinkLoadSuccess={onImageLinkLoadSuccess}
onImageLinkLoadError={onImageLinkLoadError}
/>
</div>
</>
)
}
<Textarea
ref={textAreaRef}
className={`
block w-full px-2 pr-[118px] py-[7px] leading-5 max-h-none text-sm text-gray-700 outline-none appearance-none resize-none
${visionConfig?.enabled && 'pl-12'}
`}
value={query}
onChange={handleContentChange}
onKeyUp={handleKeyUp}
onKeyDown={handleKeyDown}
onPaste={onPaste}
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
onDragOver={onDragOver}
onDrop={onDrop}
autoSize
/>
<div className={cn('absolute bottom-[7px] flex items-center h-8', noSpacing ? 'right-2' : 'right-10')}>
<div className='flex items-center px-1 h-5 rounded-md bg-gray-100 text-xs font-medium text-gray-500'>
{query.trim().length}
</div>
{
query
? (
<div className='flex justify-center items-center ml-2 w-8 h-8 cursor-pointer hover:bg-gray-100 rounded-lg' onClick={() => setQuery('')}>
<XCircle className='w-4 h-4 text-[#98A2B3]' />
</div>
)
: speechToTextConfig?.enabled
? (
<div
className='group flex justify-center items-center ml-2 w-8 h-8 hover:bg-primary-50 rounded-lg cursor-pointer'
onClick={handleVoiceInputShow}
>
<Microphone01 className='block w-4 h-4 text-gray-500 group-hover:hidden' />
<Microphone01Solid className='hidden w-4 h-4 text-primary-600 group-hover:block' />
</div>
)
: null
}
<div className='mx-2 w-[1px] h-4 bg-black opacity-5' />
{isMobile
? sendBtn
: (
<Tooltip
popupContent={
<div>
<div>{t('common.operation.send')} Enter</div>
<div>{t('common.operation.lineBreak')} Shift Enter</div>
</div>
}
>
{sendBtn}
</Tooltip>
)}
</div>
{
voiceInputShow && (
<VoiceInput
onCancel={() => setVoiceInputShow(false)}
onConverted={(text) => {
setQuery(text)
textAreaRef.current?.focus()
}}
/>
)
}
</div>
</div>
{appData?.site?.custom_disclaimer && <div className='text-xs text-gray-500 mt-1 text-center'>
{appData.site.custom_disclaimer}
</div>}
</>
)
}
export default memo(ChatInput)

View File

@ -0,0 +1,31 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import GroupName from '@/app/components/app/configuration/base/group-name'
export type IFeatureGroupProps = {
title: string
description?: string
children: React.ReactNode
}
const FeatureGroup: FC<IFeatureGroupProps> = ({
title,
description,
children,
}) => {
return (
<div className='mb-6'>
<div className='mb-2'>
<GroupName name={title} />
{description && (
<div className='text-xs font-normal text-gray-500'>{description}</div>
)}
</div>
<div className='space-y-2'>
{children}
</div>
</div>
)
}
export default React.memo(FeatureGroup)

View File

@ -0,0 +1,96 @@
'use client'
import type { FC } from 'react'
import React, { useCallback } from 'react'
import produce from 'immer'
import s from './style.module.css'
import cn from '@/utils/classnames'
import Switch from '@/app/components/base/switch'
import { FeatureEnum } from '@/app/components/base/features/types'
import { useFeaturesStore } from '@/app/components/base/features/hooks'
import { useModalContext } from '@/context/modal-context'
export type IFeatureItemProps = {
icon: React.ReactNode
previewImgClassName?: string
title: string
description: string
value: boolean
onChange: (type: FeatureEnum, value: boolean) => void
type: FeatureEnum
}
const FeatureItem: FC<IFeatureItemProps> = ({
icon,
previewImgClassName,
title,
description,
value,
onChange,
type,
}) => {
const featuresStore = useFeaturesStore()
const { setShowModerationSettingModal } = useModalContext()
const handleChange = useCallback((newValue: boolean) => {
const {
features,
setFeatures,
} = featuresStore!.getState()
if (newValue && !features.moderation?.type && type === FeatureEnum.moderation) {
setShowModerationSettingModal({
payload: {
enabled: true,
type: 'keywords',
config: {
keywords: '',
inputs_config: {
enabled: true,
preset_response: '',
},
},
},
onSaveCallback: (newModeration) => {
setFeatures(produce(features, (draft) => {
draft.moderation = newModeration
}))
},
onCancelCallback: () => {
setFeatures(produce(features, (draft) => {
draft.moderation = { enabled: false }
}))
},
})
return
}
onChange(type, newValue)
}, [type, onChange, featuresStore, setShowModerationSettingModal])
return (
<div className={cn(s.wrap, 'relative flex justify-between p-3 rounded-xl border border-transparent bg-gray-50 hover:border-gray-200 cursor-pointer')}>
<div className='flex space-x-3 mr-2'>
{/* icon */}
<div
className='shrink-0 flex items-center justify-center w-8 h-8 rounded-lg border border-gray-200 bg-white'
style={{
boxShadow: '0px 1px 2px rgba(16, 24, 40, 0.05)',
}}
>
{icon}
</div>
<div>
<div className='text-sm font-semibold text-gray-800'>{title}</div>
<div className='text-xs font-normal text-gray-500'>{description}</div>
</div>
</div>
<Switch onChange={handleChange} defaultValue={value} />
{
previewImgClassName && (
<div className={cn(s.preview, s[previewImgClassName])}>
</div>)
}
</div>
)
}
export default React.memo(FeatureItem)

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 84 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@ -0,0 +1,41 @@
.preview {
display: none;
position: absolute;
top: 0;
left: 100%;
transform: translate(32px, -54px);
width: 280px;
height: 360px;
background: center center no-repeat;
background-size: contain;
border-radius: 8px;
}
.wrap:hover .preview {
display: block;
}
.openingStatementPreview {
background-image: url(~@/app/components/app/configuration/config/feature/choose-feature/feature-item/preview-imgs/opening-statement.png);
}
.suggestedQuestionsAfterAnswerPreview {
background-image: url(~@/app/components/app/configuration/config/feature/choose-feature/feature-item/preview-imgs/suggested-questions-after-answer.png);
}
.moreLikeThisPreview {
background-image: url(~@/app/components/app/configuration/config/feature/choose-feature/feature-item/preview-imgs/more-like-this.png);
}
.speechToTextPreview {
background-image: url(~@/app/components/app/configuration/config/feature/choose-feature/feature-item/preview-imgs/speech-to-text.png);
}
.textToSpeechPreview {
@apply shadow-lg rounded-lg;
background-image: url(~@/app/components/app/configuration/config/feature/choose-feature/feature-item/preview-imgs/text-to-audio-preview-assistant@2x.png);
}
.citationPreview {
background-image: url(~@/app/components/app/configuration/config/feature/choose-feature/feature-item/preview-imgs/citation.png);
}

View File

@ -0,0 +1,147 @@
'use client'
import type { FC } from 'react'
import React, { useCallback } from 'react'
import produce from 'immer'
import { useTranslation } from 'react-i18next'
import {
useFeatures,
useFeaturesStore,
} from '../hooks'
import type { OnFeaturesChange } from '../types'
import FeatureGroup from './feature-group'
import FeatureItem from './feature-item'
import Modal from '@/app/components/base/modal'
import SuggestedQuestionsAfterAnswerIcon from '@/app/components/app/configuration/base/icons/suggested-questions-after-answer-icon'
import { Microphone01, Speaker } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices'
import { Citations } from '@/app/components/base/icons/src/vender/solid/editor'
import { FileSearch02 } from '@/app/components/base/icons/src/vender/solid/files'
import { MessageHeartCircle } from '@/app/components/base/icons/src/vender/solid/communication'
import { FeatureEnum } from '@/app/components/base/features/types'
import { useDefaultModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
export type FeatureModalProps = {
onChange?: OnFeaturesChange
}
const FeatureModal: FC<FeatureModalProps> = ({
onChange,
}) => {
const { t } = useTranslation()
const { data: speech2textDefaultModel } = useDefaultModel(ModelTypeEnum.speech2text)
const { data: text2speechDefaultModel } = useDefaultModel(ModelTypeEnum.tts)
const featuresStore = useFeaturesStore()
const setShowFeaturesModal = useFeatures(s => s.setShowFeaturesModal)
const features = useFeatures(s => s.features)
const handleCancelModal = useCallback(() => {
setShowFeaturesModal(false)
}, [setShowFeaturesModal])
const handleChange = useCallback((type: FeatureEnum, enabled: boolean) => {
const {
features,
setFeatures,
} = featuresStore!.getState()
const newFeatures = produce(features, (draft) => {
draft[type] = {
...draft[type],
enabled,
}
})
setFeatures(newFeatures)
if (onChange)
onChange(newFeatures)
}, [featuresStore, onChange])
return (
<Modal
isShow
onClose={handleCancelModal}
className='w-[400px]'
title={t('appDebug.operation.addFeature')}
closable
overflowVisible
>
<div className='pt-5 pb-10'>
{/* Chat Feature */}
<FeatureGroup
title={t('appDebug.feature.groupChat.title')}
description={t('appDebug.feature.groupChat.description') as string}
>
<>
<FeatureItem
icon={<MessageHeartCircle className='w-4 h-4 text-[#DD2590]' />}
previewImgClassName='openingStatementPreview'
title={t('appDebug.feature.conversationOpener.title')}
description={t('appDebug.feature.conversationOpener.description')}
value={!!features.opening?.enabled}
onChange={handleChange}
type={FeatureEnum.opening}
/>
<FeatureItem
icon={<SuggestedQuestionsAfterAnswerIcon />}
previewImgClassName='suggestedQuestionsAfterAnswerPreview'
title={t('appDebug.feature.suggestedQuestionsAfterAnswer.title')}
description={t('appDebug.feature.suggestedQuestionsAfterAnswer.description')}
value={!!features.suggested?.enabled}
onChange={handleChange}
type={FeatureEnum.suggested}
/>
{
!!text2speechDefaultModel && (
<FeatureItem
icon={<Speaker className='w-4 h-4 text-[#7839EE]' />}
previewImgClassName='textToSpeechPreview'
title={t('appDebug.feature.textToSpeech.title')}
description={t('appDebug.feature.textToSpeech.description')}
value={!!features.text2speech?.enabled}
onChange={handleChange}
type={FeatureEnum.text2speech}
/>
)
}
{
!!speech2textDefaultModel && (
<FeatureItem
icon={<Microphone01 className='w-4 h-4 text-[#7839EE]' />}
previewImgClassName='speechToTextPreview'
title={t('appDebug.feature.speechToText.title')}
description={t('appDebug.feature.speechToText.description')}
value={!!features.speech2text?.enabled}
onChange={handleChange}
type={FeatureEnum.speech2text}
/>
)
}
<FeatureItem
icon={<Citations className='w-4 h-4 text-[#FD853A]' />}
previewImgClassName='citationPreview'
title={t('appDebug.feature.citation.title')}
description={t('appDebug.feature.citation.description')}
value={!!features.citation?.enabled}
onChange={handleChange}
type={FeatureEnum.citation}
/>
</>
</FeatureGroup>
<FeatureGroup title={t('appDebug.feature.toolbox.title')}>
<>
<FeatureItem
icon={<FileSearch02 className='w-4 h-4 text-[#039855]' />}
previewImgClassName=''
title={t('appDebug.feature.moderation.title')}
description={t('appDebug.feature.moderation.description')}
value={!!features.moderation?.enabled}
onChange={handleChange}
type={FeatureEnum.moderation}
/>
</>
</FeatureGroup>
</div>
</Modal>
)
}
export default React.memo(FeatureModal)

View File

@ -0,0 +1,42 @@
'use client'
import React from 'react'
import { useTranslation } from 'react-i18next'
import {
RiAddLine,
} from '@remixicon/react'
import { useFeatures } from '../hooks'
import type { OnFeaturesChange } from '../types'
import FeatureModal from './feature-modal'
import Button from '@/app/components/base/button'
type ChooseFeatureProps = {
onChange?: OnFeaturesChange
disabled?: boolean
}
const ChooseFeature = ({
onChange,
disabled,
}: ChooseFeatureProps) => {
const { t } = useTranslation()
const showFeaturesModal = useFeatures(s => s.showFeaturesModal)
const setShowFeaturesModal = useFeatures(s => s.setShowFeaturesModal)
return (
<>
<Button
className={`
border-primary-100 bg-primary-25 text-xs font-semibold text-primary-600
`}
onClick={() => !disabled && setShowFeaturesModal(true)}
>
<RiAddLine className='mr-1 w-4 h-4' />
{t('appDebug.operation.addFeature')}
</Button>
{
showFeaturesModal && (
<FeatureModal onChange={onChange} />
)
}
</>
)
}
export default React.memo(ChooseFeature)

View File

@ -0,0 +1,25 @@
'use client'
import React, { type FC } from 'react'
import { useTranslation } from 'react-i18next'
import Panel from '@/app/components/app/configuration/base/feature-panel'
import { Citations } from '@/app/components/base/icons/src/vender/solid/editor'
const Citation: FC = () => {
const { t } = useTranslation()
return (
<Panel
title={
<div className='flex items-center gap-2'>
<div>{t('appDebug.feature.citation.title')}</div>
</div>
}
headerIcon={<Citations className='w-4 h-4 text-[#FD853A]' />}
headerRight={
<div className='text-xs text-gray-500'>{t('appDebug.feature.citation.resDes')}</div>
}
noBodySpacing
/>
)
}
export default React.memo(Citation)

View File

@ -0,0 +1,63 @@
'use client'
import produce from 'immer'
import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import type { OnFeaturesChange } from '../../types'
import {
useFeatures,
useFeaturesStore,
} from '../../hooks'
import ParamConfig from './param-config'
import Switch from '@/app/components/base/switch'
import { File05 } from '@/app/components/base/icons/src/vender/solid/files'
type FileUploadProps = {
onChange?: OnFeaturesChange
disabled?: boolean
}
const FileUpload = ({
onChange,
disabled,
}: FileUploadProps) => {
const { t } = useTranslation()
const featuresStore = useFeaturesStore()
const file = useFeatures(s => s.features.file)
const handleSwitch = useCallback((value: boolean) => {
const {
features,
setFeatures,
} = featuresStore!.getState()
const newFeatures = produce(features, (draft) => {
if (draft.file?.image)
draft.file.image.enabled = value
})
setFeatures(newFeatures)
if (onChange)
onChange(newFeatures)
}, [featuresStore, onChange])
return (
<div className='flex items-center px-3 h-12 bg-gray-50 rounded-xl overflow-hidden'>
<div className='shrink-0 flex items-center justify-center mr-1 w-6 h-6'>
<File05 className='shrink-0 w-4 h-4 text-[#6938EF]' />
</div>
<div className='shrink-0 mr-2 whitespace-nowrap text-sm text-gray-800 font-semibold'>
{t('common.imageUploader.imageUpload')}
</div>
<div className='grow' />
<div className='flex items-center'>
<ParamConfig onChange={onChange} disabled={disabled} />
<div className='ml-4 mr-3 w-[1px] h-3.5 bg-gray-200'></div>
<Switch
defaultValue={file?.image?.enabled}
onChange={handleSwitch}
disabled={disabled}
size='md'
/>
</div>
</div>
)
}
export default React.memo(FileUpload)

View File

@ -0,0 +1,119 @@
'use client'
import produce from 'immer'
import React, { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import type { OnFeaturesChange } from '../../types'
import {
useFeatures,
useFeaturesStore,
} from '../../hooks'
import RadioGroup from './radio-group'
import { TransferMethod } from '@/types/app'
import ParamItem from '@/app/components/base/param-item'
const MIN = 1
const MAX = 6
type ParamConfigContentProps = {
onChange?: OnFeaturesChange
}
const ParamConfigContent = ({
onChange,
}: ParamConfigContentProps) => {
const { t } = useTranslation()
const featuresStore = useFeaturesStore()
const file = useFeatures(s => s.features.file)
const transferMethod = useMemo(() => {
if (!file?.image?.transfer_methods || file?.image.transfer_methods.length === 2)
return TransferMethod.all
return file.image.transfer_methods[0]
}, [file?.image?.transfer_methods])
const handleTransferMethodsChange = useCallback((value: TransferMethod) => {
const {
features,
setFeatures,
} = featuresStore!.getState()
const newFeatures = produce(features, (draft) => {
if (draft.file?.image) {
if (value === TransferMethod.all)
draft.file.image.transfer_methods = [TransferMethod.remote_url, TransferMethod.local_file]
else
draft.file.image.transfer_methods = [value]
}
})
setFeatures(newFeatures)
if (onChange)
onChange(newFeatures)
}, [featuresStore, onChange])
const handleLimitsChange = useCallback((_key: string, value: number) => {
if (!value)
return
const {
features,
setFeatures,
} = featuresStore!.getState()
const newFeatures = produce(features, (draft) => {
if (draft.file?.image)
draft.file.image.number_limits = value
})
setFeatures(newFeatures)
if (onChange)
onChange(newFeatures)
}, [featuresStore, onChange])
return (
<div>
<div>
<div className='leading-6 text-base font-semibold text-gray-800'>{t('common.operation.settings')}</div>
<div className='pt-3 space-y-6'>
<div>
<div className='mb-2 leading-[18px] text-[13px] font-semibold text-gray-800'>{t('appDebug.vision.visionSettings.uploadMethod')}</div>
<RadioGroup
className='space-x-3'
options={[
{
label: t('appDebug.vision.visionSettings.both'),
value: TransferMethod.all,
},
{
label: t('appDebug.vision.visionSettings.localUpload'),
value: TransferMethod.local_file,
},
{
label: t('appDebug.vision.visionSettings.url'),
value: TransferMethod.remote_url,
},
]}
value={transferMethod}
onChange={handleTransferMethodsChange}
/>
</div>
<div>
<ParamItem
id='upload_limit'
className=''
name={t('appDebug.vision.visionSettings.uploadLimit')}
noTooltip
{...{
default: 2,
step: 1,
min: MIN,
max: MAX,
}}
value={file?.image?.number_limits || 3}
enable={true}
onChange={handleLimitsChange}
/>
</div>
</div>
</div>
</div>
)
}
export default React.memo(ParamConfigContent)

View File

@ -0,0 +1,49 @@
'use client'
import { memo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { OnFeaturesChange } from '../../types'
import ParamConfigContent from './param-config-content'
import cn from '@/utils/classnames'
import { Settings01 } from '@/app/components/base/icons/src/vender/line/general'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
type ParamsConfigProps = {
onChange?: OnFeaturesChange
disabled?: boolean
}
const ParamsConfig = ({
onChange,
disabled,
}: ParamsConfigProps) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='bottom-end'
offset={{
mainAxis: 4,
}}
>
<PortalToFollowElemTrigger onClick={() => !disabled && setOpen(v => !v)}>
<div className={cn('flex items-center rounded-md h-7 px-3 space-x-1 text-gray-700 cursor-pointer hover:bg-gray-200', open && 'bg-gray-200', disabled && 'cursor-not-allowed opacity-50')}>
<Settings01 className='w-3.5 h-3.5 ' />
<div className='ml-1 leading-[18px] text-xs font-medium '>{t('appDebug.voice.settings')}</div>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent style={{ zIndex: 50 }}>
<div className='w-80 sm:w-[412px] p-4 bg-white rounded-lg border-[0.5px] border-gray-200 shadow-lg space-y-3'>
<ParamConfigContent onChange={onChange} />
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default memo(ParamsConfig)

View File

@ -0,0 +1,40 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import s from './style.module.css'
import cn from '@/utils/classnames'
type OPTION = {
label: string
value: any
}
type Props = {
className?: string
options: OPTION[]
value: any
onChange: (value: any) => void
}
const RadioGroup: FC<Props> = ({
className = '',
options,
value,
onChange,
}) => {
return (
<div className={cn(className, 'flex')}>
{options.map(item => (
<div
key={item.value}
className={cn(s.item, item.value === value && s.checked)}
onClick={() => onChange(item.value)}
>
<div className={s.radio}></div>
<div className='text-[13px] font-medium text-gray-900'>{item.label}</div>
</div>
))}
</div>
)
}
export default React.memo(RadioGroup)

View File

@ -0,0 +1,24 @@
.item {
@apply grow flex items-center h-8 px-2.5 rounded-lg bg-gray-25 border border-gray-100 cursor-pointer space-x-2;
}
.item:hover {
background-color: #ffffff;
border-color: #B2CCFF;
box-shadow: 0px 12px 16px -4px rgba(16, 24, 40, 0.08), 0px 4px 6px -2px rgba(16, 24, 40, 0.03);
}
.item.checked {
background-color: #ffffff;
border-color: #528BFF;
box-shadow: 0px 1px 2px 0px rgba(16, 24, 40, 0.06), 0px 1px 3px 0px rgba(16, 24, 40, 0.10);
}
.radio {
@apply w-4 h-4 border-[2px] border-gray-200 rounded-full;
}
.item.checked .radio {
border-width: 5px;
border-color: #155eef;
}

View File

@ -0,0 +1,119 @@
import {
memo,
useMemo,
} from 'react'
import { useTranslation } from 'react-i18next'
import type { OnFeaturesChange } from '../types'
import { useFeatures } from '../hooks'
import FileUpload from './file-upload'
import OpeningStatement from './opening-statement'
import type { OpeningStatementProps } from './opening-statement'
import SuggestedQuestionsAfterAnswer from './suggested-questions-after-answer'
import TextToSpeech from './text-to-speech'
import SpeechToText from './speech-to-text'
import Citation from './citation'
import Moderation from './moderation'
import type { InputVar } from '@/app/components/workflow/types'
export type FeaturePanelProps = {
onChange?: OnFeaturesChange
openingStatementProps: OpeningStatementProps
disabled?: boolean
workflowVariables: InputVar[]
}
const FeaturePanel = ({
onChange,
openingStatementProps,
disabled,
workflowVariables,
}: FeaturePanelProps) => {
const { t } = useTranslation()
const features = useFeatures(s => s.features)
const showAdvanceFeature = useMemo(() => {
return features.opening?.enabled || features.suggested?.enabled || features.speech2text?.enabled || features.text2speech?.enabled || features.citation?.enabled
}, [features])
const showToolFeature = useMemo(() => {
return features.moderation?.enabled
}, [features])
return (
<div className='space-y-3'>
<FileUpload
onChange={onChange}
disabled={disabled}
/>
{
showAdvanceFeature && (
<div>
<div className='flex items-center'>
<div className='shrink-0 text-xs font-semibold text-gray-500'>
{t('appDebug.feature.groupChat.title')}
</div>
<div
className='grow ml-3 h-[1px]'
style={{ background: 'linear-gradient(270deg, rgba(243, 244, 246, 0) 0%, #F3F4F6 100%)' }}
></div>
</div>
<div className='py-2 space-y-2'>
{
features.opening?.enabled && (
<OpeningStatement
{...openingStatementProps}
onChange={onChange}
readonly={disabled}
workflowVariables={workflowVariables}
/>
)
}
{
features.suggested?.enabled && (
<SuggestedQuestionsAfterAnswer />
)
}
{
features.text2speech?.enabled && (
<TextToSpeech onChange={onChange} disabled={disabled} />
)
}
{
features.speech2text?.enabled && (
<SpeechToText />
)
}
{
features.citation?.enabled && (
<Citation />
)
}
</div>
</div>
)
}
{
showToolFeature && (
<div>
<div className='flex items-center'>
<div className='shrink-0 text-xs font-semibold text-gray-500'>
{t('appDebug.feature.groupChat.title')}
</div>
<div
className='grow ml-3 h-[1px]'
style={{ background: 'linear-gradient(270deg, rgba(243, 244, 246, 0) 0%, #F3F4F6 100%)' }}
></div>
</div>
<div className='py-2 space-y-2'>
{
features.moderation?.enabled && (
<Moderation onChange={onChange} disabled={disabled} />
)
}
</div>
</div>
)
}
</div>
)
}
export default memo(FeaturePanel)

View File

@ -0,0 +1,80 @@
import type { FC } from 'react'
import { memo } from 'react'
import { useContext } from 'use-context-selector'
import type { CodeBasedExtensionForm } from '@/models/common'
import I18n from '@/context/i18n'
import { PortalSelect } from '@/app/components/base/select'
import type { ModerationConfig } from '@/models/debug'
type FormGenerationProps = {
forms: CodeBasedExtensionForm[]
value: ModerationConfig['config']
onChange: (v: Record<string, string>) => void
}
const FormGeneration: FC<FormGenerationProps> = ({
forms,
value,
onChange,
}) => {
const { locale } = useContext(I18n)
const handleFormChange = (type: string, v: string) => {
onChange({ ...value, [type]: v })
}
return (
<>
{
forms.map((form, index) => (
<div
key={index}
className='py-2'
>
<div className='flex items-center h-9 text-sm font-medium text-gray-900'>
{locale === 'zh-Hans' ? form.label['zh-Hans'] : form.label['en-US']}
</div>
{
form.type === 'text-input' && (
<input
value={value?.[form.variable] || ''}
className='block px-3 w-full h-9 bg-gray-100 rounded-lg text-sm text-gray-900 outline-none appearance-none'
placeholder={form.placeholder}
onChange={e => handleFormChange(form.variable, e.target.value)}
/>
)
}
{
form.type === 'paragraph' && (
<div className='relative px-3 py-2 h-[88px] bg-gray-100 rounded-lg'>
<textarea
value={value?.[form.variable] || ''}
className='block w-full h-full bg-transparent text-sm outline-none appearance-none resize-none'
placeholder={form.placeholder}
onChange={e => handleFormChange(form.variable, e.target.value)}
/>
</div>
)
}
{
form.type === 'select' && (
<PortalSelect
value={value?.[form.variable]}
items={form.options.map((option) => {
return {
name: option.label[locale === 'zh-Hans' ? 'zh-Hans' : 'en-US'],
value: option.value,
}
})}
onSelect={item => handleFormChange(form.variable, item.value as string)}
popupClassName='w-[576px] !z-[102]'
/>
)
}
</div>
))
}
</>
)
}
export default memo(FormGeneration)

View File

@ -0,0 +1,108 @@
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import useSWR from 'swr'
import produce from 'immer'
import { useContext } from 'use-context-selector'
import {
useFeatures,
useFeaturesStore,
} from '../../hooks'
import type { OnFeaturesChange } from '../../types'
import { FileSearch02 } from '@/app/components/base/icons/src/vender/solid/files'
import { Settings01 } from '@/app/components/base/icons/src/vender/line/general'
import { useModalContext } from '@/context/modal-context'
import { fetchCodeBasedExtensionList } from '@/service/common'
import I18n from '@/context/i18n'
type ModerationProps = {
onChange?: OnFeaturesChange
disabled?: boolean
}
const Moderation = ({
onChange,
disabled,
}: ModerationProps) => {
const { t } = useTranslation()
const { setShowModerationSettingModal } = useModalContext()
const { locale } = useContext(I18n)
const featuresStore = useFeaturesStore()
const moderation = useFeatures(s => s.features.moderation)
const { data: codeBasedExtensionList } = useSWR(
'/code-based-extension?module=moderation',
fetchCodeBasedExtensionList,
)
const handleOpenModerationSettingModal = () => {
if (disabled)
return
const {
features,
setFeatures,
} = featuresStore!.getState()
setShowModerationSettingModal({
payload: moderation as any,
onSaveCallback: (newModeration) => {
const newFeatures = produce(features, (draft) => {
draft.moderation = newModeration
})
setFeatures(newFeatures)
if (onChange)
onChange(newFeatures)
},
})
}
const renderInfo = () => {
let prefix = ''
let suffix = ''
if (moderation?.type === 'openai_moderation')
prefix = t('appDebug.feature.moderation.modal.provider.openai')
else if (moderation?.type === 'keywords')
prefix = t('appDebug.feature.moderation.modal.provider.keywords')
else if (moderation?.type === 'api')
prefix = t('common.apiBasedExtension.selector.title')
else
prefix = codeBasedExtensionList?.data.find(item => item.name === moderation?.type)?.label[locale] || ''
if (moderation?.config?.inputs_config?.enabled && moderation.config?.outputs_config?.enabled)
suffix = t('appDebug.feature.moderation.allEnabled')
else if (moderation?.config?.inputs_config?.enabled)
suffix = t('appDebug.feature.moderation.inputEnabled')
else if (moderation?.config?.outputs_config?.enabled)
suffix = t('appDebug.feature.moderation.outputEnabled')
return `${prefix} · ${suffix}`
}
return (
<div className='flex items-center px-3 h-12 bg-gray-50 rounded-xl overflow-hidden'>
<div className='shrink-0 flex items-center justify-center mr-1 w-6 h-6'>
<FileSearch02 className='shrink-0 w-4 h-4 text-[#039855]' />
</div>
<div className='shrink-0 mr-2 whitespace-nowrap text-sm text-gray-800 font-semibold'>
{t('appDebug.feature.moderation.title')}
</div>
<div
className='grow block w-0 text-right text-xs text-gray-500 truncate'
title={renderInfo()}>
{renderInfo()}
</div>
<div className='shrink-0 ml-4 mr-1 w-[1px] h-3.5 bg-gray-200'></div>
<div
className={`
shrink-0 flex items-center px-3 h-7 cursor-pointer rounded-md
text-xs text-gray-700 font-medium hover:bg-gray-200
${disabled && '!cursor-not-allowed'}
`}
onClick={handleOpenModerationSettingModal}
>
<Settings01 className='mr-[5px] w-3.5 h-3.5' />
{t('common.operation.settings')}
</div>
</div>
)
}
export default memo(Moderation)

View File

@ -0,0 +1,73 @@
import type { FC } from 'react'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import Switch from '@/app/components/base/switch'
import type { ModerationContentConfig } from '@/models/debug'
type ModerationContentProps = {
title: string
info?: string
showPreset?: boolean
config: ModerationContentConfig
onConfigChange: (config: ModerationContentConfig) => void
}
const ModerationContent: FC<ModerationContentProps> = ({
title,
info,
showPreset = true,
config,
onConfigChange,
}) => {
const { t } = useTranslation()
const handleConfigChange = (field: string, value: boolean | string) => {
if (field === 'preset_response' && typeof value === 'string')
value = value.slice(0, 100)
onConfigChange({ ...config, [field]: value })
}
return (
<div className='py-2'>
<div className='rounded-lg bg-gray-50 border border-gray-200'>
<div className='flex items-center justify-between px-3 h-10 rounded-lg'>
<div className='shrink-0 text-sm font-medium text-gray-900'>{title}</div>
<div className='grow flex items-center justify-end'>
{
info && (
<div className='mr-2 text-xs text-gray-500 truncate' title={info}>{info}</div>
)
}
<Switch
size='l'
defaultValue={config.enabled}
onChange={v => handleConfigChange('enabled', v)}
/>
</div>
</div>
{
config.enabled && showPreset && (
<div className='px-3 pt-1 pb-3 bg-white rounded-lg'>
<div className='flex items-center justify-between h-8 text-[13px] font-medium text-gray-700'>
{t('appDebug.feature.moderation.modal.content.preset')}
<span className='text-xs font-normal text-gray-500'>{t('appDebug.feature.moderation.modal.content.supportMarkdown')}</span>
</div>
<div className='relative px-3 py-2 h-20 rounded-lg bg-gray-100'>
<textarea
value={config.preset_response || ''}
className='block w-full h-full bg-transparent text-sm outline-none appearance-none resize-none'
placeholder={t('appDebug.feature.moderation.modal.content.placeholder') || ''}
onChange={e => handleConfigChange('preset_response', e.target.value)}
/>
<div className='absolute bottom-2 right-2 flex items-center px-1 h-5 rounded-md bg-gray-50 text-xs font-medium text-gray-300'>
<span>{(config.preset_response || '').length}</span>/<span className='text-gray-500'>100</span>
</div>
</div>
</div>
)
}
</div>
</div>
)
}
export default memo(ModerationContent)

View File

@ -0,0 +1,376 @@
import type { ChangeEvent, FC } from 'react'
import {
memo,
useState,
} from 'react'
import useSWR from 'swr'
import { useContext } from 'use-context-selector'
import { useTranslation } from 'react-i18next'
import ModerationContent from './moderation-content'
import FormGeneration from './form-generation'
import ApiBasedExtensionSelector from '@/app/components/header/account-setting/api-based-extension-page/selector'
import Modal from '@/app/components/base/modal'
import Button from '@/app/components/base/button'
import { BookOpen01 } from '@/app/components/base/icons/src/vender/line/education'
import type { ModerationConfig, ModerationContentConfig } from '@/models/debug'
import { useToastContext } from '@/app/components/base/toast'
import {
fetchCodeBasedExtensionList,
fetchModelProviders,
} from '@/service/common'
import type { CodeBasedExtensionItem } from '@/models/common'
import I18n from '@/context/i18n'
import { LanguagesSupported } from '@/i18n/language'
import { InfoCircle } from '@/app/components/base/icons/src/vender/line/general'
import { useModalContext } from '@/context/modal-context'
import { CustomConfigurationStatusEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
const systemTypes = ['openai_moderation', 'keywords', 'api']
type Provider = {
key: string
name: string
form_schema?: CodeBasedExtensionItem['form_schema']
}
type ModerationSettingModalProps = {
data: ModerationConfig
onCancel: () => void
onSave: (moderationConfig: ModerationConfig) => void
}
const ModerationSettingModal: FC<ModerationSettingModalProps> = ({
data,
onCancel,
onSave,
}) => {
const { t } = useTranslation()
const { notify } = useToastContext()
const { locale } = useContext(I18n)
const { data: modelProviders, isLoading, mutate } = useSWR('/workspaces/current/model-providers', fetchModelProviders)
const [localeData, setLocaleData] = useState<ModerationConfig>(data)
const { setShowAccountSettingModal } = useModalContext()
const handleOpenSettingsModal = () => {
setShowAccountSettingModal({
payload: 'provider',
onCancelCallback: () => {
mutate()
},
})
}
const { data: codeBasedExtensionList } = useSWR(
'/code-based-extension?module=moderation',
fetchCodeBasedExtensionList,
)
const openaiProvider = modelProviders?.data.find(item => item.provider === 'openai')
const systemOpenaiProviderEnabled = openaiProvider?.system_configuration.enabled
const systemOpenaiProviderQuota = systemOpenaiProviderEnabled ? openaiProvider?.system_configuration.quota_configurations.find(item => item.quota_type === openaiProvider.system_configuration.current_quota_type) : undefined
const systemOpenaiProviderCanUse = systemOpenaiProviderQuota?.is_valid
const customOpenaiProvidersCanUse = openaiProvider?.custom_configuration.status === CustomConfigurationStatusEnum.active
const isOpenAIProviderConfigured = customOpenaiProvidersCanUse || systemOpenaiProviderCanUse
const providers: Provider[] = [
{
key: 'openai_moderation',
name: t('appDebug.feature.moderation.modal.provider.openai'),
},
{
key: 'keywords',
name: t('appDebug.feature.moderation.modal.provider.keywords'),
},
{
key: 'api',
name: t('common.apiBasedExtension.selector.title'),
},
...(
codeBasedExtensionList
? codeBasedExtensionList.data.map((item) => {
return {
key: item.name,
name: locale === 'zh-Hans' ? item.label['zh-Hans'] : item.label['en-US'],
form_schema: item.form_schema,
}
})
: []
),
]
const currentProvider = providers.find(provider => provider.key === localeData.type)
const handleDataTypeChange = (type: string) => {
let config: undefined | Record<string, any>
const currProvider = providers.find(provider => provider.key === type)
if (systemTypes.findIndex(t => t === type) < 0 && currProvider?.form_schema) {
config = currProvider?.form_schema.reduce((prev, next) => {
prev[next.variable] = next.default
return prev
}, {} as Record<string, any>)
}
setLocaleData({
...localeData,
type,
config,
})
}
const handleDataKeywordsChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value
const arr = value.split('\n').reduce((prev: string[], next: string) => {
if (next !== '')
prev.push(next.slice(0, 100))
if (next === '' && prev[prev.length - 1] !== '')
prev.push(next)
return prev
}, [])
setLocaleData({
...localeData,
config: {
...localeData.config,
keywords: arr.slice(0, 100).join('\n'),
},
})
}
const handleDataContentChange = (contentType: string, contentConfig: ModerationContentConfig) => {
setLocaleData({
...localeData,
config: {
...localeData.config,
[contentType]: contentConfig,
},
})
}
const handleDataApiBasedChange = (apiBasedExtensionId: string) => {
setLocaleData({
...localeData,
config: {
...localeData.config,
api_based_extension_id: apiBasedExtensionId,
},
})
}
const handleDataExtraChange = (extraValue: Record<string, string>) => {
setLocaleData({
...localeData,
config: {
...localeData.config,
...extraValue,
},
})
}
const formatData = (originData: ModerationConfig) => {
const { enabled, type, config } = originData
const { inputs_config, outputs_config } = config!
const params: Record<string, string | undefined> = {}
if (type === 'keywords')
params.keywords = config?.keywords
if (type === 'api')
params.api_based_extension_id = config?.api_based_extension_id
if (systemTypes.findIndex(t => t === type) < 0 && currentProvider?.form_schema) {
currentProvider.form_schema.forEach((form) => {
params[form.variable] = config?.[form.variable]
})
}
return {
type,
enabled,
config: {
inputs_config: inputs_config || { enabled: false },
outputs_config: outputs_config || { enabled: false },
...params,
},
}
}
const handleSave = () => {
if (localeData.type === 'openai_moderation' && !isOpenAIProviderConfigured)
return
if (!localeData.config?.inputs_config?.enabled && !localeData.config?.outputs_config?.enabled) {
notify({ type: 'error', message: t('appDebug.feature.moderation.modal.content.condition') })
return
}
if (localeData.type === 'keywords' && !localeData.config.keywords) {
notify({ type: 'error', message: t('appDebug.errorMessage.valueOfVarRequired', { key: locale !== LanguagesSupported[1] ? 'keywords' : '关键词' }) })
return
}
if (localeData.type === 'api' && !localeData.config.api_based_extension_id) {
notify({ type: 'error', message: t('appDebug.errorMessage.valueOfVarRequired', { key: locale !== LanguagesSupported[1] ? 'API Extension' : 'API 扩展' }) })
return
}
if (systemTypes.findIndex(t => t === localeData.type) < 0 && currentProvider?.form_schema) {
for (let i = 0; i < currentProvider.form_schema.length; i++) {
if (!localeData.config?.[currentProvider.form_schema[i].variable] && currentProvider.form_schema[i].required) {
notify({
type: 'error',
message: t('appDebug.errorMessage.valueOfVarRequired', { key: locale !== LanguagesSupported[1] ? currentProvider.form_schema[i].label['en-US'] : currentProvider.form_schema[i].label['zh-Hans'] }),
})
return
}
}
}
if (localeData.config.inputs_config?.enabled && !localeData.config.inputs_config.preset_response && localeData.type !== 'api') {
notify({ type: 'error', message: t('appDebug.feature.moderation.modal.content.errorMessage') })
return
}
if (localeData.config.outputs_config?.enabled && !localeData.config.outputs_config.preset_response && localeData.type !== 'api') {
notify({ type: 'error', message: t('appDebug.feature.moderation.modal.content.errorMessage') })
return
}
onSave(formatData(localeData))
}
return (
<Modal
isShow
onClose={() => { }}
className='!p-8 !pb-6 !mt-14 !max-w-none !w-[640px]'
>
<div className='mb-2 text-xl font-semibold text-[#1D2939]'>
{t('appDebug.feature.moderation.modal.title')}
</div>
<div className='py-2'>
<div className='leading-9 text-sm font-medium text-gray-900'>
{t('appDebug.feature.moderation.modal.provider.title')}
</div>
<div className='grid gap-2.5 grid-cols-3'>
{
providers.map(provider => (
<div
key={provider.key}
className={`
flex items-center px-3 py-2 rounded-lg text-sm text-gray-900 cursor-pointer
${localeData.type === provider.key ? 'bg-white border-[1.5px] border-primary-400 shadow-sm' : 'border border-gray-100 bg-gray-25'}
${localeData.type === 'openai_moderation' && provider.key === 'openai_moderation' && !isOpenAIProviderConfigured && 'opacity-50'}
`}
onClick={() => handleDataTypeChange(provider.key)}
>
<div className={`
mr-2 w-4 h-4 rounded-full border
${localeData.type === provider.key ? 'border-[5px] border-primary-600' : 'border border-gray-300'}`} />
{provider.name}
</div>
))
}
</div>
{
!isLoading && !isOpenAIProviderConfigured && localeData.type === 'openai_moderation' && (
<div className='flex items-center mt-2 px-3 py-2 bg-[#FFFAEB] rounded-lg border border-[#FEF0C7]'>
<InfoCircle className='mr-1 w-4 h-4 text-[#F79009]' />
<div className='flex items-center text-xs font-medium text-gray-700'>
{t('appDebug.feature.moderation.modal.openaiNotConfig.before')}
<span
className='text-primary-600 cursor-pointer'
onClick={handleOpenSettingsModal}
>
&nbsp;{t('common.settings.provider')}&nbsp;
</span>
{t('appDebug.feature.moderation.modal.openaiNotConfig.after')}
</div>
</div>
)
}
</div>
{
localeData.type === 'keywords' && (
<div className='py-2'>
<div className='mb-1 text-sm font-medium text-gray-900'>{t('appDebug.feature.moderation.modal.provider.keywords')}</div>
<div className='mb-2 text-xs text-gray-500'>{t('appDebug.feature.moderation.modal.keywords.tip')}</div>
<div className='relative px-3 py-2 h-[88px] bg-gray-100 rounded-lg'>
<textarea
value={localeData.config?.keywords || ''}
onChange={handleDataKeywordsChange}
className='block w-full h-full bg-transparent text-sm outline-none appearance-none resize-none'
placeholder={t('appDebug.feature.moderation.modal.keywords.placeholder') || ''}
/>
<div className='absolute bottom-2 right-2 flex items-center px-1 h-5 rounded-md bg-gray-50 text-xs font-medium text-gray-300'>
<span>{(localeData.config?.keywords || '').split('\n').filter(Boolean).length}</span>/<span className='text-gray-500'>100 {t('appDebug.feature.moderation.modal.keywords.line')}</span>
</div>
</div>
</div>
)
}
{
localeData.type === 'api' && (
<div className='py-2'>
<div className='flex items-center justify-between h-9'>
<div className='text-sm font-medium text-gray-900'>{t('common.apiBasedExtension.selector.title')}</div>
<a
href={t('common.apiBasedExtension.linkUrl') || '/'}
target='_blank' rel='noopener noreferrer'
className='group flex items-center text-xs text-gray-500 hover:text-primary-600'
>
<BookOpen01 className='mr-1 w-3 h-3 text-gray-500 group-hover:text-primary-600' />
{t('common.apiBasedExtension.link')}
</a>
</div>
<ApiBasedExtensionSelector
value={localeData.config?.api_based_extension_id || ''}
onChange={handleDataApiBasedChange}
/>
</div>
)
}
{
systemTypes.findIndex(t => t === localeData.type) < 0
&& currentProvider?.form_schema
&& (
<FormGeneration
forms={currentProvider?.form_schema}
value={localeData.config}
onChange={handleDataExtraChange}
/>
)
}
<div className='my-3 h-[1px] bg-gradient-to-r from-[#F3F4F6]'></div>
<ModerationContent
title={t('appDebug.feature.moderation.modal.content.input') || ''}
config={localeData.config?.inputs_config || { enabled: false, preset_response: '' }}
onConfigChange={config => handleDataContentChange('inputs_config', config)}
info={(localeData.type === 'api' && t('appDebug.feature.moderation.modal.content.fromApi')) || ''}
showPreset={!(localeData.type === 'api')}
/>
<ModerationContent
title={t('appDebug.feature.moderation.modal.content.output') || ''}
config={localeData.config?.outputs_config || { enabled: false, preset_response: '' }}
onConfigChange={config => handleDataContentChange('outputs_config', config)}
info={(localeData.type === 'api' && t('appDebug.feature.moderation.modal.content.fromApi')) || ''}
showPreset={!(localeData.type === 'api')}
/>
<div className='mt-1 mb-8 text-xs font-medium text-gray-500'>{t('appDebug.feature.moderation.modal.content.condition')}</div>
<div className='flex items-center justify-end'>
<Button
onClick={onCancel}
className='mr-2'
>
{t('common.operation.cancel')}
</Button>
<Button
variant='primary'
onClick={handleSave}
disabled={localeData.type === 'openai_moderation' && !isOpenAIProviderConfigured}
>
{t('common.operation.save')}
</Button>
</div>
</Modal>
)
}
export default memo(ModerationSettingModal)

View File

@ -0,0 +1,327 @@
'use client'
import type { FC } from 'react'
import React, { useEffect, useRef, useState } from 'react'
import produce from 'immer'
import {
RiAddLine,
RiDeleteBinLine,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useBoolean } from 'ahooks'
import { ReactSortable } from 'react-sortablejs'
import {
useFeatures,
useFeaturesStore,
} from '../../hooks'
import type { OnFeaturesChange } from '../../types'
import cn from '@/utils/classnames'
import Panel from '@/app/components/app/configuration/base/feature-panel'
import Button from '@/app/components/base/button'
import OperationBtn from '@/app/components/app/configuration/base/operation-btn'
import { getInputKeys } from '@/app/components/base/block-input'
import ConfirmAddVar from '@/app/components/app/configuration/config-prompt/confirm-add-var'
import { getNewVar } from '@/utils/var'
import { varHighlightHTML } from '@/app/components/app/configuration/base/var-highlight'
import type { PromptVariable } from '@/models/debug'
import type { InputVar } from '@/app/components/workflow/types'
const MAX_QUESTION_NUM = 5
export type OpeningStatementProps = {
onChange?: OnFeaturesChange
readonly?: boolean
promptVariables?: PromptVariable[]
onAutoAddPromptVariable: (variable: PromptVariable[]) => void
workflowVariables?: InputVar[]
}
// regex to match the {{}} and replace it with a span
const regex = /\{\{([^}]+)\}\}/g
const OpeningStatement: FC<OpeningStatementProps> = ({
onChange,
readonly,
promptVariables = [],
onAutoAddPromptVariable,
workflowVariables = [],
}) => {
const { t } = useTranslation()
const featureStore = useFeaturesStore()
const openingStatement = useFeatures(s => s.features.opening)
const value = openingStatement?.opening_statement || ''
const suggestedQuestions = openingStatement?.suggested_questions || []
const [notIncludeKeys, setNotIncludeKeys] = useState<string[]>([])
const hasValue = !!(value || '').trim()
const inputRef = useRef<HTMLTextAreaElement>(null)
const [isFocus, { setTrue: didSetFocus, setFalse: setBlur }] = useBoolean(false)
const setFocus = () => {
didSetFocus()
setTimeout(() => {
const input = inputRef.current
if (input) {
input.focus()
input.setSelectionRange(input.value.length, input.value.length)
}
}, 0)
}
const [tempValue, setTempValue] = useState(value)
useEffect(() => {
setTempValue(value || '')
}, [value])
const [tempSuggestedQuestions, setTempSuggestedQuestions] = useState(suggestedQuestions || [])
const notEmptyQuestions = tempSuggestedQuestions.filter(question => !!question && question.trim())
const coloredContent = (tempValue || '')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(regex, varHighlightHTML({ name: '$1' })) // `<span class="${highLightClassName}">{{$1}}</span>`
.replace(/\n/g, '<br />')
const handleEdit = () => {
if (readonly)
return
setFocus()
}
const [isShowConfirmAddVar, { setTrue: showConfirmAddVar, setFalse: hideConfirmAddVar }] = useBoolean(false)
const handleCancel = () => {
setBlur()
setTempValue(value)
setTempSuggestedQuestions(suggestedQuestions)
}
const handleConfirm = () => {
const keys = getInputKeys(tempValue)
const promptKeys = promptVariables.map(item => item.key)
const workflowVariableKeys = workflowVariables.map(item => item.variable)
let notIncludeKeys: string[] = []
if (promptKeys.length === 0 && workflowVariables.length === 0) {
if (keys.length > 0)
notIncludeKeys = keys
}
else {
if (workflowVariables.length > 0)
notIncludeKeys = keys.filter(key => !workflowVariableKeys.includes(key))
else notIncludeKeys = keys.filter(key => !promptKeys.includes(key))
}
if (notIncludeKeys.length > 0) {
setNotIncludeKeys(notIncludeKeys)
showConfirmAddVar()
return
}
setBlur()
const { getState } = featureStore!
const {
features,
setFeatures,
} = getState()
const newFeatures = produce(features, (draft) => {
if (draft.opening) {
draft.opening.opening_statement = tempValue
draft.opening.suggested_questions = tempSuggestedQuestions
}
})
setFeatures(newFeatures)
if (onChange)
onChange(newFeatures)
}
const cancelAutoAddVar = () => {
const { getState } = featureStore!
const {
features,
setFeatures,
} = getState()
const newFeatures = produce(features, (draft) => {
if (draft.opening)
draft.opening.opening_statement = tempValue
})
setFeatures(newFeatures)
if (onChange)
onChange(newFeatures)
hideConfirmAddVar()
setBlur()
}
const autoAddVar = () => {
const { getState } = featureStore!
const {
features,
setFeatures,
} = getState()
const newFeatures = produce(features, (draft) => {
if (draft.opening)
draft.opening.opening_statement = tempValue
})
setFeatures(newFeatures)
if (onChange)
onChange(newFeatures)
onAutoAddPromptVariable([...notIncludeKeys.map(key => getNewVar(key, 'string'))])
hideConfirmAddVar()
setBlur()
}
const headerRight = !readonly ? (
isFocus ? (
<div className='flex items-center space-x-1'>
<Button
variant='ghost'
size='small'
onClick={handleCancel}
>
{t('common.operation.cancel')}
</Button>
<Button size='small' onClick={handleConfirm} variant="primary">{t('common.operation.save')}</Button>
</div>
) : (
<OperationBtn type='edit' actionName={hasValue ? '' : t('appDebug.openingStatement.writeOpener') as string} onClick={handleEdit} />
)
) : null
const renderQuestions = () => {
return isFocus ? (
<div>
<div className='flex items-center py-2'>
<div className='shrink-0 flex space-x-0.5 leading-[18px] text-xs font-medium text-gray-500'>
<div className='uppercase'>{t('appDebug.openingStatement.openingQuestion')}</div>
<div>·</div>
<div>{tempSuggestedQuestions.length}/{MAX_QUESTION_NUM}</div>
</div>
<div className='ml-3 grow w-0 h-px bg-[#243, 244, 246]'></div>
</div>
<ReactSortable
className="space-y-1"
list={tempSuggestedQuestions.map((name, index) => {
return {
id: index,
name,
}
})}
setList={list => setTempSuggestedQuestions(list.map(item => item.name))}
handle='.handle'
ghostClass="opacity-50"
animation={150}
>
{tempSuggestedQuestions.map((question, index) => {
return (
<div className='group relative rounded-lg border border-gray-200 flex items-center pl-2.5 hover:border-gray-300 hover:bg-white' key={index}>
<div className='handle flex items-center justify-center w-4 h-4 cursor-grab'>
<svg width="6" height="10" viewBox="0 0 6 10" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fillRule="evenodd" clipRule="evenodd" d="M1 2C1.55228 2 2 1.55228 2 1C2 0.447715 1.55228 0 1 0C0.447715 0 0 0.447715 0 1C0 1.55228 0.447715 2 1 2ZM1 6C1.55228 6 2 5.55228 2 5C2 4.44772 1.55228 4 1 4C0.447715 4 0 4.44772 0 5C0 5.55228 0.447715 6 1 6ZM6 1C6 1.55228 5.55228 2 5 2C4.44772 2 4 1.55228 4 1C4 0.447715 4.44772 0 5 0C5.55228 0 6 0.447715 6 1ZM5 6C5.55228 6 6 5.55228 6 5C6 4.44772 5.55228 4 5 4C4.44772 4 4 4.44772 4 5C4 5.55228 4.44772 6 5 6ZM2 9C2 9.55229 1.55228 10 1 10C0.447715 10 0 9.55229 0 9C0 8.44771 0.447715 8 1 8C1.55228 8 2 8.44771 2 9ZM5 10C5.55228 10 6 9.55229 6 9C6 8.44771 5.55228 8 5 8C4.44772 8 4 8.44771 4 9C4 9.55229 4.44772 10 5 10Z" fill="#98A2B3" />
</svg>
</div>
<input
type="input"
value={question || ''}
onChange={(e) => {
const value = e.target.value
setTempSuggestedQuestions(tempSuggestedQuestions.map((item, i) => {
if (index === i)
return value
return item
}))
}}
className={'w-full overflow-x-auto pl-1.5 pr-8 text-sm leading-9 text-gray-900 border-0 grow h-9 bg-transparent focus:outline-none cursor-pointer rounded-lg'}
/>
<div
className='block absolute top-1/2 translate-y-[-50%] right-1.5 p-1 rounded-md cursor-pointer hover:bg-[#FEE4E2] hover:text-[#D92D20]'
onClick={() => {
setTempSuggestedQuestions(tempSuggestedQuestions.filter((_, i) => index !== i))
}}
>
<RiDeleteBinLine className='w-3.5 h-3.5' />
</div>
</div>
)
})}</ReactSortable>
{tempSuggestedQuestions.length < MAX_QUESTION_NUM && (
<div
onClick={() => { setTempSuggestedQuestions([...tempSuggestedQuestions, '']) }}
className='mt-1 flex items-center h-9 px-3 gap-2 rounded-lg cursor-pointer text-gray-400 bg-gray-100 hover:bg-gray-200'>
<RiAddLine className='w-4 h-4' />
<div className='text-gray-500 text-[13px]'>{t('appDebug.variableConfig.addOption')}</div>
</div>
)}
</div>
) : (
<div className='mt-1.5 flex flex-wrap'>
{notEmptyQuestions.map((question, index) => {
return (
<div key={index} className='mt-1 mr-1 max-w-full truncate last:mr-0 shrink-0 leading-8 items-center px-2.5 rounded-lg border border-gray-200 shadow-xs bg-white text-[13px] font-normal text-gray-900 cursor-pointer'>
{question}
</div>
)
})}
</div>
)
}
return (
<Panel
className={cn(isShowConfirmAddVar && 'h-[220px]', 'relative !bg-gray-25')}
title={t('appDebug.openingStatement.title')}
headerIcon={
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fillRule="evenodd" clipRule="evenodd" d="M8.33353 1.33301C4.83572 1.33301 2.00019 4.16854 2.00019 7.66634C2.00019 8.37301 2.11619 9.05395 2.3307 9.69036C2.36843 9.80229 2.39063 9.86853 2.40507 9.91738L2.40979 9.93383L2.40729 9.93903C2.39015 9.97437 2.36469 10.0218 2.31705 10.11L1.2158 12.1484C1.14755 12.2746 1.07633 12.4064 1.02735 12.5209C0.978668 12.6348 0.899813 12.8437 0.938613 13.0914C0.984094 13.3817 1.15495 13.6373 1.40581 13.7903C1.61981 13.9208 1.843 13.9279 1.96683 13.9264C2.09141 13.925 2.24036 13.9095 2.38314 13.8947L5.81978 13.5395C5.87482 13.5338 5.9036 13.5309 5.92468 13.5292L5.92739 13.529L5.93564 13.532C5.96154 13.5413 5.99666 13.5548 6.0573 13.5781C6.76459 13.8506 7.53244 13.9997 8.33353 13.9997C11.8313 13.9997 14.6669 11.1641 14.6669 7.66634C14.6669 4.16854 11.8313 1.33301 8.33353 1.33301ZM5.9799 5.72116C6.73142 5.08698 7.73164 5.27327 8.33144 5.96584C8.93125 5.27327 9.91854 5.09365 10.683 5.72116C11.4474 6.34867 11.5403 7.41567 10.9501 8.16572C10.5845 8.6304 9.6668 9.47911 9.02142 10.0576C8.78435 10.2702 8.66582 10.3764 8.52357 10.4192C8.40154 10.456 8.26134 10.456 8.13931 10.4192C7.99706 10.3764 7.87853 10.2702 7.64147 10.0576C6.99609 9.47911 6.07839 8.6304 5.71276 8.16572C5.12259 7.41567 5.22839 6.35534 5.9799 5.72116Z" fill="#E74694" />
</svg>
}
headerRight={headerRight}
hasHeaderBottomBorder={!hasValue}
isFocus={isFocus}
>
<div className='text-gray-700 text-sm'>
{(hasValue || (!hasValue && isFocus)) ? (
<>
{isFocus
? (
<div>
<textarea
ref={inputRef}
value={tempValue}
rows={3}
onChange={e => setTempValue(e.target.value)}
className="w-full px-0 text-sm border-0 bg-transparent focus:outline-none "
placeholder={t('appDebug.openingStatement.placeholder') as string}
>
</textarea>
</div>
)
: (
<div dangerouslySetInnerHTML={{
__html: coloredContent,
}}></div>
)}
{renderQuestions()}
</>) : (
<div className='pt-2 pb-1 text-xs text-gray-500'>{t('appDebug.openingStatement.noDataPlaceHolder')}</div>
)}
{isShowConfirmAddVar && (
<ConfirmAddVar
varNameArr={notIncludeKeys}
onConfirm={autoAddVar}
onCancel={cancelAutoAddVar}
onHide={hideConfirmAddVar}
/>
)}
</div>
</Panel>
)
}
export default React.memo(OpeningStatement)

View File

@ -0,0 +1,38 @@
import ReactSlider from 'react-slider'
import s from './style.module.css'
import cn from '@/utils/classnames'
type ISliderProps = {
className?: string
value: number
max?: number
min?: number
step?: number
disabled?: boolean
onChange: (value: number) => void
}
const Slider: React.FC<ISliderProps> = ({ className, max, min, step, value, disabled, onChange }) => {
return <ReactSlider
disabled={disabled}
value={isNaN(value) ? 0 : value}
min={min || 0}
max={max || 100}
step={step || 1}
className={cn(className, s.slider)}
thumbClassName={cn(s['slider-thumb'], 'top-[-7px] w-2 h-[18px] bg-white border !border-black/8 rounded-[36px] shadow-md cursor-pointer')}
trackClassName={s['slider-track']}
onChange={onChange}
renderThumb={(props, state) => (
<div {...props}>
<div className='relative w-full h-full'>
<div className='absolute top-[-16px] left-[50%] translate-x-[-50%] leading-[18px] text-xs font-medium text-gray-900'>
{(state.valueNow / 100).toFixed(2)}
</div>
</div>
</div>
)}
/>
}
export default Slider

View File

@ -0,0 +1,20 @@
.slider {
position: relative;
}
.slider.disabled {
opacity: 0.6;
}
.slider-thumb:focus {
outline: none;
}
.slider-track {
background-color: #528BFF;
height: 2px;
}
.slider-track-1 {
background-color: #E5E7EB;
}

View File

@ -0,0 +1,46 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import Slider from '@/app/components/app/configuration/toolbox/score-slider/base-slider'
type Props = {
className?: string
value: number
onChange: (value: number) => void
}
const ScoreSlider: FC<Props> = ({
className,
value,
onChange,
}) => {
const { t } = useTranslation()
return (
<div className={className}>
<div className='h-[1px] mt-[14px]'>
<Slider
max={100}
min={80}
step={1}
value={value}
onChange={onChange}
/>
</div>
<div className='mt-[10px] flex justify-between items-center leading-4 text-xs font-normal '>
<div className='flex space-x-1 text-[#00A286]'>
<div>0.8</div>
<div>·</div>
<div>{t('appDebug.feature.annotation.scoreThreshold.easyMatch')}</div>
</div>
<div className='flex space-x-1 text-[#0057D8]'>
<div>1.0</div>
<div>·</div>
<div>{t('appDebug.feature.annotation.scoreThreshold.accurateMatch')}</div>
</div>
</div>
</div>
)
}
export default React.memo(ScoreSlider)

View File

@ -0,0 +1,22 @@
'use client'
import React, { type FC } from 'react'
import { useTranslation } from 'react-i18next'
import { Microphone01 } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices'
const SpeechToTextConfig: FC = () => {
const { t } = useTranslation()
return (
<div className='flex items-center px-3 h-12 bg-gray-50 rounded-xl overflow-hidden'>
<div className='shrink-0 flex items-center justify-center mr-1 w-6 h-6'>
<Microphone01 className='w-4 h-4 text-[#7839EE]' />
</div>
<div className='shrink-0 mr-2 flex items-center whitespace-nowrap text-sm text-gray-800 font-semibold'>
<div>{t('appDebug.feature.speechToText.title')}</div>
</div>
<div className='grow'></div>
<div className='text-xs text-gray-500'>{t('appDebug.feature.speechToText.resDes')}</div>
</div>
)
}
export default React.memo(SpeechToTextConfig)

View File

@ -0,0 +1,25 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { MessageSmileSquare } from '@/app/components/base/icons/src/vender/solid/communication'
import Tooltip from '@/app/components/base/tooltip'
const SuggestedQuestionsAfterAnswer: FC = () => {
const { t } = useTranslation()
return (
<div className='flex items-center px-3 h-12 bg-gray-50 rounded-xl overflow-hidden'>
<div className='shrink-0 flex items-center justify-center mr-1 w-6 h-6'>
<MessageSmileSquare className='w-4 h-4 text-[#06AED4]' />
</div>
<div className='shrink-0 mr-2 flex items-center whitespace-nowrap text-sm text-gray-800 font-semibold'>
<div className='mr-2'>{t('appDebug.feature.suggestedQuestionsAfterAnswer.title')}</div>
<Tooltip popupContent={t('appDebug.feature.suggestedQuestionsAfterAnswer.description')}/>
</div>
<div className='grow'></div>
<div className='text-xs text-gray-500'>{t('appDebug.feature.suggestedQuestionsAfterAnswer.resDes')}</div>
</div>
)
}
export default React.memo(SuggestedQuestionsAfterAnswer)

View File

@ -0,0 +1,62 @@
'use client'
import useSWR from 'swr'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { usePathname } from 'next/navigation'
import { useFeatures } from '../../hooks'
import type { OnFeaturesChange } from '../../types'
import ParamsConfig from './params-config'
import { Speaker } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices'
import { languages } from '@/i18n/language'
import { fetchAppVoices } from '@/service/apps'
import AudioBtn from '@/app/components/base/audio-btn'
type TextToSpeechProps = {
onChange?: OnFeaturesChange
disabled?: boolean
}
const TextToSpeech = ({
onChange,
disabled,
}: TextToSpeechProps) => {
const { t } = useTranslation()
const textToSpeech = useFeatures(s => s.features.text2speech)
const pathname = usePathname()
const matched = pathname.match(/\/app\/([^/]+)/)
const appId = (matched?.length && matched[1]) ? matched[1] : ''
const language = textToSpeech?.language
const languageInfo = languages.find(i => i.value === textToSpeech?.language)
const voiceItems = useSWR({ appId, language }, fetchAppVoices).data
const voiceItem = voiceItems?.find(item => item.value === textToSpeech?.voice)
return (
<div className='flex items-center px-3 h-12 bg-gray-50 rounded-xl overflow-hidden'>
<div className='shrink-0 flex items-center justify-center mr-1 w-6 h-6'>
<Speaker className='w-4 h-4 text-[#7839EE]' />
</div>
<div className='shrink-0 mr-2 whitespace-nowrap text-sm text-gray-800 font-semibold'>
{t('appDebug.feature.textToSpeech.title')}
</div>
<div
className='grow '>
</div>
<div className='shrink-0 text-xs text-gray-500 inline-flex items-center gap-2'>
{languageInfo && (`${languageInfo?.name} - `)}{voiceItem?.name ?? t('appDebug.voice.defaultDisplay')}
{ languageInfo?.example && (
<AudioBtn
value={languageInfo?.example}
voice={voiceItem?.value}
noCache={false}
isAudition={true}
/>
)}
</div>
<div className='shrink-0 flex items-center'>
<ParamsConfig onChange={onChange} disabled={disabled} />
</div>
</div>
)
}
export default React.memo(TextToSpeech)

View File

@ -0,0 +1,241 @@
'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 { Listbox, Transition } from '@headlessui/react'
import { CheckIcon, ChevronDownIcon } from '@heroicons/react/20/solid'
import {
useFeatures,
useFeaturesStore,
} from '../../hooks'
import type { OnFeaturesChange } from '../../types'
import classNames from '@/utils/classnames'
import type { Item } from '@/app/components/base/select'
import { fetchAppVoices } from '@/service/apps'
import Tooltip from '@/app/components/base/tooltip'
import { languages } from '@/i18n/language'
import RadioGroup from '@/app/components/app/configuration/config-vision/radio-group'
import { TtsAutoPlay } from '@/types/app'
type VoiceParamConfigProps = {
onChange?: OnFeaturesChange
}
const VoiceParamConfig = ({
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<string, string>) => {
const {
features,
setFeatures,
} = featuresStore!.getState()
const newFeatures = produce(features, (draft) => {
draft.text2speech = {
...draft.text2speech,
...value,
}
})
setFeatures(newFeatures)
if (onChange)
onChange(newFeatures)
}
return (
<div>
<div>
<div className='leading-6 text-base font-semibold text-gray-800'>{t('appDebug.voice.voiceSettings.title')}</div>
<div className='pt-3 space-y-6'>
<div>
<div className='mb-2 flex items-center space-x-1'>
<div
className='leading-[18px] text-[13px] font-semibold text-gray-800'>{t('appDebug.voice.voiceSettings.language')}</div>
<Tooltip
popupContent={
<div className='w-[180px]'>
{t('appDebug.voice.voiceSettings.resolutionTooltip').split('\n').map(item => (
<div key={item}>{item}
</div>
))}
</div>
}
/>
</div>
<Listbox
value={languageItem}
onChange={(value: Item) => {
handleChange({
language: String(value.value),
})
}}
>
<div className={'relative h-9'}>
<Listbox.Button
className={'w-full h-full rounded-lg border-0 bg-gray-100 py-1.5 pl-3 pr-10 sm:text-sm sm:leading-6 focus-visible:outline-none focus-visible:bg-gray-200 group-hover:bg-gray-200 cursor-pointer'}>
<span className={classNames('block truncate text-left', !languageItem?.name && 'text-gray-400')}>
{languageItem?.name ? t(`common.voice.language.${languageItem?.value.replace('-', '')}`) : localLanguagePlaceholder}
</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronDownIcon
className="h-5 w-5 text-gray-400"
aria-hidden="true"
/>
</span>
</Listbox.Button>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options
className="absolute z-10 mt-1 px-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg border-gray-200 border-[0.5px] focus:outline-none sm:text-sm">
{languages.map((item: Item) => (
<Listbox.Option
key={item.value}
className={({ active }) =>
`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 }) => (
<>
<span
className={classNames('block', selected && 'font-normal')}>{t(`common.voice.language.${(item.value).toString().replace('-', '')}`)}</span>
{(selected || item.value === text2speech?.language) && (
<span
className={classNames(
'absolute inset-y-0 right-0 flex items-center pr-4 text-gray-700',
)}
>
<CheckIcon className="h-5 w-5" aria-hidden="true"/>
</span>
)}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</Listbox>
</div>
<div>
<div
className='mb-2 leading-[18px] text-[13px] font-semibold text-gray-800'>{t('appDebug.voice.voiceSettings.voice')}</div>
<Listbox
value={voiceItem ?? {}}
disabled={!languageItem}
onChange={(value: Item) => {
handleChange({
voice: String(value.value),
})
}}
>
<div className={'relative h-9'}>
<Listbox.Button
className={'w-full h-full rounded-lg border-0 bg-gray-100 py-1.5 pl-3 pr-10 sm:text-sm sm:leading-6 focus-visible:outline-none focus-visible:bg-gray-200 group-hover:bg-gray-200 cursor-pointer'}>
<span
className={classNames('block truncate text-left', !voiceItem?.name && 'text-gray-400')}>{voiceItem?.name ?? localVoicePlaceholder}</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronDownIcon
className="h-5 w-5 text-gray-400"
aria-hidden="true"
/>
</span>
</Listbox.Button>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options
className="absolute z-10 mt-1 px-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg border-gray-200 border-[0.5px] focus:outline-none sm:text-sm">
{voiceItems?.map((item: Item) => (
<Listbox.Option
key={item.value}
className={({ active }) =>
`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 }) => (
<>
<span className={classNames('block', selected && 'font-normal')}>{item.name}</span>
{(selected || item.value === text2speech?.voice) && (
<span
className={classNames(
'absolute inset-y-0 right-0 flex items-center pr-4 text-gray-700',
)}
>
<CheckIcon className="h-5 w-5" aria-hidden="true"/>
</span>
)}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</Listbox>
</div>
<div>
<div
className='mb-2 leading-[18px] text-[13px] font-semibold text-gray-800'>{t('appDebug.voice.voiceSettings.autoPlay')}</div>
<RadioGroup
className='space-x-3'
options={[
{
label: t('appDebug.voice.voiceSettings.autoPlayEnabled'),
value: TtsAutoPlay.enabled,
},
{
label: t('appDebug.voice.voiceSettings.autoPlayDisabled'),
value: TtsAutoPlay.disabled,
},
]}
value={text2speech?.autoPlay ? text2speech?.autoPlay : TtsAutoPlay.disabled}
onChange={(value: TtsAutoPlay) => {
handleChange({
autoPlay: value,
})
}}
/>
</div>
</div>
</div>
</div>
)
}
export default React.memo(VoiceParamConfig)

View File

@ -0,0 +1,48 @@
'use client'
import { memo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { OnFeaturesChange } from '../../types'
import ParamConfigContent from './param-config-content'
import cn from '@/utils/classnames'
import { Settings01 } from '@/app/components/base/icons/src/vender/line/general'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
type ParamsConfigProps = {
onChange?: OnFeaturesChange
disabled?: boolean
}
const ParamsConfig = ({
onChange,
disabled,
}: ParamsConfigProps) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='bottom-end'
offset={{
mainAxis: 4,
}}
>
<PortalToFollowElemTrigger onClick={() => !disabled && setOpen(v => !v)}>
<div className={cn('flex items-center rounded-md h-7 px-3 space-x-1 text-gray-700 cursor-pointer hover:bg-gray-200', open && 'bg-gray-200')}>
<Settings01 className='w-3.5 h-3.5 ' />
<div className='ml-1 leading-[18px] text-xs font-medium '>{t('appDebug.voice.settings')}</div>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent style={{ zIndex: 50 }}>
<div className='w-80 sm:w-[412px] p-4 bg-white rounded-lg border-[0.5px] border-gray-200 shadow-lg space-y-3'>
<ParamConfigContent onChange={onChange} />
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default memo(ParamsConfig)