fix: edit message add image

This commit is contained in:
jialin
2025-12-02 14:00:58 +08:00
parent 5cbb38922c
commit ce3a82ddda
11 changed files with 218 additions and 212 deletions
+1 -1
View File
@@ -87,4 +87,4 @@ const UploadAudio: React.FC<UploadAudioProps> = (props) => {
);
};
export default React.memo(UploadAudio);
export default UploadAudio;
@@ -201,9 +201,11 @@ const GroundSTT: React.FC<MessageProps> = forwardRef((props, ref) => {
const handleUploadChange = useCallback(
async (data: { file: any; fileList: any }) => {
const res = await readAudioFile(data.file);
setAudioData(res);
setTokenResult(null);
try {
const res = await readAudioFile(data.file);
setAudioData(res);
setTokenResult(null);
} catch (error) {}
},
[]
);
@@ -1,5 +1,4 @@
import SimpleAudio from '@/components/audio-player/simple-audio';
import DropDownActions from '@/components/drop-down-actions';
import IconFont from '@/components/icon-font';
import UploadAudio from '@/components/upload-audio';
import HotKeys, { KeyMap } from '@/config/hotkeys';
@@ -11,11 +10,8 @@ import {
import {
ClearOutlined,
CustomerServiceOutlined,
LinkOutlined,
PictureOutlined,
SendOutlined,
SwapOutlined,
UploadOutlined
SwapOutlined
} from '@ant-design/icons';
import { useIntl } from '@umijs/max';
import { Button, Checkbox, Divider, Input, Tooltip } from 'antd';
@@ -32,9 +28,9 @@ import { useHotkeys } from 'react-hotkeys-hook';
import styled from 'styled-components';
import { Roles } from '../config';
import { AudioFormat, MessageItem } from '../config/types';
import useAddImage from '../hooks/use-add-image';
import '../style/message-input.less';
import ThumbImg from './thumb-img';
import UploadImg from './upload-img';
const AudioWrapper = styled.div`
padding-block: 10px;
@@ -300,39 +296,6 @@ const MessageInput: React.FC<MessageInputProps> = forwardRef(
});
};
const handleInputImageUrl = (e: any) => {
const url = e.target.value;
// ends with .png, .jpg, .jpeg
const regex = /(https?:\/\/\S+\.(png|jpg|jpeg))/gi;
const isValid = regex.test(url);
setOpenImgTips(!isValid);
if (!isValid) {
return;
}
handleUpdateImgList([
{
uid: updateUidCount(),
dataUrl: e.target.value
}
]);
setIsFromUrl(false);
setOpenImgTips(false);
};
const handleOnEscape = (e: any) => {
if (e.key === 'Escape') {
setIsFromUrl(false);
setOpenImgTips(false);
}
};
const handleAddImgFromUrl = () => {
setIsFromUrl(true);
setTimeout(() => {
inputImgRef.current?.focus?.();
}, 100);
};
const handleUploadAudioChange = async (data: {
file: any;
fileList: any[];
@@ -398,6 +361,11 @@ const MessageInput: React.FC<MessageInputProps> = forwardRef(
}
};
const { ImageURLInput, UploadImageButton } = useAddImage({
handleUpdateImgList: handleUpdateImgList,
updateUidCount: updateUidCount
});
useImperativeHandle(ref, () => ({
handleInputChange: handleInputChange
}));
@@ -451,41 +419,9 @@ const MessageInput: React.FC<MessageInputProps> = forwardRef(
{checkLabel}
</Checkbox>
)}
{actions.includes('upload') && message.role === Roles.User && (
<DropDownActions
placement={'topLeft'}
menu={{
items: [
{
label: (
<UploadImg
handleUpdateImgList={handleUpdateImgList}
size="middle"
>
{intl.formatMessage({ id: 'playground.img.upload' })}
</UploadImg>
),
key: 'upload_image',
icon: <UploadOutlined />
},
{
label: intl.formatMessage({
id: 'playground.uploadImage.url.button'
}),
key: 'add_image_url',
icon: <LinkOutlined />,
onClick: handleAddImgFromUrl
}
]
}}
>
<Button
type="text"
size="middle"
icon={<PictureOutlined />}
></Button>
</DropDownActions>
)}
{actions.includes('upload') &&
message.role === Roles.User &&
UploadImageButton}
{actions.includes('upload') && message.role === Roles.User && (
<UploadAudio
@@ -532,6 +468,7 @@ const MessageInput: React.FC<MessageInputProps> = forwardRef(
))}
</>
)}
{ImageURLInput}
</div>
<div className="actions">
{actions.includes('add') && (
@@ -603,26 +540,7 @@ const MessageInput: React.FC<MessageInputProps> = forwardRef(
</AudioWrapper>
)}
</ImgsWrapper>
{isFromUrl && (
<Tooltip
open={openImgTips}
title={intl.formatMessage({
id: 'playground.uploadImage.url.invalid'
})}
>
<Input
ref={inputImgRef}
status={openImgTips ? 'error' : ''}
placeholder={intl.formatMessage({
id: 'playground.uploadImage.url.holder'
})}
style={{ width: 360 }}
onBlur={handleInputImageUrl}
onPressEnter={handleInputImageUrl}
onKeyDown={handleOnEscape}
></Input>
</Tooltip>
)}
<div className="input-box">
{actions.includes('paste') ? (
<TextArea
@@ -12,6 +12,7 @@ import {
} from '@ant-design/icons';
import { useIntl } from '@umijs/max';
import { Button, Tooltip } from 'antd';
import classNames from 'classnames';
import _ from 'lodash';
import React from 'react';
import { Roles } from '../../config';
@@ -20,7 +21,7 @@ import {
MessageItem,
MessageItemAction
} from '../../config/types';
import UploadImg from '../upload-img';
import useAddImage from '../../hooks/use-add-image';
interface MessageActionsProps {
data: MessageItem;
@@ -57,32 +58,47 @@ const MessageActions: React.FC<MessageActionsProps> = ({
file: any;
fileList: any[];
}) => {
const base64Audio = await convertFileToBase64(audio.file);
const audioData = await readAudioFile(audio.file);
updateMessage?.({
role: data.role,
content: data.content,
uid: data.uid,
imgs: data.imgs || [],
audio: [
{
uid: audio.file.uid || audio.fileList[0].uid,
format: audioTypeMap[audio.file.type] as AudioFormat,
base64: base64Audio.split(',')[1],
data: _.pick(audioData, ['url', 'name', 'duration'])
}
]
});
try {
const base64Audio = await convertFileToBase64(audio.file);
const audioData = await readAudioFile(audio.file);
updateMessage?.({
role: data.role,
content: data.content,
uid: data.uid,
imgs: data.imgs || [],
audio: [
{
uid: audio.file.uid || audio.fileList[0].uid,
format: audioTypeMap[audio.file.type] as AudioFormat,
base64: base64Audio.split(',')[1],
data: _.pick(audioData, ['url', 'name', 'duration'])
}
]
});
} catch (error) {
console.log('error uploading audio file', error);
}
};
const { ImageURLInput, UploadImageButton, isFromUrl } = useAddImage({
size: 'small',
handleUpdateImgList: handleUpdateImgList,
updateUidCount: () => `img-${Date.now()}`
});
return (
<>
{ImageURLInput}
{actions.length > 1 && !loading ? (
<div className="actions">
<div
className={classNames('actions', {
'has-url-input': isFromUrl
})}
>
<div className="actions-wrap gap-5">
{actions.includes('upload') && data.role === Roles.User && (
<UploadImg handleUpdateImgList={handleUpdateImgList} />
)}
{actions.includes('upload') &&
data.role === Roles.User &&
UploadImageButton}
{actions.includes('upload') && data.role === Roles.User && (
<UploadAudio
type="text"
@@ -16,10 +16,8 @@ import ThumbImg from '../thumb-img';
import ThinkContent from './think-content';
const AudioWrapper = styled.div`
padding-top: 10px;
height: max-content;
width: max-content;
margin-inline: 10px;
`;
const ThumbImgWrapper = styled.div.attrs({
@@ -29,6 +27,12 @@ const ThumbImgWrapper = styled.div.attrs({
justify-content: flex-start;
overflow-x: auto;
flex-direction: column;
padding: 0px;
gap: 8px;
&.has-content {
padding-inline: 8px;
padding-block: 8px 0;
}
`;
interface MessageBodyProps {
@@ -179,31 +183,6 @@ const MessageBody: React.FC<MessageBodyProps> = forwardRef(
});
};
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();
@@ -218,7 +197,7 @@ const MessageBody: React.FC<MessageBodyProps> = forwardRef(
data.imgs?.length || (data.audio && data.audio?.length > 0)
})}
>
<div className="justify-start ">
<div className="justify-start">
<ThumbImg
editable={editable}
dataList={data.imgs || []}
@@ -249,7 +228,12 @@ const MessageBody: React.FC<MessageBodyProps> = forwardRef(
})}
onClick={handleClickWrapper}
>
<ThumbImgWrapper>
<ThumbImgWrapper
className={classNames({
'has-content':
data.imgs?.length || (data.audio && data.audio?.length > 0)
})}
>
<ThumbImg
style={{ paddingBlockEnd: 0 }}
editable={editable}
@@ -257,7 +241,7 @@ const MessageBody: React.FC<MessageBodyProps> = forwardRef(
onDelete={handleDeleteImg}
/>
{data.audio && data.audio.length > 0 && (
<AudioWrapper className={data.imgs?.length ? '' : 'm-l-10'}>
<AudioWrapper>
<SimpleAudio
url={data.audio?.[0]?.data.url}
name={data.audio?.[0]?.data.name}
@@ -0,0 +1,120 @@
import DropDownActions from '@/components/drop-down-actions';
import {
LinkOutlined,
PictureOutlined,
UploadOutlined
} from '@ant-design/icons';
import { useIntl } from '@umijs/max';
import { Button, Input, Tooltip } from 'antd';
import { useRef, useState } from 'react';
import UploadImg from '../components/upload-img';
const regex = /(https?:\/\/\S+\.(png|jpg|jpeg))/gi;
const useAddImage = (options: {
size?: 'small' | 'middle' | 'large';
handleUpdateImgList: (
list: { uid: number | string; dataUrl: string }[]
) => void;
updateUidCount: () => number | string;
}) => {
const { handleUpdateImgList, updateUidCount, size = 'middle' } = options;
const intl = useIntl();
const [isFromUrl, setIsFromUrl] = useState(false);
const [openImgTips, setOpenImgTips] = useState(false);
const inputImgRef = useRef<any>(null);
const handleAddImgFromUrl = () => {
setIsFromUrl(true);
setTimeout(() => {
inputImgRef.current?.focus?.();
}, 100);
};
const handleInputImageUrl = (e: any) => {
const url = e.target.value;
const isValid = regex.test(url);
setOpenImgTips(!isValid);
if (isValid) {
handleUpdateImgList([
{
uid: updateUidCount(),
dataUrl: e.target.value
}
]);
setIsFromUrl(false);
}
setTimeout(() => {
setOpenImgTips(false);
}, 2000);
};
const handleOnEscape = (e: any) => {
if (e.key === 'Escape') {
setIsFromUrl(false);
setOpenImgTips(false);
}
};
const ImageURLInput = isFromUrl ? (
<Tooltip
open={openImgTips}
title={intl.formatMessage({
id: 'playground.uploadImage.url.invalid'
})}
>
<Input
ref={inputImgRef}
status={openImgTips ? 'error' : ''}
placeholder={intl.formatMessage({
id: 'playground.uploadImage.url.holder'
})}
style={{ width: 360, height: 32 }}
onBlur={handleInputImageUrl}
onPressEnter={handleInputImageUrl}
onKeyDown={handleOnEscape}
></Input>
</Tooltip>
) : null;
const UploadImageButton = (
<DropDownActions
placement={'topLeft'}
menu={{
items: [
{
label: (
<UploadImg
handleUpdateImgList={handleUpdateImgList}
size="middle"
>
{intl.formatMessage({ id: 'playground.img.upload' })}
</UploadImg>
),
key: 'upload_image',
icon: <UploadOutlined />
},
{
label: intl.formatMessage({
id: 'playground.uploadImage.url.button'
}),
key: 'add_image_url',
icon: <LinkOutlined />,
onClick: handleAddImgFromUrl
}
]
}}
>
<Button type="text" size={size} icon={<PictureOutlined />}></Button>
</DropDownActions>
);
return {
isFromUrl,
ImageURLInput,
UploadImageButton
};
};
export default useAddImage;
@@ -1,30 +0,0 @@
import _ from 'lodash';
import { useState } from 'react';
const imageUrlRegex = /(https?:\/\/\S+\.(png|jpg|jpeg))/gi;
const useFindImages = () => {
function extractImageUrls(text: string) {
return [...text.matchAll(imageUrlRegex)].map((m) => m[0]);
}
const [pendingUrls, setPendingUrls] = useState<string[]>([]);
const debounceExtract = _.debounce((text: string) => {
const urls = extractImageUrls(text);
if (urls.length > 0) {
setPendingUrls(urls);
} else {
setPendingUrls([]);
}
}, 200);
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const text = e.target.value;
debounceExtract(text);
};
return {
handleInputChange
};
};
+2 -2
View File
@@ -156,7 +156,7 @@ const Playground: React.FC = () => {
activeKey={activeKey}
key="view-code-buttons"
/>,
<>
<div key="divider-wrapper">
{activeKey === 'chat' && (
<Divider
key="divider"
@@ -164,7 +164,7 @@ const Playground: React.FC = () => {
style={{ height: 24, marginInline: 16 }}
/>
)}
</>,
</div>,
<ExtraContent key="extra-content" />
]}
>
@@ -33,6 +33,11 @@
border: 1px solid var(--ant-color-border);
border-radius: var(--border-radius-base);
background-color: var(--color-white-1);
&.has-url-input {
display: flex;
margin-left: 8px;
}
}
.actions-wrap {
@@ -58,5 +58,4 @@
display: flex;
gap: 10px;
flex-wrap: wrap;
padding: 10px;
}
+22 -30
View File
@@ -28,50 +28,43 @@ export const loadAudioData = async (
url: string;
}> => {
return new Promise((resolve, reject) => {
try {
const audioBlob = new Blob([data], { type: type });
const fileSize = convertFileSize(audioBlob.size);
const audioBlob = new Blob([data], { type: type });
const fileSize = convertFileSize(audioBlob.size);
const audio = document.createElement('audio');
const url = URL.createObjectURL(audioBlob);
audio.src = url;
const audio = document.createElement('audio');
const url = URL.createObjectURL(audioBlob);
audio.src = url;
audio.addEventListener('loadedmetadata', () => {
const duration = audio.duration;
resolve({
data: audioBlob,
size: fileSize,
type: type,
duration: Math.ceil(duration),
url: url
});
audio.addEventListener('loadedmetadata', () => {
const duration = audio.duration;
resolve({
data: audioBlob,
size: fileSize,
type: type,
duration: Math.ceil(duration),
url: url
});
});
audio.addEventListener('ended', () => {
URL.revokeObjectURL(audio.src);
});
audio.addEventListener('ended', () => {
URL.revokeObjectURL(audio.src);
});
audio.addEventListener('error', () => {
URL.revokeObjectURL(url);
message.error('Failed to load audio metadata invalid file');
reject(new Error('Failed to load audio metadata invalid file'));
});
} catch (error) {
console.log('error====', error);
reject(error);
}
audio.addEventListener('error', () => {
URL.revokeObjectURL(url);
message.error('Failed to load audio metadata invalid file');
reject(new Error('Failed to load audio metadata invalid file'));
});
});
};
export const readAudioFile = async (
file: File
): Promise<{ url: string; name: string; duration: number }> => {
console.log('file====', file);
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = async function (e: any) {
try {
console.log('file====', file);
const arrayBuffer = e.target.result;
const audioData = await loadAudioData(arrayBuffer, file.type);
resolve({
@@ -79,7 +72,6 @@ export const readAudioFile = async (
name: file.name
});
} catch (error) {
console.log('error====', error);
reject(error);
}
};