UX: Smoother streaming for discoveries (#1154)

## 🔍 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.
This commit is contained in:
Keegan George 2025-02-27 07:32:39 -08:00 committed by GitHub
parent aa13d16022
commit e15952031d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 103 additions and 73 deletions

View File

@ -2,39 +2,20 @@ import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking"; import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object"; import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert"; 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 willDestroy from "@ember/render-modifiers/modifiers/will-destroy";
import { cancel, later } from "@ember/runloop"; import { cancel, later } from "@ember/runloop";
import { service } from "@ember/service"; import { service } from "@ember/service";
import CookText from "discourse/components/cook-text"; import CookText from "discourse/components/cook-text";
import DButton from "discourse/components/d-button"; import DButton from "discourse/components/d-button";
import concatClass from "discourse/helpers/concat-class";
import { ajax } from "discourse/lib/ajax"; import { ajax } from "discourse/lib/ajax";
import { bind } from "discourse/lib/decorators"; import { bind } from "discourse/lib/decorators";
import { i18n } from "discourse-i18n"; import { i18n } from "discourse-i18n";
import AiBlinkingAnimation from "./ai-blinking-animation"; import AiBlinkingAnimation from "./ai-blinking-animation";
const DISCOVERY_TIMEOUT_MS = 10000; const DISCOVERY_TIMEOUT_MS = 10000;
const BUFFER_WORDS_COUNT = 50; const STREAMED_TEXT_SPEED = 23;
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;
}
export default class AiSearchDiscoveries extends Component { export default class AiSearchDiscoveries extends Component {
@service search; @service search;
@ -43,9 +24,36 @@ export default class AiSearchDiscoveries extends Component {
@tracked loadingDiscoveries = false; @tracked loadingDiscoveries = false;
@tracked hideDiscoveries = false; @tracked hideDiscoveries = false;
@tracked fulldiscoveryToggled = false; @tracked fullDiscoveryToggled = false;
@tracked discoveryPreviewLength = this.args.discoveryPreviewLength || 150;
@tracked isStreaming = false;
@tracked streamedText = "";
discoveryTimeout = null; 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 @bind
async _updateDiscovery(update) { async _updateDiscovery(update) {
@ -54,28 +62,29 @@ export default class AiSearchDiscoveries extends Component {
cancel(this.discoveryTimeout); cancel(this.discoveryTimeout);
} }
if (!this.discobotDiscoveries.discoveryPreview) { if (!this.discobotDiscoveries.discovery) {
const buffered = setUpBuffer( this.discobotDiscoveries.discovery = "";
update.ai_discover_reply,
BUFFER_WORDS_COUNT
);
if (buffered) {
this.discobotDiscoveries.discoveryPreview = buffered;
this.loadingDiscoveries = false;
}
} }
const newText = update.ai_discover_reply;
this.discobotDiscoveries.modelUsed = update.model_used; this.discobotDiscoveries.modelUsed = update.model_used;
this.discobotDiscoveries.discovery = update.ai_discover_reply; this.loadingDiscoveries = false;
// Handling short replies. // Handling short replies.
if (update.done) { if (update.done) {
if (!this.discobotDiscoveries.discoveryPreview) { this.discobotDiscoveries.discovery = newText;
this.discobotDiscoveries.discoveryPreview = update.ai_discover_reply; this.streamedText = newText;
} this.isStreaming = false;
this.discobotDiscoveries.discovery = update.ai_discover_reply; // Clear pending animations
this.loadingDiscoveries = false; 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() { get toggleLabel() {
if (this.fulldiscoveryToggled) { if (this.fullDiscoveryToggled) {
return "discourse_ai.discobot_discoveries.collapse"; return "discourse_ai.discobot_discoveries.collapse";
} else { } else {
return "discourse_ai.discobot_discoveries.tell_me_more"; return "discourse_ai.discobot_discoveries.tell_me_more";
@ -109,21 +118,30 @@ export default class AiSearchDiscoveries extends Component {
} }
get toggleIcon() { get toggleIcon() {
if (this.fulldiscoveryToggled) { if (this.fullDiscoveryToggled) {
return "chevron-up"; return "chevron-up";
} else { } else {
return ""; return "";
} }
} }
get toggleMakesSense() { get canShowExpandtoggle() {
return ( return (
this.discobotDiscoveries.discoveryPreview && !this.loadingDiscoveries &&
this.discobotDiscoveries.discoveryPreview !== this.renderedDiscovery.length > this.discoveryPreviewLength
this.discobotDiscoveries.discovery
); );
} }
get renderedDiscovery() {
return this.isStreaming
? this.streamedText
: this.discobotDiscoveries.discovery;
}
get renderPreviewOnly() {
return !this.fullDiscoveryToggled && this.canShowExpandtoggle;
}
@action @action
async triggerDiscovery() { async triggerDiscovery() {
if (this.discobotDiscoveries.lastQuery === this.query) { if (this.discobotDiscoveries.lastQuery === this.query) {
@ -153,12 +171,11 @@ export default class AiSearchDiscoveries extends Component {
@action @action
toggleDiscovery() { toggleDiscovery() {
this.fulldiscoveryToggled = !this.fulldiscoveryToggled; this.fullDiscoveryToggled = !this.fullDiscoveryToggled;
} }
timeoutDiscovery() { timeoutDiscovery() {
this.loadingDiscoveries = false; this.loadingDiscoveries = false;
this.discobotDiscoveries.discoveryPreview = "";
this.discobotDiscoveries.discovery = ""; this.discobotDiscoveries.discovery = "";
this.discobotDiscoveries.discoveryTimedOut = true; this.discobotDiscoveries.discoveryTimedOut = true;
@ -167,7 +184,8 @@ export default class AiSearchDiscoveries extends Component {
<template> <template>
<div <div
class="ai-search-discoveries" class="ai-search-discoveries"
{{didInsert this.subscribe}} {{didInsert this.subscribe @searchTerm}}
{{didUpdate this.subscribe @searchTerm}}
{{didInsert this.triggerDiscovery this.query}} {{didInsert this.triggerDiscovery this.query}}
{{willDestroy this.unsubscribe}} {{willDestroy this.unsubscribe}}
> >
@ -177,19 +195,20 @@ export default class AiSearchDiscoveries extends Component {
{{else if this.discobotDiscoveries.discoveryTimedOut}} {{else if this.discobotDiscoveries.discoveryTimedOut}}
{{i18n "discourse_ai.discobot_discoveries.timed_out"}} {{i18n "discourse_ai.discobot_discoveries.timed_out"}}
{{else}} {{else}}
{{#if this.fulldiscoveryToggled}} <article
<div class="ai-search-discoveries__discovery-full cooked"> class={{concatClass
<CookText @rawText={{this.discobotDiscoveries.discovery}} /> "ai-search-discoveries__discovery"
(if this.renderPreviewOnly "preview")
(if this.isStreaming "streaming")
"streamable-content"
}}
>
<div class="cooked">
<CookText @rawText={{this.renderedDiscovery}} />
</div> </div>
{{else}} </article>
<div class="ai-search-discoveries__discovery-preview cooked">
<CookText
@rawText={{this.discobotDiscoveries.discoveryPreview}}
/>
</div>
{{/if}}
{{#if this.toggleMakesSense}} {{#if this.canShowExpandtoggle}}
<DButton <DButton
class="btn-flat btn-text ai-search-discoveries__toggle" class="btn-flat btn-text ai-search-discoveries__toggle"
@label={{this.toggleLabel}} @label={{this.toggleLabel}}

View File

@ -49,7 +49,7 @@ export default class AiDiscobotDiscoveries extends Component {
</span> </span>
</h3> </h3>
<AiSearchDiscoveries /> <AiSearchDiscoveries @discoveryPreviewLength={{50}} />
<h3 class="ai-search-discoveries__regular-results-title"> <h3 class="ai-search-discoveries__regular-results-title">
{{icon "bars-staggered"}} {{icon "bars-staggered"}}

View File

@ -4,14 +4,12 @@ import Service from "@ember/service";
export default class DiscobotDiscoveries extends Service { export default class DiscobotDiscoveries extends Service {
// We use this to retain state after search menu gets closed. // We use this to retain state after search menu gets closed.
// Similar to discourse/discourse#25504 // Similar to discourse/discourse#25504
@tracked discoveryPreview = "";
@tracked discovery = ""; @tracked discovery = "";
@tracked lastQuery = ""; @tracked lastQuery = "";
@tracked discoveryTimedOut = false; @tracked discoveryTimedOut = false;
@tracked modelUsed = ""; @tracked modelUsed = "";
resetDiscovery() { resetDiscovery() {
this.discoveryPreview = "";
this.discovery = ""; this.discovery = "";
this.discoveryTimedOut = false; this.discoveryTimedOut = false;
this.modelUsed = ""; this.modelUsed = "";

View File

@ -1,3 +1,12 @@
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.ai-search-discoveries { .ai-search-discoveries {
&__regular-results-title { &__regular-results-title {
margin-bottom: 0; margin-bottom: 0;
@ -7,7 +16,8 @@
margin: 0; margin: 0;
} }
&__discovery-preview { &__discovery {
&.preview {
height: 3.5em; // roughly the loading skeleton height height: 3.5em; // roughly the loading skeleton height
overflow: hidden; overflow: hidden;
position: relative; position: relative;
@ -20,6 +30,9 @@
height: 50%; height: 50%;
width: 100%; width: 100%;
bottom: 0; bottom: 0;
opacity: 0;
animation: fade-in 0.5s ease-in forwards;
}
} }
} }