diff --git a/frontend/src/components/chatPanel/ChatWindow.jsx b/frontend/src/components/chatPanel/ChatWindow.jsx index c1b2e4e..c676819 100644 --- a/frontend/src/components/chatPanel/ChatWindow.jsx +++ b/frontend/src/components/chatPanel/ChatWindow.jsx @@ -73,7 +73,7 @@ const transformPage = (rawMsgs) => { continue; // преамбулу как сообщение не рендерим } if (type !== 'user') sawAi = true; - bubbles.push({ text: m.content, sender: type === 'user' ? 'user' : 'ai' }); + bubbles.push({ text: m.content, sender: type === 'user' ? 'user' : 'ai', timestamp: m.timestamp || null }); } return { bubbles, leadingMetas }; }; @@ -523,7 +523,10 @@ const ChatWindow = ({ onNavigateToDoc, isActive = true, activeChatId: propActive setChats((prev) => { const found = prev.find((c) => c.id === activeChatId); if (!found) return prev; - const newMessages = [...(found.messages || []), { text, sender: 'user', clientMsgId }]; + const newMessages = [ + ...(found.messages || []), + { text, sender: 'user', clientMsgId, timestamp: new Date().toISOString() }, + ]; const updatedChat = { ...found, id: conversationId, diff --git a/frontend/src/components/chatPanel/Message.jsx b/frontend/src/components/chatPanel/Message.jsx index 3d51494..8e3302c 100644 --- a/frontend/src/components/chatPanel/Message.jsx +++ b/frontend/src/components/chatPanel/Message.jsx @@ -468,53 +468,100 @@ function getMarkdownComponents(onNavigateToDoc) { }; } -const Message = ({ text, sender, toolCalls, toolCallsRunId, preparing, conversationId, onNavigateToDoc }) => { +/** Форматирует timestamp: если < 24ч — относительное время, иначе — дата. */ +const formatTimestamp = (ts) => { + if (!ts) return null; + const date = new Date(ts); + if (isNaN(date)) return null; + const diffMs = Date.now() - date.getTime(); + const diffMin = Math.floor(diffMs / 60000); + if (diffMs < 0) return null; + if (diffMin < 1) return '< 1 мин.'; + if (diffMin < 60) return `${diffMin} мин. назад`; + const diffH = Math.floor(diffMin / 60); + if (diffH < 24) return `${diffH} ч. назад`; + return date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' }); +}; + +const formatFullDatetime = (ts) => { + if (!ts) return null; + const date = new Date(ts); + if (isNaN(date)) return null; + return date.toLocaleString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit' }); +}; + +const Message = ({ text, sender, toolCalls, toolCallsRunId, preparing, conversationId, onNavigateToDoc, timestamp }) => { const { t } = useTranslation('chat'); const [showSource, setShowSource] = useState(false); const messageClass = `message ${sender}`; const hasToolCalls = toolCalls && toolCalls.length > 0; const showPreparing = preparing && sender === 'ai'; + const timeLabel = formatTimestamp(timestamp); + const timeTitle = formatFullDatetime(timestamp); - const messageContent = ( + // Пузырь — только контент сообщения, без футера + const bubble = (
{sender === 'ai' ? ( - <> -
- - + showSource ? ( +
{text}
+ ) : ( +
+ + {text} +
- {showSource ? ( -
{text}
- ) : ( -
- - {text} - -
- )} - + ) ) : ( - <> -
- -
-
{text}
- +
{text}
)}
); + // Футер под пузырём: AI — время слева, кнопки справа; + // user — кнопка слева, время справа. + const footer = + sender === 'ai' ? ( +
+ {timeLabel && ( + + {timeLabel} + + )} +
+ + +
+
+ ) : ( +
+ + {timeLabel && ( + + {timeLabel} + + )} +
+ ); + + const messageBlock = ( +
+ {bubble} + {footer} +
+ ); + if (hasToolCalls && sender === 'ai') { return (
- {messageContent} + {messageBlock} {showPreparing && }
@@ -526,13 +573,13 @@ const Message = ({ text, sender, toolCalls, toolCallsRunId, preparing, conversat if (showPreparing) { return ( <> - {messageContent} + {messageBlock} ); } - return messageContent; + return messageBlock; }; export default Message; diff --git a/frontend/src/components/chatPanel/MessageList.jsx b/frontend/src/components/chatPanel/MessageList.jsx index ca26fa5..7ee0667 100644 --- a/frontend/src/components/chatPanel/MessageList.jsx +++ b/frontend/src/components/chatPanel/MessageList.jsx @@ -135,6 +135,7 @@ const MessageList = ({ text={msg.text} sender={msg.sender} toolCalls={msg.toolCalls} + timestamp={msg.timestamp} toolCallsRunId={msg.toolCallsRunId} preparing={msg.preparing} conversationId={conversationId} diff --git a/frontend/src/components/chatPanel/chatEventReducer.js b/frontend/src/components/chatPanel/chatEventReducer.js index 54fe95e..7479eda 100644 --- a/frontend/src/components/chatPanel/chatEventReducer.js +++ b/frontend/src/components/chatPanel/chatEventReducer.js @@ -53,7 +53,7 @@ const lastAiIndexForRun = (msgs, runId) => { }; const pushAi = (msgs, runId) => { - msgs.push({ text: '', sender: 'ai', runId, toolCalls: [] }); + msgs.push({ text: '', sender: 'ai', runId, toolCalls: [], timestamp: new Date().toISOString() }); return msgs.length - 1; }; @@ -97,7 +97,7 @@ export function applyChatEvent(chat, ev, ctx) { // Дубликат после reload: сообщение уже подгрузилось из БД (хвост истории). const last = msgs[msgs.length - 1]; if (last && last.sender === 'user' && last.text === payload?.text) return chat; - msgs.push({ text: payload?.text || '', sender: 'user' }); + msgs.push({ text: payload?.text || '', sender: 'user', timestamp: new Date().toISOString() }); return { ...chat, messages: msgs }; } diff --git a/frontend/src/components/chatPanel/styles/bubbles.css b/frontend/src/components/chatPanel/styles/bubbles.css index 039239c..439556a 100644 --- a/frontend/src/components/chatPanel/styles/bubbles.css +++ b/frontend/src/components/chatPanel/styles/bubbles.css @@ -1,19 +1,30 @@ +/* Обёртка пузырь + футер — управляет выравниванием в колонке */ +.message-block { + display: flex; + flex-direction: column; + max-width: 75%; + margin-bottom: 10px; +} +.message-block--ai { + align-self: flex-start; +} +.message-block--user { + align-self: flex-end; + margin-left: auto; +} + /* Базовые стили для любого сообщения */ .message { padding: 0.7rem 1rem; border-radius: 14px; - max-width: 75%; line-height: 1.5; word-wrap: break-word; - margin-bottom: 10px; } /* Сообщение от пользователя */ .message.user { background-color: var(--kb-user-bubble-bg); color: var(--kb-user-bubble-color); - align-self: flex-end; - margin-left: auto; border-bottom-right-radius: 4px; } @@ -26,7 +37,6 @@ .message.ai { background-color: var(--kb-ai-bubble-bg); color: var(--kb-ai-bubble-color); - align-self: flex-start; border-bottom-left-radius: 4px; white-space: normal; } diff --git a/frontend/src/components/chatPanel/styles/tool-calls.css b/frontend/src/components/chatPanel/styles/tool-calls.css index 9aab8d3..52e1ba8 100644 --- a/frontend/src/components/chatPanel/styles/tool-calls.css +++ b/frontend/src/components/chatPanel/styles/tool-calls.css @@ -16,10 +16,13 @@ flex-direction: column; } -/* Пузырь заполняет колонку без вложенного max-width:75% */ -.message-row-with-tools .message-main-col > .message { - align-self: stretch; +/* Блок (пузырь + футер) заполняет колонку без вложенного max-width:75% */ +.message-row-with-tools .message-main-col > .message-block { max-width: 100%; + margin-bottom: 0; +} +.message-row-with-tools .message-main-col > .message-block > .message { + align-self: stretch; } .tool-call-notifications { diff --git a/frontend/src/components/chatPanel/styles/toolbar.css b/frontend/src/components/chatPanel/styles/toolbar.css index 2feaa67..d931ce8 100644 --- a/frontend/src/components/chatPanel/styles/toolbar.css +++ b/frontend/src/components/chatPanel/styles/toolbar.css @@ -1,20 +1,34 @@ -/* ── Source toggle ── */ -.message-source-toggle { +/* ── Футер под пузырём: время + кнопки ── + Рендерится ВНЕ пузыря (sibling .message), на фоне чата. */ +.message-footer { display: flex; align-items: center; - justify-content: flex-end; - gap: 8px; - margin-bottom: 4px; + gap: 6px; + padding: 2px 2px 0; } -/* Тулбар сверху сообщения (кнопка копирования) */ -.message-toolbar { +/* AI: время слева, кнопки справа */ +.message-footer--ai { + justify-content: space-between; +} + +/* User: кнопка слева, время справа (зеркально относительно AI) */ +.message-footer--user { + justify-content: space-between; +} + +.message-footer-actions { display: flex; align-items: center; - margin-bottom: 4px; + gap: 6px; } -.message-toolbar--user { - justify-content: flex-end; + +.message-footer-time { + font-size: 0.68rem; + color: #aaa; + white-space: nowrap; + user-select: none; + cursor: default; } /* ── Кнопка «копировать всё сообщение» ── */ @@ -41,16 +55,6 @@ color: var(--kb-success); } -/* На синем пузыре пользователя — светлые тона */ -.message.user .message-copy-btn { - color: rgba(255, 255, 255, 0.8); -} -.message.user .message-copy-btn:hover { - color: #fff; - background: rgba(255, 255, 255, 0.18); - border-color: rgba(255, 255, 255, 0.35); -} - /* ── Кнопка переключения источника ── */ .message-source-btn { background: none;