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" + ); + }); +});