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..02578426
--- /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,
+ 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..42f55aea
--- /dev/null
+++ b/assets/javascripts/discourse/controllers/discourse-ai-bot-conversations.js
@@ -0,0 +1,66 @@
+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 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/routes/discourse-ai-bot-conversations.js b/assets/javascripts/discourse/routes/discourse-ai-bot-conversations.js
new file mode 100644
index 00000000..08c4d8b1
--- /dev/null
+++ b/assets/javascripts/discourse/routes/discourse-ai-bot-conversations.js
@@ -0,0 +1,3 @@
+import DiscourseRoute from "discourse/routes/discourse";
+
+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
new file mode 100644
index 00000000..e89a8837
--- /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 { 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 (error) {
+ // eslint-disable-next-line no-console
+ console.error("Failed to submit message:", error);
+ }
+ }
+}
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.hbs b/assets/javascripts/discourse/templates/discourse-ai-bot-conversations.hbs
new file mode 100644
index 00000000..087cdc8e
--- /dev/null
+++ b/assets/javascripts/discourse/templates/discourse-ai-bot-conversations.hbs
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
{{i18n "discourse_ai.ai_bot.conversations.header"}}
+
+
+
+
+
+ {{i18n "discourse_ai.ai_bot.conversations.disclaimer"}}
+
+
+
\ No newline at end of file
diff --git a/assets/javascripts/initializers/ai-conversations-sidebar.js b/assets/javascripts/initializers/ai-conversations-sidebar.js
new file mode 100644
index 00000000..bb728ef0
--- /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("1.8.0", (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.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..22f14bf5
--- /dev/null
+++ b/assets/stylesheets/modules/ai-bot-conversations/common.scss
@@ -0,0 +1,341 @@
+// 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-question-button {
+ border-radius: var(--border-radius);
+ border: 1px solid var(--primary-low);
+ line-height: var(--line-height-medium);
+
+ .d-button-label {
+ color: var(--primary-high);
+ }
+
+ .discourse-no-touch & {
+ &:hover {
+ background: var(--primary-low);
+ }
+ }
+ }
+
+ .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..e3de1173 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"
+ 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..d85bebbd
--- /dev/null
+++ b/spec/system/page_objects/components/ai_pm_homepage.rb
@@ -0,0 +1,29 @@
+# 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: "Message must be longer than 10 characters")
+ 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(() => {