diff --git a/src/components/ChatPanel.ts b/src/components/ChatPanel.ts index fd8a4e7..a904622 100644 --- a/src/components/ChatPanel.ts +++ b/src/components/ChatPanel.ts @@ -69,6 +69,15 @@ export class ChatPanel { private cachedScrollContainer: HTMLElement | null = null; private cachedBtnWrapper: HTMLElement | null = null; + // Состояние для точечного рендера во время стриминга + private streamRenderState = { + isRendering: false, + pendingUpdates: new Map(), + 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 { + // 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); diff --git a/src/views/ChatView.ts b/src/views/ChatView.ts index 80165a9..f06ec5b 100644 --- a/src/views/ChatView.ts +++ b/src/views/ChatView.ts @@ -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); + } } }