Check debug (#4384)

* feat : Added support for interactive nodes in the debugging interface (#4339)

* feat: add VSCode launch configuration and enhance debug API handler

* feat: refactor debug API handler to streamline workflow processing and enhance interactive chat features

* feat: enhance debug API handler with structured input forms and improved query handling

* feat: enhance debug API handler to support optional query and histories parameters

* feat: simplify query and histories initialization in debug API handler

* feat: add realmode parameter to workflow dispatch and update interactive handling

* feat: add optional query parameter to PostWorkflowDebugProps and remove realmode from ModuleDispatchProps

* feat: add history parameter to PostWorkflowDebugProps and update related components

* feat: remove realmode

* feat: simplify handler parameter destructuring in debug.ts

* feat: remove unused interactive prop from WholeResponseContent component

* feat: refactor onNextNodeDebug to use parameter object for better readability

* feat: Merge selections and next actions to remove unused state management

* feat: 添加 NodeDebugResponse 组件以增强调试功能

* feat: Simplify the import statements in InteractiveComponents.tsx

* feat: Update the handler function to use default parameters to simplify the code

* feat: Add optional workflowInteractiveResponse field to PostWorkflowDebugResponse type

* feat: Add the workflowInteractiveResponse field in the debugging handler to enhance response capabilities

* feat: Added workflowInteractiveResponse field in FlowNodeItemType to enhance responsiveness

* feat: Refactor NodeDebugResponse to utilize workflowInteractiveResponse for improved interactivity

* feat: Extend UserSelectInteractive and UserInputInteractive types to inherit from InteractiveBasicType

* feat: Refactor NodeDebugResponse to streamline interactive handling and improve code clarity

* feat: 重构交互式调试逻辑,创建共用 Hook 以简化用户选择和输入处理

* fix: type error

* feat: 重构 AIResponseBox 组件,简化用户交互逻辑并引入共用表单组件

* feat: 清理 AIResponseBox 和表单组件代码,移除冗余注释和未使用的导入

* fix: type error

* feat: 重构 AIResponseBox 组件,简化类型定义并优化代码结构

* refactor: 将 FormItem 接口更改为类型定义,优化代码结构

* refactor: 将 NodeDebugResponseProps 接口更改为类型定义,优化代码结构

* refactor: 移除不必要的入口节点检查,简化调试处理逻辑

* feat: 移动调试交互组件位置

* refactor: 将 InteractiveBasicType 中的属性设为可选,简化数据结构

* refactor: 优化类型定义

* refactor: 移除未使用的 ChatItemType 和 UserChatItemValueItemType 导入

* refactor: 将接口定义更改为类型别名,简化代码结构

* refactor: 更新类型定义,使用类型别名简化代码结构

* refactor: 使用类型导入简化代码结构,重构 AIResponseBox 组件

* refactor: 提取描述框和表单项标签组件,简化代码结构

* refactor: 移除多余的空行

* refactor: 移除多余的空行和注释

* refactor: 移除多余的空行,简化 AIResponseBox 组件代码

* refactor: 重构组件,移动 FormComponents 到 InteractiveComponents,简化代码结构

* refactor: 移除多余的空行,简化 NodeDebugResponse 组件代码

* refactor: 更新导入语句,使用 type 关键字优化类型导入

* refactor: 在 tsconfig.json 中启用 verbatimModuleSyntax 选项

* Revert "refactor: 在 tsconfig.json 中启用 verbatimModuleSyntax 选项"

This reverts commit 2b335a9938.

* revert: rendertool

* refactor: Remove unused imports and functions to simplify code

* perf: debug interactive

---------

Co-authored-by: Theresa <63280168+sd0ric4@users.noreply.github.com>
This commit is contained in:
Archer
2025-03-28 17:09:08 +08:00
committed by GitHub
parent 2d3ae7f944
commit 0ed99d8c9a
16 changed files with 792 additions and 503 deletions

View File

@@ -8,43 +8,80 @@ import {
Box,
Button,
Flex,
HStack,
Textarea
HStack
} from '@chakra-ui/react';
import { ChatItemValueTypeEnum } from '@fastgpt/global/core/chat/constants';
import {
import type {
AIChatItemValueItemType,
ToolModuleResponseItemType,
UserChatItemValueItemType
} from '@fastgpt/global/core/chat/type';
import React, { useCallback, useEffect } from 'react';
import React, { useCallback, useMemo } from 'react';
import MyIcon from '@fastgpt/web/components/common/Icon';
import Avatar from '@fastgpt/web/components/common/Avatar';
import {
import type {
InteractiveBasicType,
UserInputInteractive,
UserSelectInteractive
} from '@fastgpt/global/core/workflow/template/system/interactive/type';
import { isEqual } from 'lodash';
import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel';
import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip';
import { FlowNodeInputTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { useTranslation } from 'next-i18next';
import { Controller, useForm } from 'react-hook-form';
import MySelect from '@fastgpt/web/components/common/MySelect';
import MyTextarea from '@/components/common/Textarea/MyTextarea';
import MyNumberInput from '@fastgpt/web/components/common/Input/NumberInput';
import { SendPromptFnType } from '../ChatContainer/ChatBox/type';
import { eventBus, EventNameEnum } from '@/web/common/utils/eventbus';
import { SelectOptionsComponent, FormInputComponent } from './Interactive/InteractiveComponents';
type props = {
value: UserChatItemValueItemType | AIChatItemValueItemType;
isLastResponseValue: boolean;
isChatting: boolean;
const accordionButtonStyle = {
w: 'auto',
bg: 'white',
borderRadius: 'md',
borderWidth: '1px',
borderColor: 'myGray.200',
boxShadow: '1',
pl: 3,
pr: 2.5,
_hover: {
bg: 'auto'
}
};
const onSendPrompt: SendPromptFnType = (e) => eventBus.emit(EventNameEnum.sendQuestion, e);
const RenderResoningContent = React.memo(function RenderResoningContent({
content,
isChatting,
isLastResponseValue
}: {
content: string;
isChatting: boolean;
isLastResponseValue: boolean;
}) {
const { t } = useTranslation();
const showAnimation = isChatting && isLastResponseValue;
return (
<Accordion allowToggle defaultIndex={isLastResponseValue ? 0 : undefined}>
<AccordionItem borderTop={'none'} borderBottom={'none'}>
<AccordionButton {...accordionButtonStyle} py={1}>
<HStack mr={2} spacing={1}>
<MyIcon name={'core/chat/think'} w={'0.85rem'} />
<Box fontSize={'sm'}>{t('chat:ai_reasoning')}</Box>
</HStack>
{showAnimation && <MyIcon name={'common/loading'} w={'0.85rem'} />}
<AccordionIcon color={'myGray.600'} ml={5} />
</AccordionButton>
<AccordionPanel
py={0}
pr={0}
pl={3}
mt={2}
borderLeft={'2px solid'}
borderColor={'myGray.300'}
color={'myGray.500'}
>
<Markdown source={content} showAnimation={showAnimation} />
</AccordionPanel>
</AccordionItem>
</Accordion>
);
});
const RenderText = React.memo(function RenderText({
showAnimation,
text
@@ -58,6 +95,7 @@ const RenderText = React.memo(function RenderText({
return <Markdown source={source} showAnimation={showAnimation} />;
});
const RenderTool = React.memo(
function RenderTool({
showAnimation,
@@ -69,37 +107,20 @@ const RenderTool = React.memo(
return (
<Box>
{tools.map((tool) => {
const toolParams = (() => {
const formatJson = (string: string) => {
try {
return JSON.stringify(JSON.parse(tool.params), null, 2);
return JSON.stringify(JSON.parse(string), null, 2);
} catch (error) {
return tool.params;
return string;
}
})();
const toolResponse = (() => {
try {
return JSON.stringify(JSON.parse(tool.response), null, 2);
} catch (error) {
return tool.response;
}
})();
};
const toolParams = formatJson(tool.params);
const toolResponse = formatJson(tool.response);
return (
<Accordion key={tool.id} allowToggle _notLast={{ mb: 2 }}>
<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'
}}
>
<AccordionButton {...accordionButtonStyle}>
<Avatar src={tool.toolAvatar} w={'1.25rem'} h={'1.25rem'} borderRadius={'sm'} />
<Box mx={2} fontSize={'sm'} color={'myGray.900'}>
{tool.toolName}
@@ -140,99 +161,24 @@ ${toolResponse}`}
},
(prevProps, nextProps) => isEqual(prevProps, nextProps)
);
const RenderResoningContent = React.memo(function RenderResoningContent({
content,
isChatting,
isLastResponseValue
}: {
content: string;
isChatting: boolean;
isLastResponseValue: boolean;
}) {
const { t } = useTranslation();
const showAnimation = isChatting && isLastResponseValue;
return (
<Accordion allowToggle defaultIndex={isLastResponseValue ? 0 : undefined}>
<AccordionItem borderTop={'none'} borderBottom={'none'}>
<AccordionButton
w={'auto'}
bg={'white'}
borderRadius={'md'}
borderWidth={'1px'}
borderColor={'myGray.200'}
boxShadow={'1'}
pl={3}
pr={2.5}
py={1}
_hover={{
bg: 'auto'
}}
>
<HStack mr={2} spacing={1}>
<MyIcon name={'core/chat/think'} w={'0.85rem'} />
<Box fontSize={'sm'}>{t('chat:ai_reasoning')}</Box>
</HStack>
{showAnimation && <MyIcon name={'common/loading'} w={'0.85rem'} />}
<AccordionIcon color={'myGray.600'} ml={5} />
</AccordionButton>
<AccordionPanel
py={0}
pr={0}
pl={3}
mt={2}
borderLeft={'2px solid'}
borderColor={'myGray.300'}
color={'myGray.500'}
>
<Markdown source={content} showAnimation={showAnimation} />
</AccordionPanel>
</AccordionItem>
</Accordion>
);
});
const onSendPrompt = (e: { text: string; isInteractivePrompt: boolean }) =>
eventBus.emit(EventNameEnum.sendQuestion, e);
const RenderUserSelectInteractive = React.memo(function RenderInteractive({
interactive
}: {
interactive: InteractiveBasicType & UserSelectInteractive;
}) {
return (
<>
{interactive?.params?.description && <Markdown source={interactive.params.description} />}
<Flex flexDirection={'column'} gap={2} w={'250px'}>
{interactive.params.userSelectOptions?.map((option) => {
const selected = option.value === interactive?.params?.userSelectedVal;
return (
<Button
key={option.key}
variant={'whitePrimary'}
whiteSpace={'pre-wrap'}
isDisabled={interactive?.params?.userSelectedVal !== undefined}
{...(selected
? {
_disabled: {
cursor: 'default',
borderColor: 'primary.300',
bg: 'primary.50 !important',
color: 'primary.600'
}
}
: {})}
onClick={() => {
onSendPrompt({
text: option.value,
isInteractivePrompt: true
});
}}
>
{option.value}
</Button>
);
})}
</Flex>
</>
<SelectOptionsComponent
interactiveParams={interactive.params}
onSelect={(value) => {
onSendPrompt({
text: value,
isInteractivePrompt: true
});
}}
/>
);
});
const RenderUserFormInteractive = React.memo(function RenderFormInput({
@@ -241,110 +187,52 @@ const RenderUserFormInteractive = React.memo(function RenderFormInput({
interactive: InteractiveBasicType & UserInputInteractive;
}) {
const { t } = useTranslation();
const { register, setValue, handleSubmit: handleSubmitChat, control, reset } = useForm();
const onSubmit = useCallback((data: any) => {
const defaultValues = useMemo(() => {
if (interactive.type === 'userInput') {
return interactive.params.inputForm?.reduce((acc: Record<string, any>, item) => {
acc[item.label] = !!item.value ? item.value : item.defaultValue;
return acc;
}, {});
}
return {};
}, [interactive]);
const handleFormSubmit = useCallback((data: Record<string, any>) => {
onSendPrompt({
text: JSON.stringify(data),
isInteractivePrompt: true
});
}, []);
useEffect(() => {
if (interactive.type === 'userInput') {
const defaultValues = interactive.params.inputForm?.reduce(
(acc: Record<string, any>, item) => {
acc[item.label] = !!item.value ? item.value : item.defaultValue;
return acc;
},
{}
);
reset(defaultValues);
}
}, []);
return (
<Flex flexDirection={'column'} gap={2} w={'250px'}>
{interactive.params.description && <Markdown source={interactive.params.description} />}
{interactive.params.inputForm?.map((input) => (
<Box key={input.label}>
<FormLabel mb={1} required={input.required} whiteSpace={'pre-wrap'}>
{input.label}
{input.description && <QuestionTip ml={1} label={input.description} />}
</FormLabel>
{input.type === FlowNodeInputTypeEnum.input && (
<MyTextarea
isDisabled={interactive.params.submitted}
{...register(input.label, {
required: input.required
})}
bg={'white'}
autoHeight
minH={40}
maxH={100}
/>
)}
{input.type === FlowNodeInputTypeEnum.textarea && (
<Textarea
isDisabled={interactive.params.submitted}
bg={'white'}
{...register(input.label, {
required: input.required
})}
rows={5}
maxLength={input.maxLength || 4000}
/>
)}
{input.type === FlowNodeInputTypeEnum.numberInput && (
<MyNumberInput
min={input.min}
max={input.max}
defaultValue={input.defaultValue}
isDisabled={interactive.params.submitted}
bg={'white'}
register={register}
name={input.label}
isRequired={input.required}
/>
)}
{input.type === FlowNodeInputTypeEnum.select && (
<Controller
key={input.label}
control={control}
name={input.label}
rules={{ required: input.required }}
render={({ field: { ref, value } }) => {
if (!input.list) return <></>;
return (
<MySelect
ref={ref}
width={'100%'}
list={input.list}
value={value}
isDisabled={interactive.params.submitted}
onChange={(e) => setValue(input.label, e)}
/>
);
}}
/>
)}
</Box>
))}
{!interactive.params.submitted && (
<Flex w={'full'} justifyContent={'end'}>
<Button onClick={handleSubmitChat(onSubmit)}>{t('common:Submit')}</Button>
</Flex>
)}
<FormInputComponent
interactiveParams={interactive.params}
defaultValues={defaultValues}
SubmitButton={({ onSubmit }) => (
<Button onClick={() => onSubmit(handleFormSubmit)()}>{t('common:Submit')}</Button>
)}
/>
</Flex>
);
});
const AIResponseBox = ({ value, isLastResponseValue, isChatting }: props) => {
if (value.type === ChatItemValueTypeEnum.text && value.text)
const AIResponseBox = ({
value,
isLastResponseValue,
isChatting
}: {
value: UserChatItemValueItemType | AIChatItemValueItemType;
isLastResponseValue: boolean;
isChatting: boolean;
}) => {
if (value.type === ChatItemValueTypeEnum.text && value.text) {
return (
<RenderText showAnimation={isChatting && isLastResponseValue} text={value.text.content} />
);
if (value.type === ChatItemValueTypeEnum.reasoning && value.reasoning)
}
if (value.type === ChatItemValueTypeEnum.reasoning && value.reasoning) {
return (
<RenderResoningContent
isChatting={isChatting}
@@ -352,14 +240,18 @@ const AIResponseBox = ({ value, isLastResponseValue, isChatting }: props) => {
content={value.reasoning.content}
/>
);
if (value.type === ChatItemValueTypeEnum.tool && value.tools)
return <RenderTool showAnimation={isChatting} tools={value.tools} />;
if (value.type === ChatItemValueTypeEnum.interactive && value.interactive) {
if (value.interactive.type === 'userSelect')
return <RenderUserSelectInteractive interactive={value.interactive} />;
if (value.interactive?.type === 'userInput')
return <RenderUserFormInteractive interactive={value.interactive} />;
}
if (value.type === ChatItemValueTypeEnum.tool && value.tools) {
return <RenderTool showAnimation={isChatting} tools={value.tools} />;
}
if (value.type === ChatItemValueTypeEnum.interactive && value.interactive) {
if (value.interactive.type === 'userSelect') {
return <RenderUserSelectInteractive interactive={value.interactive} />;
}
if (value.interactive?.type === 'userInput') {
return <RenderUserFormInteractive interactive={value.interactive} />;
}
}
return null;
};
export default React.memo(AIResponseBox);

View File

@@ -0,0 +1,206 @@
import React, { useCallback } from 'react';
import { Box, Button, Flex, Textarea } from '@chakra-ui/react';
import { Controller, useForm, UseFormHandleSubmit } from 'react-hook-form';
import Markdown from '@/components/Markdown';
import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel';
import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip';
import MySelect from '@fastgpt/web/components/common/MySelect';
import MyTextarea from '@/components/common/Textarea/MyTextarea';
import MyNumberInput from '@fastgpt/web/components/common/Input/NumberInput';
import { FlowNodeInputTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import {
UserInputFormItemType,
UserInputInteractive,
UserSelectInteractive,
UserSelectOptionItemType
} from '@fastgpt/global/core/workflow/template/system/interactive/type';
const DescriptionBox = React.memo(function DescriptionBox({
description
}: {
description?: string;
}) {
if (!description) return null;
return (
<Box mb={4}>
<Markdown source={description} />
</Box>
);
});
export const SelectOptionsComponent = React.memo(function SelectOptionsComponent({
interactiveParams,
onSelect
}: {
interactiveParams: UserSelectInteractive['params'];
onSelect: (value: string) => void;
}) {
const { description, userSelectOptions, userSelectedVal } = interactiveParams;
return (
<Box maxW={'100%'}>
<DescriptionBox description={description} />
<Flex flexDirection={'column'} gap={3} w={'250px'}>
{userSelectOptions.map((option: UserSelectOptionItemType) => {
const selected = option.value === userSelectedVal;
return (
<Button
key={option.key}
variant={'whitePrimary'}
whiteSpace={'pre-wrap'}
isDisabled={!!userSelectedVal}
{...(selected
? {
_disabled: {
cursor: 'default',
borderColor: 'primary.300',
bg: 'primary.50 !important',
color: 'primary.600'
}
}
: {})}
onClick={() => onSelect(option.value)}
>
{option.value}
</Button>
);
})}
</Flex>
</Box>
);
});
export const FormInputComponent = React.memo(function FormInputComponent({
interactiveParams,
defaultValues = {},
SubmitButton
}: {
interactiveParams: UserInputInteractive['params'];
defaultValues?: Record<string, any>;
SubmitButton: (e: { onSubmit: UseFormHandleSubmit<Record<string, any>> }) => React.JSX.Element;
}) {
const { description, inputForm, submitted } = interactiveParams;
const { register, setValue, handleSubmit, control } = useForm({
defaultValues
});
const FormItemLabel = useCallback(
({
label,
required,
description
}: {
label: string;
required?: boolean;
description?: string;
}) => {
return (
<Flex mb={1} alignItems={'center'}>
<FormLabel required={required} mb={0} fontWeight="medium" color="gray.700">
{label}
</FormLabel>
{description && <QuestionTip ml={1} label={description} />}
</Flex>
);
},
[]
);
const RenderFormInput = useCallback(
({ input }: { input: UserInputFormItemType }) => {
const { type, label, required, maxLength, min, max, defaultValue, list } = input;
switch (type) {
case FlowNodeInputTypeEnum.input:
return (
<MyTextarea
isDisabled={submitted}
{...register(label, {
required: required
})}
bg={'white'}
autoHeight
minH={40}
maxH={100}
/>
);
case FlowNodeInputTypeEnum.textarea:
return (
<Textarea
isDisabled={submitted}
bg={'white'}
{...register(label, {
required: required
})}
rows={5}
maxLength={maxLength || 4000}
/>
);
case FlowNodeInputTypeEnum.numberInput:
return (
<MyNumberInput
min={min}
max={max}
defaultValue={defaultValue}
isDisabled={submitted}
bg={'white'}
register={register}
name={label}
isRequired={required}
/>
);
case FlowNodeInputTypeEnum.select:
return (
<Controller
key={label}
control={control}
name={label}
rules={{ required: required }}
render={({ field: { ref, value } }) => {
if (!list) return <></>;
return (
<MySelect
ref={ref}
width={'100%'}
list={list}
value={value}
isDisabled={submitted}
onChange={(e) => setValue(label, e)}
/>
);
}}
/>
);
default:
return null;
}
},
[control, register, setValue, submitted]
);
return (
<Box>
<DescriptionBox description={description} />
<Flex flexDirection={'column'} gap={3}>
{inputForm.map((input) => (
<Box key={input.label}>
<FormItemLabel
label={input.label}
required={input.required}
description={input.description}
/>
<RenderFormInput input={input} />
</Box>
))}
</Flex>
{!submitted && (
<Flex justifyContent={'flex-end'} mt={4}>
<SubmitButton onSubmit={handleSubmit} />
</Flex>
)}
</Box>
);
});