Skip to content
This repository was archived by the owner on Jul 22, 2025. It is now read-only.

Commit 5264641

Browse files
committed
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.
1 parent 54ff07e commit 5264641

File tree

4 files changed

+103
-73
lines changed

4 files changed

+103
-73
lines changed

assets/javascripts/discourse/components/ai-search-discoveries.gjs

Lines changed: 77 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -2,39 +2,20 @@ import Component from "@glimmer/component";
22
import { tracked } from "@glimmer/tracking";
33
import { action } from "@ember/object";
44
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
5+
import didUpdate from "@ember/render-modifiers/modifiers/did-update";
56
import willDestroy from "@ember/render-modifiers/modifiers/will-destroy";
67
import { cancel, later } from "@ember/runloop";
78
import { service } from "@ember/service";
89
import CookText from "discourse/components/cook-text";
910
import DButton from "discourse/components/d-button";
11+
import concatClass from "discourse/helpers/concat-class";
1012
import { ajax } from "discourse/lib/ajax";
1113
import { bind } from "discourse/lib/decorators";
1214
import { i18n } from "discourse-i18n";
1315
import AiBlinkingAnimation from "./ai-blinking-animation";
1416

1517
const DISCOVERY_TIMEOUT_MS = 10000;
16-
const BUFFER_WORDS_COUNT = 50;
17-
18-
function setUpBuffer(discovery, bufferTarget) {
19-
const paragraphs = discovery.split(/\n+/);
20-
let wordCount = 0;
21-
const paragraphBuffer = [];
22-
23-
for (const paragraph of paragraphs) {
24-
const wordsInParagraph = paragraph.split(/\s+/);
25-
wordCount += wordsInParagraph.length;
26-
27-
if (wordCount >= bufferTarget) {
28-
paragraphBuffer.push(paragraph.concat("..."));
29-
return paragraphBuffer.join("\n");
30-
} else {
31-
paragraphBuffer.push(paragraph);
32-
paragraphBuffer.push("\n");
33-
}
34-
}
35-
36-
return null;
37-
}
18+
const STREAMED_TEXT_SPEED = 23;
3819

