Message.tsx (5524B)
1 import { useState, useCallback } from "react"; 2 import type { Message as MessageType } from "../../store/chat"; 3 import { MarkdownRenderer } from "./MarkdownRenderer"; 4 import { ToolCall } from "../ToolCall/ToolCall"; 5 6 interface MessageProps { 7 message: MessageType; 8 onEdit?: (id: string, newContent: string) => void; 9 onRegenerate?: (id: string) => void; 10 showForkButton?: boolean; 11 onFork?: () => void; 12 } 13 14 export function Message({ message, onEdit, onRegenerate }: MessageProps) { 15 const [isEditing, setIsEditing] = useState(false); 16 const [editContent, setEditContent] = useState(message.content); 17 18 const isUser = message.role === "user"; 19 const showActions = (isUser || !isUser) && (onEdit || onRegenerate); 20 21 const handleSave = useCallback(() => { 22 if (editContent.trim() && onEdit) { 23 onEdit(message.id, editContent.trim()); 24 } 25 setIsEditing(false); 26 }, [editContent, message.id, onEdit]); 27 28 const handleCancel = useCallback(() => { 29 setEditContent(message.content); 30 setIsEditing(false); 31 }, [message.content]); 32 33 const handleKeyDown = useCallback( 34 (e: React.KeyboardEvent) => { 35 if (e.key === "Escape") handleCancel(); 36 if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) handleSave(); 37 }, 38 [handleCancel, handleSave], 39 ); 40 41 return ( 42 <div className={`flex ${isUser ? "justify-end" : "justify-start"}`}> 43 <div 44 className={`group relative max-w-[80%] rounded-2xl px-4 py-3 ${ 45 isUser ? "bg-gray-800 text-gray-100" : "bg-gray-900 text-gray-100" 46 }`} 47 > 48 {showActions && ( 49 <div className="absolute -top-8 left-0 hidden gap-1 group-hover:flex"> 50 {isUser && onEdit && ( 51 <button 52 type="button" 53 aria-label="Edit message" 54 onClick={() => setIsEditing(true)} 55 className="rounded bg-gray-700 px-2 py-1 text-xs text-gray-300 hover:bg-gray-600" 56 > 57 ✏️ 58 </button> 59 )} 60 {!isUser && onRegenerate && ( 61 <button 62 type="button" 63 aria-label="Regenerate response" 64 onClick={() => onRegenerate(message.id)} 65 className="rounded bg-gray-700 px-2 py-1 text-xs text-gray-300 hover:bg-gray-600" 66 > 67 🔄 68 </button> 69 )} 70 </div> 71 )} 72 73 {message.toolCalls.length > 0 && ( 74 <div className="mb-3 space-y-1"> 75 {message.toolCalls.map((tc) => ( 76 <ToolCall key={tc.id} toolCall={tc} /> 77 ))} 78 </div> 79 )} 80 81 {isEditing ? ( 82 <div className="space-y-2"> 83 <textarea 84 value={editContent} 85 onChange={(e) => setEditContent(e.target.value)} 86 onKeyDown={handleKeyDown} 87 className="w-full resize-none rounded border border-gray-600 bg-gray-950 px-2 py-1 text-sm text-gray-100 focus:border-gray-400 focus:outline-none" 88 rows={3} 89 /> 90 <div className="flex gap-2"> 91 <button 92 type="button" 93 aria-label="Save edit" 94 onClick={handleSave} 95 className="rounded bg-blue-600 px-2 py-1 text-xs text-white hover:bg-blue-500" 96 > 97 Save 98 </button> 99 <button 100 type="button" 101 onClick={handleCancel} 102 className="rounded bg-gray-700 px-2 py-1 text-xs text-gray-300 hover:bg-gray-600" 103 > 104 Cancel 105 </button> 106 </div> 107 </div> 108 ) : ( 109 <> 110 {isUser && 111 message.attachments && 112 message.attachments.filter((a) => a.content_type.startsWith("image/")) 113 .length > 0 && ( 114 <div className="mb-2 flex flex-wrap gap-2"> 115 {message.attachments 116 .filter((a) => a.content_type.startsWith("image/")) 117 .map((att) => ( 118 <a 119 key={att.id} 120 href={`/api/attachments/${att.id}`} 121 target="_blank" 122 rel="noopener noreferrer" 123 > 124 <img 125 src={`/api/attachments/${att.id}`} 126 alt={att.filename} 127 className="max-h-[200px] w-auto rounded object-cover" 128 /> 129 </a> 130 ))} 131 </div> 132 )} 133 134 {message.content && ( 135 <div className="text-sm leading-relaxed"> 136 {isUser ? ( 137 <p className="whitespace-pre-wrap">{message.content}</p> 138 ) : ( 139 <MarkdownRenderer content={message.content} /> 140 )} 141 </div> 142 )} 143 </> 144 )} 145 146 {message.model && ( 147 <div className="mt-2 text-xs text-gray-500">{message.model}</div> 148 )} 149 150 {message.forkId && message.originalContent && ( 151 <div className="mt-2 text-xs"> 152 <button 153 type="button" 154 onClick={() => alert(message.originalContent)} 155 className="text-blue-400 hover:text-blue-300" 156 > 157 View original 158 </button> 159 </div> 160 )} 161 </div> 162 </div> 163 ); 164 }