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:
Keegan George 2024-11-08 01:08:32 +09:00 committed by GitHub
parent 021e09607d
commit c421f713a3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 51 additions and 67 deletions

View File

@ -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 theres 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">

View File

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

View File

@ -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,";