feat: render markdown in chat
This commit is contained in:
@@ -19,6 +19,7 @@ const SealTextArea: React.FC<TextAreaProps & SealFormItemProps> = (props) => {
|
|||||||
variant,
|
variant,
|
||||||
extra,
|
extra,
|
||||||
addAfter,
|
addAfter,
|
||||||
|
trim,
|
||||||
...rest
|
...rest
|
||||||
} = props;
|
} = props;
|
||||||
const [isFocus, setIsFocus] = useState(false);
|
const [isFocus, setIsFocus] = useState(false);
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
} from 'react';
|
} from 'react';
|
||||||
import { CHAT_API } from '../apis';
|
import { CHAT_API } from '../apis';
|
||||||
import { OpenAIViewCode, Roles, generateMessages } from '../config';
|
import { OpenAIViewCode, Roles, generateMessages } from '../config';
|
||||||
import { MessageItem } from '../config/types';
|
import { MessageItem, MessageItemAction } from '../config/types';
|
||||||
import '../style/ground-left.less';
|
import '../style/ground-left.less';
|
||||||
import '../style/system-message-wrap.less';
|
import '../style/system-message-wrap.less';
|
||||||
import MessageInput from './message-input';
|
import MessageInput from './message-input';
|
||||||
@@ -53,6 +53,11 @@ const GroundLeft: React.FC<MessageProps> = forwardRef((props, ref) => {
|
|||||||
const paramsRef = useRef<any>(null);
|
const paramsRef = useRef<any>(null);
|
||||||
const messageListLengthCache = useRef<number>(0);
|
const messageListLengthCache = useRef<number>(0);
|
||||||
const reasonContentRef = useRef<any>('');
|
const reasonContentRef = useRef<any>('');
|
||||||
|
const [actions, setActions] = useState<MessageItemAction[]>([
|
||||||
|
'upload',
|
||||||
|
'delete',
|
||||||
|
'copy'
|
||||||
|
]);
|
||||||
|
|
||||||
const { initialize, updateScrollerPosition } = useOverlayScroller();
|
const { initialize, updateScrollerPosition } = useOverlayScroller();
|
||||||
const { initialize: innitializeParams } = useOverlayScroller();
|
const { initialize: innitializeParams } = useOverlayScroller();
|
||||||
@@ -249,6 +254,15 @@ const GroundLeft: React.FC<MessageProps> = forwardRef((props, ref) => {
|
|||||||
setMessageList(userMsg);
|
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);
|
const throttleUpdatePosition = _.throttle(updateScrollerPosition, 100);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -303,6 +317,7 @@ const GroundLeft: React.FC<MessageProps> = forwardRef((props, ref) => {
|
|||||||
setMessageList={setMessageList}
|
setMessageList={setMessageList}
|
||||||
editable={true}
|
editable={true}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
actions={actions}
|
||||||
/>
|
/>
|
||||||
{loading && (
|
{loading && (
|
||||||
<Spin size="small">
|
<Spin size="small">
|
||||||
@@ -323,6 +338,18 @@ const GroundLeft: React.FC<MessageProps> = forwardRef((props, ref) => {
|
|||||||
minRows: 5,
|
minRows: 5,
|
||||||
maxRows: 5
|
maxRows: 5
|
||||||
}}
|
}}
|
||||||
|
actions={[
|
||||||
|
'clear',
|
||||||
|
'layout',
|
||||||
|
'role',
|
||||||
|
'upload',
|
||||||
|
'add',
|
||||||
|
'paste',
|
||||||
|
'check'
|
||||||
|
]}
|
||||||
|
defaultChecked={false}
|
||||||
|
checkLabel="Markdown"
|
||||||
|
onCheck={handleOnCheck}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
disabled={!parameters.model}
|
disabled={!parameters.model}
|
||||||
isEmpty={!messageList.length}
|
isEmpty={!messageList.length}
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ interface MessageInputProps {
|
|||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
actions?: ActionType[];
|
actions?: ActionType[];
|
||||||
checkLabel?: React.ReactNode;
|
checkLabel?: React.ReactNode;
|
||||||
|
defaultChecked?: boolean;
|
||||||
defaultSize?: { minRows: number; maxRows: number };
|
defaultSize?: { minRows: number; maxRows: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +117,7 @@ const MessageInput: React.FC<MessageInputProps> = forwardRef(
|
|||||||
isEmpty,
|
isEmpty,
|
||||||
submitIcon,
|
submitIcon,
|
||||||
placeholer,
|
placeholer,
|
||||||
|
defaultChecked = true,
|
||||||
tools,
|
tools,
|
||||||
style,
|
style,
|
||||||
checkLabel,
|
checkLabel,
|
||||||
@@ -356,35 +358,31 @@ const MessageInput: React.FC<MessageInputProps> = forwardRef(
|
|||||||
<div className="tool-bar">
|
<div className="tool-bar">
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
{title}
|
{title}
|
||||||
{
|
{actions.includes('role') && (
|
||||||
<>
|
<>
|
||||||
{actions.includes('role') && (
|
<Button
|
||||||
<>
|
type="text"
|
||||||
<Button
|
size="middle"
|
||||||
type="text"
|
onClick={handleToggleRole}
|
||||||
size="middle"
|
icon={<SwapOutlined rotate={90} />}
|
||||||
onClick={handleToggleRole}
|
>
|
||||||
icon={<SwapOutlined rotate={90} />}
|
{intl.formatMessage({ id: `playground.${message.role}` })}
|
||||||
>
|
</Button>
|
||||||
{intl.formatMessage({ id: `playground.${message.role}` })}
|
<Divider type="vertical" style={{ margin: 0 }} />
|
||||||
</Button>
|
|
||||||
<Divider type="vertical" style={{ margin: 0 }} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{actions.includes('upload') && message.role === Roles.User && (
|
|
||||||
<UploadImg
|
|
||||||
handleUpdateImgList={handleUpdateImgList}
|
|
||||||
size="middle"
|
|
||||||
></UploadImg>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
}
|
)}
|
||||||
{tools}
|
|
||||||
{actions.includes('check') && (
|
{actions.includes('check') && (
|
||||||
<Checkbox onChange={onCheck} defaultChecked={true}>
|
<Checkbox onChange={onCheck} defaultChecked={defaultChecked}>
|
||||||
{checkLabel}
|
{checkLabel}
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
)}
|
)}
|
||||||
|
{actions.includes('upload') && message.role === Roles.User && (
|
||||||
|
<UploadImg
|
||||||
|
handleUpdateImgList={handleUpdateImgList}
|
||||||
|
size="middle"
|
||||||
|
></UploadImg>
|
||||||
|
)}
|
||||||
|
{tools}
|
||||||
{actions.includes('clear') && (
|
{actions.includes('clear') && (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
title={intl.formatMessage({ id: 'common.button.clear' })}
|
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 { useIntl } from '@umijs/max';
|
||||||
import { Button, Input, Tooltip } from 'antd';
|
import React from 'react';
|
||||||
import classNames from 'classnames';
|
|
||||||
import _ from 'lodash';
|
|
||||||
import React, { useCallback, useRef } from 'react';
|
|
||||||
import { Roles } from '../../config';
|
|
||||||
import { MessageItem, MessageItemAction } from '../../config/types';
|
import { MessageItem, MessageItemAction } from '../../config/types';
|
||||||
import '../../style/content-item.less';
|
import '../../style/content-item.less';
|
||||||
import ThumbImg from '../thumb-img';
|
import MessageActions from './message-actions';
|
||||||
import UploadImg from '../upload-img';
|
import MessageBody from './message-body';
|
||||||
|
|
||||||
interface MessageItemProps {
|
interface MessageItemProps {
|
||||||
data: MessageItem;
|
data: MessageItem;
|
||||||
@@ -31,143 +25,6 @@ const ContentItem: React.FC<MessageItemProps> = ({
|
|||||||
actions = ['upload', 'delete', 'copy']
|
actions = ['upload', 'delete', 'copy']
|
||||||
}) => {
|
}) => {
|
||||||
const intl = useIntl();
|
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 (
|
return (
|
||||||
<div className="content-item">
|
<div className="content-item">
|
||||||
@@ -181,71 +38,20 @@ const ContentItem: React.FC<MessageItemProps> = ({
|
|||||||
intl.formatMessage({ id: `playground.${data.role}` })}
|
intl.formatMessage({ id: `playground.${data.role}` })}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="actions">
|
<MessageActions
|
||||||
{actions.includes('upload') && data.role === Roles.User && (
|
data={data}
|
||||||
<UploadImg handleUpdateImgList={handleUpdateImgList}></UploadImg>
|
actions={actions}
|
||||||
)}
|
onDelete={onDelete}
|
||||||
{data.content && actions.includes('copy') && (
|
updateMessage={updateMessage}
|
||||||
<CopyButton
|
></MessageActions>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
{editable ? (
|
<MessageBody
|
||||||
<div
|
editable={editable}
|
||||||
className={classNames('message-content-input', {
|
data={data}
|
||||||
'has-img': data.imgs?.length
|
loading={loading}
|
||||||
})}
|
actions={actions}
|
||||||
onClick={handleClickWrapper}
|
updateMessage={updateMessage}
|
||||||
>
|
></MessageBody>
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
</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;
|
type?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MessageItemAction = 'upload' | 'delete' | 'copy';
|
export type MessageItemAction = 'upload' | 'delete' | 'copy' | 'markdown';
|
||||||
|
|
||||||
export interface MessageItem {
|
export interface MessageItem {
|
||||||
content: string;
|
content: string;
|
||||||
|
|||||||
@@ -20,6 +20,18 @@
|
|||||||
display: none;
|
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 {
|
&:hover {
|
||||||
.actions {
|
.actions {
|
||||||
display: flex;
|
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) => {
|
const errorHandler = async (res: any) => {
|
||||||
try {
|
try {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
console.log('errorHandler:', data);
|
||||||
return {
|
return {
|
||||||
error: true,
|
error: true,
|
||||||
data: data
|
data: data
|
||||||
|
|||||||
Reference in New Issue
Block a user