diff --git a/assets/javascripts/discourse/components/ai-post-helper-menu.gjs b/assets/javascripts/discourse/components/ai-post-helper-menu.gjs
index ce013a1b..df626e86 100644
--- a/assets/javascripts/discourse/components/ai-post-helper-menu.gjs
+++ b/assets/javascripts/discourse/components/ai-post-helper-menu.gjs
@@ -10,6 +10,7 @@ import CookText from "discourse/components/cook-text";
import DButton from "discourse/components/d-button";
import FastEdit from "discourse/components/fast-edit";
import FastEditModal from "discourse/components/modal/fast-edit";
+import concatClass from "discourse/helpers/concat-class";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { bind } from "discourse/lib/decorators";
@@ -20,6 +21,7 @@ import { i18n } from "discourse-i18n";
import eq from "truth-helpers/helpers/eq";
import AiHelperLoading from "../components/ai-helper-loading";
import AiHelperOptionsList from "../components/ai-helper-options-list";
+import SmoothStreamer from "../lib/smooth-streamer";
export default class AiPostHelperMenu extends Component {
@service messageBus;
@@ -43,6 +45,12 @@ export default class AiPostHelperMenu extends Component {
@tracked isSavingFootnote = false;
@tracked supportsAddFootnote = this.args.data.supportsFastEdit;
+ @tracked
+ smoothStreamer = new SmoothStreamer(
+ () => this.suggestion,
+ (newValue) => (this.suggestion = newValue)
+ );
+
MENU_STATES = {
options: "OPTIONS",
loading: "LOADING",
@@ -172,9 +180,9 @@ export default class AiPostHelperMenu extends Component {
}
@bind
- _updateResult(result) {
+ async _updateResult(result) {
this.streaming = !result.done;
- this.suggestion = result.result;
+ await this.smoothStreamer.updateResult(result, "result");
}
@action
@@ -350,8 +358,18 @@ export default class AiPostHelperMenu extends Component {
{{willDestroy this.unsubscribe}}
>
{{#if this.suggestion}}
-
-
+
+
this.discobotDiscoveries.discovery,
+ (newValue) => (this.discobotDiscoveries.discovery = newValue)
+ );
discoveryTimeout = null;
typingTimer = null;
@@ -53,36 +55,6 @@ export default class AiSearchDiscoveries extends Component {
);
}
- 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() {
- this.cancelTypingTimer();
- this.typeCharacter();
- }
-
- cancelTypingTimer() {
- if (this.typingTimer) {
- cancel(this.typingTimer);
- }
- }
-
- resetStreaming() {
- this.cancelTypingTimer();
- this.streamedText = "";
- this.streamedTextLength = 0;
- }
-
@bind
async _updateDiscovery(update) {
if (this.query === update.query) {
@@ -94,23 +66,9 @@ export default class AiSearchDiscoveries extends Component {
this.discobotDiscoveries.discovery = "";
}
- const newText = update.ai_discover_reply;
this.discobotDiscoveries.modelUsed = update.model_used;
this.loadingDiscoveries = false;
-
- // Handling short replies.
- if (update.done) {
- this.discobotDiscoveries.discovery = newText;
- this.streamedText = newText;
- this.isStreaming = false;
-
- // Clear pending animations
- this.cancelTypingTimer();
- } else if (newText.length > this.discobotDiscoveries.discovery.length) {
- this.discobotDiscoveries.discovery = newText;
- this.isStreaming = true;
- await this.onTextUpdate();
- }
+ this.smoothStreamer.updateResult(update, "ai_discover_reply");
}
}
@@ -153,16 +111,10 @@ export default class AiSearchDiscoveries extends Component {
get canShowExpandtoggle() {
return (
!this.loadingDiscoveries &&
- this.renderedDiscovery.length > this.discoveryPreviewLength
+ this.smoothStreamer.renderedText.length > this.discoveryPreviewLength
);
}
- get renderedDiscovery() {
- return this.isStreaming
- ? this.streamedText
- : this.discobotDiscoveries.discovery;
- }
-
get renderPreviewOnly() {
return !this.fullDiscoveryToggled && this.canShowExpandtoggle;
}
@@ -173,7 +125,7 @@ export default class AiSearchDiscoveries extends Component {
this.hideDiscoveries = false;
return;
} else {
- this.resetStreaming();
+ this.smoothStreamer.resetStreaming();
this.discobotDiscoveries.resetDiscovery();
}
@@ -225,12 +177,12 @@ export default class AiSearchDiscoveries extends Component {
class={{concatClass
"ai-search-discoveries__discovery"
(if this.renderPreviewOnly "preview")
- (if this.isStreaming "streaming")
+ (if this.smoothStreamer.isStreaming "streaming")
"streamable-content"
}}
>
-
+
diff --git a/assets/javascripts/discourse/components/modal/ai-summary-modal.gjs b/assets/javascripts/discourse/components/modal/ai-summary-modal.gjs
index 51e78145..5c634997 100644
--- a/assets/javascripts/discourse/components/modal/ai-summary-modal.gjs
+++ b/assets/javascripts/discourse/components/modal/ai-summary-modal.gjs
@@ -5,7 +5,6 @@ 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 { not } from "truth-helpers";
import CookText from "discourse/components/cook-text";
@@ -20,8 +19,7 @@ import { shortDateNoYear } from "discourse/lib/formatter";
import { i18n } from "discourse-i18n";
import DTooltip from "float-kit/components/d-tooltip";
import AiSummarySkeleton from "../../components/ai-summary-skeleton";
-
-const STREAMED_TEXT_SPEED = 15;
+import SmoothStreamer from "../../lib/smooth-streamer";
export default class AiSummaryModal extends Component {
@service siteSettings;
@@ -37,11 +35,12 @@ export default class AiSummaryModal extends Component {
@tracked outdated = false;
@tracked canRegenerate = false;
@tracked loading = false;
- @tracked isStreaming = false;
- @tracked streamedText = "";
@tracked currentIndex = 0;
- typingTimer = null;
- streamedTextLength = 0;
+ @tracked
+ smoothStreamer = new SmoothStreamer(
+ () => this.text,
+ (newValue) => (this.text = newValue)
+ );
get outdatedSummaryWarningText() {
let outdatedText = i18n("summary.outdated");
@@ -57,7 +56,7 @@ export default class AiSummaryModal extends Component {
}
resetSummary() {
- this.streamedText = "";
+ this.smoothStreamer.resetStreaming();
this.currentIndex = 0;
this.text = "";
this.summarizedOn = null;
@@ -142,26 +141,6 @@ export default class AiSummaryModal extends Component {
});
}
- typeCharacter() {
- if (this.streamedTextLength < this.text.length) {
- this.streamedText += this.text.charAt(this.streamedTextLength);
- this.streamedTextLength++;
-
- this.typingTimer = later(this, this.typeCharacter, STREAMED_TEXT_SPEED);
- } else {
- this.typingTimer = null;
- }
- }
-
- onTextUpdate() {
- // Reset only if there’s a new summary to process
- if (this.typingTimer) {
- cancel(this.typingTimer);
- }
-
- this.typeCharacter();
- }
-
@bind
async _updateSummary(update) {
const topicSummary = {
@@ -169,14 +148,11 @@ export default class AiSummaryModal extends Component {
raw: update.ai_topic_summary?.summarized_text,
...update.ai_topic_summary,
};
- const newText = topicSummary.raw || "";
+
this.loading = false;
+ this.smoothStreamer.updateResult(topicSummary, "raw");
if (update.done) {
- this.text = newText;
- this.streamedText = newText;
- this.displayedTextLength = newText.length;
- this.isStreaming = false;
this.summarizedOn = shortDateNoYear(
moment(topicSummary.updated_at, "YYYY-MM-DD HH:mm:ss Z")
);
@@ -184,16 +160,6 @@ export default class AiSummaryModal extends Component {
this.newPostsSinceSummary = topicSummary.new_posts_since_summary;
this.outdated = topicSummary.outdated;
this.canRegenerate = topicSummary.outdated && topicSummary.can_regenerate;
-
- // Clear pending animations
- if (this.typingTimer) {
- cancel(this.typingTimer);
- this.typingTimer = null;
- }
- } else if (newText.length > this.text.length) {
- this.text = newText;
- this.isStreaming = true;
- this.onTextUpdate();
}
}
@@ -226,14 +192,14 @@ export default class AiSummaryModal extends Component {
class={{concatClass
"ai-summary-box"
"streamable-content"
- (if this.isStreaming "streaming")
+ (if this.smoothStreamer.isStreaming "streaming")
}}
>
{{#if this.loading}}
{{else}}
-
+
{{/if}}
diff --git a/assets/javascripts/discourse/lib/smooth-streamer.gjs b/assets/javascripts/discourse/lib/smooth-streamer.gjs
new file mode 100644
index 00000000..7ce83afa
--- /dev/null
+++ b/assets/javascripts/discourse/lib/smooth-streamer.gjs
@@ -0,0 +1,116 @@
+import { tracked } from "@glimmer/tracking";
+import { cancel, later } from "@ember/runloop";
+
+const DEFAULT_TYPING_DELAY = 15;
+
+/**
+ * SmoothStreamer provides a typing animation effect for streamed text updates.
+ */
+export default class SmoothStreamer {
+ @tracked isStreaming = false;
+ @tracked streamedText = "";
+ typingTimer = null;
+ streamedTextLength = 0;
+
+ /**
+ * @param {() => string} getRealtimeText - Function to retrieve the latest realtime text
+ * @param {(value: string) => void} setRealtimeText - Function to update the realtime text
+ * @param {number} [typingDelay] - Delay (in ms) between each character reveal
+ */
+ constructor(getRealtimeText, setRealtimeText, typingDelay) {
+ this.getRealtimeText = getRealtimeText;
+ this.setRealtimeText = setRealtimeText;
+ this.typingDelay = typingDelay || DEFAULT_TYPING_DELAY;
+ }
+
+ /**
+ * Retrieves the appropriate text: either the animated stream or the full realtime text.
+ * @returns {string}
+ */
+ get renderedText() {
+ return this.isStreaming ? this.streamedText : this.realtimeText;
+ }
+
+ /**
+ * Retrieves the current realtime text.
+ * @returns {string}
+ */
+ get realtimeText() {
+ return this.getRealtimeText();
+ }
+
+ /**
+ * Updates the realtime text.
+ * @param {string} value - The new text value
+ */
+ set realtimeText(value) {
+ this.setRealtimeText(value);
+ }
+
+ /**
+ * Resets the streaming state, clearing all animation progress.
+ */
+ resetStreaming() {
+ this.#cancelTypingTimer();
+ this.isStreaming = false;
+ this.streamedText = "";
+ this.streamedTextLength = 0;
+ }
+
+ /**
+ * Processes an update result (typically from MessageBus)
+ * either completing the stream or continuing animation.
+ * @param {object} result - The result object containing the new text and status
+ * @param {string} newTextKey - The key in result that holds the new text value (e.g. if the JSON is { text: "Hello", done: false }, newTextKey would be "text")
+ */
+ async updateResult(result, newTextKey) {
+ const newText = result[newTextKey];
+
+ if (result?.done) {
+ this.streamedText = newText;
+ this.realtimeText = newText;
+ this.isStreaming = false;
+
+ // Clear pending animations
+ this.#cancelTypingTimer();
+ } else if (newText.length > this.realtimeText.length) {
+ this.realtimeText = newText;
+ this.isStreaming = true;
+ await this.#onTextUpdate();
+ }
+ }
+
+ /**
+ * Types out the next character in the streaming text.
+ * Private method.
+ */
+ #typeCharacter() {
+ if (this.streamedTextLength < this.realtimeText.length) {
+ this.streamedText += this.realtimeText.charAt(this.streamedTextLength);
+ this.streamedTextLength++;
+
+ this.typingTimer = later(this, this.#typeCharacter, this.typingDelay);
+ } else {
+ this.typingTimer = null;
+ }
+ }
+
+ /**
+ * Handles text updates and restarts the typing animation.
+ * Private method.
+ */
+ #onTextUpdate() {
+ this.#cancelTypingTimer();
+ this.#typeCharacter();
+ }
+
+ /**
+ * Cancels any pending typing animation.
+ * Private method.
+ */
+ #cancelTypingTimer() {
+ if (this.typingTimer) {
+ cancel(this.typingTimer);
+ }
+ }
+}
diff --git a/test/javascripts/unit/lib/smooth-streamer-test.js b/test/javascripts/unit/lib/smooth-streamer-test.js
new file mode 100644
index 00000000..411a4f61
--- /dev/null
+++ b/test/javascripts/unit/lib/smooth-streamer-test.js
@@ -0,0 +1,106 @@
+import { later } from "@ember/runloop";
+import { settled } from "@ember/test-helpers";
+import { setupTest } from "ember-qunit";
+import { module, test } from "qunit";
+import SmoothStreamer from "discourse/plugins/discourse-ai/discourse/lib/smooth-streamer";
+
+module("Discourse AI | Unit | Lib | smooth-streamer", function (hooks) {
+ setupTest(hooks);
+
+ test("it initializes correctly", function (assert) {
+ let mockText = "";
+ const getRealtimeText = () => mockText;
+ const setRealtimeText = (val) => (mockText = val);
+
+ const streamer = new SmoothStreamer(getRealtimeText, setRealtimeText);
+
+ assert.false(streamer.isStreaming, "isStreaming should be false initially");
+ assert.strictEqual(
+ streamer.renderedText,
+ "",
+ "renderedText should be empty initially"
+ );
+ });
+
+ test("it streams text with animation", async function (assert) {
+ let mockText = "";
+ const getRealtimeText = () => mockText;
+ const setRealtimeText = (val) => (mockText = val);
+
+ const streamer = new SmoothStreamer(getRealtimeText, setRealtimeText);
+ const result1 = { text: "Hello", done: false };
+
+ await streamer.updateResult(result1, "text");
+ assert.true(
+ streamer.isStreaming,
+ "isStreaming should be true while animating"
+ );
+
+ await settled();
+ assert.strictEqual(
+ streamer.realtimeText,
+ "Hello",
+ "Realtime text should be updated immediately"
+ );
+ assert.ok(
+ streamer.streamedText.length > 0,
+ "Streamed text should start appearing"
+ );
+
+ await new Promise((resolve) => later(resolve, 50));
+ assert.strictEqual(
+ streamer.streamedText,
+ "Hello",
+ "Streamed text should fully appear"
+ );
+ });
+
+ test("it stops streaming when done", async function (assert) {
+ let mockText = "";
+ const getRealtimeText = () => mockText;
+ const setRealtimeText = (val) => (mockText = val);
+
+ const streamer = new SmoothStreamer(getRealtimeText, setRealtimeText);
+ await streamer.updateResult({ text: "Done text", done: true }, "text");
+
+ assert.false(streamer.isStreaming, "isStreaming should be false when done");
+ assert.strictEqual(
+ streamer.streamedText,
+ "Done text",
+ "Streamed text should be complete"
+ );
+ assert.strictEqual(
+ streamer.realtimeText,
+ "Done text",
+ "Realtime text should match the final input"
+ );
+ });
+
+ test("resetStreaming clears all progress", function (assert) {
+ let mockText = "Some text";
+ const getRealtimeText = () => mockText;
+ const setRealtimeText = (val) => (mockText = val);
+
+ const streamer = new SmoothStreamer(getRealtimeText, setRealtimeText);
+ streamer.streamedText = "Some text";
+ streamer.streamedTextLength = 9;
+ streamer.isStreaming = true;
+
+ streamer.resetStreaming();
+
+ assert.strictEqual(
+ streamer.streamedText,
+ "",
+ "Streamed text should be cleared"
+ );
+ assert.strictEqual(
+ streamer.streamedTextLength,
+ 0,
+ "Streamed text length should be reset"
+ );
+ assert.false(
+ streamer.isStreaming,
+ "isStreaming should be false after reset"
+ );
+ });
+});