diff --git a/app/controllers/discourse_ai/ai_bot/conversations_controller.rb b/app/controllers/discourse_ai/ai_bot/conversations_controller.rb index ee8bbdb59..af5952b79 100644 --- a/app/controllers/discourse_ai/ai_bot/conversations_controller.rb +++ b/app/controllers/discourse_ai/ai_bot/conversations_controller.rb @@ -7,21 +7,38 @@ class ConversationsController < ::ApplicationController requires_login def index - # Step 1: Retrieve all AI bot user IDs + page = params[:page]&.to_i || 0 + per_page = params[:per_page]&.to_i || 40 + bot_user_ids = EntryPoint.all_bot_ids - # Step 2: Query for PM topics including current_user and any bot ID pms = Topic .private_messages_for_user(current_user) .joins(:topic_users) .where(topic_users: { user_id: bot_user_ids }) .distinct + .order(last_posted_at: :desc) + .offset(page * per_page) + .limit(per_page) - # Step 3: Serialize (empty array if no results) - serialized_pms = serialize_data(pms, BasicTopicSerializer) + total = + Topic + .private_messages_for_user(current_user) + .joins(:topic_users) + .where(topic_users: { user_id: bot_user_ids }) + .distinct + .count - render json: serialized_pms, status: 200 + render json: { + conversations: serialize_data(pms, BasicTopicSerializer), + meta: { + total: total, + page: page, + per_page: per_page, + more: total > (page + 1) * per_page, + }, + } end end end diff --git a/assets/javascripts/discourse/routes/discourse-ai-bot-conversations.js b/assets/javascripts/discourse/routes/discourse-ai-bot-conversations.js index 55d328ae7..08c4d8b15 100644 --- a/assets/javascripts/discourse/routes/discourse-ai-bot-conversations.js +++ b/assets/javascripts/discourse/routes/discourse-ai-bot-conversations.js @@ -1,16 +1,3 @@ -import { service } from "@ember/service"; import DiscourseRoute from "discourse/routes/discourse"; -export default class DiscourseAiBotConversationsRoute extends DiscourseRoute { - @service aiConversationsSidebarManager; - - activate() { - super.activate(...arguments); - this.aiConversationsSidebarManager.forceCustomSidebar(); - } - - deactivate() { - super.deactivate(...arguments); - this.aiConversationsSidebarManager.stopForcingCustomSidebar(); - } -} +export default class DiscourseAiBotConversationsRoute extends DiscourseRoute {} diff --git a/assets/javascripts/discourse/services/ai-bot-conversations-hidden-submit.js b/assets/javascripts/discourse/services/ai-bot-conversations-hidden-submit.js index 3973b2528..d2f61bf3c 100644 --- a/assets/javascripts/discourse/services/ai-bot-conversations-hidden-submit.js +++ b/assets/javascripts/discourse/services/ai-bot-conversations-hidden-submit.js @@ -37,7 +37,8 @@ export default class AiBotConversationsHiddenSubmit extends Service { // borrowed from ai-bot-helper.js const draftKey = "new_private_message_ai_" + new Date().getTime(); - const personaWithUsername = this.currentUser.ai_enabled_personas.find( + // For now.. find a persona with a username.. + const selectedPersona = this.currentUser.ai_enabled_personas.find( (persona) => persona.username ); @@ -45,13 +46,15 @@ export default class AiBotConversationsHiddenSubmit extends Service { await this.composer.open({ action: Composer.PRIVATE_MESSAGE, draftKey, - recipients: personaWithUsername.username, + recipients: selectedPersona.username, topicTitle: i18n("discourse_ai.ai_bot.default_pm_prefix"), topicBody: this.inputValue, archetypeId: "private_message", disableDrafts: true, }); + this.composer.model.metaData = { ai_persona_id: selectedPersona.id }; + try { await this.composer.save(); if (this.inputValue.length > 10) { diff --git a/assets/javascripts/initializers/ai-conversations-sidebar.js b/assets/javascripts/initializers/ai-conversations-sidebar.js index 0ba05bd37..77bbc646c 100644 --- a/assets/javascripts/initializers/ai-conversations-sidebar.js +++ b/assets/javascripts/initializers/ai-conversations-sidebar.js @@ -1,7 +1,8 @@ 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 { AI_CONVERSATIONS_PANEL } from "../discourse/services/ai-conversations-sidebar-manager"; @@ -21,31 +22,60 @@ export default { return; } - // TODO: Replace - const recentConversations = 10; - // Step 1: Add a custom sidebar panel api.addSidebarPanel( (BaseCustomSidebarPanel) => class AiConversationsSidebarPanel extends BaseCustomSidebarPanel { key = AI_CONVERSATIONS_PANEL; - hidden = true; // Hide from panel switching UI + hidden = true; displayHeader = true; expandActiveSection = true; - - // Optional - customize if needed - // switchButtonLabel = "Your Panel"; - // switchButtonIcon = "cog"; } ); api.renderInOutlet("sidebar-footer-actions", AiBotSidebarNewConversation); - // Step 2: Add a custom section to your panel api.addSidebarSection( (BaseCustomSidebarSection, BaseCustomSidebarSectionLink) => { + const AiConversationLink = class extends BaseCustomSidebarSectionLink { + route = "topic.fromParamsNear"; + prefixType = "icon"; + prefixValue = "robot"; + + 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 = []; + @tracked links = new TrackedArray(); @tracked topics = []; + @tracked hasMore = []; + page = 0; isFetching = false; totalTopicsCount = 0; @@ -53,131 +83,162 @@ export default { super(...arguments); this.fetchMessages(); - appEvents.on("topic:created", (topic) => { - // when asking a new question - this.addNewMessage(topic); - this.watchForTitleUpdate(topic); - }); + appEvents.on("topic:created", this, "addNewMessageToSidebar"); + } + + @bind + willDestroy() { + this.removeScrollListener(); + appEvents.on("topic:created", this, "addNewMessageToSidebar"); + } + + get name() { + return "ai-conversations-history"; + } + + get text() { + // TODO: FIX + //return i18n(themePrefix("messages_sidebar.title")); + return "Conversations"; + } + + get sidebarElement() { + return document.querySelector( + ".sidebar-wrapper .sidebar-sections" + ); } - fetchMessages() { + 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(); + } + } + } + + fetchMessages(isLoadingMore = false) { if (this.isFetching) { return; } this.isFetching = true; - ajax("/discourse-ai/ai-bot/conversations.json") + ajax("/discourse-ai/ai-bot/conversations.json", { + data: { page: this.page, per_page: 40 }, + }) .then((data) => { - this.topics = data.conversations.slice( - 0, - recentConversations - ); + if (isLoadingMore) { + this.topics = [...this.topics, ...data.conversations]; + } else { + this.topics = data.conversations; + } + + this.totalTopicsCount = data.meta.total; + this.hasMore = data.meta.more; this.isFetching = false; + this.removeScrollListener(); this.buildSidebarLinks(); + this.attachScrollListener(); }) - .catch((e) => { + .catch(() => { this.isFetching = false; }); } - addNewMessage(newTopic) { - // the pm endpoint isn't fast enough include the newly created topic - // so this adds the new topic to the existing list - const builtTopic = - new (class extends BaseCustomSidebarSectionLink { - name = newTopic.title; - route = "topic.fromParamsNear"; - models = [newTopic.topic_slug, newTopic.topic_id, 0]; - title = newTopic.title; - text = newTopic.title; - prefixType = "icon"; - prefixValue = "robot"; - })(); - - this.links = [builtTopic, ...this.links]; + loadMore() { + if (this.isFetching || !this.hasMore) { + return; + } + + this.page = this.page + 1; + this.fetchMessages(true); } buildSidebarLinks() { - this.links = this.topics.map((topic) => { - return new (class extends BaseCustomSidebarSectionLink { - name = topic.title; - route = "topic.fromParamsNear"; - models = [ - topic.slug, - topic.id, - topic.last_read_post_number || 0, - ]; - title = topic.title; - text = topic.title; - prefixType = "icon"; - prefixValue = "robot"; - })(); - }); + this.links = this.topics.map( + (topic) => new AiConversationLink(topic) + ); + } - if (this.totalTopicsCount > recentConversations) { - this.links.push( - new (class extends BaseCustomSidebarSectionLink { - name = "View All"; - route = "userPrivateMessages.user.index"; - models = [currentUser.username]; - title = "View all..."; - text = "View all..."; - prefixType = "icon"; - prefixValue = "list"; - })() - ); - } + addNewMessage(newTopic) { + this.links = [new AiConversationLink(newTopic), ...this.links]; } watchForTitleUpdate(topic) { const channel = `/discourse-ai/ai-bot/topic/${topic.topic_id}`; - messageBus.subscribe(channel, () => { - this.fetchMessages(); + const topicId = topic.topic_id; + const callback = this.updateTopicTitle.bind(this); + messageBus.subscribe(channel, ({ title }) => { + callback(topicId, title); messageBus.unsubscribe(channel); }); } - get name() { - return "custom-messages"; - } - - get text() { - // TODO: FIX - //return i18n(themePrefix("messages_sidebar.title")); - return "Conversations"; - } - - get displaySection() { - return this.links?.length > 0; + updateTopicTitle(topicId, 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 ); - api.modifyClass( - "route:topic", - (Superclass) => - class extends Superclass { - activate() { - super.activate(); - const topic = this.modelFor("topic"); - if ( - topic && - topic.archetype === "private_message" && - topic.ai_persona_name - ) { - aiConversationsSidebarManager.forceCustomSidebar(); - } - } - - deactivate() { - super.activate(); - aiConversationsSidebarManager.stopForcingCustomSidebar(); - } - } - ); + const setSidebarPanel = (transition) => { + if (transition?.to?.name === "discourse-ai-bot-conversations") { + return aiConversationsSidebarManager.forceCustomSidebar(); + } + + const topic = api.container.lookup("controller:topic").model; + if ( + topic && + topic.archetype === "private_message" && + topic.ai_persona_name + ) { + 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 index b6da8210d..17bc64e46 100644 --- a/assets/stylesheets/modules/ai-bot-conversations/common.scss +++ b/assets/stylesheets/modules/ai-bot-conversations/common.scss @@ -1,3 +1,8 @@ +// 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 {