parent
349b895ac5
commit
4498f069f0
@ -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;
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in new issue