blob: 1e79ca7c44a1162eefa9af78b85d5648426a26c9 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { FC, useEffect, useState, useRef } from 'react';
import { Button } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import copy from 'copy-to-clipboard';
import { markdownToHtml, voteConversation } from '@/services';
import { Icon, htmlRender } from '@/components';
interface IProps {
canType?: boolean;
chatId: string;
isLast: boolean;
isCompleted: boolean;
content: string;
minHeight?: number;
actionData: {
helpful: number;
unhelpful: number;
};
}
const escapeHtml = (text: string) =>
text
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const renderPlainTextAsHtml = (text: string) =>
escapeHtml(text).replace(/\r?\n/g, '<br />');
const BubbleAi: FC<IProps> = ({
canType = false,
isLast,
isCompleted,
content,
chatId = '',
actionData,
minHeight = 0,
}) => {
const { t } = useTranslation('translation', { keyPrefix: 'ai_assistant' });
const [displayContent, setDisplayContent] = useState('');
const [copyText, setCopyText] = useState<string>(t('copy'));
const [isHelpful, setIsHelpful] = useState(false);
const [isUnhelpful, setIsUnhelpful] = useState(false);
const [canShowAction, setCanShowAction] = useState(false);
const [safeHtml, setSafeHtml] = useState('');
const typewriterRef = useRef<{
timer: NodeJS.Timeout | null;
index: number;
isTyping: boolean;
}>({
timer: null,
index: 0,
isTyping: false,
});
const renderTimerRef = useRef<NodeJS.Timeout | null>(null);
const renderTaskRef = useRef(0);
const fmtContainer = useRef<HTMLDivElement>(null);
// add ref for ScrollIntoView
const containerRef = useRef<HTMLDivElement>(null);
const handleCopy = () => {
const res = copy(displayContent);
if (res) {
setCopyText(t('copied', { keyPrefix: 'messages' }));
setTimeout(() => {
setCopyText(t('copy'));
}, 1200);
}
};
const handleVote = (voteType: 'helpful' | 'unhelpful') => {
const isCancel =
(voteType === 'helpful' && isHelpful) ||
(voteType === 'unhelpful' && isUnhelpful);
voteConversation({
chat_completion_id: chatId,
cancel: isCancel,
vote_type: voteType,
}).then(() => {
setIsHelpful(voteType === 'helpful' && !isCancel);
setIsUnhelpful(voteType === 'unhelpful' && !isCancel);
});
};
useEffect(() => {
if ((!canType || !isLast) && content) {
// 如果不是最后一个消息,直接返回,不进行打字效果
if (typewriterRef.current.timer) {
clearInterval(typewriterRef.current.timer);
typewriterRef.current.timer = null;
}
setDisplayContent(content);
setCanShowAction(true);
typewriterRef.current.timer = null;
typewriterRef.current.isTyping = false;
return;
}
// 当内容变化时,清理之前的计时器
if (typewriterRef.current.timer) {
clearInterval(typewriterRef.current.timer);
typewriterRef.current.timer = null;
}
// 如果内容为空,则直接返回
if (!content) {
setDisplayContent('');
return;
}
// 如果内容比当前显示的短,则重置
if (content.length < displayContent.length) {
setDisplayContent('');
typewriterRef.current.index = 0;
}
// 如果内容与显示内容相同,不需要做任何事
if (content === displayContent) {
return;
}
typewriterRef.current.isTyping = true;
// start typing animation
typewriterRef.current.timer = setInterval(() => {
const currentIndex = typewriterRef.current.index;
if (currentIndex < content.length) {
const remainingLength = content.length - currentIndex;
const baseRandomNum = Math.floor(Math.random() * 3) + 2;
let randomNum = Math.min(baseRandomNum, remainingLength);
// 简单的单词边界检查(可选)
const nextChar = content[currentIndex + randomNum];
const prevChar = content[currentIndex + randomNum - 1];
// 如果下一个字符是字母,当前字符也是字母,尝试调整到空格处
if (
nextChar &&
/[a-zA-Z]/.test(nextChar) &&
/[a-zA-Z]/.test(prevChar)
) {
// 向前找1-2个字符,看看有没有空格
for (
let i = 1;
i <= 2 && currentIndex + randomNum - i > currentIndex;
i += 1
) {
if (content[currentIndex + randomNum - i] === ' ') {
randomNum = randomNum - i + 1;
break;
}
}
// 向后找1-2个字符,看看有没有空格
for (
let i = 1;
i <= 2 && currentIndex + randomNum + i < content.length;
i += 1
) {
if (content[currentIndex + randomNum + i] === ' ') {
randomNum = randomNum + i + 1;
break;
}
}
}
const nextIndex = currentIndex + randomNum;
const newContent = content.substring(0, nextIndex);
setDisplayContent(newContent);
typewriterRef.current.index = nextIndex;
setCanShowAction(false);
} else {
clearInterval(typewriterRef.current.timer as NodeJS.Timeout);
typewriterRef.current.timer = null;
typewriterRef.current.isTyping = false;
setCanShowAction(false);
}
}, 30);
// eslint-disable-next-line consistent-return
return () => {
if (typewriterRef.current.timer) {
clearInterval(typewriterRef.current.timer);
typewriterRef.current.timer = null;
}
};
}, [content, isCompleted]);
useEffect(() => {
if (renderTimerRef.current) {
clearTimeout(renderTimerRef.current);
renderTimerRef.current = null;
}
renderTaskRef.current += 1;
const currentRenderTask = renderTaskRef.current;
if (!displayContent) {
setSafeHtml('');
return undefined;
}
// During streaming, render escaped plain text to avoid executing unsanitized HTML.
if (!isCompleted) {
setSafeHtml(renderPlainTextAsHtml(displayContent));
return undefined;
}
renderTimerRef.current = setTimeout(() => {
markdownToHtml(displayContent)
.then((resp) => {
if (renderTaskRef.current !== currentRenderTask) {
return;
}
setSafeHtml(resp || renderPlainTextAsHtml(displayContent));
})
.catch(() => {
if (renderTaskRef.current !== currentRenderTask) {
return;
}
setSafeHtml(renderPlainTextAsHtml(displayContent));
});
}, 0);
return () => {
if (renderTimerRef.current) {
clearTimeout(renderTimerRef.current);
renderTimerRef.current = null;
}
};
}, [displayContent, isCompleted]);
useEffect(() => {
setIsHelpful(actionData.helpful > 0);
setIsUnhelpful(actionData.unhelpful > 0);
}, [actionData]);
useEffect(() => {
if (fmtContainer.current && isCompleted && safeHtml) {
htmlRender(fmtContainer.current, {
copySuccessText: t('copied', { keyPrefix: 'messages' }),
copyText: t('copy', { keyPrefix: 'messages' }),
});
const links = fmtContainer.current.querySelectorAll('a');
links.forEach((link) => {
link.setAttribute('target', '_blank');
});
setCanShowAction(true);
}
}, [isCompleted, safeHtml, t]);
return (
<div
className="rounded bubble-ai"
ref={containerRef}
style={{ minHeight: `${minHeight}px`, overflowAnchor: 'none' }}>
<div id={chatId}>
<div
className="fmt text-break text-wrap"
ref={fmtContainer}
style={{ transition: 'all 0.2s ease' }}
dangerouslySetInnerHTML={{ __html: safeHtml }}
/>
{canShowAction && (
<div className="action">
<Button
variant="link"
className="p-0 link-secondary small me-3"
onClick={handleCopy}>
<Icon name="copy" />
<span className="ms-1">{copyText}</span>
</Button>
<Button
variant="link"
className={`p-0 small me-3 ${isHelpful ? 'link-primary active' : 'link-secondary'}`}
onClick={() => handleVote('helpful')}>
<Icon name="hand-thumbs-up-fill" />
<span className="ms-1">Helpful</span>
</Button>
<Button
variant="link"
className={`p-0 small me-3 ${isUnhelpful ? 'link-primary active' : 'link-secondary'}`}
onClick={() => handleVote('unhelpful')}>
<Icon name="hand-thumbs-down-fill" />
<span className="ms-1">Unhelpful</span>
</Button>
</div>
)}
</div>
</div>
);
};
export default BubbleAi;