diff --git a/web/app/components/base/features/context.tsx b/web/app/components/base/features/context.tsx new file mode 100644 index 0000000000..3c9347c0c5 --- /dev/null +++ b/web/app/components/base/features/context.tsx @@ -0,0 +1,27 @@ +import { + createContext, + useRef, +} from 'react' +import type { + FeaturesState, + FeaturesStore, +} from './store' +import { createFeaturesStore } from './store' + +export const FeaturesContext = createContext(null) + +type FeaturesProviderProps = { + children: React.ReactNode +} & Partial +export const FeaturesProvider = ({ children, ...props }: FeaturesProviderProps) => { + const storeRef = useRef() + + if (!storeRef.current) + storeRef.current = createFeaturesStore(props) + + return ( + + {children} + + ) +} diff --git a/web/app/components/base/features/feature-choose/feature-group/index.tsx b/web/app/components/base/features/feature-choose/feature-group/index.tsx new file mode 100644 index 0000000000..a4b27f18d4 --- /dev/null +++ b/web/app/components/base/features/feature-choose/feature-group/index.tsx @@ -0,0 +1,31 @@ +'use client' +import type { FC } from 'react' +import React from 'react' +import GroupName from '@/app/components/app/configuration/base/group-name' + +export type IFeatureGroupProps = { + title: string + description?: string + children: React.ReactNode +} + +const FeatureGroup: FC = ({ + title, + description, + children, +}) => { + return ( +
+
+ + {description && ( +
{description}
+ )} +
+
+ {children} +
+
+ ) +} +export default React.memo(FeatureGroup) diff --git a/web/app/components/base/features/feature-choose/feature-item/index.tsx b/web/app/components/base/features/feature-choose/feature-item/index.tsx new file mode 100644 index 0000000000..0d7ab4e02c --- /dev/null +++ b/web/app/components/base/features/feature-choose/feature-item/index.tsx @@ -0,0 +1,52 @@ +'use client' +import type { FC } from 'react' +import React from 'react' +import cn from 'classnames' +import s from './style.module.css' +import Switch from '@/app/components/base/switch' + +export type IFeatureItemProps = { + icon: React.ReactNode + previewImgClassName?: string + title: string + description: string + value: boolean + onChange: (value: boolean) => void +} + +const FeatureItem: FC = ({ + icon, + previewImgClassName, + title, + description, + value, + onChange, +}) => { + return ( +
+
+ {/* icon */} +
+ {icon} +
+
+
{title}
+
{description}
+
+
+ + + { + previewImgClassName && ( +
+
) + } +
+ ) +} +export default React.memo(FeatureItem) diff --git a/web/app/components/base/features/feature-choose/feature-item/preview-imgs/citation.svg b/web/app/components/base/features/feature-choose/feature-item/preview-imgs/citation.svg new file mode 100644 index 0000000000..82fb182a7a --- /dev/null +++ b/web/app/components/base/features/feature-choose/feature-item/preview-imgs/citation.svg @@ -0,0 +1,150 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/app/components/base/features/feature-choose/feature-item/preview-imgs/citations-and-attributions-preview@2x.png b/web/app/components/base/features/feature-choose/feature-item/preview-imgs/citations-and-attributions-preview@2x.png new file mode 100644 index 0000000000..ef066204ca Binary files /dev/null and b/web/app/components/base/features/feature-choose/feature-item/preview-imgs/citations-and-attributions-preview@2x.png differ diff --git a/web/app/components/base/features/feature-choose/feature-item/preview-imgs/conversation-opener-preview@2x.png b/web/app/components/base/features/feature-choose/feature-item/preview-imgs/conversation-opener-preview@2x.png new file mode 100644 index 0000000000..15639d500d Binary files /dev/null and b/web/app/components/base/features/feature-choose/feature-item/preview-imgs/conversation-opener-preview@2x.png differ diff --git a/web/app/components/base/features/feature-choose/feature-item/preview-imgs/more-like-this-preview@2x.png b/web/app/components/base/features/feature-choose/feature-item/preview-imgs/more-like-this-preview@2x.png new file mode 100644 index 0000000000..62671c5889 Binary files /dev/null and b/web/app/components/base/features/feature-choose/feature-item/preview-imgs/more-like-this-preview@2x.png differ diff --git a/web/app/components/base/features/feature-choose/feature-item/preview-imgs/more-like-this.svg b/web/app/components/base/features/feature-choose/feature-item/preview-imgs/more-like-this.svg new file mode 100644 index 0000000000..6ccc84e70f --- /dev/null +++ b/web/app/components/base/features/feature-choose/feature-item/preview-imgs/more-like-this.svg @@ -0,0 +1,188 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/app/components/base/features/feature-choose/feature-item/preview-imgs/next-question-suggestion-preview@2x.png b/web/app/components/base/features/feature-choose/feature-item/preview-imgs/next-question-suggestion-preview@2x.png new file mode 100644 index 0000000000..758708ff15 Binary files /dev/null and b/web/app/components/base/features/feature-choose/feature-item/preview-imgs/next-question-suggestion-preview@2x.png differ diff --git a/web/app/components/base/features/feature-choose/feature-item/preview-imgs/opening-statement.png b/web/app/components/base/features/feature-choose/feature-item/preview-imgs/opening-statement.png new file mode 100644 index 0000000000..51e1bf395e Binary files /dev/null and b/web/app/components/base/features/feature-choose/feature-item/preview-imgs/opening-statement.png differ diff --git a/web/app/components/base/features/feature-choose/feature-item/preview-imgs/opening-suggestion-preview@2x.png b/web/app/components/base/features/feature-choose/feature-item/preview-imgs/opening-suggestion-preview@2x.png new file mode 100644 index 0000000000..8bb4add322 Binary files /dev/null and b/web/app/components/base/features/feature-choose/feature-item/preview-imgs/opening-suggestion-preview@2x.png differ diff --git a/web/app/components/base/features/feature-choose/feature-item/preview-imgs/speech-to-text-preview@2x.png b/web/app/components/base/features/feature-choose/feature-item/preview-imgs/speech-to-text-preview@2x.png new file mode 100644 index 0000000000..68df3983dc Binary files /dev/null and b/web/app/components/base/features/feature-choose/feature-item/preview-imgs/speech-to-text-preview@2x.png differ diff --git a/web/app/components/base/features/feature-choose/feature-item/preview-imgs/speech-to-text.svg b/web/app/components/base/features/feature-choose/feature-item/preview-imgs/speech-to-text.svg new file mode 100644 index 0000000000..029b92fee4 --- /dev/null +++ b/web/app/components/base/features/feature-choose/feature-item/preview-imgs/speech-to-text.svg @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/app/components/base/features/feature-choose/feature-item/preview-imgs/suggested-questions-after-answer.svg b/web/app/components/base/features/feature-choose/feature-item/preview-imgs/suggested-questions-after-answer.svg new file mode 100644 index 0000000000..c0102b7e08 --- /dev/null +++ b/web/app/components/base/features/feature-choose/feature-item/preview-imgs/suggested-questions-after-answer.svg @@ -0,0 +1,163 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/app/components/base/features/feature-choose/feature-item/preview-imgs/text-to-audio-preview-assistant@2x.png b/web/app/components/base/features/feature-choose/feature-item/preview-imgs/text-to-audio-preview-assistant@2x.png new file mode 100644 index 0000000000..91396e72c7 Binary files /dev/null and b/web/app/components/base/features/feature-choose/feature-item/preview-imgs/text-to-audio-preview-assistant@2x.png differ diff --git a/web/app/components/base/features/feature-choose/feature-item/preview-imgs/text-to-audio-preview-completion@2x.png b/web/app/components/base/features/feature-choose/feature-item/preview-imgs/text-to-audio-preview-completion@2x.png new file mode 100644 index 0000000000..7558e78bd9 Binary files /dev/null and b/web/app/components/base/features/feature-choose/feature-item/preview-imgs/text-to-audio-preview-completion@2x.png differ diff --git a/web/app/components/base/features/feature-choose/feature-item/style.module.css b/web/app/components/base/features/feature-choose/feature-item/style.module.css new file mode 100644 index 0000000000..20f0534744 --- /dev/null +++ b/web/app/components/base/features/feature-choose/feature-item/style.module.css @@ -0,0 +1,39 @@ +.preview { + display: none; + position: absolute; + transform: translate(480px, -54px); + width: 280px; + height: 360px; + background: center center no-repeat; + background-size: contain; + border-radius: 8px; +} + +.wrap:hover .preview { + display: block; +} + +.openingStatementPreview { + background-image: url(./preview-imgs/opening-statement.png); +} + +.suggestedQuestionsAfterAnswerPreview { + background-image: url(./preview-imgs/suggested-questions-after-answer.svg); +} + +.moreLikeThisPreview { + background-image: url(./preview-imgs/more-like-this.svg); +} + +.speechToTextPreview { + background-image: url(./preview-imgs/speech-to-text.svg); +} + +.textToSpeechPreview { + @apply shadow-lg rounded-lg; + background-image: url(./preview-imgs/text-to-audio-preview-assistant@2x.png); +} + +.citationPreview { + background-image: url(./preview-imgs/citation.svg); +} diff --git a/web/app/components/base/features/feature-choose/feature-modal.tsx b/web/app/components/base/features/feature-choose/feature-modal.tsx new file mode 100644 index 0000000000..755bb5b788 --- /dev/null +++ b/web/app/components/base/features/feature-choose/feature-modal.tsx @@ -0,0 +1,153 @@ +'use client' +import type { FC } from 'react' +import React, { useCallback } from 'react' +import produce from 'immer' +import { useTranslation } from 'react-i18next' +import { useFeatures } from '../hooks' +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 { + MessageFast, + MessageHeartCircle, +} from '@/app/components/base/icons/src/vender/solid/communication' + +export type ChooseFeatureProps = { + showTextToSpeechItem?: boolean + showSpeechToTextItem?: boolean +} + +const ChooseFeature: FC = ({ + showTextToSpeechItem, + showSpeechToTextItem, +}) => { + const { t } = useTranslation() + const setShowFeaturesModal = useFeatures(s => s.setShowFeaturesModal) + const openingStatement = useFeatures(s => s.openingStatement) + const setOpeningStatement = useFeatures(s => s.setOpeningStatement) + const suggestedQuestionsAfterAnswer = useFeatures(s => s.suggestedQuestionsAfterAnswer) + const setSuggestedQuestionsAfterAnswer = useFeatures(s => s.setSuggestedQuestionsAfterAnswer) + const textToSpeech = useFeatures(s => s.textToSpeech) + const setTextToSpeech = useFeatures(s => s.setTextToSpeech) + const speechToText = useFeatures(s => s.speechToText) + const setSpeechToText = useFeatures(s => s.setSpeechToText) + const citation = useFeatures(s => s.citation) + const setCitation = useFeatures(s => s.setCitation) + const moderation = useFeatures(s => s.moderation) + const setModeration = useFeatures(s => s.setModeration) + const annotation = useFeatures(s => s.annotation) + const setAnnotation = useFeatures(s => s.setAnnotation) + + const handleCancelModal = useCallback(() => { + setShowFeaturesModal(false) + }, [setShowFeaturesModal]) + + return ( + +
+ {/* Chat Feature */} + + <> + } + previewImgClassName='openingStatementPreview' + title={t('appDebug.feature.conversationOpener.title')} + description={t('appDebug.feature.conversationOpener.description')} + value={openingStatement.enabled} + onChange={value => setOpeningStatement(produce(openingStatement, (draft) => { + draft.enabled = value + }))} + /> + } + previewImgClassName='suggestedQuestionsAfterAnswerPreview' + title={t('appDebug.feature.suggestedQuestionsAfterAnswer.title')} + description={t('appDebug.feature.suggestedQuestionsAfterAnswer.description')} + value={suggestedQuestionsAfterAnswer.enabled} + onChange={value => setSuggestedQuestionsAfterAnswer(produce(suggestedQuestionsAfterAnswer, (draft) => { + draft.enabled = value + }))} + /> + { + showTextToSpeechItem && ( + } + previewImgClassName='textToSpeechPreview' + title={t('appDebug.feature.textToSpeech.title')} + description={t('appDebug.feature.textToSpeech.description')} + value={textToSpeech.enabled} + onChange={value => setTextToSpeech(produce(textToSpeech, (draft) => { + draft.enabled = value + }))} + /> + ) + } + { + showSpeechToTextItem && ( + } + previewImgClassName='speechToTextPreview' + title={t('appDebug.feature.speechToText.title')} + description={t('appDebug.feature.speechToText.description')} + value={speechToText.enabled} + onChange={value => setSpeechToText(produce(speechToText, (draft) => { + draft.enabled = value + }))} + /> + ) + } + } + previewImgClassName='citationPreview' + title={t('appDebug.feature.citation.title')} + description={t('appDebug.feature.citation.description')} + value={citation.enabled} + onChange={value => setCitation(produce(citation, (draft) => { + draft.enabled = value + }))} + /> + + + + + <> + } + previewImgClassName='' + title={t('appDebug.feature.moderation.title')} + description={t('appDebug.feature.moderation.description')} + value={moderation.enabled} + onChange={value => setModeration(produce(moderation, (draft) => { + draft.enabled = value + }))} + /> + } + title={t('appDebug.feature.annotation.title')} + description={t('appDebug.feature.annotation.description')} + value={annotation.enabled} + onChange={value => setAnnotation(produce(annotation, (draft) => { + draft.enabled = value + }))} + /> + + +
+
+ ) +} +export default React.memo(ChooseFeature) diff --git a/web/app/components/base/features/feature-choose/index.tsx b/web/app/components/base/features/feature-choose/index.tsx new file mode 100644 index 0000000000..32db48f944 --- /dev/null +++ b/web/app/components/base/features/feature-choose/index.tsx @@ -0,0 +1,30 @@ +'use client' +import React from 'react' +import { useTranslation } from 'react-i18next' +import { useFeatures } from '../hooks' +import FeatureModal from './feature-modal' +import Button from '@/app/components/base/button' +import { Plus02 } from '@/app/components/base/icons/src/vender/line/general' + +const ChooseFeature = () => { + const { t } = useTranslation() + const showFeaturesModal = useFeatures(s => s.showFeaturesModal) + const setShowFeaturesModal = useFeatures(s => s.setShowFeaturesModal) + return ( + <> + + { + showFeaturesModal && ( + + ) + } + + ) +} +export default React.memo(ChooseFeature) diff --git a/web/app/components/base/features/feature-panel/annotation/annotation-ctrl-btn/index.tsx b/web/app/components/base/features/feature-panel/annotation/annotation-ctrl-btn/index.tsx new file mode 100644 index 0000000000..1dcae64416 --- /dev/null +++ b/web/app/components/base/features/feature-panel/annotation/annotation-ctrl-btn/index.tsx @@ -0,0 +1,135 @@ +'use client' +import type { FC } from 'react' +import React, { useRef, useState } from 'react' +import { useHover } from 'ahooks' +import cn from 'classnames' +import { useTranslation } from 'react-i18next' +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 TooltipPlus from '@/app/components/base/tooltip-plus' +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 = ({ + 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(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 ( +
+
+ {cached + ? ( +
+
setShowModal(true)} + > + {!isCachedBtnHovering + ? ( + <> + +
{t('appDebug.feature.annotation.cached')}
+ + ) + : <> + +
{t('appDebug.feature.annotation.remove')}
+ } +
+
+ ) + : answer + ? ( + +
+ +
+
+ ) + : null + } + +
+ +
+
+ +
+ setShowModal(false)} + onRemove={handleRemove} + /> +
+ ) +} +export default React.memo(CacheCtrlBtn) diff --git a/web/app/components/base/features/feature-panel/annotation/config-param-modal.tsx b/web/app/components/base/features/feature-panel/annotation/config-param-modal.tsx new file mode 100644 index 0000000000..c4f449628e --- /dev/null +++ b/web/app/components/base/features/feature-panel/annotation/config-param-modal.tsx @@ -0,0 +1,140 @@ +'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' + +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 = ({ + isShow, + onHide: doHide, + onSave, + isInit, + annotationConfig: oldAnnotationConfig, +}) => { + const { t } = useTranslation() + const { + modelList: embeddingsModelList, + defaultModel: embeddingsDefaultModel, + currentModel: isEmbeddingsDefaultModelValid, + } = useModelListAndDefaultModelAndCurrentProviderAndModel(2) + 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 ( + +
+ {t(`appAnnotation.initSetup.${isInit ? 'title' : 'configTitle'}`)} +
+ +
+ + { + setAnnotationConfig({ + ...annotationConfig, + score_threshold: val / 100, + }) + }} + /> + + + +
+ { + setEmbeddingModel({ + providerName: val.provider, + modelName: val.model, + }) + }} + /> +
+
+
+ +
+ + +
+
+ ) +} +export default React.memo(ConfigParamModal) diff --git a/web/app/components/base/features/feature-panel/annotation/config-param.tsx b/web/app/components/base/features/feature-panel/annotation/config-param.tsx new file mode 100644 index 0000000000..519f3505f9 --- /dev/null +++ b/web/app/components/base/features/feature-panel/annotation/config-param.tsx @@ -0,0 +1,124 @@ +'use client' +import type { FC } from 'react' +import React from 'react' +import { useTranslation } from 'react-i18next' +import { useContext } from 'use-context-selector' +import { usePathname, useRouter } from 'next/navigation' +import ConfigParamModal from './config-param-modal' +import Panel from '@/app/components/app/configuration/base/feature-panel' +import { MessageFast } from '@/app/components/base/icons/src/vender/solid/communication' +import TooltipPlus from '@/app/components/base/tooltip-plus' +import { HelpCircle, LinkExternal02, Settings04 } from '@/app/components/base/icons/src/vender/line/general' +import ConfigContext from '@/context/debug-configuration' +import type { EmbeddingModelConfig } from '@/app/components/app/annotation/type' +import { updateAnnotationScore } from '@/service/annotation' + +export type AnnotationProps = { + onEmbeddingChange: (embeddingModel: EmbeddingModelConfig) => void + onScoreChange: (score: number, embeddingModel?: EmbeddingModelConfig) => void +} + +export const Item: FC<{ title: string; tooltip: string; children: JSX.Element }> = ({ + title, + tooltip, + children, +}) => { + return ( +
+
+
{title}
+ {tooltip}
+ } + > + + +
+
{children}
+ + ) +} + +const AnnotationReplyConfig: FC = ({ + onEmbeddingChange, + onScoreChange, +}) => { + const { t } = useTranslation() + const router = useRouter() + const pathname = usePathname() + const matched = pathname.match(/\/app\/([^/]+)/) + const appId = (matched?.length && matched[1]) ? matched[1] : '' + const { + annotationConfig, + } = useContext(ConfigContext) + + const [isShowEdit, setIsShowEdit] = React.useState(false) + + return ( + <> + + } + title={t('appDebug.feature.annotation.title')} + headerRight={ +
+
{ setIsShowEdit(true) }} + > + +
+ + {t('common.operation.params')} +
+
+
{ + router.push(`/app/${appId}/annotations`) + }}> +
{t('appDebug.feature.annotation.cacheManagement')}
+ +
+
+ } + noBodySpacing + /> + {isShowEdit && ( + { + setIsShowEdit(false) + }} + onSave={async (embeddingModel, score) => { + let isEmbeddingModelChanged = false + if ( + embeddingModel.embedding_model_name !== annotationConfig.embedding_model.embedding_model_name + && embeddingModel.embedding_provider_name !== annotationConfig.embedding_model.embedding_provider_name + ) { + await onEmbeddingChange(embeddingModel) + isEmbeddingModelChanged = true + } + + if (score !== annotationConfig.score_threshold) { + await updateAnnotationScore(appId, annotationConfig.id, score) + if (isEmbeddingModelChanged) + onScoreChange(score, embeddingModel) + + else + onScoreChange(score) + } + + setIsShowEdit(false) + }} + annotationConfig={annotationConfig} + /> + )} + + ) +} +export default React.memo(AnnotationReplyConfig) diff --git a/web/app/components/base/features/feature-panel/annotation/type.ts b/web/app/components/base/features/feature-panel/annotation/type.ts new file mode 100644 index 0000000000..910453478c --- /dev/null +++ b/web/app/components/base/features/feature-panel/annotation/type.ts @@ -0,0 +1,4 @@ +export enum PageType { + log = 'log', + annotation = 'annotation', +} diff --git a/web/app/components/base/features/feature-panel/annotation/use-annotation-config.ts b/web/app/components/base/features/feature-panel/annotation/use-annotation-config.ts new file mode 100644 index 0000000000..540302cb27 --- /dev/null +++ b/web/app/components/base/features/feature-panel/annotation/use-annotation-config.ts @@ -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 diff --git a/web/app/components/base/features/feature-panel/citation/index.tsx b/web/app/components/base/features/feature-panel/citation/index.tsx new file mode 100644 index 0000000000..4003b68cd3 --- /dev/null +++ b/web/app/components/base/features/feature-panel/citation/index.tsx @@ -0,0 +1,25 @@ +'use client' +import React, { type FC } from 'react' +import { useTranslation } from 'react-i18next' +import Panel from '@/app/components/app/configuration/base/feature-panel' +import { Citations } from '@/app/components/base/icons/src/vender/solid/editor' + +const Citation: FC = () => { + const { t } = useTranslation() + + return ( + +
{t('appDebug.feature.citation.title')}
+ + } + headerIcon={} + headerRight={ +
{t('appDebug.feature.citation.resDes')}
+ } + noBodySpacing + /> + ) +} +export default React.memo(Citation) diff --git a/web/app/components/base/features/feature-panel/index.tsx b/web/app/components/base/features/feature-panel/index.tsx new file mode 100644 index 0000000000..f789a3828d --- /dev/null +++ b/web/app/components/base/features/feature-panel/index.tsx @@ -0,0 +1,116 @@ +import { + memo, + useMemo, +} from 'react' +import { useTranslation } from 'react-i18next' +import { useFeatures } from '../hooks' +import OpeningStatement from './opening-statement' +import type { OpeningStatementProps } from './opening-statement' +import SuggestedQuestionsAfterAnswer from './suggested-questions-after-answer' +import TextToSpeech from './text-to-speech' +import SpeechToText from './speech-to-text' +import Citation from './citation' +import Moderation from './moderation' +import Annotation from './annotation/config-param' +import type { AnnotationProps } from './annotation/config-param' + +export type FeaturePanelProps = { + openingStatementProps: OpeningStatementProps + annotationProps: AnnotationProps +} +const FeaturePanel = ({ + openingStatementProps, + annotationProps, +}: FeaturePanelProps) => { + const { t } = useTranslation() + const openingStatement = useFeatures(s => s.openingStatement) + const suggestedQuestionsAfterAnswer = useFeatures(s => s.suggestedQuestionsAfterAnswer) + const textToSpeech = useFeatures(s => s.textToSpeech) + const speechToText = useFeatures(s => s.speechToText) + const citation = useFeatures(s => s.citation) + const moderation = useFeatures(s => s.moderation) + const annotation = useFeatures(s => s.annotation) + + const showAdvanceFeature = useMemo(() => { + return openingStatement.enabled || suggestedQuestionsAfterAnswer.enabled || textToSpeech.enabled || speechToText.enabled || citation.enabled + }, [openingStatement, suggestedQuestionsAfterAnswer, textToSpeech, speechToText, citation]) + + const showToolFeature = useMemo(() => { + return moderation.enabled || annotation.enabled + }, [moderation, annotation]) + + return ( +
+ { + showAdvanceFeature && ( +
+
+
+ {t('appDebug.feature.groupChat.title')} +
+
+
+
+ { + openingStatement.enabled && ( + + ) + } + { + suggestedQuestionsAfterAnswer.enabled && ( + + ) + } + { + textToSpeech.enabled && ( + + ) + } + { + speechToText.enabled && ( + + ) + } + { + citation.enabled && ( + + ) + } +
+
+ ) + } + { + showToolFeature && ( +
+
+
+ {t('appDebug.feature.groupChat.title')} +
+
+
+
+ { + moderation.enabled && ( + + ) + } + { + annotation.enabled && ( + + ) + } +
+
+ ) + } +
+ ) +} +export default memo(FeaturePanel) diff --git a/web/app/components/base/features/feature-panel/moderation/form-generation.tsx b/web/app/components/base/features/feature-panel/moderation/form-generation.tsx new file mode 100644 index 0000000000..4f7e73a128 --- /dev/null +++ b/web/app/components/base/features/feature-panel/moderation/form-generation.tsx @@ -0,0 +1,80 @@ +import type { FC } from 'react' +import { memo } from 'react' +import { useContext } from 'use-context-selector' +import type { CodeBasedExtensionForm } from '@/models/common' +import I18n from '@/context/i18n' +import { PortalSelect } from '@/app/components/base/select' +import type { ModerationConfig } from '@/models/debug' + +type FormGenerationProps = { + forms: CodeBasedExtensionForm[] + value: ModerationConfig['config'] + onChange: (v: Record) => void +} +const FormGeneration: FC = ({ + forms, + value, + onChange, +}) => { + const { locale } = useContext(I18n) + + const handleFormChange = (type: string, v: string) => { + onChange({ ...value, [type]: v }) + } + + return ( + <> + { + forms.map((form, index) => ( +
+
+ {locale === 'zh-Hans' ? form.label['zh-Hans'] : form.label['en-US']} +
+ { + form.type === 'text-input' && ( + handleFormChange(form.variable, e.target.value)} + /> + ) + } + { + form.type === 'paragraph' && ( +
+ +
+ ) + : ( +
+ )} + {renderQuestions()} + ) : ( +
{t('appDebug.openingStatement.noDataPlaceHolder')}
+ )} + + {isShowConfirmAddVar && ( + + )} + +
+
+ ) +} +export default React.memo(OpeningStatement) diff --git a/web/app/components/base/features/feature-panel/score-slider/base-slider/index.tsx b/web/app/components/base/features/feature-panel/score-slider/base-slider/index.tsx new file mode 100644 index 0000000000..b659e14f40 --- /dev/null +++ b/web/app/components/base/features/feature-panel/score-slider/base-slider/index.tsx @@ -0,0 +1,38 @@ +import ReactSlider from 'react-slider' +import cn from 'classnames' +import s from './style.module.css' + +type ISliderProps = { + className?: string + value: number + max?: number + min?: number + step?: number + disabled?: boolean + onChange: (value: number) => void +} + +const Slider: React.FC = ({ className, max, min, step, value, disabled, onChange }) => { + return ( +
+
+
+ {(state.valueNow / 100).toFixed(2)} +
+
+
+ )} + /> +} + +export default Slider diff --git a/web/app/components/base/features/feature-panel/score-slider/base-slider/style.module.css b/web/app/components/base/features/feature-panel/score-slider/base-slider/style.module.css new file mode 100644 index 0000000000..4e93b39563 --- /dev/null +++ b/web/app/components/base/features/feature-panel/score-slider/base-slider/style.module.css @@ -0,0 +1,20 @@ +.slider { + position: relative; +} + +.slider.disabled { + opacity: 0.6; +} + +.slider-thumb:focus { + outline: none; +} + +.slider-track { + background-color: #528BFF; + height: 2px; +} + +.slider-track-1 { + background-color: #E5E7EB; +} \ No newline at end of file diff --git a/web/app/components/base/features/feature-panel/score-slider/index.tsx b/web/app/components/base/features/feature-panel/score-slider/index.tsx new file mode 100644 index 0000000000..9826cbadcf --- /dev/null +++ b/web/app/components/base/features/feature-panel/score-slider/index.tsx @@ -0,0 +1,46 @@ +'use client' +import type { FC } from 'react' +import React from 'react' +import { useTranslation } from 'react-i18next' +import Slider from '@/app/components/app/configuration/toolbox/score-slider/base-slider' + +type Props = { + className?: string + value: number + onChange: (value: number) => void +} + +const ScoreSlider: FC = ({ + className, + value, + onChange, +}) => { + const { t } = useTranslation() + + return ( +
+
+ +
+
+
+
0.8
+
·
+
{t('appDebug.feature.annotation.scoreThreshold.easyMatch')}
+
+
+
1.0
+
·
+
{t('appDebug.feature.annotation.scoreThreshold.accurateMatch')}
+
+
+
+ ) +} +export default React.memo(ScoreSlider) diff --git a/web/app/components/base/features/feature-panel/speech-to-text/index.tsx b/web/app/components/base/features/feature-panel/speech-to-text/index.tsx new file mode 100644 index 0000000000..e452b38971 --- /dev/null +++ b/web/app/components/base/features/feature-panel/speech-to-text/index.tsx @@ -0,0 +1,25 @@ +'use client' +import React, { type FC } from 'react' +import { useTranslation } from 'react-i18next' +import Panel from '@/app/components/app/configuration/base/feature-panel' +import { Microphone01 } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices' + +const SpeechToTextConfig: FC = () => { + const { t } = useTranslation() + + return ( + +
{t('appDebug.feature.speechToText.title')}
+ + } + headerIcon={} + headerRight={ +
{t('appDebug.feature.speechToText.resDes')}
+ } + noBodySpacing + /> + ) +} +export default React.memo(SpeechToTextConfig) diff --git a/web/app/components/base/features/feature-panel/suggested-questions-after-answer/index.tsx b/web/app/components/base/features/feature-panel/suggested-questions-after-answer/index.tsx new file mode 100644 index 0000000000..533d249487 --- /dev/null +++ b/web/app/components/base/features/feature-panel/suggested-questions-after-answer/index.tsx @@ -0,0 +1,33 @@ +'use client' +import type { FC } from 'react' +import React from 'react' +import { useTranslation } from 'react-i18next' +import Panel from '@/app/components/app/configuration/base/feature-panel' +import SuggestedQuestionsAfterAnswerIcon from '@/app/components/app/configuration/base/icons/suggested-questions-after-answer-icon' +import Tooltip from '@/app/components/base/tooltip' +import { HelpCircle } from '@/app/components/base/icons/src/vender/line/general' + +const SuggestedQuestionsAfterAnswer: FC = () => { + const { t } = useTranslation() + + return ( + +
{t('appDebug.feature.suggestedQuestionsAfterAnswer.title')}
+ + {t('appDebug.feature.suggestedQuestionsAfterAnswer.description')} + } selector='suggestion-question-tooltip'> + + + + } + headerIcon={} + headerRight={ +
{t('appDebug.feature.suggestedQuestionsAfterAnswer.resDes')}
+ } + noBodySpacing + /> + ) +} +export default React.memo(SuggestedQuestionsAfterAnswer) diff --git a/web/app/components/base/features/feature-panel/text-to-speech/index.tsx b/web/app/components/base/features/feature-panel/text-to-speech/index.tsx new file mode 100644 index 0000000000..f0a3648894 --- /dev/null +++ b/web/app/components/base/features/feature-panel/text-to-speech/index.tsx @@ -0,0 +1,50 @@ +'use client' +import useSWR from 'swr' +import React, { type FC } from 'react' +import { useTranslation } from 'react-i18next' +import { usePathname } from 'next/navigation' +import { useFeatures } from '../../hooks' +import Panel from '@/app/components/app/configuration/base/feature-panel' +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' + +const TextToSpeech: FC = () => { + const { t } = useTranslation() + const textToSpeech = useFeatures(s => s.textToSpeech) + + 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 ( + +
{t('appDebug.feature.textToSpeech.title')}
+ + } + headerIcon={} + headerRight={ +
+ {languageInfo && (`${languageInfo?.name} - `)}{voiceItem?.name ?? t('appDebug.voice.defaultDisplay')} + { languageInfo?.example && ( + + )} +
+ } + noBodySpacing + isShowTextToSpeech={true} + /> + ) +} +export default React.memo(TextToSpeech) diff --git a/web/app/components/base/features/hooks.ts b/web/app/components/base/features/hooks.ts new file mode 100644 index 0000000000..755337081f --- /dev/null +++ b/web/app/components/base/features/hooks.ts @@ -0,0 +1,12 @@ +import { useContext } from 'react' +import { useStore } from 'zustand' +import { FeaturesContext } from './context' +import type { FeatureStoreState } from './store' + +export function useFeatures(selector: (state: FeatureStoreState) => T): T { + const store = useContext(FeaturesContext) + if (!store) + throw new Error('Missing FeaturesContext.Provider in the tree') + + return useStore(store, selector) +} diff --git a/web/app/components/base/features/index.tsx b/web/app/components/base/features/index.tsx new file mode 100644 index 0000000000..13bffb3669 --- /dev/null +++ b/web/app/components/base/features/index.tsx @@ -0,0 +1,3 @@ +export { default as FeaturesPanel } from './feature-panel' +export { default as FeaturesChoose } from './feature-choose' +export { FeaturesProvider } from './context' diff --git a/web/app/components/base/features/store.ts b/web/app/components/base/features/store.ts new file mode 100644 index 0000000000..edda945fd8 --- /dev/null +++ b/web/app/components/base/features/store.ts @@ -0,0 +1,79 @@ +import { createStore } from 'zustand' +import type { + AnnotationReply, + OpeningStatement, + RetrieverResource, + SensitiveWordAvoidance, + SpeechToText, + SuggestedQuestionsAfterAnswer, + TextToSpeech, +} from './types' + +export type FeaturesModal = { + showFeaturesModal: boolean + setShowFeaturesModal: (showFeaturesModal: boolean) => void +} + +export type FeaturesState = { + openingStatement: OpeningStatement + suggestedQuestionsAfterAnswer: SuggestedQuestionsAfterAnswer + textToSpeech: TextToSpeech + speechToText: SpeechToText + citation: RetrieverResource + moderation: SensitiveWordAvoidance + annotation: AnnotationReply +} + +export type FeaturesAction = { + setOpeningStatement: (openingStatement: OpeningStatement) => void + setSuggestedQuestionsAfterAnswer: (suggestedQuestionsAfterAnswer: SuggestedQuestionsAfterAnswer) => void + setTextToSpeech: (textToSpeech: TextToSpeech) => void + setSpeechToText: (speechToText: SpeechToText) => void + setCitation: (citation: RetrieverResource) => void + setModeration: (moderation: SensitiveWordAvoidance) => void + setAnnotation: (annotation: AnnotationReply) => void +} + +export type FeatureStoreState = FeaturesState & FeaturesAction & FeaturesModal + +export type FeaturesStore = ReturnType + +export const createFeaturesStore = (initProps?: Partial) => { + const DEFAULT_PROPS: FeaturesState = { + openingStatement: { + enabled: false, + }, + suggestedQuestionsAfterAnswer: { + enabled: false, + }, + textToSpeech: { + enabled: false, + }, + speechToText: { + enabled: false, + }, + citation: { + enabled: false, + }, + moderation: { + enabled: false, + }, + annotation: { + enabled: false, + }, + } + return createStore()(set => ({ + ...DEFAULT_PROPS, + ...initProps, + setOpeningStatement: openingStatement => set(() => ({ openingStatement })), + setSuggestedQuestionsAfterAnswer: suggestedQuestionsAfterAnswer => set(() => ({ suggestedQuestionsAfterAnswer })), + setSpeechToText: speechToText => set(() => ({ speechToText })), + setTextToSpeech: textToSpeech => set(() => ({ textToSpeech })), + setCitation: citation => set(() => ({ citation })), + setModeration: moderation => set(() => ({ moderation })), + setAnnotation: annotation => set(() => ({ annotation })), + + showFeaturesModal: false, + setShowFeaturesModal: showFeaturesModal => set(() => ({ showFeaturesModal })), + })) +} diff --git a/web/app/components/base/features/types.ts b/web/app/components/base/features/types.ts new file mode 100644 index 0000000000..1ec90454d1 --- /dev/null +++ b/web/app/components/base/features/types.ts @@ -0,0 +1,33 @@ +export type EnabledOrDisabled = { + enabled: boolean +} + +export type OpeningStatement = EnabledOrDisabled & { + opening_statement?: string + suggested_questions?: string[] +} + +export type SuggestedQuestionsAfterAnswer = EnabledOrDisabled + +export type TextToSpeech = EnabledOrDisabled & { + language?: string + voice?: string +} + +export type SpeechToText = EnabledOrDisabled + +export type RetrieverResource = EnabledOrDisabled + +export type SensitiveWordAvoidance = EnabledOrDisabled & { + type?: string + config?: any +} + +export type AnnotationReply = EnabledOrDisabled & { + id?: string + score_threshold?: number + embedding_model?: { + embedding_model_name: string + embedding_provider_name: string + } +} diff --git a/web/app/components/workflow/features.tsx b/web/app/components/workflow/features.tsx index 7857862de0..9f1e897d84 100644 --- a/web/app/components/workflow/features.tsx +++ b/web/app/components/workflow/features.tsx @@ -1,37 +1,44 @@ import { memo } from 'react' import { useStore } from './store' -import Button from '@/app/components/base/button' +import { XClose } from '@/app/components/base/icons/src/vender/line/general' import { - Plus02, - XClose, -} from '@/app/components/base/icons/src/vender/line/general' + FeaturesChoose, + FeaturesPanel, + FeaturesProvider, +} from '@/app/components/base/features' const Features = () => { - const showFeatures = useStore(state => state.showFeatures) - const setShowFeatures = useStore(state => state.setShowFeatures) - - if (!showFeatures) - return null + const setShowFeaturesPanel = useStore(state => state.setShowFeaturesPanel) return ( -
-
- Features -
- -
-
setShowFeatures(false)} - > - + +
+
+ Features +
+ +
+
setShowFeaturesPanel(false)} + > + +
+
+ {}, + }} + annotationProps={{ + onEmbeddingChange: () => {}, + onScoreChange: () => {}, + }} + /> +
-
+ ) } diff --git a/web/app/components/workflow/header/index.tsx b/web/app/components/workflow/header/index.tsx index b283ad1772..e1ed100fa0 100644 --- a/web/app/components/workflow/header/index.tsx +++ b/web/app/components/workflow/header/index.tsx @@ -13,13 +13,13 @@ import { ArrowNarrowLeft } from '@/app/components/base/icons/src/vender/line/arr const Header: FC = () => { const mode = useStore(state => state.mode) - const setShowFeatures = useStore(state => state.setShowFeatures) + const setShowFeaturesPanel = useStore(state => state.setShowFeaturesPanel) const runStaus = useStore(state => state.runStaus) const setRunStaus = useStore(state => state.setRunStaus) const handleShowFeatures = useCallback(() => { - setShowFeatures(true) - }, [setShowFeatures]) + setShowFeaturesPanel(true) + }, [setShowFeaturesPanel]) return (
= memo(({ nodes: initialNodes, edges: initialEdges, }) => { + const showFeaturesPanel = useStore(state => state.showFeaturesPanel) const [nodes] = useNodesState(initialNodes) const [edges, _, onEdgesChange] = useEdgesState(initialEdges) const nodesInitialized = useNodesInitialized() @@ -64,7 +66,9 @@ const Workflow: FC = memo(({
- + { + showFeaturesPanel && + } void - setShowFeatures: (showFeatures: boolean) => void + setShowFeaturesPanel: (showFeaturesPanel: boolean) => void + setShowFeaturesModal: (showFeaturesModal: boolean) => void setRunStaus: (runStaus: string) => void } @@ -17,8 +19,10 @@ export const useStore = create(set => ({ mode: 'workflow', showRunHistory: false, setShowRunHistory: showRunHistory => set(() => ({ showRunHistory })), - showFeatures: false, - setShowFeatures: showFeatures => set(() => ({ showFeatures })), + showFeaturesPanel: false, + setShowFeaturesPanel: showFeaturesPanel => set(() => ({ showFeaturesPanel })), + showFeaturesModal: false, + setShowFeaturesModal: showFeaturesModal => set(() => ({ showFeaturesModal })), runStaus: '', setRunStaus: runStaus => set(() => ({ runStaus })), }))