Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions frontend/src/components/chatPanel/ChatWindow.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
};
Expand Down Expand Up @@ -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,
Expand Down
109 changes: 78 additions & 31 deletions frontend/src/components/chatPanel/Message.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
<div className={messageClass}>
{sender === 'ai' ? (
<>
<div className="message-source-toggle">
<MessageCopyButton text={text} />
<button
className={`message-source-btn ${showSource ? 'message-source-btn--active' : ''}`}
onClick={() => setShowSource((v) => !v)}
title={showSource ? t('message.viewFormatted') : t('message.viewSource')}
>
{showSource ? `◈ ${t('message.btnMarkdown')}` : `{ } ${t('message.btnSource')}`}
</button>
showSource ? (
<pre className="message-raw-source">{text}</pre>
) : (
<div className="md-preview md-preview--chat">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={getMarkdownComponents(onNavigateToDoc)}>
{text}
</ReactMarkdown>
</div>
{showSource ? (
<pre className="message-raw-source">{text}</pre>
) : (
<div className="md-preview md-preview--chat">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={getMarkdownComponents(onNavigateToDoc)}>
{text}
</ReactMarkdown>
</div>
)}
</>
)
) : (
<>
<div className="message-toolbar message-toolbar--user">
<MessageCopyButton text={text} />
</div>
<div className="user-message-text">{text}</div>
</>
<div className="user-message-text">{text}</div>
)}
</div>
);

// Футер под пузырём: AI — время слева, кнопки справа;
// user — кнопка слева, время справа.
const footer =
sender === 'ai' ? (
<div className="message-footer message-footer--ai">
{timeLabel && (
<span className="message-footer-time" title={timeTitle ?? undefined}>
{timeLabel}
</span>
)}
<div className="message-footer-actions">
<MessageCopyButton text={text} />
<button
className={`message-source-btn ${showSource ? 'message-source-btn--active' : ''}`}
onClick={() => setShowSource((v) => !v)}
title={showSource ? t('message.viewFormatted') : t('message.viewSource')}
>
{showSource ? `◈ ${t('message.btnMarkdown')}` : `{ } ${t('message.btnSource')}`}
</button>
</div>
</div>
) : (
<div className="message-footer message-footer--user">
<MessageCopyButton text={text} />
{timeLabel && (
<span className="message-footer-time" title={timeTitle ?? undefined}>
{timeLabel}
</span>
)}
</div>
);

const messageBlock = (
<div className={`message-block message-block--${sender}`}>
{bubble}
{footer}
</div>
);

if (hasToolCalls && sender === 'ai') {
return (
<div className="message-row-with-tools">
<div className="message-main-col">
{messageContent}
{messageBlock}
<DocChangeBlock toolCalls={toolCalls} onNavigateToDoc={onNavigateToDoc} />
{showPreparing && <ToolPreparingIndicator />}
</div>
Expand All @@ -526,13 +573,13 @@ const Message = ({ text, sender, toolCalls, toolCallsRunId, preparing, conversat
if (showPreparing) {
return (
<>
{messageContent}
{messageBlock}
<ToolPreparingIndicator />
</>
);
}

return messageContent;
return messageBlock;
};

export default Message;
1 change: 1 addition & 0 deletions frontend/src/components/chatPanel/MessageList.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/chatPanel/chatEventReducer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

Expand Down Expand Up @@ -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 };
}

Expand Down
20 changes: 15 additions & 5 deletions frontend/src/components/chatPanel/styles/bubbles.css
Original file line number Diff line number Diff line change
@@ -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;
}

Expand All @@ -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;
}
9 changes: 6 additions & 3 deletions frontend/src/components/chatPanel/styles/tool-calls.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
44 changes: 24 additions & 20 deletions frontend/src/components/chatPanel/styles/toolbar.css
Original file line number Diff line number Diff line change
@@ -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;
}

/* ── Кнопка «копировать всё сообщение» ── */
Expand All @@ -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;
Expand Down