discourse-ai/assets/javascripts/discourse/connectors/topic-map-expanded-after/ai-summary-box.gjs

306 lines
8.9 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { array } from "@ember/helper";
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 CookText from "discourse/components/cook-text";
import DButton from "discourse/components/d-button";
import concatClass from "discourse/helpers/concat-class";
import { ajax } from "discourse/lib/ajax";
import { shortDateNoYear } from "discourse/lib/formatter";
import dIcon from "discourse-common/helpers/d-icon";
import i18n from "discourse-common/helpers/i18n";
import { bind } from "discourse-common/utils/decorators";
import I18n from "discourse-i18n";
import DMenu from "float-kit/components/d-menu";
import DTooltip from "float-kit/components/d-tooltip";
import AiSummarySkeleton from "../../components/ai-summary-skeleton";
const STREAMED_TEXT_SPEED = 15;
export default class AiSummaryBox extends Component {
@service siteSettings;
@service messageBus;
@service currentUser;
@service site;
@tracked text = "";
@tracked summarizedOn = null;
@tracked summarizedBy = null;
@tracked newPostsSinceSummary = null;
@tracked outdated = false;
@tracked canRegenerate = false;
@tracked loading = false;
@tracked isStreaming = false;
@tracked streamedText = "";
@tracked currentIndex = 0;
typingTimer = null;
streamedTextLength = 0;
get outdatedSummaryWarningText() {
let outdatedText = I18n.t("summary.outdated");
if (!this.topRepliesSummaryEnabled && this.newPostsSinceSummary > 0) {
outdatedText += " ";
outdatedText += I18n.t("summary.outdated_posts", {
count: this.newPostsSinceSummary,
});
}
return outdatedText;
}
resetSummary() {
this.streamedText = "";
this.currentIndex = 0;
this.text = "";
this.summarizedOn = null;
this.summarizedBy = null;
this.newPostsSinceSummary = null;
this.outdated = false;
this.canRegenerate = false;
this.loading = false;
this._channel = null;
}
get topRepliesSummaryEnabled() {
return this.args.outletArgs.postStream.summary;
}
get topicId() {
return this.args.outletArgs.topic.id;
}
get baseSummarizationURL() {
return `/discourse-ai/summarization/t/${this.topicId}`;
}
@bind
subscribe(unsubscribe, [topicId]) {
const sameTopicId = this.args.outletArgs.topic.id === topicId;
if (unsubscribe && this._channel && !sameTopicId) {
this.unsubscribe();
}
const channel = `/discourse-ai/summaries/topic/${this.args.outletArgs.topic.id}`;
this._channel = channel;
this.messageBus.subscribe(channel, this._updateSummary);
}
@bind
unsubscribe() {
this.messageBus.unsubscribe(
"/discourse-ai/summaries/topic/*",
this._updateSummary
);
this.resetSummary();
}
@action
generateSummary() {
let fetchURL = this.baseSummarizationURL;
if (this.currentUser) {
fetchURL += `?stream=true`;
}
return this._requestSummary(fetchURL);
}
@action
regenerateSummary() {
let fetchURL = this.baseSummarizationURL;
if (this.currentUser) {
fetchURL += `?stream=true`;
if (this.canRegenerate) {
fetchURL += "&skip_age_check=true";
}
}
// ensure summary is reset before requesting a new one:
this.resetSummary();
return this._requestSummary(fetchURL);
}
@action
_requestSummary(url) {
if (this.loading || (this.text && !this.canRegenerate)) {
return;
}
this.loading = true;
this.summarizedOn = null;
return ajax(url).then((data) => {
if (data?.ai_topic_summary?.summarized_text) {
data.done = true;
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
async _updateSummary(update) {
const topicSummary = {
done: update.done,
raw: update.ai_topic_summary?.summarized_text,
...update.ai_topic_summary,
};
const newText = topicSummary.raw || "";
this.loading = false;
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")
);
this.summarizedBy = topicSummary.algorithm;
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();
}
}
@action
onRegisterApi(api) {
this.dMenu = api;
}
@action
async onClose() {
await this.dMenu.close();
this.unsubscribe();
}
<template>
{{#if @outletArgs.topic.summarizable}}
<div
class="ai-summarization-button"
{{didInsert this.subscribe}}
{{didUpdate this.subscribe @outletArgs.topic.id}}
{{willDestroy this.unsubscribe}}
>
<DMenu
@onShow={{this.generateSummary}}
@arrow={{false}}
@identifier="topic-map__ai-summary"
@onRegisterApi={{this.onRegisterApi}}
@interactive={{true}}
@triggers="click"
@placement="left"
@modalForMobile={{true}}
@groupIdentifier="topic-map"
@inline={{true}}
@label={{i18n "summary.buttons.generate"}}
@title={{i18n "summary.buttons.generate"}}
@icon="discourse-sparkles"
@triggerClass="ai-topic-summarization"
@closeOnClickOutside={{false}}
>
<:content>
<div class="ai-summary-container">
<header class="ai-summary__header">
<h3>{{i18n "discourse_ai.summarization.topic.title"}}</h3>
{{#if this.site.desktopView}}
<DButton
@title="discourse_ai.summarization.topic.close"
@action={{this.onClose}}
@icon="times"
@class="btn-transparent ai-summary__close"
/>
{{/if}}
</header>
<article
class={{concatClass
"ai-summary-box"
"streamable-content"
(if this.isStreaming "streaming")
}}
>
{{#if this.loading}}
<AiSummarySkeleton />
{{else}}
<div class="generated-summary cooked">
<CookText @rawText={{this.streamedText}} />
</div>
{{#if this.summarizedOn}}
<div class="summarized-on">
<p>
{{i18n "summary.summarized_on" date=this.summarizedOn}}
<DTooltip @placements={{array "top-end"}}>
<:trigger>
{{dIcon "info-circle"}}
</:trigger>
<:content>
{{i18n
"summary.model_used"
model=this.summarizedBy
}}
</:content>
</DTooltip>
</p>
<div class="outdated-summary">
{{#if this.outdated}}
<p>{{this.outdatedSummaryWarningText}}</p>
{{/if}}
{{#if this.canRegenerate}}
<DButton
@label="summary.buttons.regenerate"
@title="summary.buttons.regenerate"
@action={{this.regenerateSummary}}
@icon="sync"
/>
{{/if}}
</div>
</div>
{{/if}}
{{/if}}
</article>
</div>
</:content>
</DMenu>
</div>
{{/if}}
</template>
}