Perf: Optimized Chat Streaming via Partial DOM Updates

This commit is contained in:
dimitrievgs 2026-05-16 21:29:01 +03:00
parent cdf45430aa
commit 29a47ed28d
2 changed files with 116 additions and 16 deletions

View File

@ -69,6 +69,15 @@ export class ChatPanel {
private cachedScrollContainer: HTMLElement | null = null;
private cachedBtnWrapper: HTMLElement | null = null;
// Состояние для точечного рендера во время стриминга
private streamRenderState = {
isRendering: false,
pendingUpdates: new Map<string, string>(),
lastRenderTime: 0,
throttleMs: 300,
timerId: null as number | null
};
constructor(container: HTMLElement, props: ChatPanelProps) {
this.container = container;
this.props = props;
@ -235,6 +244,7 @@ export class ChatPanel {
const messageEl = historyContainer.createDiv({
cls: `message ${msg.role} message-type-${msg.type || 'default'} group/turn-messages`
});
messageEl.setAttribute('data-node-id', msg.node_id);
// Метаинформация
const metaDiv = messageEl.createDiv({ cls: 'chat-meta' });
@ -434,6 +444,87 @@ export class ChatPanel {
await this.renderHistory();
}
/**
* Принимает запрос на точечное обновление сообщения (вызывается из ChatView)
*/
streamUpdate(nodeId: string, content: string): void {
this.streamRenderState.pendingUpdates.set(nodeId, content);
this.processStreamUpdates();
}
/**
* Обрабатывает очередь так, чтобы рендер происходил не чаще чем раз в 300мс
* и дожидался окончания предыдущей отрисовки.
*/
private async processStreamUpdates(): Promise<void> {
// 1. Блокировка: если предыдущий рендер ещё идет, прерываемся (оно вызовется само в конце)
if (this.streamRenderState.isRendering) return;
const now = Date.now();
const timeSinceLastRender = now - this.streamRenderState.lastRenderTime;
// 2. Блокировка 300мс: если прошло недостаточно времени, ждем остаток времени
if (timeSinceLastRender < this.streamRenderState.throttleMs) {
if (!this.streamRenderState.timerId) {
this.streamRenderState.timerId = window.setTimeout(() => {
this.streamRenderState.timerId = null;
this.processStreamUpdates();
}, this.streamRenderState.throttleMs - timeSinceLastRender);
}
return;
}
// Если обновлять нечего - выходим
if (this.streamRenderState.pendingUpdates.size === 0) return;
// 3. Начинаем рендер
this.streamRenderState.isRendering = true;
// Забираем и очищаем очередь задач
const updates = new Map(this.streamRenderState.pendingUpdates);
this.streamRenderState.pendingUpdates.clear();
for (const [nodeId, content] of updates.entries()) {
// Находим контейнер по атрибуту
const messageEl = this.container.querySelector(`.message[data-node-id="${nodeId}"]`);
if (!messageEl) continue;
const contentDiv = messageEl.querySelector('.chat-message-content') as HTMLElement;
if (!contentDiv) continue;
// Создаем невидимый div для фонового рендера (чтобы высота текущего блока не схлопывалась в 0)
const tempDiv = document.createElement('div');
// Вызываем тяжелый маркдаун Обсидиана и ЖДЕМ
await MarkdownRenderer.renderMarkdown(content, tempDiv, '', this.props.plugin);
// Моментально перебрасываем готовый DOM
contentDiv.innerHTML = '';
while (tempDiv.firstChild) {
contentDiv.appendChild(tempDiv.firstChild);
}
}
// Запоминаем время окончания, снимаем флаг
this.streamRenderState.lastRenderTime = Date.now();
this.streamRenderState.isRendering = false;
// Плавный автоскролл вниз при потоковом выводе
const scrollContainer = this.container.closest('.view-content') as HTMLElement;
if (scrollContainer) {
// Скроллим только если пользователь не улетел высоко наверх читать старое
const isNearBottom = scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight < 100;
if (isNearBottom) {
scrollContainer.scrollTop = scrollContainer.scrollHeight;
}
}
// Если за время рендера/ожидания прилетели новые чанки - запускаем по кругу
if (this.streamRenderState.pendingUpdates.size > 0) {
this.processStreamUpdates();
}
}
destroy(): void {
if (this.scrollDebounceTimer !== null) {
window.clearTimeout(this.scrollDebounceTimer);

View File

@ -425,33 +425,42 @@ export class ChatView extends ItemView {
* @param {string} nodeId - ID узла, содержимое которого нужно очистить.
*/
clearStreamingMessage(nodeId: string): void {
const messageIndex = this.chatHistory.findIndex((msg) => msg.node_id === nodeId);
if (messageIndex !== -1) {
this.chatHistory[messageIndex].content = 'Получение ответа...';
this.chatHistory[messageIndex].type = 'llm'; // Убедимся, что тип узла LLM
// Если нужно, сбросьте другие флаги
}
const message = this.chatHistory.find((msg) => msg.node_id === nodeId);
if (message) {
message.content = 'Получение ответа...';
message.type = 'llm';
if (this.chatPanel) {
this.chatPanel.updateHistory(this.chatHistory);
// Точечное обновление вместо полного рендера всей панели
if (this.chatPanel) {
this.chatPanel.streamUpdate(nodeId, message.content);
}
}
}
updateStreamingMessage(nodeId: string, content: string): void {
const lastMessage = this.chatHistory[this.chatHistory.length - 1];
if (lastMessage && lastMessage.node_id === nodeId) {
lastMessage.content = content;
// Надежный поиск по ID, а не по длине массива (предотвращает создание дублей)
let message = this.chatHistory.find((msg) => msg.node_id === nodeId);
if (message) {
message.content = content;
// Отправляем точечное обновление в панель чата
if (this.chatPanel) {
this.chatPanel.streamUpdate(nodeId, content);
}
} else {
// Если узла еще нет (первый чанк стрима), добавляем его ОДИН РАЗ
this.chatHistory.push({
role: 'assistant',
content: content,
node_id: nodeId,
graph_id: this.selectedGraphId || ''
});
}
if (this.chatPanel) {
this.chatPanel.updateHistory(this.chatHistory);
// При самом первом появлении отрисовываем штатным способом (создается HTML-контейнер)
if (this.chatPanel) {
this.chatPanel.updateHistory(this.chatHistory);
}
}
}