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 @@ ...@@ -436,6 +436,16 @@
word-break: break-word; 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 { pre {
display: block; display: block;
width: 100%; width: 100%;
......
...@@ -28,6 +28,7 @@ const AudioBlock = dynamic(() => import('./codeBlock/Audio'), { ssr: false }); ...@@ -28,6 +28,7 @@ const AudioBlock = dynamic(() => import('./codeBlock/Audio'), { ssr: false });
const ChatGuide = dynamic(() => import('./chat/Guide'), { ssr: false }); const ChatGuide = dynamic(() => import('./chat/Guide'), { ssr: false });
const QuestionGuide = dynamic(() => import('./chat/QuestionGuide'), { 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 }); const A = dynamic(() => import('./A'), { ssr: false });
function MarkdownImgRenderer(props: any) { function MarkdownImgRenderer(props: any) {
...@@ -177,7 +178,7 @@ function Code(e: any) { ...@@ -177,7 +178,7 @@ function Code(e: any) {
autoPreviewHtmlCodeBlock, autoPreviewHtmlCodeBlock,
markdownClassName markdownClassName
} = e; } = e;
const match = /language-(\w+)/.exec(className || ''); const match = /language-([\w-]+)/.exec(className || '');
const codeType = match?.[1]?.toLowerCase(); const codeType = match?.[1]?.toLowerCase();
const strChildren = String(children); const strChildren = String(children);
...@@ -220,6 +221,9 @@ function Code(e: any) { ...@@ -220,6 +221,9 @@ function Code(e: any) {
if (codeType === CodeClassNameEnum.audio) { if (codeType === CodeClassNameEnum.audio) {
return <AudioBlock code={strChildren} />; return <AudioBlock code={strChildren} />;
} }
if (codeType === CodeClassNameEnum.quickReplies) {
return <QuickReplies text={strChildren} />;
}
return ( return (
<CodeLight className={className} codeBlock={codeBlock} match={match}> <CodeLight className={className} codeBlock={codeBlock} match={match}>
......
...@@ -11,7 +11,8 @@ export enum CodeClassNameEnum { ...@@ -11,7 +11,8 @@ export enum CodeClassNameEnum {
htm = 'htm', htm = 'htm',
svg = 'svg', svg = 'svg',
video = 'video', video = 'video',
audio = 'audio' audio = 'audio',
quickReplies = 'quick-replies'
} }
const streamingIncompleteMarkdownTailPatterns = [ const streamingIncompleteMarkdownTailPatterns = [
......
...@@ -564,7 +564,8 @@ export const useChatGenerate = ({ ...@@ -564,7 +564,8 @@ export const useChatGenerate = ({
history = chatRecords, history = chatRecords,
interactive, interactive,
autoTTSResponse = false, autoTTSResponse = false,
hideInUI = false hideInUI = false,
clearInput = true
}) => { }) => {
variablesForm.handleSubmit( variablesForm.handleSubmit(
async ({ variables = {} }) => { async ({ variables = {} }) => {
...@@ -679,7 +680,9 @@ export const useChatGenerate = ({ ...@@ -679,7 +680,9 @@ export const useChatGenerate = ({
: newChatList : newChatList
); );
resetInputVal({}); if (clearInput) {
resetInputVal({});
}
setQuestionGuide([]); setQuestionGuide([]);
scrollToBottom('smooth', 100); scrollToBottom('smooth', 100);
...@@ -794,7 +797,7 @@ export const useChatGenerate = ({ ...@@ -794,7 +797,7 @@ export const useChatGenerate = ({
}) })
); );
if (!err?.responseText) { if (!err?.responseText && clearInput) {
resetInputVal({ text, files }); resetInputVal({ text, files });
} }
......
...@@ -60,6 +60,10 @@ import AppChatMain from './components/AppChatMain'; ...@@ -60,6 +60,10 @@ import AppChatMain from './components/AppChatMain';
import { useSystem } from '@fastgpt/web/hooks/useSystem'; import { useSystem } from '@fastgpt/web/hooks/useSystem';
import ScrollToBottomButton from './components/ScrollToBottomButton'; import ScrollToBottomButton from './components/ScrollToBottomButton';
import { useToast } from '@fastgpt/web/hooks/useToast'; import { useToast } from '@fastgpt/web/hooks/useToast';
import {
QuickReplyContextProvider,
useRegisterQuickReplyClickHandler
} from '../context/quickReplyContext';
const ChatHomeVariablesForm = dynamic(() => import('./components/home/ChatHomeVariablesForm')); const ChatHomeVariablesForm = dynamic(() => import('./components/home/ChatHomeVariablesForm'));
const DesktopHomeLayout = dynamic(() => import('./components/home/DesktopHomeLayout')); const DesktopHomeLayout = dynamic(() => import('./components/home/DesktopHomeLayout'));
...@@ -93,6 +97,8 @@ type Props = OutLinkChatAuthProps & ...@@ -93,6 +97,8 @@ type Props = OutLinkChatAuthProps &
/** 覆盖默认已读接口;不传则使用普通 App Chat 的 postMarkChatRead。 */ /** 覆盖默认已读接口;不传则使用普通 App Chat 的 postMarkChatRead。 */
onMarkChatRead?: (data: MarkChatReadBodyType) => Promise<unknown>; onMarkChatRead?: (data: MarkChatReadBodyType) => Promise<unknown>;
EmptyState?: React.ReactNode; EmptyState?: React.ReactNode;
/** 是否启用 AI 正文 quick-replies 快捷回复渲染,默认关闭。 */
enableQuickReplies?: boolean;
}; };
const ChatBox = ({ const ChatBox = ({
...@@ -114,6 +120,7 @@ const ChatBox = ({ ...@@ -114,6 +120,7 @@ const ChatBox = ({
boxBodyProps, boxBodyProps,
inputBodyProps, inputBodyProps,
EmptyState, EmptyState,
enableQuickReplies = false,
...props ...props
}: Props) => { }: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
...@@ -442,6 +449,20 @@ const ChatBox = ({ ...@@ -442,6 +449,20 @@ const ChatBox = ({
}; };
}, [isReady, resetInputVal, sendPrompt, canSendPrompt, lastInteractive]); }, [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 // Auto send prompt
useDebounceEffect( useDebounceEffect(
() => { () => {
...@@ -656,11 +677,14 @@ const ChatBox = ({ ...@@ -656,11 +677,14 @@ const ChatBox = ({
</MyBox> </MyBox>
); );
}; };
const ChatBoxContainer = (props: Props) => { const ChatBoxContainer = (props: Props) => {
const { enableQuickReplies = false } = props;
return ( return (
<ChatProvider {...props}> <ChatProvider {...props}>
<ChatBox {...props} /> <QuickReplyContextProvider enableQuickReplies={enableQuickReplies}>
<ChatBox {...props} />
</QuickReplyContextProvider>
</ChatProvider> </ChatProvider>
); );
}; };
......
...@@ -35,6 +35,8 @@ export type ChatBoxInputType = { ...@@ -35,6 +35,8 @@ export type ChatBoxInputType = {
files?: UserInputFileItemType[]; files?: UserInputFileItemType[];
interactive?: WorkflowInteractiveResponseType; interactive?: WorkflowInteractiveResponseType;
hideInUI?: boolean; hideInUI?: boolean;
/** 发送后是否清空输入框;快捷回复等不占用输入框内容的发送场景可设为 false。 */
clearInput?: boolean;
}; };
export type SendPromptFnType = ( 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 = ({ ...@@ -183,6 +183,7 @@ export const useChatTest = ({
chatType={ChatTypeEnum.test} chatType={ChatTypeEnum.test}
enableAutoResume enableAutoResume
onStartChat={startChat} onStartChat={startChat}
enableQuickReplies
/> />
) )
); );
......
...@@ -268,6 +268,7 @@ const AppChatWindow = () => { ...@@ -268,6 +268,7 @@ const AppChatWindow = () => {
chatType={ChatTypeEnum.chat} chatType={ChatTypeEnum.chat}
outLinkAuthData={outLinkAuthData} outLinkAuthData={outLinkAuthData}
onStartChat={onStartChat} onStartChat={onStartChat}
enableQuickReplies
/> />
)} )}
</Box> </Box>
......
...@@ -487,6 +487,7 @@ const HomeChatWindow = () => { ...@@ -487,6 +487,7 @@ const HomeChatWindow = () => {
onStartChat={onStartChat} onStartChat={onStartChat}
quickAppList={(chatSettings?.quickAppList || []).slice(0, 3)} quickAppList={(chatSettings?.quickAppList || []).slice(0, 3)}
onSwitchQuickApp={handleSwitchQuickApp} onSwitchQuickApp={handleSwitchQuickApp}
enableQuickReplies
/> />
</Box> </Box>
<SandboxEditorModal /> <SandboxEditorModal />
......
...@@ -406,6 +406,7 @@ const OutLink = (props: Props) => { ...@@ -406,6 +406,7 @@ const OutLink = (props: Props) => {
onStartChat={startChat} onStartChat={startChat}
chatType={ChatTypeEnum.share} chatType={ChatTypeEnum.share}
showWorkorder={showWorkorder === '1'} showWorkorder={showWorkorder === '1'}
enableQuickReplies
/> />
)} )}
</Box> </Box>
......
...@@ -58,6 +58,7 @@ describe('Markdown utils', () => { ...@@ -58,6 +58,7 @@ describe('Markdown utils', () => {
expect(CodeClassNameEnum.svg).toBe('svg'); expect(CodeClassNameEnum.svg).toBe('svg');
expect(CodeClassNameEnum.video).toBe('video'); expect(CodeClassNameEnum.video).toBe('video');
expect(CodeClassNameEnum.audio).toBe('audio'); 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