ui-nyla/src/components/UiComponents/Messages/ChatMessages/ChatMessage.tsx
2026-04-11 19:44:52 +02:00

235 lines
9.7 KiB
TypeScript

import React, { useState } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { FaExclamationTriangle } from 'react-icons/fa';
import { IoIosTrash, IoIosCheckmark, IoIosClose } from 'react-icons/io';
import { Message } from '../MessagesTypes';
import { formatTimestamp } from '../MessageUtils';
import { DocumentItem, ActionInfo } from '../MessageParts';
import { WorkflowFile } from '../../../../hooks/usePlayground';
import styles from '../Messages.module.css';
import { useLanguage } from '../../../../providers/language/LanguageContext';
export interface ChatMessageProps {
message: Message;
showDocuments?: boolean;
renderDocument?: (document: any, message: Message) => React.ReactNode;
onFileDelete?: (file: WorkflowFile) => Promise<void>;
onFileRemove?: (file: WorkflowFile) => Promise<void>;
onFileView?: (file: WorkflowFile) => Promise<void>;
onFileDownload?: (file: WorkflowFile) => Promise<void>;
deletingFiles?: Set<string>;
previewingFiles?: Set<string>;
removingFiles?: Set<string>;
downloadingFiles?: Set<string>;
workflowId?: string;
onMessageDelete?: (messageId: string) => Promise<void>;
deletingMessages?: Set<string>;
}
/**
* Renders a single message in chat style (bubble UI)
*/
export const ChatMessage: React.FC<ChatMessageProps> = ({ message,
showDocuments = true,
renderDocument,
onFileDelete,
onFileRemove,
onFileView,
onFileDownload,
deletingFiles,
previewingFiles,
removingFiles,
downloadingFiles,
workflowId,
onMessageDelete,
deletingMessages
}) => {
const { t } = useLanguage();
const isUser = message.role?.toLowerCase() === 'user';
const isError = message.actionProgress === 'fail' || message.actionProgress === 'error';
const messageClass = isUser ? styles.messageUser : styles.messageAssistant;
const errorClass = isError ? styles.messageError : '';
const isDeleting = deletingMessages?.has(message.id) || false;
// Message delete 2-click confirmation state
const [isConfirmingDelete, setIsConfirmingDelete] = useState(false);
const handleDeleteClick = (e: React.MouseEvent) => {
e.stopPropagation();
setIsConfirmingDelete(true);
};
const handleConfirmDelete = async (e: React.MouseEvent) => {
e.stopPropagation();
setIsConfirmingDelete(false);
if (onMessageDelete && message.id) {
await onMessageDelete(message.id);
}
};
const handleCancelDelete = (e: React.MouseEvent) => {
e.stopPropagation();
setIsConfirmingDelete(false);
};
return (
<div className={`${styles.message} ${messageClass} ${errorClass}`}>
<div className={styles.messageBubble}>
{/* Error indicator for failed actions */}
{isError && (
<div className={styles.errorIndicator}>
<FaExclamationTriangle className={styles.errorIcon} />
<span>{t('Aktion fehlgeschlagen')}</span>
</div>
)}
{/* Message content */}
{message.message && (
<div className={styles.messageContent}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
className={styles.markdownContent}
components={{
// Custom styling for markdown elements
h1: ({node, ...props}) => <h1 className={styles.markdownH1} {...props} />,
h2: ({node, ...props}) => <h2 className={styles.markdownH2} {...props} />,
h3: ({node, ...props}) => <h3 className={styles.markdownH3} {...props} />,
h4: ({node, ...props}) => <h4 className={styles.markdownH4} {...props} />,
h5: ({node, ...props}) => <h5 className={styles.markdownH5} {...props} />,
h6: ({node, ...props}) => <h6 className={styles.markdownH6} {...props} />,
p: ({node, ...props}) => <p className={styles.markdownP} {...props} />,
ul: ({node, ...props}) => <ul className={styles.markdownUl} {...props} />,
ol: ({node, ...props}) => <ol className={styles.markdownOl} {...props} />,
li: ({node, ...props}) => <li className={styles.markdownLi} {...props} />,
table: ({node, ...props}) => <div className={styles.markdownTableWrapper}><table className={styles.markdownTable} {...props} /></div>,
thead: ({node, ...props}) => <thead className={styles.markdownThead} {...props} />,
tbody: ({node, ...props}) => <tbody className={styles.markdownTbody} {...props} />,
tr: ({node, ...props}) => <tr className={styles.markdownTr} {...props} />,
th: ({node, ...props}) => <th className={styles.markdownTh} data-in-table="true" {...props} />,
td: ({node, ...props}) => <td className={styles.markdownTd} data-in-table="true" {...props} />,
code: ({node, inline, ...props}: any) =>
inline ? (
<code className={styles.markdownCodeInline} {...props} />
) : (
<code className={styles.markdownCodeBlock} {...props} />
),
pre: ({node, ...props}) => <pre className={styles.markdownPre} {...props} />,
blockquote: ({node, ...props}) => <blockquote className={styles.markdownBlockquote} {...props} />,
strong: ({node, ...props}) => <strong className={styles.markdownStrong} {...props} />,
em: ({node, ...props}) => <em className={styles.markdownEm} {...props} />,
a: ({node, ...props}: any) => {
// Check if link is inside a table by checking parent chain
// In react-markdown, we need to check the node structure
const isInTable = (node: any): boolean => {
let current = node.parent;
while (current) {
if (current.type === 'tableCell' || current.type === 'tableRow' || current.type === 'table') {
return true;
}
current = current.parent;
}
return false;
};
if (isInTable(node)) {
// Render as plain text if inside table
return <span className={styles.markdownLinkText}>{props.children}</span>;
}
return <a className={styles.markdownLink} {...props} />;
},
hr: ({node, ...props}) => <hr className={styles.markdownHr} {...props} />,
}}
>
{message.message}
</ReactMarkdown>
</div>
)}
{/* Summary if different from message */}
{message.summary && message.summary !== message.message && (
<div className={styles.messageSummary}>
<strong>{t('Zusammenfassung')}:</strong> {message.summary}
</div>
)}
{/* Documents */}
{showDocuments && message.documents && Array.isArray(message.documents) && message.documents.length > 0 && (
<div className={styles.documentsContainer}>
{message.documentsLabel && (
<div className={styles.documentsLabel}>{message.documentsLabel}</div>
)}
<div className={styles.documentsList}>
{message.documents.map((doc) => (
<div key={doc.id}>
{renderDocument ? renderDocument(doc, message) : (
<DocumentItem
document={doc}
message={message}
onFileDelete={onFileDelete}
onFileRemove={onFileRemove}
onFileView={onFileView}
onFileDownload={onFileDownload}
deletingFiles={deletingFiles}
previewingFiles={previewingFiles}
removingFiles={removingFiles}
downloadingFiles={downloadingFiles}
workflowId={workflowId}
/>
)}
</div>
))}
</div>
</div>
)}
{/* Action information (shown only for assistant messages) */}
{!isUser && <ActionInfo message={message} />}
{/* Timestamp and message actions */}
<div className={styles.messageFooter}>
{message.publishedAt && (
<div className={styles.messageTimestamp}>
{formatTimestamp(message.publishedAt)}
</div>
)}
{onMessageDelete && message.id && (
<div className={styles.messageActions}>
{isConfirmingDelete ? (
<div className={styles.messageDeleteConfirm}>
<button
onClick={handleConfirmDelete}
className={styles.messageDeleteConfirmBtn}
title={t('Löschen bestätigen')}
disabled={isDeleting}
>
<IoIosCheckmark />
</button>
<button
onClick={handleCancelDelete}
className={styles.messageDeleteCancelBtn}
title={t('Abbrechen')}
disabled={isDeleting}
>
<IoIosClose />
</button>
</div>
) : (
<button
onClick={handleDeleteClick}
className={styles.messageDeleteBtn}
title={t('Nachricht löschen')}
disabled={isDeleting}
>
<IoIosTrash />
</button>
)}
</div>
)}
</div>
</div>
</div>
);
};