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.
This commit is contained in:
Keegan George 2025-03-27 08:06:33 -07:00 committed by GitHub
parent 60fe924ce0
commit 6827d63e30
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 266 additions and 108 deletions

View File

@ -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}}
<div class="ai-post-helper__suggestion__text" dir="auto">
<CookText @rawText={{this.suggestion}} />
<div
class={{concatClass
(if this.smoothStreamer.isStreaming "streaming")
"streamable-content"
"ai-post-helper__suggestion__text"
}}
dir="auto"
>
<CookText
@rawText={{this.smoothStreamer.renderedText}}
class="cooked"
/>
</div>
<div class="ai-post-helper__suggestion__buttons">
<DButton

View File

@ -12,10 +12,10 @@ import concatClass from "discourse/helpers/concat-class";
import { ajax } from "discourse/lib/ajax";
import { bind } from "discourse/lib/decorators";
import { i18n } from "discourse-i18n";
import SmoothStreamer from "../lib/smooth-streamer";
import AiBlinkingAnimation from "./ai-blinking-animation";
const DISCOVERY_TIMEOUT_MS = 10000;
const STREAMED_TEXT_SPEED = 23;
export default class AiSearchDiscoveries extends Component {
@service search;
@ -27,9 +27,11 @@ export default class AiSearchDiscoveries extends Component {
@tracked hideDiscoveries = false;
@tracked fullDiscoveryToggled = false;
@tracked discoveryPreviewLength = this.args.discoveryPreviewLength || 150;
@tracked isStreaming = false;
@tracked streamedText = "";
@tracked
smoothStreamer = new SmoothStreamer(
() => 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"
}}
>
<div class="cooked">
<CookText @rawText={{this.renderedDiscovery}} />
<CookText @rawText={{this.smoothStreamer.renderedText}} />
</div>
</article>

View File

@ -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 theres 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}}
<AiSummarySkeleton />
{{else}}
<div class="generated-summary cooked">
<CookText @rawText={{this.streamedText}} />
<CookText @rawText={{this.smoothStreamer.renderedText}} />
</div>
{{/if}}
</article>

View File

@ -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);
}
}
}

View File

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