From 5fec8fe79eeac7dae40013ff05f07ef18b568e38 Mon Sep 17 00:00:00 2001 From: Mark VanLandingham Date: Mon, 21 Apr 2025 15:17:10 -0500 Subject: [PATCH] FEATURE: Experimental Private Message Bot Homepage (#1159) Overview This PR introduces a Bot Homepage that was first introduced at https://ask.discourse.org/. Key Features: Add a bot homepage: /discourse-ai/ai-bot/conversations Display a sidebar with previous bot conversations Infinite scroll for large counts Sidebar still visible when navigation mode is header_dropdown Sidebar visible on homepage and bot PM show view Add New Question button to the bottom of sidebar on bot PM show view Add persona picker to homepage --- .../ai_bot/conversations_controller.rb | 35 ++ .../components/ai-bot-header-icon.gjs | 4 + .../ai-bot-sidebar-new-conversation.gjs | 27 ++ .../discourse-ai-bot-conversations.js | 70 ++++ .../discourse-ai-bot-dashboard-route-map.js | 5 + .../discourse/lib/ai-bot-helper.js | 51 ++- .../ai-bot-conversations-hidden-submit.js | 62 ++++ .../ai-conversations-sidebar-manager.js | 40 +++ .../discourse-ai-bot-conversations.gjs | 51 +++ .../initializers/ai-conversations-sidebar.js | 256 ++++++++++++++ .../modules/ai-bot-conversations/common.scss | 325 ++++++++++++++++++ config/locales/client.en.yml | 8 + config/routes.rb | 4 + config/settings.yml | 4 + plugin.rb | 2 + spec/system/ai_bot/personal_message_spec.rb | 185 ++++++++++ .../page_objects/components/ai_pm_homepage.rb | 32 ++ .../acceptance/post-helper-menu-test.js | 2 + .../acceptance/topic-summary-test.js | 2 + 19 files changed, 1149 insertions(+), 16 deletions(-) create mode 100644 app/controllers/discourse_ai/ai_bot/conversations_controller.rb create mode 100644 assets/javascripts/discourse/components/ai-bot-sidebar-new-conversation.gjs create mode 100644 assets/javascripts/discourse/controllers/discourse-ai-bot-conversations.js create mode 100644 assets/javascripts/discourse/discourse-ai-bot-dashboard-route-map.js create mode 100644 assets/javascripts/discourse/services/ai-bot-conversations-hidden-submit.js create mode 100644 assets/javascripts/discourse/services/ai-conversations-sidebar-manager.js create mode 100644 assets/javascripts/discourse/templates/discourse-ai-bot-conversations.gjs create mode 100644 assets/javascripts/initializers/ai-conversations-sidebar.js create mode 100644 assets/stylesheets/modules/ai-bot-conversations/common.scss create mode 100644 spec/system/ai_bot/personal_message_spec.rb create mode 100644 spec/system/page_objects/components/ai_pm_homepage.rb diff --git a/app/controllers/discourse_ai/ai_bot/conversations_controller.rb b/app/controllers/discourse_ai/ai_bot/conversations_controller.rb new file mode 100644 index 00000000..ee569b8a --- /dev/null +++ b/app/controllers/discourse_ai/ai_bot/conversations_controller.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module DiscourseAi + module AiBot + class ConversationsController < ::ApplicationController + requires_plugin ::DiscourseAi::PLUGIN_NAME + requires_login + + def index + page = params[:page].to_i + per_page = params[:per_page]&.to_i || 40 + + bot_user_ids = EntryPoint.all_bot_ids + base_query = + Topic + .private_messages_for_user(current_user) + .joins(:topic_users) + .where(topic_users: { user_id: bot_user_ids }) + .distinct + total = base_query.count + pms = base_query.order(last_posted_at: :desc).offset(page * per_page).limit(per_page) + + render json: { + conversations: serialize_data(pms, BasicTopicSerializer), + meta: { + total: total, + page: page, + per_page: per_page, + has_more: total > (page + 1) * per_page, + }, + } + end + end + end +end diff --git a/assets/javascripts/discourse/components/ai-bot-header-icon.gjs b/assets/javascripts/discourse/components/ai-bot-header-icon.gjs index 1907f517..3cbda25c 100644 --- a/assets/javascripts/discourse/components/ai-bot-header-icon.gjs +++ b/assets/javascripts/discourse/components/ai-bot-header-icon.gjs @@ -9,6 +9,7 @@ export default class AiBotHeaderIcon extends Component { @service currentUser; @service siteSettings; @service composer; + @service router; get bots() { const availableBots = this.currentUser.ai_enabled_chat_bots @@ -24,6 +25,9 @@ export default class AiBotHeaderIcon extends Component { @action compose() { + if (this.siteSettings.ai_enable_experimental_bot_ux) { + return this.router.transitionTo("discourse-ai-bot-conversations"); + } composeAiBotMessage(this.bots[0], this.composer); } diff --git a/assets/javascripts/discourse/components/ai-bot-sidebar-new-conversation.gjs b/assets/javascripts/discourse/components/ai-bot-sidebar-new-conversation.gjs new file mode 100644 index 00000000..915b8283 --- /dev/null +++ b/assets/javascripts/discourse/components/ai-bot-sidebar-new-conversation.gjs @@ -0,0 +1,27 @@ +import Component from "@glimmer/component"; +import { service } from "@ember/service"; +import DButton from "discourse/components/d-button"; +import { AI_CONVERSATIONS_PANEL } from "../services/ai-conversations-sidebar-manager"; + +export default class AiBotSidebarNewConversation extends Component { + @service router; + @service sidebarState; + + get shouldRender() { + return ( + this.router.currentRouteName !== "discourse-ai-bot-conversations" && + this.sidebarState.isCurrentPanel(AI_CONVERSATIONS_PANEL) + ); + } + + +} diff --git a/assets/javascripts/discourse/controllers/discourse-ai-bot-conversations.js b/assets/javascripts/discourse/controllers/discourse-ai-bot-conversations.js new file mode 100644 index 00000000..c23923a3 --- /dev/null +++ b/assets/javascripts/discourse/controllers/discourse-ai-bot-conversations.js @@ -0,0 +1,70 @@ +import Controller from "@ember/controller"; +import { action } from "@ember/object"; +import { service } from "@ember/service"; +import { tracked } from "@ember-compat/tracked-built-ins"; + +export default class DiscourseAiBotConversations extends Controller { + @service aiBotConversationsHiddenSubmit; + @service currentUser; + + @tracked selectedPersona = this.personaOptions[0].username; + + textarea = null; + + init() { + super.init(...arguments); + this.selectedPersonaChanged(this.selectedPersona); + } + + get personaOptions() { + if (this.currentUser.ai_enabled_personas) { + return this.currentUser.ai_enabled_personas + .filter((persona) => persona.username) + .map((persona) => { + return { + id: persona.id, + username: persona.username, + name: persona.name, + description: persona.description, + }; + }); + } + } + + get displayPersonaSelector() { + return this.personaOptions.length > 1; + } + + get filterable() { + return this.personaOptions.length > 4; + } + + @action + selectedPersonaChanged(username) { + this.selectedPersona = username; + this.aiBotConversationsHiddenSubmit.personaUsername = username; + } + + @action + updateInputValue(event) { + this._autoExpandTextarea(); + this.aiBotConversationsHiddenSubmit.inputValue = event.target.value; + } + + @action + handleKeyDown(event) { + if (event.key === "Enter" && !event.shiftKey) { + this.aiBotConversationsHiddenSubmit.submitToBot(); + } + } + + @action + setTextArea(element) { + this.textarea = element; + } + + _autoExpandTextarea() { + this.textarea.style.height = "auto"; + this.textarea.style.height = this.textarea.scrollHeight + "px"; + } +} diff --git a/assets/javascripts/discourse/discourse-ai-bot-dashboard-route-map.js b/assets/javascripts/discourse/discourse-ai-bot-dashboard-route-map.js new file mode 100644 index 00000000..0c5b9213 --- /dev/null +++ b/assets/javascripts/discourse/discourse-ai-bot-dashboard-route-map.js @@ -0,0 +1,5 @@ +export default function () { + this.route("discourse-ai-bot-conversations", { + path: "/discourse-ai/ai-bot/conversations", + }); +} diff --git a/assets/javascripts/discourse/lib/ai-bot-helper.js b/assets/javascripts/discourse/lib/ai-bot-helper.js index 69832586..d7fb0132 100644 --- a/assets/javascripts/discourse/lib/ai-bot-helper.js +++ b/assets/javascripts/discourse/lib/ai-bot-helper.js @@ -23,24 +23,43 @@ export function showShareConversationModal(modal, topicId) { .catch(popupAjaxError); } -export function composeAiBotMessage(targetBot, composer) { +export async function composeAiBotMessage( + targetBot, + composer, + options = { + skipFocus: false, + topicBody: "", + personaUsername: null, + } +) { const currentUser = composer.currentUser; const draftKey = "new_private_message_ai_" + new Date().getTime(); - let botUsername = currentUser.ai_enabled_chat_bots.find( - (bot) => bot.model_name === targetBot - ).username; + let botUsername; + if (targetBot) { + botUsername = currentUser.ai_enabled_chat_bots.find( + (bot) => bot.model_name === targetBot + )?.username; + } else if (options.personaUsername) { + botUsername = options.personaUsername; + } else { + botUsername = currentUser.ai_enabled_chat_bots[0].username; + } - composer.focusComposer({ - fallbackToNewTopic: true, - openOpts: { - action: Composer.PRIVATE_MESSAGE, - recipients: botUsername, - topicTitle: i18n("discourse_ai.ai_bot.default_pm_prefix"), - archetypeId: "private_message", - draftKey, - hasGroups: false, - warningsDisabled: true, - }, - }); + const data = { + action: Composer.PRIVATE_MESSAGE, + recipients: botUsername, + topicTitle: i18n("discourse_ai.ai_bot.default_pm_prefix"), + archetypeId: "private_message", + draftKey, + hasGroups: false, + warningsDisabled: true, + }; + + if (options.skipFocus) { + data.topicBody = options.topicBody; + await composer.open(data); + } else { + composer.focusComposer({ fallbackToNewTopic: true, openOpts: data }); + } } diff --git a/assets/javascripts/discourse/services/ai-bot-conversations-hidden-submit.js b/assets/javascripts/discourse/services/ai-bot-conversations-hidden-submit.js new file mode 100644 index 00000000..7796007d --- /dev/null +++ b/assets/javascripts/discourse/services/ai-bot-conversations-hidden-submit.js @@ -0,0 +1,62 @@ +import { action } from "@ember/object"; +import { next } from "@ember/runloop"; +import Service, { service } from "@ember/service"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import { i18n } from "discourse-i18n"; +import { composeAiBotMessage } from "../lib/ai-bot-helper"; + +export default class AiBotConversationsHiddenSubmit extends Service { + @service composer; + @service aiConversationsSidebarManager; + @service dialog; + + personaUsername; + + inputValue = ""; + + @action + focusInput() { + this.composer.destroyDraft(); + this.composer.close(); + next(() => { + document.getElementById("custom-homepage-input").focus(); + }); + } + + @action + async submitToBot() { + this.composer.destroyDraft(); + this.composer.close(); + + if (this.inputValue.length < 10) { + return this.dialog.alert({ + message: i18n( + "discourse_ai.ai_bot.conversations.min_input_length_message" + ), + didConfirm: () => this.focusInput(), + didCancel: () => this.focusInput(), + }); + } + + // we are intentionally passing null as the targetBot to allow for the + // function to select the first available bot. This will be refactored in the + // future to allow for selecting a specific bot. + await composeAiBotMessage(null, this.composer, { + skipFocus: true, + topicBody: this.inputValue, + personaUsername: this.personaUsername, + }); + + try { + await this.composer.save(); + this.aiConversationsSidebarManager.newTopicForceSidebar = true; + if (this.inputValue.length > 10) { + // prevents submitting same message again when returning home + // but avoids deleting too-short message on submit + this.inputValue = ""; + } + } catch (e) { + popupAjaxError(e); + } + } +} diff --git a/assets/javascripts/discourse/services/ai-conversations-sidebar-manager.js b/assets/javascripts/discourse/services/ai-conversations-sidebar-manager.js new file mode 100644 index 00000000..ce41d0f1 --- /dev/null +++ b/assets/javascripts/discourse/services/ai-conversations-sidebar-manager.js @@ -0,0 +1,40 @@ +import { tracked } from "@glimmer/tracking"; +import Service, { service } from "@ember/service"; +import { ADMIN_PANEL, MAIN_PANEL } from "discourse/lib/sidebar/panels"; + +export const AI_CONVERSATIONS_PANEL = "ai-conversations"; + +export default class AiConversationsSidebarManager extends Service { + @service sidebarState; + + @tracked newTopicForceSidebar = false; + + forceCustomSidebar() { + // Set the panel to your custom panel + this.sidebarState.setPanel(AI_CONVERSATIONS_PANEL); + + // Use separated mode to ensure independence from hamburger menu + this.sidebarState.setSeparatedMode(); + + // Hide panel switching buttons to keep UI clean + this.sidebarState.hideSwitchPanelButtons(); + + this.sidebarState.isForcingSidebar = true; + document.body.classList.add("has-ai-conversations-sidebar"); + return true; + } + + stopForcingCustomSidebar() { + // This method is called when leaving your route + // Only restore main panel if we previously forced ours + document.body.classList.remove("has-ai-conversations-sidebar"); + const isAdminSidebarActive = + this.sidebarState.currentPanel?.key === ADMIN_PANEL; + // only restore main panel if we previously forced our sidebar + // and not if we are in admin sidebar + if (this.sidebarState.isForcingSidebar && !isAdminSidebarActive) { + this.sidebarState.setPanel(MAIN_PANEL); // Return to main sidebar panel + this.sidebarState.isForcingSidebar = false; + } + } +} diff --git a/assets/javascripts/discourse/templates/discourse-ai-bot-conversations.gjs b/assets/javascripts/discourse/templates/discourse-ai-bot-conversations.gjs new file mode 100644 index 00000000..0085e06b --- /dev/null +++ b/assets/javascripts/discourse/templates/discourse-ai-bot-conversations.gjs @@ -0,0 +1,51 @@ +import { hash } from "@ember/helper"; +import { on } from "@ember/modifier"; +import didInsert from "@ember/render-modifiers/modifiers/did-insert"; +import RouteTemplate from "ember-route-template"; +import DButton from "discourse/components/d-button"; +import { i18n } from "discourse-i18n"; +import DropdownSelectBox from "select-kit/components/dropdown-select-box"; + +export default RouteTemplate( +