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