DEV: Handle streaming animation within `AiSummaryBox` (#901)
This PR further decouples the streaming animation by completely handling the streaming animation directly in the `AiSummaryBox` component. Previously, handling the streaming animation by calling methods in the `ai-streamer` API was leading to timing issues making things out-of-sync. This results in some issues such as the last update of streamed text not being shown. Handling streaming directly in the component should simplify things drastically and prevent any issues.
This commit is contained in:
parent
021e09607d
commit
c421f713a3
|
@ -5,8 +5,9 @@ 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 { next } from "@ember/runloop";
|
import { cancel, later } from "@ember/runloop";
|
||||||
import { service } from "@ember/service";
|
import { service } from "@ember/service";
|
||||||
|
import CookText from "discourse/components/cook-text";
|
||||||
import DButton from "discourse/components/d-button";
|
import DButton from "discourse/components/d-button";
|
||||||
import concatClass from "discourse/helpers/concat-class";
|
import concatClass from "discourse/helpers/concat-class";
|
||||||
import { ajax } from "discourse/lib/ajax";
|
import { ajax } from "discourse/lib/ajax";
|
||||||
|
@ -18,8 +19,8 @@ import I18n from "discourse-i18n";
|
||||||
import DMenu from "float-kit/components/d-menu";
|
import DMenu from "float-kit/components/d-menu";
|
||||||
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 streamUpdaterText from "../../lib/ai-streamer/progress-handlers";
|
|
||||||
import SummaryUpdater from "../../lib/ai-streamer/updaters/summary-updater";
|
const STREAMED_TEXT_SPEED = 15;
|
||||||
|
|
||||||
export default class AiSummaryBox extends Component {
|
export default class AiSummaryBox extends Component {
|
||||||
@service siteSettings;
|
@service siteSettings;
|
||||||
|
@ -35,8 +36,10 @@ export default class AiSummaryBox extends Component {
|
||||||
@tracked canRegenerate = false;
|
@tracked canRegenerate = false;
|
||||||
@tracked loading = false;
|
@tracked loading = false;
|
||||||
@tracked isStreaming = false;
|
@tracked isStreaming = false;
|
||||||
oldRaw = null; // used for comparison in SummaryUpdater in lib/ai-streamer/updaters
|
@tracked streamedText = "";
|
||||||
finalSummary = null;
|
@tracked currentIndex = 0;
|
||||||
|
typingTimer = null;
|
||||||
|
streamedTextLength = 0;
|
||||||
|
|
||||||
get outdatedSummaryWarningText() {
|
get outdatedSummaryWarningText() {
|
||||||
let outdatedText = I18n.t("summary.outdated");
|
let outdatedText = I18n.t("summary.outdated");
|
||||||
|
@ -52,6 +55,8 @@ export default class AiSummaryBox extends Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
resetSummary() {
|
resetSummary() {
|
||||||
|
this.streamedText = "";
|
||||||
|
this.currentIndex = 0;
|
||||||
this.text = "";
|
this.text = "";
|
||||||
this.summarizedOn = null;
|
this.summarizedOn = null;
|
||||||
this.summarizedBy = null;
|
this.summarizedBy = null;
|
||||||
|
@ -83,8 +88,7 @@ export default class AiSummaryBox extends Component {
|
||||||
}
|
}
|
||||||
const channel = `/discourse-ai/summaries/topic/${this.args.outletArgs.topic.id}`;
|
const channel = `/discourse-ai/summaries/topic/${this.args.outletArgs.topic.id}`;
|
||||||
this._channel = channel;
|
this._channel = channel;
|
||||||
// we attempt to recover the last message in the bus so we subscrcibe at -2
|
this.messageBus.subscribe(channel, this._updateSummary);
|
||||||
this.messageBus.subscribe(channel, this._updateSummary, -2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@bind
|
@bind
|
||||||
|
@ -134,36 +138,63 @@ export default class AiSummaryBox extends Component {
|
||||||
return ajax(url).then((data) => {
|
return ajax(url).then((data) => {
|
||||||
if (data?.ai_topic_summary?.summarized_text) {
|
if (data?.ai_topic_summary?.summarized_text) {
|
||||||
data.done = true;
|
data.done = true;
|
||||||
// Our streamer won't display the summary unless the summary box is in the DOM.
|
this._updateSummary(data);
|
||||||
// Wait for the next runloop or cached summaries won't appear.
|
|
||||||
next(() => this._updateSummary(data));
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
_updateSummary(update) {
|
async _updateSummary(update) {
|
||||||
const topicSummary = {
|
const topicSummary = {
|
||||||
done: update.done,
|
done: update.done,
|
||||||
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.isStreaming = true;
|
|
||||||
streamUpdaterText(SummaryUpdater, topicSummary, this);
|
|
||||||
|
|
||||||
if (update.done) {
|
if (update.done) {
|
||||||
|
this.text = newText;
|
||||||
|
this.streamedText = newText;
|
||||||
|
this.displayedTextLength = newText.length;
|
||||||
this.isStreaming = false;
|
this.isStreaming = false;
|
||||||
this.text = this.finalSummary;
|
|
||||||
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")
|
||||||
);
|
);
|
||||||
this.summarizedBy = topicSummary.algorithm;
|
this.summarizedBy = topicSummary.algorithm;
|
||||||
this.newPostsSinceSummary = topicSummary.new_posts_since_summary;
|
this.newPostsSinceSummary = topicSummary.new_posts_since_summary;
|
||||||
this.outdated = topicSummary.outdated;
|
this.outdated = topicSummary.outdated;
|
||||||
this.newPostsSinceSummary = topicSummary.new_posts_since_summary;
|
|
||||||
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -228,7 +259,7 @@ export default class AiSummaryBox extends Component {
|
||||||
<AiSummarySkeleton />
|
<AiSummarySkeleton />
|
||||||
{{else}}
|
{{else}}
|
||||||
<div class="generated-summary cooked">
|
<div class="generated-summary cooked">
|
||||||
{{this.text}}
|
<CookText @rawText={{this.streamedText}} />
|
||||||
</div>
|
</div>
|
||||||
{{#if this.summarizedOn}}
|
{{#if this.summarizedOn}}
|
||||||
<div class="summarized-on">
|
<div class="summarized-on">
|
||||||
|
|
|
@ -1,47 +0,0 @@
|
||||||
import { cook } from "discourse/lib/text";
|
|
||||||
import StreamUpdater from "./stream-updater";
|
|
||||||
|
|
||||||
export default class SummaryUpdater extends StreamUpdater {
|
|
||||||
constructor(topicSummary, componentContext) {
|
|
||||||
super();
|
|
||||||
this.topicSummary = topicSummary;
|
|
||||||
this.componentContext = componentContext;
|
|
||||||
|
|
||||||
if (this.topicSummary) {
|
|
||||||
this.summaryBox = document.querySelector("article.ai-summary-box");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get element() {
|
|
||||||
return this.summaryBox;
|
|
||||||
}
|
|
||||||
|
|
||||||
set streaming(value) {
|
|
||||||
if (this.element) {
|
|
||||||
if (value) {
|
|
||||||
this.componentContext.isStreaming = true;
|
|
||||||
} else {
|
|
||||||
this.componentContext.isStreaming = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async setRaw(value, done) {
|
|
||||||
this.componentContext.oldRaw = value;
|
|
||||||
const cooked = await cook(value);
|
|
||||||
|
|
||||||
await this.setCooked(cooked);
|
|
||||||
|
|
||||||
if (done) {
|
|
||||||
this.componentContext.finalSummary = cooked;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async setCooked(value) {
|
|
||||||
this.componentContext.text = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
get raw() {
|
|
||||||
return this.componentContext.oldRaw || "";
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { click, visit } from "@ember/test-helpers";
|
import { click, visit } from "@ember/test-helpers";
|
||||||
import { skip, test } from "qunit";
|
import { test } from "qunit";
|
||||||
import topicFixtures from "discourse/tests/fixtures/topic";
|
import topicFixtures from "discourse/tests/fixtures/topic";
|
||||||
import {
|
import {
|
||||||
acceptance,
|
acceptance,
|
||||||
|
@ -30,7 +30,7 @@ acceptance("Topic - Summary", function (needs) {
|
||||||
updateCurrentUser({ id: currentUserId });
|
updateCurrentUser({ id: currentUserId });
|
||||||
});
|
});
|
||||||
|
|
||||||
skip("displays streamed summary", async function (assert) {
|
test("displays streamed summary", async function (assert) {
|
||||||
await visit("/t/-/1");
|
await visit("/t/-/1");
|
||||||
|
|
||||||
const partialSummary = "This a";
|
const partialSummary = "This a";
|
||||||
|
@ -67,7 +67,7 @@ acceptance("Topic - Summary", function (needs) {
|
||||||
.exists("summary metadata exists");
|
.exists("summary metadata exists");
|
||||||
});
|
});
|
||||||
|
|
||||||
skip("clicking summary links", async function (assert) {
|
test("clicking summary links", async function (assert) {
|
||||||
await visit("/t/-/1");
|
await visit("/t/-/1");
|
||||||
|
|
||||||
const partialSummary = "In this post,";
|
const partialSummary = "In this post,";
|
||||||
|
|
Loading…
Reference in New Issue