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) + ); + } + + + {{#if this.shouldRender}} + + {{/if}} + +} 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( + + + {{#if @controller.displayPersonaSelector}} + + + + {{/if}} + + + + {{i18n "discourse_ai.ai_bot.conversations.header"}} + + + + + + {{i18n "discourse_ai.ai_bot.conversations.disclaimer"}} + + + + +); diff --git a/assets/javascripts/initializers/ai-conversations-sidebar.js b/assets/javascripts/initializers/ai-conversations-sidebar.js new file mode 100644 index 00000000..662624f5 --- /dev/null +++ b/assets/javascripts/initializers/ai-conversations-sidebar.js @@ -0,0 +1,256 @@ +import { tracked } from "@glimmer/tracking"; +import { TrackedArray } from "@ember-compat/tracked-built-ins"; +import { ajax } from "discourse/lib/ajax"; +import { bind } from "discourse/lib/decorators"; +import { withPluginApi } from "discourse/lib/plugin-api"; +import { i18n } from "discourse-i18n"; +import AiBotSidebarNewConversation from "../discourse/components/ai-bot-sidebar-new-conversation"; +import { isPostFromAiBot } from "../discourse/lib/ai-bot-helper"; +import { AI_CONVERSATIONS_PANEL } from "../discourse/services/ai-conversations-sidebar-manager"; + +export default { + name: "ai-conversations-sidebar", + + initialize() { + withPluginApi((api) => { + const currentUser = api.container.lookup("service:current-user"); + if (!currentUser) { + return; + } + + const aiConversationsSidebarManager = api.container.lookup( + "service:ai-conversations-sidebar-manager" + ); + const appEvents = api.container.lookup("service:app-events"); + const messageBus = api.container.lookup("service:message-bus"); + + api.addSidebarPanel( + (BaseCustomSidebarPanel) => + class AiConversationsSidebarPanel extends BaseCustomSidebarPanel { + key = AI_CONVERSATIONS_PANEL; + hidden = true; + displayHeader = true; + expandActiveSection = true; + } + ); + + api.renderInOutlet("sidebar-footer-actions", AiBotSidebarNewConversation); + api.addSidebarSection( + (BaseCustomSidebarSection, BaseCustomSidebarSectionLink) => { + const AiConversationLink = class extends BaseCustomSidebarSectionLink { + route = "topic.fromParamsNear"; + + constructor(topic) { + super(...arguments); + this.topic = topic; + } + + get name() { + return this.topic.title; + } + + get models() { + return [ + this.topic.slug, + this.topic.id, + this.topic.last_read_post_number || 0, + ]; + } + + get title() { + return this.topic.title; + } + + get text() { + return this.topic.title; + } + + get classNames() { + return `ai-conversation-${this.topic.id}`; + } + }; + + return class extends BaseCustomSidebarSection { + @tracked links = new TrackedArray(); + @tracked topics = []; + @tracked hasMore = []; + page = 0; + isFetching = false; + totalTopicsCount = 0; + + constructor() { + super(...arguments); + this.fetchMessages(); + + appEvents.on("topic:created", this, "addNewMessageToSidebar"); + } + + @bind + willDestroy() { + this.removeScrollListener(); + appEvents.on("topic:created", this, "addNewMessageToSidebar"); + } + + get name() { + return "ai-conversations-history"; + } + + get text() { + return i18n( + "discourse_ai.ai_bot.conversations.messages_sidebar_title" + ); + } + + get sidebarElement() { + return document.querySelector( + ".sidebar-wrapper .sidebar-sections" + ); + } + + addNewMessageToSidebar(topic) { + this.addNewMessage(topic); + this.watchForTitleUpdate(topic); + } + + @bind + removeScrollListener() { + const sidebar = this.sidebarElement; + if (sidebar) { + sidebar.removeEventListener("scroll", this.scrollHandler); + } + } + + @bind + attachScrollListener() { + const sidebar = this.sidebarElement; + if (sidebar) { + sidebar.addEventListener("scroll", this.scrollHandler); + } + } + + @bind + scrollHandler() { + const sidebarElement = this.sidebarElement; + if (!sidebarElement) { + return; + } + + const scrollPosition = sidebarElement.scrollTop; + const scrollHeight = sidebarElement.scrollHeight; + const clientHeight = sidebarElement.clientHeight; + + // When user has scrolled to bottom with a small threshold + if (scrollHeight - scrollPosition - clientHeight < 100) { + if (this.hasMore && !this.isFetching) { + this.loadMore(); + } + } + } + + async fetchMessages(isLoadingMore = false) { + if (this.isFetching) { + return; + } + + try { + this.isFetching = true; + const data = await ajax( + "/discourse-ai/ai-bot/conversations.json", + { + data: { page: this.page, per_page: 40 }, + } + ); + + if (isLoadingMore) { + this.topics = [...this.topics, ...data.conversations]; + } else { + this.topics = data.conversations; + } + + this.totalTopicsCount = data.meta.total; + this.hasMore = data.meta.has_more; + this.isFetching = false; + this.removeScrollListener(); + this.buildSidebarLinks(); + this.attachScrollListener(); + } catch { + this.isFetching = false; + } + } + + loadMore() { + if (this.isFetching || !this.hasMore) { + return; + } + + this.page = this.page + 1; + this.fetchMessages(true); + } + + buildSidebarLinks() { + this.links = this.topics.map( + (topic) => new AiConversationLink(topic) + ); + } + + addNewMessage(newTopic) { + this.links = [new AiConversationLink(newTopic), ...this.links]; + } + + watchForTitleUpdate(topic) { + const channel = `/discourse-ai/ai-bot/topic/${topic.topic_id}`; + const topicId = topic.topic_id; + const callback = this.updateTopicTitle.bind(this); + messageBus.subscribe(channel, ({ title }) => { + callback(topicId, title); + messageBus.unsubscribe(channel); + }); + } + + updateTopicTitle(topicId, title) { + // update the topic title in the sidebar, instead of the default title + const text = document.querySelector( + `.sidebar-section-link-wrapper .ai-conversation-${topicId} .sidebar-section-link-content-text` + ); + if (text) { + text.innerText = title; + } + } + }; + }, + AI_CONVERSATIONS_PANEL + ); + + const setSidebarPanel = (transition) => { + if (transition?.to?.name === "discourse-ai-bot-conversations") { + return aiConversationsSidebarManager.forceCustomSidebar(); + } + + const topic = api.container.lookup("controller:topic").model; + if ( + topic?.archetype === "private_message" && + topic.postStream.posts.some((post) => + isPostFromAiBot(post, currentUser) + ) + ) { + return aiConversationsSidebarManager.forceCustomSidebar(); + } + + // newTopicForceSidebar is set to true when a new topic is created. We have + // this because the condition `postStream.posts` above will not be true as the bot response + // is not in the postStream yet when this initializer is ran. So we need to force + // the sidebar to open when creating a new topic. After that, we set it to false again. + if (aiConversationsSidebarManager.newTopicForceSidebar) { + aiConversationsSidebarManager.newTopicForceSidebar = false; + return aiConversationsSidebarManager.forceCustomSidebar(); + } + + aiConversationsSidebarManager.stopForcingCustomSidebar(); + }; + + api.container + .lookup("service:router") + .on("routeDidChange", setSidebarPanel); + }); + }, +}; diff --git a/assets/stylesheets/modules/ai-bot-conversations/common.scss b/assets/stylesheets/modules/ai-bot-conversations/common.scss new file mode 100644 index 00000000..3bff178f --- /dev/null +++ b/assets/stylesheets/modules/ai-bot-conversations/common.scss @@ -0,0 +1,325 @@ +// Hide the new question button from the hamburger menu's footer +.hamburger-panel .ai-new-question-button { + display: none; +} + +body.has-ai-conversations-sidebar { + .sidebar-wrapper { + .sidebar-footer-actions { + display: flex; + flex-direction: column; + margin-left: 0; + align-items: flex-end; + width: 100%; + + .ai-new-question-button { + width: 100%; + } + } + + .sidebar-container { + border: none; + } + + // ai related sidebar content + [data-section-name="ai-conversations-history"] { + .sidebar-section-header-wrapper { + pointer-events: none; + font-size: var(--font-down-1); + + .sidebar-section-header-caret { + display: none; + } + + .sidebar-section-header-text { + letter-spacing: 0.5px; + } + } + + .sidebar-section-link-wrapper { + .sidebar-section-link { + height: unset; + padding-block: 0.65em; + font-size: var(--font-down-1); + letter-spacing: 0.35px; + border-radius: 0 var(--border-radius) var(--border-radius) 0; + + .sidebar-section-link-content-text { + white-space: normal; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; + } + } + } + + .sidebar-section-link-prefix { + align-self: start; + } + } + } + + // topic elements + #topic-footer-button-share-and-invite, + body:not(.staff) #topic-footer-button-archive, + #topic-footer-buttons .topic-notifications-button, + .bookmark-menu-trigger, + .more-topics__container, + .private-message-glyph-wrapper, + .topic-header-participants, + .topic-above-footer-buttons-outlet, + .topic-map, + .timeline-ago, + #topic-footer-buttons .topic-footer-main-buttons details { + display: none; + } + + .topic-timer-info { + border: none; + } + + .topic-owner .actions .create-flag { + // why flag my own post + display: none; + } + + .container.posts { + margin-bottom: 0; + + .topic-navigation.with-timeline { + top: calc(var(--header-offset, 60px) + 5.5em); + } + + .topic-navigation { + .topic-notifications-button { + display: none; + } + } + } + + #topic-title { + display: flex; + justify-content: center; + width: 100%; + + .title-wrapper { + width: 100%; + max-width: 960px; + } + } + + .small-action, + .onscreen-post .row { + justify-content: center; + } + + #topic-footer-buttons { + width: calc(100% - 6.5em); + margin-top: 0; + + @media screen and (max-width: 924px) { + max-width: unset; + width: 100%; + } + + @media screen and (min-width: 1300px) { + width: 100%; + max-width: 51em; + } + + .topic-footer-main-buttons { + justify-content: flex-end; + + @media screen and (min-width: 1180px) { + margin-right: 0.6em; + } + + @media screen and (max-width: 924px) { + margin-right: 0.6em; + } + } + } + + #topic-progress-wrapper.docked { + display: none; + } + + @media screen and (max-width: 924px) { + .archetype-private_message .topic-post:last-child { + margin-bottom: 0; + } + } + + nav.post-controls .actions button { + padding: 0.5em 0.65em; + + &.reply { + .d-icon { + margin-right: 0.45em; + } + } + } + + .topic-footer-main-buttons { + margin-left: calc(var(--topic-avatar-width) - 1.15em); + } + + .ai-bot-conversations { + height: calc(100dvh - var(--header-offset) - 1.25em); + + @media screen and (min-width: 675px) { + border: 1px solid var(--primary-low); + padding: 2em 2em 3em; + border-radius: var(--border-radius); + height: calc(100dvh - var(--header-offset) - 10em); + } + + &__persona-selector { + display: flex; + justify-content: flex-end; + } + + &__content-wrapper { + display: flex; + flex-direction: column; + box-sizing: border-box; + align-items: center; + justify-content: center; + height: 100%; + } + + &__input-wrapper { + display: flex; + align-items: stretch; + gap: 0.5em; + width: 100%; + max-width: 90dvw; + margin-top: 2em; + + @media screen and (min-width: 600px) { + width: 80%; + max-width: 46em; + } + + .btn-primary { + align-self: end; + min-height: 2.5em; + } + + #ai-bot-conversations-input { + width: 100%; + margin: 0; + resize: none; + border-radius: var(--d-button-border-radius); + max-height: 30vh; + + &:focus { + outline: none; + border-color: var(--primary-high); + } + } + } + + h1 { + margin-bottom: 0.45em; + max-width: 20em; + text-align: center; + font-size: var(--font-up-6); + line-height: var(--line-height-medium); + + @media screen and (min-height: 300px) { + margin-top: -1em; + } + + @media screen and (min-height: 600px) { + margin-top: -3em; + } + + @media screen and (min-height: 900px) { + margin-top: -6em; + } + } + + .ai-disclaimer { + text-align: center; + font-size: var(--font-down-1); + color: var(--primary-700); + + @media screen and (min-width: 600px) { + width: 80%; + max-width: 46em; + } + } + + .sidebar-footer-wrapper { + display: flex; + + .powered-by-discourse { + display: block; + } + + button { + display: none; + } + } + } + + // composer + .reply-details .dropdown-select-box.composer-actions, + .composer-fields { + display: none; + } + + // hide user stuff + .new-user-wrapper { + .user-navigation { + display: none; + } + } + + .user-main .about.collapsed-info .details { + display: none; + } + + .user-content { + margin-top: 0; + } + + @media screen and (max-width: 600px) { + .share-ai-conversation-button { + .d-icon { + margin: 0; + } + + .d-button-label { + display: none; + } + } + } + + .mobile-view { + nav.post-controls .actions button.reply .d-icon { + margin: 0; + } + + .search-dropdown { + display: none; + } + + .sidebar-custom-sections { + display: none; + } + } + + // custom user card link + .user-card-meta__profile-link { + display: block; + padding: 0.5em 0 0.25em; + + .d-icon { + font-size: var(--font-down-1); + margin-right: 0.15em; + } + } +} diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 1811de61..2a5f7138 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -716,6 +716,14 @@ en: 5-pro: "Gemini" mixtral-8x7B-Instruct-V0: "1": "Mixtral-8x7B V0.1" + conversations: + header: "What can I help with?" + submit: "Submit question" + disclaimer: "Generative AI can make mistakes. Verify important information." + placeholder: "Ask a question..." + new: "New Question" + min_input_length_message: "Message must be longer than 10 characters" + messages_sidebar_title: "Conversations" sentiments: dashboard: title: "Sentiment" diff --git a/config/routes.rb b/config/routes.rb index c5df0fc7..a41c966a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -37,6 +37,10 @@ DiscourseAi::Engine.routes.draw do get "/preview/:topic_id" => "shared_ai_conversations#preview" end + scope module: :ai_bot, path: "/ai-bot/conversations" do + get "/" => "conversations#index" + end + scope module: :ai_bot, path: "/ai-bot/artifacts" do get "/:id" => "artifacts#show" get "/:id/:version" => "artifacts#show" diff --git a/config/settings.yml b/config/settings.yml index c8d9fb8d..cf679d7a 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -390,3 +390,7 @@ discourse_ai: ai_rag_images_enabled: default: false hidden: true + ai_enable_experimental_bot_ux: + default: false + client: true + diff --git a/plugin.rb b/plugin.rb index e495f250..e83d8df7 100644 --- a/plugin.rb +++ b/plugin.rb @@ -43,6 +43,8 @@ register_asset "stylesheets/modules/ai-bot/common/ai-persona.scss" register_asset "stylesheets/modules/ai-bot/common/ai-discobot-discoveries.scss" register_asset "stylesheets/modules/ai-bot/mobile/ai-persona.scss", :mobile +register_asset "stylesheets/modules/ai-bot-conversations/common.scss" + register_asset "stylesheets/modules/embeddings/common/semantic-related-topics.scss" register_asset "stylesheets/modules/embeddings/common/semantic-search.scss" diff --git a/spec/system/ai_bot/personal_message_spec.rb b/spec/system/ai_bot/personal_message_spec.rb new file mode 100644 index 00000000..0dd76355 --- /dev/null +++ b/spec/system/ai_bot/personal_message_spec.rb @@ -0,0 +1,185 @@ +# frozen_string_literal: true + +RSpec.describe "AI Bot - Personal Message", type: :system do + let(:topic_page) { PageObjects::Pages::Topic.new } + let(:composer) { PageObjects::Components::Composer.new } + let(:ai_pm_homepage) { PageObjects::Components::AiPmHomepage.new } + let(:sidebar) { PageObjects::Components::NavigationMenu::Sidebar.new } + let(:header_dropdown) { PageObjects::Components::NavigationMenu::HeaderDropdown.new } + let(:dialog) { PageObjects::Components::Dialog.new } + + fab!(:user) { Fabricate(:user, refresh_auto_groups: true) } + + fab!(:claude_2) do + Fabricate( + :llm_model, + provider: "anthropic", + url: "https://api.anthropic.com/v1/messages", + name: "claude-2", + ) + end + fab!(:bot_user) do + toggle_enabled_bots(bots: [claude_2]) + SiteSetting.ai_bot_enabled = true + claude_2.reload.user + end + fab!(:bot) do + persona = + AiPersona + .find(DiscourseAi::Personas::Persona.system_personas[DiscourseAi::Personas::General]) + .class_instance + .new + DiscourseAi::Personas::Bot.as(bot_user, persona: persona) + end + + fab!(:pm) do + Fabricate( + :private_message_topic, + title: "This is my special PM", + user: user, + topic_allowed_users: [ + Fabricate.build(:topic_allowed_user, user: user), + Fabricate.build(:topic_allowed_user, user: bot_user), + ], + ) + end + fab!(:first_post) do + Fabricate(:post, topic: pm, user: user, post_number: 1, raw: "This is a reply by the user") + end + fab!(:second_post) do + Fabricate(:post, topic: pm, user: bot_user, post_number: 2, raw: "This is a bot reply") + end + fab!(:third_post) do + Fabricate( + :post, + topic: pm, + user: user, + post_number: 3, + raw: "This is a second reply by the user", + ) + end + fab!(:topic_user) { Fabricate(:topic_user, topic: pm, user: user) } + fab!(:topic_bot_user) { Fabricate(:topic_user, topic: pm, user: bot_user) } + + fab!(:persona) do + persona = + AiPersona.create!( + name: "Test Persona", + description: "A test persona", + allowed_group_ids: [Group::AUTO_GROUPS[:trust_level_0]], + enabled: true, + system_prompt: "You are a helpful bot", + ) + + persona.create_user! + persona.update!( + default_llm_id: claude_2.id, + allow_chat_channel_mentions: true, + allow_topic_mentions: true, + ) + persona + end + + before do + SiteSetting.ai_enable_experimental_bot_ux = true + SiteSetting.ai_bot_enabled = true + Jobs.run_immediately! + SiteSetting.ai_bot_allowed_groups = "#{Group::AUTO_GROUPS[:trust_level_0]}" + sign_in(user) + end + + it "has normal bot interaction when `ai_enable_experimental_bot_ux` is disabled" do + SiteSetting.ai_enable_experimental_bot_ux = false + visit "/" + find(".ai-bot-button").click + + expect(ai_pm_homepage).to have_no_homepage + expect(composer).to be_opened + end + + context "when `ai_enable_experimental_bot_ux` is enabled" do + it "renders landing page on bot click" do + visit "/" + find(".ai-bot-button").click + expect(ai_pm_homepage).to have_homepage + expect(sidebar).to be_visible + end + + it "displays error when message is too short" do + visit "/" + find(".ai-bot-button").click + + ai_pm_homepage.input.fill_in(with: "a") + ai_pm_homepage.submit + expect(ai_pm_homepage).to have_too_short_dialog + dialog.click_yes + expect(composer).to be_closed + end + + it "renders sidebar even when navigation menu is set to header" do + SiteSetting.navigation_menu = "header dropdown" + visit "/" + find(".ai-bot-button").click + expect(ai_pm_homepage).to have_homepage + expect(sidebar).to be_visible + expect(header_dropdown).to be_visible + end + + it "hides default content in the sidebar" do + visit "/" + find(".ai-bot-button").click + + expect(ai_pm_homepage).to have_homepage + expect(sidebar).to have_no_tags_section + expect(sidebar).to have_no_section("categories") + expect(sidebar).to have_no_section("messages") + expect(sidebar).to have_no_section("chat-dms") + expect(sidebar).to have_no_section("chat-channels") + expect(sidebar).to have_no_section("user-threads") + end + + it "shows the bot conversation in the sidebar" do + visit "/" + find(".ai-bot-button").click + + expect(ai_pm_homepage).to have_homepage + expect(sidebar).to have_section("ai-conversations-history") + expect(sidebar).to have_section_link(pm.title) + expect(sidebar).to have_no_css("button.ai-new-question-button") + end + + it "navigates to the bot conversation when clicked" do + visit "/" + find(".ai-bot-button").click + + expect(ai_pm_homepage).to have_homepage + sidebar.find( + ".sidebar-section[data-section-name='ai-conversations-history'] a.sidebar-section-link", + ).click + expect(topic_page).to have_topic_title(pm.title) + end + + it "displays sidebar and 'new question' on the topic page" do + topic_page.visit_topic(pm) + expect(sidebar).to be_visible + expect(sidebar).to have_css("button.ai-new-question-button") + end + + it "redirect to the homepage when 'new question' is clicked" do + topic_page.visit_topic(pm) + expect(sidebar).to be_visible + sidebar.find("button.ai-new-question-button").click + expect(ai_pm_homepage).to have_homepage + end + + it "can send a new message to the bot" do + topic_page.visit_topic(pm) + topic_page.click_reply_button + expect(composer).to be_opened + + composer.fill_in(with: "Hello bot replying to you") + composer.submit + expect(page).to have_content("Hello bot replying to you") + end + end +end diff --git a/spec/system/page_objects/components/ai_pm_homepage.rb b/spec/system/page_objects/components/ai_pm_homepage.rb new file mode 100644 index 00000000..8b158522 --- /dev/null +++ b/spec/system/page_objects/components/ai_pm_homepage.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module PageObjects + module Components + class AiPmHomepage < PageObjects::Components::Base + HOMEPAGE_WRAPPER_CLASS = ".ai-bot-conversations__content-wrapper" + + def input + page.find("#ai-bot-conversations-input") + end + + def submit + page.find(".ai-conversation-submit").click + end + + def has_too_short_dialog? + page.find( + ".dialog-content", + text: I18n.t("js.discourse_ai.ai_bot.conversations.min_input_length_message"), + ) + end + + def has_homepage? + page.has_css?(HOMEPAGE_WRAPPER_CLASS) + end + + def has_no_homepage? + page.has_no_css?(HOMEPAGE_WRAPPER_CLASS) + end + end + end +end diff --git a/test/javascripts/acceptance/post-helper-menu-test.js b/test/javascripts/acceptance/post-helper-menu-test.js index 3100d7c3..c8bf67f5 100644 --- a/test/javascripts/acceptance/post-helper-menu-test.js +++ b/test/javascripts/acceptance/post-helper-menu-test.js @@ -50,6 +50,8 @@ acceptance("AI Helper - Post Helper Menu", function (needs) { done: false, }); }); + + server.get("/discourse-ai/ai-bot/conversations.json", () => {}); }); test("displays streamed explanation", async function (assert) { diff --git a/test/javascripts/acceptance/topic-summary-test.js b/test/javascripts/acceptance/topic-summary-test.js index 11290c04..be8a3e87 100644 --- a/test/javascripts/acceptance/topic-summary-test.js +++ b/test/javascripts/acceptance/topic-summary-test.js @@ -29,6 +29,8 @@ acceptance("Topic - Summary", function (needs) { done: false, }); }); + + server.get("/discourse-ai/ai-bot/conversations.json", () => {}); }); needs.hooks.beforeEach(() => {
+ {{i18n "discourse_ai.ai_bot.conversations.disclaimer"}} +