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 { 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 {
<template>
<div
class="ai-search-discoveries"
{{didInsert this.subscribe}}
{{didInsert this.subscribe @searchTerm}}
{{didUpdate this.subscribe @searchTerm}}
{{didInsert this.triggerDiscovery this.query}}
{{willDestroy this.unsubscribe}}
>
@ -177,19 +195,20 @@ export default class AiSearchDiscoveries extends Component {
{{else if this.discobotDiscoveries.discoveryTimedOut}}
{{i18n "discourse_ai.discobot_discoveries.timed_out"}}
{{else}}
{{#if this.fulldiscoveryToggled}}
<div class="ai-search-discoveries__discovery-full cooked">
<CookText @rawText={{this.discobotDiscoveries.discovery}} />
<article
class={{concatClass
"ai-search-discoveries__discovery"
(if this.renderPreviewOnly "preview")
(if this.isStreaming "streaming")
"streamable-content"
}}
>
<div class="cooked">
<CookText @rawText={{this.renderedDiscovery}} />
</div>
{{else}}
<div class="ai-search-discoveries__discovery-preview cooked">
<CookText
@rawText={{this.discobotDiscoveries.discoveryPreview}}
/>
</div>
{{/if}}
</article>
{{#if this.toggleMakesSense}}
{{#if this.canShowExpandtoggle}}
<DButton
class="btn-flat btn-text ai-search-discoveries__toggle"
@label={{this.toggleLabel}}

View File

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

View File

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

View File

@ -1,3 +1,12 @@
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.ai-search-discoveries {
&__regular-results-title {
margin-bottom: 0;
@ -7,19 +16,23 @@
margin: 0;
}
&__discovery-preview {
height: 3.5em; // roughly the loading skeleton height
overflow: hidden;
position: relative;
&__discovery {
&.preview {
height: 3.5em; // roughly the loading skeleton height
overflow: hidden;
position: relative;
&::after {
content: "";
position: absolute;
display: block;
background: linear-gradient(rgba(255, 255, 255, 0), var(--secondary));
height: 50%;
width: 100%;
bottom: 0;
&::after {
content: "";
position: absolute;
display: block;
background: linear-gradient(rgba(255, 255, 255, 0), var(--secondary));
height: 50%;
width: 100%;
bottom: 0;
opacity: 0;
animation: fade-in 0.5s ease-in forwards;
}
}
}