Feat/attachments (#9526)
Co-authored-by: Joel <iamjoel007@gmail.com> Co-authored-by: JzoNg <jzongcode@gmail.com>
|
|
@ -14,8 +14,8 @@ const AddButton: FC<Props> = ({
|
|||
onClick,
|
||||
}) => {
|
||||
return (
|
||||
<div className={cn(className, 'p-1 rounded-md cursor-pointer hover:bg-gray-200 select-none')} onClick={onClick}>
|
||||
<RiAddLine className='w-4 h-4 text-gray-500' />
|
||||
<div className={cn(className, 'p-1 rounded-md cursor-pointer hover:bg-state-base-hover select-none')} onClick={onClick}>
|
||||
<RiAddLine className='w-4 h-4 text-text-tertiary' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ import {
|
|||
getUrl,
|
||||
stopChatMessageResponding,
|
||||
} from '@/service/share'
|
||||
import AnswerIcon from '@/app/components/base/answer-icon'
|
||||
|
||||
const ChatWrapper = () => {
|
||||
const {
|
||||
|
|
@ -56,7 +55,7 @@ const ChatWrapper = () => {
|
|||
appConfig,
|
||||
{
|
||||
inputs: (currentConversationId ? currentConversationItem?.inputs : newConversationInputs) as any,
|
||||
promptVariables: inputsForms,
|
||||
inputsForm: inputsForms,
|
||||
},
|
||||
appPrevChatList,
|
||||
taskId => stopChatMessageResponding('', taskId, isInstalledApp, appId),
|
||||
|
|
@ -65,19 +64,18 @@ const ChatWrapper = () => {
|
|||
useEffect(() => {
|
||||
if (currentChatInstanceRef.current)
|
||||
currentChatInstanceRef.current.handleStop = handleStop
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const doSend: OnSend = useCallback((message, files, last_answer) => {
|
||||
const data: any = {
|
||||
query: message,
|
||||
files,
|
||||
inputs: currentConversationId ? currentConversationItem?.inputs : newConversationInputs,
|
||||
conversation_id: currentConversationId,
|
||||
parent_message_id: last_answer?.id || getLastAnswer(chatListRef.current)?.id || null,
|
||||
}
|
||||
|
||||
if (appConfig?.file_upload?.image.enabled && files?.length)
|
||||
data.files = files
|
||||
|
||||
handleSend(
|
||||
getUrl('chat-messages', isInstalledApp, appId || ''),
|
||||
data,
|
||||
|
|
@ -152,35 +150,31 @@ const ChatWrapper = () => {
|
|||
isMobile,
|
||||
])
|
||||
|
||||
const answerIcon = (appData?.site && appData.site.use_icon_as_answer_icon)
|
||||
? <AnswerIcon
|
||||
iconType={appData.site.icon_type}
|
||||
icon={appData.site.icon}
|
||||
background={appData.site.icon_background}
|
||||
imageUrl={appData.site.icon_url}
|
||||
/>
|
||||
: null
|
||||
|
||||
return (
|
||||
<Chat
|
||||
appData={appData}
|
||||
config={appConfig}
|
||||
chatList={chatList}
|
||||
isResponding={isResponding}
|
||||
chatContainerInnerClassName={`mx-auto pt-6 w-full max-w-full ${isMobile && 'px-4'}`}
|
||||
chatFooterClassName='pb-4'
|
||||
chatFooterInnerClassName={`mx-auto w-full max-w-full ${isMobile && 'px-4'}`}
|
||||
onSend={doSend}
|
||||
onRegenerate={doRegenerate}
|
||||
onStopResponding={handleStop}
|
||||
chatNode={chatNode}
|
||||
allToolIcons={appMeta?.tool_icons || {}}
|
||||
onFeedback={handleFeedback}
|
||||
suggestedQuestions={suggestedQuestions}
|
||||
answerIcon={answerIcon}
|
||||
hideProcessDetail
|
||||
themeBuilder={themeBuilder}
|
||||
/>
|
||||
<div
|
||||
className='h-full bg-chatbot-bg overflow-hidden'
|
||||
>
|
||||
<Chat
|
||||
appData={appData}
|
||||
config={appConfig}
|
||||
chatList={chatList}
|
||||
isResponding={isResponding}
|
||||
chatContainerInnerClassName={`mx-auto pt-6 w-full max-w-[720px] ${isMobile && 'px-4'}`}
|
||||
chatFooterClassName='pb-4'
|
||||
chatFooterInnerClassName={`mx-auto w-full max-w-[720px] ${isMobile && 'px-4'}`}
|
||||
onSend={doSend}
|
||||
inputs={currentConversationId ? currentConversationItem?.inputs as any : newConversationInputs}
|
||||
inputsForm={inputsForms}
|
||||
onRegenerate={doRegenerate}
|
||||
onStopResponding={handleStop}
|
||||
chatNode={chatNode}
|
||||
allToolIcons={appMeta?.tool_icons || {}}
|
||||
onFeedback={handleFeedback}
|
||||
suggestedQuestions={suggestedQuestions}
|
||||
hideProcessDetail
|
||||
themeBuilder={themeBuilder}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { memo } from 'react'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
|
||||
type InputProps = {
|
||||
form: any
|
||||
|
|
@ -23,9 +24,9 @@ const FormInput: FC<InputProps> = ({
|
|||
|
||||
if (type === 'paragraph') {
|
||||
return (
|
||||
<textarea
|
||||
<Textarea
|
||||
value={value}
|
||||
className='grow h-[104px] rounded-lg bg-gray-100 px-2.5 py-2 outline-none appearance-none resize-none'
|
||||
className='resize-none'
|
||||
onChange={e => onChange(variable, e.target.value)}
|
||||
placeholder={`${label}${!required ? `(${t('appDebug.variableTable.optional')})` : ''}`}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,24 +1,26 @@
|
|||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useChatWithHistoryContext } from '../context'
|
||||
import Input from './form-input'
|
||||
import { PortalSelect } from '@/app/components/base/select'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
|
||||
|
||||
const Form = () => {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
inputsForms,
|
||||
newConversationInputs,
|
||||
newConversationInputsRef,
|
||||
handleNewConversationInputsChange,
|
||||
isMobile,
|
||||
} = useChatWithHistoryContext()
|
||||
|
||||
const handleFormChange = useCallback((variable: string, value: string) => {
|
||||
const handleFormChange = (variable: string, value: any) => {
|
||||
handleNewConversationInputsChange({
|
||||
...newConversationInputs,
|
||||
...newConversationInputsRef.current,
|
||||
[variable]: value,
|
||||
})
|
||||
}, [newConversationInputs, handleNewConversationInputsChange])
|
||||
}
|
||||
|
||||
const renderField = (form: any) => {
|
||||
const {
|
||||
|
|
@ -48,6 +50,34 @@ const Form = () => {
|
|||
/>
|
||||
)
|
||||
}
|
||||
if (form.type === InputVarType.singleFile) {
|
||||
return (
|
||||
<FileUploaderInAttachmentWrapper
|
||||
value={newConversationInputs[variable] ? [newConversationInputs[variable]] : []}
|
||||
onChange={files => handleFormChange(variable, files[0])}
|
||||
fileConfig={{
|
||||
allowed_file_types: form.allowed_file_types,
|
||||
allowed_file_extensions: form.allowed_file_extensions,
|
||||
allowed_file_upload_methods: form.allowed_file_upload_methods,
|
||||
number_limits: 1,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (form.type === InputVarType.multiFiles) {
|
||||
return (
|
||||
<FileUploaderInAttachmentWrapper
|
||||
value={newConversationInputs[variable]}
|
||||
onChange={files => handleFormChange(variable, files)}
|
||||
fileConfig={{
|
||||
allowed_file_types: form.allowed_file_types,
|
||||
allowed_file_extensions: form.allowed_file_extensions,
|
||||
allowed_file_upload_methods: form.allowed_file_upload_methods,
|
||||
number_limits: form.max_length,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalSelect
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ export type ChatWithHistoryContextValue = {
|
|||
conversationList: AppConversationData['data']
|
||||
showConfigPanelBeforeChat: boolean
|
||||
newConversationInputs: Record<string, any>
|
||||
newConversationInputsRef: RefObject<Record<string, any>>
|
||||
handleNewConversationInputsChange: (v: Record<string, any>) => void
|
||||
inputsForms: any[]
|
||||
handleNewConversation: () => void
|
||||
|
|
@ -57,6 +58,7 @@ export const ChatWithHistoryContext = createContext<ChatWithHistoryContextValue>
|
|||
conversationList: [],
|
||||
showConfigPanelBeforeChat: false,
|
||||
newConversationInputs: {},
|
||||
newConversationInputsRef: { current: {} },
|
||||
handleNewConversationInputsChange: () => {},
|
||||
inputsForms: [],
|
||||
handleNewConversation: () => {},
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@ import type {
|
|||
import { useToastContext } from '@/app/components/base/toast'
|
||||
import { changeLanguage } from '@/i18n/i18next-config'
|
||||
import { useAppFavicon } from '@/hooks/use-app-favicon'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
|
||||
export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
|
||||
const isInstalledApp = useMemo(() => !!installedAppInfo, [installedAppInfo])
|
||||
|
|
@ -127,7 +129,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
|
|||
setNewConversationInputs(newInputs)
|
||||
}, [])
|
||||
const inputsForms = useMemo(() => {
|
||||
return (appParams?.user_input_form || []).filter((item: any) => item.paragraph || item.select || item['text-input'] || item.number).map((item: any) => {
|
||||
return (appParams?.user_input_form || []).filter((item: any) => !item.external_data_tool).map((item: any) => {
|
||||
if (item.paragraph) {
|
||||
return {
|
||||
...item.paragraph,
|
||||
|
|
@ -147,6 +149,20 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
|
|||
}
|
||||
}
|
||||
|
||||
if (item['file-list']) {
|
||||
return {
|
||||
...item['file-list'],
|
||||
type: 'file-list',
|
||||
}
|
||||
}
|
||||
|
||||
if (item.file) {
|
||||
return {
|
||||
...item.file,
|
||||
type: 'file',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...item['text-input'],
|
||||
type: 'text-input',
|
||||
|
|
@ -206,21 +222,38 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
|
|||
|
||||
const { notify } = useToastContext()
|
||||
const checkInputsRequired = useCallback((silent?: boolean) => {
|
||||
if (inputsForms.length) {
|
||||
for (let i = 0; i < inputsForms.length; i += 1) {
|
||||
const item = inputsForms[i]
|
||||
|
||||
if (item.required && !newConversationInputsRef.current[item.variable]) {
|
||||
if (!silent) {
|
||||
notify({
|
||||
type: 'error',
|
||||
message: t('appDebug.errorMessage.valueOfVarRequired', { key: item.variable }),
|
||||
})
|
||||
}
|
||||
let hasEmptyInput = ''
|
||||
let fileIsUploading = false
|
||||
const requiredVars = inputsForms.filter(({ required }) => required)
|
||||
if (requiredVars.length) {
|
||||
requiredVars.forEach(({ variable, label, type }) => {
|
||||
if (hasEmptyInput)
|
||||
return
|
||||
|
||||
if (fileIsUploading)
|
||||
return
|
||||
|
||||
if (!newConversationInputsRef.current[variable] && !silent)
|
||||
hasEmptyInput = label as string
|
||||
|
||||
if ((type === InputVarType.singleFile || type === InputVarType.multiFiles) && newConversationInputsRef.current[variable] && !silent) {
|
||||
const files = newConversationInputsRef.current[variable]
|
||||
if (Array.isArray(files))
|
||||
fileIsUploading = files.find(item => item.transferMethod === TransferMethod.local_file && !item.uploadedId)
|
||||
else
|
||||
fileIsUploading = files.transferMethod === TransferMethod.local_file && !files.uploadedId
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
if (hasEmptyInput) {
|
||||
notify({ type: 'error', message: t('appDebug.errorMessage.valueOfVarRequired', { key: hasEmptyInput }) })
|
||||
return false
|
||||
}
|
||||
|
||||
if (fileIsUploading) {
|
||||
notify({ type: 'info', message: t('appDebug.errorMessage.waitForFileUpload') })
|
||||
return
|
||||
}
|
||||
|
||||
return true
|
||||
|
|
@ -377,6 +410,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
|
|||
setShowConfigPanelBeforeChat,
|
||||
setShowNewConversationItemInList,
|
||||
newConversationInputs,
|
||||
newConversationInputsRef,
|
||||
handleNewConversationInputsChange,
|
||||
inputsForms,
|
||||
handleNewConversation,
|
||||
|
|
|
|||
|
|
@ -125,6 +125,7 @@ const ChatWithHistoryWrap: FC<ChatWithHistoryWrapProps> = ({
|
|||
conversationList,
|
||||
showConfigPanelBeforeChat,
|
||||
newConversationInputs,
|
||||
newConversationInputsRef,
|
||||
handleNewConversationInputsChange,
|
||||
inputsForms,
|
||||
handleNewConversation,
|
||||
|
|
@ -158,6 +159,7 @@ const ChatWithHistoryWrap: FC<ChatWithHistoryWrapProps> = ({
|
|||
conversationList,
|
||||
showConfigPanelBeforeChat,
|
||||
newConversationInputs,
|
||||
newConversationInputsRef,
|
||||
handleNewConversationInputsChange,
|
||||
inputsForms,
|
||||
handleNewConversation,
|
||||
|
|
|
|||
|
|
@ -2,41 +2,32 @@ import type { FC } from 'react'
|
|||
import { memo } from 'react'
|
||||
import type {
|
||||
ChatItem,
|
||||
VisionFile,
|
||||
} from '../../types'
|
||||
import { Markdown } from '@/app/components/base/markdown'
|
||||
import Thought from '@/app/components/base/chat/chat/thought'
|
||||
import ImageGallery from '@/app/components/base/image-gallery'
|
||||
import type { Emoji } from '@/app/components/tools/types'
|
||||
import { FileList } from '@/app/components/base/file-uploader'
|
||||
import { getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils'
|
||||
|
||||
type AgentContentProps = {
|
||||
item: ChatItem
|
||||
responding?: boolean
|
||||
allToolIcons?: Record<string, string | Emoji>
|
||||
}
|
||||
const AgentContent: FC<AgentContentProps> = ({
|
||||
item,
|
||||
responding,
|
||||
allToolIcons,
|
||||
}) => {
|
||||
const {
|
||||
annotation,
|
||||
agent_thoughts,
|
||||
} = item
|
||||
|
||||
const getImgs = (list?: VisionFile[]) => {
|
||||
if (!list)
|
||||
return []
|
||||
return list.filter(file => file.type === 'image' && file.belongs_to === 'assistant')
|
||||
}
|
||||
|
||||
if (annotation?.logAnnotation)
|
||||
return <Markdown content={annotation?.logAnnotation.content || ''} />
|
||||
|
||||
return (
|
||||
<div>
|
||||
{agent_thoughts?.map((thought, index) => (
|
||||
<div key={index}>
|
||||
<div key={index} className='px-2 py-1'>
|
||||
{thought.thought && (
|
||||
<Markdown content={thought.thought} />
|
||||
)}
|
||||
|
|
@ -45,14 +36,20 @@ const AgentContent: FC<AgentContentProps> = ({
|
|||
{!!thought.tool && (
|
||||
<Thought
|
||||
thought={thought}
|
||||
allToolIcons={allToolIcons || {}}
|
||||
isFinished={!!thought.observation || !responding}
|
||||
/>
|
||||
)}
|
||||
|
||||
{getImgs(thought.message_files).length > 0 && (
|
||||
<ImageGallery srcs={getImgs(thought.message_files).map(file => file.url)} />
|
||||
)}
|
||||
{
|
||||
!!thought.message_files?.length && (
|
||||
<FileList
|
||||
files={getProcessedFilesFromResponse(thought.message_files.map((item: any) => ({ ...item, related_id: item.id })))}
|
||||
showDeleteAction={false}
|
||||
showDownloadAction={true}
|
||||
canPreview={true}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { FC } from 'react'
|
|||
import { memo } from 'react'
|
||||
import type { ChatItem } from '../../types'
|
||||
import { Markdown } from '@/app/components/base/markdown'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type BasicContentProps = {
|
||||
item: ChatItem
|
||||
|
|
@ -15,9 +16,17 @@ const BasicContent: FC<BasicContentProps> = ({
|
|||
} = item
|
||||
|
||||
if (annotation?.logAnnotation)
|
||||
return <Markdown content={annotation?.logAnnotation.content || ''} />
|
||||
return <Markdown content={annotation?.logAnnotation.content || ''} className='px-2 py-1' />
|
||||
|
||||
return <Markdown content={content} className={`${item.isError && '!text-[#F04438]'}`} />
|
||||
return (
|
||||
<Markdown
|
||||
className={cn(
|
||||
'px-2 py-1',
|
||||
item.isError && '!text-[#F04438]',
|
||||
)}
|
||||
content={content}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(BasicContent)
|
||||
|
|
|
|||
|
|
@ -18,10 +18,10 @@ import { AnswerTriangle } from '@/app/components/base/icons/src/vender/solid/gen
|
|||
import LoadingAnim from '@/app/components/base/chat/chat/loading-anim'
|
||||
import Citation from '@/app/components/base/chat/chat/citation'
|
||||
import { EditTitle } from '@/app/components/app/annotation/edit-annotation-modal/edit-item'
|
||||
import type { Emoji } from '@/app/components/tools/types'
|
||||
import type { AppData } from '@/models/share'
|
||||
import AnswerIcon from '@/app/components/base/answer-icon'
|
||||
import cn from '@/utils/classnames'
|
||||
import { FileList } from '@/app/components/base/file-uploader'
|
||||
|
||||
type AnswerProps = {
|
||||
item: ChatItem
|
||||
|
|
@ -30,7 +30,6 @@ type AnswerProps = {
|
|||
config?: ChatConfig
|
||||
answerIcon?: ReactNode
|
||||
responding?: boolean
|
||||
allToolIcons?: Record<string, string | Emoji>
|
||||
showPromptLog?: boolean
|
||||
chatAnswerContainerInner?: string
|
||||
hideProcessDetail?: boolean
|
||||
|
|
@ -44,7 +43,6 @@ const Answer: FC<AnswerProps> = ({
|
|||
config,
|
||||
answerIcon,
|
||||
responding,
|
||||
allToolIcons,
|
||||
showPromptLog,
|
||||
chatAnswerContainerInner,
|
||||
hideProcessDetail,
|
||||
|
|
@ -59,6 +57,8 @@ const Answer: FC<AnswerProps> = ({
|
|||
more,
|
||||
annotation,
|
||||
workflowProcess,
|
||||
allFiles,
|
||||
message_files,
|
||||
} = item
|
||||
const hasAgentThoughts = !!agent_thoughts?.length
|
||||
|
||||
|
|
@ -135,7 +135,6 @@ const Answer: FC<AnswerProps> = ({
|
|||
<WorkflowProcess
|
||||
data={workflowProcess}
|
||||
item={item}
|
||||
hideInfo
|
||||
hideProcessDetail={hideProcessDetail}
|
||||
/>
|
||||
)
|
||||
|
|
@ -146,7 +145,6 @@ const Answer: FC<AnswerProps> = ({
|
|||
<WorkflowProcess
|
||||
data={workflowProcess}
|
||||
item={item}
|
||||
hideInfo
|
||||
hideProcessDetail={hideProcessDetail}
|
||||
/>
|
||||
)
|
||||
|
|
@ -168,7 +166,28 @@ const Answer: FC<AnswerProps> = ({
|
|||
<AgentContent
|
||||
item={item}
|
||||
responding={responding}
|
||||
allToolIcons={allToolIcons}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!!allFiles?.length && (
|
||||
<FileList
|
||||
className='my-1'
|
||||
files={allFiles}
|
||||
showDeleteAction={false}
|
||||
showDownloadAction
|
||||
canPreview
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!!message_files?.length && (
|
||||
<FileList
|
||||
className='my-1'
|
||||
files={message_files}
|
||||
showDeleteAction={false}
|
||||
showDownloadAction
|
||||
canPreview
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import cn from '@/utils/classnames'
|
|||
import CopyBtn from '@/app/components/base/copy-btn'
|
||||
import { MessageFast } from '@/app/components/base/icons/src/vender/solid/communication'
|
||||
import AudioBtn from '@/app/components/base/audio-btn'
|
||||
import AnnotationCtrlBtn from '@/app/components/app/configuration/toolbox/annotation/annotation-ctrl-btn'
|
||||
import AnnotationCtrlBtn from '@/app/components/base/features/new-feature-panel/annotation-reply/annotation-ctrl-btn'
|
||||
import EditReplyModal from '@/app/components/app/annotation/edit-annotation-modal'
|
||||
import {
|
||||
ThumbsDown,
|
||||
|
|
|
|||
71
web/app/components/base/chat/chat/answer/tool-detail.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
RiArrowRightSLine,
|
||||
RiHammerFill,
|
||||
RiLoader2Line,
|
||||
} from '@remixicon/react'
|
||||
import type { ToolInfoInThought } from '../type'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type ToolDetailProps = {
|
||||
payload: ToolInfoInThought
|
||||
}
|
||||
const ToolDetail = ({
|
||||
payload,
|
||||
}: ToolDetailProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { name, label, input, isFinished, output } = payload
|
||||
const toolLabel = name.startsWith('dataset_') ? t('dataset.knowledge') : label
|
||||
const [expand, setExpand] = useState(false)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl',
|
||||
!expand && 'border-l-[0.25px] border-components-panel-border bg-workflow-process-bg',
|
||||
expand && 'border-[0.5px] border-components-panel-border-subtle bg-background-section-burn',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center system-xs-medium text-text-tertiary px-2.5 py-2 cursor-pointer',
|
||||
expand && 'pb-1.5',
|
||||
)}
|
||||
onClick={() => setExpand(!expand)}
|
||||
>
|
||||
{isFinished && <RiHammerFill className='mr-1 w-3.5 h-3.5' />}
|
||||
{!isFinished && <RiLoader2Line className='mr-1 w-3.5 h-3.5 animate-spin' />}
|
||||
{t(`tools.thought.${isFinished ? 'used' : 'using'}`)}
|
||||
<div className='mx-1 text-text-secondary'>{toolLabel}</div>
|
||||
{!expand && <RiArrowRightSLine className='w-4 h-4' />}
|
||||
{expand && <RiArrowDownSLine className='ml-auto w-4 h-4' />}
|
||||
</div>
|
||||
{
|
||||
expand && (
|
||||
<>
|
||||
<div className='mb-0.5 mx-1 rounded-[10px] bg-components-panel-on-panel-item-bg text-text-secondary'>
|
||||
<div className='flex items-center justify-between px-2 pt-1 h-7 system-xs-semibold-uppercase'>
|
||||
{t('tools.thought.requestTitle')}
|
||||
</div>
|
||||
<div className='pt-1 px-3 pb-2 code-xs-regular break-words'>
|
||||
{input}
|
||||
</div>
|
||||
</div>
|
||||
<div className='mx-1 mb-1 rounded-[10px] bg-components-panel-on-panel-item-bg text-text-secondary'>
|
||||
<div className='flex items-center justify-between px-2 pt-1 h-7 system-xs-semibold-uppercase'>
|
||||
{t('tools.thought.responseTitle')}
|
||||
</div>
|
||||
<div className='pt-1 px-3 pb-2 code-xs-regular break-words'>
|
||||
{output}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ToolDetail
|
||||
|
|
@ -20,7 +20,6 @@ import { useStore as useAppStore } from '@/app/components/app/store'
|
|||
type WorkflowProcessProps = {
|
||||
data: WorkflowProcess
|
||||
item?: ChatItem
|
||||
grayBg?: boolean
|
||||
expand?: boolean
|
||||
hideInfo?: boolean
|
||||
hideProcessDetail?: boolean
|
||||
|
|
@ -28,7 +27,6 @@ type WorkflowProcessProps = {
|
|||
const WorkflowProcessItem = ({
|
||||
data,
|
||||
item,
|
||||
grayBg,
|
||||
expand = false,
|
||||
hideInfo = false,
|
||||
hideProcessDetail = false,
|
||||
|
|
@ -40,6 +38,8 @@ const WorkflowProcessItem = ({
|
|||
const failed = data.status === WorkflowRunningStatus.Failed || data.status === WorkflowRunningStatus.Stopped
|
||||
|
||||
const background = useMemo(() => {
|
||||
if (collapse)
|
||||
return 'linear-gradient(90deg, rgba(200, 206, 218, 0.20) 0%, rgba(200, 206, 218, 0.04) 100%)'
|
||||
if (running && !collapse)
|
||||
return 'linear-gradient(180deg, #E1E4EA 0%, #EAECF0 100%)'
|
||||
|
||||
|
|
@ -67,41 +67,36 @@ const WorkflowProcessItem = ({
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'mb-2 rounded-xl border-[0.5px] border-black/8',
|
||||
collapse ? 'py-[7px]' : hideInfo ? 'pt-2 pb-1' : 'py-2',
|
||||
collapse && (!grayBg ? 'bg-white' : 'bg-gray-50'),
|
||||
hideInfo ? 'mx-[-8px] px-1' : 'w-full px-3',
|
||||
'-mx-1 px-2.5 rounded-xl border-[0.5px]',
|
||||
collapse ? 'py-[7px] border-components-panel-border' : 'pt-[7px] px-1 pb-1 border-components-panel-border-subtle',
|
||||
)}
|
||||
style={{
|
||||
background,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center h-[18px] cursor-pointer',
|
||||
hideInfo && 'px-[6px]',
|
||||
)}
|
||||
className={cn('flex items-center cursor-pointer', !collapse && 'px-1.5')}
|
||||
onClick={() => setCollapse(!collapse)}
|
||||
>
|
||||
{
|
||||
running && (
|
||||
<RiLoader2Line className='shrink-0 mr-1 w-3 h-3 text-[#667085] animate-spin' />
|
||||
<RiLoader2Line className='shrink-0 mr-1 w-3.5 h-3.5 text-text-tertiary' />
|
||||
)
|
||||
}
|
||||
{
|
||||
succeeded && (
|
||||
<CheckCircle className='shrink-0 mr-1 w-3 h-3 text-[#12B76A]' />
|
||||
<CheckCircle className='shrink-0 mr-1 w-3.5 h-3.5 text-text-success' />
|
||||
)
|
||||
}
|
||||
{
|
||||
failed && (
|
||||
<RiErrorWarningFill className='shrink-0 mr-1 w-3 h-3 text-[#F04438]' />
|
||||
<RiErrorWarningFill className='shrink-0 mr-1 w-3.5 h-3.5 text-text-destructive' />
|
||||
)
|
||||
}
|
||||
<div className='grow text-xs font-medium text-gray-700'>
|
||||
<div className={cn('system-xs-medium text-text-secondary', !collapse && 'grow')}>
|
||||
{t('workflow.common.workflowProcess')}
|
||||
</div>
|
||||
<RiArrowRightSLine className={`'ml-1 w-3 h-3 text-gray-500' ${collapse ? '' : 'rotate-90'}`} />
|
||||
<RiArrowRightSLine className={`'ml-1 w-4 h-4 text-text-tertiary' ${collapse ? '' : 'rotate-90'}`} />
|
||||
</div>
|
||||
{
|
||||
!collapse && (
|
||||
|
|
|
|||
47
web/app/components/base/chat/chat/chat-input-area/hooks.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import {
|
||||
useCallback,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import type { TextAreaRef } from 'rc-textarea'
|
||||
|
||||
export const useTextAreaHeight = () => {
|
||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||
const textareaRef = useRef<TextAreaRef>(null)
|
||||
const textValueRef = useRef<HTMLDivElement>(null)
|
||||
const holdSpaceRef = useRef<HTMLDivElement>(null)
|
||||
const [isMultipleLine, setIsMultipleLine] = useState(false)
|
||||
|
||||
const handleComputeHeight = useCallback(() => {
|
||||
const textareaElement = textareaRef.current?.resizableTextArea.textArea
|
||||
if (wrapperRef.current && textareaElement && textValueRef.current && holdSpaceRef.current) {
|
||||
const { width: wrapperWidth } = wrapperRef.current.getBoundingClientRect()
|
||||
const { height: textareaHeight } = textareaElement.getBoundingClientRect()
|
||||
const { width: textValueWidth } = textValueRef.current.getBoundingClientRect()
|
||||
const { width: holdSpaceWidth } = holdSpaceRef.current.getBoundingClientRect()
|
||||
|
||||
if (textareaHeight > 32) {
|
||||
setIsMultipleLine(true)
|
||||
}
|
||||
else {
|
||||
if (textValueWidth + holdSpaceWidth >= wrapperWidth)
|
||||
setIsMultipleLine(true)
|
||||
else
|
||||
setIsMultipleLine(false)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleTextareaResize = useCallback(() => {
|
||||
handleComputeHeight()
|
||||
}, [handleComputeHeight])
|
||||
|
||||
return {
|
||||
wrapperRef,
|
||||
textareaRef,
|
||||
textValueRef,
|
||||
holdSpaceRef,
|
||||
handleTextareaResize,
|
||||
isMultipleLine,
|
||||
}
|
||||
}
|
||||
209
web/app/components/base/chat/chat/chat-input-area/index.tsx
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
import {
|
||||
useCallback,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import Textarea from 'rc-textarea'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Recorder from 'js-audio-recorder'
|
||||
import type {
|
||||
EnableType,
|
||||
OnSend,
|
||||
} from '../../types'
|
||||
import type { Theme } from '../../embedded-chatbot/theme/theme-context'
|
||||
import type { InputForm } from '../type'
|
||||
import { useCheckInputsForms } from '../check-input-forms-hooks'
|
||||
import { useTextAreaHeight } from './hooks'
|
||||
import Operation from './operation'
|
||||
import cn from '@/utils/classnames'
|
||||
import { FileListInChatInput } from '@/app/components/base/file-uploader'
|
||||
import { useFile } from '@/app/components/base/file-uploader/hooks'
|
||||
import {
|
||||
FileContextProvider,
|
||||
useFileStore,
|
||||
} from '@/app/components/base/file-uploader/store'
|
||||
import VoiceInput from '@/app/components/base/voice-input'
|
||||
import { useToastContext } from '@/app/components/base/toast'
|
||||
import FeatureBar from '@/app/components/base/features/new-feature-panel/feature-bar'
|
||||
import type { FileUpload } from '@/app/components/base/features/types'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
|
||||
type ChatInputAreaProps = {
|
||||
showFeatureBar?: boolean
|
||||
showFileUpload?: boolean
|
||||
featureBarDisabled?: boolean
|
||||
onFeatureBarClick?: (state: boolean) => void
|
||||
visionConfig?: FileUpload
|
||||
speechToTextConfig?: EnableType
|
||||
onSend?: OnSend
|
||||
inputs?: Record<string, any>
|
||||
inputsForm?: InputForm[]
|
||||
theme?: Theme | null
|
||||
}
|
||||
const ChatInputArea = ({
|
||||
showFeatureBar,
|
||||
showFileUpload,
|
||||
featureBarDisabled,
|
||||
onFeatureBarClick,
|
||||
visionConfig,
|
||||
speechToTextConfig = { enabled: true },
|
||||
onSend,
|
||||
inputs = {},
|
||||
inputsForm = [],
|
||||
theme,
|
||||
}: ChatInputAreaProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useToastContext()
|
||||
const {
|
||||
wrapperRef,
|
||||
textareaRef,
|
||||
textValueRef,
|
||||
holdSpaceRef,
|
||||
handleTextareaResize,
|
||||
isMultipleLine,
|
||||
} = useTextAreaHeight()
|
||||
const [query, setQuery] = useState('')
|
||||
const isUseInputMethod = useRef(false)
|
||||
const [showVoiceInput, setShowVoiceInput] = useState(false)
|
||||
const filesStore = useFileStore()
|
||||
const {
|
||||
handleDragFileEnter,
|
||||
handleDragFileLeave,
|
||||
handleDragFileOver,
|
||||
handleDropFile,
|
||||
handleClipboardPasteFile,
|
||||
isDragActive,
|
||||
} = useFile(visionConfig!)
|
||||
const { checkInputsForm } = useCheckInputsForms()
|
||||
|
||||
const handleSend = () => {
|
||||
if (onSend) {
|
||||
const { files, setFiles } = filesStore.getState()
|
||||
if (files.find(item => item.transferMethod === TransferMethod.local_file && !item.uploadedId)) {
|
||||
notify({ type: 'info', message: t('appDebug.errorMessage.waitForFileUpload') })
|
||||
return
|
||||
}
|
||||
if (!query || !query.trim()) {
|
||||
notify({ type: 'info', message: t('appAnnotation.errorMessage.queryRequired') })
|
||||
return
|
||||
}
|
||||
if (checkInputsForm(inputs, inputsForm)) {
|
||||
onSend(query, files)
|
||||
setQuery('')
|
||||
setFiles([])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyUp = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.code === '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.code === 'Enter' && !e.shiftKey) {
|
||||
setQuery(query.replace(/\n$/, ''))
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
const handleShowVoiceInput = useCallback(() => {
|
||||
(Recorder as any).getPermission().then(() => {
|
||||
setShowVoiceInput(true)
|
||||
}, () => {
|
||||
notify({ type: 'error', message: t('common.voiceInput.notAllow') })
|
||||
})
|
||||
}, [t, notify])
|
||||
|
||||
const operation = (
|
||||
<Operation
|
||||
ref={holdSpaceRef}
|
||||
fileConfig={visionConfig}
|
||||
speechToTextConfig={speechToTextConfig}
|
||||
onShowVoiceInput={handleShowVoiceInput}
|
||||
onSend={handleSend}
|
||||
theme={theme}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'relative pb-[9px] bg-components-panel-bg-blur border border-components-chat-input-border rounded-xl shadow-md z-10',
|
||||
isDragActive && 'border border-dashed border-components-option-card-option-selected-border',
|
||||
)}
|
||||
>
|
||||
<div className='relative px-[9px] pt-[9px] max-h-[158px] overflow-x-hidden overflow-y-auto'>
|
||||
<FileListInChatInput fileConfig={visionConfig!} />
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className='flex items-center justify-between'
|
||||
>
|
||||
<div className='flex items-center relative grow w-full'>
|
||||
<div
|
||||
ref={textValueRef}
|
||||
className='absolute w-auto h-auto p-1 leading-6 body-lg-regular pointer-events-none whitespace-pre invisible'
|
||||
>
|
||||
{query}
|
||||
</div>
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
className={cn(
|
||||
'p-1 w-full leading-6 body-lg-regular text-text-tertiary outline-none',
|
||||
)}
|
||||
placeholder={t('common.chat.inputPlaceholder') || ''}
|
||||
autoSize={{ minRows: 1 }}
|
||||
onResize={handleTextareaResize}
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value)
|
||||
handleTextareaResize()
|
||||
}}
|
||||
onKeyUp={handleKeyUp}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handleClipboardPasteFile}
|
||||
onDragEnter={handleDragFileEnter}
|
||||
onDragLeave={handleDragFileLeave}
|
||||
onDragOver={handleDragFileOver}
|
||||
onDrop={handleDropFile}
|
||||
/>
|
||||
</div>
|
||||
{
|
||||
!isMultipleLine && operation
|
||||
}
|
||||
</div>
|
||||
{
|
||||
showVoiceInput && (
|
||||
<VoiceInput
|
||||
onCancel={() => setShowVoiceInput(false)}
|
||||
onConverted={text => setQuery(text)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{
|
||||
isMultipleLine && (
|
||||
<div className='px-[9px]'>{operation}</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{showFeatureBar && <FeatureBar showFileUpload={showFileUpload} disabled={featureBarDisabled} onFeatureBarClick={onFeatureBarClick} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const ChatInputAreaWrapper = (props: ChatInputAreaProps) => {
|
||||
return (
|
||||
<FileContextProvider>
|
||||
<ChatInputArea {...props} />
|
||||
</FileContextProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChatInputAreaWrapper
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
import {
|
||||
forwardRef,
|
||||
memo,
|
||||
} from 'react'
|
||||
import {
|
||||
RiMicLine,
|
||||
RiSendPlane2Fill,
|
||||
} from '@remixicon/react'
|
||||
import type {
|
||||
EnableType,
|
||||
} from '../../types'
|
||||
import type { Theme } from '../../embedded-chatbot/theme/theme-context'
|
||||
import Button from '@/app/components/base/button'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import { FileUploaderInChatInput } from '@/app/components/base/file-uploader'
|
||||
import type { FileUpload } from '@/app/components/base/features/types'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type OperationProps = {
|
||||
fileConfig?: FileUpload
|
||||
speechToTextConfig?: EnableType
|
||||
onShowVoiceInput?: () => void
|
||||
onSend: () => void
|
||||
theme?: Theme | null
|
||||
}
|
||||
const Operation = forwardRef<HTMLDivElement, OperationProps>(({
|
||||
fileConfig,
|
||||
speechToTextConfig,
|
||||
onShowVoiceInput,
|
||||
onSend,
|
||||
theme,
|
||||
}, ref) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'shrink-0 flex items-center justify-end',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className='flex items-center pl-1'
|
||||
ref={ref}
|
||||
>
|
||||
<div className='flex items-center space-x-1'>
|
||||
{fileConfig?.enabled && <FileUploaderInChatInput fileConfig={fileConfig} />}
|
||||
{
|
||||
speechToTextConfig?.enabled && (
|
||||
<ActionButton
|
||||
size='l'
|
||||
onClick={onShowVoiceInput}
|
||||
>
|
||||
<RiMicLine className='w-5 h-5' />
|
||||
</ActionButton>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<Button
|
||||
className='ml-3 px-0 w-8'
|
||||
variant='primary'
|
||||
onClick={onSend}
|
||||
style={
|
||||
theme
|
||||
? {
|
||||
backgroundColor: theme.primaryColor,
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>
|
||||
<RiSendPlane2Fill className='w-4 h-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
Operation.displayName = 'Operation'
|
||||
|
||||
export default memo(Operation)
|
||||
|
|
@ -1,258 +0,0 @@
|
|||
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)
|
||||
54
web/app/components/base/chat/chat/check-input-forms-hooks.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { InputForm } from './type'
|
||||
import { useToastContext } from '@/app/components/base/toast'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
|
||||
export const useCheckInputsForms = () => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useToastContext()
|
||||
|
||||
const checkInputsForm = useCallback((inputs: Record<string, any>, inputsForm: InputForm[]) => {
|
||||
let hasEmptyInput = ''
|
||||
let fileIsUploading = false
|
||||
const requiredVars = inputsForm.filter(({ required }) => required)
|
||||
|
||||
if (requiredVars?.length) {
|
||||
requiredVars.forEach(({ variable, label, type }) => {
|
||||
if (hasEmptyInput)
|
||||
return
|
||||
|
||||
if (fileIsUploading)
|
||||
return
|
||||
|
||||
if (!inputs[variable])
|
||||
hasEmptyInput = label as string
|
||||
|
||||
if ((type === InputVarType.singleFile || type === InputVarType.multiFiles) && inputs[variable]) {
|
||||
const files = inputs[variable]
|
||||
if (Array.isArray(files))
|
||||
fileIsUploading = files.find(item => item.transferMethod === TransferMethod.local_file && !item.uploadedId)
|
||||
else
|
||||
fileIsUploading = files.transferMethod === TransferMethod.local_file && !files.uploadedId
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (hasEmptyInput) {
|
||||
notify({ type: 'error', message: t('appDebug.errorMessage.valueOfVarRequired', { key: hasEmptyInput }) })
|
||||
return false
|
||||
}
|
||||
|
||||
if (fileIsUploading) {
|
||||
notify({ type: 'info', message: t('appDebug.errorMessage.waitForFileUpload') })
|
||||
return
|
||||
}
|
||||
|
||||
return true
|
||||
}, [notify, t])
|
||||
|
||||
return {
|
||||
checkInputsForm,
|
||||
}
|
||||
}
|
||||
|
|
@ -10,7 +10,6 @@ export type ChatContextValue = Pick<ChatProps, 'config'
|
|||
| 'showPromptLog'
|
||||
| 'questionIcon'
|
||||
| 'answerIcon'
|
||||
| 'allToolIcons'
|
||||
| 'onSend'
|
||||
| 'onRegenerate'
|
||||
| 'onAnnotationEdited'
|
||||
|
|
@ -35,7 +34,6 @@ export const ChatContextProvider = ({
|
|||
showPromptLog,
|
||||
questionIcon,
|
||||
answerIcon,
|
||||
allToolIcons,
|
||||
onSend,
|
||||
onRegenerate,
|
||||
onAnnotationEdited,
|
||||
|
|
@ -51,7 +49,6 @@ export const ChatContextProvider = ({
|
|||
showPromptLog,
|
||||
questionIcon,
|
||||
answerIcon,
|
||||
allToolIcons,
|
||||
onSend,
|
||||
onRegenerate,
|
||||
onAnnotationEdited,
|
||||
|
|
|
|||
|
|
@ -6,23 +6,31 @@ import {
|
|||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { produce, setAutoFreeze } from 'immer'
|
||||
import { uniqBy } from 'lodash-es'
|
||||
import { useParams, usePathname } from 'next/navigation'
|
||||
import { v4 as uuidV4 } from 'uuid'
|
||||
import type {
|
||||
ChatConfig,
|
||||
ChatItem,
|
||||
Inputs,
|
||||
PromptVariable,
|
||||
VisionFile,
|
||||
} from '../types'
|
||||
import type { InputForm } from './type'
|
||||
import {
|
||||
getProcessedInputs,
|
||||
processOpeningStatement,
|
||||
} from './utils'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import { useToastContext } from '@/app/components/base/toast'
|
||||
import { ssePost } from '@/service/base'
|
||||
import { replaceStringWithValues } from '@/app/components/app/configuration/prompt-value-panel'
|
||||
import type { Annotation } from '@/models/log'
|
||||
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
|
||||
import useTimestamp from '@/hooks/use-timestamp'
|
||||
import { AudioPlayerManager } from '@/app/components/base/audio-btn/audio.player.manager'
|
||||
import type { FileEntity } from '@/app/components/base/file-uploader/types'
|
||||
import {
|
||||
getProcessedFiles,
|
||||
getProcessedFilesFromResponse,
|
||||
} from '@/app/components/base/file-uploader/utils'
|
||||
|
||||
type GetAbortController = (abortController: AbortController) => void
|
||||
type SendCallback = {
|
||||
|
|
@ -32,50 +40,11 @@ type SendCallback = {
|
|||
isPublicAPI?: boolean
|
||||
}
|
||||
|
||||
export const useCheckPromptVariables = () => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useToastContext()
|
||||
|
||||
const checkPromptVariables = useCallback((promptVariablesConfig: {
|
||||
inputs: Inputs
|
||||
promptVariables: PromptVariable[]
|
||||
}) => {
|
||||
const {
|
||||
promptVariables,
|
||||
inputs,
|
||||
} = promptVariablesConfig
|
||||
let hasEmptyInput = ''
|
||||
const requiredVars = promptVariables.filter(({ key, name, required, type }) => {
|
||||
if (type !== 'string' && type !== 'paragraph' && type !== 'select')
|
||||
return false
|
||||
const res = (!key || !key.trim()) || (!name || !name.trim()) || (required || required === undefined || required === null)
|
||||
return res
|
||||
})
|
||||
|
||||
if (requiredVars?.length) {
|
||||
requiredVars.forEach(({ key, name }) => {
|
||||
if (hasEmptyInput)
|
||||
return
|
||||
|
||||
if (!inputs[key])
|
||||
hasEmptyInput = name
|
||||
})
|
||||
}
|
||||
|
||||
if (hasEmptyInput) {
|
||||
notify({ type: 'error', message: t('appDebug.errorMessage.valueOfVarRequired', { key: hasEmptyInput }) })
|
||||
return false
|
||||
}
|
||||
}, [notify, t])
|
||||
|
||||
return checkPromptVariables
|
||||
}
|
||||
|
||||
export const useChat = (
|
||||
config?: ChatConfig,
|
||||
promptVariablesConfig?: {
|
||||
formSettings?: {
|
||||
inputs: Inputs
|
||||
promptVariables: PromptVariable[]
|
||||
inputsForm: InputForm[]
|
||||
},
|
||||
prevChatList?: ChatItem[],
|
||||
stopChat?: (taskId: string) => void,
|
||||
|
|
@ -93,7 +62,6 @@ export const useChat = (
|
|||
const [suggestedQuestions, setSuggestQuestions] = useState<string[]>([])
|
||||
const conversationMessagesAbortControllerRef = useRef<AbortController | null>(null)
|
||||
const suggestedQuestionsAbortControllerRef = useRef<AbortController | null>(null)
|
||||
const checkPromptVariables = useCheckPromptVariables()
|
||||
const params = useParams()
|
||||
const pathname = usePathname()
|
||||
useEffect(() => {
|
||||
|
|
@ -113,8 +81,8 @@ export const useChat = (
|
|||
}, [])
|
||||
|
||||
const getIntroduction = useCallback((str: string) => {
|
||||
return replaceStringWithValues(str, promptVariablesConfig?.promptVariables || [], promptVariablesConfig?.inputs || {})
|
||||
}, [promptVariablesConfig?.inputs, promptVariablesConfig?.promptVariables])
|
||||
return processOpeningStatement(str, formSettings?.inputs || {}, formSettings?.inputsForm || [])
|
||||
}, [formSettings?.inputs, formSettings?.inputsForm])
|
||||
useEffect(() => {
|
||||
if (config?.opening_statement) {
|
||||
handleUpdateChatList(produce(chatListRef.current, (draft) => {
|
||||
|
|
@ -196,7 +164,11 @@ export const useChat = (
|
|||
|
||||
const handleSend = useCallback(async (
|
||||
url: string,
|
||||
data: any,
|
||||
data: {
|
||||
query: string
|
||||
files?: FileEntity[]
|
||||
[key: string]: any
|
||||
},
|
||||
{
|
||||
onGetConversationMessages,
|
||||
onGetSuggestedQuestions,
|
||||
|
|
@ -211,9 +183,6 @@ export const useChat = (
|
|||
return false
|
||||
}
|
||||
|
||||
if (promptVariablesConfig?.inputs && promptVariablesConfig?.promptVariables)
|
||||
checkPromptVariables(promptVariablesConfig)
|
||||
|
||||
const questionId = `question-${Date.now()}`
|
||||
const questionItem = {
|
||||
id: questionId,
|
||||
|
|
@ -244,13 +213,17 @@ export const useChat = (
|
|||
handleResponding(true)
|
||||
hasStopResponded.current = false
|
||||
|
||||
const { query, files, inputs, ...restData } = data
|
||||
const bodyParams = {
|
||||
response_mode: 'streaming',
|
||||
conversation_id: conversationId.current,
|
||||
...data,
|
||||
files: getProcessedFiles(files || []),
|
||||
query,
|
||||
inputs: getProcessedInputs(inputs || {}, formSettings?.inputsForm || []),
|
||||
...restData,
|
||||
}
|
||||
if (bodyParams?.files?.length) {
|
||||
bodyParams.files = bodyParams.files.map((item: VisionFile) => {
|
||||
bodyParams.files = bodyParams.files.map((item) => {
|
||||
if (item.transfer_method === TransferMethod.local_file) {
|
||||
return {
|
||||
...item,
|
||||
|
|
@ -443,6 +416,8 @@ export const useChat = (
|
|||
return
|
||||
}
|
||||
responseItem.citation = messageEnd.metadata?.retriever_resources || []
|
||||
const processedFilesFromResponse = getProcessedFilesFromResponse(messageEnd.files || [])
|
||||
responseItem.allFiles = uniqBy([...(responseItem.allFiles || []), ...(processedFilesFromResponse || [])], 'id')
|
||||
|
||||
const newListWithAnswer = produce(
|
||||
chatListRef.current.filter(item => item.id !== responseItem.id && item.id !== placeholderAnswerId),
|
||||
|
|
@ -567,15 +542,17 @@ export const useChat = (
|
|||
})
|
||||
return true
|
||||
}, [
|
||||
checkPromptVariables,
|
||||
config?.suggested_questions_after_answer,
|
||||
updateCurrentQA,
|
||||
t,
|
||||
notify,
|
||||
promptVariablesConfig,
|
||||
handleUpdateChatList,
|
||||
handleResponding,
|
||||
formatTime,
|
||||
params.token,
|
||||
params.appId,
|
||||
pathname,
|
||||
formSettings,
|
||||
])
|
||||
|
||||
const handleAnnotationEdited = useCallback((query: string, answer: string, index: number) => {
|
||||
|
|
|
|||
|
|
@ -22,10 +22,11 @@ import type {
|
|||
import type { ThemeBuilder } from '../embedded-chatbot/theme/theme-context'
|
||||
import Question from './question'
|
||||
import Answer from './answer'
|
||||
import ChatInput from './chat-input'
|
||||
import ChatInputArea from './chat-input-area'
|
||||
import TryToAsk from './try-to-ask'
|
||||
import { ChatContextProvider } from './context'
|
||||
import classNames from '@/utils/classnames'
|
||||
import type { InputForm } from './type'
|
||||
import cn from '@/utils/classnames'
|
||||
import type { Emoji } from '@/app/components/tools/types'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { StopCircle } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices'
|
||||
|
|
@ -43,6 +44,8 @@ export type ChatProps = {
|
|||
onStopResponding?: () => void
|
||||
noChatInput?: boolean
|
||||
onSend?: OnSend
|
||||
inputs?: Record<string, any>
|
||||
inputsForm?: InputForm[]
|
||||
onRegenerate?: OnRegenerate
|
||||
chatContainerClassName?: string
|
||||
chatContainerInnerClassName?: string
|
||||
|
|
@ -62,6 +65,9 @@ export type ChatProps = {
|
|||
hideProcessDetail?: boolean
|
||||
hideLogModal?: boolean
|
||||
themeBuilder?: ThemeBuilder
|
||||
showFeatureBar?: boolean
|
||||
showFileUpload?: boolean
|
||||
onFeatureBarClick?: (state: boolean) => void
|
||||
noSpacing?: boolean
|
||||
}
|
||||
|
||||
|
|
@ -69,6 +75,8 @@ const Chat: FC<ChatProps> = ({
|
|||
appData,
|
||||
config,
|
||||
onSend,
|
||||
inputs,
|
||||
inputsForm,
|
||||
onRegenerate,
|
||||
chatList,
|
||||
isResponding,
|
||||
|
|
@ -83,7 +91,6 @@ const Chat: FC<ChatProps> = ({
|
|||
showPromptLog,
|
||||
questionIcon,
|
||||
answerIcon,
|
||||
allToolIcons,
|
||||
onAnnotationAdded,
|
||||
onAnnotationEdited,
|
||||
onAnnotationRemoved,
|
||||
|
|
@ -93,6 +100,9 @@ const Chat: FC<ChatProps> = ({
|
|||
hideProcessDetail,
|
||||
hideLogModal,
|
||||
themeBuilder,
|
||||
showFeatureBar,
|
||||
showFileUpload,
|
||||
onFeatureBarClick,
|
||||
noSpacing,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
|
@ -187,7 +197,6 @@ const Chat: FC<ChatProps> = ({
|
|||
showPromptLog={showPromptLog}
|
||||
questionIcon={questionIcon}
|
||||
answerIcon={answerIcon}
|
||||
allToolIcons={allToolIcons}
|
||||
onSend={onSend}
|
||||
onRegenerate={onRegenerate}
|
||||
onAnnotationAdded={onAnnotationAdded}
|
||||
|
|
@ -198,12 +207,12 @@ const Chat: FC<ChatProps> = ({
|
|||
<div className='relative h-full'>
|
||||
<div
|
||||
ref={chatContainerRef}
|
||||
className={classNames('relative h-full overflow-y-auto overflow-x-hidden', chatContainerClassName)}
|
||||
className={cn('relative h-full overflow-y-auto overflow-x-hidden', chatContainerClassName)}
|
||||
>
|
||||
{chatNode}
|
||||
<div
|
||||
ref={chatContainerInnerRef}
|
||||
className={classNames('w-full', !noSpacing && 'px-8', chatContainerInnerClassName)}
|
||||
className={cn('w-full', !noSpacing && 'px-8', chatContainerInnerClassName)}
|
||||
>
|
||||
{
|
||||
chatList.map((item, index) => {
|
||||
|
|
@ -219,7 +228,6 @@ const Chat: FC<ChatProps> = ({
|
|||
config={config}
|
||||
answerIcon={answerIcon}
|
||||
responding={isLast && isResponding}
|
||||
allToolIcons={allToolIcons}
|
||||
showPromptLog={showPromptLog}
|
||||
chatAnswerContainerInner={chatAnswerContainerInner}
|
||||
hideProcessDetail={hideProcessDetail}
|
||||
|
|
@ -248,7 +256,7 @@ const Chat: FC<ChatProps> = ({
|
|||
>
|
||||
<div
|
||||
ref={chatFooterInnerRef}
|
||||
className={`${chatFooterInnerClassName}`}
|
||||
className={cn('relative', chatFooterInnerClassName)}
|
||||
>
|
||||
{
|
||||
!noStopResponding && isResponding && (
|
||||
|
|
@ -270,12 +278,17 @@ const Chat: FC<ChatProps> = ({
|
|||
}
|
||||
{
|
||||
!noChatInput && (
|
||||
<ChatInput
|
||||
visionConfig={config?.file_upload?.image}
|
||||
<ChatInputArea
|
||||
showFeatureBar={showFeatureBar}
|
||||
showFileUpload={showFileUpload}
|
||||
featureBarDisabled={isResponding}
|
||||
onFeatureBarClick={onFeatureBarClick}
|
||||
visionConfig={config?.file_upload}
|
||||
speechToTextConfig={config?.speech_to_text}
|
||||
onSend={onSend}
|
||||
inputs={inputs}
|
||||
inputsForm={inputsForm}
|
||||
theme={themeBuilder?.theme}
|
||||
noSpacing={noSpacing}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,10 +8,9 @@ import {
|
|||
import type { ChatItem } from '../types'
|
||||
import type { Theme } from '../embedded-chatbot/theme/theme-context'
|
||||
import { CssTransform } from '../embedded-chatbot/theme/utils'
|
||||
import { QuestionTriangle } from '@/app/components/base/icons/src/vender/solid/general'
|
||||
import { User } from '@/app/components/base/icons/src/public/avatar'
|
||||
import { Markdown } from '@/app/components/base/markdown'
|
||||
import ImageGallery from '@/app/components/base/image-gallery'
|
||||
import { FileList } from '@/app/components/base/file-uploader'
|
||||
|
||||
type QuestionProps = {
|
||||
item: ChatItem
|
||||
|
|
@ -28,21 +27,20 @@ const Question: FC<QuestionProps> = ({
|
|||
message_files,
|
||||
} = item
|
||||
|
||||
const imgSrcs = message_files?.length ? message_files.map(item => item.url) : []
|
||||
return (
|
||||
<div className='flex justify-end mb-2 last:mb-0 pl-10'>
|
||||
<div className='group relative mr-4'>
|
||||
<QuestionTriangle
|
||||
className='absolute -right-2 top-0 w-2 h-3 text-[#D1E9FF]/50'
|
||||
style={theme ? { color: theme.chatBubbleColor } : {}}
|
||||
/>
|
||||
<div
|
||||
className='px-4 py-3 bg-[#D1E9FF]/50 rounded-b-2xl rounded-tl-2xl text-sm text-gray-900'
|
||||
className='px-4 py-3 bg-[#D1E9FF]/50 rounded-2xl text-sm text-gray-900'
|
||||
style={theme?.chatBubbleColorStyle ? CssTransform(theme.chatBubbleColorStyle) : {}}
|
||||
>
|
||||
{
|
||||
!!imgSrcs.length && (
|
||||
<ImageGallery srcs={imgSrcs} />
|
||||
!!message_files?.length && (
|
||||
<FileList
|
||||
files={message_files}
|
||||
showDeleteAction={false}
|
||||
showDownloadAction={true}
|
||||
/>
|
||||
)
|
||||
}
|
||||
<Markdown content={content} />
|
||||
|
|
|
|||
|
|
@ -2,12 +2,10 @@
|
|||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import type { ThoughtItem, ToolInfoInThought } from '../type'
|
||||
import Tool from '@/app/components/base/chat/chat/thought/tool'
|
||||
import type { Emoji } from '@/app/components/tools/types'
|
||||
import ToolDetail from '@/app/components/base/chat/chat/answer/tool-detail'
|
||||
|
||||
export type IThoughtProps = {
|
||||
thought: ThoughtItem
|
||||
allToolIcons: Record<string, string | Emoji>
|
||||
isFinished: boolean
|
||||
}
|
||||
|
||||
|
|
@ -24,7 +22,6 @@ function getValue(value: string, isValueArray: boolean, index: number) {
|
|||
|
||||
const Thought: FC<IThoughtProps> = ({
|
||||
thought,
|
||||
allToolIcons,
|
||||
isFinished,
|
||||
}) => {
|
||||
const [toolNames, isValueArray]: [string[], boolean] = (() => {
|
||||
|
|
@ -50,10 +47,9 @@ const Thought: FC<IThoughtProps> = ({
|
|||
return (
|
||||
<div className='my-2 space-y-2'>
|
||||
{toolThoughtList.map((item: ToolInfoInThought, index) => (
|
||||
<Tool
|
||||
<ToolDetail
|
||||
key={index}
|
||||
payload={item}
|
||||
allToolIcons={allToolIcons}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import type { TypeWithI18N } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import type { Annotation, MessageRating } from '@/models/log'
|
||||
import type { VisionFile } from '@/types/app'
|
||||
import type { FileEntity } from '@/app/components/base/file-uploader/types'
|
||||
import type { InputVarType } from '@/app/components/workflow/types'
|
||||
import type { FileResponse } from '@/types/workflow'
|
||||
|
||||
export type MessageMore = {
|
||||
time: string
|
||||
|
|
@ -42,7 +44,7 @@ export type ThoughtItem = {
|
|||
observation: string
|
||||
position: number
|
||||
files?: string[]
|
||||
message_files?: VisionFile[]
|
||||
message_files?: FileEntity[]
|
||||
}
|
||||
|
||||
export type CitationItem = {
|
||||
|
|
@ -88,9 +90,9 @@ export type IChatItem = {
|
|||
useCurrentUserAvatar?: boolean
|
||||
isOpeningStatement?: boolean
|
||||
suggestedQuestions?: string[]
|
||||
log?: { role: string; text: string; files?: VisionFile[] }[]
|
||||
log?: { role: string; text: string; files?: FileEntity[] }[]
|
||||
agent_thoughts?: ThoughtItem[]
|
||||
message_files?: VisionFile[]
|
||||
message_files?: FileEntity[]
|
||||
workflow_run_id?: string
|
||||
// for agent log
|
||||
conversationId?: string
|
||||
|
|
@ -112,6 +114,7 @@ export type Metadata = {
|
|||
export type MessageEnd = {
|
||||
id: string
|
||||
metadata: Metadata
|
||||
files?: FileResponse[]
|
||||
}
|
||||
|
||||
export type MessageReplace = {
|
||||
|
|
@ -129,3 +132,11 @@ export type AnnotationReply = {
|
|||
annotation_id: string
|
||||
annotation_author_name: string
|
||||
}
|
||||
|
||||
export type InputForm = {
|
||||
type: InputVarType
|
||||
label: string
|
||||
variable: any
|
||||
required: boolean
|
||||
[key: string]: any
|
||||
}
|
||||
|
|
|
|||
32
web/app/components/base/chat/chat/utils.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import type { InputForm } from './type'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import { getProcessedFiles } from '@/app/components/base/file-uploader/utils'
|
||||
|
||||
export const processOpeningStatement = (openingStatement: string, inputs: Record<string, any>, inputsForm: InputForm[]) => {
|
||||
if (!openingStatement)
|
||||
return openingStatement
|
||||
|
||||
return openingStatement.replace(/\{\{([^}]+)\}\}/g, (match, key) => {
|
||||
const name = inputs[key]
|
||||
if (name) { // has set value
|
||||
return name
|
||||
}
|
||||
|
||||
const valueObj = inputsForm.find(v => v.variable === key)
|
||||
return valueObj ? `{{${valueObj.label}}}` : match
|
||||
})
|
||||
}
|
||||
|
||||
export const getProcessedInputs = (inputs: Record<string, any>, inputsForm: InputForm[]) => {
|
||||
const processedInputs = { ...inputs }
|
||||
|
||||
inputsForm.forEach((item) => {
|
||||
if (item.type === InputVarType.multiFiles && inputs[item.variable])
|
||||
processedInputs[item.variable] = getProcessedFiles(inputs[item.variable])
|
||||
|
||||
if (item.type === InputVarType.singleFile && inputs[item.variable])
|
||||
processedInputs[item.variable] = getProcessedFiles([inputs[item.variable]])[0]
|
||||
})
|
||||
|
||||
return processedInputs
|
||||
}
|
||||
|
|
@ -58,7 +58,7 @@ const ChatWrapper = () => {
|
|||
appConfig,
|
||||
{
|
||||
inputs: (currentConversationId ? currentConversationItem?.inputs : newConversationInputs) as any,
|
||||
promptVariables: inputsForms,
|
||||
inputsForm: inputsForms,
|
||||
},
|
||||
appPrevChatList,
|
||||
taskId => stopChatMessageResponding('', taskId, isInstalledApp, appId),
|
||||
|
|
@ -72,14 +72,12 @@ const ChatWrapper = () => {
|
|||
const doSend: OnSend = useCallback((message, files, last_answer) => {
|
||||
const data: any = {
|
||||
query: message,
|
||||
files,
|
||||
inputs: currentConversationId ? currentConversationItem?.inputs : newConversationInputs,
|
||||
conversation_id: currentConversationId,
|
||||
parent_message_id: last_answer?.id || getLastAnswer(chatListRef.current)?.id || null,
|
||||
}
|
||||
|
||||
if (appConfig?.file_upload?.image.enabled && files?.length)
|
||||
data.files = files
|
||||
|
||||
handleSend(
|
||||
getUrl('chat-messages', isInstalledApp, appId || ''),
|
||||
data,
|
||||
|
|
@ -159,6 +157,8 @@ const ChatWrapper = () => {
|
|||
chatFooterClassName='pb-4'
|
||||
chatFooterInnerClassName={cn('mx-auto w-full max-w-full tablet:px-4', isMobile && 'px-4')}
|
||||
onSend={doSend}
|
||||
inputs={currentConversationId ? currentConversationItem?.inputs as any : newConversationInputs}
|
||||
inputsForm={inputsForms}
|
||||
onRegenerate={doRegenerate}
|
||||
onStopResponding={handleStop}
|
||||
chatNode={chatNode}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { memo } from 'react'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
|
||||
type InputProps = {
|
||||
form: any
|
||||
|
|
@ -23,9 +24,9 @@ const FormInput: FC<InputProps> = ({
|
|||
|
||||
if (type === 'paragraph') {
|
||||
return (
|
||||
<textarea
|
||||
<Textarea
|
||||
value={value}
|
||||
className='grow h-[104px] rounded-lg bg-gray-100 px-2.5 py-2 outline-none appearance-none resize-none'
|
||||
className='resize-none'
|
||||
onChange={e => onChange(variable, e.target.value)}
|
||||
placeholder={`${label}${!required ? `(${t('appDebug.variableTable.optional')})` : ''}`}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,24 +1,26 @@
|
|||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useEmbeddedChatbotContext } from '../context'
|
||||
import Input from './form-input'
|
||||
import { PortalSelect } from '@/app/components/base/select'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
|
||||
|
||||
const Form = () => {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
inputsForms,
|
||||
newConversationInputs,
|
||||
newConversationInputsRef,
|
||||
handleNewConversationInputsChange,
|
||||
isMobile,
|
||||
} = useEmbeddedChatbotContext()
|
||||
|
||||
const handleFormChange = useCallback((variable: string, value: string) => {
|
||||
const handleFormChange = (variable: string, value: any) => {
|
||||
handleNewConversationInputsChange({
|
||||
...newConversationInputs,
|
||||
...newConversationInputsRef.current,
|
||||
[variable]: value,
|
||||
})
|
||||
}, [newConversationInputs, handleNewConversationInputsChange])
|
||||
}
|
||||
|
||||
const renderField = (form: any) => {
|
||||
const {
|
||||
|
|
@ -49,6 +51,46 @@ const Form = () => {
|
|||
)
|
||||
}
|
||||
|
||||
if (form.type === 'number') {
|
||||
return (
|
||||
<input
|
||||
className="grow h-9 rounded-lg bg-gray-100 px-2.5 outline-none appearance-none"
|
||||
type="number"
|
||||
value={newConversationInputs[variable] || ''}
|
||||
onChange={e => handleFormChange(variable, e.target.value)}
|
||||
placeholder={`${label}${!required ? `(${t('appDebug.variableTable.optional')})` : ''}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (form.type === InputVarType.singleFile) {
|
||||
return (
|
||||
<FileUploaderInAttachmentWrapper
|
||||
value={newConversationInputs[variable] ? [newConversationInputs[variable]] : []}
|
||||
onChange={files => handleFormChange(variable, files[0])}
|
||||
fileConfig={{
|
||||
allowed_file_types: form.allowed_file_types,
|
||||
allowed_file_extensions: form.allowed_file_extensions,
|
||||
allowed_file_upload_methods: form.allowed_file_upload_methods,
|
||||
number_limits: 1,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (form.type === InputVarType.multiFiles) {
|
||||
return (
|
||||
<FileUploaderInAttachmentWrapper
|
||||
value={newConversationInputs[variable]}
|
||||
onChange={files => handleFormChange(variable, files)}
|
||||
fileConfig={{
|
||||
allowed_file_types: form.allowed_file_types,
|
||||
allowed_file_extensions: form.allowed_file_extensions,
|
||||
allowed_file_upload_methods: form.allowed_file_upload_methods,
|
||||
number_limits: form.max_length,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalSelect
|
||||
popupClassName='w-[200px]'
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ export type EmbeddedChatbotContextValue = {
|
|||
conversationList: AppConversationData['data']
|
||||
showConfigPanelBeforeChat: boolean
|
||||
newConversationInputs: Record<string, any>
|
||||
newConversationInputsRef: RefObject<Record<string, any>>
|
||||
handleNewConversationInputsChange: (v: Record<string, any>) => void
|
||||
inputsForms: any[]
|
||||
handleNewConversation: () => void
|
||||
|
|
@ -51,6 +52,7 @@ export const EmbeddedChatbotContext = createContext<EmbeddedChatbotContextValue>
|
|||
conversationList: [],
|
||||
showConfigPanelBeforeChat: false,
|
||||
newConversationInputs: {},
|
||||
newConversationInputsRef: { current: {} },
|
||||
handleNewConversationInputsChange: () => {},
|
||||
inputsForms: [],
|
||||
handleNewConversation: () => {},
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ import type {
|
|||
} from '@/models/share'
|
||||
import { useToastContext } from '@/app/components/base/toast'
|
||||
import { changeLanguage } from '@/i18n/i18next-config'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
|
||||
export const useEmbeddedChatbot = () => {
|
||||
const isInstalledApp = false
|
||||
|
|
@ -94,7 +96,7 @@ export const useEmbeddedChatbot = () => {
|
|||
setNewConversationInputs(newInputs)
|
||||
}, [])
|
||||
const inputsForms = useMemo(() => {
|
||||
return (appParams?.user_input_form || []).filter((item: any) => item.paragraph || item.select || item['text-input'] || item.number).map((item: any) => {
|
||||
return (appParams?.user_input_form || []).filter((item: any) => !item.external_data_tool).map((item: any) => {
|
||||
if (item.paragraph) {
|
||||
let value = initInputs[item.paragraph.variable]
|
||||
if (value && item.paragraph.max_length && value.length > item.paragraph.max_length)
|
||||
|
|
@ -123,6 +125,20 @@ export const useEmbeddedChatbot = () => {
|
|||
}
|
||||
}
|
||||
|
||||
if (item['file-list']) {
|
||||
return {
|
||||
...item['file-list'],
|
||||
type: 'file-list',
|
||||
}
|
||||
}
|
||||
|
||||
if (item.file) {
|
||||
return {
|
||||
...item.file,
|
||||
type: 'file',
|
||||
}
|
||||
}
|
||||
|
||||
let value = initInputs[item['text-input'].variable]
|
||||
if (value && item['text-input'].max_length && value.length > item['text-input'].max_length)
|
||||
value = value.slice(0, item['text-input'].max_length)
|
||||
|
|
@ -192,21 +208,38 @@ export const useEmbeddedChatbot = () => {
|
|||
|
||||
const { notify } = useToastContext()
|
||||
const checkInputsRequired = useCallback((silent?: boolean) => {
|
||||
if (inputsForms.length) {
|
||||
for (let i = 0; i < inputsForms.length; i += 1) {
|
||||
const item = inputsForms[i]
|
||||
|
||||
if (item.required && !newConversationInputsRef.current[item.variable]) {
|
||||
if (!silent) {
|
||||
notify({
|
||||
type: 'error',
|
||||
message: t('appDebug.errorMessage.valueOfVarRequired', { key: item.variable }),
|
||||
})
|
||||
}
|
||||
let hasEmptyInput = ''
|
||||
let fileIsUploading = false
|
||||
const requiredVars = inputsForms.filter(({ required }) => required)
|
||||
if (requiredVars.length) {
|
||||
requiredVars.forEach(({ variable, label, type }) => {
|
||||
if (hasEmptyInput)
|
||||
return
|
||||
|
||||
if (fileIsUploading)
|
||||
return
|
||||
|
||||
if (!newConversationInputsRef.current[variable] && !silent)
|
||||
hasEmptyInput = label as string
|
||||
|
||||
if ((type === InputVarType.singleFile || type === InputVarType.multiFiles) && newConversationInputsRef.current[variable] && !silent) {
|
||||
const files = newConversationInputsRef.current[variable]
|
||||
if (Array.isArray(files))
|
||||
fileIsUploading = files.find(item => item.transferMethod === TransferMethod.local_file && !item.uploadedId)
|
||||
else
|
||||
fileIsUploading = files.transferMethod === TransferMethod.local_file && !files.uploadedId
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
if (hasEmptyInput) {
|
||||
notify({ type: 'error', message: t('appDebug.errorMessage.valueOfVarRequired', { key: hasEmptyInput }) })
|
||||
return false
|
||||
}
|
||||
|
||||
if (fileIsUploading) {
|
||||
notify({ type: 'info', message: t('appDebug.errorMessage.waitForFileUpload') })
|
||||
return
|
||||
}
|
||||
|
||||
return true
|
||||
|
|
@ -278,6 +311,7 @@ export const useEmbeddedChatbot = () => {
|
|||
setShowConfigPanelBeforeChat,
|
||||
setShowNewConversationItemInList,
|
||||
newConversationInputs,
|
||||
newConversationInputsRef,
|
||||
handleNewConversationInputsChange,
|
||||
inputsForms,
|
||||
handleNewConversation,
|
||||
|
|
|
|||
|
|
@ -124,6 +124,7 @@ const EmbeddedChatbotWrapper = () => {
|
|||
conversationList,
|
||||
showConfigPanelBeforeChat,
|
||||
newConversationInputs,
|
||||
newConversationInputsRef,
|
||||
handleNewConversationInputsChange,
|
||||
inputsForms,
|
||||
handleNewConversation,
|
||||
|
|
@ -151,6 +152,7 @@ const EmbeddedChatbotWrapper = () => {
|
|||
conversationList,
|
||||
showConfigPanelBeforeChat,
|
||||
newConversationInputs,
|
||||
newConversationInputsRef,
|
||||
handleNewConversationInputsChange,
|
||||
inputsForms,
|
||||
handleNewConversation,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import type {
|
||||
ModelConfig,
|
||||
VisionFile,
|
||||
VisionSettings,
|
||||
} from '@/types/app'
|
||||
import type { IChatItem } from '@/app/components/base/chat/chat/type'
|
||||
import type { NodeTracing } from '@/types/workflow'
|
||||
import type { WorkflowRunningStatus } from '@/app/components/workflow/types'
|
||||
import type { FileEntity } from '@/app/components/base/file-uploader/types'
|
||||
|
||||
export type { VisionFile } from '@/types/app'
|
||||
export { TransferMethod } from '@/types/app'
|
||||
|
|
@ -55,15 +55,17 @@ export type WorkflowProcess = {
|
|||
tracing: NodeTracing[]
|
||||
expand?: boolean // for UI
|
||||
resultText?: string
|
||||
files?: FileEntity[]
|
||||
}
|
||||
|
||||
export type ChatItem = IChatItem & {
|
||||
isError?: boolean
|
||||
workflowProcess?: WorkflowProcess
|
||||
conversationId?: string
|
||||
allFiles?: FileEntity[]
|
||||
}
|
||||
|
||||
export type OnSend = (message: string, files?: VisionFile[], last_answer?: ChatItem | null) => void
|
||||
export type OnSend = (message: string, files?: FileEntity[], last_answer?: ChatItem | null) => void
|
||||
|
||||
export type OnRegenerate = (chatItem: ChatItem) => void
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { addFileInfos, sortAgentSorts } from '../../tools/utils'
|
||||
import { UUID_NIL } from './constants'
|
||||
import type { ChatItem } from './types'
|
||||
import { getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils'
|
||||
|
||||
async function decodeBase64AndDecompress(base64String: string) {
|
||||
const binaryString = atob(base64String)
|
||||
|
|
@ -30,6 +31,7 @@ function getLastAnswer(chatList: ChatItem[]) {
|
|||
|
||||
function appendQAToChatList(chatList: ChatItem[], item: any) {
|
||||
// we append answer first and then question since will reverse the whole chatList later
|
||||
const answerFiles = item.message_files?.filter((file: any) => file.belongs_to === 'assistant') || []
|
||||
chatList.push({
|
||||
id: item.id,
|
||||
content: item.answer,
|
||||
|
|
@ -37,13 +39,14 @@ function appendQAToChatList(chatList: ChatItem[], item: any) {
|
|||
feedback: item.feedback,
|
||||
isAnswer: true,
|
||||
citation: item.retriever_resources,
|
||||
message_files: item.message_files?.filter((file: any) => file.belongs_to === 'assistant') || [],
|
||||
message_files: getProcessedFilesFromResponse(answerFiles.map((item: any) => ({ ...item, related_id: item.id }))),
|
||||
})
|
||||
const questionFiles = item.message_files?.filter((file: any) => file.belongs_to === 'user') || []
|
||||
chatList.push({
|
||||
id: `question-${item.id}`,
|
||||
content: item.query,
|
||||
isAnswer: false,
|
||||
message_files: item.message_files?.filter((file: any) => file.belongs_to === 'user') || [],
|
||||
message_files: getProcessedFilesFromResponse(questionFiles.map((item: any) => ({ ...item, related_id: item.id }))),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
109
web/app/components/base/chip/index.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import type { FC } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { RiArrowDownSLine, RiCheckLine, RiCloseCircleFill, RiFilter3Line } from '@remixicon/react'
|
||||
import cn from '@/utils/classnames'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
|
||||
export type Item = {
|
||||
value: number | string
|
||||
name: string
|
||||
} & Record<string, any>
|
||||
|
||||
type Props = {
|
||||
className?: string
|
||||
panelClassName?: string
|
||||
showLeftIcon?: boolean
|
||||
leftIcon?: any
|
||||
value: number | string
|
||||
items: Item[]
|
||||
onSelect: (item: any) => void
|
||||
onClear: () => void
|
||||
}
|
||||
const Chip: FC<Props> = ({
|
||||
className,
|
||||
panelClassName,
|
||||
showLeftIcon = true,
|
||||
leftIcon,
|
||||
value,
|
||||
items,
|
||||
onSelect,
|
||||
onClear,
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const triggerContent = useMemo(() => {
|
||||
return items.find(item => item.value === value)?.name || ''
|
||||
}, [items, value])
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement='bottom-start'
|
||||
offset={4}
|
||||
>
|
||||
<div className='relative'>
|
||||
<PortalToFollowElemTrigger
|
||||
onClick={() => setOpen(v => !v)}
|
||||
className='block'
|
||||
>
|
||||
<div className={cn(
|
||||
'flex items-center min-h-8 px-2 py-1 rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal cursor-pointer hover:bg-state-base-hover-alt',
|
||||
open && !value && '!bg-state-base-hover-alt hover:bg-state-base-hover-alt',
|
||||
!open && !!value && '!bg-components-button-secondary-bg shadow-xs !border-components-button-secondary-border hover:!bg-components-button-secondary-bg-hover hover:border-components-button-secondary-border-hover',
|
||||
open && !!value && '!bg-components-button-secondary-bg-hover !border-components-button-secondary-border-hover shadow-xs hover:!bg-components-button-secondary-bg-hover hover:border-components-button-secondary-border-hover',
|
||||
className,
|
||||
)}>
|
||||
{showLeftIcon && (
|
||||
<div className='p-0.5'>
|
||||
{leftIcon || (
|
||||
<RiFilter3Line className={cn('h-4 w-4 text-text-tertiary', !!value && 'text-text-secondary')} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className='grow first-line:p-1 flex items-center gap-0.5'>
|
||||
<div className={cn('system-sm-regular text-text-tertiary', !!value && 'text-text-secondary')}>
|
||||
{triggerContent}
|
||||
</div>
|
||||
</div>
|
||||
{!value && <RiArrowDownSLine className='h-4 w-4 text-text-tertiary' />}
|
||||
{!!value && (
|
||||
<div className='p-[1px] cursor-pointer group/clear' onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onClear()
|
||||
}}>
|
||||
<RiCloseCircleFill className='h-3.5 w-3.5 text-text-quaternary group-hover/clear:text-text-tertiary' />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className='z-[1002]'>
|
||||
<div className={cn('relative w-[240px] bg-components-panel-bg-blur rounded-xl border-[0.5px] border-components-panel-border shadow-lg', panelClassName)}>
|
||||
<div className='p-1 max-h-72 overflow-auto'>
|
||||
{items.map(item => (
|
||||
<div
|
||||
key={item.value}
|
||||
className='flex items-center gap-2 pl-3 py-[6px] px-2 rounded-lg cursor-pointer hover:bg-state-base-hover'
|
||||
onClick={() => {
|
||||
onSelect(item)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<div title={item.name} className='grow text-text-secondary system-sm-medium truncate'>{item.name}</div>
|
||||
{value === item.value && <RiCheckLine className='shrink-0 w-4 h-4 text-util-colors-blue-light-blue-light-600' />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</div>
|
||||
</PortalToFollowElem>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
export default Chip
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
'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)
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
'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)
|
||||
|
Before Width: | Height: | Size: 175 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 211 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
|
@ -1,41 +0,0 @@
|
|||
.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);
|
||||
}
|
||||
|
|
@ -1,147 +0,0 @@
|
|||
'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)
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
'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)
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
'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)
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
'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)
|
||||
|
|
@ -1,119 +0,0 @@
|
|||
'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)
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
'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)
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
'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)
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
.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;
|
||||
}
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
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)
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
'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)
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
'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)
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
'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)
|
||||
|
|
@ -1,241 +0,0 @@
|
|||
'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)
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
'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)
|
||||
|
|
@ -1,3 +1 @@
|
|||
export { default as FeaturesPanel } from './feature-panel'
|
||||
export { default as FeaturesChoose } from './feature-choose'
|
||||
export { FeaturesProvider } from './context'
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
|
||||
export const Item: FC<{ title: string; tooltip: string; children: JSX.Element }> = ({
|
||||
title,
|
||||
tooltip,
|
||||
children,
|
||||
}) => {
|
||||
return (
|
||||
<div>
|
||||
<div className='flex items-center space-x-1'>
|
||||
<div>{title}</div>
|
||||
<Tooltip
|
||||
popupContent={
|
||||
<div className='max-w-[200px] leading-[18px] text-[13px] font-medium text-gray-800'>{tooltip}</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
import React, { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import produce from 'immer'
|
||||
import { RiEqualizer2Line, RiExternalLinkLine } from '@remixicon/react'
|
||||
import { MessageFast } from '@/app/components/base/icons/src/vender/features'
|
||||
import FeatureCard from '@/app/components/base/features/new-feature-panel/feature-card'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks'
|
||||
import type { OnFeaturesChange } from '@/app/components/base/features/types'
|
||||
import useAnnotationConfig from '@/app/components/base/features/new-feature-panel/annotation-reply/use-annotation-config'
|
||||
import ConfigParamModal from '@/app/components/base/features/new-feature-panel/annotation-reply/config-param-modal'
|
||||
import AnnotationFullModal from '@/app/components/billing/annotation-full/modal'
|
||||
import { ANNOTATION_DEFAULT } from '@/config'
|
||||
|
||||
type Props = {
|
||||
disabled?: boolean
|
||||
onChange?: OnFeaturesChange
|
||||
}
|
||||
|
||||
const AnnotationReply = ({
|
||||
disabled,
|
||||
onChange,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const matched = pathname.match(/\/app\/([^/]+)/)
|
||||
const appId = (matched?.length && matched[1]) ? matched[1] : ''
|
||||
const featuresStore = useFeaturesStore()
|
||||
const annotationReply = useFeatures(s => s.features.annotationReply)
|
||||
|
||||
const updateAnnotationReply = useCallback((newConfig: any) => {
|
||||
const {
|
||||
features,
|
||||
setFeatures,
|
||||
} = featuresStore!.getState()
|
||||
const newFeatures = produce(features, (draft) => {
|
||||
draft.annotationReply = newConfig
|
||||
})
|
||||
setFeatures(newFeatures)
|
||||
if (onChange)
|
||||
onChange(newFeatures)
|
||||
}, [featuresStore, onChange])
|
||||
|
||||
const {
|
||||
handleEnableAnnotation,
|
||||
handleDisableAnnotation,
|
||||
isShowAnnotationConfigInit,
|
||||
setIsShowAnnotationConfigInit,
|
||||
isShowAnnotationFullModal,
|
||||
setIsShowAnnotationFullModal,
|
||||
} = useAnnotationConfig({
|
||||
appId,
|
||||
annotationConfig: annotationReply as any || {
|
||||
id: '',
|
||||
enabled: false,
|
||||
score_threshold: ANNOTATION_DEFAULT.score_threshold,
|
||||
embedding_model: {
|
||||
embedding_provider_name: '',
|
||||
embedding_model_name: '',
|
||||
},
|
||||
},
|
||||
setAnnotationConfig: updateAnnotationReply,
|
||||
})
|
||||
|
||||
const handleSwitch = useCallback((enabled: boolean) => {
|
||||
if (enabled)
|
||||
setIsShowAnnotationConfigInit(true)
|
||||
else
|
||||
handleDisableAnnotation(annotationReply?.embedding_model as any)
|
||||
}, [annotationReply?.embedding_model, handleDisableAnnotation, setIsShowAnnotationConfigInit])
|
||||
|
||||
const [isHovering, setIsHovering] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<FeatureCard
|
||||
icon={
|
||||
<div className='shrink-0 p-1 rounded-lg border-[0.5px] border-divider-subtle shadow-xs bg-util-colors-indigo-indigo-600'>
|
||||
<MessageFast className='w-4 h-4 text-text-primary-on-surface' />
|
||||
</div>
|
||||
}
|
||||
title={t('appDebug.feature.annotation.title')}
|
||||
value={!!annotationReply?.enabled}
|
||||
onChange={state => handleSwitch(state)}
|
||||
onMouseEnter={() => setIsHovering(true)}
|
||||
onMouseLeave={() => setIsHovering(false)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<>
|
||||
{!annotationReply?.enabled && (
|
||||
<div className='min-h-8 text-text-tertiary system-xs-regular line-clamp-2'>{t('appDebug.feature.annotation.description')}</div>
|
||||
)}
|
||||
{!!annotationReply?.enabled && (
|
||||
<>
|
||||
{!isHovering && (
|
||||
<div className='pt-0.5 flex items-center gap-4'>
|
||||
<div className=''>
|
||||
<div className='mb-0.5 text-text-tertiary system-2xs-medium-uppercase'>{t('appDebug.feature.annotation.scoreThreshold.title')}</div>
|
||||
<div className='text-text-secondary system-xs-regular'>{annotationReply.score_threshold || '-'}</div>
|
||||
</div>
|
||||
<div className='w-px h-[27px] bg-divider-subtle rotate-12'></div>
|
||||
<div className=''>
|
||||
<div className='mb-0.5 text-text-tertiary system-2xs-medium-uppercase'>{t('common.modelProvider.embeddingModel.key')}</div>
|
||||
<div className='text-text-secondary system-xs-regular'>{annotationReply.embedding_model?.embedding_model_name}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isHovering && (
|
||||
<div className='flex items-center justify-between'>
|
||||
<Button className='w-[178px]' onClick={() => setIsShowAnnotationConfigInit(true)} disabled={disabled}>
|
||||
<RiEqualizer2Line className='mr-1 w-4 h-4' />
|
||||
{t('common.operation.params')}
|
||||
</Button>
|
||||
<Button className='w-[178px]' onClick={() => {
|
||||
router.push(`/app/${appId}/annotations`)
|
||||
}}>
|
||||
<RiExternalLinkLine className='mr-1 w-4 h-4' />
|
||||
{t('appDebug.feature.annotation.cacheManagement')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
</FeatureCard>
|
||||
<ConfigParamModal
|
||||
appId={appId}
|
||||
isInit
|
||||
isShow={isShowAnnotationConfigInit}
|
||||
onHide={() => {
|
||||
setIsShowAnnotationConfigInit(false)
|
||||
// showChooseFeatureTrue()
|
||||
}}
|
||||
onSave={async (embeddingModel, score) => {
|
||||
await handleEnableAnnotation(embeddingModel, score)
|
||||
setIsShowAnnotationConfigInit(false)
|
||||
}}
|
||||
annotationConfig={annotationReply as any}
|
||||
/>
|
||||
{isShowAnnotationFullModal && (
|
||||
<AnnotationFullModal
|
||||
show={isShowAnnotationFullModal}
|
||||
onHide={() => setIsShowAnnotationFullModal(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default AnnotationReply
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
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'
|
||||
import Slider from '@/app/components/base/features/new-feature-panel/annotation-reply/score-slider/base-slider'
|
||||
|
||||
type Props = {
|
||||
className?: string
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
export enum PageType {
|
||||
log = 'log',
|
||||
annotation = 'annotation',
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
import React, { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import produce from 'immer'
|
||||
import { Citations } from '@/app/components/base/icons/src/vender/features'
|
||||
import FeatureCard from '@/app/components/base/features/new-feature-panel/feature-card'
|
||||
import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks'
|
||||
import type { OnFeaturesChange } from '@/app/components/base/features/types'
|
||||
import { FeatureEnum } from '@/app/components/base/features/types'
|
||||
|
||||
type Props = {
|
||||
disabled?: boolean
|
||||
onChange?: OnFeaturesChange
|
||||
}
|
||||
|
||||
const Citation = ({
|
||||
disabled,
|
||||
onChange,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const features = useFeatures(s => s.features)
|
||||
const featuresStore = useFeaturesStore()
|
||||
|
||||
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 (
|
||||
<FeatureCard
|
||||
icon={
|
||||
<div className='shrink-0 p-1 rounded-lg border-[0.5px] border-divider-subtle shadow-xs bg-util-colors-warning-warning-500'>
|
||||
<Citations className='w-4 h-4 text-text-primary-on-surface' />
|
||||
</div>
|
||||
}
|
||||
title={t('appDebug.feature.citation.title')}
|
||||
value={!!features.citation?.enabled}
|
||||
description={t('appDebug.feature.citation.description')!}
|
||||
onChange={state => handleChange(FeatureEnum.citation, state)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default Citation
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
import React, { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import produce from 'immer'
|
||||
import { RiEditLine } from '@remixicon/react'
|
||||
import { LoveMessage } from '@/app/components/base/icons/src/vender/features'
|
||||
import FeatureCard from '@/app/components/base/features/new-feature-panel/feature-card'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks'
|
||||
import type { OnFeaturesChange } from '@/app/components/base/features/types'
|
||||
import { FeatureEnum } from '@/app/components/base/features/types'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import type { PromptVariable } from '@/models/debug'
|
||||
import type { InputVar } from '@/app/components/workflow/types'
|
||||
|
||||
type Props = {
|
||||
disabled?: boolean
|
||||
onChange?: OnFeaturesChange
|
||||
promptVariables?: PromptVariable[]
|
||||
workflowVariables?: InputVar[]
|
||||
onAutoAddPromptVariable?: (variable: PromptVariable[]) => void
|
||||
}
|
||||
|
||||
const ConversationOpener = ({
|
||||
disabled,
|
||||
onChange,
|
||||
promptVariables,
|
||||
workflowVariables,
|
||||
onAutoAddPromptVariable,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const { setShowOpeningModal } = useModalContext()
|
||||
const opening = useFeatures(s => s.features.opening)
|
||||
const featuresStore = useFeaturesStore()
|
||||
const [isHovering, setIsHovering] = useState(false)
|
||||
const handleOpenOpeningModal = useCallback(() => {
|
||||
if (disabled)
|
||||
return
|
||||
const {
|
||||
features,
|
||||
setFeatures,
|
||||
} = featuresStore!.getState()
|
||||
setShowOpeningModal({
|
||||
payload: {
|
||||
...opening,
|
||||
promptVariables,
|
||||
workflowVariables,
|
||||
onAutoAddPromptVariable,
|
||||
},
|
||||
onSaveCallback: (newOpening) => {
|
||||
const newFeatures = produce(features, (draft) => {
|
||||
draft.opening = newOpening
|
||||
})
|
||||
setFeatures(newFeatures)
|
||||
if (onChange)
|
||||
onChange()
|
||||
},
|
||||
onCancelCallback: () => {
|
||||
if (onChange)
|
||||
onChange()
|
||||
},
|
||||
})
|
||||
}, [disabled, featuresStore, onAutoAddPromptVariable, onChange, opening, promptVariables, setShowOpeningModal])
|
||||
|
||||
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()
|
||||
}, [featuresStore, onChange])
|
||||
|
||||
return (
|
||||
<FeatureCard
|
||||
icon={
|
||||
<div className='shrink-0 p-1 rounded-lg border-[0.5px] border-divider-subtle shadow-xs bg-util-colors-blue-light-blue-light-500'>
|
||||
<LoveMessage className='w-4 h-4 text-text-primary-on-surface' />
|
||||
</div>
|
||||
}
|
||||
title={t('appDebug.feature.conversationOpener.title')}
|
||||
value={!!opening?.enabled}
|
||||
onChange={state => handleChange(FeatureEnum.opening, state)}
|
||||
onMouseEnter={() => setIsHovering(true)}
|
||||
onMouseLeave={() => setIsHovering(false)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<>
|
||||
{!opening?.enabled && (
|
||||
<div className='min-h-8 text-text-tertiary system-xs-regular line-clamp-2'>{t('appDebug.feature.conversationOpener.description')}</div>
|
||||
)}
|
||||
{!!opening?.enabled && (
|
||||
<>
|
||||
{!isHovering && (
|
||||
<div className='min-h-8 text-text-tertiary system-xs-regular line-clamp-2'>
|
||||
{opening.opening_statement || t('appDebug.openingStatement.placeholder')}
|
||||
</div>
|
||||
)}
|
||||
{isHovering && (
|
||||
<Button className='w-full' onClick={handleOpenOpeningModal} disabled={disabled}>
|
||||
<RiEditLine className='mr-1 w-4 h-4' />
|
||||
{t('appDebug.openingStatement.writeOpener')}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
</FeatureCard>
|
||||
)
|
||||
}
|
||||
|
||||
export default ConversationOpener
|
||||
|
|
@ -0,0 +1,206 @@
|
|||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import produce from 'immer'
|
||||
import { ReactSortable } from 'react-sortablejs'
|
||||
import { RiAddLine, RiAsterisk, RiCloseLine, RiDeleteBinLine, RiDraggable } from '@remixicon/react'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import Button from '@/app/components/base/button'
|
||||
import ConfirmAddVar from '@/app/components/app/configuration/config-prompt/confirm-add-var'
|
||||
import type { OpeningStatement } from '@/app/components/base/features/types'
|
||||
import { getInputKeys } from '@/app/components/base/block-input'
|
||||
import type { PromptVariable } from '@/models/debug'
|
||||
import type { InputVar } from '@/app/components/workflow/types'
|
||||
import { getNewVar } from '@/utils/var'
|
||||
|
||||
type OpeningSettingModalProps = {
|
||||
data: OpeningStatement
|
||||
onSave: (newState: OpeningStatement) => void
|
||||
onCancel: () => void
|
||||
promptVariables?: PromptVariable[]
|
||||
workflowVariables?: InputVar[]
|
||||
onAutoAddPromptVariable?: (variable: PromptVariable[]) => void
|
||||
}
|
||||
|
||||
const MAX_QUESTION_NUM = 5
|
||||
|
||||
const OpeningSettingModal = ({
|
||||
data,
|
||||
onSave,
|
||||
onCancel,
|
||||
promptVariables = [],
|
||||
workflowVariables = [],
|
||||
onAutoAddPromptVariable,
|
||||
}: OpeningSettingModalProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [tempValue, setTempValue] = useState(data?.opening_statement || '')
|
||||
useEffect(() => {
|
||||
setTempValue(data.opening_statement || '')
|
||||
}, [data.opening_statement])
|
||||
const [tempSuggestedQuestions, setTempSuggestedQuestions] = useState(data.suggested_questions || [])
|
||||
const [isShowConfirmAddVar, { setTrue: showConfirmAddVar, setFalse: hideConfirmAddVar }] = useBoolean(false)
|
||||
const [notIncludeKeys, setNotIncludeKeys] = useState<string[]>([])
|
||||
|
||||
const handleSave = useCallback((ignoreVariablesCheck?: boolean) => {
|
||||
if (!ignoreVariablesCheck) {
|
||||
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
|
||||
}
|
||||
}
|
||||
const newOpening = produce(data, (draft) => {
|
||||
if (draft) {
|
||||
draft.opening_statement = tempValue
|
||||
draft.suggested_questions = tempSuggestedQuestions
|
||||
}
|
||||
})
|
||||
onSave(newOpening)
|
||||
}, [data, onSave, promptVariables, workflowVariables, showConfirmAddVar, tempSuggestedQuestions, tempValue])
|
||||
|
||||
const cancelAutoAddVar = useCallback(() => {
|
||||
hideConfirmAddVar()
|
||||
handleSave(true)
|
||||
}, [handleSave, hideConfirmAddVar])
|
||||
|
||||
const autoAddVar = useCallback(() => {
|
||||
onAutoAddPromptVariable?.([
|
||||
...notIncludeKeys.map(key => getNewVar(key, 'string')),
|
||||
])
|
||||
hideConfirmAddVar()
|
||||
handleSave(true)
|
||||
}, [handleSave, hideConfirmAddVar, notIncludeKeys, onAutoAddPromptVariable])
|
||||
|
||||
const renderQuestions = () => {
|
||||
return (
|
||||
<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}>
|
||||
<RiDraggable className='handle w-4 h-4 cursor-grab' />
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isShow
|
||||
onClose={() => { }}
|
||||
className='!p-6 !mt-14 !max-w-none !w-[640px] !bg-components-panel-bg-blur'
|
||||
>
|
||||
<div className='flex items-center justify-between mb-6'>
|
||||
<div className='text-text-primary title-2xl-semi-bold'>{t('appDebug.feature.conversationOpener.title')}</div>
|
||||
<div className='p-1 cursor-pointer' onClick={onCancel}><RiCloseLine className='w-4 h-4 text-text-tertiary'/></div>
|
||||
</div>
|
||||
<div className='flex gap-2 mb-8'>
|
||||
<div className='shrink-0 mt-1.5 w-8 h-8 p-1.5 rounded-lg border-components-panel-border bg-util-colors-orange-dark-orange-dark-500'>
|
||||
<RiAsterisk className='w-5 h-5 text-text-primary-on-surface' />
|
||||
</div>
|
||||
<div className='grow p-3 bg-chat-bubble-bg rounded-2xl border-t border-divider-subtle shadow-xs'>
|
||||
<textarea
|
||||
value={tempValue}
|
||||
rows={3}
|
||||
onChange={e => setTempValue(e.target.value)}
|
||||
className="w-full px-0 text-text-secondary system-md-regular border-0 bg-transparent focus:outline-none"
|
||||
placeholder={t('appDebug.openingStatement.placeholder') as string}
|
||||
/>
|
||||
{renderQuestions()}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex items-center justify-end'>
|
||||
<Button
|
||||
onClick={onCancel}
|
||||
className='mr-2'
|
||||
>
|
||||
{t('common.operation.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='primary'
|
||||
onClick={() => handleSave()}
|
||||
>
|
||||
{t('common.operation.save')}
|
||||
</Button>
|
||||
</div>
|
||||
{isShowConfirmAddVar && (
|
||||
<ConfirmAddVar
|
||||
varNameArr={notIncludeKeys}
|
||||
onConfirm={autoAddVar}
|
||||
onCancel={cancelAutoAddVar}
|
||||
onHide={hideConfirmAddVar}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default OpeningSettingModal
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import { Fragment, useCallback } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { Dialog, Transition } from '@headlessui/react'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type DialogProps = {
|
||||
className?: string
|
||||
children: ReactNode
|
||||
show: boolean
|
||||
onClose?: () => void
|
||||
inWorkflow?: boolean
|
||||
}
|
||||
|
||||
const DialogWrapper = ({
|
||||
className,
|
||||
children,
|
||||
show,
|
||||
onClose,
|
||||
inWorkflow = true,
|
||||
}: DialogProps) => {
|
||||
const close = useCallback(() => onClose?.(), [onClose])
|
||||
return (
|
||||
<Transition appear show={show} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-40" onClose={close}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-black bg-opacity-25" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0">
|
||||
<div className={cn('flex flex-col items-end justify-center min-h-full pb-2', inWorkflow ? 'pt-[112px]' : 'pt-[64px] pr-2')}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className={cn('grow flex relative w-[420px] h-0 p-0 overflow-hidden text-left align-middle transition-all transform bg-components-panel-bg-alt border-components-panel-border shadow-xl', inWorkflow ? 'border-t-[0.5px] border-l-[0.5px] border-b-[0.5px] rounded-l-2xl' : 'border-[0.5px] rounded-2xl', className)}>
|
||||
{children}
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition >
|
||||
)
|
||||
}
|
||||
|
||||
export default DialogWrapper
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
import React, { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiApps2AddLine, RiArrowRightLine, RiSparklingFill } from '@remixicon/react'
|
||||
import { Citations, ContentModeration, FolderUpload, LoveMessage, MessageFast, Microphone01, TextToAudio, VirtualAssistant } from '@/app/components/base/icons/src/vender/features'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import VoiceSettings from '@/app/components/base/features/new-feature-panel/text-to-speech/voice-settings'
|
||||
import { useFeatures } from '@/app/components/base/features/hooks'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type Props = {
|
||||
isChatMode?: boolean
|
||||
showFileUpload?: boolean
|
||||
disabled?: boolean
|
||||
onFeatureBarClick?: (state: boolean) => void
|
||||
}
|
||||
|
||||
const FeatureBar = ({
|
||||
isChatMode = true,
|
||||
showFileUpload = true,
|
||||
disabled,
|
||||
onFeatureBarClick,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const features = useFeatures(s => s.features)
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
|
||||
const noFeatureEnabled = useMemo(() => {
|
||||
// completion app citation is always true but not enabled for setting
|
||||
const data = {
|
||||
...features,
|
||||
citation: { enabled: isChatMode ? features.citation?.enabled : false },
|
||||
file: showFileUpload ? features.file! : { enabled: false },
|
||||
}
|
||||
return !Object.values(data).some(f => f.enabled)
|
||||
}, [features, isChatMode, showFileUpload])
|
||||
|
||||
return (
|
||||
<div className='-translate-y-2 m-1 mt-0 px-2.5 py-2 pt-4 bg-util-colors-indigo-indigo-50 rounded-b-[10px] border-l border-b border-r border-components-panel-border-subtle'>
|
||||
{noFeatureEnabled && (
|
||||
<div className='flex items-end gap-1 cursor-pointer' onClick={() => onFeatureBarClick?.(true)}>
|
||||
<RiApps2AddLine className='w-3.5 h-3.5 text-text-accent' />
|
||||
<div className='text-text-accent body-xs-medium'>{t('appDebug.feature.bar.empty')}</div>
|
||||
<RiArrowRightLine className='w-3.5 h-3.5 text-text-accent' />
|
||||
</div>
|
||||
)}
|
||||
{!noFeatureEnabled && (
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='shrink-0 flex items-center gap-0.5'>
|
||||
{!!features.moreLikeThis?.enabled && (
|
||||
<Tooltip
|
||||
popupContent={t('appDebug.feature.moreLikeThis.title')}
|
||||
>
|
||||
<div className='shrink-0 p-1 rounded-lg border-[0.5px] border-divider-subtle shadow-xs bg-util-colors-blue-light-blue-light-500'>
|
||||
<RiSparklingFill className='w-3.5 h-3.5 text-text-primary-on-surface' />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!!features.opening?.enabled && (
|
||||
<Tooltip
|
||||
popupContent={t('appDebug.feature.conversationOpener.title')}
|
||||
>
|
||||
<div className='shrink-0 p-1 rounded-lg border-[0.5px] border-divider-subtle shadow-xs bg-util-colors-blue-light-blue-light-500'>
|
||||
<LoveMessage className='w-3.5 h-3.5 text-text-primary-on-surface' />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!!features.moderation?.enabled && (
|
||||
<Tooltip
|
||||
popupContent={t('appDebug.feature.moderation.title')}
|
||||
>
|
||||
<div className='shrink-0 p-1 rounded-lg border-[0.5px] border-divider-subtle shadow-xs bg-text-success'>
|
||||
<ContentModeration className='w-3.5 h-3.5 text-text-primary-on-surface' />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!!features.speech2text?.enabled && (
|
||||
<Tooltip
|
||||
popupContent={t('appDebug.feature.speechToText.title')}
|
||||
>
|
||||
<div className='shrink-0 p-1 rounded-lg border-[0.5px] border-divider-subtle shadow-xs bg-util-colors-violet-violet-600'>
|
||||
<Microphone01 className='w-3.5 h-3.5 text-text-primary-on-surface' />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!!features.text2speech?.enabled && (
|
||||
<VoiceSettings placementLeft={false} open={modalOpen && !disabled} onOpen={setModalOpen}>
|
||||
<Tooltip
|
||||
popupContent={t('appDebug.feature.textToSpeech.title')}
|
||||
>
|
||||
<div className={cn('shrink-0 p-1 rounded-lg border-[0.5px] border-divider-subtle shadow-xs bg-util-colors-violet-violet-600', !disabled && 'cursor-pointer')}>
|
||||
<TextToAudio className='w-3.5 h-3.5 text-text-primary-on-surface' />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</VoiceSettings>
|
||||
)}
|
||||
{showFileUpload && !!features.file?.enabled && (
|
||||
<Tooltip
|
||||
popupContent={t('appDebug.feature.fileUpload.title')}
|
||||
>
|
||||
<div className='shrink-0 p-1 rounded-lg border-[0.5px] border-divider-subtle shadow-xs bg-util-colors-blue-blue-600'>
|
||||
<FolderUpload className='w-3.5 h-3.5 text-text-primary-on-surface' />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!!features.suggested?.enabled && (
|
||||
<Tooltip
|
||||
popupContent={t('appDebug.feature.suggestedQuestionsAfterAnswer.title')}
|
||||
>
|
||||
<div className='shrink-0 p-1 rounded-lg border-[0.5px] border-divider-subtle shadow-xs bg-util-colors-blue-light-blue-light-500'>
|
||||
<VirtualAssistant className='w-3.5 h-3.5 text-text-primary-on-surface' />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isChatMode && !!features.citation?.enabled && (
|
||||
<Tooltip
|
||||
popupContent={t('appDebug.feature.citation.title')}
|
||||
>
|
||||
<div className='shrink-0 p-1 rounded-lg border-[0.5px] border-divider-subtle shadow-xs bg-util-colors-warning-warning-500'>
|
||||
<Citations className='w-4 h-4 text-text-primary-on-surface' />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isChatMode && !!features.annotationReply?.enabled && (
|
||||
<Tooltip
|
||||
popupContent={t('appDebug.feature.annotation.title')}
|
||||
>
|
||||
<div className='shrink-0 p-1 rounded-lg border-[0.5px] border-divider-subtle shadow-xs bg-util-colors-indigo-indigo-600'>
|
||||
<MessageFast className='w-3.5 h-3.5 text-text-primary-on-surface' />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<div className='grow text-text-tertiary body-xs-regular'>{t('appDebug.feature.bar.enableText')}</div>
|
||||
<Button className='shrink-0' variant='ghost-accent' size='small' onClick={() => onFeatureBarClick?.(true)}>
|
||||
<div className='mx-1'>{t('appDebug.feature.bar.manage')}</div>
|
||||
<RiArrowRightLine className='w-3.5 h-3.5 text-text-accent' />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FeatureBar
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import React from 'react'
|
||||
import {
|
||||
RiQuestionLine,
|
||||
} from '@remixicon/react'
|
||||
import Switch from '@/app/components/base/switch'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
|
||||
type Props = {
|
||||
icon: any
|
||||
title: any
|
||||
tooltip?: any
|
||||
value: any
|
||||
description?: string
|
||||
children?: React.ReactNode
|
||||
disabled?: boolean
|
||||
onChange?: (state: any) => void
|
||||
onMouseEnter?: () => void
|
||||
onMouseLeave?: () => void
|
||||
}
|
||||
|
||||
const FeatureCard = ({
|
||||
icon,
|
||||
title,
|
||||
tooltip,
|
||||
value,
|
||||
description,
|
||||
children,
|
||||
disabled,
|
||||
onChange,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
}: Props) => {
|
||||
return (
|
||||
<div
|
||||
className='mb-1 p-3 border-t-[0.5px] border-l-[0.5px] border-effects-highlight rounded-xl bg-background-section-burn'
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
<div className='mb-2 flex items-center gap-2'>
|
||||
{icon}
|
||||
<div className='grow flex items-center text-text-secondary system-sm-semibold'>
|
||||
{title}
|
||||
{tooltip && (
|
||||
<Tooltip
|
||||
popupContent={tooltip}
|
||||
>
|
||||
<div className='ml-0.5 p-px'><RiQuestionLine className='w-3.5 h-3.5 text-text-quaternary' /></div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<Switch disabled={disabled} className='shrink-0' onChange={state => onChange?.(state)} defaultValue={value} />
|
||||
</div>
|
||||
{description && (
|
||||
<div className='min-h-8 text-text-tertiary system-xs-regular line-clamp-2'>{description}</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FeatureCard
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
import React, { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import produce from 'immer'
|
||||
import { RiEqualizer2Line } from '@remixicon/react'
|
||||
import { FolderUpload } from '@/app/components/base/icons/src/vender/features'
|
||||
import FeatureCard from '@/app/components/base/features/new-feature-panel/feature-card'
|
||||
import SettingModal from '@/app/components/base/features/new-feature-panel/file-upload/setting-modal'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks'
|
||||
import type { OnFeaturesChange } from '@/app/components/base/features/types'
|
||||
import { FeatureEnum } from '@/app/components/base/features/types'
|
||||
|
||||
type Props = {
|
||||
disabled: boolean
|
||||
onChange?: OnFeaturesChange
|
||||
}
|
||||
|
||||
const FileUpload = ({
|
||||
disabled,
|
||||
onChange,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const file = useFeatures(s => s.features.file)
|
||||
const featuresStore = useFeaturesStore()
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [isHovering, setIsHovering] = useState(false)
|
||||
|
||||
const supportedTypes = useMemo(() => {
|
||||
return file?.allowed_file_types?.join(',') || '-'
|
||||
}, [file?.allowed_file_types])
|
||||
|
||||
const handleChange = useCallback((type: FeatureEnum, enabled: boolean) => {
|
||||
const {
|
||||
features,
|
||||
setFeatures,
|
||||
} = featuresStore!.getState()
|
||||
|
||||
const newFeatures = produce(features, (draft) => {
|
||||
draft[type] = {
|
||||
...draft[type],
|
||||
enabled,
|
||||
image: { enabled },
|
||||
}
|
||||
})
|
||||
setFeatures(newFeatures)
|
||||
if (onChange)
|
||||
onChange()
|
||||
}, [featuresStore, onChange])
|
||||
|
||||
return (
|
||||
<FeatureCard
|
||||
icon={
|
||||
<div className='shrink-0 p-1 rounded-lg border-[0.5px] border-divider-subtle shadow-xs bg-util-colors-blue-blue-600'>
|
||||
<FolderUpload className='w-4 h-4 text-text-primary-on-surface' />
|
||||
</div>
|
||||
}
|
||||
title={t('appDebug.feature.fileUpload.title')}
|
||||
value={file?.enabled}
|
||||
onChange={state => handleChange(FeatureEnum.file, state)}
|
||||
onMouseEnter={() => setIsHovering(true)}
|
||||
onMouseLeave={() => setIsHovering(false)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<>
|
||||
{!file?.enabled && (
|
||||
<div className='min-h-8 text-text-tertiary system-xs-regular line-clamp-2'>{t('appDebug.feature.fileUpload.description')}</div>
|
||||
)}
|
||||
{file?.enabled && (
|
||||
<>
|
||||
{!isHovering && !modalOpen && (
|
||||
<div className='pt-0.5 flex items-center gap-4'>
|
||||
<div className=''>
|
||||
<div className='mb-0.5 text-text-tertiary system-2xs-medium-uppercase'>{t('appDebug.feature.fileUpload.supportedTypes')}</div>
|
||||
<div className='text-text-secondary system-xs-regular'>{supportedTypes}</div>
|
||||
</div>
|
||||
<div className='w-px h-[27px] bg-divider-subtle rotate-12'></div>
|
||||
<div className=''>
|
||||
<div className='mb-0.5 text-text-tertiary system-2xs-medium-uppercase'>{t('appDebug.feature.fileUpload.numberLimit')}</div>
|
||||
<div className='text-text-secondary system-xs-regular'>{file?.number_limits}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(isHovering || modalOpen) && (
|
||||
<SettingModal
|
||||
open={modalOpen && !disabled}
|
||||
onOpen={(v) => {
|
||||
setModalOpen(v)
|
||||
setIsHovering(v)
|
||||
}}
|
||||
onChange={onChange}
|
||||
>
|
||||
<Button className='w-full' disabled={disabled}>
|
||||
<RiEqualizer2Line className='mr-1 w-4 h-4' />
|
||||
{t('common.operation.settings')}
|
||||
</Button>
|
||||
</SettingModal>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
</FeatureCard>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileUpload
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
import React, { useCallback, useMemo, useState } from 'react'
|
||||
import produce from 'immer'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import FileUploadSetting from '@/app/components/workflow/nodes/_base/components/file-upload-setting'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks'
|
||||
import type { OnFeaturesChange } from '@/app/components/base/features/types'
|
||||
import type { UploadFileSetting } from '@/app/components/workflow/types'
|
||||
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
|
||||
|
||||
type SettingContentProps = {
|
||||
imageUpload?: boolean
|
||||
onClose: () => void
|
||||
onChange?: OnFeaturesChange
|
||||
}
|
||||
const SettingContent = ({
|
||||
imageUpload,
|
||||
onClose,
|
||||
onChange,
|
||||
}: SettingContentProps) => {
|
||||
const { t } = useTranslation()
|
||||
const featuresStore = useFeaturesStore()
|
||||
const file = useFeatures(state => state.features.file)
|
||||
const fileSettingPayload = useMemo(() => {
|
||||
return {
|
||||
allowed_file_upload_methods: file?.allowed_file_upload_methods || ['local_file', 'remote_url'],
|
||||
allowed_file_types: file?.allowed_file_types || [SupportUploadFileTypes.image],
|
||||
allowed_file_extensions: file?.allowed_file_extensions || FILE_EXTS[SupportUploadFileTypes.image],
|
||||
max_length: file?.number_limits || 3,
|
||||
} as UploadFileSetting
|
||||
}, [file])
|
||||
const [tempPayload, setTempPayload] = useState<UploadFileSetting>(fileSettingPayload)
|
||||
|
||||
const handleChange = useCallback(() => {
|
||||
const {
|
||||
features,
|
||||
setFeatures,
|
||||
} = featuresStore!.getState()
|
||||
|
||||
const newFeatures = produce(features, (draft) => {
|
||||
draft.file = {
|
||||
...draft.file,
|
||||
allowed_file_upload_methods: tempPayload.allowed_file_upload_methods,
|
||||
number_limits: tempPayload.max_length,
|
||||
allowed_file_types: tempPayload.allowed_file_types,
|
||||
allowed_file_extensions: tempPayload.allowed_file_extensions,
|
||||
}
|
||||
})
|
||||
|
||||
setFeatures(newFeatures)
|
||||
if (onChange)
|
||||
onChange()
|
||||
}, [featuresStore, onChange, tempPayload])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
<div className='text-text-primary system-xl-semibold'>{!imageUpload ? t('appDebug.feature.fileUpload.modalTitle') : t('appDebug.feature.imageUpload.modalTitle')}</div>
|
||||
<div className='p-1 cursor-pointer' onClick={onClose}><RiCloseLine className='w-4 h-4 text-text-tertiary'/></div>
|
||||
</div>
|
||||
<FileUploadSetting
|
||||
isMultiple
|
||||
inFeaturePanel
|
||||
hideSupportFileType={imageUpload}
|
||||
payload={tempPayload}
|
||||
onChange={(p: UploadFileSetting) => setTempPayload(p)}
|
||||
/>
|
||||
<div className='mt-4 flex items-center justify-end'>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
className='mr-2'
|
||||
>
|
||||
{t('common.operation.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='primary'
|
||||
onClick={handleChange}
|
||||
disabled={tempPayload.allowed_file_types.length === 0}
|
||||
>
|
||||
{t('common.operation.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(SettingContent)
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
'use client'
|
||||
import { memo } from 'react'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import SettingContent from '@/app/components/base/features/new-feature-panel/file-upload/setting-content'
|
||||
import type { OnFeaturesChange } from '@/app/components/base/features/types'
|
||||
|
||||
type FileUploadSettingsProps = {
|
||||
open: boolean
|
||||
onOpen: (state: any) => void
|
||||
onChange?: OnFeaturesChange
|
||||
disabled?: boolean
|
||||
children?: React.ReactNode
|
||||
imageUpload?: boolean
|
||||
}
|
||||
const FileUploadSettings = ({
|
||||
open,
|
||||
onOpen,
|
||||
onChange,
|
||||
disabled,
|
||||
children,
|
||||
imageUpload,
|
||||
}: FileUploadSettingsProps) => {
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={onOpen}
|
||||
placement='left'
|
||||
offset={{
|
||||
mainAxis: 32,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger className='flex' onClick={() => !disabled && onOpen((open: boolean) => !open)}>
|
||||
{children}
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent style={{ zIndex: 50 }}>
|
||||
<div className='w-[360px] p-4 bg-components-panel-bg rounded-2xl border-[0.5px] border-components-panel-border shadow-2xl'>
|
||||
<SettingContent
|
||||
imageUpload={imageUpload}
|
||||
onClose={() => onOpen(false)}
|
||||
onChange={(v) => {
|
||||
onChange?.(v)
|
||||
onOpen(false)
|
||||
}} />
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
export default memo(FileUploadSettings)
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
import React, { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import produce from 'immer'
|
||||
import { VirtualAssistant } from '@/app/components/base/icons/src/vender/features'
|
||||
import FeatureCard from '@/app/components/base/features/new-feature-panel/feature-card'
|
||||
import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks'
|
||||
import type { OnFeaturesChange } from '@/app/components/base/features/types'
|
||||
import { FeatureEnum } from '@/app/components/base/features/types'
|
||||
|
||||
type Props = {
|
||||
disabled?: boolean
|
||||
onChange?: OnFeaturesChange
|
||||
}
|
||||
|
||||
const FollowUp = ({
|
||||
disabled,
|
||||
onChange,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const features = useFeatures(s => s.features)
|
||||
const featuresStore = useFeaturesStore()
|
||||
|
||||
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 (
|
||||
<FeatureCard
|
||||
icon={
|
||||
<div className='shrink-0 p-1 rounded-lg border-[0.5px] border-divider-subtle shadow-xs bg-util-colors-blue-light-blue-light-500'>
|
||||
<VirtualAssistant className='w-4 h-4 text-text-primary-on-surface' />
|
||||
</div>
|
||||
}
|
||||
title={t('appDebug.feature.suggestedQuestionsAfterAnswer.title')}
|
||||
value={!!features.suggested?.enabled}
|
||||
description={t('appDebug.feature.suggestedQuestionsAfterAnswer.description')!}
|
||||
onChange={state => handleChange(FeatureEnum.suggested, state)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default FollowUp
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
import React, { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import produce from 'immer'
|
||||
import { RiEqualizer2Line, RiImage2Fill } from '@remixicon/react'
|
||||
import FeatureCard from '@/app/components/base/features/new-feature-panel/feature-card'
|
||||
import SettingModal from '@/app/components/base/features/new-feature-panel/file-upload/setting-modal'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks'
|
||||
import type { OnFeaturesChange } from '@/app/components/base/features/types'
|
||||
import { FeatureEnum } from '@/app/components/base/features/types'
|
||||
|
||||
type Props = {
|
||||
disabled: boolean
|
||||
onChange?: OnFeaturesChange
|
||||
}
|
||||
|
||||
const FileUpload = ({
|
||||
disabled,
|
||||
onChange,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const file = useFeatures(s => s.features.file)
|
||||
const featuresStore = useFeaturesStore()
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [isHovering, setIsHovering] = useState(false)
|
||||
|
||||
const supportedTypes = useMemo(() => {
|
||||
return file?.allowed_file_types?.join(',') || '-'
|
||||
}, [file?.allowed_file_types])
|
||||
|
||||
const handleChange = useCallback((type: FeatureEnum, enabled: boolean) => {
|
||||
const {
|
||||
features,
|
||||
setFeatures,
|
||||
} = featuresStore!.getState()
|
||||
|
||||
const newFeatures = produce(features, (draft) => {
|
||||
draft[type] = {
|
||||
...draft[type],
|
||||
enabled,
|
||||
image: { enabled },
|
||||
}
|
||||
})
|
||||
setFeatures(newFeatures)
|
||||
if (onChange)
|
||||
onChange()
|
||||
}, [featuresStore, onChange])
|
||||
|
||||
return (
|
||||
<FeatureCard
|
||||
icon={
|
||||
<div className='shrink-0 p-1 rounded-lg border-[0.5px] border-divider-subtle shadow-xs bg-util-colors-indigo-indigo-600'>
|
||||
<RiImage2Fill className='w-4 h-4 text-text-primary-on-surface' />
|
||||
</div>
|
||||
}
|
||||
title={
|
||||
<div className='flex items-center'>
|
||||
{t('appDebug.feature.imageUpload.title')}
|
||||
<Badge
|
||||
text='LEGACY'
|
||||
className='shrink-0 mx-1 border-text-accent-secondary text-text-accent-secondary'
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
value={file?.enabled}
|
||||
onChange={state => handleChange(FeatureEnum.file, state)}
|
||||
onMouseEnter={() => setIsHovering(true)}
|
||||
onMouseLeave={() => setIsHovering(false)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<>
|
||||
{!file?.enabled && (
|
||||
<div className='min-h-8 text-text-tertiary system-xs-regular line-clamp-2'>{t('appDebug.feature.imageUpload.description')}</div>
|
||||
)}
|
||||
{file?.enabled && (
|
||||
<>
|
||||
{!isHovering && !modalOpen && (
|
||||
<div className='pt-0.5 flex items-center gap-4'>
|
||||
<div className=''>
|
||||
<div className='mb-0.5 text-text-tertiary system-2xs-medium-uppercase'>{t('appDebug.feature.imageUpload.supportedTypes')}</div>
|
||||
<div className='text-text-secondary system-xs-regular'>{supportedTypes}</div>
|
||||
</div>
|
||||
<div className='w-px h-[27px] bg-divider-subtle rotate-12'></div>
|
||||
<div className=''>
|
||||
<div className='mb-0.5 text-text-tertiary system-2xs-medium-uppercase'>{t('appDebug.feature.imageUpload.numberLimit')}</div>
|
||||
<div className='text-text-secondary system-xs-regular'>{file?.number_limits}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(isHovering || modalOpen) && (
|
||||
<SettingModal
|
||||
imageUpload
|
||||
open={modalOpen && !disabled}
|
||||
onOpen={(v) => {
|
||||
setModalOpen(v)
|
||||
setIsHovering(v)
|
||||
}}
|
||||
onChange={onChange}
|
||||
>
|
||||
<Button className='w-full' disabled={disabled}>
|
||||
<RiEqualizer2Line className='mr-1 w-4 h-4' />
|
||||
{t('common.operation.settings')}
|
||||
</Button>
|
||||
</SettingModal>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
</FeatureCard>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileUpload
|
||||
126
web/app/components/base/features/new-feature-panel/index.tsx
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import { RiCloseLine, RiInformation2Fill } from '@remixicon/react'
|
||||
import DialogWrapper from '@/app/components/base/features/new-feature-panel/dialog-wrapper'
|
||||
import { useDefaultModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import type { OnFeaturesChange } from '@/app/components/base/features/types'
|
||||
|
||||
import MoreLikeThis from '@/app/components/base/features/new-feature-panel/more-like-this'
|
||||
import ConversationOpener from '@/app/components/base/features/new-feature-panel/conversation-opener'
|
||||
import FollowUp from '@/app/components/base/features/new-feature-panel/follow-up'
|
||||
import SpeechToText from '@/app/components/base/features/new-feature-panel/speech-to-text'
|
||||
import TextToSpeech from '@/app/components/base/features/new-feature-panel/text-to-speech'
|
||||
import FileUpload from '@/app/components/base/features/new-feature-panel/file-upload'
|
||||
import Citation from '@/app/components/base/features/new-feature-panel/citation'
|
||||
import ImageUpload from '@/app/components/base/features/new-feature-panel/image-upload'
|
||||
import Moderation from '@/app/components/base/features/new-feature-panel/moderation'
|
||||
import AnnotationReply from '@/app/components/base/features/new-feature-panel/annotation-reply'
|
||||
import type { PromptVariable } from '@/models/debug'
|
||||
import type { InputVar } from '@/app/components/workflow/types'
|
||||
import I18n from '@/context/i18n'
|
||||
import { LanguagesSupported } from '@/i18n/language'
|
||||
|
||||
type Props = {
|
||||
show: boolean
|
||||
isChatMode: boolean
|
||||
disabled: boolean
|
||||
onChange?: OnFeaturesChange
|
||||
onClose: () => void
|
||||
inWorkflow?: boolean
|
||||
showFileUpload?: boolean
|
||||
promptVariables?: PromptVariable[]
|
||||
workflowVariables?: InputVar[]
|
||||
onAutoAddPromptVariable?: (variable: PromptVariable[]) => void
|
||||
}
|
||||
|
||||
const NewFeaturePanel = ({
|
||||
show,
|
||||
isChatMode,
|
||||
disabled,
|
||||
onChange,
|
||||
onClose,
|
||||
inWorkflow = true,
|
||||
showFileUpload = true,
|
||||
promptVariables,
|
||||
workflowVariables,
|
||||
onAutoAddPromptVariable,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const { locale } = useContext(I18n)
|
||||
const { data: speech2textDefaultModel } = useDefaultModel(ModelTypeEnum.speech2text)
|
||||
const { data: text2speechDefaultModel } = useDefaultModel(ModelTypeEnum.tts)
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
show={show}
|
||||
onClose={onClose}
|
||||
inWorkflow={inWorkflow}
|
||||
>
|
||||
<div className='grow flex flex-col h-full'>
|
||||
{/* header */}
|
||||
<div className='shrink-0 flex justify-between p-4 pb-3'>
|
||||
<div>
|
||||
<div className='text-text-primary system-xl-semibold'>{t('workflow.common.features')}</div>
|
||||
<div className='text-text-tertiary body-xs-regular'>{t('workflow.common.featuresDescription')}</div>
|
||||
</div>
|
||||
<div className='w-8 h-8 p-2 cursor-pointer' onClick={onClose}><RiCloseLine className='w-4 h-4 text-text-tertiary'/></div>
|
||||
</div>
|
||||
{/* list */}
|
||||
<div className='grow basis-0 overflow-y-auto px-4 pb-4'>
|
||||
{showFileUpload && (
|
||||
<div className='relative mb-1 p-2 rounded-xl border border-components-panel-border shadow-xs'>
|
||||
<div className='absolute top-0 left-0 w-full h-full rounded-xl opacity-40' style={{ background: 'linear-gradient(92deg, rgba(11, 165, 236, 0.25) 18.12%, rgba(255, 255, 255, 0.00) 167.31%)' }}></div>
|
||||
<div className='relative flex items-start w-full h-full'>
|
||||
<div className='shrink-0 mr-0.5 p-0.5'>
|
||||
<RiInformation2Fill className='w-5 h-5 text-text-accent' />
|
||||
</div>
|
||||
<div className='p-1 text-text-primary system-xs-medium'>
|
||||
<span>{isChatMode ? t('workflow.common.fileUploadTip') : t('workflow.common.ImageUploadLegacyTip')}</span>
|
||||
<a
|
||||
className='text-text-accent'
|
||||
href={`https://docs.dify.ai/${locale === LanguagesSupported[1] && 'v/zh-hans/'}'guides/workflow/bulletin`}
|
||||
target='_blank' rel='noopener noreferrer'
|
||||
>{t('workflow.common.featuresDocLink')}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isChatMode && !inWorkflow && (
|
||||
<MoreLikeThis disabled={disabled} onChange={onChange} />
|
||||
)}
|
||||
{isChatMode && (
|
||||
<ConversationOpener
|
||||
disabled={disabled}
|
||||
onChange={onChange}
|
||||
promptVariables={promptVariables}
|
||||
workflowVariables={workflowVariables}
|
||||
onAutoAddPromptVariable={onAutoAddPromptVariable}
|
||||
/>
|
||||
)}
|
||||
{isChatMode && (
|
||||
<FollowUp disabled={disabled} onChange={onChange} />
|
||||
)}
|
||||
{text2speechDefaultModel && (isChatMode || !inWorkflow) && (
|
||||
<TextToSpeech disabled={disabled} onChange={onChange} />
|
||||
)}
|
||||
{isChatMode && speech2textDefaultModel && (
|
||||
<SpeechToText disabled={disabled} onChange={onChange} />
|
||||
)}
|
||||
{showFileUpload && isChatMode && <FileUpload disabled={disabled} onChange={onChange} />}
|
||||
{showFileUpload && !isChatMode && <ImageUpload disabled={disabled} onChange={onChange} />}
|
||||
{isChatMode && (
|
||||
<Citation disabled={disabled} onChange={onChange} />
|
||||
)}
|
||||
{(isChatMode || !inWorkflow) && <Moderation disabled={disabled} onChange={onChange} />}
|
||||
{!inWorkflow && isChatMode && (
|
||||
<AnnotationReply disabled={disabled} onChange={onChange} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export default NewFeaturePanel
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
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 Textarea from '@/app/components/base/textarea'
|
||||
import type { ModerationConfig } from '@/models/debug'
|
||||
|
||||
type FormGenerationProps = {
|
||||
|
|
@ -45,10 +45,10 @@ const FormGeneration: FC<FormGenerationProps> = ({
|
|||
}
|
||||
{
|
||||
form.type === 'paragraph' && (
|
||||
<div className='relative px-3 py-2 h-[88px] bg-gray-100 rounded-lg'>
|
||||
<textarea
|
||||
<div className='relative'>
|
||||
<Textarea
|
||||
className='resize-none'
|
||||
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)}
|
||||
/>
|
||||
|
|
@ -77,4 +77,4 @@ const FormGeneration: FC<FormGenerationProps> = ({
|
|||
)
|
||||
}
|
||||
|
||||
export default memo(FormGeneration)
|
||||
export default FormGeneration
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
import React, { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useSWR from 'swr'
|
||||
import produce from 'immer'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import { RiEqualizer2Line } from '@remixicon/react'
|
||||
import { ContentModeration } from '@/app/components/base/icons/src/vender/features'
|
||||
import FeatureCard from '@/app/components/base/features/new-feature-panel/feature-card'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks'
|
||||
import type { OnFeaturesChange } from '@/app/components/base/features/types'
|
||||
import { FeatureEnum } from '@/app/components/base/features/types'
|
||||
import { fetchCodeBasedExtensionList } from '@/service/common'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import I18n from '@/context/i18n'
|
||||
|
||||
type Props = {
|
||||
disabled?: boolean
|
||||
onChange?: OnFeaturesChange
|
||||
}
|
||||
|
||||
const Moderation = ({
|
||||
disabled,
|
||||
onChange,
|
||||
}: Props) => {
|
||||
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 [isHovering, setIsHovering] = useState(false)
|
||||
|
||||
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)
|
||||
},
|
||||
onCancelCallback: () => {
|
||||
if (onChange)
|
||||
onChange()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleChange = useCallback((type: FeatureEnum, enabled: boolean) => {
|
||||
const {
|
||||
features,
|
||||
setFeatures,
|
||||
} = featuresStore!.getState()
|
||||
|
||||
if (enabled && !features.moderation?.type && type === FeatureEnum.moderation) {
|
||||
setShowModerationSettingModal({
|
||||
payload: {
|
||||
enabled: true,
|
||||
type: 'keywords',
|
||||
config: {
|
||||
keywords: '',
|
||||
inputs_config: {
|
||||
enabled: true,
|
||||
preset_response: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
onSaveCallback: (newModeration) => {
|
||||
const newFeatures = produce(features, (draft) => {
|
||||
draft.moderation = newModeration
|
||||
})
|
||||
setFeatures(newFeatures)
|
||||
if (onChange)
|
||||
onChange(newFeatures)
|
||||
},
|
||||
onCancelCallback: () => {
|
||||
const newFeatures = produce(features, (draft) => {
|
||||
draft.moderation = { enabled: false }
|
||||
})
|
||||
setFeatures(newFeatures)
|
||||
if (onChange)
|
||||
onChange()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (!enabled) {
|
||||
const newFeatures = produce(features, (draft) => {
|
||||
draft.moderation = { enabled: false }
|
||||
})
|
||||
setFeatures(newFeatures)
|
||||
if (onChange)
|
||||
onChange(newFeatures)
|
||||
}
|
||||
}, [featuresStore, onChange, setShowModerationSettingModal])
|
||||
|
||||
const providerContent = useMemo(() => {
|
||||
if (moderation?.type === 'openai_moderation')
|
||||
return t('appDebug.feature.moderation.modal.provider.openai')
|
||||
else if (moderation?.type === 'keywords')
|
||||
return t('appDebug.feature.moderation.modal.provider.keywords')
|
||||
else if (moderation?.type === 'api')
|
||||
return t('common.apiBasedExtension.selector.title')
|
||||
else
|
||||
return codeBasedExtensionList?.data.find(item => item.name === moderation?.type)?.label[locale] || '-'
|
||||
}, [codeBasedExtensionList?.data, locale, moderation?.type, t])
|
||||
|
||||
const enableContent = useMemo(() => {
|
||||
if (moderation?.config?.inputs_config?.enabled && moderation.config?.outputs_config?.enabled)
|
||||
return t('appDebug.feature.moderation.allEnabled')
|
||||
else if (moderation?.config?.inputs_config?.enabled)
|
||||
return t('appDebug.feature.moderation.inputEnabled')
|
||||
else if (moderation?.config?.outputs_config?.enabled)
|
||||
return t('appDebug.feature.moderation.outputEnabled')
|
||||
}, [moderation?.config?.inputs_config?.enabled, moderation?.config?.outputs_config?.enabled, t])
|
||||
|
||||
return (
|
||||
<FeatureCard
|
||||
icon={
|
||||
<div className='shrink-0 p-1 rounded-lg border-[0.5px] border-divider-subtle shadow-xs bg-text-success'>
|
||||
<ContentModeration className='w-4 h-4 text-text-primary-on-surface' />
|
||||
</div>
|
||||
}
|
||||
title={t('appDebug.feature.moderation.title')}
|
||||
value={!!moderation?.enabled}
|
||||
onChange={state => handleChange(FeatureEnum.moderation, state)}
|
||||
onMouseEnter={() => setIsHovering(true)}
|
||||
onMouseLeave={() => setIsHovering(false)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<>
|
||||
{!moderation?.enabled && (
|
||||
<div className='min-h-8 text-text-tertiary system-xs-regular line-clamp-2'>{t('appDebug.feature.moderation.description')}</div>
|
||||
)}
|
||||
{!!moderation?.enabled && (
|
||||
<>
|
||||
{!isHovering && (
|
||||
<div className='pt-0.5 flex items-center gap-4'>
|
||||
<div className=''>
|
||||
<div className='mb-0.5 text-text-tertiary system-2xs-medium-uppercase'>{t('appDebug.feature.moderation.modal.provider.title')}</div>
|
||||
<div className='text-text-secondary system-xs-regular'>{providerContent}</div>
|
||||
</div>
|
||||
<div className='w-px h-[27px] bg-divider-subtle rotate-12'></div>
|
||||
<div className=''>
|
||||
<div className='mb-0.5 text-text-tertiary system-2xs-medium-uppercase'>{t('appDebug.feature.moderation.contentEnableLabel')}</div>
|
||||
<div className='text-text-secondary system-xs-regular'>{enableContent}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isHovering && (
|
||||
<Button className='w-full' onClick={handleOpenModerationSettingModal} disabled={disabled}>
|
||||
<RiEqualizer2Line className='mr-1 w-4 h-4' />
|
||||
{t('common.operation.settings')}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
</FeatureCard>
|
||||
)
|
||||
}
|
||||
|
||||
export default Moderation
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
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'
|
||||
|
|
@ -70,4 +69,4 @@ const ModerationContent: FC<ModerationContentProps> = ({
|
|||
)
|
||||
}
|
||||
|
||||
export default memo(ModerationContent)
|
||||
export default ModerationContent
|
||||
|
|
@ -1,11 +1,9 @@
|
|||
import type { ChangeEvent, FC } from 'react'
|
||||
import {
|
||||
memo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useState } from 'react'
|
||||
import useSWR from 'swr'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import ModerationContent from './moderation-content'
|
||||
import FormGeneration from './form-generation'
|
||||
import ApiBasedExtensionSelector from '@/app/components/header/account-setting/api-based-extension-page/selector'
|
||||
|
|
@ -240,10 +238,11 @@ const ModerationSettingModal: FC<ModerationSettingModalProps> = ({
|
|||
<Modal
|
||||
isShow
|
||||
onClose={() => { }}
|
||||
className='!p-8 !pb-6 !mt-14 !max-w-none !w-[640px]'
|
||||
className='!p-6 !mt-14 !max-w-none !w-[600px]'
|
||||
>
|
||||
<div className='mb-2 text-xl font-semibold text-[#1D2939]'>
|
||||
{t('appDebug.feature.moderation.modal.title')}
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='text-text-primary title-2xl-semi-bold'>{t('appDebug.feature.moderation.modal.title')}</div>
|
||||
<div className='p-1 cursor-pointer' onClick={onCancel}><RiCloseLine className='w-4 h-4 text-text-tertiary'/></div>
|
||||
</div>
|
||||
<div className='py-2'>
|
||||
<div className='leading-9 text-sm font-medium text-gray-900'>
|
||||
|
|
@ -373,4 +372,4 @@ const ModerationSettingModal: FC<ModerationSettingModalProps> = ({
|
|||
)
|
||||
}
|
||||
|
||||
export default memo(ModerationSettingModal)
|
||||
export default ModerationSettingModal
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
import React, { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import produce from 'immer'
|
||||
import { RiSparklingFill } from '@remixicon/react'
|
||||
import FeatureCard from '@/app/components/base/features/new-feature-panel/feature-card'
|
||||
import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks'
|
||||
import type { OnFeaturesChange } from '@/app/components/base/features/types'
|
||||
import { FeatureEnum } from '@/app/components/base/features/types'
|
||||
|
||||
type Props = {
|
||||
disabled?: boolean
|
||||
onChange?: OnFeaturesChange
|
||||
}
|
||||
|
||||
const MoreLikeThis = ({
|
||||
disabled,
|
||||
onChange,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const features = useFeatures(s => s.features)
|
||||
const featuresStore = useFeaturesStore()
|
||||
|
||||
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()
|
||||
}, [featuresStore, onChange])
|
||||
|
||||
return (
|
||||
<FeatureCard
|
||||
icon={
|
||||
<div className='shrink-0 p-1 rounded-lg border-[0.5px] border-divider-subtle shadow-xs bg-util-colors-blue-light-blue-light-500'>
|
||||
<RiSparklingFill className='w-4 h-4 text-text-primary-on-surface' />
|
||||
</div>
|
||||
}
|
||||
title={t('appDebug.feature.moreLikeThis.title')}
|
||||
tooltip={t('appDebug.feature.moreLikeThis.tip')}
|
||||
value={!!features.moreLikeThis?.enabled}
|
||||
description={t('appDebug.feature.moreLikeThis.description')!}
|
||||
onChange={state => handleChange(FeatureEnum.moreLikeThis, state)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default MoreLikeThis
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
import React, { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import produce from 'immer'
|
||||
import { Microphone01 } from '@/app/components/base/icons/src/vender/features'
|
||||
import FeatureCard from '@/app/components/base/features/new-feature-panel/feature-card'
|
||||
import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks'
|
||||
import type { OnFeaturesChange } from '@/app/components/base/features/types'
|
||||
import { FeatureEnum } from '@/app/components/base/features/types'
|
||||
|
||||
type Props = {
|
||||
disabled: boolean
|
||||
onChange?: OnFeaturesChange
|
||||
}
|
||||
|
||||
const SpeechToText = ({
|
||||
disabled,
|
||||
onChange,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const features = useFeatures(s => s.features)
|
||||
const featuresStore = useFeaturesStore()
|
||||
|
||||
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()
|
||||
}, [featuresStore, onChange])
|
||||
|
||||
return (
|
||||
<FeatureCard
|
||||
icon={
|
||||
<div className='shrink-0 p-1 rounded-lg border-[0.5px] border-divider-subtle shadow-xs bg-util-colors-violet-violet-600'>
|
||||
<Microphone01 className='w-4 h-4 text-text-primary-on-surface' />
|
||||
</div>
|
||||
}
|
||||
title={t('appDebug.feature.speechToText.title')}
|
||||
value={!!features.speech2text?.enabled}
|
||||
description={t('appDebug.feature.speechToText.description')!}
|
||||
onChange={state => handleChange(FeatureEnum.speech2text, state)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default SpeechToText
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
import React, { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import produce from 'immer'
|
||||
import { RiEqualizer2Line } from '@remixicon/react'
|
||||
import { TextToAudio } from '@/app/components/base/icons/src/vender/features'
|
||||
import FeatureCard from '@/app/components/base/features/new-feature-panel/feature-card'
|
||||
import Button from '@/app/components/base/button'
|
||||
import VoiceSettings from '@/app/components/base/features/new-feature-panel/text-to-speech/voice-settings'
|
||||
import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks'
|
||||
import type { OnFeaturesChange } from '@/app/components/base/features/types'
|
||||
import { FeatureEnum } from '@/app/components/base/features/types'
|
||||
import { languages } from '@/i18n/language'
|
||||
import { TtsAutoPlay } from '@/types/app'
|
||||
|
||||
type Props = {
|
||||
disabled: boolean
|
||||
onChange?: OnFeaturesChange
|
||||
}
|
||||
|
||||
const TextToSpeech = ({
|
||||
disabled,
|
||||
onChange,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const textToSpeech = useFeatures(s => s.features.text2speech) // .language .voice .autoPlay
|
||||
const languageInfo = languages.find(i => i.value === textToSpeech?.language)
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [isHovering, setIsHovering] = useState(false)
|
||||
const features = useFeatures(s => s.features)
|
||||
const featuresStore = useFeaturesStore()
|
||||
|
||||
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()
|
||||
}, [featuresStore, onChange])
|
||||
|
||||
return (
|
||||
<FeatureCard
|
||||
icon={
|
||||
<div className='shrink-0 p-1 rounded-lg border-[0.5px] border-divider-subtle shadow-xs bg-util-colors-violet-violet-600'>
|
||||
<TextToAudio className='w-4 h-4 text-text-primary-on-surface' />
|
||||
</div>
|
||||
}
|
||||
title={t('appDebug.feature.textToSpeech.title')}
|
||||
value={!!features.text2speech?.enabled}
|
||||
onChange={state => handleChange(FeatureEnum.text2speech, state)}
|
||||
onMouseEnter={() => setIsHovering(true)}
|
||||
onMouseLeave={() => setIsHovering(false)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<>
|
||||
{!features.text2speech?.enabled && (
|
||||
<div className='min-h-8 text-text-tertiary system-xs-regular line-clamp-2'>{t('appDebug.feature.textToSpeech.description')}</div>
|
||||
)}
|
||||
{!!features.text2speech?.enabled && (
|
||||
<>
|
||||
{!isHovering && !modalOpen && (
|
||||
<div className='pt-0.5 flex items-center gap-4'>
|
||||
<div className=''>
|
||||
<div className='mb-0.5 text-text-tertiary system-2xs-medium-uppercase'>{t('appDebug.voice.voiceSettings.language')}</div>
|
||||
<div className='text-text-secondary system-xs-regular'>{languageInfo?.name || '-'}</div>
|
||||
</div>
|
||||
<div className='w-px h-[27px] bg-divider-subtle rotate-12'></div>
|
||||
<div className=''>
|
||||
<div className='mb-0.5 text-text-tertiary system-2xs-medium-uppercase'>{t('appDebug.voice.voiceSettings.voice')}</div>
|
||||
<div className='text-text-secondary system-xs-regular'>{features.text2speech?.voice || t('appDebug.voice.defaultDisplay')}</div>
|
||||
</div>
|
||||
<div className='w-px h-[27px] bg-divider-subtle rotate-12'></div>
|
||||
<div className=''>
|
||||
<div className='mb-0.5 text-text-tertiary system-2xs-medium-uppercase'>{t('appDebug.voice.voiceSettings.autoPlay')}</div>
|
||||
<div className='text-text-secondary system-xs-regular'>{features.text2speech?.autoPlay === TtsAutoPlay.enabled ? t('appDebug.voice.voiceSettings.autoPlayEnabled') : t('appDebug.voice.voiceSettings.autoPlayDisabled')}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(isHovering || modalOpen) && (
|
||||
<VoiceSettings open={modalOpen && !disabled} onOpen={setModalOpen} onChange={onChange}>
|
||||
<Button className='w-full' disabled={disabled}>
|
||||
<RiEqualizer2Line className='mr-1 w-4 h-4' />
|
||||
{t('appDebug.voice.voiceSettings.title')}
|
||||
</Button>
|
||||
</VoiceSettings>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
</FeatureCard>
|
||||
)
|
||||
}
|
||||
|
||||
export default TextToSpeech
|
||||
|
|
@ -0,0 +1,242 @@
|
|||
'use client'
|
||||
import useSWR from 'swr'
|
||||
import produce from 'immer'
|
||||
import React, { Fragment } from 'react'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { Listbox, Transition } from '@headlessui/react'
|
||||
import { CheckIcon, ChevronDownIcon } from '@heroicons/react/20/solid'
|
||||
import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks'
|
||||
import type { Item } from '@/app/components/base/select'
|
||||
import { fetchAppVoices } from '@/service/apps'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import Switch from '@/app/components/base/switch'
|
||||
import AudioBtn from '@/app/components/base/audio-btn'
|
||||
import { languages } from '@/i18n/language'
|
||||
import { TtsAutoPlay } from '@/types/app'
|
||||
import type { OnFeaturesChange } from '@/app/components/base/features/types'
|
||||
import classNames from '@/utils/classnames'
|
||||
|
||||
type VoiceParamConfigProps = {
|
||||
onClose: () => void
|
||||
onChange?: OnFeaturesChange
|
||||
}
|
||||
const VoiceParamConfig = ({
|
||||
onClose,
|
||||
onChange,
|
||||
}: VoiceParamConfigProps) => {
|
||||
const { t } = useTranslation()
|
||||
const pathname = usePathname()
|
||||
const matched = pathname.match(/\/app\/([^/]+)/)
|
||||
const appId = (matched?.length && matched[1]) ? matched[1] : ''
|
||||
const text2speech = useFeatures(state => state.features.text2speech)
|
||||
const featuresStore = useFeaturesStore()
|
||||
|
||||
let languageItem = languages.find(item => item.value === text2speech?.language)
|
||||
if (languages && !languageItem)
|
||||
languageItem = languages[0]
|
||||
const localLanguagePlaceholder = languageItem?.name || t('common.placeholder.select')
|
||||
|
||||
const language = languageItem?.value
|
||||
const voiceItems = useSWR({ appId, language }, fetchAppVoices).data
|
||||
let voiceItem = voiceItems?.find(item => item.value === text2speech?.voice)
|
||||
if (voiceItems && !voiceItem)
|
||||
voiceItem = voiceItems[0]
|
||||
const localVoicePlaceholder = voiceItem?.name || t('common.placeholder.select')
|
||||
|
||||
const handleChange = (value: Record<string, string>) => {
|
||||
const {
|
||||
features,
|
||||
setFeatures,
|
||||
} = featuresStore!.getState()
|
||||
|
||||
const newFeatures = produce(features, (draft) => {
|
||||
draft.text2speech = {
|
||||
...draft.text2speech,
|
||||
...value,
|
||||
}
|
||||
})
|
||||
|
||||
setFeatures(newFeatures)
|
||||
if (onChange)
|
||||
onChange()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
<div className='text-text-primary system-xl-semibold'>{t('appDebug.voice.voiceSettings.title')}</div>
|
||||
<div className='p-1 cursor-pointer' onClick={onClose}><RiCloseLine className='w-4 h-4 text-text-tertiary'/></div>
|
||||
</div>
|
||||
<div className='mb-3'>
|
||||
<div className='mb-1 py-1 flex items-center text-text-secondary system-sm-semibold'>
|
||||
{t('appDebug.voice.voiceSettings.language')}
|
||||
<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-8'>
|
||||
<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 className='mb-3'>
|
||||
<div className='mb-1 py-1 text-text-secondary system-sm-semibold'>
|
||||
{t('appDebug.voice.voiceSettings.voice')}
|
||||
</div>
|
||||
<div className='flex items-center gap-1'>
|
||||
<Listbox
|
||||
value={voiceItem ?? {}}
|
||||
disabled={!languageItem}
|
||||
onChange={(value: Item) => {
|
||||
handleChange({
|
||||
voice: String(value.value),
|
||||
})
|
||||
}}
|
||||
>
|
||||
<div className={'grow relative h-8'}>
|
||||
<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>
|
||||
{languageItem?.example && (
|
||||
<div className='shrink-0 h-8 p-1 rounded-lg bg-components-button-tertiary-bg'>
|
||||
<AudioBtn
|
||||
value={languageItem?.example}
|
||||
isAudition
|
||||
voice={text2speech?.voice}
|
||||
noCache
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className='mb-1 py-1 text-text-secondary system-sm-semibold'>
|
||||
{t('appDebug.voice.voiceSettings.autoPlay')}
|
||||
</div>
|
||||
<Switch className='shrink-0'
|
||||
defaultValue={text2speech?.autoPlay === TtsAutoPlay.enabled}
|
||||
onChange={(value: boolean) => {
|
||||
handleChange({
|
||||
autoPlay: value ? TtsAutoPlay.enabled : TtsAutoPlay.disabled,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(VoiceParamConfig)
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
'use client'
|
||||
import { memo } from 'react'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import ParamConfigContent from '@/app/components/base/features/new-feature-panel/text-to-speech/param-config-content'
|
||||
import type { OnFeaturesChange } from '@/app/components/base/features/types'
|
||||
|
||||
type VoiceSettingsProps = {
|
||||
open: boolean
|
||||
onOpen: (state: any) => void
|
||||
onChange?: OnFeaturesChange
|
||||
disabled?: boolean
|
||||
children?: React.ReactNode
|
||||
placementLeft?: boolean
|
||||
}
|
||||
const VoiceSettings = ({
|
||||
open,
|
||||
onOpen,
|
||||
onChange,
|
||||
disabled,
|
||||
children,
|
||||
placementLeft = true,
|
||||
}: VoiceSettingsProps) => {
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={onOpen}
|
||||
placement={placementLeft ? 'left' : 'top'}
|
||||
offset={{
|
||||
mainAxis: placementLeft ? 32 : 4,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger className='flex' onClick={() => !disabled && onOpen((open: boolean) => !open)}>
|
||||
{children}
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent style={{ zIndex: 50 }}>
|
||||
<div className='w-[360px] p-4 bg-components-panel-bg rounded-2xl border-[0.5px] border-components-panel-border shadow-2xl'>
|
||||
<ParamConfigContent onClose={() => onOpen(false)} onChange={onChange} />
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
export default memo(VoiceSettings)
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { createStore } from 'zustand'
|
||||
import type { Features } from './types'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import { Resolution, TransferMethod } from '@/types/app'
|
||||
|
||||
export type FeaturesModal = {
|
||||
showFeaturesModal: boolean
|
||||
|
|
@ -22,6 +22,9 @@ export type FeaturesStore = ReturnType<typeof createFeaturesStore>
|
|||
export const createFeaturesStore = (initProps?: Partial<FeaturesState>) => {
|
||||
const DEFAULT_PROPS: FeaturesState = {
|
||||
features: {
|
||||
moreLikeThis: {
|
||||
enabled: false,
|
||||
},
|
||||
opening: {
|
||||
enabled: false,
|
||||
},
|
||||
|
|
@ -43,10 +46,14 @@ export const createFeaturesStore = (initProps?: Partial<FeaturesState>) => {
|
|||
file: {
|
||||
image: {
|
||||
enabled: false,
|
||||
detail: Resolution.high,
|
||||
number_limits: 3,
|
||||
transfer_methods: [TransferMethod.local_file, TransferMethod.remote_url],
|
||||
},
|
||||
},
|
||||
annotationReply: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
return createStore<FeatureStoreState>()(set => ({
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import type { TransferMethod, TtsAutoPlay } from '@/types/app'
|
||||
import type { Resolution, TransferMethod, TtsAutoPlay } from '@/types/app'
|
||||
|
||||
export type EnabledOrDisabled = {
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export type MoreLikeThis = EnabledOrDisabled
|
||||
|
||||
export type OpeningStatement = EnabledOrDisabled & {
|
||||
opening_statement?: string
|
||||
suggested_questions?: string[]
|
||||
|
|
@ -28,12 +30,28 @@ export type SensitiveWordAvoidance = EnabledOrDisabled & {
|
|||
|
||||
export type FileUpload = {
|
||||
image?: EnabledOrDisabled & {
|
||||
detail?: Resolution
|
||||
number_limits?: number
|
||||
transfer_methods?: TransferMethod[]
|
||||
}
|
||||
allowed_file_types?: string[]
|
||||
allowed_file_extensions?: string[]
|
||||
allowed_file_upload_methods?: TransferMethod[]
|
||||
number_limits?: number
|
||||
} & EnabledOrDisabled
|
||||
|
||||
export type AnnotationReplyConfig = {
|
||||
enabled: boolean
|
||||
id?: string
|
||||
score_threshold?: number
|
||||
embedding_model?: {
|
||||
embedding_provider_name: string
|
||||
embedding_model_name: string
|
||||
}
|
||||
}
|
||||
|
||||
export enum FeatureEnum {
|
||||
moreLikeThis = 'moreLikeThis',
|
||||
opening = 'opening',
|
||||
suggested = 'suggested',
|
||||
text2speech = 'text2speech',
|
||||
|
|
@ -41,9 +59,11 @@ export enum FeatureEnum {
|
|||
citation = 'citation',
|
||||
moderation = 'moderation',
|
||||
file = 'file',
|
||||
annotationReply = 'annotationReply',
|
||||
}
|
||||
|
||||
export type Features = {
|
||||
[FeatureEnum.moreLikeThis]?: MoreLikeThis
|
||||
[FeatureEnum.opening]?: OpeningStatement
|
||||
[FeatureEnum.suggested]?: SuggestedQuestionsAfterAnswer
|
||||
[FeatureEnum.text2speech]?: TextToSpeech
|
||||
|
|
@ -51,6 +71,7 @@ export type Features = {
|
|||
[FeatureEnum.citation]?: RetrieverResource
|
||||
[FeatureEnum.moderation]?: SensitiveWordAvoidance
|
||||
[FeatureEnum.file]?: FileUpload
|
||||
[FeatureEnum.annotationReply]?: AnnotationReplyConfig
|
||||
}
|
||||
|
||||
export type OnFeaturesChange = (features: Features) => void
|
||||
export type OnFeaturesChange = (features?: Features) => void
|
||||
|
|
|
|||
3
web/app/components/base/file-uploader/constants.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export const FILE_SIZE_LIMIT = 15 * 1024 * 1024
|
||||
|
||||
export const FILE_URL_REGEX = /^(https?|ftp):\/\//
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
import {
|
||||
memo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiUploadCloud2Line } from '@remixicon/react'
|
||||
import FileInput from '../file-input'
|
||||
import { useFile } from '../hooks'
|
||||
import { useStore } from '../store'
|
||||
import { FILE_URL_REGEX } from '../constants'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import Button from '@/app/components/base/button'
|
||||
import type { FileUpload } from '@/app/components/base/features/types'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type FileFromLinkOrLocalProps = {
|
||||
showFromLink?: boolean
|
||||
showFromLocal?: boolean
|
||||
trigger: (open: boolean) => React.ReactNode
|
||||
fileConfig: FileUpload
|
||||
}
|
||||
const FileFromLinkOrLocal = ({
|
||||
showFromLink = true,
|
||||
showFromLocal = true,
|
||||
trigger,
|
||||
fileConfig,
|
||||
}: FileFromLinkOrLocalProps) => {
|
||||
const { t } = useTranslation()
|
||||
const files = useStore(s => s.files)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [url, setUrl] = useState('')
|
||||
const [showError, setShowError] = useState(false)
|
||||
const { handleLoadFileFromLink } = useFile(fileConfig)
|
||||
const disabled = !!fileConfig.number_limits && files.length >= fileConfig.number_limits
|
||||
|
||||
const handleSaveUrl = () => {
|
||||
if (!url)
|
||||
return
|
||||
|
||||
if (!FILE_URL_REGEX.test(url)) {
|
||||
setShowError(true)
|
||||
return
|
||||
}
|
||||
handleLoadFileFromLink(url)
|
||||
setUrl('')
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
placement='top'
|
||||
offset={4}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)} asChild>
|
||||
{trigger(open)}
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className='z-10'>
|
||||
<div className='p-3 w-[280px] bg-components-panel-bg-blur border-[0.5px] border-components-panel-border rounded-xl shadow-lg'>
|
||||
{
|
||||
showFromLink && (
|
||||
<>
|
||||
<div className={cn(
|
||||
'flex items-center p-1 h-8 bg-components-input-bg-active border border-components-input-border-active rounded-lg shadow-xs',
|
||||
showError && 'border-components-input-border-destructive',
|
||||
)}>
|
||||
<input
|
||||
className='grow block mr-0.5 px-1 bg-transparent system-sm-regular outline-none appearance-none'
|
||||
placeholder={t('common.fileUploader.pasteFileLinkInputPlaceholder') || ''}
|
||||
value={url}
|
||||
onChange={(e) => {
|
||||
setShowError(false)
|
||||
setUrl(e.target.value)
|
||||
}}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Button
|
||||
className='shrink-0'
|
||||
size='small'
|
||||
variant='primary'
|
||||
disabled={!url || disabled}
|
||||
onClick={handleSaveUrl}
|
||||
>
|
||||
{t('common.operation.ok')}
|
||||
</Button>
|
||||
</div>
|
||||
{
|
||||
showError && (
|
||||
<div className='mt-0.5 body-xs-regular text-text-destructive'>
|
||||
{t('common.fileUploader.pasteFileLinkInvalid')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
showFromLink && showFromLocal && (
|
||||
<div className='flex items-center p-2 h-7 system-2xs-medium-uppercase text-text-quaternary'>
|
||||
<div className='mr-2 w-[93px] h-[1px] bg-gradient-to-l from-[rgba(16,24,40,0.08)]' />
|
||||
OR
|
||||
<div className='ml-2 w-[93px] h-[1px] bg-gradient-to-r from-[rgba(16,24,40,0.08)]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
showFromLocal && (
|
||||
<Button
|
||||
className='relative w-full'
|
||||
variant='secondary-accent'
|
||||
disabled={disabled}
|
||||
>
|
||||
<RiUploadCloud2Line className='mr-1 w-4 h-4' />
|
||||
{t('common.fileUploader.uploadFromComputer')}
|
||||
<FileInput fileConfig={fileConfig} />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(FileFromLinkOrLocal)
|
||||
32
web/app/components/base/file-uploader/file-image-render.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import cn from '@/utils/classnames'
|
||||
|
||||
type FileImageRenderProps = {
|
||||
imageUrl: string
|
||||
className?: string
|
||||
alt?: string
|
||||
onLoad?: () => void
|
||||
onError?: () => void
|
||||
showDownloadAction?: boolean
|
||||
}
|
||||
const FileImageRender = ({
|
||||
imageUrl,
|
||||
className,
|
||||
alt,
|
||||
onLoad,
|
||||
onError,
|
||||
showDownloadAction,
|
||||
}: FileImageRenderProps) => {
|
||||
return (
|
||||
<div className={cn('border-[2px] border-effects-image-frame shadow-xs', className)}>
|
||||
<img
|
||||
className={cn('w-full h-full object-cover', showDownloadAction && 'cursor-pointer')}
|
||||
alt={alt}
|
||||
onLoad={onLoad}
|
||||
onError={onError}
|
||||
src={imageUrl}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileImageRender
|
||||