diff --git a/docSite/assets/imgs/questionGuide.png b/docSite/assets/imgs/questionGuide.png new file mode 100644 index 0000000000..63130ecc7f Binary files /dev/null and b/docSite/assets/imgs/questionGuide.png differ diff --git a/docSite/content/docs/course/custom_link.md b/docSite/content/docs/course/custom_link.md new file mode 100644 index 0000000000..6546d244de --- /dev/null +++ b/docSite/content/docs/course/custom_link.md @@ -0,0 +1,43 @@ +--- +title: "自定义词库地址" +description: "FastGPT 自定义输入提示的接口地址" +icon: "code" +draft: false +toc: true +weight: 350 +--- + +![](/imgs/questionGuide.png) + +## 什么是输入提示 +可自定义开启或关闭,当输入提示开启,并且词库中存在数据时,用户在输入问题时如果输入命中词库,那么会在输入框上方展示对应的智能推荐数据 + +用户可配置词库,选择存储在 FastGPT 数据库中,或者提供自定义接口获取词库 + +## 数据格式 +词库的形式为一个字符串数组,定义的词库接口应该有两种方法 —— GET & POST + +### GET +对于 GET 方法,用于获取词库数据,FastGPT 会给接口发送数据 query 为 +``` +{ + appId: 'xxxx' +} +``` +返回数据格式应当为 +``` +{ + data: ['xxx', 'xxxx'] +} +``` + +### POST +对于 POST 方法,用于更新词库数据,FastGPT 会给接口发送数据 body 为 +``` + { + appId: 'xxxx', + text: ['xxx', 'xxxx'] + } +``` +接口应当按照获取的数据格式存储相对应的词库数组 + diff --git a/packages/global/core/app/constants.ts b/packages/global/core/app/constants.ts index 18bbff28ff..93b6f3ada4 100644 --- a/packages/global/core/app/constants.ts +++ b/packages/global/core/app/constants.ts @@ -18,3 +18,9 @@ export const defaultWhisperConfig: AppWhisperConfigType = { autoSend: false, autoTTSResponse: false }; + +export const defaultQuestionGuideTextConfig = { + open: false, + textList: [], + customURL: '' +}; diff --git a/packages/global/core/app/type.d.ts b/packages/global/core/app/type.d.ts index 0da1d8b6ac..5c30ea6c9c 100644 --- a/packages/global/core/app/type.d.ts +++ b/packages/global/core/app/type.d.ts @@ -88,6 +88,7 @@ export type AppSimpleEditFormType = { }; whisper: AppWhisperConfigType; scheduleTrigger: AppScheduledTriggerConfigType | null; + questionGuideText: AppQuestionGuideTextConfigType; }; }; @@ -123,6 +124,12 @@ export type AppWhisperConfigType = { autoSend: boolean; autoTTSResponse: boolean; }; +// question guide text +export type AppQuestionGuideTextConfigType = { + open: boolean; + textList: string[]; + customURL: string; +}; // interval timer export type AppScheduledTriggerConfigType = { cronString: string; diff --git a/packages/global/core/app/utils.ts b/packages/global/core/app/utils.ts index 44a00a4bc8..28ae43c56b 100644 --- a/packages/global/core/app/utils.ts +++ b/packages/global/core/app/utils.ts @@ -5,7 +5,7 @@ import type { FlowNodeInputItemType } from '../workflow/type/io.d'; import { getGuideModule, splitGuideModule } from '../workflow/utils'; import { StoreNodeItemType } from '../workflow/type'; import { DatasetSearchModeEnum } from '../dataset/constants'; -import { defaultWhisperConfig } from './constants'; +import { defaultQuestionGuideTextConfig, defaultWhisperConfig } from './constants'; export const getDefaultAppForm = (): AppSimpleEditFormType => { return { @@ -35,7 +35,8 @@ export const getDefaultAppForm = (): AppSimpleEditFormType => { type: 'web' }, whisper: defaultWhisperConfig, - scheduleTrigger: null + scheduleTrigger: null, + questionGuideText: defaultQuestionGuideTextConfig } }; }; @@ -109,7 +110,8 @@ export const appWorkflow2Form = ({ nodes }: { nodes: StoreNodeItemType[] }) => { questionGuide, ttsConfig, whisperConfig, - scheduledTriggerConfig + scheduledTriggerConfig, + questionGuideText } = splitGuideModule(getGuideModule(nodes)); defaultAppForm.userGuide = { @@ -118,7 +120,8 @@ export const appWorkflow2Form = ({ nodes }: { nodes: StoreNodeItemType[] }) => { questionGuide: questionGuide, tts: ttsConfig, whisper: whisperConfig, - scheduleTrigger: scheduledTriggerConfig + scheduleTrigger: scheduledTriggerConfig, + questionGuideText: questionGuideText }; } else if (node.flowNodeType === FlowNodeTypeEnum.pluginModule) { if (!node.pluginId) return; diff --git a/packages/global/core/workflow/constants.ts b/packages/global/core/workflow/constants.ts index c12741b082..46a2693059 100644 --- a/packages/global/core/workflow/constants.ts +++ b/packages/global/core/workflow/constants.ts @@ -45,6 +45,7 @@ export enum NodeInputKeyEnum { whisper = 'whisper', variables = 'variables', scheduleTrigger = 'scheduleTrigger', + questionGuideText = 'questionGuideText', // entry userChatInput = 'userChatInput', diff --git a/packages/global/core/workflow/template/system/systemConfig.ts b/packages/global/core/workflow/template/system/systemConfig.ts index cb7aa5d11a..4357746d43 100644 --- a/packages/global/core/workflow/template/system/systemConfig.ts +++ b/packages/global/core/workflow/template/system/systemConfig.ts @@ -56,6 +56,12 @@ export const SystemConfigNode: FlowNodeTemplateType = { renderTypeList: [FlowNodeInputTypeEnum.hidden], valueType: WorkflowIOValueTypeEnum.any, label: '' + }, + { + key: NodeInputKeyEnum.questionGuideText, + renderTypeList: [FlowNodeInputTypeEnum.hidden], + valueType: WorkflowIOValueTypeEnum.any, + label: '' } ], outputs: [] diff --git a/packages/global/core/workflow/utils.ts b/packages/global/core/workflow/utils.ts index 2e72f7d24b..65cd2a7be7 100644 --- a/packages/global/core/workflow/utils.ts +++ b/packages/global/core/workflow/utils.ts @@ -11,7 +11,8 @@ import type { VariableItemType, AppTTSConfigType, AppWhisperConfigType, - AppScheduledTriggerConfigType + AppScheduledTriggerConfigType, + AppQuestionGuideTextConfigType } from '../app/type'; import { EditorVariablePickerType } from '../../../web/components/common/Textarea/PromptEditor/type'; import { defaultWhisperConfig } from '../app/constants'; @@ -59,13 +60,20 @@ export const splitGuideModule = (guideModules?: StoreNodeItemType) => { guideModules?.inputs?.find((item) => item.key === NodeInputKeyEnum.scheduleTrigger)?.value ?? null; + const questionGuideText: AppQuestionGuideTextConfigType = guideModules?.inputs?.find( + (item) => item.key === NodeInputKeyEnum.questionGuideText + )?.value || { + open: false + }; + return { welcomeText, variableNodes, questionGuide, ttsConfig, whisperConfig, - scheduledTriggerConfig + scheduledTriggerConfig, + questionGuideText }; }; export const replaceAppChatConfig = ({ diff --git a/packages/service/core/app/qGuideSchema.ts b/packages/service/core/app/qGuideSchema.ts new file mode 100644 index 0000000000..4f0741a93b --- /dev/null +++ b/packages/service/core/app/qGuideSchema.ts @@ -0,0 +1,40 @@ +import { TeamCollectionName } from '@fastgpt/global/support/user/team/constant'; +import { connectionMongo, type Model } from '../../common/mongo'; +const { Schema, model, models } = connectionMongo; + +export const AppQGuideCollectionName = 'app_question_guides'; + +type AppQGuideSchemaType = { + _id: string; + appId: string; + teamId: string; + text: string; +}; + +const AppQGuideSchema = new Schema({ + appId: { + type: Schema.Types.ObjectId, + ref: AppQGuideCollectionName, + required: true + }, + teamId: { + type: Schema.Types.ObjectId, + ref: TeamCollectionName, + required: true + }, + text: { + type: String, + default: '' + } +}); + +try { + AppQGuideSchema.index({ appId: 1 }); +} catch (error) { + console.log(error); +} + +export const MongoAppQGuide: Model = + models[AppQGuideCollectionName] || model(AppQGuideCollectionName, AppQGuideSchema); + +MongoAppQGuide.syncIndexes(); diff --git a/packages/web/components/common/Icon/constants.ts b/packages/web/components/common/Icon/constants.ts index 2afda1a544..daa13392ea 100644 --- a/packages/web/components/common/Icon/constants.ts +++ b/packages/web/components/common/Icon/constants.ts @@ -1,6 +1,7 @@ // @ts-nocheck export const iconPaths = { + book: () => import('./icons/book.svg'), change: () => import('./icons/change.svg'), chatSend: () => import('./icons/chatSend.svg'), closeSolid: () => import('./icons/closeSolid.svg'), @@ -64,6 +65,7 @@ export const iconPaths = { 'core/app/appApiLight': () => import('./icons/core/app/appApiLight.svg'), 'core/app/customFeedback': () => import('./icons/core/app/customFeedback.svg'), 'core/app/headphones': () => import('./icons/core/app/headphones.svg'), + 'core/app/inputGuides': () => import('./icons/core/app/inputGuides.svg'), 'core/app/logsLight': () => import('./icons/core/app/logsLight.svg'), 'core/app/markLight': () => import('./icons/core/app/markLight.svg'), 'core/app/publish/lark': () => import('./icons/core/app/publish/lark.svg'), @@ -219,6 +221,7 @@ export const iconPaths = { 'support/user/userFill': () => import('./icons/support/user/userFill.svg'), 'support/user/userLight': () => import('./icons/support/user/userLight.svg'), text: () => import('./icons/text.svg'), + union: () => import('./icons/union.svg'), user: () => import('./icons/user.svg'), wx: () => import('./icons/wx.svg') }; diff --git a/packages/web/components/common/Icon/icons/book.svg b/packages/web/components/common/Icon/icons/book.svg new file mode 100644 index 0000000000..9e45d5e205 --- /dev/null +++ b/packages/web/components/common/Icon/icons/book.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/web/components/common/Icon/icons/core/app/inputGuides.svg b/packages/web/components/common/Icon/icons/core/app/inputGuides.svg new file mode 100644 index 0000000000..844279379b --- /dev/null +++ b/packages/web/components/common/Icon/icons/core/app/inputGuides.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/web/components/common/Icon/icons/union.svg b/packages/web/components/common/Icon/icons/union.svg new file mode 100644 index 0000000000..80c6c5b67a --- /dev/null +++ b/packages/web/components/common/Icon/icons/union.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/web/hooks/useScrollPagination.tsx b/packages/web/hooks/useScrollPagination.tsx index e2222a065f..dd989351b7 100644 --- a/packages/web/hooks/useScrollPagination.tsx +++ b/packages/web/hooks/useScrollPagination.tsx @@ -85,14 +85,16 @@ export function useScrollPagination< ...props }: { children: React.ReactNode; isLoading?: boolean } & BoxProps) => { return ( - - {children} + <> + + {children} + {noMore.current && ( {t('common.No more data')} )} - + ); } ); @@ -115,6 +117,7 @@ export function useScrollPagination< return { containerRef, list, + data, isLoading, ScrollList, fetchData: loadData diff --git a/projects/app/i18n/en/app.json b/projects/app/i18n/en/app.json index 22b8ab2920..03734f220f 100644 --- a/projects/app/i18n/en/app.json +++ b/projects/app/i18n/en/app.json @@ -47,6 +47,14 @@ "type": "\"{{type}}\" type\n{{description}}" }, "modules": { + "Config Texts": "Config Texts", + "Config question guide": "Config question guide", + "Custom question guide URL": "Custom question guide URL", + "Input Guide": "Input Guide", + "Only support CSV": "Only support CSV", + "Question Guide": "Question guide", + "Question Guide Switch": "Open question guide", + "Question Guide Texts": "Texts", "Title is required": "Module name cannot be empty" } } diff --git a/projects/app/i18n/en/common.json b/projects/app/i18n/en/common.json index 41b16bf496..8f0008ce52 100644 --- a/projects/app/i18n/en/common.json +++ b/projects/app/i18n/en/common.json @@ -7,6 +7,7 @@ "Move": "Move", "Name": "Name", "New Create": "Create New", + "No data": "No data", "Rename": "Rename", "Running": "Running", "UnKnow": "Unknown", @@ -45,6 +46,7 @@ "Delete Tip": "Delete Tip", "Delete Warning": "Delete Warning", "Detail": "Detail", + "Documents": "Documents", "Done": "Done", "Edit": "Edit", "Exit": "Exit", @@ -101,6 +103,7 @@ "Search": "Search", "Select File Failed": "Select File Failed", "Select One Folder": "Select a folder", + "Select all": "Select all", "Select template": "Select template", "Set Avatar": "Click to set avatar", "Set Name": "Set a name", diff --git a/projects/app/i18n/zh/app.json b/projects/app/i18n/zh/app.json index 83c85cec35..23fdd132ac 100644 --- a/projects/app/i18n/zh/app.json +++ b/projects/app/i18n/zh/app.json @@ -46,6 +46,14 @@ "type": "\"{{type}}\"类型\n{{description}}" }, "modules": { + "Config Texts": "配置词库", + "Config question guide": "配置输入提示", + "Custom question guide URL": "自定义词库地址", + "Input Guide": "智能推荐", + "Only support CSV": "仅支持 CSV 导入,点击下载模板", + "Question Guide": "输入提示", + "Question Guide Switch": "是否开启", + "Question Guide Texts": "词库", "Title is required": "模块名不能为空" } } diff --git a/projects/app/i18n/zh/common.json b/projects/app/i18n/zh/common.json index db7ed9fb68..de5c628448 100644 --- a/projects/app/i18n/zh/common.json +++ b/projects/app/i18n/zh/common.json @@ -7,6 +7,7 @@ "Move": "移动", "Name": "名称", "New Create": "新建", + "No data": "暂无数据", "Rename": "重命名", "Running": "运行中", "UnKnow": "未知", @@ -45,6 +46,7 @@ "Delete Tip": "删除提示", "Delete Warning": "删除警告", "Detail": "详情", + "Documents": "文档", "Done": "完成", "Edit": "编辑", "Exit": "退出", @@ -102,6 +104,7 @@ "Search": "搜索", "Select File Failed": "选择文件异常", "Select One Folder": "选择一个目录", + "Select all": "全选", "Select template": "选择模板", "Set Avatar": "点击设置头像", "Set Name": "取个名字", diff --git a/projects/app/src/components/ChatBox/MessageInput.tsx b/projects/app/src/components/ChatBox/MessageInput.tsx index 6a4195271a..24f6669d4a 100644 --- a/projects/app/src/components/ChatBox/MessageInput.tsx +++ b/projects/app/src/components/ChatBox/MessageInput.tsx @@ -16,6 +16,11 @@ import { ChatBoxInputFormType, ChatBoxInputType, UserInputFileItemType } from '. import { textareaMinH } from './constants'; import { UseFormReturn, useFieldArray } from 'react-hook-form'; import { useChatProviderStore } from './Provider'; +import QuestionGuide from './components/QustionGuide'; +import { useQuery } from '@tanstack/react-query'; +import { getMyQuestionGuides } from '@/web/core/app/api'; +import { getAppQGuideCustomURL } from '@/web/core/app/utils'; +import { useAppStore } from '@/web/core/app/store/useAppStore'; const nanoid = customAlphabet('abcdefghijklmnopqrstuvwxyz1234567890', 6); const MessageInput = ({ @@ -53,6 +58,7 @@ const MessageInput = ({ const { isPc, whisperModel } = useSystemStore(); const canvasRef = useRef(null); const { t } = useTranslation(); + const { appDetail } = useAppStore(); const havInput = !!inputValue || fileList.length > 0; const hasFileUploading = fileList.some((item) => !item.url); @@ -205,6 +211,23 @@ const MessageInput = ({ startSpeak(finishWhisperTranscription); }, [finishWhisperTranscription, isSpeaking, startSpeak, stopSpeak]); + const { data } = useQuery( + [appId, inputValue], + async () => { + if (!appId) return { list: [], total: 0 }; + return getMyQuestionGuides({ + appId, + customURL: getAppQGuideCustomURL(appDetail), + pageSize: 5, + current: 1, + searchKey: inputValue + }); + }, + { + enabled: !!appId + } + ); + return ( + {/* popup */} + {havInput && ( + setValue('input', value)} + bottom={'100%'} + top={'auto'} + left={0} + right={0} + mb={2} + overflowY={'auto'} + boxShadow={'sm'} + /> + )} + {/* file preview */} {fileList.map((item, index) => ( @@ -377,7 +415,12 @@ const MessageInput = ({ // @ts-ignore e.key === 'a' && e.ctrlKey && e.target?.select(); - if ((isPc || window !== parent) && e.keyCode === 13 && !e.shiftKey) { + if ( + (isPc || window !== parent) && + e.keyCode === 13 && + !e.shiftKey && + !(havInput && data?.list.length && data?.list.length > 0) + ) { handleSend(); e.preventDefault(); } diff --git a/projects/app/src/components/ChatBox/components/QustionGuide.tsx b/projects/app/src/components/ChatBox/components/QustionGuide.tsx new file mode 100644 index 0000000000..b71e22be3e --- /dev/null +++ b/projects/app/src/components/ChatBox/components/QustionGuide.tsx @@ -0,0 +1,98 @@ +import { Box, BoxProps, Flex } from '@chakra-ui/react'; +import { EditorVariablePickerType } from '@fastgpt/web/components/common/Textarea/PromptEditor/type'; +import React, { useCallback, useEffect } from 'react'; +import MyIcon from '@fastgpt/web/components/common/Icon'; +import { useI18n } from '@/web/context/I18n'; + +export default function QuestionGuide({ + guides, + setDropdownValue, + ...props +}: { + guides: string[]; + setDropdownValue?: (value: string) => void; +} & BoxProps) { + const [highlightedIndex, setHighlightedIndex] = React.useState(0); + const { appT } = useI18n(); + + const handleKeyDown = useCallback( + (event: any) => { + if (event.keyCode === 38) { + setHighlightedIndex((prevIndex) => Math.max(prevIndex - 1, 0)); + } else if (event.keyCode === 40) { + setHighlightedIndex((prevIndex) => Math.min(prevIndex + 1, guides.length - 1)); + } else if (event.keyCode === 13 && guides[highlightedIndex]) { + setDropdownValue?.(guides[highlightedIndex]); + event.preventDefault(); + } + }, + [highlightedIndex, setDropdownValue, guides] + ); + + useEffect(() => { + document.addEventListener('keydown', handleKeyDown); + + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [handleKeyDown]); + + return guides.length ? ( + + + + {appT('modules.Input Guide')} + + {guides.map((item, index) => ( + { + e.preventDefault(); + + setDropdownValue?.(item); + }} + onMouseEnter={() => { + setHighlightedIndex(index); + }} + > + {item} + + ))} + + ) : null; +} diff --git a/projects/app/src/components/ChatBox/index.tsx b/projects/app/src/components/ChatBox/index.tsx index 311e1a0012..f982e502e4 100644 --- a/projects/app/src/components/ChatBox/index.tsx +++ b/projects/app/src/components/ChatBox/index.tsx @@ -58,7 +58,7 @@ import ChatProvider, { useChatProviderStore } from './Provider'; import ChatItem from './components/ChatItem'; import dynamic from 'next/dynamic'; -import { useCreation, useUpdateEffect } from 'ahooks'; +import { useCreation } from 'ahooks'; const ResponseTags = dynamic(() => import('./ResponseTags')); const FeedbackModal = dynamic(() => import('./FeedbackModal')); diff --git a/projects/app/src/components/core/app/QGuidesConfig.tsx b/projects/app/src/components/core/app/QGuidesConfig.tsx new file mode 100644 index 0000000000..60cd88778f --- /dev/null +++ b/projects/app/src/components/core/app/QGuidesConfig.tsx @@ -0,0 +1,479 @@ +import MyIcon from '@fastgpt/web/components/common/Icon'; +import MyTooltip from '@/components/MyTooltip'; +import { + Box, + Button, + Flex, + ModalBody, + useDisclosure, + Switch, + Input, + Textarea, + InputGroup, + InputRightElement, + Checkbox, + useCheckboxGroup, + ModalFooter, + BoxProps +} from '@chakra-ui/react'; +import React, { ChangeEvent, useEffect, useMemo, useRef } from 'react'; +import { useTranslation } from 'next-i18next'; +import type { AppQuestionGuideTextConfigType } from '@fastgpt/global/core/app/type.d'; +import MyModal from '@fastgpt/web/components/common/MyModal'; +import { useAppStore } from '@/web/core/app/store/useAppStore'; +import MyInput from '@/components/MyInput'; +import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip'; +import { useI18n } from '@/web/context/I18n'; +import { fileDownload } from '@/web/common/file/utils'; +import { getDocPath } from '@/web/common/system/doc'; +import { useScrollPagination } from '@fastgpt/web/hooks/useScrollPagination'; +import { getMyQuestionGuides } from '@/web/core/app/api'; +import { getAppQGuideCustomURL } from '@/web/core/app/utils'; +import { useQuery } from '@tanstack/react-query'; + +const csvTemplate = `"第一列内容" +"必填列" +"只会将第一列内容导入,其余列会被忽略" +"AIGC发展分为几个阶段?" +`; + +const QGuidesConfig = ({ + value, + onChange +}: { + value: AppQuestionGuideTextConfigType; + onChange: (e: AppQuestionGuideTextConfigType) => void; +}) => { + const { t } = useTranslation(); + const { appT, commonT } = useI18n(); + const { isOpen, onOpen, onClose } = useDisclosure(); + const { isOpen: isOpenTexts, onOpen: onOpenTexts, onClose: onCloseTexts } = useDisclosure(); + const isOpenQuestionGuide = value.open; + const { appDetail } = useAppStore(); + const [searchKey, setSearchKey] = React.useState(''); + + const { data } = useQuery( + [appDetail._id, searchKey], + async () => { + return getMyQuestionGuides({ + appId: appDetail._id, + customURL: getAppQGuideCustomURL(appDetail), + pageSize: 30, + current: 1, + searchKey + }); + }, + { + enabled: !!appDetail._id + } + ); + + useEffect(() => { + onChange({ + ...value, + textList: data?.list || [] + }); + }, [data]); + + const formLabel = useMemo(() => { + if (!isOpenQuestionGuide) { + return t('core.app.whisper.Close'); + } + return t('core.app.whisper.Open'); + }, [t, isOpenQuestionGuide]); + + return ( + + + {appT('modules.Question Guide')} + + + + + + + + {appT('modules.Question Guide Switch')} + { + onChange({ + ...value, + open: e.target.checked + }); + }} + /> + + {isOpenQuestionGuide && ( + <> + + {appT('modules.Question Guide Texts')} + + {value.textList.length || 0} + + + + + <> + + {appT('modules.Custom question guide URL')} + window.open(getDocPath('/docs/course/custom_link'))} + color={'primary.700'} + alignItems={'center'} + cursor={'pointer'} + > + + {commonT('common.Documents')} + + + +