openreplay/frontend/app/components/Kai/components/ChatMsg.tsx
2025-05-13 17:17:18 +02:00

217 lines
5.7 KiB
TypeScript

import React from 'react';
import { Icon, CopyButton } from 'UI';
import cn from 'classnames';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import {
Loader,
ThumbsUp,
ThumbsDown,
ListRestart,
FileDown,
Clock,
} from 'lucide-react';
import { Button, Tooltip } from 'antd';
import { kaiStore } from '../KaiStore';
import { toast } from 'react-toastify';
import { durationFormatted } from 'App/date';
export function ChatMsg({
text,
isUser,
userName,
messageId,
isLast,
duration,
feedback,
siteId,
}: {
text: string;
isUser: boolean;
messageId: string;
userName?: string;
isLast?: boolean;
duration?: number;
feedback: boolean | null;
siteId: string;
}) {
const [isProcessing, setIsProcessing] = React.useState(false);
const bodyRef = React.useRef<HTMLDivElement>(null);
const onRetry = () => {
kaiStore.editMessage(text);
};
const onFeedback = (feedback: 'like' | 'dislike', messageId: string) => {
kaiStore.sendMsgFeedback(feedback, messageId, siteId);
};
const onExport = () => {
setIsProcessing(true);
if (!bodyRef.current) {
toast.error('Failed to export message');
setIsProcessing(false);
return;
}
import('jspdf')
.then(({ jsPDF }) => {
const doc = new jsPDF();
doc.addImage('/assets/img/logo-img.png', 80, 3, 30, 5);
doc.html(bodyRef.current!, {
callback: function (doc) {
doc.save('document.pdf');
},
margin: [10, 10, 10, 10],
x: 0,
y: 0,
width: 190, // Target width
windowWidth: 675, // Window width for rendering
});
})
.catch((e) => {
console.error('Error exporting message:', e);
toast.error('Failed to export message');
})
.finally(() => {
setIsProcessing(false);
});
};
return (
<div
className={cn(
'flex items-start gap-2',
isUser ? 'flex-row-reverse' : 'flex-row',
)}
>
{isUser ? (
<div
className={
'rounded-full bg-main text-white min-w-8 min-h-8 flex items-center justify-center sticky top-0'
}
>
<span className={'font-semibold'}>{userName}</span>
</div>
) : (
<div
className={
'rounded-full bg-white shadow min-w-8 min-h-8 flex items-center justify-center sticky top-0'
}
>
<Icon name={'kai_colored'} size={18} />
</div>
)}
<div className={'mt-1 flex flex-col'}>
<div className="markdown-body" ref={bodyRef}>
<Markdown remarkPlugins={[remarkGfm]}>{text}</Markdown>
</div>
{isUser ? (
isLast ? (
<div
onClick={onRetry}
className={
'ml-auto flex items-center gap-2 px-2 rounded-lg border border-gray-medium text-sm cursor-pointer hover:border-main hover:text-main w-fit'
}
>
<ListRestart size={16} />
<div>Edit</div>
</div>
) : null
) : (
<div className={'flex items-center gap-2'}>
{duration ? <MsgDuration duration={duration} /> : null}
<div className="ml-auto" />
<IconButton
active={feedback === true}
tooltip="Like this answer"
onClick={() => onFeedback('like', messageId)}
>
<ThumbsUp size={16} />
</IconButton>
<IconButton
active={feedback === false}
tooltip="Dislike this answer"
onClick={() => onFeedback('dislike', messageId)}
>
<ThumbsDown size={16} />
</IconButton>
<CopyButton
getHtml={() => bodyRef.current?.innerHTML}
content={text}
isIcon
format={'text/html'}
/>
<IconButton
processing={isProcessing}
tooltip="Export as PDF"
onClick={onExport}
>
<FileDown size={16} />
</IconButton>
</div>
)}
</div>
</div>
);
}
function IconButton({
children,
onClick,
tooltip,
processing,
active,
}: {
children: React.ReactNode;
onClick?: () => void;
tooltip?: string;
processing?: boolean;
active?: boolean;
}) {
return (
<Tooltip title={tooltip}>
<Button
onClick={onClick}
type={active ? 'primary' : 'text'}
icon={children}
size="small"
loading={processing}
/>
</Tooltip>
);
}
export function ChatNotice({
content,
duration,
}: {
content: string;
duration?: number;
}) {
const startTime = React.useRef(duration ? Date.now() - duration : Date.now());
const [activeDuration, setDuration] = React.useState(duration ?? 0);
React.useEffect(() => {
const interval = setInterval(() => {
setDuration(Math.round(Date.now() - startTime.current));
}, 250);
return () => clearInterval(interval);
}, []);
return (
<div className="flex flex-col gap-1 items-start p-2 rounded-lg bg-gray-lightest border-gray-light w-fit ">
<div className="flex gap-2 items-start">
<div className={'animate-spin mt-1'}>
<Loader size={14} />
</div>
<div className={'animate-pulse'}>{content}</div>
</div>
<MsgDuration duration={activeDuration} />
</div>
);
}
function MsgDuration({ duration }: { duration: number }) {
return (
<div className="text-disabled-text text-sm flex items-center gap-1">
<Clock size={14} />
<span className="leading-none">{durationFormatted(duration)}</span>
</div>
);
}