3920
export default class AiSearchDiscoveries extends Component {
4021
@service search;
@@ -43,9 +24,36 @@ export default class AiSearchDiscoveries extends Component {
4324

4425
@tracked loadingDiscoveries = false;
4526
@tracked hideDiscoveries = false;
46-
@tracked fulldiscoveryToggled = false;
27+
@tracked fullDiscoveryToggled = false;
28+
@tracked discoveryPreviewLength = this.args.discoveryPreviewLength || 150;
29+
30+
@tracked isStreaming = false;
31+
@tracked streamedText = "";
4732

4833
discoveryTimeout = null;
34+
typingTimer = null;
35+
streamedTextLength = 0;
36+
37+
typeCharacter() {
38+
if (this.streamedTextLength < this.discobotDiscoveries.discovery.length) {
39+
this.streamedText += this.discobotDiscoveries.discovery.charAt(
40+
this.streamedTextLength
41+
);
42+
this.streamedTextLength++;
43+
44+
this.typingTimer = later(this, this.typeCharacter, STREAMED_TEXT_SPEED);
45+
} else {
46+
this.typingTimer = null;
47+
}
48+
}
49+
50+
onTextUpdate() {
51+
if (this.typingTimer) {
52+
cancel(this.typingTimer);
53+
}
54+
55+
this.typeCharacter();
56+
}
4957

5058
@bind
5159
async _updateDiscovery(update) {
@@ -54,28 +62,29 @@ export default class AiSearchDiscoveries extends Component {
5462
cancel(this.discoveryTimeout);
5563
}
5664

57-
if (!this.discobotDiscoveries.discoveryPreview) {
58-
const buffered = setUpBuffer(
59-
update.ai_discover_reply,
60-
BUFFER_WORDS_COUNT
61-
);
62-
if (buffered) {
63-
this.discobotDiscoveries.discoveryPreview = buffered;
64-
this.loadingDiscoveries = false;
65-
}
65+
if (!this.discobotDiscoveries.discovery) {
66+
this.discobotDiscoveries.discovery = "";
6667
}
6768

69+
const newText = update.ai_discover_reply;
6870
this.discobotDiscoveries.modelUsed = update.model_used;
69-
this.discobotDiscoveries.discovery = update.ai_discover_reply;
71+
this.loadingDiscoveries = false;
7072

7173
// Handling short replies.
7274
if (update.done) {
73-
if (!this.discobotDiscoveries.discoveryPreview) {
74-
this.discobotDiscoveries.discoveryPreview = update.ai_discover_reply;
75+
this.discobotDiscoveries.discovery = newText;
76+
this.streamedText = newText;
77+
this.isStreaming = false;
78+
79+
// Clear pending animations
80+
if (this.typingTimer) {
81+
cancel(this.typingTimer);
82+
this.typingTimer = null;
7583
}
76-
77-
this.discobotDiscoveries.discovery = update.ai_discover_reply;
78-
this.loadingDiscoveries = false;
84+
} else if (newText.length > this.discobotDiscoveries.discovery.length) {
85+
this.discobotDiscoveries.discovery = newText;
86+
this.isStreaming = true;
87+
await this.onTextUpdate();
7988
}
8089
}
8190
}
@@ -101,29 +110,38 @@ export default class AiSearchDiscoveries extends Component {
101110
}
102111

103112
get toggleLabel() {
104-
if (this.fulldiscoveryToggled) {
113+
if (this.fullDiscoveryToggled) {
105114
return "discourse_ai.discobot_discoveries.collapse";
106115
} else {
107116
return "discourse_ai.discobot_discoveries.tell_me_more";
108117
}
109118
}
110119

111120
get toggleIcon() {
112-
if (this.fulldiscoveryToggled) {
121+
if (this.fullDiscoveryToggled) {
113122
return "chevron-up";
114123
} else {
115124
return "";
116125
}
117126
}
118127

119-
get toggleMakesSense() {
128+
get canShowExpandtoggle() {
120129
return (
121-
this.discobotDiscoveries.discoveryPreview &&
122-
this.discobotDiscoveries.discoveryPreview !==
123-
this.discobotDiscoveries.discovery
130+
!this.loadingDiscoveries &&
131+
this.renderedDiscovery.length > this.discoveryPreviewLength
124132
);
125133
}
126134

135+
get renderedDiscovery() {
136+
return this.isStreaming
137+
? this.streamedText
138+
: this.discobotDiscoveries.discovery;
139+
}
140+
141+
get renderPreviewOnly() {
142+
return !this.fullDiscoveryToggled && this.canShowExpandtoggle;
143+
}
144+
127145
@action
128146
async triggerDiscovery() {
129147
if (this.discobotDiscoveries.lastQuery === this.query) {
@@ -153,12 +171,11 @@ export default class AiSearchDiscoveries extends Component {
153171

154172
@action
155173
toggleDiscovery() {
156-
this.fulldiscoveryToggled = !this.fulldiscoveryToggled;
174+
this.fullDiscoveryToggled = !this.fullDiscoveryToggled;
157175
}
158176

159177
timeoutDiscovery() {
160178
this.loadingDiscoveries = false;
161-
this.discobotDiscoveries.discoveryPreview = "";
162179
this.discobotDiscoveries.discovery = "";
163180

164181
this.discobotDiscoveries.discoveryTimedOut = true;
@@ -167,7 +184,8 @@ export default class AiSearchDiscoveries extends Component {
167184
<template>
168185
<div
169186
class="ai-search-discoveries"
170-
{{didInsert this.subscribe}}
187+
{{didInsert this.subscribe @searchTerm}}
188+
{{didUpdate this.subscribe @searchTerm}}
171189
{{didInsert this.triggerDiscovery this.query}}
172190
{{willDestroy this.unsubscribe}}
173191
>
@@ -177,19 +195,20 @@ export default class AiSearchDiscoveries extends Component {
177195
{{else if this.discobotDiscoveries.discoveryTimedOut}}
178196
{{i18n "discourse_ai.discobot_discoveries.timed_out"}}
179197
{{else}}
180-
{{#if this.fulldiscoveryToggled}}
181-
<div class="ai-search-discoveries__discovery-full cooked">
182-
<CookText @rawText={{this.discobotDiscoveries.discovery}} />
183-
</div>
184-
{{else}}
185-
<div class="ai-search-discoveries__discovery-preview cooked">
186-
<CookText
187-
@rawText={{this.discobotDiscoveries.discoveryPreview}}
188-
/>
198+
<article
199+
class={{concatClass
200+
"ai-search-discoveries__discovery"
201+
(if this.renderPreviewOnly "preview")
202+
(if this.isStreaming "streaming")
203+
"streamable-content"
204+
}}
205+
>
206+
<div class="cooked">
207+
<CookText @rawText={{this.renderedDiscovery}} />
189208
</div>
190-
{{/if}}
209+
</article>
191210

192-
{{#if this.toggleMakesSense}}
211+
{{#if this.canShowExpandtoggle}}
193212
<DButton
194213
class="btn-flat btn-text ai-search-discoveries__toggle"
195214
@label={{this.toggleLabel}}

assets/javascripts/discourse/connectors/search-menu-results-type-top/ai-discobot-discoveries.gjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ export default class AiDiscobotDiscoveries extends Component {
4949
</span>
5050
</h3>
5151

52-
<AiSearchDiscoveries />
52+
<AiSearchDiscoveries @discoveryPreviewLength={{50}} />
5353

5454
<h3 class="ai-search-discoveries__regular-results-title">
5555
{{icon "bars-staggered"}}

assets/javascripts/discourse/services/discobot-discoveries.js

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,12 @@ import Service from "@ember/service";
44
export default class DiscobotDiscoveries extends Service {
55
// We use this to retain state after search menu gets closed.
66
// Similar to discourse/discourse#25504
7-
@tracked discoveryPreview = "";
87
@tracked discovery = "";
98
@tracked lastQuery = "";
109
@tracked discoveryTimedOut = false;
1110
@tracked modelUsed = "";
1211

1312
resetDiscovery() {
14-
this.discoveryPreview = "";
1513
this.discovery = "";
1614
this.discoveryTimedOut = false;
1715
this.modelUsed = "";

assets/stylesheets/modules/ai-bot/common/ai-discobot-discoveries.scss

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
1+
@keyframes fade-in {
2+
from {
3+
opacity: 0;
4+
}
5+
to {
6+
opacity: 1;
7+
}
8+
}
9+
110
.ai-search-discoveries {
211
&__regular-results-title {
312
margin-bottom: 0;
@@ -7,19 +16,23 @@
716
margin: 0;
817
}
918

10-
&__discovery-preview {
11-
height: 3.5em; // roughly the loading skeleton height
12-
overflow: hidden;
13-
position: relative;
19+
&__discovery {
20+
&.preview {
21+
height: 3.5em; // roughly the loading skeleton height
22+
overflow: hidden;
23+
position: relative;
1424

15-
&::after {
16-
content: "";
17-
position: absolute;
18-
display: block;
19-
background: linear-gradient(rgba(255, 255, 255, 0), var(--secondary));
20-
height: 50%;
21-
width: 100%;
22-
bottom: 0;
25+
&::after {
26+
content: "";
27+
position: absolute;
28+
display: block;
29+
background: linear-gradient(rgba(255, 255, 255, 0), var(--secondary));
30+
height: 50%;
31+
width: 100%;
32+
bottom: 0;
33+
opacity: 0;
34+
animation: fade-in 0.5s ease-in forwards;
35+
}
2336
}
2437
}
2538

0 commit comments

Comments
 (0)