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

362 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React from 'react';
import { CopyButton, Icon } from 'UI';
import { observer } from 'mobx-react-lite';
import cn from 'classnames';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import {
Loader,
ThumbsUp,
ThumbsDown,
SquarePen,
Clock,
ChartLine,
} from 'lucide-react';
import { Button, Tooltip } from 'antd';
import { kaiStore, Message } from '../KaiStore';
import { toast } from 'react-toastify';
import { durationFormatted } from 'App/date';
import WidgetChart from '@/components/Dashboard/components/WidgetChart';
import Widget from 'App/mstore/types/widget';
import { useTranslation } from 'react-i18next';
import SessionItem from 'Shared/SessionItem';
function ChatMsg({
userName,
siteId,
canEdit,
message,
chatTitle,
onReplay,
}: {
message: Message;
userName?: string;
canEdit?: boolean;
siteId: string;
chatTitle: string | null;
onReplay: (session: any) => void;
}) {
const { t } = useTranslation();
const [metric, setMetric] = React.useState<Widget | null>(null);
const [loadingChart, setLoadingChart] = React.useState(false);
const {
text,
isUser,
messageId,
duration,
feedback,
supports_visualization,
chart_data,
} = message;
const isEditing = kaiStore.replacing && messageId === kaiStore.replacing;
const [isProcessing, setIsProcessing] = React.useState(false);
const bodyRef = React.useRef<HTMLDivElement>(null);
const chartRef = React.useRef<HTMLDivElement>(null);
const onEdit = () => {
kaiStore.editMessage(text, messageId);
};
const onCancelEdit = () => {
kaiStore.setQueryText('');
kaiStore.setReplacing(null);
};
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;
}
const userPrompt = kaiStore.getPreviousMessage(message.messageId);
import('jspdf')
.then(async ({ jsPDF }) => {
const doc = new jsPDF();
const blockWidth = 170; // mm
const content = bodyRef.current!.cloneNode(true) as HTMLElement;
if (userPrompt) {
const titleHeader = document.createElement('h2');
titleHeader.textContent = userPrompt.text;
titleHeader.style.marginBottom = '10px';
content.prepend(titleHeader);
}
// insert logo /assets/img/logo-img.png
const logo = new Image();
logo.src = '/assets/img/logo-img.png';
logo.style.width = '130px';
const container = document.createElement('div');
container.style.display = 'flex';
container.style.alignItems = 'center';
container.style.justifyContent = 'center';
container.style.marginBottom = '10mm';
container.style.width = `${blockWidth}mm`;
container.appendChild(logo);
content.prepend(container);
content.querySelectorAll('ul').forEach((ul) => {
const frag = document.createDocumentFragment();
ul.querySelectorAll('li').forEach((li) => {
const div = document.createElement('div');
div.textContent = '• ' + li.textContent;
frag.appendChild(div);
});
ul.replaceWith(frag);
});
content.querySelectorAll('h1,h2,h3,h4,h5,h6').forEach((el) => {
(el as HTMLElement).style.letterSpacing = '0.5px';
});
content.querySelectorAll('*').forEach((node) => {
node.childNodes.forEach((child) => {
if (child.nodeType === Node.TEXT_NODE) {
const txt = child.textContent || '';
const replaced = txt.replace(/-/g, '');
if (replaced !== txt) child.textContent = replaced;
}
});
});
if (metric && chartRef.current) {
const { default: html2canvas } = await import('html2canvas');
const metricContainer = chartRef.current;
const image = await html2canvas(metricContainer, {
backgroundColor: null,
scale: 2,
});
const imgData = image.toDataURL('image/png');
const imgHeight = (image.height * blockWidth) / image.width;
content.appendChild(
Object.assign(document.createElement('img'), {
src: imgData,
style: `width: ${blockWidth}mm; height: ${imgHeight}mm; margin-top: 10px;`,
}),
);
}
doc.html(content, {
callback: function (doc) {
doc.save((chatTitle ?? 'document') + '.pdf');
},
// top, bottom, ?, left
margin: [10, 10, 20, 20],
x: 0,
y: 0,
// Target width
width: blockWidth,
// Window width for rendering
windowWidth: 675,
});
})
.catch((e) => {
console.error('Error exporting message:', e);
toast.error('Failed to export message');
})
.finally(() => {
setIsProcessing(false);
});
};
React.useEffect(() => {
if (chart_data) {
const metric = kaiStore.getParsedChart(chart_data);
setMetric(metric);
}
}, [chart_data]);
const getChart = async () => {
try {
setLoadingChart(true);
const metric = await kaiStore.getMessageChart(messageId, siteId);
setMetric(metric);
} catch (e) {
toast.error(e.message);
} finally {
setLoadingChart(false);
}
};
const metricData = metric?.data;
React.useEffect(() => {
if (!chart_data && metricData) {
const hasValues =
metricData.values?.length > 0 || metricData.chart?.length > 0;
if (hasValues) {
kaiStore.saveLatestChart(messageId, siteId);
}
}
}, [metricData, chart_data]);
return (
<div className={cn('flex gap-2', isUser ? 'flex-row-reverse' : 'flex-row')}>
<div className={'mt-1 flex flex-col group/actions max-w-[60svw]'}>
<div
className={cn(
'markdown-body',
isUser ? 'bg-gray-lighter px-4 rounded-full' : '',
isEditing ? '!bg-active-blue' : '',
)}
data-openreplay-obscured
ref={bodyRef}
>
<Markdown remarkPlugins={[remarkGfm]}>{text}</Markdown>
</div>
{metric ? (
<div
ref={chartRef}
className="p-2 border-gray-light rounded-lg shadow bg-glassWhite mb-2"
>
<WidgetChart metric={metric} isPreview height={360} />
</div>
) : null}
{message.sessions ? (
<div className="flex flex-col">
{message.sessions.map((session) => (
<div className="shadow border rounded-2xl overflow-hidden mb-2">
<SessionItem
disableUser
key={session.sessionId}
session={session}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onReplay(session);
}}
slim
/>
</div>
))}
</div>
) : null}
{isUser ? (
<div className="invisible group-hover/actions:visible mt-1 ml-auto flex gap-2 items-center">
{canEdit && !isEditing ? (
<IconButton onClick={onEdit} tooltip={t('Edit')}>
<SquarePen size={16} />
</IconButton>
) : null}
{isEditing ? (
<Button
onClick={onCancelEdit}
type="text"
size="small"
className={'text-xs'}
>
{t('Cancel')}
</Button>
) : null}
<CopyButton
getHtml={() => bodyRef.current?.innerHTML}
content={text}
isIcon
format={'text/html'}
/>
</div>
) : (
<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 strokeWidth={2} size={16} />
</IconButton>
<IconButton
active={feedback === false}
tooltip="Dislike this answer"
onClick={() => onFeedback('dislike', messageId)}
>
<ThumbsDown strokeWidth={2} size={16} />
</IconButton>
{supports_visualization ? (
<IconButton
tooltip="Visualize this answer"
onClick={getChart}
processing={loadingChart}
>
<ChartLine strokeWidth={2} size={16} />
</IconButton>
) : null}
<CopyButton
getHtml={() => bodyRef.current?.innerHTML}
content={text}
isIcon
format={'text/html'}
/>
<IconButton
processing={isProcessing}
tooltip="Export as PDF"
onClick={onExport}
>
<Icon name="export-pdf" 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>
);
}
export default observer(ChatMsg);