235 lines
9.7 KiB
TypeScript
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>
|
|
);
|
|
};
|
|
|