Skip to content

Commit 01d716f

Browse files
siiigurec121914yu
andauthored
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>
1 parent 3ac4087 commit 01d716f

13 files changed

Lines changed: 186 additions & 7 deletions

File tree

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { Box, Button } from '@chakra-ui/react';
2+
import React from 'react';
3+
import { useContextSelector } from 'use-context-selector';
4+
import { QuickReplyContext } from '@/components/core/chat/ChatContainer/context/quickReplyContext';
5+
6+
type QuickRepliesProps = {
7+
text: string;
8+
};
9+
10+
const QUICK_REPLIES_MAX_LENGTH = 300;
11+
12+
const parseQuickReplies = (text: string): string[] | null => {
13+
const content = text.replace(/\n$/, '');
14+
15+
if (!content || content.length > QUICK_REPLIES_MAX_LENGTH) {
16+
return null;
17+
}
18+
19+
const options = content
20+
.split('\n')
21+
.map((line) => line.trim())
22+
.filter(Boolean);
23+
24+
return options.length > 0 ? options : null;
25+
};
26+
27+
/** 解析并渲染 quick-replies 代码块;未启用或解析失败时不渲染内容。 */
28+
const QuickReplies = ({ text }: QuickRepliesProps) => {
29+
const enableQuickReplies = useContextSelector(QuickReplyContext, (v) => v.enableQuickReplies);
30+
const onQuickReplyClick = useContextSelector(QuickReplyContext, (v) => v.onQuickReplyClick);
31+
const options = React.useMemo(() => parseQuickReplies(text), [text]);
32+
33+
if (!enableQuickReplies || !options) {
34+
return null;
35+
}
36+
37+
return (
38+
<Box
39+
display="inline-grid"
40+
gridTemplateColumns="max-content"
41+
gap={2}
42+
maxW="full"
43+
data-quick-replies=""
44+
>
45+
{options.map((text, index) => (
46+
<Button
47+
key={`${index}-${text}`}
48+
type="button"
49+
size="sm"
50+
variant="whitePrimaryOutline"
51+
justifyContent={'left'}
52+
py={4}
53+
px={4}
54+
w="full"
55+
fontSize="sm"
56+
userSelect={'auto'}
57+
onClick={() => onQuickReplyClick?.(text)}
58+
>
59+
{text}
60+
</Button>
61+
))}
62+
</Box>
63+
);
64+
};
65+
66+
export default React.memo(QuickReplies);

projects/app/src/components/Markdown/index.module.scss

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,16 @@
436436
word-break: break-word;
437437
}
438438

