Perf: Optimized Chat Streaming via Partial DOM Updates
This commit is contained in:
parent
cdf45430aa
commit
29a47ed28d
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// Если нужно, сбросьте другие флаги
|
||||
}
|
||||
|
||||
if (this.chatPanel) {
|
||||
this.chatPanel.updateHistory(this.chatHistory);
|
||||
const message = this.chatHistory.find((msg) => msg.node_id === nodeId);
|
||||
if (message) {
|
||||
message.content = 'Получение ответа...';
|
||||
message.type = 'llm';
|
||||
|
||||
// Точечное обновление вместо полного рендера всей панели
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user