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 cachedScrollContainer: HTMLElement | null = null;
|
||||||
private cachedBtnWrapper: 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) {
|
constructor(container: HTMLElement, props: ChatPanelProps) {
|
||||||
this.container = container;
|
this.container = container;
|
||||||
this.props = props;
|
this.props = props;
|
||||||
|
|
@ -235,6 +244,7 @@ export class ChatPanel {
|
||||||
const messageEl = historyContainer.createDiv({
|
const messageEl = historyContainer.createDiv({
|
||||||
cls: `message ${msg.role} message-type-${msg.type || 'default'} group/turn-messages`
|
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' });
|
const metaDiv = messageEl.createDiv({ cls: 'chat-meta' });
|
||||||
|
|
@ -434,6 +444,87 @@ export class ChatPanel {
|
||||||
await this.renderHistory();
|
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 {
|
destroy(): void {
|
||||||
if (this.scrollDebounceTimer !== null) {
|
if (this.scrollDebounceTimer !== null) {
|
||||||
window.clearTimeout(this.scrollDebounceTimer);
|
window.clearTimeout(this.scrollDebounceTimer);
|
||||||
|
|
|
||||||
|
|
@ -425,33 +425,42 @@ export class ChatView extends ItemView {
|
||||||
* @param {string} nodeId - ID узла, содержимое которого нужно очистить.
|
* @param {string} nodeId - ID узла, содержимое которого нужно очистить.
|
||||||
*/
|
*/
|
||||||
clearStreamingMessage(nodeId: string): void {
|
clearStreamingMessage(nodeId: string): void {
|
||||||
const messageIndex = this.chatHistory.findIndex((msg) => msg.node_id === nodeId);
|
const message = this.chatHistory.find((msg) => msg.node_id === nodeId);
|
||||||
if (messageIndex !== -1) {
|
if (message) {
|
||||||
this.chatHistory[messageIndex].content = 'Получение ответа...';
|
message.content = 'Получение ответа...';
|
||||||
this.chatHistory[messageIndex].type = 'llm'; // Убедимся, что тип узла LLM
|
message.type = 'llm';
|
||||||
// Если нужно, сбросьте другие флаги
|
|
||||||
}
|
// Точечное обновление вместо полного рендера всей панели
|
||||||
|
if (this.chatPanel) {
|
||||||
if (this.chatPanel) {
|
this.chatPanel.streamUpdate(nodeId, message.content);
|
||||||
this.chatPanel.updateHistory(this.chatHistory);
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
updateStreamingMessage(nodeId: string, content: string): void {
|
updateStreamingMessage(nodeId: string, content: string): void {
|
||||||
const lastMessage = this.chatHistory[this.chatHistory.length - 1];
|
// Надежный поиск по ID, а не по длине массива (предотвращает создание дублей)
|
||||||
if (lastMessage && lastMessage.node_id === nodeId) {
|
let message = this.chatHistory.find((msg) => msg.node_id === nodeId);
|
||||||
lastMessage.content = content;
|
|
||||||
|
if (message) {
|
||||||
|
message.content = content;
|
||||||
|
// Отправляем точечное обновление в панель чата
|
||||||
|
if (this.chatPanel) {
|
||||||
|
this.chatPanel.streamUpdate(nodeId, content);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// Если узла еще нет (первый чанк стрима), добавляем его ОДИН РАЗ
|
||||||
this.chatHistory.push({
|
this.chatHistory.push({
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: content,
|
content: content,
|
||||||
node_id: nodeId,
|
node_id: nodeId,
|
||||||
graph_id: this.selectedGraphId || ''
|
graph_id: this.selectedGraphId || ''
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
// При самом первом появлении отрисовываем штатным способом (создается HTML-контейнер)
|
||||||
if (this.chatPanel) {
|
if (this.chatPanel) {
|
||||||
this.chatPanel.updateHistory(this.chatHistory);
|
this.chatPanel.updateHistory(this.chatHistory);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user