439+
pre:has([data-quick-replies]),
440+
code:has([data-quick-replies]) {
441+
background: transparent !important;
442+
border: none;
443+
padding: 0;
444+
margin: 0;
445+
color: inherit;
446+
overflow: visible;
447+
}
448+
439449
pre {
440450
display: block;
441451
width: 100%;

projects/app/src/components/Markdown/index.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ const AudioBlock = dynamic(() => import('./codeBlock/Audio'), { ssr: false });
2828

2929
const ChatGuide = dynamic(() => import('./chat/Guide'), { ssr: false });
3030
const QuestionGuide = dynamic(() => import('./chat/QuestionGuide'), { ssr: false });
31+
const QuickReplies = dynamic(() => import('./chat/QuickReplies'), { ssr: false });
3132
const A = dynamic(() => import('./A'), { ssr: false });
3233

3334
function MarkdownImgRenderer(props: any) {
@@ -177,7 +178,7 @@ function Code(e: any) {
177178
autoPreviewHtmlCodeBlock,
178179
markdownClassName
179180
} = e;
180-
const match = /language-(\w+)/.exec(className || '');
181+
const match = /language-([\w-]+)/.exec(className || '');
181182
const codeType = match?.[1]?.toLowerCase();
182183

183184
const strChildren = String(children);
@@ -220,6 +221,9 @@ function Code(e: any) {
220221
if (codeType === CodeClassNameEnum.audio) {
221222
return <AudioBlock code={strChildren} />;
222223
}
224+
if (codeType === CodeClassNameEnum.quickReplies) {
225+
return <QuickReplies text={strChildren} />;
226+
}
223227

224228
return (
225229
<CodeLight className={className} codeBlock={codeBlock} match={match}>

projects/app/src/components/Markdown/utils.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ export enum CodeClassNameEnum {
1111
htm = 'htm',
1212
svg = 'svg',
1313
video = 'video',
14-
audio = 'audio'
14+
audio = 'audio',
15+
quickReplies = 'quick-replies'
1516
}
1617

1718
const streamingIncompleteMarkdownTailPatterns = [

projects/app/src/components/core/chat/ChatContainer/ChatBox/hooks/useChatGenerate.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -564,7 +564,8 @@ export const useChatGenerate = ({
564564
history = chatRecords,
565565
interactive,
566566
autoTTSResponse = false,
567-
hideInUI = false
567+
hideInUI = false,
568+
clearInput = true
568569
}) => {
569570
variablesForm.handleSubmit(
570571
async ({ variables = {} }) => {
@@ -679,7 +680,9 @@ export const useChatGenerate = ({
679680
: newChatList
680681
);
681682

682-
resetInputVal({});
683+
if (clearInput) {
684+
resetInputVal({});
685+
}
683686
setQuestionGuide([]);
684687
scrollToBottom('smooth', 100);
685688

@@ -794,7 +797,7 @@ export const useChatGenerate = ({
794797
})
795798
);
796799

797-
if (!err?.responseText) {
800+
if (!err?.responseText && clearInput) {
798801
resetInputVal({ text, files });
799802
}
800803

projects/app/src/components/core/chat/ChatContainer/ChatBox/index.tsx

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ import AppChatMain from './components/AppChatMain';
6060
import { useSystem } from '@fastgpt/web/hooks/useSystem';
6161
import ScrollToBottomButton from './components/ScrollToBottomButton';
6262
import { useToast } from '@fastgpt/web/hooks/useToast';
63+
import {
64+
QuickReplyContextProvider,
65+
useRegisterQuickReplyClickHandler
66+
} from '../context/quickReplyContext';
6367

6468
const ChatHomeVariablesForm = dynamic(() => import('./components/home/ChatHomeVariablesForm'));
6569
const DesktopHomeLayout = dynamic(() => import('./components/home/DesktopHomeLayout'));
@@ -93,6 +97,8 @@ type Props = OutLinkChatAuthProps &
9397
/** 覆盖默认已读接口;不传则使用普通 App Chat 的 postMarkChatRead。 */
9498
onMarkChatRead?: (data: MarkChatReadBodyType) => Promise<unknown>;
9599
EmptyState?: React.ReactNode;
100+
/** 是否启用 AI 正文 quick-replies 快捷回复渲染,默认关闭。 */
101+
enableQuickReplies?: boolean;
96102
};
97103

98104
const ChatBox = ({
@@ -114,6 +120,7 @@ const ChatBox = ({
114120
boxBodyProps,
115121
inputBodyProps,
116122
EmptyState,
123+
enableQuickReplies = false,
117124
...props
118125
}: Props) => {
119126
const { t } = useTranslation();
@@ -442,6 +449,20 @@ const ChatBox = ({
442449
};
443450
}, [isReady, resetInputVal, sendPrompt, canSendPrompt, lastInteractive]);
444451

452+
/** 快捷回复点击:直接发送选项文本,并保留输入框原有内容。 */
453+
const handleQuickReplyClick = useMemoizedFn((text: string) => {
454+
const trimmedText = text.trim();
455+
if (!trimmedText) return;
456+
457+
sendPromptWithDisabledGuard({
458+
text: trimmedText,
459+
interactive: lastInteractive,
460+
clearInput: false
461+
});
462+
});
463+
464+
useRegisterQuickReplyClickHandler(enableQuickReplies ? handleQuickReplyClick : undefined);
465+
445466
// Auto send prompt
446467
useDebounceEffect(
447468
() => {
@@ -656,11 +677,14 @@ const ChatBox = ({
656677
</MyBox>
657678
);
658679
};
659-
660680
const ChatBoxContainer = (props: Props) => {
681+
const { enableQuickReplies = false } = props;
682+
661683
return (
662684
<ChatProvider {...props}>
663-
<ChatBox {...props} />
685+
<QuickReplyContextProvider enableQuickReplies={enableQuickReplies}>
686+
<ChatBox {...props} />
687+
</QuickReplyContextProvider>
664688
</ChatProvider>
665689
);
666690
};

projects/app/src/components/core/chat/ChatContainer/ChatBox/type.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ export type ChatBoxInputType = {
3535
files?: UserInputFileItemType[];
3636
interactive?: WorkflowInteractiveResponseType;
3737
hideInUI?: boolean;
38+
/** 发送后是否清空输入框;快捷回复等不占用输入框内容的发送场景可设为 false。 */
39+
clearInput?: boolean;
3840
};
3941

4042
export type SendPromptFnType = (
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import React, { useEffect, useMemo, useRef } from 'react';
2+
import { createContext } from 'use-context-selector';
3+
4+
export type QuickReplyContextValue = {
5+
enableQuickReplies?: boolean;
6+
onQuickReplyClick?: (text: string) => void;
7+
};
8+
9+
export const QuickReplyContext = createContext<QuickReplyContextValue>({});
10+
11+
type QuickReplyHandlerRegistryContextValue = {
12+
setHandler: (handler?: (text: string) => void) => void;
13+
};
14+
15+
const QuickReplyHandlerRegistryContext = React.createContext<QuickReplyHandlerRegistryContextValue>(
16+
{
17+
setHandler: () => {}
18+
}
19+
);
20+
21+
export const QuickReplyContextProvider = ({
22+
enableQuickReplies,
23+
children
24+
}: {
25+
enableQuickReplies?: boolean;
26+
children: React.ReactNode;
27+
}) => {
28+
const handlerRef = useRef<(text: string) => void>();
29+
30+
const value = useMemo(
31+
() => ({
32+
enableQuickReplies,
33+
onQuickReplyClick: enableQuickReplies
34+
? (text: string) => handlerRef.current?.(text)
35+
: undefined
36+
}),
37+
[enableQuickReplies]
38+
);
39+
40+
const registryValue = useMemo(
41+
() => ({
42+
setHandler: (handler?: (text: string) => void) => {
43+
handlerRef.current = handler;
44+
}
45+
}),
46+
[]
47+
);
48+
49+
return (
50+
<QuickReplyHandlerRegistryContext.Provider value={registryValue}>
51+
<QuickReplyContext.Provider value={value}>{children}</QuickReplyContext.Provider>
52+
</QuickReplyHandlerRegistryContext.Provider>
53+
);
54+
};
55+
56+
/** ChatBox 内部注册快捷回复点击处理函数。 */
57+
export const useRegisterQuickReplyClickHandler = (handler?: (text: string) => void) => {
58+
const { setHandler } = React.useContext(QuickReplyHandlerRegistryContext);
59+
60+
useEffect(() => {
61+
setHandler(handler);
62+
return () => setHandler(undefined);
63+
}, [handler, setHandler]);
64+
};

projects/app/src/pageComponents/app/detail/useChatTest.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@ export const useChatTest = ({
183183
chatType={ChatTypeEnum.test}
184184
enableAutoResume
185185
onStartChat={startChat}
186+
enableQuickReplies
186187
/>
187188
)
188189
);

projects/app/src/pageComponents/chat/ChatWindow/AppChatWindow.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,7 @@ const AppChatWindow = () => {
268268
chatType={ChatTypeEnum.chat}
269269
outLinkAuthData={outLinkAuthData}
270270
onStartChat={onStartChat}
271+
enableQuickReplies
271272
/>
272273
)}
273274
</Box>

0 commit comments

Comments
 (0)