diff --git a/app/assets/javascripts/discourse/app/components/scrolling-post-stream.js b/app/assets/javascripts/discourse/app/components/scrolling-post-stream.js
index 901005e0866..83fd00a5eb9 100644
--- a/app/assets/javascripts/discourse/app/components/scrolling-post-stream.js
+++ b/app/assets/javascripts/discourse/app/components/scrolling-post-stream.js
@@ -8,6 +8,7 @@ import offsetCalculator from "discourse/lib/offset-calculator";
import { inject as service } from "@ember/service";
import { bind } from "discourse-common/utils/decorators";
import domUtils from "discourse-common/utils/dom-utils";
+import showModal from "discourse/lib/show-modal";
const DEBOUNCE_DELAY = 50;
@@ -268,6 +269,12 @@ export default MountWidget.extend({
this.screenTrack.setOnscreen(onscreenPostNumbers, readPostNumbers);
},
+ showSummary() {
+ showModal("topic-summary").setProperties({
+ topicId: this.posts["posts"][0].topic_id,
+ });
+ },
+
_scrollTriggered() {
scheduleOnce("afterRender", this, this.scrolled);
},
diff --git a/app/assets/javascripts/discourse/app/components/topic-summary.hbs b/app/assets/javascripts/discourse/app/components/topic-summary.hbs
new file mode 100644
index 00000000000..e5b491c0604
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/topic-summary.hbs
@@ -0,0 +1,14 @@
+
+
+
+
+ {{#unless this.loading}}
+
{{this.summary}}
+ {{/unless}}
+
+
+
+
+
\ No newline at end of file
diff --git a/app/assets/javascripts/discourse/app/components/topic-summary.js b/app/assets/javascripts/discourse/app/components/topic-summary.js
new file mode 100644
index 00000000000..902344fc5bd
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/topic-summary.js
@@ -0,0 +1,25 @@
+import Component from "@glimmer/component";
+import { tracked } from "@glimmer/tracking";
+import { ajax } from "discourse/lib/ajax";
+import { popupAjaxError } from "discourse/lib/ajax-error";
+import { action } from "@ember/object";
+import { schedule } from "@ember/runloop";
+
+export default class TopicSummary extends Component {
+ @tracked loading = false;
+ @tracked summary = null;
+
+ @action
+ summarize() {
+ schedule("afterRender", () => {
+ this.loading = true;
+
+ ajax(`/t/${this.args.topicId}/strategy-summary`)
+ .then((data) => {
+ this.summary = data.summary;
+ })
+ .catch(popupAjaxError)
+ .finally(() => (this.loading = false));
+ });
+ }
+}
diff --git a/app/assets/javascripts/discourse/app/components/topic-timeline.hbs b/app/assets/javascripts/discourse/app/components/topic-timeline.hbs
index 5b1b643040a..ef77e284523 100644
--- a/app/assets/javascripts/discourse/app/components/topic-timeline.hbs
+++ b/app/assets/javascripts/discourse/app/components/topic-timeline.hbs
@@ -15,7 +15,7 @@
@mobileView={{@mobileView}}
@toggleMultiSelect={{@toggleMultiSelect}}
@showTopicSlowModeUpdate={{@showTopicSlowModeUpdate}}
- @showSummary={{@showSummary}}
+ @showTopReplies={{@showTopReplies}}
@deleteTopic={{@deleteTopic}}
@recoverTopic={{@recoverTopic}}
@toggleClosed={{@toggleClosed}}
diff --git a/app/assets/javascripts/discourse/app/components/topic-timeline/container.hbs b/app/assets/javascripts/discourse/app/components/topic-timeline/container.hbs
index 1701e398f12..6efc2aebde9 100644
--- a/app/assets/javascripts/discourse/app/components/topic-timeline/container.hbs
+++ b/app/assets/javascripts/discourse/app/components/topic-timeline/container.hbs
@@ -119,7 +119,7 @@
type="button"
class="show-summary btn btn-small"
title={{i18n "summary.short_title"}}
- {{on "click" @showSummary}}
+ {{on "click" @showTopReplies}}
>
{{d-icon "layer-group"}}
{{i18n "summary.short_label"}}
diff --git a/app/assets/javascripts/discourse/app/controllers/topic.js b/app/assets/javascripts/discourse/app/controllers/topic.js
index d76069ae562..1dae88e723d 100644
--- a/app/assets/javascripts/discourse/app/controllers/topic.js
+++ b/app/assets/javascripts/discourse/app/controllers/topic.js
@@ -520,9 +520,9 @@ export default Controller.extend(bufferedProperty("model"), {
}
},
- showSummary() {
+ showTopReplies() {
return this.get("model.postStream")
- .showSummary()
+ .showTopReplies()
.then(() => {
this.updateQueryParams();
});
diff --git a/app/assets/javascripts/discourse/app/lib/plugin-api.js b/app/assets/javascripts/discourse/app/lib/plugin-api.js
index 34a273de1dd..6a7979929c8 100644
--- a/app/assets/javascripts/discourse/app/lib/plugin-api.js
+++ b/app/assets/javascripts/discourse/app/lib/plugin-api.js
@@ -68,7 +68,6 @@ import {
import { addTagsHtmlCallback } from "discourse/lib/render-tags";
import { addToolbarCallback } from "discourse/components/d-editor";
import { addTopicParticipantClassesCallback } from "discourse/widgets/topic-map";
-import { addTopicSummaryCallback } from "discourse/widgets/toggle-topic-summary";
import { addTopicTitleDecorator } from "discourse/components/topic-title";
import { addUserMenuProfileTabItem } from "discourse/components/user-menu/profile-tab-content";
import { addUsernameSelectorDecorator } from "discourse/helpers/decorate-username-selector";
@@ -1047,28 +1046,6 @@ class PluginApi {
addTopicParticipantClassesCallback(callback);
}
- /**
- * EXPERIMENTAL. Do not use.
- * Adds a callback to be topic summary widget markup that can be used, for example,
- * to add an extra button to the topic summary widget.
- *
- * Example:
- *
- * api.addTopicSummaryCallback((html, attrs, widget) => {
- * html.push(
- * widget.attach("button", {
- * className: "btn btn-primary",
- * icon: "magic",
- * title: "discourse_ai.ai_helper.title",
- * label: "discourse_ai.ai_helper.title",
- * action: "showAiSummary",
- * })
- * );
- **/
- addTopicSummaryCallback(callback) {
- addTopicSummaryCallback(callback);
- }
-
/**
*
* Adds a callback to be executed on the "transformed" post that is passed to the post
diff --git a/app/assets/javascripts/discourse/app/lib/transform-post.js b/app/assets/javascripts/discourse/app/lib/transform-post.js
index 689cfd0daeb..f8f586ae67e 100644
--- a/app/assets/javascripts/discourse/app/lib/transform-post.js
+++ b/app/assets/javascripts/discourse/app/lib/transform-post.js
@@ -218,7 +218,7 @@ export default function transformPost(
postAtts.userFilters = postStream.userFilters;
postAtts.topicSummaryEnabled = postStream.summary;
postAtts.topicWordCount = topic.word_count;
- postAtts.hasTopicSummary = topic.has_summary;
+ postAtts.hasTopRepliesSummary = topic.has_summary;
}
if (postAtts.isDeleted) {
diff --git a/app/assets/javascripts/discourse/app/models/post-stream.js b/app/assets/javascripts/discourse/app/models/post-stream.js
index 58df4e99db6..cfae484f7e0 100644
--- a/app/assets/javascripts/discourse/app/models/post-stream.js
+++ b/app/assets/javascripts/discourse/app/models/post-stream.js
@@ -251,7 +251,7 @@ export default RestModel.extend({
});
},
- showSummary() {
+ showTopReplies() {
this.cancelFilter();
this.set("filter", "summary");
return this.refreshAndJumpToSecondVisible();
diff --git a/app/assets/javascripts/discourse/app/templates/modal/topic-summary.hbs b/app/assets/javascripts/discourse/app/templates/modal/topic-summary.hbs
new file mode 100644
index 00000000000..b745e904c03
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/templates/modal/topic-summary.hbs
@@ -0,0 +1,4 @@
+
\ No newline at end of file
diff --git a/app/assets/javascripts/discourse/app/templates/topic.hbs b/app/assets/javascripts/discourse/app/templates/topic.hbs
index 1e817eb528e..ec1ddb47d5e 100644
--- a/app/assets/javascripts/discourse/app/templates/topic.hbs
+++ b/app/assets/javascripts/discourse/app/templates/topic.hbs
@@ -228,7 +228,7 @@
@info={{info}}
@model={{this.model}}
@replyToPost={{action "replyToPost"}}
- @showSummary={{action "showSummary"}}
+ @showTopReplies={{action "showTopReplies"}}
@jumpToPostPrompt={{action "jumpToPostPrompt"}}
@enteredIndex={{this.enteredIndex}}
@prevEvent={{info.prevEvent}}
@@ -342,7 +342,7 @@
@unhidePost={{action "unhidePost"}}
@replyToPost={{action "replyToPost"}}
@toggleWiki={{action "toggleWiki"}}
- @showSummary={{action "showSummary"}}
+ @showTopReplies={{action "showTopReplies"}}
@cancelFilter={{action "cancelFilter"}}
@removeAllowedUser={{action "removeAllowedUser"}}
@removeAllowedGroup={{action "removeAllowedGroup"}}
diff --git a/app/assets/javascripts/discourse/app/widgets/toggle-topic-summary.js b/app/assets/javascripts/discourse/app/widgets/toggle-topic-summary.js
index b6f36f6bada..bcbbb7de409 100644
--- a/app/assets/javascripts/discourse/app/widgets/toggle-topic-summary.js
+++ b/app/assets/javascripts/discourse/app/widgets/toggle-topic-summary.js
@@ -1,6 +1,7 @@
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;
@@ -31,32 +32,43 @@ createWidget("toggle-summary-description", {
},
});
-let topicSummaryCallbacks = null;
-export function addTopicSummaryCallback(callback) {
- topicSummaryCallbacks = topicSummaryCallbacks || [];
- topicSummaryCallbacks.push(callback);
-}
-
export default createWidget("toggle-topic-summary", {
tagName: "section.information.toggle-summary",
html(attrs) {
- let html = [
- this.attach("toggle-summary-description", attrs),
- this.attach("button", {
- className: "btn btn-primary",
- icon: attrs.topicSummaryEnabled ? null : "layer-group",
- title: attrs.topicSummaryEnabled ? null : "summary.short_title",
- label: attrs.topicSummaryEnabled ? "summary.disable" : "summary.enable",
- action: attrs.topicSummaryEnabled ? "cancelFilter" : "showSummary",
- }),
- ];
+ const html = [];
+ const summarizationButtons = [];
- if (topicSummaryCallbacks) {
- topicSummaryCallbacks.forEach((callback) => {
- html = callback(html, attrs, this);
- });
+ if (attrs.hasTopRepliesSummary) {
+ html.push(this.attach("toggle-summary-description", attrs));
+ summarizationButtons.push(
+ this.attach("button", {
+ className: "btn btn-primary",
+ 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 (attrs.includeSummary) {
+ const title = I18n.t("summary.strategy.button_title");
+
+ summarizationButtons.push(
+ this.attach("button", {
+ className: "btn btn-primary topic-strategy-summarization",
+ icon: "magic",
+ translatedTitle: title,
+ translatedLabel: title,
+ action: "showSummary",
+ })
+ );
+ }
+
+ html.push(h("div.summarization-buttons", summarizationButtons));
+
return html;
},
});
diff --git a/app/assets/javascripts/discourse/app/widgets/topic-map.js b/app/assets/javascripts/discourse/app/widgets/topic-map.js
index dfeaddfde78..6b94820fdc8 100644
--- a/app/assets/javascripts/discourse/app/widgets/topic-map.js
+++ b/app/assets/javascripts/discourse/app/widgets/topic-map.js
@@ -376,7 +376,7 @@ export default createWidget("topic-map", {
buildKey: (attrs) => `topic-map-${attrs.id}`,
defaultState(attrs) {
- return { collapsed: !attrs.hasTopicSummary };
+ return { collapsed: !attrs.hasTopRepliesSummary };
},
html(attrs, state) {
@@ -386,7 +386,8 @@ export default createWidget("topic-map", {
contents.push(this.attach("topic-map-expanded", attrs));
}
- if (attrs.hasTopicSummary) {
+ if (attrs.hasTopRepliesSummary || this._includesSummary()) {
+ attrs.includeSummary = this._includesSummary();
contents.push(this.attach("toggle-topic-summary", attrs));
}
@@ -399,4 +400,19 @@ export default createWidget("topic-map", {
toggleMap() {
this.state.collapsed = !this.state.collapsed;
},
+
+ _includesSummary() {
+ const customSummaryAllowedGroups =
+ this.siteSettings.custom_summarization_allowed_groups
+ .split("|")
+ .map(parseInt);
+
+ return (
+ this.siteSettings.summarization_strategy &&
+ this.currentUser &&
+ this.currentUser.groups.some((g) =>
+ customSummaryAllowedGroups.includes(g.id)
+ )
+ );
+ },
});
diff --git a/app/assets/javascripts/discourse/tests/integration/components/widgets/post-test.js b/app/assets/javascripts/discourse/tests/integration/components/widgets/post-test.js
index 27be61e0c6c..3ad97ae96c9 100644
--- a/app/assets/javascripts/discourse/tests/integration/components/widgets/post-test.js
+++ b/app/assets/javascripts/discourse/tests/integration/components/widgets/post-test.js
@@ -840,12 +840,12 @@ module("Integration | Component | Widget | post", function (hooks) {
assert.ok(!exists(".toggle-summary"));
});
- test("topic map - has summary", async function (assert) {
- this.set("args", { showTopicMap: true, hasTopicSummary: true });
- this.set("showSummary", () => (this.summaryToggled = true));
+ test("topic map - has top replies summary", async function (assert) {
+ this.set("args", { showTopicMap: true, hasTopRepliesSummary: true });
+ this.set("showTopReplies", () => (this.summaryToggled = true));
await render(
- hbs``
+ hbs``
);
assert.strictEqual(count(".toggle-summary"), 1);
diff --git a/app/assets/stylesheets/common/base/topic-post.scss b/app/assets/stylesheets/common/base/topic-post.scss
index 021758c387e..2977916ecbf 100644
--- a/app/assets/stylesheets/common/base/topic-post.scss
+++ b/app/assets/stylesheets/common/base/topic-post.scss
@@ -833,6 +833,12 @@ aside.quote {
margin-left: 0.25em;
}
}
+
+ .toggle-summary {
+ .summarization-buttons {
+ display: flex;
+ }
+ }
}
.topic-avatar,
diff --git a/app/assets/stylesheets/desktop/topic-post.scss b/app/assets/stylesheets/desktop/topic-post.scss
index fac39efdf38..478b75547b4 100644
--- a/app/assets/stylesheets/desktop/topic-post.scss
+++ b/app/assets/stylesheets/desktop/topic-post.scss
@@ -325,6 +325,10 @@ pre.codeblock-buttons:hover {
background: var(--primary-low);
}
}
+
+ .toggle-summary .summarization-buttons .topic-strategy-summarization {
+ margin-left: 10px;
+ }
}
#topic-footer-buttons {
diff --git a/app/assets/stylesheets/mobile/topic-post.scss b/app/assets/stylesheets/mobile/topic-post.scss
index 80c1562d81d..ff3cc54ac7b 100644
--- a/app/assets/stylesheets/mobile/topic-post.scss
+++ b/app/assets/stylesheets/mobile/topic-post.scss
@@ -199,6 +199,16 @@ a.reply-to-tab {
background: var(--blend-primary-secondary-5);
width: 100%;
}
+
+ .toggle-summary {
+ .summarization-buttons {
+ flex-direction: column;
+
+ .topic-strategy-summarization {
+ margin-top: 10px;
+ }
+ }
+ }
}
#topic-footer-buttons {
diff --git a/app/controllers/topics_controller.rb b/app/controllers/topics_controller.rb
index bc5cb894bb3..eb4ff526652 100644
--- a/app/controllers/topics_controller.rb
+++ b/app/controllers/topics_controller.rb
@@ -30,6 +30,7 @@ class TopicsController < ApplicationController
publish
reset_bump_date
set_slow_mode
+ summary
]
before_action :consider_user_for_promotion, only: :show
@@ -1167,6 +1168,30 @@ class TopicsController < ApplicationController
head :ok
end
+ def summary
+ topic = Topic.find(params[:topic_id])
+ guardian.ensure_can_see!(topic)
+ strategy = Summarization::Base.selected_strategy
+ raise Discourse::NotFound.new unless strategy
+
+ raise Discourse::InvalidAccess unless strategy.can_request_summaries?(current_user)
+
+ RateLimiter.new(current_user, "summary", 6, 5.minutes).performed!
+
+ hijack do
+ summary_opts = {
+ filter: "summary",
+ exclude_deleted_users: true,
+ exclude_hidden: true,
+ show_deleted: false,
+ }
+
+ content = TopicView.new(topic, current_user, summary_opts).posts.pluck(:raw).join("\n")
+
+ render json: { summary: strategy.summarize(content) }
+ end
+ end
+
private
def topic_params
diff --git a/app/models/summarization_strategy.rb b/app/models/summarization_strategy.rb
new file mode 100644
index 00000000000..cb8b6d7c0ba
--- /dev/null
+++ b/app/models/summarization_strategy.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+require "enum_site_setting"
+
+class SummarizationStrategy < EnumSiteSetting
+ def self.valid_value?(val)
+ true
+ end
+
+ def self.values
+ @values ||=
+ Summarization::Base.available_strategies.map do |strategy|
+ { name: strategy.display_name, value: strategy.model }
+ end
+ end
+end
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index 32e54de6d10..dc73a418142 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -2054,10 +2054,13 @@ en:
other {# minutes}
}.
- enable: "Summarize This Topic"
+ enable: "Show top replies"
disable: "Show All Posts"
short_label: "Summarize"
short_title: "Show a summary of this topic: the most interesting posts as determined by the community"
+ strategy:
+ button_title: "Summarize this topic"
+ title: "Topic summary"
deleted_filter:
enabled_description: "This topic contains deleted posts, which have been hidden."
diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml
index 6338534a7e1..2b7d573b8db 100644
--- a/config/locales/server.en.yml
+++ b/config/locales/server.en.yml
@@ -1600,6 +1600,8 @@ en:
summary_percent_filter: "When a user clicks 'Summarize This Topic', show the top % of posts"
summary_max_results: "Maximum posts returned by 'Summarize This Topic'"
summary_timeline_button: "Show a 'Summarize' button in the timeline"
+ summarization_strategy: "Additional ways to summarize content registered by plugins"
+ custom_summarization_allowed_groups: "Groups allowed to summarize contents using the `summarization_strategy`."
enable_personal_messages: "DEPRECATED, use the 'personal message enabled groups' setting instead. Allow trust level 1 (configurable via min trust to send messages) users to create messages and reply to messages. Note that staff can always send messages no matter what."
personal_message_enabled_groups: "Allow users within these groups to create messages and reply to messages. Trust level groups include all trust levels above that number, for example choosing trust_level_1 also allows trust_level_2, 3, 4 users to send PMs. Note that staff can always send messages no matter what."
diff --git a/config/routes.rb b/config/routes.rb
index 839d1d48bed..e4cd387373c 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -1319,6 +1319,11 @@ Discourse::Application.routes.draw do
topic_id: /\d+/,
}
get "t/:topic_id/summary" => "topics#show", :constraints => { topic_id: /\d+/ }
+ get "t/:topic_id/strategy-summary" => "topics#summary",
+ :constraints => {
+ topic_id: /\d+/,
+ },
+ :format => :json
put "t/:slug/:topic_id" => "topics#update", :constraints => { topic_id: /\d+/ }
put "t/:slug/:topic_id/star" => "topics#star", :constraints => { topic_id: /\d+/ }
put "t/:topic_id/star" => "topics#star", :constraints => { topic_id: /\d+/ }
diff --git a/config/site_settings.yml b/config/site_settings.yml
index 142a816719a..02daab25ffc 100644
--- a/config/site_settings.yml
+++ b/config/site_settings.yml
@@ -2351,6 +2351,17 @@ uncategorized:
client: true
default: false
+ summarization_strategy:
+ client: true
+ default: ""
+ enum: "SummarizationStrategy"
+ validator: "SummarizationValidator"
+ custom_summarization_allowed_groups:
+ client: true
+ type: group_list
+ list_type: compact
+ default: "3|14" # 3: @staff, 14: @trust_level_4
+
automatic_topic_heat_values: true
# View heat thresholds
diff --git a/lib/discourse_plugin_registry.rb b/lib/discourse_plugin_registry.rb
index 9ea9aad9ea8..59fa648bb34 100644
--- a/lib/discourse_plugin_registry.rb
+++ b/lib/discourse_plugin_registry.rb
@@ -113,6 +113,8 @@ class DiscoursePluginRegistry
define_filtered_register :list_suggested_for_providers
+ define_filtered_register :summarization_strategies
+
def self.register_auth_provider(auth_provider)
self.auth_providers << auth_provider
end
diff --git a/lib/plugin/instance.rb b/lib/plugin/instance.rb
index a49a008e1c1..eba36d1f756 100644
--- a/lib/plugin/instance.rb
+++ b/lib/plugin/instance.rb
@@ -1264,6 +1264,17 @@ class Plugin::Instance
DiscoursePluginRegistry.register_bookmarkable(RegisteredBookmarkable.new(klass), self)
end
+ ##
+ # Register an object that inherits from [Summarization::Base], which provides a way
+ # to summarize content. Staff can select which strategy to use
+ # through the `summarization_strategy` setting.
+ def register_summarization_strategy(strategy)
+ if !strategy.class.ancestors.include?(Summarization::Base)
+ raise ArgumentError.new("Not a valid summarization strategy")
+ end
+ DiscoursePluginRegistry.register_summarization_strategy(strategy, self)
+ end
+
protected
def self.js_path
diff --git a/lib/summarization/base.rb b/lib/summarization/base.rb
new file mode 100644
index 00000000000..d4886476bbc
--- /dev/null
+++ b/lib/summarization/base.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+module Summarization
+ class Base
+ def self.available_strategies
+ DiscoursePluginRegistry.summarization_strategies
+ end
+
+ def self.find_strategy(strategy_model)
+ available_strategies.detect { |s| s.model == strategy_model }
+ end
+
+ def self.selected_strategy
+ return if SiteSetting.summarization_strategy.blank?
+
+ find_strategy(SiteSetting.summarization_strategy)
+ end
+
+ def initialize(model)
+ @model = model
+ end
+
+ attr_reader :model
+
+ def can_request_summaries?(user)
+ user_group_ids = user.group_ids
+
+ SiteSetting.custom_summarization_allowed_groups_map.any? do |group_id|
+ user_group_ids.include?(group_id)
+ end
+ end
+
+ def correctly_configured?
+ raise NotImplemented
+ end
+
+ def display_name
+ raise NotImplemented
+ end
+
+ def configuration_hint
+ raise NotImplemented
+ end
+
+ def summarize(content)
+ raise NotImplemented
+ end
+ end
+end
diff --git a/lib/validators/summarization_validator.rb b/lib/validators/summarization_validator.rb
new file mode 100644
index 00000000000..8cb35fbb39f
--- /dev/null
+++ b/lib/validators/summarization_validator.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class SummarizationValidator
+ def initialize(opts = {})
+ @opts = opts
+ end
+
+ def valid_value?(val)
+ strategy = Summarization::Base.find_strategy(val)
+
+ return true unless strategy
+
+ strategy.correctly_configured?.tap { |is_valid| @strategy = strategy unless is_valid }
+ end
+
+ def error_message
+ @strategy.configuration_hint
+ end
+end
diff --git a/plugins/chat/app/controllers/chat/api/summaries_controller.rb b/plugins/chat/app/controllers/chat/api/summaries_controller.rb
new file mode 100644
index 00000000000..209aac46032
--- /dev/null
+++ b/plugins/chat/app/controllers/chat/api/summaries_controller.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+class Chat::Api::SummariesController < Chat::ApiController
+ VALID_SINCE_VALUES = [1, 3, 6, 12, 24, 72, 168]
+
+ def get_summary
+ since = params[:since].to_i
+ raise Discourse::InvalidParameters.new(:since) if !VALID_SINCE_VALUES.include?(since)
+
+ channel = Chat::Channel.find(params[:channel_id])
+ guardian.ensure_can_join_chat_channel!(channel)
+
+ strategy = Summarization::Base.selected_strategy
+ raise Discourse::NotFound.new unless strategy
+ raise Discourse::InvalidAccess unless strategy.can_request_summaries?(current_user)
+
+ RateLimiter.new(current_user, "channel_summary", 6, 5.minutes).performed!
+
+ hijack do
+ content =
+ channel
+ .chat_messages
+ .where("chat_messages.created_at > ?", since.hours.ago)
+ .includes(:user)
+ .order(created_at: :asc)
+ .pluck(:username_lower, :message)
+ .map { "#{_1}: #{_2}" }
+ .join("\n")
+
+ render json: { summary: strategy.summarize(content) }
+ end
+ end
+end
diff --git a/plugins/chat/assets/javascripts/discourse/components/channel-summary.hbs b/plugins/chat/assets/javascripts/discourse/components/channel-summary.hbs
new file mode 100644
index 00000000000..eed8d85a0c9
--- /dev/null
+++ b/plugins/chat/assets/javascripts/discourse/components/channel-summary.hbs
@@ -0,0 +1,22 @@
+
+ {{i18n "chat.summarization.description"}}
+
+
+
+
+ {{#unless this.loading}}
+
{{this.summary}}
+ {{/unless}}
+
+
+
+
+
\ No newline at end of file
diff --git a/plugins/chat/assets/javascripts/discourse/components/channel-summary.js b/plugins/chat/assets/javascripts/discourse/components/channel-summary.js
new file mode 100644
index 00000000000..881ded2750a
--- /dev/null
+++ b/plugins/chat/assets/javascripts/discourse/components/channel-summary.js
@@ -0,0 +1,65 @@
+import Component from "@glimmer/component";
+import { tracked } from "@glimmer/tracking";
+import { ajax } from "discourse/lib/ajax";
+import { popupAjaxError } from "discourse/lib/ajax-error";
+import { action } from "@ember/object";
+import I18n from "I18n";
+
+export default class ChannelSumarry extends Component {
+ @tracked sinceHours = null;
+ @tracked loading = false;
+ @tracked availableSummaries = {};
+ @tracked summary = null;
+ sinceOptions = [
+ {
+ name: I18n.t("chat.summarization.since", { count: 1 }),
+ value: 1,
+ },
+ {
+ name: I18n.t("chat.summarization.since", { count: 3 }),
+ value: 3,
+ },
+ {
+ name: I18n.t("chat.summarization.since", { count: 6 }),
+ value: 6,
+ },
+ {
+ name: I18n.t("chat.summarization.since", { count: 12 }),
+ value: 12,
+ },
+ {
+ name: I18n.t("chat.summarization.since", { count: 24 }),
+ value: 24,
+ },
+ {
+ name: I18n.t("chat.summarization.since", { count: 72 }),
+ value: 72,
+ },
+ {
+ name: I18n.t("chat.summarization.since", { count: 168 }),
+ value: 168,
+ },
+ ];
+
+ @action
+ summarize(value) {
+ this.loading = true;
+
+ if (this.availableSummaries[value]) {
+ this.summary = this.availableSummaries[value];
+ this.loading = false;
+ return;
+ }
+
+ ajax(`/chat/api/channels/${this.args.channelId}/summarize`, {
+ method: "GET",
+ data: { since: value },
+ })
+ .then((data) => {
+ this.availableSummaries[this.sinceHours] = data.summary;
+ this.summary = this.availableSummaries[this.sinceHours];
+ })
+ .catch(popupAjaxError)
+ .finally(() => (this.loading = false));
+ }
+}
diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-composer.js b/plugins/chat/assets/javascripts/discourse/components/chat-composer.js
index cb258dce87d..eff75513848 100644
--- a/plugins/chat/assets/javascripts/discourse/components/chat-composer.js
+++ b/plugins/chat/assets/javascripts/discourse/components/chat-composer.js
@@ -378,6 +378,13 @@ export default class ChatComposer extends Component {
}
}
+ @action
+ showChannelSummaryModal() {
+ showModal("channel-summary").setProperties({
+ channelId: this.args.channel.id,
+ });
+ }
+
#addMentionedUser(userData) {
const user = User.create(userData);
this.currentMessage.mentionedUsers.set(user.id, user);
diff --git a/plugins/chat/assets/javascripts/discourse/initializers/chat-setup.js b/plugins/chat/assets/javascripts/discourse/initializers/chat-setup.js
index 9365975d7fa..c6dc5fbfeca 100644
--- a/plugins/chat/assets/javascripts/discourse/initializers/chat-setup.js
+++ b/plugins/chat/assets/javascripts/discourse/initializers/chat-setup.js
@@ -21,6 +21,7 @@ export default {
this.chatService = container.lookup("service:chat");
this.site = container.lookup("service:site");
this.siteSettings = container.lookup("service:site-settings");
+ this.currentUser = container.lookup("service:current-user");
this.appEvents = container.lookup("service:app-events");
this.appEvents.on("discourse:focus-changed", this, "_handleFocusChanged");
@@ -86,6 +87,28 @@ export default {
},
});
+ const summarizationAllowedGroups =
+ this.siteSettings.custom_summarization_allowed_groups
+ .split("|")
+ .map(parseInt);
+
+ const canSummarize =
+ this.siteSettings.summarization_strategy &&
+ this.currentUser &&
+ this.currentUser.groups.some((g) =>
+ summarizationAllowedGroups.includes(g.id)
+ );
+
+ if (canSummarize) {
+ api.registerChatComposerButton({
+ translatedLabel: "chat.summarization.title",
+ id: "channel-summary",
+ icon: "magic",
+ position: "dropdown",
+ action: "showChannelSummaryModal",
+ });
+ }
+
// we want to decorate the chat quote dates regardless
// of whether the current user has chat enabled
api.decorateCookedElement(
diff --git a/plugins/chat/assets/javascripts/discourse/templates/modal/channel-summary.hbs b/plugins/chat/assets/javascripts/discourse/templates/modal/channel-summary.hbs
new file mode 100644
index 00000000000..c5bf414e9f7
--- /dev/null
+++ b/plugins/chat/assets/javascripts/discourse/templates/modal/channel-summary.hbs
@@ -0,0 +1,4 @@
+
\ No newline at end of file
diff --git a/plugins/chat/assets/stylesheets/common/channel-summary-modal.scss b/plugins/chat/assets/stylesheets/common/channel-summary-modal.scss
new file mode 100644
index 00000000000..b1ce698e8ec
--- /dev/null
+++ b/plugins/chat/assets/stylesheets/common/channel-summary-modal.scss
@@ -0,0 +1,10 @@
+.channel-summary-modal {
+ .summarization-since,
+ .summary-area {
+ margin: 10px 0 10px 0;
+ }
+
+ .summary-area {
+ min-height: 50px;
+ }
+}
diff --git a/plugins/chat/assets/stylesheets/common/index.scss b/plugins/chat/assets/stylesheets/common/index.scss
index a315be7e605..570cd2fb48d 100644
--- a/plugins/chat/assets/stylesheets/common/index.scss
+++ b/plugins/chat/assets/stylesheets/common/index.scss
@@ -56,3 +56,4 @@
@import "chat-thread-header";
@import "chat-thread-list-header";
@import "chat-thread-unread-indicator";
+@import "channel-summary-modal";
diff --git a/plugins/chat/config/locales/client.en.yml b/plugins/chat/config/locales/client.en.yml
index 6bb9ac98116..926199872cc 100644
--- a/plugins/chat/config/locales/client.en.yml
+++ b/plugins/chat/config/locales/client.en.yml
@@ -114,6 +114,14 @@ en:
heading: "Chat"
join: "Join"
last_visit: "last visit"
+
+ summarization:
+ title: "Summarize messages"
+ description: "Select an option below to summarize the conversation sent during the desired timeframe."
+ summarize: "Summarize"
+ since:
+ one: "Last hour"
+ other: "Last %{count} hours"
mention_warning:
dismiss: "dismiss"
cannot_see: "%{username} can't access this channel and was not notified."
diff --git a/plugins/chat/config/routes.rb b/plugins/chat/config/routes.rb
index 6ea17804cc3..d00c8c223b9 100644
--- a/plugins/chat/config/routes.rb
+++ b/plugins/chat/config/routes.rb
@@ -35,6 +35,8 @@ Chat::Engine.routes.draw do
put "/channels/:channel_id/messages/:message_id/restore" => "channel_messages#restore"
delete "/channels/:channel_id/messages/:message_id" => "channel_messages#destroy"
+
+ get "/channels/:channel_id/summarize" => "summaries#get_summary"
end
# direct_messages_controller routes
diff --git a/plugins/chat/spec/requests/chat/api/summaries_controller_spec.rb b/plugins/chat/spec/requests/chat/api/summaries_controller_spec.rb
new file mode 100644
index 00000000000..19f9f7d1df7
--- /dev/null
+++ b/plugins/chat/spec/requests/chat/api/summaries_controller_spec.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+RSpec.describe Chat::Api::SummariesController do
+ fab!(:current_user) { Fabricate(:user) }
+ fab!(:group) { Fabricate(:group) }
+ let(:plugin) { Plugin::Instance.new }
+
+ before do
+ group.add(current_user)
+
+ strategy = DummyCustomSummarization.new("dummy")
+ plugin.register_summarization_strategy(strategy)
+ SiteSetting.summarization_strategy = strategy.model
+ SiteSetting.custom_summarization_allowed_groups = group.id
+
+ SiteSetting.chat_enabled = true
+ SiteSetting.chat_allowed_groups = group.id
+ sign_in(current_user)
+ end
+
+ describe "#get_summary" do
+ context "when the user is not allowed to join the channel" do
+ fab!(:channel) { Fabricate(:private_category_channel) }
+
+ it "returns a 403" do
+ get "/chat/api/channels/#{channel.id}/summarize", params: { since: 6 }
+
+ expect(response.status).to eq(403)
+ end
+ end
+ end
+end
diff --git a/plugins/chat/spec/system/chat_summarization_spec.rb b/plugins/chat/spec/system/chat_summarization_spec.rb
new file mode 100644
index 00000000000..38af780b02b
--- /dev/null
+++ b/plugins/chat/spec/system/chat_summarization_spec.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+RSpec.describe "Summarize a channel since your last visit", type: :system, js: true do
+ fab!(:current_user) { Fabricate(:user) }
+ fab!(:group) { Fabricate(:group) }
+ let(:plugin) { Plugin::Instance.new }
+
+ fab!(:channel) { Fabricate(:chat_channel) }
+
+ fab!(:message_1) { Fabricate(:chat_message, chat_channel: channel) }
+
+ let(:chat) { PageObjects::Pages::Chat.new }
+
+ before do
+ group.add(current_user)
+
+ strategy = DummyCustomSummarization.new("dummy")
+ plugin.register_summarization_strategy(strategy)
+ SiteSetting.summarization_strategy = strategy.model
+ SiteSetting.custom_summarization_allowed_groups = group.id.to_s
+
+ SiteSetting.chat_enabled = true
+ SiteSetting.chat_allowed_groups = group.id.to_s
+ sign_in(current_user)
+ chat_system_bootstrap(current_user, [channel])
+ end
+
+ it "displays a summary of the messages since the selected timeframe" do
+ chat.visit_channel(channel)
+
+ find(".chat-composer-dropdown__trigger-btn").click
+ find(".chat-composer-dropdown__action-btn.channel-summary").click
+
+ expect(page.has_css?(".channel-summary-modal", wait: 5)).to eq(true)
+
+ find(".summarization-since").click
+ find(".select-kit-row[data-value=\"3\"]").click
+
+ expect(find(".summary-area").text).to eq(DummyCustomSummarization::RESPONSE)
+ end
+end
diff --git a/spec/lib/summarization/base_spec.rb b/spec/lib/summarization/base_spec.rb
new file mode 100644
index 00000000000..6b211d6f95a
--- /dev/null
+++ b/spec/lib/summarization/base_spec.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+describe Summarization::Base do
+ fab!(:user) { Fabricate(:user) }
+ fab!(:group) { Fabricate(:group) }
+
+ before { group.add(user) }
+
+ describe "#can_request_summaries?" do
+ it "returns true if the user group is present in the custom_summarization_allowed_groups_map setting" do
+ SiteSetting.custom_summarization_allowed_groups = group.id
+
+ expect(described_class.new(nil).can_request_summaries?(user)).to eq(true)
+ end
+
+ it "returns false if the user group is not present in the custom_summarization_allowed_groups_map setting" do
+ SiteSetting.custom_summarization_allowed_groups = ""
+
+ expect(described_class.new(nil).can_request_summaries?(user)).to eq(false)
+ end
+ end
+end
diff --git a/spec/requests/topics_controller_spec.rb b/spec/requests/topics_controller_spec.rb
index b8e94e72742..653d2c2a8a0 100644
--- a/spec/requests/topics_controller_spec.rb
+++ b/spec/requests/topics_controller_spec.rb
@@ -5456,4 +5456,57 @@ RSpec.describe TopicsController do
end
end
end
+
+ describe "#summary" do
+ fab!(:topic) { Fabricate(:topic) }
+ let(:plugin) { Plugin::Instance.new }
+
+ before do
+ strategy = DummyCustomSummarization.new("dummy")
+ plugin.register_summarization_strategy(strategy)
+ SiteSetting.summarization_strategy = strategy.model
+ end
+
+ context "for anons" do
+ it "returns a 404" do
+ get "/t/#{topic.id}/strategy-summary.json"
+
+ expect(response.status).to eq(403)
+ end
+ end
+
+ context "when the user is a member of an allowlisted group" do
+ fab!(:user) { Fabricate(:leader) }
+
+ before { sign_in(user) }
+
+ it "returns a 404 if there is no topic" do
+ invalid_topic_id = 999
+
+ get "/t/#{invalid_topic_id}/strategy-summary.json"
+
+ expect(response.status).to eq(404)
+ end
+
+ it "returns a 403 if not allowed to see the topic" do
+ pm = Fabricate(:private_message_topic)
+
+ get "/t/#{pm.id}/strategy-summary.json"
+
+ expect(response.status).to eq(403)
+ end
+ end
+
+ context "when the user is not a member of an allowlited group" do
+ fab!(:user) { Fabricate(:user) }
+
+ before { sign_in(user) }
+
+ it "return a 404" do
+ get "/t/#{topic.id}/strategy-summary.json"
+
+ expect(response.status).to eq(403)
+ end
+ end
+ end
end
diff --git a/spec/support/dummy_custom_summarization.rb b/spec/support/dummy_custom_summarization.rb
new file mode 100644
index 00000000000..05d4593113d
--- /dev/null
+++ b/spec/support/dummy_custom_summarization.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+class DummyCustomSummarization < Summarization::Base
+ RESPONSE = "This is a summary of the content you gave me"
+
+ def display_name
+ "dummy"
+ end
+
+ def correctly_configured?
+ true
+ end
+
+ def configuration_hint
+ "hint"
+ end
+
+ def summarize(_content)
+ RESPONSE
+ end
+end
diff --git a/spec/system/topic_summarization_spec.rb b/spec/system/topic_summarization_spec.rb
new file mode 100644
index 00000000000..d54bc8444d6
--- /dev/null
+++ b/spec/system/topic_summarization_spec.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+RSpec.describe "Topic summarization", type: :system, js: true do
+ fab!(:user) { Fabricate(:admin) }
+
+ # has_summary to force topic map to be present.
+ fab!(:topic) { Fabricate(:topic, has_summary: true) }
+ fab!(:post_1) { Fabricate(:post, topic: topic) }
+ fab!(:post_2) { Fabricate(:post, topic: topic) }
+
+ let(:plugin) { Plugin::Instance.new }
+
+ before do
+ sign_in(user)
+ strategy = DummyCustomSummarization.new("dummy")
+ plugin.register_summarization_strategy(strategy)
+ SiteSetting.summarization_strategy = strategy.model
+ end
+
+ it "returns a summary using the selected timeframe" do
+ visit("/t/-/#{topic.id}")
+
+ find(".topic-strategy-summarization").click
+
+ expect(page.has_css?(".topic-summary-modal", wait: 5)).to eq(true)
+
+ expect(find(".summary-area").text).to eq(DummyCustomSummarization::RESPONSE)
+ end
+end