Commit 01d716f0 by siigure Committed by GitHub

feat(markdown): add quick replies feature and related styling (#7136)

* feat(markdown): add quick replies feature and related styling

- Introduced a new QuickReplies component to render quick reply options in the Markdown renderer.
- Updated Markdown component to support quick replies with new props for enabling the feature and handling click events.
- Enhanced styling for code blocks containing quick replies to ensure proper display.
- Added utility functions to parse and validate quick replies content.
- Integrated quick replies context in ChatBox to manage state and interactions.

This update enhances user interaction by allowing quick replies directly within the chat interface.

* refactor(markdown): streamline quick replies integration and context management

* perf: quick reply

* add user select

---------

Co-authored-by: archer <545436317@qq.com>
parent 3ac40877
import { Box, Button } from '@chakra-ui/react';
import React from 'react';
import { useContextSelector } from 'use-context-selector';
import { QuickReplyContext } from '@/components/core/chat/ChatContainer/context/quickReplyContext';
type QuickRepliesProps = {
text: string;
};
const QUICK_REPLIES_MAX_LENGTH = 300;
const parseQuickReplies = (text: string): string[] | null => {
const content = text.replace(/\n$/, '');
if (!content || content.length > QUICK_REPLIES_MAX_LENGTH) {
return null;
}
const options = content
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
return options.length > 0 ? options : null;
};
/** 解析并渲染 quick-replies 代码块;未启用或解析失败时不渲染内容。 */
const QuickReplies = ({ text }: QuickRepliesProps) => {
const enableQuickReplies = useContextSelector(QuickReplyContext, (v) => v.enableQuickReplies);
const onQuickReplyClick = useContextSelector(QuickReplyContext, (v) => v.onQuickReplyClick);
const options = React.useMemo(() => parseQuickReplies(text), [text]);
if (!enableQuickReplies || !options) {
return null;
}
return (
<Box
display="inline-grid"
gridTemplateColumns="max-content"
gap={2}
maxW="full"
data-quick-replies=""
>
{options.map((text, index) => (
<Button
key={`${index}-${text}`}
type="button"
size="sm"
variant="whitePrimaryOutline"
justifyContent={'left'}
py={4}
px={4}
w="full"
fontSize="sm"
userSelect={'auto'}
onClick={() => onQuickReplyClick?.(text)}
>
{text}
</Button>
))}
</Box>
);
};
export default React.memo(QuickReplies);
......@@ -436,6 +436,16 @@
word-break: break-word;
}
pre:has([data-quick-replies]),
code:has([data-quick-replies]) {
background: transparent !important;
border: none;
padding: 0;
margin: 0;
color: inherit;
overflow: visible;
}
pre {
display: block;
width: 100%;
......
......@@ -28,6 +28,7 @@ const AudioBlock = dynamic(() => import('./codeBlock/Audio'), { ssr: false });
const ChatGuide = dynamic(() => import('./chat/Guide'), { ssr: false });
const QuestionGuide = dynamic(() => import('./chat/QuestionGuide'), { ssr: false });
const QuickReplies = dynamic(() => import('./chat/QuickReplies'), { ssr: false });
const A = dynamic(() => import('./A'), { ssr: false });
function MarkdownImgRenderer(props: any) {
......@@ -177,7 +178,7 @@ function Code(e: any) {
autoPreviewHtmlCodeBlock,
markdownClassName
} = e;
const match = /language-(\w+)/.exec(className || '');
const match = /language-([\w-]+)/.exec(className || '');
const codeType = match?.[1]?.toLowerCase();
const strChildren = String(children);
......@@ -220,6 +221,9 @@ function Code(e: any) {
if (codeType === CodeClassNameEnum.audio) {
return <AudioBlock code={strChildren} />;
}
if (codeType === CodeClassNameEnum.quickReplies) {
return <QuickReplies text={strChildren} />;
}
return (
<CodeLight className={className} codeBlock={codeBlock} match={match}>
......
......@@ -11,7 +11,8 @@ export enum CodeClassNameEnum {
htm = 'htm',
svg = 'svg',
video = 'video',
audio = 'audio'
audio = 'audio',
quickReplies = 'quick-replies'
}
const streamingIncompleteMarkdownTailPatterns = [
......
......@@ -564,7 +564,8 @@ export const useChatGenerate = ({
history = chatRecords,
interactive,
autoTTSResponse = false,
hideInUI = false
hideInUI = false,
clearInput = true
}) => {
variablesForm.handleSubmit(
async ({ variables = {} }) => {
......@@ -679,7 +680,9 @@ export const useChatGenerate = ({
: newChatList
);
resetInputVal({});
if (clearInput) {
resetInputVal({});
}
setQuestionGuide([]);
scrollToBottom('smooth', 100);
......@@ -794,7 +797,7 @@ export const useChatGenerate = ({
})
);
if (!err?.responseText) {
if (!err?.responseText && clearInput) {
resetInputVal({ text, files });
}
......
......@@ -60,6 +60,10 @@ import AppChatMain from './components/AppChatMain';
import { useSystem } from '@fastgpt/web/hooks/useSystem';
import ScrollToBottomButton from './components/ScrollToBottomButton';
import { useToast } from '@fastgpt/web/hooks/useToast';
import {
QuickReplyContextProvider,
useRegisterQuickReplyClickHandler
} from '../context/quickReplyContext';
const ChatHomeVariablesForm = dynamic(() => import('./components/home/ChatHomeVariablesForm'));
const DesktopHomeLayout = dynamic(() => import('./components/home/DesktopHomeLayout'));
......@@ -93,6 +97,8 @@ type Props = OutLinkChatAuthProps &
/** 覆盖默认已读接口;不传则使用普通 App Chat 的 postMarkChatRead。 */
onMarkChatRead?: (data: MarkChatReadBodyType) => Promise<unknown>;
EmptyState?: React.ReactNode;
/** 是否启用 AI 正文 quick-replies 快捷回复渲染,默认关闭。 */
enableQuickReplies?: boolean;
};
const ChatBox = ({
......@@ -114,6 +120,7 @@ const ChatBox = ({
boxBodyProps,
inputBodyProps,
EmptyState,
enableQuickReplies = false,
...props
}: Props) => {
const { t } = useTranslation();
......@@ -442,6 +449,20 @@ const ChatBox = ({
};
}, [isReady, resetInputVal, sendPrompt, canSendPrompt, lastInteractive]);
/** 快捷回复点击:直接发送选项文本,并保留输入框原有内容。 */
const handleQuickReplyClick = useMemoizedFn((text: string) => {
const trimmedText = text.trim();
if (!trimmedText) return;
sendPromptWithDisabledGuard({
text: trimmedText,
interactive: lastInteractive,
clearInput: false
});
});
useRegisterQuickReplyClickHandler(enableQuickReplies ? handleQuickReplyClick : undefined);
// Auto send prompt
useDebounceEffect(
() => {
......@@ -656,11 +677,14 @@ const ChatBox = ({
</MyBox>
);
};
const ChatBoxContainer = (props: Props) => {
const { enableQuickReplies = false } = props;
return (
<ChatProvider {...props}>
<ChatBox {...props} />
<QuickReplyContextProvider enableQuickReplies={enableQuickReplies}>
<ChatBox {...props} />
</QuickReplyContextProvider>
</ChatProvider>
);
};
......
......@@ -35,6 +35,8 @@ export type ChatBoxInputType = {
files?: UserInputFileItemType[];
interactive?: WorkflowInteractiveResponseType;
hideInUI?: boolean;
/** 发送后是否清空输入框;快捷回复等不占用输入框内容的发送场景可设为 false。 */
clearInput?: boolean;
};
export type SendPromptFnType = (
......
import React, { useEffect, useMemo, useRef } from 'react';
import { createContext } from 'use-context-selector';
export type QuickReplyContextValue = {
enableQuickReplies?: boolean;
onQuickReplyClick?: (text: string) => void;
};
export const QuickReplyContext = createContext<QuickReplyContextValue>({});
type QuickReplyHandlerRegistryContextValue = {
setHandler: (handler?: (text: string) => void) => void;
};
const QuickReplyHandlerRegistryContext = React.createContext<QuickReplyHandlerRegistryContextValue>(
{
setHandler: () => {}
}
);
export const QuickReplyContextProvider = ({
enableQuickReplies,
children
}: {
enableQuickReplies?: boolean;
children: React.ReactNode;
}) => {
const handlerRef = useRef<(text: string) => void>();
const value = useMemo(
() => ({
enableQuickReplies,
onQuickReplyClick: enableQuickReplies
? (text: string) => handlerRef.current?.(text)
: undefined
}),
[enableQuickReplies]
);
const registryValue = useMemo(
() => ({
setHandler: (handler?: (text: string) => void) => {
handlerRef.current = handler;
}
}),
[]
);
return (
<QuickReplyHandlerRegistryContext.Provider value={registryValue}>
<QuickReplyContext.Provider value={value}>{children}</QuickReplyContext.Provider>
</QuickReplyHandlerRegistryContext.Provider>
);
};
/** ChatBox 内部注册快捷回复点击处理函数。 */
export const useRegisterQuickReplyClickHandler = (handler?: (text: string) => void) => {
const { setHandler } = React.useContext(QuickReplyHandlerRegistryContext);
useEffect(() => {
setHandler(handler);
return () => setHandler(undefined);
}, [handler, setHandler]);
};
......@@ -183,6 +183,7 @@ export const useChatTest = ({
chatType={ChatTypeEnum.test}
enableAutoResume
onStartChat={startChat}
enableQuickReplies
/>
)
);
......
......@@ -268,6 +268,7 @@ const AppChatWindow = () => {
chatType={ChatTypeEnum.chat}
outLinkAuthData={outLinkAuthData}
onStartChat={onStartChat}
enableQuickReplies
/>
)}
</Box>
......
......@@ -487,6 +487,7 @@ const HomeChatWindow = () => {
onStartChat={onStartChat}
quickAppList={(chatSettings?.quickAppList || []).slice(0, 3)}
onSwitchQuickApp={handleSwitchQuickApp}
enableQuickReplies
/>
</Box>
<SandboxEditorModal />
......
......@@ -406,6 +406,7 @@ const OutLink = (props: Props) => {
onStartChat={startChat}
chatType={ChatTypeEnum.share}
showWorkorder={showWorkorder === '1'}
enableQuickReplies
/>
)}
</Box>
......
......@@ -58,6 +58,7 @@ describe('Markdown utils', () => {
expect(CodeClassNameEnum.svg).toBe('svg');
expect(CodeClassNameEnum.video).toBe('video');
expect(CodeClassNameEnum.audio).toBe('audio');
expect(CodeClassNameEnum.quickReplies).toBe('quick-replies');
});
});
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment