feat: question guide (#1508)

* feat: question guide

* fix

* fix

* fix

* change interface

* fix
This commit is contained in:
heheer
2024-05-19 17:34:16 +08:00
committed by GitHub
parent fd31a0b763
commit e35ce2caa0
40 changed files with 1071 additions and 34 deletions

View File

@@ -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<HTMLCanvasElement>(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 (
<Box m={['0 auto', '10px auto']} w={'100%'} maxW={['auto', 'min(800px, 100%)']} px={[0, 5]}>
<Box
@@ -214,7 +237,7 @@ const MessageInput = ({
boxShadow={isSpeaking ? `0 0 10px rgba(54,111,255,0.4)` : `0 0 10px rgba(0,0,0,0.2)`}
borderRadius={['none', 'md']}
bg={'white'}
overflow={'hidden'}
overflow={'display'}
{...(isPc
? {
border: '1px solid',
@@ -243,6 +266,21 @@ const MessageInput = ({
{t('core.chat.Converting to text')}
</Flex>
{/* popup */}
{havInput && (
<QuestionGuide
guides={data?.list || []}
setDropdownValue={(value) => setValue('input', value)}
bottom={'100%'}
top={'auto'}
left={0}
right={0}
mb={2}
overflowY={'auto'}
boxShadow={'sm'}
/>
)}
{/* file preview */}
<Flex wrap={'wrap'} px={[2, 4]} userSelect={'none'}>
{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();
}

View File

@@ -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 ? (
<Box
bg={'white'}
boxShadow={'lg'}
borderWidth={'1px'}
borderColor={'borderColor.base'}
p={2}
borderRadius={'md'}
position={'absolute'}
top={'100%'}
w={'auto'}
zIndex={99999}
maxH={'300px'}
overflow={'auto'}
className="nowheel"
{...props}
>
<Flex alignItems={'center'} fontSize={'sm'} color={'myGray.600'} gap={2} mb={2} px={2}>
<MyIcon name={'union'} />
<Box>{appT('modules.Input Guide')}</Box>
</Flex>
{guides.map((item, index) => (
<Flex
alignItems={'center'}
as={'li'}
key={item}
px={4}
py={3}
borderRadius={'sm'}
cursor={'pointer'}
maxH={'300px'}
overflow={'auto'}
_notLast={{
mb: 1
}}
{...(highlightedIndex === index
? {
bg: 'primary.50',
color: 'primary.600'
}
: {
bg: 'myGray.50',
color: 'myGray.600'
})}
onMouseDown={(e) => {
e.preventDefault();
setDropdownValue?.(item);
}}
onMouseEnter={() => {
setHighlightedIndex(index);
}}
>
<Box fontSize={'sm'}>{item}</Box>
</Flex>
))}
</Box>
) : null;
}

View File

@@ -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'));

View File

@@ -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<string>('');
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 (
<Flex alignItems={'center'}>
<MyIcon name={'core/app/inputGuides'} mr={2} w={'20px'} />
<Box fontWeight={'medium'}>{appT('modules.Question Guide')}</Box>
<Box flex={1} />
<MyTooltip label={appT('modules.Config question guide')}>
<Button
variant={'transparentBase'}
iconSpacing={1}
size={'sm'}
mr={'-5px'}
onClick={onOpen}
>
{formLabel}
</Button>
</MyTooltip>
<MyModal
title={appT('modules.Question Guide')}
iconSrc="core/app/inputGuides"
isOpen={isOpen}
onClose={onClose}
>
<ModalBody px={[5, 16]} pt={[4, 8]} w={'500px'}>
<Flex justifyContent={'space-between'} alignItems={'center'}>
{appT('modules.Question Guide Switch')}
<Switch
isChecked={isOpenQuestionGuide}
size={'lg'}
onChange={(e) => {
onChange({
...value,
open: e.target.checked
});
}}
/>
</Flex>
{isOpenQuestionGuide && (
<>
<Flex mt={8} alignItems={'center'}>
{appT('modules.Question Guide Texts')}
<Box fontSize={'xs'} px={2} bg={'myGray.100'} ml={1} rounded={'full'}>
{value.textList.length || 0}
</Box>
<Box flex={'1 0 0'} />
<Button
variant={'whiteBase'}
size={'sm'}
leftIcon={<MyIcon boxSize={'4'} name={'common/settingLight'} />}
onClick={() => {
onOpenTexts();
onClose();
}}
>
{appT('modules.Config Texts')}
</Button>
</Flex>
<>
<Flex mt={8} alignItems={'center'}>
{appT('modules.Custom question guide URL')}
<Flex
onClick={() => window.open(getDocPath('/docs/course/custom_link'))}
color={'primary.700'}
alignItems={'center'}
cursor={'pointer'}
>
<MyIcon name={'book'} ml={4} mr={1} />
{commonT('common.Documents')}
</Flex>
<Box flex={'1 0 0'} />
</Flex>
<Textarea
mt={2}
bg={'myGray.50'}
defaultValue={value.customURL}
onBlur={(e) =>
onChange({
...value,
customURL: e.target.value
})
}
/>
</>
</>
)}
</ModalBody>
<ModalFooter px={[5, 16]} pb={[4, 8]}>
<Button onClick={() => onClose()}>{commonT('common.Confirm')}</Button>
</ModalFooter>
</MyModal>
{isOpenTexts && (
<TextConfigModal
onCloseTexts={onCloseTexts}
onOpen={onOpen}
value={value}
onChange={onChange}
setSearchKey={setSearchKey}
/>
)}
</Flex>
);
};
export default React.memo(QGuidesConfig);
const TextConfigModal = ({
onCloseTexts,
onOpen,
value,
onChange,
setSearchKey
}: {
onCloseTexts: () => void;
onOpen: () => void;
value: AppQuestionGuideTextConfigType;
onChange: (e: AppQuestionGuideTextConfigType) => void;
setSearchKey: (key: string) => void;
}) => {
const { appT, commonT } = useI18n();
const fileInputRef = useRef<HTMLInputElement>(null);
const [checkboxValue, setCheckboxValue] = React.useState<string[]>([]);
const [isEditIndex, setIsEditIndex] = React.useState(-1);
const [isAdding, setIsAdding] = React.useState(false);
const [showIcons, setShowIcons] = React.useState<number | null>(null);
const { getCheckboxProps } = useCheckboxGroup();
const handleFileSelected = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = (e: ProgressEvent<FileReader>) => {
const content = e.target?.result as string;
const rows = content.split('\n');
const texts = rows.map((row) => row.split(',')[0]);
const newText = texts.filter((row) => value.textList.indexOf(row) === -1 && !!row);
onChange({
...value,
textList: [...newText, ...value.textList]
});
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
reader.readAsText(file);
}
};
const allSelected = useMemo(() => {
return value.textList.length === checkboxValue.length && value.textList.length !== 0;
}, [value.textList, checkboxValue]);
return (
<MyModal
title={appT('modules.Config Texts')}
iconSrc="core/app/inputGuides"
isOpen={true}
onClose={() => {
setCheckboxValue([]);
onCloseTexts();
onOpen();
}}
>
<ModalBody w={'500px'} px={0}>
<Flex gap={4} px={8} alignItems={'center'} borderBottom={'1px solid #E8EBF0'} pb={4}>
<Box flex={1}>
<MyInput
leftIcon={<MyIcon name={'common/searchLight'} boxSize={4} />}
bg={'myGray.50'}
w={'full'}
h={9}
placeholder={commonT('common.Search')}
onChange={(e) => setSearchKey(e.target.value)}
/>
</Box>
<Input
type="file"
accept=".csv"
style={{ display: 'none' }}
ref={fileInputRef}
onChange={handleFileSelected}
/>
<Button
onClick={() => {
fileInputRef.current?.click();
}}
variant={'whiteBase'}
size={'sm'}
leftIcon={<MyIcon name={'common/importLight'} boxSize={4} />}
>
{commonT('common.Import')}
</Button>
<Box
cursor={'pointer'}
onClick={() => {
fileDownload({
text: csvTemplate,
type: 'text/csv;charset=utf-8',
filename: 'questionGuide_template.csv'
});
}}
>
<QuestionTip ml={-2} label={appT('modules.Only support CSV')} />
</Box>
</Flex>
<Box mt={4}>
<Flex justifyContent={'space-between'} px={8}>
<Flex alignItems={'center'}>
<Checkbox
sx={{
'.chakra-checkbox__control': {
bg: allSelected ? 'primary.50' : 'none',
boxShadow: allSelected && '0 0 0 2px #F0F4FF',
_hover: {
bg: 'primary.50'
},
border: allSelected && '1px solid #3370FF',
color: 'primary.600'
},
svg: {
strokeWidth: '1px !important'
}
}}
value={'all'}
size={'lg'}
mr={2}
isChecked={allSelected}
onChange={(e) => {
if (e.target.checked) {
setCheckboxValue(value.textList);
} else {
setCheckboxValue([]);
}
}}
/>
<Box fontSize={'sm'} color={'myGray.600'} fontWeight={'medium'}>
{commonT('common.Select all')}
</Box>
</Flex>
<Flex gap={4}>
<Button
variant={'whiteBase'}
display={checkboxValue.length === 0 ? 'none' : 'flex'}
size={'sm'}
leftIcon={<MyIcon name={'delete'} boxSize={4} />}
onClick={() => {
setCheckboxValue([]);
onChange({
...value,
textList: value.textList.filter((_) => !checkboxValue.includes(_))
});
}}
>
{commonT('common.Delete')}
</Button>
<Button
display={checkboxValue.length !== 0 ? 'none' : 'flex'}
onClick={() => {
onChange({
...value,
textList: ['', ...value.textList]
});
setIsEditIndex(0);
setIsAdding(true);
}}
size={'sm'}
leftIcon={<MyIcon name={'common/addLight'} boxSize={4} />}
>
{commonT('common.Add')}
</Button>
</Flex>
</Flex>
<Box h={'400px'} pb={4} overflow={'auto'} px={8}>
{value.textList.map((text, index) => {
const selected = checkboxValue.includes(text);
return (
<Flex
key={index}
alignItems={'center'}
h={10}
mt={2}
onMouseEnter={() => setShowIcons(index)}
onMouseLeave={() => setShowIcons(null)}
>
<Checkbox
{...getCheckboxProps({ value: text })}
sx={{
'.chakra-checkbox__control': {
bg: selected ? 'primary.50' : 'none',
boxShadow: selected ? '0 0 0 2px #F0F4FF' : 'none',
_hover: {
bg: 'primary.50'
},
border: selected && '1px solid #3370FF',
color: 'primary.600'
},
svg: {
strokeWidth: '1px !important'
}
}}
size={'lg'}
mr={2}
isChecked={selected}
onChange={(e) => {
if (e.target.checked) {
setCheckboxValue([...checkboxValue, text]);
} else {
setCheckboxValue(checkboxValue.filter((_) => _ !== text));
}
}}
/>
{index === isEditIndex ? (
<InputGroup alignItems={'center'} h={'full'}>
<Input
autoFocus
h={'full'}
defaultValue={text}
onBlur={(e) => {
setIsEditIndex(-1);
if (
!e.target.value ||
(value.textList.indexOf(e.target.value) !== -1 &&
value.textList.indexOf(e.target.value) !== index)
) {
isAdding &&
onChange({
...value,
textList: value.textList.filter((_, i) => i !== index)
});
} else {
onChange({
...value,
textList: value.textList?.map((v, i) =>
i !== index ? v : e.target.value
)
});
}
setIsAdding(false);
}}
/>
<InputRightElement alignItems={'center'} pr={4} display={'flex'}>
<MyIcon name={'save'} boxSize={4} cursor={'pointer'} />
</InputRightElement>
</InputGroup>
) : (
<Flex
h={10}
w={'full'}
rounded={'md'}
px={4}
bg={'myGray.50'}
alignItems={'center'}
border={'1px solid #F0F1F6'}
_hover={{ border: '1px solid #94B5FF' }}
>
{text}
<Box flex={1} />
{checkboxValue.length === 0 && (
<Box display={showIcons === index ? 'flex' : 'none'}>
<MyIcon
name={'edit'}
boxSize={4}
mr={2}
color={'myGray.600'}
cursor={'pointer'}
onClick={() => setIsEditIndex(index)}
/>
<MyIcon
name={'delete'}
boxSize={4}
color={'myGray.600'}
cursor={'pointer'}
onClick={() => {
const temp = value.textList?.filter((_, i) => i !== index);
onChange({
...value,
textList: temp
});
}}
/>
</Box>
)}
</Flex>
)}
</Flex>
);
})}
</Box>
</Box>
</ModalBody>
</MyModal>
);
};

View File

@@ -16,8 +16,10 @@ import MyTooltip from '@/components/MyTooltip';
import { useUserStore } from '@/web/support/user/useUserStore';
import ChatBox from '@/components/ChatBox';
import type { ComponentRef, StartChatFnProps } from '@/components/ChatBox/type.d';
import { getGuideModule } from '@fastgpt/global/core/workflow/utils';
import { checkChatSupportSelectFileByModules } from '@/web/core/chat/utils';
import {
checkChatSupportSelectFileByModules,
getAppQuestionGuidesByModules
} from '@/web/core/chat/utils';
import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import { useTranslation } from 'next-i18next';
import { StoreEdgeItemType } from '@fastgpt/global/core/workflow/type/edge';
@@ -26,6 +28,7 @@ import {
initWorkflowEdgeStatus,
storeNodes2RuntimeNodes
} from '@fastgpt/global/core/workflow/runtime/utils';
import { getGuideModule } from '@fastgpt/global/core/workflow/utils';
export type ChatTestComponentRef = {
resetChatTest: () => void;

View File

@@ -9,6 +9,7 @@ import { welcomeTextTip } from '@fastgpt/global/core/workflow/template/tip';
import QGSwitch from '@/components/core/app/QGSwitch';
import TTSSelect from '@/components/core/app/TTSSelect';
import WhisperConfig from '@/components/core/app/WhisperConfig';
import QGuidesConfig from '@/components/core/app/QGuidesConfig';
import { splitGuideModule } from '@fastgpt/global/core/workflow/utils';
import { useTranslation } from 'next-i18next';
import { TTSTypeEnum } from '@/web/core/app/constants';
@@ -21,11 +22,6 @@ import { WorkflowContext } from '../../context';
import { VariableItemType } from '@fastgpt/global/core/app/type';
import { useMemoizedFn } from 'ahooks';
import VariableEdit from '@/components/core/app/VariableEdit';
import {
FlowNodeOutputTypeEnum,
FlowNodeTypeEnum
} from '@fastgpt/global/core/workflow/node/constant';
import { FlowNodeOutputItemType } from '@fastgpt/global/core/workflow/type/io';
const NodeUserGuide = ({ data, selected }: NodeProps<FlowNodeItemType>) => {
const theme = useTheme();
@@ -60,6 +56,9 @@ const NodeUserGuide = ({ data, selected }: NodeProps<FlowNodeItemType>) => {
<Box mt={3} pt={3} borderTop={theme.borders.base}>
<ScheduledTrigger data={data} />
</Box>
<Box mt={3} pt={3} borderTop={theme.borders.base}>
<QuestionInputGuide data={data} />
</Box>
</Box>
</NodeCard>
</>
@@ -240,3 +239,26 @@ function ScheduledTrigger({ data }: { data: FlowNodeItemType }) {
/>
);
}
function QuestionInputGuide({ data }: { data: FlowNodeItemType }) {
const { inputs, nodeId } = data;
const onChangeNode = useContextSelector(WorkflowContext, (v) => v.onChangeNode);
const { questionGuideText } = splitGuideModule({ inputs } as StoreNodeItemType);
return (
<QGuidesConfig
value={questionGuideText}
onChange={(e) => {
onChangeNode({
nodeId,
key: NodeInputKeyEnum.questionGuideText,
type: 'updateInput',
value: {
...inputs.find((item) => item.key === NodeInputKeyEnum.questionGuideText),
value: e
}
});
}}
/>
);
}