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

Commit 6827d63

Browse files
authored
DEV: Streaming animation API for components (#1224)
This takes the logic used in summarization/discoveries for streaming and consolidates it into a single helper lib for smooth streaming. It introduces a new lib: `SmoothStreamer` that can be used by components for smooth streaming text from message bus updates. Additionally, the PR makes use of that new lib in the AI post menu helper.
1 parent 60fe924 commit 6827d63

File tree

5 files changed

+266
-108
lines changed

5 files changed

+266
-108
lines changed

assets/javascripts/discourse/components/ai-post-helper-menu.gjs

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import CookText from "discourse/components/cook-text";
1010
import DButton from "discourse/components/d-button";
1111
import FastEdit from "discourse/components/fast-edit";
1212
import FastEditModal from "discourse/components/modal/fast-edit";
13+
import concatClass from "discourse/helpers/concat-class";
1314
import { ajax } from "discourse/lib/ajax";
1415
import { popupAjaxError } from "discourse/lib/ajax-error";
1516
import { bind } from "discourse/lib/decorators";
@@ -20,6 +21,7 @@ import { i18n } from "discourse-i18n";
2021
import eq from "truth-helpers/helpers/eq";
2122
import AiHelperLoading from "../components/ai-helper-loading";
2223
import AiHelperOptionsList from "../components/ai-helper-options-list";
24+
import SmoothStreamer from "../lib/smooth-streamer";
2325

2426
export default class AiPostHelperMenu extends Component {
2527
@service messageBus;
@@ -43,6 +45,12 @@ export default class AiPostHelperMenu extends Component {
4345
@tracked isSavingFootnote = false;
4446
@tracked supportsAddFootnote = this.args.data.supportsFastEdit;
4547

48+
@tracked
49+
smoothStreamer = new SmoothStreamer(
50+
() => this.suggestion,
51+
(newValue) => (this.suggestion = newValue)
52+
);
53+
4654
MENU_STATES = {
4755
options: "OPTIONS",
4856
loading: "LOADING",
@@ -172,9 +180,9 @@ export default class AiPostHelperMenu extends Component {
172180
}
173181

174182
@bind
175-
_updateResult(result) {
183+
async _updateResult(result) {
176184
this.streaming = !result.done;
177-
this.suggestion = result.result;
185+
await this.smoothStreamer.updateResult(result, "result");
178186
}
179187

180188
@action
@@ -350,8 +358,18 @@ export default class AiPostHelperMenu extends Component {
350358
{{willDestroy this.unsubscribe}}
351359
>
352360
{{#if this.suggestion}}
353-
<div class="ai-post-helper__suggestion__text" dir="auto">
354-
<CookText @rawText={{this.suggestion}} />
361+
<div
362+
class={{concatClass
363+
(if this.smoothStreamer.isStreaming "streaming")
364+
"streamable-content"
365+
"ai-post-helper__suggestion__text"
366+
}}
367+
dir="auto"
368+
>
369+
<CookText
370+
@rawText={{this.smoothStreamer.renderedText}}
371+
class="cooked"
372+
/>
355373
</div>
356374
<div class="ai-post-helper__suggestion__buttons">
357375
<DButton

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

Lines changed: 11 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,10 @@ import concatClass from "discourse/helpers/concat-class";
1212
import { ajax } from "discourse/lib/ajax";
1313
import { bind } from "discourse/lib/decorators";
1414
import { i18n } from "discourse-i18n";
15+
import SmoothStreamer from "../lib/smooth-streamer";
1516
import AiBlinkingAnimation from "./ai-blinking-animation";
1617

1718
const DISCOVERY_TIMEOUT_MS = 10000;
18-
const STREAMED_TEXT_SPEED = 23;
1919

2020
export default class AiSearchDiscoveries extends Component {
2121
@service search;
@@ -27,9 +27,11 @@ export default class AiSearchDiscoveries extends Component {
2727
@tracked hideDiscoveries = false;
2828
@tracked fullDiscoveryToggled = false;
2929
@tracked discoveryPreviewLength = this.args.discoveryPreviewLength || 150;
30-
31-
@tracked isStreaming = false;
32-
@tracked streamedText = "";
30+
@tracked
31+
smoothStreamer = new SmoothStreamer(
32+
() => this.discobotDiscoveries.discovery,
33+
(newValue) => (this.discobotDiscoveries.discovery = newValue)
34+
);
3335

3436
discoveryTimeout = null;
3537
typingTimer = null;
@@ -53,36 +55,6 @@ export default class AiSearchDiscoveries extends Component {
5355
);
5456
}
5557

56-
typeCharacter() {
57-
if (this.streamedTextLength < this.discobotDiscoveries.discovery.length) {
58-
this.streamedText += this.discobotDiscoveries.discovery.charAt(
59-
this.streamedTextLength
60-
);
61-
this.streamedTextLength++;
62-
63-
this.typingTimer = later(this, this.typeCharacter, STREAMED_TEXT_SPEED);
64-
} else {
65-
this.typingTimer = null;
66-
}
67-
}
68-
69-
onTextUpdate() {
70-
this.cancelTypingTimer();
71-
this.typeCharacter();
72-
}
73-
74-
cancelTypingTimer() {
75-
if (this.typingTimer) {
76-
cancel(this.typingTimer);
77-
}
78-
}
79-
80-
resetStreaming() {
81-
this.cancelTypingTimer();
82-
this.streamedText = "";
83-
this.streamedTextLength = 0;
84-
}
85-
8658
@bind
8759
async _updateDiscovery(update) {
8860
if (this.query === update.query) {
@@ -94,23 +66,9 @@ export default class AiSearchDiscoveries extends Component {
9466
this.discobotDiscoveries.discovery = "";
9567
}
9668

97-
const newText = update.ai_discover_reply;
9869
this.discobotDiscoveries.modelUsed = update.model_used;
9970
this.loadingDiscoveries = false;
100-
101-
// Handling short replies.
102-
if (update.done) {
103-
this.discobotDiscoveries.discovery = newText;
104-
this.streamedText = newText;
105-
this.isStreaming = false;
106-
107-
// Clear pending animations
108-
this.cancelTypingTimer();
109-
} else if (newText.length > this.discobotDiscoveries.discovery.length) {
110-
this.discobotDiscoveries.discovery = newText;
111-
this.isStreaming = true;
112-
await this.onTextUpdate();
113-
}
71+
this.smoothStreamer.updateResult(update, "ai_discover_reply");
11472
}
11573
}
11674

@@ -153,16 +111,10 @@ export default class AiSearchDiscoveries extends Component {
153111
get canShowExpandtoggle() {
154112
return (
155113
!this.loadingDiscoveries &&
156-
this.renderedDiscovery.length > this.discoveryPreviewLength
114+
this.smoothStreamer.renderedText.length > this.discoveryPreviewLength
157115
);
158116
}
159117

160-
get renderedDiscovery() {
161-
return this.isStreaming
162-
? this.streamedText
163-
: this.discobotDiscoveries.discovery;
164-
}
165-
166118
get renderPreviewOnly() {
167119
return !this.fullDiscoveryToggled && this.canShowExpandtoggle;
168120
}
@@ -173,7 +125,7 @@ export default class AiSearchDiscoveries extends Component {
173125
this.hideDiscoveries = false;
174126
return;
175127
} else {
176-
this.resetStreaming();
128+
this.smoothStreamer.resetStreaming();
177129
this.discobotDiscoveries.resetDiscovery();
178130
}
179131

@@ -225,12 +177,12 @@ export default class AiSearchDiscoveries extends Component {
225177
class={{concatClass
226178
"ai-search-discoveries__discovery"
227179
(if this.renderPreviewOnly "preview")
228-
(if this.isStreaming "streaming")
180+
(if this.smoothStreamer.isStreaming "streaming")
229181
"streamable-content"
230182
}}
231183
>
232184
<div class="cooked">
233-
<CookText @rawText={{this.renderedDiscovery}} />
185+
<CookText @rawText={{this.smoothStreamer.renderedText}} />
234186
</div>
235187
</article>
236188

assets/javascripts/discourse/components/modal/ai-summary-modal.gjs

Lines changed: 11 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import { action } from "@ember/object";
55
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
66
import didUpdate from "@ember/render-modifiers/modifiers/did-update";
77
import willDestroy from "@ember/render-modifiers/modifiers/will-destroy";
8-
import { cancel, later } from "@ember/runloop";
98
import { service } from "@ember/service";
109
import { not } from "truth-helpers";
1110
import CookText from "discourse/components/cook-text";
@@ -20,8 +19,7 @@ import { shortDateNoYear } from "discourse/lib/formatter";
2019
import { i18n } from "discourse-i18n";
2120
import DTooltip from "float-kit/components/d-tooltip";
2221
import AiSummarySkeleton from "../../components/ai-summary-skeleton";
23-
24-
const STREAMED_TEXT_SPEED = 15;
22+
import SmoothStreamer from "../../lib/smooth-streamer";
2523

2624
export default class AiSummaryModal extends Component {
2725
@service siteSettings;
@@ -37,11 +35,12 @@ export default class AiSummaryModal extends Component {
3735
@tracked outdated = false;
3836
@tracked canRegenerate = false;
3937
@tracked loading = false;
40-
@tracked isStreaming = false;
41-
@tracked streamedText = "";
4238
@tracked currentIndex = 0;
43-
typingTimer = null;
44-
streamedTextLength = 0;
39+
@tracked
40+
smoothStreamer = new SmoothStreamer(
41+
() => this.text,
42+
(newValue) => (this.text = newValue)
43+
);
4544

4645
get outdatedSummaryWarningText() {
4746
let outdatedText = i18n("summary.outdated");
@@ -57,7 +56,7 @@ export default class AiSummaryModal extends Component {
5756
}
5857

5958
resetSummary() {
60-
this.streamedText = "";
59+
this.smoothStreamer.resetStreaming();
6160
this.currentIndex = 0;
6261
this.text = "";
6362
this.summarizedOn = null;
@@ -142,58 +141,25 @@ export default class AiSummaryModal extends Component {
142141
});
143142
}
144143

145-
typeCharacter() {
146-
if (this.streamedTextLength < this.text.length) {
147-
this.streamedText += this.text.charAt(this.streamedTextLength);
148-
this.streamedTextLength++;
149-
150-
this.typingTimer = later(this, this.typeCharacter, STREAMED_TEXT_SPEED);
151-
} else {
152-
this.typingTimer = null;
153-
}
154-
}
155-
156-
onTextUpdate() {
157-
// Reset only if there’s a new summary to process
158-
if (this.typingTimer) {
159-
cancel(this.typingTimer);
160-
}
161-
162-
this.typeCharacter();
163-
}
164-
165144
@bind
166145
async _updateSummary(update) {
167146
const topicSummary = {
168147
done: update.done,
169148
raw: update.ai_topic_summary?.summarized_text,
170149
...update.ai_topic_summary,
171150
};
172-
const newText = topicSummary.raw || "";
151+
173152
this.loading = false;
153+
this.smoothStreamer.updateResult(topicSummary, "raw");
174154

175155
if (update.done) {
176-
this.text = newText;
177-
this.streamedText = newText;
178-
this.displayedTextLength = newText.length;
179-
this.isStreaming = false;
180156
this.summarizedOn = shortDateNoYear(
181157
moment(topicSummary.updated_at, "YYYY-MM-DD HH:mm:ss Z")
182158
);
183159
this.summarizedBy = topicSummary.algorithm;
184160
this.newPostsSinceSummary = topicSummary.new_posts_since_summary;
185161
this.outdated = topicSummary.outdated;
186162
this.canRegenerate = topicSummary.outdated && topicSummary.can_regenerate;
187-
188-
// Clear pending animations
189-
if (this.typingTimer) {
190-
cancel(this.typingTimer);
191-
this.typingTimer = null;
192-
}
193-
} else if (newText.length > this.text.length) {
194-
this.text = newText;
195-
this.isStreaming = true;
196-
this.onTextUpdate();
197163
}
198164
}
199165

@@ -226,14 +192,14 @@ export default class AiSummaryModal extends Component {
226192
class={{concatClass
227193
"ai-summary-box"
228194
"streamable-content"
229-
(if this.isStreaming "streaming")
195+
(if this.smoothStreamer.isStreaming "streaming")
230196
}}
231197
>
232198
{{#if this.loading}}
233199
<AiSummarySkeleton />
234200
{{else}}
235201
<div class="generated-summary cooked">
236-
<CookText @rawText={{this.streamedText}} />
202+
<CookText @rawText={{this.smoothStreamer.renderedText}} />
237203
</div>
238204
{{/if}}
239205
</article>

0 commit comments

Comments
 (0)