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:
parent
60fe924ce0
commit
6827d63e30
|
@ -10,6 +10,7 @@ import CookText from "discourse/components/cook-text";
|
||||||
import DButton from "discourse/components/d-button";
|
import DButton from "discourse/components/d-button";
|
||||||
import FastEdit from "discourse/components/fast-edit";
|
import FastEdit from "discourse/components/fast-edit";
|
||||||
import FastEditModal from "discourse/components/modal/fast-edit";
|
import FastEditModal from "discourse/components/modal/fast-edit";
|
||||||
|
import concatClass from "discourse/helpers/concat-class";
|
||||||
import { ajax } from "discourse/lib/ajax";
|
import { ajax } from "discourse/lib/ajax";
|
||||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||||
import { bind } from "discourse/lib/decorators";
|
import { bind } from "discourse/lib/decorators";
|
||||||
|
@ -20,6 +21,7 @@ import { i18n } from "discourse-i18n";
|
||||||
import eq from "truth-helpers/helpers/eq";
|
import eq from "truth-helpers/helpers/eq";
|
||||||
import AiHelperLoading from "../components/ai-helper-loading";
|
import AiHelperLoading from "../components/ai-helper-loading";
|
||||||
import AiHelperOptionsList from "../components/ai-helper-options-list";
|
import AiHelperOptionsList from "../components/ai-helper-options-list";
|
||||||
|
import SmoothStreamer from "../lib/smooth-streamer";
|
||||||
|
|
||||||
export default class AiPostHelperMenu extends Component {
|
export default class AiPostHelperMenu extends Component {
|
||||||
@service messageBus;
|
@service messageBus;
|
||||||
|
@ -43,6 +45,12 @@ export default class AiPostHelperMenu extends Component {
|
||||||
@tracked isSavingFootnote = false;
|
@tracked isSavingFootnote = false;
|
||||||
@tracked supportsAddFootnote = this.args.data.supportsFastEdit;
|
@tracked supportsAddFootnote = this.args.data.supportsFastEdit;
|
||||||
|
|
||||||
|
@tracked
|
||||||
|
smoothStreamer = new SmoothStreamer(
|
||||||
|
() => this.suggestion,
|
||||||
|
(newValue) => (this.suggestion = newValue)
|
||||||
|
);
|
||||||
|
|
||||||
MENU_STATES = {
|
MENU_STATES = {
|
||||||
options: "OPTIONS",
|
options: "OPTIONS",
|
||||||
loading: "LOADING",
|
loading: "LOADING",
|
||||||
|
@ -172,9 +180,9 @@ export default class AiPostHelperMenu extends Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
@bind
|
@bind
|
||||||
_updateResult(result) {
|
async _updateResult(result) {
|
||||||
this.streaming = !result.done;
|
this.streaming = !result.done;
|
||||||
this.suggestion = result.result;
|
await this.smoothStreamer.updateResult(result, "result");
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
|
@ -350,8 +358,18 @@ export default class AiPostHelperMenu extends Component {
|
||||||
{{willDestroy this.unsubscribe}}
|
{{willDestroy this.unsubscribe}}
|
||||||
>
|
>
|
||||||
{{#if this.suggestion}}
|
{{#if this.suggestion}}
|
||||||
<div class="ai-post-helper__suggestion__text" dir="auto">
|
<div
|
||||||
<CookText @rawText={{this.suggestion}} />
|
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>
|
||||||
<div class="ai-post-helper__suggestion__buttons">
|
<div class="ai-post-helper__suggestion__buttons">
|
||||||
<DButton
|
<DButton
|
||||||
|
|
|
@ -12,10 +12,10 @@ import concatClass from "discourse/helpers/concat-class";
|
||||||
import { ajax } from "discourse/lib/ajax";
|
import { ajax } from "discourse/lib/ajax";
|
||||||
import { bind } from "discourse/lib/decorators";
|
import { bind } from "discourse/lib/decorators";
|
||||||
import { i18n } from "discourse-i18n";
|
import { i18n } from "discourse-i18n";
|
||||||
|
import SmoothStreamer from "../lib/smooth-streamer";
|
||||||
import AiBlinkingAnimation from "./ai-blinking-animation";
|
import AiBlinkingAnimation from "./ai-blinking-animation";
|
||||||
|
|
||||||
const DISCOVERY_TIMEOUT_MS = 10000;
|
const DISCOVERY_TIMEOUT_MS = 10000;
|
||||||
const STREAMED_TEXT_SPEED = 23;
|
|
||||||
|
|
||||||
export default class AiSearchDiscoveries extends Component {
|
export default class AiSearchDiscoveries extends Component {
|
||||||
@service search;
|
@service search;
|
||||||
|
@ -27,9 +27,11 @@ export default class AiSearchDiscoveries extends Component {
|
||||||
@tracked hideDiscoveries = false;
|
@tracked hideDiscoveries = false;
|
||||||
@tracked fullDiscoveryToggled = false;
|
@tracked fullDiscoveryToggled = false;
|
||||||
@tracked discoveryPreviewLength = this.args.discoveryPreviewLength || 150;
|
@tracked discoveryPreviewLength = this.args.discoveryPreviewLength || 150;
|
||||||
|
@tracked
|
||||||
@tracked isStreaming = false;
|
smoothStreamer = new SmoothStreamer(
|
||||||
@tracked streamedText = "";
|
() => this.discobotDiscoveries.discovery,
|
||||||
|
(newValue) => (this.discobotDiscoveries.discovery = newValue)
|
||||||
|
);
|
||||||
|
|
||||||
discoveryTimeout = null;
|
discoveryTimeout = null;
|
||||||
typingTimer = 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
|
@bind
|
||||||
async _updateDiscovery(update) {
|
async _updateDiscovery(update) {
|
||||||
if (this.query === update.query) {
|
if (this.query === update.query) {
|
||||||
|
@ -94,23 +66,9 @@ export default class AiSearchDiscoveries extends Component {
|
||||||
this.discobotDiscoveries.discovery = "";
|
this.discobotDiscoveries.discovery = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
const newText = update.ai_discover_reply;
|
|
||||||
this.discobotDiscoveries.modelUsed = update.model_used;
|
this.discobotDiscoveries.modelUsed = update.model_used;
|
||||||
this.loadingDiscoveries = false;
|
this.loadingDiscoveries = false;
|
||||||
|
this.smoothStreamer.updateResult(update, "ai_discover_reply");
|
||||||
// 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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -153,16 +111,10 @@ export default class AiSearchDiscoveries extends Component {
|
||||||
get canShowExpandtoggle() {
|
get canShowExpandtoggle() {
|
||||||
return (
|
return (
|
||||||
!this.loadingDiscoveries &&
|
!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() {
|
get renderPreviewOnly() {
|
||||||
return !this.fullDiscoveryToggled && this.canShowExpandtoggle;
|
return !this.fullDiscoveryToggled && this.canShowExpandtoggle;
|
||||||
}
|
}
|
||||||
|
@ -173,7 +125,7 @@ export default class AiSearchDiscoveries extends Component {
|
||||||
this.hideDiscoveries = false;
|
this.hideDiscoveries = false;
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
this.resetStreaming();
|
this.smoothStreamer.resetStreaming();
|
||||||
this.discobotDiscoveries.resetDiscovery();
|
this.discobotDiscoveries.resetDiscovery();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -225,12 +177,12 @@ export default class AiSearchDiscoveries extends Component {
|
||||||
class={{concatClass
|
class={{concatClass
|
||||||
"ai-search-discoveries__discovery"
|
"ai-search-discoveries__discovery"
|
||||||
(if this.renderPreviewOnly "preview")
|
(if this.renderPreviewOnly "preview")
|
||||||
(if this.isStreaming "streaming")
|
(if this.smoothStreamer.isStreaming "streaming")
|
||||||
"streamable-content"
|
"streamable-content"
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="cooked">
|
<div class="cooked">
|
||||||
<CookText @rawText={{this.renderedDiscovery}} />
|
<CookText @rawText={{this.smoothStreamer.renderedText}} />
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,6 @@ import { action } from "@ember/object";
|
||||||
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
|
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
|
||||||
import didUpdate from "@ember/render-modifiers/modifiers/did-update";
|
import didUpdate from "@ember/render-modifiers/modifiers/did-update";
|
||||||
import willDestroy from "@ember/render-modifiers/modifiers/will-destroy";
|
import willDestroy from "@ember/render-modifiers/modifiers/will-destroy";
|
||||||
import { cancel, later } from "@ember/runloop";
|
|
||||||
import { service } from "@ember/service";
|
import { service } from "@ember/service";
|
||||||
import { not } from "truth-helpers";
|
import { not } from "truth-helpers";
|
||||||
import CookText from "discourse/components/cook-text";
|
import CookText from "discourse/components/cook-text";
|
||||||
|
@ -20,8 +19,7 @@ import { shortDateNoYear } from "discourse/lib/formatter";
|
||||||
import { i18n } from "discourse-i18n";
|
import { i18n } from "discourse-i18n";
|
||||||
import DTooltip from "float-kit/components/d-tooltip";
|
import DTooltip from "float-kit/components/d-tooltip";
|
||||||
import AiSummarySkeleton from "../../components/ai-summary-skeleton";
|
import AiSummarySkeleton from "../../components/ai-summary-skeleton";
|
||||||
|
import SmoothStreamer from "../../lib/smooth-streamer";
|
||||||
const STREAMED_TEXT_SPEED = 15;
|
|
||||||
|
|
||||||
export default class AiSummaryModal extends Component {
|
export default class AiSummaryModal extends Component {
|
||||||
@service siteSettings;
|
@service siteSettings;
|
||||||
|
@ -37,11 +35,12 @@ export default class AiSummaryModal extends Component {
|
||||||
@tracked outdated = false;
|
@tracked outdated = false;
|
||||||
@tracked canRegenerate = false;
|
@tracked canRegenerate = false;
|
||||||
@tracked loading = false;
|
@tracked loading = false;
|
||||||
@tracked isStreaming = false;
|
|
||||||
@tracked streamedText = "";
|
|
||||||
@tracked currentIndex = 0;
|
@tracked currentIndex = 0;
|
||||||
typingTimer = null;
|
@tracked
|
||||||
streamedTextLength = 0;
|
smoothStreamer = new SmoothStreamer(
|
||||||
|
() => this.text,
|
||||||
|
(newValue) => (this.text = newValue)
|
||||||
|
);
|
||||||
|
|
||||||
get outdatedSummaryWarningText() {
|
get outdatedSummaryWarningText() {
|
||||||
let outdatedText = i18n("summary.outdated");
|
let outdatedText = i18n("summary.outdated");
|
||||||
|
@ -57,7 +56,7 @@ export default class AiSummaryModal extends Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
resetSummary() {
|
resetSummary() {
|
||||||
this.streamedText = "";
|
this.smoothStreamer.resetStreaming();
|
||||||
this.currentIndex = 0;
|
this.currentIndex = 0;
|
||||||
this.text = "";
|
this.text = "";
|
||||||
this.summarizedOn = null;
|
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
|
@bind
|
||||||
async _updateSummary(update) {
|
async _updateSummary(update) {
|
||||||
const topicSummary = {
|
const topicSummary = {
|
||||||
|
@ -169,14 +148,11 @@ export default class AiSummaryModal extends Component {
|
||||||
raw: update.ai_topic_summary?.summarized_text,
|
raw: update.ai_topic_summary?.summarized_text,
|
||||||
...update.ai_topic_summary,
|
...update.ai_topic_summary,
|
||||||
};
|
};
|
||||||
const newText = topicSummary.raw || "";
|
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
|
this.smoothStreamer.updateResult(topicSummary, "raw");
|
||||||
|
|
||||||
if (update.done) {
|
if (update.done) {
|
||||||
this.text = newText;
|
|
||||||
this.streamedText = newText;
|
|
||||||
this.displayedTextLength = newText.length;
|
|
||||||
this.isStreaming = false;
|
|
||||||
this.summarizedOn = shortDateNoYear(
|
this.summarizedOn = shortDateNoYear(
|
||||||
moment(topicSummary.updated_at, "YYYY-MM-DD HH:mm:ss Z")
|
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.newPostsSinceSummary = topicSummary.new_posts_since_summary;
|
||||||
this.outdated = topicSummary.outdated;
|
this.outdated = topicSummary.outdated;
|
||||||
this.canRegenerate = topicSummary.outdated && topicSummary.can_regenerate;
|
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
|
class={{concatClass
|
||||||
"ai-summary-box"
|
"ai-summary-box"
|
||||||
"streamable-content"
|
"streamable-content"
|
||||||
(if this.isStreaming "streaming")
|
(if this.smoothStreamer.isStreaming "streaming")
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{{#if this.loading}}
|
{{#if this.loading}}
|
||||||
<AiSummarySkeleton />
|
<AiSummarySkeleton />
|
||||||
{{else}}
|
{{else}}
|
||||||
<div class="generated-summary cooked">
|
<div class="generated-summary cooked">
|
||||||
<CookText @rawText={{this.streamedText}} />
|
<CookText @rawText={{this.smoothStreamer.renderedText}} />
|
||||||
</div>
|
</div>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</article>
|
</article>
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in New Issue