From e15952031db6ea0ca80f72bb226bd5841a791d43 Mon Sep 17 00:00:00 2001 From: Keegan George Date: Thu, 27 Feb 2025 07:32:39 -0800 Subject: [PATCH] UX: Smoother streaming for discoveries (#1154) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## :mag: Overview This update ensures that the streaming for discoveries is smoother, especially on first update. ## ➕ More details To help with smoother streaming, the discovery preview (which was being tracked as a separate property in the JS logic) will be removed and the entire discovery content will be shown/hidden via the existing CSS. The preview was already receiving the full update even though it was visually hidden, so removing the separate property shouldn't have any negative performance hit. Visually hiding it with CSS only will help simplify the component and also allow for smoother streaming. We will instead remove the buffered streaming approach and instead use typing timers similar to what we did for streaming summarization. No related tests as streaming animations are difficult to test. --- .../components/ai-search-discoveries.gjs | 135 ++++++++++-------- .../ai-discobot-discoveries.gjs | 2 +- .../services/discobot-discoveries.js | 2 - .../common/ai-discobot-discoveries.scss | 37 +++-- 4 files changed, 103 insertions(+), 73 deletions(-) diff --git a/assets/javascripts/discourse/components/ai-search-discoveries.gjs b/assets/javascripts/discourse/components/ai-search-discoveries.gjs index 955409dd..fc9e16c9 100644 --- a/assets/javascripts/discourse/components/ai-search-discoveries.gjs +++ b/assets/javascripts/discourse/components/ai-search-discoveries.gjs @@ -2,39 +2,20 @@ import Component from "@glimmer/component"; import { tracked } from "@glimmer/tracking"; import { action } from "@ember/object"; import didInsert from "@ember/render-modifiers/modifiers/did-insert"; +import didUpdate from "@ember/render-modifiers/modifiers/did-update"; import willDestroy from "@ember/render-modifiers/modifiers/will-destroy"; import { cancel, later } from "@ember/runloop"; import { service } from "@ember/service"; import CookText from "discourse/components/cook-text"; import DButton from "discourse/components/d-button"; +import concatClass from "discourse/helpers/concat-class"; import { ajax } from "discourse/lib/ajax"; import { bind } from "discourse/lib/decorators"; import { i18n } from "discourse-i18n"; import AiBlinkingAnimation from "./ai-blinking-animation"; const DISCOVERY_TIMEOUT_MS = 10000; -const BUFFER_WORDS_COUNT = 50; - -function setUpBuffer(discovery, bufferTarget) { - const paragraphs = discovery.split(/\n+/); - let wordCount = 0; - const paragraphBuffer = []; - - for (const paragraph of paragraphs) { - const wordsInParagraph = paragraph.split(/\s+/); - wordCount += wordsInParagraph.length; - - if (wordCount >= bufferTarget) { - paragraphBuffer.push(paragraph.concat("...")); - return paragraphBuffer.join("\n"); - } else { - paragraphBuffer.push(paragraph); - paragraphBuffer.push("\n"); - } - } - - return null; -} +const STREAMED_TEXT_SPEED = 23; export default class AiSearchDiscoveries extends Component { @service search; @@ -43,9 +24,36 @@ export default class AiSearchDiscoveries extends Component { @tracked loadingDiscoveries = false; @tracked hideDiscoveries = false; - @tracked fulldiscoveryToggled = false; + @tracked fullDiscoveryToggled = false; + @tracked discoveryPreviewLength = this.args.discoveryPreviewLength || 150; + + @tracked isStreaming = false; + @tracked streamedText = ""; discoveryTimeout = null; + typingTimer = null; + streamedTextLength = 0; + + typeCharacter() { + if (this.streamedTextLength < this.discobotDiscoveries.discovery.length) { + this.streamedText += this.discobotDiscoveries.discovery.charAt( + this.streamedTextLength + ); + this.streamedTextLength++; + + this.typingTimer = later(this, this.typeCharacter, STREAMED_TEXT_SPEED); + } else { + this.typingTimer = null; + } + } + + onTextUpdate() { + if (this.typingTimer) { + cancel(this.typingTimer); + } + + this.typeCharacter(); + } @bind async _updateDiscovery(update) { @@ -54,28 +62,29 @@ export default class AiSearchDiscoveries extends Component { cancel(this.discoveryTimeout); } - if (!this.discobotDiscoveries.discoveryPreview) { - const buffered = setUpBuffer( - update.ai_discover_reply, - BUFFER_WORDS_COUNT - ); - if (buffered) { - this.discobotDiscoveries.discoveryPreview = buffered; - this.loadingDiscoveries = false; - } + if (!this.discobotDiscoveries.discovery) { + this.discobotDiscoveries.discovery = ""; } + const newText = update.ai_discover_reply; this.discobotDiscoveries.modelUsed = update.model_used; - this.discobotDiscoveries.discovery = update.ai_discover_reply; + this.loadingDiscoveries = false; // Handling short replies. if (update.done) { - if (!this.discobotDiscoveries.discoveryPreview) { - this.discobotDiscoveries.discoveryPreview = update.ai_discover_reply; - } + this.discobotDiscoveries.discovery = newText; + this.streamedText = newText; + this.isStreaming = false; - this.discobotDiscoveries.discovery = update.ai_discover_reply; - this.loadingDiscoveries = false; + // Clear pending animations + if (this.typingTimer) { + cancel(this.typingTimer); + this.typingTimer = null; + } + } else if (newText.length > this.discobotDiscoveries.discovery.length) { + this.discobotDiscoveries.discovery = newText; + this.isStreaming = true; + await this.onTextUpdate(); } } } @@ -101,7 +110,7 @@ export default class AiSearchDiscoveries extends Component { } get toggleLabel() { - if (this.fulldiscoveryToggled) { + if (this.fullDiscoveryToggled) { return "discourse_ai.discobot_discoveries.collapse"; } else { return "discourse_ai.discobot_discoveries.tell_me_more"; @@ -109,21 +118,30 @@ export default class AiSearchDiscoveries extends Component { } get toggleIcon() { - if (this.fulldiscoveryToggled) { + if (this.fullDiscoveryToggled) { return "chevron-up"; } else { return ""; } } - get toggleMakesSense() { + get canShowExpandtoggle() { return ( - this.discobotDiscoveries.discoveryPreview && - this.discobotDiscoveries.discoveryPreview !== - this.discobotDiscoveries.discovery + !this.loadingDiscoveries && + this.renderedDiscovery.length > this.discoveryPreviewLength ); } + get renderedDiscovery() { + return this.isStreaming + ? this.streamedText + : this.discobotDiscoveries.discovery; + } + + get renderPreviewOnly() { + return !this.fullDiscoveryToggled && this.canShowExpandtoggle; + } + @action async triggerDiscovery() { if (this.discobotDiscoveries.lastQuery === this.query) { @@ -153,12 +171,11 @@ export default class AiSearchDiscoveries extends Component { @action toggleDiscovery() { - this.fulldiscoveryToggled = !this.fulldiscoveryToggled; + this.fullDiscoveryToggled = !this.fullDiscoveryToggled; } timeoutDiscovery() { this.loadingDiscoveries = false; - this.discobotDiscoveries.discoveryPreview = ""; this.discobotDiscoveries.discovery = ""; this.discobotDiscoveries.discoveryTimedOut = true; @@ -167,7 +184,8 @@ export default class AiSearchDiscoveries extends Component {