Update userselect ux (#2610)

* perf: user select ux and api

* perf: http variables replace code

* perf: http variables replace code

* perf: chat box question guide adapt interactive

* remove comment
This commit is contained in:
Archer
2024-09-04 11:11:08 +08:00
committed by GitHub
parent 85a11d08b2
commit 64708ea424
21 changed files with 1083 additions and 949 deletions

View File

@@ -45,7 +45,7 @@ import ChatBoxDivider from '../../Divider';
import { OutLinkChatAuthProps } from '@fastgpt/global/support/permission/chat';
import { getNanoid } from '@fastgpt/global/common/string/tools';
import { ChatItemValueTypeEnum, ChatRoleEnum } from '@fastgpt/global/core/chat/constants';
import { formatChatValue2InputType } from './utils';
import { checkIsInteractiveByHistories, formatChatValue2InputType } from './utils';
import { textareaMinH } from './constants';
import { SseResponseEventEnum } from '@fastgpt/global/core/workflow/runtime/constants';
import ChatProvider, { ChatBoxContext, ChatProviderProps } from './Provider';
@@ -156,15 +156,11 @@ const ChatBox = (
isChatting
} = useContextSelector(ChatBoxContext, (v) => v);
const isInteractive = useMemo(() => {
const lastAIHistory = chatHistories[chatHistories.length - 1];
if (!lastAIHistory) return false;
const lastAIMessage = lastAIHistory.value as AIChatItemValueItemType[];
const interactiveContent = lastAIMessage?.find(
(item) => item.type === ChatItemValueTypeEnum.interactive
)?.interactive?.params;
return !!interactiveContent;
}, [chatHistories]);
// Workflow running, there are user input or selection
const isInteractive = useMemo(
() => checkIsInteractiveByHistories(chatHistories),
[chatHistories]
);
// compute variable input is finish.
const chatForm = useForm<ChatBoxInputFormType>({
@@ -343,16 +339,15 @@ const ChatBox = (
// create question guide
const createQuestionGuide = useCallback(
async ({ history }: { history: ChatSiteItemType[] }) => {
async ({ histories }: { histories: ChatSiteItemType[] }) => {
if (!questionGuide || chatController.current?.signal?.aborted) return;
try {
const abortSignal = new AbortController();
questionGuideController.current = abortSignal;
const result = await postQuestionGuide(
{
messages: chats2GPTMessages({ messages: history, reserveId: false }).slice(-6),
messages: chats2GPTMessages({ messages: histories, reserveId: false }).slice(-6),
shareId,
outLinkUid,
teamId,
@@ -464,8 +459,9 @@ const ChatBox = (
}
];
// 插入内容
setChatHistories(newChatList);
const isInteractive = checkIsInteractiveByHistories(history);
// Update histories(Interactive input does not require new session rounds)
setChatHistories(isInteractive ? newChatList.slice(0, -2) : newChatList);
// 清空输入内容
resetInputVal({});
@@ -476,6 +472,7 @@ const ChatBox = (
const abortSignal = new AbortController();
chatController.current = abortSignal;
// Last empty ai message will be removed
const messages = chats2GPTMessages({ messages: newChatList, reserveId: true });
const {
@@ -483,7 +480,7 @@ const ChatBox = (
responseText,
isNewChat = false
} = await onStartChat({
messages: messages.slice(0, -1),
messages: messages,
responseChatItemId: responseChatId,
controller: abortSignal,
generatingMessage: (e) => generatingMessage({ ...e, autoTTSResponse }),
@@ -492,35 +489,29 @@ const ChatBox = (
isNewChatReplace.current = isNewChat;
// set finish status
setChatHistories((state) =>
state.map((item, index) => {
// Set last chat finish status
let newChatHistories: ChatSiteItemType[] = [];
setChatHistories((state) => {
newChatHistories = state.map((item, index) => {
if (index !== state.length - 1) return item;
return {
...item,
status: 'finish',
responseData
responseData: item.responseData
? [...item.responseData, ...responseData]
: responseData
};
})
);
setTimeout(() => {
createQuestionGuide({
history: newChatList.map((item, i) =>
i === newChatList.length - 1
? {
...item,
value: [
{
type: ChatItemValueTypeEnum.text,
text: {
content: responseText
}
}
]
}
: item
)
});
return newChatHistories;
});
setTimeout(() => {
if (!checkIsInteractiveByHistories(newChatHistories)) {
createQuestionGuide({
histories: newChatHistories
});
}
generatingScroll();
isPc && TextareaDom.current?.focus();
}, 100);

View File

@@ -1,7 +1,11 @@
import { ChatItemValueItemType, ChatSiteItemType } from '@fastgpt/global/core/chat/type';
import {
AIChatItemValueItemType,
ChatItemValueItemType,
ChatSiteItemType
} from '@fastgpt/global/core/chat/type';
import { ChatBoxInputType, UserInputFileItemType } from './type';
import { getFileIcon } from '@fastgpt/global/common/file/icon';
import { ChatItemValueTypeEnum } from '@fastgpt/global/core/chat/constants';
import { ChatItemValueTypeEnum, ChatStatusEnum } from '@fastgpt/global/core/chat/constants';
export const formatChatValue2InputType = (value?: ChatItemValueItemType[]): ChatBoxInputType => {
if (!value) {
@@ -38,6 +42,20 @@ export const formatChatValue2InputType = (value?: ChatItemValueItemType[]): Chat
};
};
export const checkIsInteractiveByHistories = (chatHistories: ChatSiteItemType[]) => {
const lastAIHistory = chatHistories[chatHistories.length - 1];
if (!lastAIHistory) return false;
const lastMessageValue = lastAIHistory.value[
lastAIHistory.value.length - 1
] as AIChatItemValueItemType;
return (
lastMessageValue.type === ChatItemValueTypeEnum.interactive &&
!!lastMessageValue?.interactive?.params
);
};
export const setUserSelectResultToHistories = (
histories: ChatSiteItemType[],
selectVal: string
@@ -47,9 +65,14 @@ export const setUserSelectResultToHistories = (
// @ts-ignore
return histories.map((item, i) => {
if (i !== histories.length - 1) return item;
item.value;
const value = item.value.map((val) => {
if (val.type !== ChatItemValueTypeEnum.interactive || !val.interactive) return val;
const value = item.value.map((val, i) => {
if (
i !== item.value.length - 1 ||
val.type !== ChatItemValueTypeEnum.interactive ||
!val.interactive
)
return val;
return {
...val,
@@ -67,6 +90,7 @@ export const setUserSelectResultToHistories = (
return {
...item,
status: ChatStatusEnum.loading,
value
};
});

View File

@@ -16,7 +16,7 @@ import {
ChatSiteItemType,
UserChatItemValueItemType
} from '@fastgpt/global/core/chat/type';
import React from 'react';
import React, { useMemo } from 'react';
import MyIcon from '@fastgpt/web/components/common/Icon';
import Avatar from '@fastgpt/web/components/common/Avatar';
import { SendPromptFnType } from '../ChatContainer/ChatBox/type';
@@ -45,144 +45,168 @@ const AIResponseBox = ({
}: props) => {
const chatHistories = useContextSelector(ChatBoxContext, (v) => v.chatHistories);
if (value.type === ChatItemValueTypeEnum.text && value.text) {
let source = (value.text?.content || '').trim();
// First empty line
if (!source && chat.value.length > 1) return null;
// computed question guide
// Question guide
const RenderQuestionGuide = useMemo(() => {
if (
isLastChild &&
!isChatting &&
questionGuides.length > 0 &&
index === chat.value.length - 1
) {
source = `${source}
\`\`\`${CodeClassNameEnum.questionGuide}
${JSON.stringify(questionGuides)}`;
return (
<Markdown
source={`\`\`\`${CodeClassNameEnum.questionGuide}
${JSON.stringify(questionGuides)}`}
/>
);
}
return null;
}, [chat.value.length, index, isChatting, isLastChild, questionGuides]);
return (
<Markdown
source={source}
showAnimation={isLastChild && isChatting && index === chat.value.length - 1}
/>
);
}
if (value.type === ChatItemValueTypeEnum.tool && value.tools) {
return (
<Box>
{value.tools.map((tool) => {
const toolParams = (() => {
try {
return JSON.stringify(JSON.parse(tool.params), null, 2);
} catch (error) {
return tool.params;
}
})();
const toolResponse = (() => {
try {
return JSON.stringify(JSON.parse(tool.response), null, 2);
} catch (error) {
return tool.response;
}
})();
const Render = useMemo(() => {
if (value.type === ChatItemValueTypeEnum.text && value.text) {
let source = (value.text?.content || '').trim();
return (
<Accordion key={tool.id} allowToggle>
<AccordionItem borderTop={'none'} borderBottom={'none'}>
<AccordionButton
w={'auto'}
bg={'white'}
borderRadius={'md'}
borderWidth={'1px'}
borderColor={'myGray.200'}
boxShadow={'1'}
pl={3}
pr={2.5}
_hover={{
bg: 'auto'
// First empty line
if (!source && chat.value.length > 1) return null;
return (
<Markdown
source={source}
showAnimation={isLastChild && isChatting && index === chat.value.length - 1}
/>
);
}
if (value.type === ChatItemValueTypeEnum.tool && value.tools) {
return (
<Box>
{value.tools.map((tool) => {
const toolParams = (() => {
try {
return JSON.stringify(JSON.parse(tool.params), null, 2);
} catch (error) {
return tool.params;
}
})();
const toolResponse = (() => {
try {
return JSON.stringify(JSON.parse(tool.response), null, 2);
} catch (error) {
return tool.response;
}
})();
return (
<Accordion key={tool.id} allowToggle>
<AccordionItem borderTop={'none'} borderBottom={'none'}>
<AccordionButton
w={'auto'}
bg={'white'}
borderRadius={'md'}
borderWidth={'1px'}
borderColor={'myGray.200'}
boxShadow={'1'}
pl={3}
pr={2.5}
_hover={{
bg: 'auto'
}}
>
<Avatar src={tool.toolAvatar} w={'1.25rem'} h={'1.25rem'} borderRadius={'sm'} />
<Box mx={2} fontSize={'sm'} color={'myGray.900'}>
{tool.toolName}
</Box>
{isChatting && !tool.response && <MyIcon name={'common/loading'} w={'14px'} />}
<AccordionIcon color={'myGray.600'} ml={5} />
</AccordionButton>
<AccordionPanel
py={0}
px={0}
mt={3}
borderRadius={'md'}
overflow={'hidden'}
maxH={'500px'}
overflowY={'auto'}
>
{toolParams && toolParams !== '{}' && (
<Box mb={3}>
<Markdown
source={`~~~json#Input
${toolParams}`}
/>
</Box>
)}
{toolResponse && (
<Markdown
source={`~~~json#Response
${toolResponse}`}
/>
)}
</AccordionPanel>
</AccordionItem>
</Accordion>
);
})}
</Box>
);
}
if (
value.type === ChatItemValueTypeEnum.interactive &&
value.interactive &&
value.interactive.type === 'userSelect'
) {
return (
<>
{value.interactive?.params?.description && (
<Markdown source={value.interactive.params.description} />
)}
<Flex flexDirection={'column'} gap={2} w={'250px'}>
{value.interactive.params.userSelectOptions?.map((option) => {
const selected = option.value === value.interactive?.params?.userSelectedVal;
return (
<Button
key={option.key}
variant={'whitePrimary'}
whiteSpace={'pre-wrap'}
isDisabled={value.interactive?.params?.userSelectedVal !== undefined}
{...(selected
? {
_disabled: {
cursor: 'default',
borderColor: 'primary.300',
bg: 'primary.50 !important',
color: 'primary.600'
}
}
: {})}
onClick={() => {
onSendMessage?.({
text: option.value,
history: setUserSelectResultToHistories(chatHistories, option.value)
});
}}
>
<Avatar src={tool.toolAvatar} w={'1.25rem'} h={'1.25rem'} borderRadius={'sm'} />
<Box mx={2} fontSize={'sm'} color={'myGray.900'}>
{tool.toolName}
</Box>
{isChatting && !tool.response && <MyIcon name={'common/loading'} w={'14px'} />}
<AccordionIcon color={'myGray.600'} ml={5} />
</AccordionButton>
<AccordionPanel
py={0}
px={0}
mt={3}
borderRadius={'md'}
overflow={'hidden'}
maxH={'500px'}
overflowY={'auto'}
>
{toolParams && toolParams !== '{}' && (
<Box mb={3}>
<Markdown
source={`~~~json#Input
${toolParams}`}
/>
</Box>
)}
{toolResponse && (
<Markdown
source={`~~~json#Response
${toolResponse}`}
/>
)}
</AccordionPanel>
</AccordionItem>
</Accordion>
);
})}
</Box>
);
}
if (
value.type === ChatItemValueTypeEnum.interactive &&
value.interactive &&
value.interactive.type === 'userSelect'
) {
return (
<Flex flexDirection={'column'} gap={2} minW={'200px'} maxW={'250px'}>
{value.interactive.params.userSelectOptions?.map((option) => {
const selected = option.value === value.interactive?.params?.userSelectedVal;
{option.value}
</Button>
);
})}
</Flex>
{/* Animation */}
{isLastChild && isChatting && index === chat.value.length - 1 && (
<Markdown source={''} showAnimation />
)}
</>
);
}
}, [chat.value.length, chatHistories, index, isChatting, isLastChild, onSendMessage, value]);
return (
<Button
key={option.key}
variant={'whitePrimary'}
isDisabled={!isLastChild && value.interactive?.params?.userSelectedVal !== undefined}
{...(selected
? {
_disabled: {
cursor: 'default',
borderColor: 'primary.300',
bg: 'primary.50 !important',
color: 'primary.600'
}
}
: {})}
onClick={() => {
onSendMessage?.({
text: option.value,
history: setUserSelectResultToHistories(chatHistories, option.value)
});
}}
>
{option.value}
</Button>
);
})}
</Flex>
);
}
return null;
return (
<>
{Render}
{RenderQuestionGuide}
</>
);
};
export default React.memo(AIResponseBox);

View File

@@ -59,6 +59,8 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
const chatMessages = GPTMessages2Chats(messages);
// console.log(JSON.stringify(chatMessages, null, 2), '====', chatMessages.length);
const userInput = chatMessages.pop()?.value as UserChatItemValueItemType[] | undefined;
/* user auth */

View File

@@ -17,11 +17,12 @@ import {
getMaxHistoryLimitFromNodes,
initWorkflowEdgeStatus,
storeNodes2RuntimeNodes,
textAdaptGptResponse
textAdaptGptResponse,
getLastInteractiveValue
} from '@fastgpt/global/core/workflow/runtime/utils';
import { GPTMessages2Chats, chatValue2RuntimePrompt } from '@fastgpt/global/core/chat/adapt';
import { getChatItems } from '@fastgpt/service/core/chat/controller';
import { saveChat } from '@fastgpt/service/core/chat/saveChat';
import { saveChat, updateInteractiveChat } from '@fastgpt/service/core/chat/saveChat';
import { responseWrite } from '@fastgpt/service/common/response';
import { pushChatUsage } from '@/service/support/wallet/usage/push';
import { authOutLinkChatStart } from '@/service/support/permission/auth/outLink';
@@ -45,7 +46,7 @@ import { AuthOutLinkChatProps } from '@fastgpt/global/support/outLink/api';
import { MongoChat } from '@fastgpt/service/core/chat/chatSchema';
import { ChatErrEnum } from '@fastgpt/global/common/error/code/chat';
import { OutLinkChatAuthProps } from '@fastgpt/global/support/permission/chat';
import { UserChatItemType } from '@fastgpt/global/core/chat/type';
import { AIChatItemType, UserChatItemType } from '@fastgpt/global/core/chat/type';
import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants';
import { NextAPI } from '@/service/middleware/entry';
@@ -210,9 +211,6 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
MongoChat.findOne({ appId: app._id, chatId }, 'source variableList variables')
]);
// Get chat histories
const newHistories = concatHistories(histories, chatMessages);
// Get store variables(Api variable precedence)
if (chatDetail?.variables) {
variables = {
@@ -221,6 +219,9 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
};
}
// Get chat histories
const newHistories = concatHistories(histories, chatMessages);
// Get runtimeNodes
let runtimeNodes = storeNodes2RuntimeNodes(nodes, getWorkflowEntryNodeIds(nodes, newHistories));
if (isPlugin) {
@@ -286,36 +287,51 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
return ChatSourceEnum.online;
})();
const isInteractiveRequest = !!getLastInteractiveValue(histories);
const { text: userSelectedVal } = chatValue2RuntimePrompt(userQuestion.value);
const newTitle = isPlugin
? variables.cTime ?? getSystemTime(user.timezone)
: getChatTitleFromChatMessage(userQuestion);
await saveChat({
chatId,
appId: app._id,
teamId,
tmbId: tmbId,
nodes,
appChatConfig: chatConfig,
variables: newVariables,
isUpdateUseTime: isOwnerUse && source === ChatSourceEnum.online, // owner update use time
newTitle,
shareId,
outLinkUid: outLinkUserId,
source,
content: [
userQuestion,
{
dataId: responseChatItemId,
obj: ChatRoleEnum.AI,
value: assistantResponses,
[DispatchNodeResponseKeyEnum.nodeResponse]: flowResponses
const aiResponse: AIChatItemType & { dataId?: string } = {
dataId: responseChatItemId,
obj: ChatRoleEnum.AI,
value: assistantResponses,
[DispatchNodeResponseKeyEnum.nodeResponse]: flowResponses
};
if (isInteractiveRequest) {
await updateInteractiveChat({
chatId,
appId: app._id,
teamId,
tmbId: tmbId,
userSelectedVal,
aiResponse,
newVariables,
newTitle
});
} else {
await saveChat({
chatId,
appId: app._id,
teamId,
tmbId: tmbId,
nodes,
appChatConfig: chatConfig,
variables: newVariables,
isUpdateUseTime: isOwnerUse && source === ChatSourceEnum.online, // owner update use time
newTitle,
shareId,
outLinkUid: outLinkUserId,
source,
content: [userQuestion, aiResponse],
metadata: {
originIp
}
],
metadata: {
originIp
}
});
});
}
}
addLog.info(`completions running time: ${(Date.now() - startTime) / 1000}s`);

View File

@@ -466,7 +466,21 @@ const RenderList = React.memo(function RenderList({
flowNodeType: templateNode.flowNodeType,
pluginId: templateNode.pluginId
}),
intro: t(templateNode.intro as any)
intro: t(templateNode.intro as any),
inputs: templateNode.inputs.map((input) => ({
...input,
valueDesc: t(input.valueDesc as any),
label: t(input.label as any),
description: t(input.description as any),
debugLabel: t(input.debugLabel as any),
toolDescription: t(input.toolDescription as any)
})),
outputs: templateNode.outputs.map((output) => ({
...output,
valueDesc: t(output.valueDesc as any),
label: t(output.label as any),
description: t(output.description as any)
}))
},
position: { x: mouseX, y: mouseY - 20 },
selected: true

View File

@@ -26,6 +26,7 @@ import { AiChatModule } from '@fastgpt/global/core/workflow/template/system/aiCh
import { DatasetSearchModule } from '@fastgpt/global/core/workflow/template/system/datasetSearch';
import { ReadFilesNodes } from '@fastgpt/global/core/workflow/template/system/readFiles';
import { i18nT } from '@fastgpt/web/i18n/utils';
import { Input_Template_UserChatInput } from '@fastgpt/global/core/workflow/template/input';
type WorkflowType = {
nodes: StoreNodeItemType[];
@@ -259,12 +260,8 @@ export function form2AppWorkflow(
value: formData.dataset.datasetSearchExtensionBg
},
{
key: 'userChatInput',
renderTypeList: [FlowNodeInputTypeEnum.reference, FlowNodeInputTypeEnum.textarea],
valueType: WorkflowIOValueTypeEnum.string,
label: '用户问题',
required: true,
toolDescription: '需要检索的内容',
...Input_Template_UserChatInput,
toolDescription: i18nT('workflow:content_to_search'),
value: question
}
],
@@ -503,6 +500,18 @@ export function form2AppWorkflow(
]
};
// Add t
config.nodes.forEach((node) => {
node.name = t(node.name);
node.intro = t(node.intro);
node.inputs.forEach((input) => {
input.label = t(input.label);
input.description = t(input.description);
input.toolDescription = t(input.toolDescription);
});
});
return config;
}