REFACTOR: Glimmerify topic summarization widgets. (#23043)

* REFACTOR: Glimerify topic summarization widgets.

Simplifies all the logic for generating/regenerating summaries and expanding/collapsing the summary box. It makes streaming easier to implement since now we can subscribe to message bus directly from the component.

* Update app/assets/javascripts/discourse/app/components/summary-box.hbs

Co-authored-by: David Taylor <david@taylorhq.com>

* Update app/assets/javascripts/discourse/app/components/summary-box.hbs

Co-authored-by: David Taylor <david@taylorhq.com>

* Update app/assets/javascripts/discourse/app/components/summary-box.hbs

Co-authored-by: David Taylor <david@taylorhq.com>

---------

Co-authored-by: David Taylor <david@taylorhq.com>
This commit is contained in:
Roman Rizzi 2023-08-09 17:11:24 -03:00 committed by GitHub
parent 0b29dc5d38
commit b832786f35
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 264 additions and 295 deletions

View File

@ -1,26 +1,28 @@
<ul class="ai-summary__list" {{did-insert this.setupAnimation}}>
{{#each this.blocks as |block|}}
<li
class={{concat-class
"ai-summary__list-item"
(if block.show "show")
(if block.shown "is-shown")
(if block.blinking "blink")
}}
{{did-update (fn this.onBlinking block) block.blinking}}
{{did-update (fn this.onShowing block) block.show}}
{{will-destroy this.teardownAnimation}}
></li>
{{/each}}
</ul>
<div class="ai-summary__container">
<ul class="ai-summary__list" {{did-insert this.setupAnimation}}>
{{#each this.blocks as |block|}}
<li
class={{concat-class
"ai-summary__list-item"
(if block.show "show")
(if block.shown "is-shown")
(if block.blinking "blink")
}}
{{did-update (fn this.onBlinking block) block.blinking}}
{{did-update (fn this.onShowing block) block.show}}
{{will-destroy this.teardownAnimation}}
></li>
{{/each}}
</ul>
<span>
<div class="ai-summary__generating-text">
{{i18n "summary.in_progress"}}
</div>
<span class="ai-summary__indicator-wave">
<span class="ai-summary__indicator-dot">.</span>
<span class="ai-summary__indicator-dot">.</span>
<span class="ai-summary__indicator-dot">.</span>
<span>
<div class="ai-summary__generating-text">
{{i18n "summary.in_progress"}}
</div>
<span class="ai-summary__indicator-wave">
<span class="ai-summary__indicator-dot">.</span>
<span class="ai-summary__indicator-dot">.</span>
<span class="ai-summary__indicator-dot">.</span>
</span>
</span>
</span>
</div>

View File

@ -0,0 +1,62 @@
{{#if @postAttrs.hasTopRepliesSummary}}
<p>{{html-safe this.topRepliesSummaryInfo}}</p>
{{/if}}
<div class="summarization-buttons">
{{#if @postAttrs.summarizable}}
{{#if this.canCollapseSummary}}
<DButton
@class="btn-primary topic-strategy-summarization"
@action={{this.toggleSummary}}
@title="summary.buttons.hide"
@label="summary.buttons.hide"
@icon="chevron-up"
/>
{{else}}
<DButton
@class="btn-primary topic-strategy-summarization"
@action={{this.generateSummary}}
@translatedLabel={{this.generateSummaryTitle}}
@translatedTitle={{this.generateSummaryTitle}}
@icon={{this.generateSummaryIcon}}
@disabled={{this.loadingSummary}}
/>
{{/if}}
{{/if}}
{{#if @postAttrs.hasTopRepliesSummary}}
<DButton
@class="top-replies"
@action={{this.toggleTopRepliesFilter}}
@translatedTitle={{this.topRepliesTitle}}
@translatedLabel={{this.topRepliesLabel}}
@icon={{this.topRepliesIcon}}
/>
{{/if}}
</div>
{{#if this.showSummaryBox}}
<article class="summary-box">
{{#if this.loadingSummary}}
<AiSummarySkeleton />
{{else}}
<div class="generated-summary">{{this.summary}}</div>
<div class="summarized-on">
<p>
{{i18n "summary.summarized_on" date=this.summarizedOn}}
<span>
{{d-icon "info-circle"}}
<DTooltip @placement="top-end">
{{i18n "summary.model_used" model=this.summarizedBy}}
</DTooltip>
</span>
</p>
{{#if this.outdated}}
<p class="outdated-summary">
{{this.outdatedSummaryWarningText}}
</p>
{{/if}}
</div>
{{/if}}
</article>
{{/if}}

View File

@ -0,0 +1,156 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import I18n from "I18n";
import { inject as service } from "@ember/service";
import { action } from "@ember/object";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { cookAsync } from "discourse/lib/text";
import { shortDateNoYear } from "discourse/lib/formatter";
const MIN_POST_READ_TIME = 4;
export default class SummaryBox extends Component {
@service siteSettings;
@tracked summary = "";
@tracked summarizedOn = null;
@tracked summarizedBy = null;
@tracked newPostsSinceSummary = null;
@tracked outdated = false;
@tracked canRegenerate = false;
@tracked regenerated = false;
@tracked showSummaryBox = false;
@tracked canCollapseSummary = false;
@tracked loadingSummary = false;
get generateSummaryTitle() {
const title = this.canRegenerate
? "summary.buttons.regenerate"
: "summary.buttons.generate";
return I18n.t(title);
}
get generateSummaryIcon() {
return this.canRegenerate ? "sync" : "magic";
}
get outdatedSummaryWarningText() {
let outdatedText = I18n.t("summary.outdated");
if (
!this.args.postAttrs.hasTopRepliesSummary &&
this.newPostsSinceSummary > 0
) {
outdatedText += " ";
outdatedText += I18n.t("summary.outdated_posts", {
count: this.newPostsSinceSummary,
});
}
return outdatedText;
}
get topRepliesSummaryEnabled() {
return this.args.postAttrs.topicSummaryEnabled;
}
get topRepliesSummaryInfo() {
if (this.args.postAttrs.topicSummaryEnabled) {
return I18n.t("summary.enabled_description");
}
const wordCount = this.args.postAttrs.topicWordCount;
if (wordCount && this.siteSettings.read_time_word_count > 0) {
const readingTime = Math.ceil(
Math.max(
wordCount / this.siteSettings.read_time_word_count,
(this.args.postAttrs.topicPostsCount * MIN_POST_READ_TIME) / 60
)
);
return I18n.messageFormat("summary.description_time_MF", {
replyCount: this.args.postAttrs.topicReplyCount,
readingTime,
});
}
return I18n.t("summary.description", {
count: this.args.postAttrs.topicReplyCount,
});
}
get topRepliesTitle() {
if (this.topRepliesSummaryEnabled) {
return;
}
return I18n.t("summary.short_title");
}
get topRepliesLabel() {
const label = this.topRepliesSummaryEnabled
? "summary.disable"
: "summary.enable";
return I18n.t(label);
}
get topRepliesIcon() {
if (this.topRepliesSummaryEnabled) {
return;
}
return "layer-group";
}
@action
toggleTopRepliesFilter() {
const filterFunction = this.topRepliesSummaryEnabled
? "cancelFilter"
: "showTopReplies";
this.args.topRepliesToggle(filterFunction);
}
@action
collapseSummary() {
this.showSummaryBox = false;
this.canCollapseSummary = false;
}
@action
generateSummary() {
this.showSummaryBox = true;
if (this.summary && !this.canRegenerate) {
this.canCollapseSummary = true;
return;
} else {
this.loadingSummary = true;
}
let fetchURL = `/t/${this.args.postAttrs.topicId}/strategy-summary`;
if (this.canRegenerate) {
fetchURL += "?skip_age_check=true";
}
ajax(fetchURL)
.then((data) => {
cookAsync(data.summary).then((cooked) => {
this.summary = cooked;
this.summarizedOn = shortDateNoYear(data.summarized_on);
this.summarizedBy = data.summarized_by;
this.newPostsSinceSummary = data.new_posts_since_summary;
this.outdated = data.outdated;
this.newPostsSinceSummary = data.new_posts_since_summary;
this.canRegenerate = data.outdated && data.can_regenerate;
this.canCollapseSummary = !this.canRegenerate;
});
})
.catch(popupAjaxError)
.finally(() => (this.loadingSummary = false));
}
}

View File

@ -1,116 +0,0 @@
import { createWidget } from "discourse/widgets/widget";
import { hbs } from "ember-cli-htmlbars";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { cookAsync } from "discourse/lib/text";
import RawHtml from "discourse/widgets/raw-html";
import I18n from "I18n";
import { shortDateNoYear } from "discourse/lib/formatter";
import { h } from "virtual-dom";
import { iconNode } from "discourse-common/lib/icon-library";
import RenderGlimmer from "discourse/widgets/render-glimmer";
export default createWidget("summary-box", {
tagName: "article.summary-box",
buildKey: (attrs) => `summary-box-${attrs.topicId}`,
defaultState() {
return { expandSummarizedOn: false };
},
html(attrs) {
const html = [];
const summary = attrs.summary;
if (summary && !attrs.skipAgeCheck) {
html.push(
new RawHtml({
html: `<div class="generated-summary">${summary.summarized_text}</div>`,
})
);
const summarizationInfo = [
h("p", {}, [
I18n.t("summary.summarized_on", { date: summary.summarized_on }),
this.buildTooltip(attrs),
]),
];
if (summary.outdated) {
summarizationInfo.push(this.outdatedSummaryWarning(attrs));
}
html.push(h("div.summarized-on", {}, summarizationInfo));
} else {
html.push(this.buildSummarySkeleton());
this.fetchSummary(attrs.topicId, attrs.skipAgeCheck);
}
return html;
},
buildSummarySkeleton() {
return new RenderGlimmer(
this,
"div.ai-summary__container",
hbs`{{ai-summary-skeleton}}`
);
},
buildTooltip(attrs) {
return new RenderGlimmer(
this,
"span",
hbs`{{d-icon "info-circle"}}<DTooltip @placement="top-end">
{{i18n "summary.model_used" model=@data.summarizedBy}}
</DTooltip>`,
{
summarizedBy: attrs.summary.summarized_by,
}
);
},
outdatedSummaryWarning(attrs) {
let outdatedText = I18n.t("summary.outdated");
if (
!attrs.hasTopRepliesSummary &&
attrs.summary.new_posts_since_summary > 0
) {
outdatedText += " ";
outdatedText += I18n.t("summary.outdated_posts", {
count: attrs.summary.new_posts_since_summary,
});
}
return h("p.outdated-summary", {}, [
outdatedText,
iconNode("exclamation-triangle", { class: "info-icon" }),
]);
},
fetchSummary(topicId, skipAgeCheck) {
let fetchURL = `/t/${topicId}/strategy-summary`;
if (skipAgeCheck) {
fetchURL += "?skip_age_check=true";
}
ajax(fetchURL)
.then((data) => {
cookAsync(data.summary).then((cooked) => {
// We store the summary in the parent so we can re-render it without doing a new request.
data.summarized_text = cooked.string;
data.summarized_on = shortDateNoYear(data.summarized_on);
if (skipAgeCheck) {
data.regenerated = true;
}
this.sendWidgetEvent("summaryUpdated", data);
});
})
.catch(popupAjaxError);
},
});

View File

@ -1,154 +0,0 @@
import I18n from "I18n";
import RawHtml from "discourse/widgets/raw-html";
import { createWidget } from "discourse/widgets/widget";
import { h } from "virtual-dom";
const MIN_POST_READ_TIME = 4;
createWidget("toggle-summary-description", {
description(attrs) {
if (attrs.topicSummaryEnabled) {
return I18n.t("summary.enabled_description");
}
if (attrs.topicWordCount && this.siteSettings.read_time_word_count > 0) {
const readingTime = Math.ceil(
Math.max(
attrs.topicWordCount / this.siteSettings.read_time_word_count,
(attrs.topicPostsCount * MIN_POST_READ_TIME) / 60
)
);
return I18n.messageFormat("summary.description_time_MF", {
replyCount: attrs.topicReplyCount,
readingTime,
});
}
return I18n.t("summary.description", { count: attrs.topicReplyCount });
},
html(attrs) {
// vdom makes putting html in the i18n difficult
return new RawHtml({ html: `<p>${this.description(attrs)}</p>` });
},
});
export default createWidget("toggle-topic-summary", {
tagName: "section.information.toggle-summary",
buildKey: (attrs) => `toggle-topic-summary-${attrs.topicId}`,
defaultState() {
return {
expandSummaryBox: false,
summaryBoxHidden: true,
summary: "",
summarizedOn: null,
summarizedBy: null,
};
},
html(attrs, state) {
const html = [];
const summarizationButtons = [];
if (attrs.summarizable) {
const canRegenerate =
!state.regenerate &&
state.summary.outdated &&
state.summary.can_regenerate;
const canCollapse =
!canRegenerate && !this.loadingSummary() && this.summaryBoxVisble();
const summarizeButton = canCollapse
? this.hideSummaryButton()
: this.generateSummaryButton(canRegenerate);
summarizationButtons.push(summarizeButton);
}
if (attrs.hasTopRepliesSummary) {
html.push(this.attach("toggle-summary-description", attrs));
summarizationButtons.push(
this.attach("button", {
className: "btn top-replies",
icon: attrs.topicSummaryEnabled ? null : "layer-group",
title: attrs.topicSummaryEnabled ? null : "summary.short_title",
label: attrs.topicSummaryEnabled
? "summary.disable"
: "summary.enable",
action: attrs.topicSummaryEnabled ? "cancelFilter" : "showTopReplies",
})
);
}
if (summarizationButtons) {
html.push(h("div.summarization-buttons", summarizationButtons));
}
if (this.summaryBoxVisble()) {
attrs.summary = state.summary;
attrs.skipAgeCheck = state.regenerate;
html.push(this.attach("summary-box", attrs));
}
return html;
},
generateSummaryButton(canRegenerate) {
const title = canRegenerate
? "summary.buttons.regenerate"
: "summary.buttons.generate";
const icon = canRegenerate ? "sync" : "magic";
return this.attach("button", {
className: "btn btn-primary topic-strategy-summarization",
icon,
title: I18n.t(title),
translatedTitle: I18n.t(title),
translatedLabel: I18n.t(title),
action: canRegenerate ? "regenerateSummary" : "expandSummaryBox",
disabled: this.loadingSummary(),
});
},
hideSummaryButton() {
return this.attach("button", {
className: "btn btn-primary topic-strategy-summarization",
icon: "chevron-up",
title: "summary.buttons.hide",
label: "summary.buttons.hide",
action: "toggleSummaryBox",
disabled: this.loadingSummary(),
});
},
loadingSummary() {
return (
this.summaryBoxVisble() && (!this.state.summary || this.state.regenerate)
);
},
summaryUpdatedEvent(summary) {
this.state.summary = summary;
if (summary.regenerated) {
this.state.regenerate = false;
}
},
summaryBoxVisble() {
return this.state.expandSummaryBox && !this.state.summaryBoxHidden;
},
expandSummaryBox() {
this.state.expandSummaryBox = true;
this.state.summaryBoxHidden = false;
},
regenerateSummary() {
this.state.regenerate = true;
},
toggleSummaryBox() {
this.state.summaryBoxHidden = !this.state.summaryBoxHidden;
},
});

View File

@ -6,6 +6,8 @@ import { h } from "virtual-dom";
import { replaceEmoji } from "discourse/widgets/emoji";
import autoGroupFlairForUser from "discourse/lib/avatar-flair";
import { userPath } from "discourse/lib/url";
import RenderGlimmer from "discourse/widgets/render-glimmer";
import { hbs } from "ember-cli-htmlbars";
const LINKS_SHOWN = 5;
@ -387,7 +389,7 @@ export default createWidget("topic-map", {
}
if (attrs.hasTopRepliesSummary || attrs.summarizable) {
contents.push(this.attach("toggle-topic-summary", attrs));
contents.push(this.buildSummaryBox(attrs));
}
if (attrs.showPMMap) {
@ -399,4 +401,21 @@ export default createWidget("topic-map", {
toggleMap() {
this.state.collapsed = !this.state.collapsed;
},
buildSummaryBox(attrs) {
return new RenderGlimmer(
this,
"section.information.toggle-summary",
hbs`<SummaryBox
@postAttrs={{@data.postAttrs}}
@topRepliesToggle={{@data.actionDispatchFunc}}
/>`,
{
postAttrs: attrs,
actionDispatchFunc: (actionName) => {
this.sendWidgetAction(actionName);
},
}
);
},
});