@@ -5,8 +5,9 @@ import { action } from "@ember/object";
55import didInsert from " @ember/render-modifiers/modifiers/did-insert" ;
66import didUpdate from " @ember/render-modifiers/modifiers/did-update" ;
77import willDestroy from " @ember/render-modifiers/modifiers/will-destroy" ;
8- import { next } from " @ember/runloop" ;
8+ import { cancel , later } from " @ember/runloop" ;
99import { service } from " @ember/service" ;
10+ import CookText from " discourse/components/cook-text" ;
1011import DButton from " discourse/components/d-button" ;
1112import concatClass from " discourse/helpers/concat-class" ;
1213import { ajax } from " discourse/lib/ajax" ;
@@ -18,8 +19,8 @@ import I18n from "discourse-i18n";
1819import DMenu from " float-kit/components/d-menu" ;
1920import DTooltip from " float-kit/components/d-tooltip" ;
2021import AiSummarySkeleton from " ../../components/ai-summary-skeleton" ;
21- import streamUpdaterText from " ../../lib/ai-streamer/progress-handlers " ;
22- import SummaryUpdater from " ../../lib/ai-streamer/updaters/summary-updater " ;
22+
23+ const STREAMED_TEXT_SPEED = 15 ;
2324
2425export default class AiSummaryBox extends Component {
2526 @service siteSettings;
@@ -35,8 +36,10 @@ export default class AiSummaryBox extends Component {
3536 @tracked canRegenerate = false ;
3637 @tracked loading = false ;
3738 @tracked isStreaming = false ;
38- oldRaw = null ; // used for comparison in SummaryUpdater in lib/ai-streamer/updaters
39- finalSummary = null ;
39+ @tracked streamedText = " " ;
40+ @tracked currentIndex = 0 ;
41+ typingTimer = null ;
42+ streamedTextLength = 0 ;
4043
4144 get outdatedSummaryWarningText () {
4245 let outdatedText = I18n .t (" summary.outdated" );
@@ -52,6 +55,8 @@ export default class AiSummaryBox extends Component {
5255 }
5356
5457 resetSummary () {
58+ this .streamedText = " " ;
59+ this .currentIndex = 0 ;
5560 this .text = " " ;
5661 this .summarizedOn = null ;
5762 this .summarizedBy = null ;
@@ -83,8 +88,7 @@ export default class AiSummaryBox extends Component {
8388 }
8489 const channel = ` /discourse-ai/summaries/topic/${ this .args .outletArgs .topic .id } ` ;
8590 this ._channel = channel;
86- // we attempt to recover the last message in the bus so we subscrcibe at -2
87- this .messageBus .subscribe (channel, this ._updateSummary , - 2 );
91+ this .messageBus .subscribe (channel, this ._updateSummary );
8892 }
8993
9094 @bind
@@ -134,36 +138,63 @@ export default class AiSummaryBox extends Component {
134138 return ajax (url).then ((data ) => {
135139 if (data? .ai_topic_summary ? .summarized_text ) {
136140 data .done = true ;
137- // Our streamer won't display the summary unless the summary box is in the DOM.
138- // Wait for the next runloop or cached summaries won't appear.
139- next (() => this ._updateSummary (data));
141+ this ._updateSummary (data);
140142 }
141143 });
142144 }
143145
146+ typeCharacter () {
147+ if (this .streamedTextLength < this .text .length ) {
148+ this .streamedText += this .text .charAt (this .streamedTextLength );
149+ this .streamedTextLength ++ ;
150+
151+ this .typingTimer = later (this , this .typeCharacter , STREAMED_TEXT_SPEED );
152+ } else {
153+ this .typingTimer = null ;
154+ }
155+ }
156+
157+ onTextUpdate () {
158+ // Reset only if there’s a new summary to process
159+ if (this .typingTimer ) {
160+ cancel (this .typingTimer );
161+ }
162+
163+ this .typeCharacter ();
164+ }
165+
144166 @bind
145- _updateSummary (update ) {
167+ async _updateSummary (update ) {
146168 const topicSummary = {
147169 done: update .done ,
148170 raw: update .ai_topic_summary ? .summarized_text ,
149171 ... update .ai_topic_summary ,
150172 };
173+ const newText = topicSummary .raw || " " ;
151174 this .loading = false ;
152175
153- this .isStreaming = true ;
154- streamUpdaterText (SummaryUpdater, topicSummary, this );
155-
156176 if (update .done ) {
177+ this .text = newText;
178+ this .streamedText = newText;
179+ this .displayedTextLength = newText .length ;
157180 this .isStreaming = false ;
158- this .text = this .finalSummary ;
159181 this .summarizedOn = shortDateNoYear (
160182 moment (topicSummary .updated_at , " YYYY-MM-DD HH:mm:ss Z" )
161183 );
162184 this .summarizedBy = topicSummary .algorithm ;
163185 this .newPostsSinceSummary = topicSummary .new_posts_since_summary ;
164186 this .outdated = topicSummary .outdated ;
165- this .newPostsSinceSummary = topicSummary .new_posts_since_summary ;
166187 this .canRegenerate = topicSummary .outdated && topicSummary .can_regenerate ;
188+
189+ // Clear pending animations
190+ if (this .typingTimer ) {
191+ cancel (this .typingTimer );
192+ this .typingTimer = null ;
193+ }
194+ } else if (newText .length > this .text .length ) {
195+ this .text = newText;
196+ this .isStreaming = true ;
197+ this .onTextUpdate ();
167198 }
168199 }
169200
@@ -228,7 +259,7 @@ export default class AiSummaryBox extends Component {
228259 <AiSummarySkeleton />
229260 {{else }}
230261 <div class =" generated-summary cooked" >
231- {{this .text }}
262+ < CookText @ rawText = {{this .streamedText }} />
232263 </div >
233264 {{#if this . summarizedOn }}
234265 <div class =" summarized-on" >
0 commit comments