React frontend with: - Authentication UI - Trading dashboard - ML signals display - Portfolio management Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
183 lines
5.2 KiB
TypeScript
183 lines
5.2 KiB
TypeScript
/**
|
|
* ChatMessage Component
|
|
* Renders an individual chat message with markdown support
|
|
*/
|
|
|
|
import React, { useState } from 'react';
|
|
import { Copy, Check, Bot, User } from 'lucide-react';
|
|
import type { ChatMessage as ChatMessageType } from '../../types/chat.types';
|
|
|
|
interface ChatMessageProps {
|
|
message: ChatMessageType;
|
|
}
|
|
|
|
export const ChatMessage: React.FC<ChatMessageProps> = ({ message }) => {
|
|
const [copied, setCopied] = useState(false);
|
|
const isUser = message.role === 'user';
|
|
const isAssistant = message.role === 'assistant';
|
|
|
|
const handleCopy = async () => {
|
|
try {
|
|
await navigator.clipboard.writeText(message.content);
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 2000);
|
|
} catch (err) {
|
|
console.error('Failed to copy:', err);
|
|
}
|
|
};
|
|
|
|
const formatTimestamp = (timestamp: string) => {
|
|
const date = new Date(timestamp);
|
|
const now = new Date();
|
|
const diff = now.getTime() - date.getTime();
|
|
const minutes = Math.floor(diff / 60000);
|
|
const hours = Math.floor(diff / 3600000);
|
|
|
|
if (minutes < 1) return 'Just now';
|
|
if (minutes < 60) return `${minutes}m ago`;
|
|
if (hours < 24) return `${hours}h ago`;
|
|
return date.toLocaleDateString();
|
|
};
|
|
|
|
// Simple markdown parser for basic formatting
|
|
const renderContent = (content: string) => {
|
|
// Split by code blocks first
|
|
const parts = content.split(/(```[\s\S]*?```|`[^`]+`)/g);
|
|
|
|
return parts.map((part, index) => {
|
|
// Code block
|
|
if (part.startsWith('```') && part.endsWith('```')) {
|
|
const code = part.slice(3, -3).trim();
|
|
const lines = code.split('\n');
|
|
const language = lines[0].match(/^\w+$/) ? lines.shift() : '';
|
|
|
|
return (
|
|
<pre key={index} className="bg-gray-950 rounded p-3 my-2 overflow-x-auto">
|
|
{language && (
|
|
<div className="text-xs text-gray-400 mb-2">{language}</div>
|
|
)}
|
|
<code className="text-sm text-gray-200">{lines.join('\n')}</code>
|
|
</pre>
|
|
);
|
|
}
|
|
|
|
// Inline code
|
|
if (part.startsWith('`') && part.endsWith('`')) {
|
|
return (
|
|
<code
|
|
key={index}
|
|
className="bg-gray-800 px-1.5 py-0.5 rounded text-sm text-gray-200"
|
|
>
|
|
{part.slice(1, -1)}
|
|
</code>
|
|
);
|
|
}
|
|
|
|
// Regular text with basic formatting
|
|
let formatted = part;
|
|
|
|
// Bold: **text** or __text__
|
|
formatted = formatted.replace(
|
|
/(\*\*|__)(.*?)\1/g,
|
|
'<strong class="font-semibold">$2</strong>'
|
|
);
|
|
|
|
// Italic: *text* or _text_
|
|
formatted = formatted.replace(
|
|
/([*_])(.*?)\1/g,
|
|
'<em class="italic">$2</em>'
|
|
);
|
|
|
|
// Bullet lists
|
|
formatted = formatted.replace(
|
|
/^[•\-*]\s+(.+)$/gm,
|
|
'<li class="ml-4">$1</li>'
|
|
);
|
|
|
|
// Numbered lists
|
|
formatted = formatted.replace(
|
|
/^\d+\.\s+(.+)$/gm,
|
|
'<li class="ml-4">$1</li>'
|
|
);
|
|
|
|
return (
|
|
<span
|
|
key={index}
|
|
dangerouslySetInnerHTML={{ __html: formatted }}
|
|
/>
|
|
);
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className={`flex gap-3 py-4 px-4 group ${
|
|
isUser ? 'justify-end' : 'justify-start'
|
|
}`}
|
|
>
|
|
{/* Avatar */}
|
|
{!isUser && (
|
|
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center">
|
|
<Bot className="w-5 h-5 text-white" />
|
|
</div>
|
|
)}
|
|
|
|
{/* Message Content */}
|
|
<div className={`flex-1 max-w-[80%] ${isUser ? 'order-first' : ''}`}>
|
|
<div
|
|
className={`rounded-lg px-4 py-3 ${
|
|
isUser
|
|
? 'bg-blue-600 text-white ml-auto'
|
|
: 'bg-gray-800 text-gray-100'
|
|
}`}
|
|
>
|
|
{/* Message text */}
|
|
<div className="text-sm whitespace-pre-wrap break-words">
|
|
{renderContent(message.content)}
|
|
</div>
|
|
|
|
{/* Tools used badge */}
|
|
{isAssistant && message.toolsUsed && message.toolsUsed.length > 0 && (
|
|
<div className="mt-2 pt-2 border-t border-gray-700">
|
|
<div className="text-xs text-gray-400">
|
|
Tools: {message.toolsUsed.join(', ')}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Timestamp and actions */}
|
|
<div
|
|
className={`flex items-center gap-2 mt-1 text-xs text-gray-500 ${
|
|
isUser ? 'justify-end' : 'justify-start'
|
|
}`}
|
|
>
|
|
<span>{formatTimestamp(message.timestamp)}</span>
|
|
|
|
{/* Copy button */}
|
|
<button
|
|
onClick={handleCopy}
|
|
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:text-gray-300"
|
|
title="Copy message"
|
|
>
|
|
{copied ? (
|
|
<Check className="w-3 h-3 text-green-400" />
|
|
) : (
|
|
<Copy className="w-3 h-3" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* User Avatar */}
|
|
{isUser && (
|
|
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-gradient-to-br from-green-500 to-teal-600 flex items-center justify-center">
|
|
<User className="w-5 h-5 text-white" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ChatMessage;
|