feat: render markdown in chat

main
jialin 1 year ago
parent 349b895ac5
commit 4498f069f0

@ -19,6 +19,7 @@ const SealTextArea: React.FC<TextAreaProps & SealFormItemProps> = (props) => {
variant,
extra,
addAfter,
trim,
...rest
} = props;
const [isFocus, setIsFocus] = useState(false);

@ -16,7 +16,7 @@ import {
} from 'react';
import { CHAT_API } from '../apis';
import { OpenAIViewCode, Roles, generateMessages } from '../config';
import { MessageItem } from '../config/types';
import { MessageItem, MessageItemAction } from '../config/types';
import '../style/ground-left.less';
import '../style/system-message-wrap.less';
import MessageInput from './message-input';
@ -53,6 +53,11 @@ const GroundLeft: React.FC<MessageProps> = forwardRef((props, ref) => {
const paramsRef = useRef<any>(null);
const messageListLengthCache = useRef<number>(0);
const reasonContentRef = useRef<any>('');
const [actions, setActions] = useState<MessageItemAction[]>([
'upload',
'delete',
'copy'
]);
const { initialize, updateScrollerPosition } = useOverlayScroller();
const { initialize: innitializeParams } = useOverlayScroller();
@ -249,6 +254,15 @@ const GroundLeft: React.FC<MessageProps> = forwardRef((props, ref) => {
setMessageList(userMsg);
};
const handleOnCheck = (e: any) => {
const checked = e.target.checked;
if (checked) {
setActions(['upload', 'delete', 'copy', 'markdown']);
} else {
setActions(['upload', 'delete', 'copy']);
}
};
const throttleUpdatePosition = _.throttle(updateScrollerPosition, 100);
useEffect(() => {
@ -303,6 +317,7 @@ const GroundLeft: React.FC<MessageProps> = forwardRef((props, ref) => {
setMessageList={setMessageList}
editable={true}
loading={loading}
actions={actions}
/>
{loading && (
<Spin size="small">
@ -323,6 +338,18 @@ const GroundLeft: React.FC<MessageProps> = forwardRef((props, ref) => {
minRows: 5,
maxRows: 5
}}
actions={[
'clear',
'layout',
'role',
'upload',
'add',
'paste',
'check'
]}
defaultChecked={false}
checkLabel="Markdown"
onCheck={handleOnCheck}
loading={loading}
disabled={!parameters.model}
isEmpty={!messageList.length}

@ -96,6 +96,7 @@ interface MessageInputProps {
style?: React.CSSProperties;
actions?: ActionType[];
checkLabel?: React.ReactNode;
defaultChecked?: boolean;
defaultSize?: { minRows: number; maxRows: number };
}
@ -116,6 +117,7 @@ const MessageInput: React.FC<MessageInputProps> = forwardRef(
isEmpty,
submitIcon,
placeholer,
defaultChecked = true,
tools,
style,
checkLabel,
@ -356,35 +358,31 @@ const MessageInput: React.FC<MessageInputProps> = forwardRef(
<div className="tool-bar">
<div className="actions">
{title}
{
{actions.includes('role') && (
<>
{actions.includes('role') && (
<>
<Button
type="text"
size="middle"
onClick={handleToggleRole}
icon={<SwapOutlined rotate={90} />}
>
{intl.formatMessage({ id: `playground.${message.role}` })}
</Button>
<Divider type="vertical" style={{ margin: 0 }} />
</>
)}
{actions.includes('upload') && message.role === Roles.User && (
<UploadImg
handleUpdateImgList={handleUpdateImgList}
size="middle"
></UploadImg>
)}
<Button
type="text"
size="middle"
onClick={handleToggleRole}
icon={<SwapOutlined rotate={90} />}
>
{intl.formatMessage({ id: `playground.${message.role}` })}
</Button>
<Divider type="vertical" style={{ margin: 0 }} />
</>
}
{tools}
)}
{actions.includes('check') && (
<Checkbox onChange={onCheck} defaultChecked={true}>
<Checkbox onChange={onCheck} defaultChecked={defaultChecked}>
{checkLabel}
</Checkbox>
)}
{actions.includes('upload') && message.role === Roles.User && (
<UploadImg
handleUpdateImgList={handleUpdateImgList}
size="middle"
></UploadImg>
)}
{tools}
{actions.includes('clear') && (
<Tooltip
title={intl.formatMessage({ id: 'common.button.clear' })}

@ -1,15 +1,9 @@
import CopyButton from '@/components/copy-button';
import { MinusCircleOutlined } from '@ant-design/icons';
import { useIntl } from '@umijs/max';
import { Button, Input, Tooltip } from 'antd';
import classNames from 'classnames';
import _ from 'lodash';
import React, { useCallback, useRef } from 'react';
import { Roles } from '../../config';
import React from 'react';
import { MessageItem, MessageItemAction } from '../../config/types';
import '../../style/content-item.less';
import ThumbImg from '../thumb-img';
import UploadImg from '../upload-img';
import MessageActions from './message-actions';
import MessageBody from './message-body';
interface MessageItemProps {
data: MessageItem;
@ -31,143 +25,6 @@ const ContentItem: React.FC<MessageItemProps> = ({
actions = ['upload', 'delete', 'copy']
}) => {
const intl = useIntl();
const inputRef = useRef<any>(null);
const imgCountRef = useRef(0);
const handleMessageChange = (e: any) => {
updateMessage?.({
imgs: data.imgs || [],
role: data.role,
content: e.target.value,
uid: data.uid
});
};
const handleToggleRole = () => {
updateMessage?.({
imgs: data.imgs || [],
role: data.role === Roles.User ? Roles.Assistant : Roles.User,
content: data.content,
uid: data.uid
});
};
const getPasteContent = useCallback(
async (event: any) => {
const clipboardData = event.clipboardData || window.clipboardData;
const items = clipboardData.items;
const imgPromises: Promise<string>[] = [];
for (let i = 0; i < items.length; i++) {
let item = items[i];
if (item.kind === 'file' && item.type.indexOf('image') !== -1) {
const file = item.getAsFile();
const imgPromise = new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = function (event) {
const base64String = event.target?.result as string;
if (base64String) {
resolve(base64String);
} else {
reject('Failed to convert image to base64');
}
};
reader.readAsDataURL(file);
});
imgPromises.push(imgPromise);
} else if (item.kind === 'string') {
// string
}
}
try {
const imgs = await Promise.all(imgPromises);
if (imgs.length) {
const list = _.map(imgs, (img: string) => {
imgCountRef.current += 1;
return {
uid: imgCountRef.current,
dataUrl: img
};
});
updateMessage?.({
role: data.role,
content: data.content,
uid: data.uid,
imgs: [...(data.imgs || []), ...list]
});
}
} catch (error) {
console.error('Error processing images:', error);
}
},
[data, updateMessage]
);
const handleOnPaste = (e: any) => {
const text = e.clipboardData.getData('text');
if (!text) {
e.preventDefault();
getPasteContent(e);
}
};
const handleUpdateImgList = useCallback(
(list: { uid: number | string; dataUrl: string }[]) => {
updateMessage?.({
role: data.role,
content: data.content,
uid: data.uid,
imgs: [...(data.imgs || []), ...list]
});
},
[data, updateMessage]
);
const handleDeleteImg = (uid: number | string) => {
const list = _.filter(data.imgs, (item: MessageItem) => item.uid !== uid);
updateMessage?.({
role: data.role,
content: data.content,
uid: data.uid,
imgs: list
});
};
const handleDeleteLastImage = useCallback(() => {
if (data.imgs && data.imgs?.length > 0) {
const newImgList = [...(data.imgs || [])];
const lastImage = newImgList.pop();
if (lastImage) {
handleDeleteImg(lastImage.uid);
}
}
}, [data.imgs, handleDeleteImg]);
const handleKeyDown = useCallback(
(event: any) => {
if (
event.key === 'Backspace' &&
data.content === '' &&
data.imgs &&
data.imgs?.length > 0
) {
// inputref blur
event.preventDefault();
handleDeleteLastImage();
}
},
[data, handleDeleteLastImage]
);
const handleClickWrapper = (e: any) => {
console.log('e===========', e);
e.stopPropagation();
e.preventDefault();
inputRef.current.focus();
};
return (
<div className="content-item">
@ -181,71 +38,20 @@ const ContentItem: React.FC<MessageItemProps> = ({
intl.formatMessage({ id: `playground.${data.role}` })}
</div>
)}
<div className="actions">
{actions.includes('upload') && data.role === Roles.User && (
<UploadImg handleUpdateImgList={handleUpdateImgList}></UploadImg>
)}
{data.content && actions.includes('copy') && (
<CopyButton
text={data.content}
size="small"
shape="default"
type="text"
fontSize="12px"
></CopyButton>
)}
{actions.includes('delete') && (
<Tooltip title={intl.formatMessage({ id: 'common.button.delete' })}>
<Button
size="small"
type="text"
onClick={onDelete}
icon={<MinusCircleOutlined />}
></Button>
</Tooltip>
)}
</div>
<MessageActions
data={data}
actions={actions}
onDelete={onDelete}
updateMessage={updateMessage}
></MessageActions>
</div>
{editable ? (
<div
className={classNames('message-content-input', {
'has-img': data.imgs?.length
})}
onClick={handleClickWrapper}
>
<ThumbImg
editable={editable}
dataList={data.imgs || []}
onDelete={handleDeleteImg}
></ThumbImg>
<Input.TextArea
ref={inputRef}
value={data.content}
variant="filled"
autoSize={{ minRows: 1 }}
style={{
borderRadius: 'var(--border-radius-mini)'
}}
readOnly={loading}
onKeyDown={handleKeyDown}
onChange={handleMessageChange}
onPaste={handleOnPaste}
></Input.TextArea>
</div>
) : (
<div
className={classNames('content-item-content', {
'has-img': data.imgs?.length
})}
>
<ThumbImg
editable={editable}
dataList={data.imgs || []}
onDelete={handleDeleteImg}
></ThumbImg>
{data.content && <div className="text">{data.content}</div>}
</div>
)}
<MessageBody
editable={editable}
data={data}
loading={loading}
actions={actions}
updateMessage={updateMessage}
></MessageBody>
</div>
);
};

@ -0,0 +1,82 @@
import CopyButton from '@/components/copy-button';
import {
FileMarkdownOutlined,
FileTextOutlined,
MinusCircleOutlined
} from '@ant-design/icons';
import { useIntl } from '@umijs/max';
import { Button, Tooltip } from 'antd';
import React, { useCallback } from 'react';
import { Roles } from '../../config';
import { MessageItem, MessageItemAction } from '../../config/types';
import UploadImg from '../upload-img';
interface MessageActionsProps {
data: MessageItem;
actions: MessageItemAction[];
renderMode?: string;
renderModeChange?: (value: string) => void;
updateMessage?: (message: MessageItem) => void;
onDelete?: () => void;
}
const renderOptions = [
{ label: 'Markdown', value: 'markdown', icon: <FileMarkdownOutlined /> },
{ label: 'Plain', value: 'plain', icon: <FileTextOutlined /> }
];
const MessageActions: React.FC<MessageActionsProps> = ({
actions,
data,
renderMode,
renderModeChange,
updateMessage,
onDelete
}) => {
const intl = useIntl();
const handleUpdateImgList = useCallback(
(list: { uid: number | string; dataUrl: string }[]) => {
updateMessage?.({
role: data.role,
content: data.content,
uid: data.uid,
imgs: [...(data.imgs || []), ...list]
});
},
[data, updateMessage]
);
return (
<>
{actions.length > 1 ? (
<div className="actions">
{actions.includes('upload') && data.role === Roles.User && (
<UploadImg handleUpdateImgList={handleUpdateImgList} />
)}
{data.content && actions.includes('copy') && (
<CopyButton
text={data.content}
size="small"
shape="default"
type="text"
fontSize="12px"
/>
)}
{actions.includes('delete') && (
<Tooltip title={intl.formatMessage({ id: 'common.button.delete' })}>
<Button
size="small"
type="text"
onClick={onDelete}
icon={<MinusCircleOutlined />}
/>
</Tooltip>
)}
</div>
) : null}
</>
);
};
export default MessageActions;

@ -0,0 +1,233 @@
import MarkdownViewer from '@/components/markdown-viewer';
import { Input } from 'antd';
import classNames from 'classnames';
import _ from 'lodash';
import React, { useCallback, useMemo, useRef } from 'react';
import { Roles } from '../../config';
import { MessageItem, MessageItemAction } from '../../config/types';
import ThumbImg from '../thumb-img';
import ThinkContent from './think-content';
import ThinkParser from './think-parser';
interface MessageBodyProps {
data: MessageItem;
editable?: boolean;
loading?: boolean;
showTitle?: boolean;
actions?: MessageItemAction[];
updateMessage?: (message: MessageItem) => void;
onDelete?: () => void;
}
const MessageBody: React.FC<MessageBodyProps> = ({
editable,
data,
loading,
actions,
updateMessage
}) => {
const inputRef = useRef<any>(null);
const imgCountRef = useRef(0);
const thinkerRef = useRef<any>(null);
const content = useMemo(() => {
if (!thinkerRef.current && actions?.includes('markdown')) {
thinkerRef.current = new ThinkParser();
}
if (actions?.includes('markdown')) {
return thinkerRef.current.parse(data.content);
}
return {
thought: '',
result: data.content
};
}, [data.content, actions]);
const getPasteContent = useCallback(
async (event: any) => {
// @ts-ignore
const clipboardData = event.clipboardData || window.clipboardData;
const items = clipboardData.items;
const imgPromises: Promise<string>[] = [];
for (let i = 0; i < items.length; i++) {
let item = items[i];
if (item.kind === 'file' && item.type.indexOf('image') !== -1) {
const file = item.getAsFile();
const imgPromise = new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = function (event) {
const base64String = event.target?.result as string;
if (base64String) {
resolve(base64String);
} else {
reject('Failed to convert image to base64');
}
};
reader.readAsDataURL(file);
});
imgPromises.push(imgPromise);
} else if (item.kind === 'string') {
// string
}
}
try {
const imgs = await Promise.all(imgPromises);
if (imgs.length) {
const list = _.map(imgs, (img: string) => {
imgCountRef.current += 1;
return {
uid: imgCountRef.current,
dataUrl: img
};
});
updateMessage?.({
role: data.role,
content: data.content,
uid: data.uid,
imgs: [...(data.imgs || []), ...list]
});
}
} catch (error) {
console.error('Error processing images:', error);
}
},
[data, updateMessage]
);
const handleOnPaste = (e: any) => {
const text = e.clipboardData.getData('text');
if (!text) {
e.preventDefault();
getPasteContent(e);
}
};
const handleDeleteImg = (uid: number | string) => {
const list = _.filter(data.imgs, (item: MessageItem) => item.uid !== uid);
updateMessage?.({
role: data.role,
content: data.content,
uid: data.uid,
imgs: list
});
};
const handleMessageChange = (e: any) => {
updateMessage?.({
imgs: data.imgs || [],
role: data.role,
content: e.target.value,
uid: data.uid
});
};
const handleDeleteLastImage = useCallback(() => {
if (data.imgs && data.imgs?.length > 0) {
const newImgList = [...(data.imgs || [])];
const lastImage = newImgList.pop();
if (lastImage) {
handleDeleteImg(lastImage.uid);
}
}
}, [data.imgs, handleDeleteImg]);
const handleKeyDown = useCallback(
(event: any) => {
if (
event.key === 'Backspace' &&
data.content === '' &&
data.imgs &&
data.imgs?.length > 0
) {
// inputref blur
event.preventDefault();
handleDeleteLastImage();
}
},
[data, handleDeleteLastImage]
);
const handleClickWrapper = (e: any) => {
e.stopPropagation();
e.preventDefault();
inputRef.current?.focus();
};
if (!editable) {
return (
<div
className={classNames('content-item-content', {
'has-img': data.imgs?.length
})}
>
<ThumbImg
editable={editable}
dataList={data.imgs || []}
onDelete={handleDeleteImg}
/>
{data.content && <div className="text">{data.content}</div>}
</div>
);
}
return (
<div
className={classNames('message-content-input', {
'has-img': data.imgs?.length
})}
onClick={handleClickWrapper}
>
<ThumbImg
editable={editable}
dataList={data.imgs || []}
onDelete={handleDeleteImg}
/>
<>
{data.role === Roles.User ? (
<Input.TextArea
ref={inputRef}
value={data.content}
variant="filled"
autoSize={{ minRows: 1 }}
style={{ borderRadius: 'var(--border-radius-mini)' }}
readOnly={loading}
onKeyDown={handleKeyDown}
onChange={handleMessageChange}
onPaste={handleOnPaste}
/>
) : (
<>
{actions?.includes('markdown') ? (
<>
<ThinkContent content={content.thought}></ThinkContent>
<div style={{ paddingInline: 4 }}>
<MarkdownViewer
content={content.result || ''}
theme="light"
/>
</div>
</>
) : (
<Input.TextArea
ref={inputRef}
value={data.content}
variant="filled"
autoSize={{ minRows: 1 }}
style={{ borderRadius: 'var(--border-radius-mini)' }}
readOnly={loading}
onKeyDown={handleKeyDown}
onChange={handleMessageChange}
onPaste={handleOnPaste}
/>
)}
</>
)}
</>
</div>
);
};
export default MessageBody;

@ -0,0 +1,26 @@
import IconFont from '@/components/icon-font';
import { Button } from 'antd';
import React from 'react';
import '../../style/think-content.less';
interface ThinkContentProps {
content: string;
}
const ThinkContent: React.FC<ThinkContentProps> = ({ content }) => {
return (
<>
{content ? (
<div className="think-wrapper">
<IconFont type="icon-AIzhineng" className="m-l-4" />
<Button size="small" type="text">
AI Thought...
</Button>
<div className="think-content">{content}</div>
</div>
) : null}
</>
);
};
export default ThinkContent;

@ -0,0 +1,59 @@
class ThinkParser {
thought: string;
result: string;
collecting: boolean;
lastCheckedIndex: number;
constructor() {
this.thought = '';
this.result = '';
this.collecting = false;
this.lastCheckedIndex = 0;
}
parse(chunk: string) {
if (this.lastCheckedIndex < chunk.length) {
const currentChunk = chunk.substring(this.lastCheckedIndex);
if (!this.collecting) {
// find <think> tag
let startIndex = currentChunk.indexOf('<think>');
if (startIndex !== -1) {
// handle text before <think> tag
this.result += currentChunk.substring(0, startIndex);
// handle thought part
this.thought += currentChunk.substring(startIndex + 7);
this.collecting = true;
} else {
// if no <think> tag found, just append the whole chunk to result
this.result += currentChunk;
}
} else {
// find </think> tag
let endIndex = currentChunk.indexOf('</think>');
if (endIndex !== -1) {
// handle text before </think> tag
this.thought += currentChunk.substring(0, endIndex);
// handle result part
this.result += currentChunk.substring(endIndex + 8);
this.collecting = false;
} else {
this.thought += currentChunk;
}
this.lastCheckedIndex = chunk.length;
}
}
return { thought: this.thought.trim(), result: this.result };
}
reset() {
this.thought = '';
this.result = '';
this.collecting = false;
this.lastCheckedIndex = 0;
}
}
export default ThinkParser;

@ -6,7 +6,7 @@ export interface ModelSelectionItem extends Global.BaseOption<string> {
type?: string;
}
export type MessageItemAction = 'upload' | 'delete' | 'copy';
export type MessageItemAction = 'upload' | 'delete' | 'copy' | 'markdown';
export interface MessageItem {
content: string;

@ -20,6 +20,18 @@
display: none;
}
.actions .mrkd-switch {
border-radius: 12px;
.ant-segmented-thumb,
.ant-segmented-item-selected,
.ant-segmented-item,
.ant-segmented-item::after {
border-radius: 10px;
overflow: hidden;
}
}
&:hover {
.actions {
display: flex;

@ -0,0 +1,13 @@
.think-wrapper {
margin-bottom: 16px;
color: var(--ant-color-text-tertiary);
border-left: 2px solid var(--ant-color-fill-secondary);
.think-content {
white-space: pre-wrap;
padding: 6px 4px;
background-color: var(--ant-color-fill-tertiary);
color: var(--ant-color-text-tertiary);
border-radius: var(--border-radius-base);
}
}

@ -27,6 +27,7 @@ const extractJSON = (dataStr: string) => {
const errorHandler = async (res: any) => {
try {
const data = await res.json();
console.log('errorHandler:', data);
return {
error: true,
data: data

Loading…
Cancel
Save