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
This commit is contained in:
Mark VanLandingham 2025-04-21 15:17:10 -05:00 committed by GitHub
parent d26c7ac48d
commit 5fec8fe79e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 1149 additions and 16 deletions

View File

@ -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

View File

@ -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);
}

View File

@ -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)
);
}
<template>
{{#if this.shouldRender}}
<DButton
@route="/discourse-ai/ai-bot/conversations"
@label="discourse_ai.ai_bot.conversations.new"
@icon="plus"
class="ai-new-question-button btn-default"
/>
{{/if}}
</template>
}

View File

@ -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";
}
}

View File

@ -0,0 +1,5 @@
export default function () {
this.route("discourse-ai-bot-conversations", {
path: "/discourse-ai/ai-bot/conversations",
});
}

View File

@ -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 });
}
}

View File

@ -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);
}
}
}

View File

@ -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;
}
}
}

View File

@ -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(
<template>
<div class="ai-bot-conversations">
{{#if @controller.displayPersonaSelector}}
<div class="ai-bot-conversations__persona-selector">
<DropdownSelectBox
class="persona-llm-selector__persona-dropdown"
@value={{@controller.selectedPersona}}
@valueProperty="username"
@content={{@controller.personaOptions}}
@options={{hash icon="robot" filterable=@controller.filterable}}
@onChange={{@controller.selectedPersonaChanged}}
/>
</div>
{{/if}}
<div class="ai-bot-conversations__content-wrapper">
<h1>{{i18n "discourse_ai.ai_bot.conversations.header"}}</h1>
<div class="ai-bot-conversations__input-wrapper">
<textarea
{{didInsert @controller.setTextArea}}
{{on "input" @controller.updateInputValue}}
{{on "keydown" @controller.handleKeyDown}}
id="ai-bot-conversations-input"
placeholder={{i18n "discourse_ai.ai_bot.conversations.placeholder"}}
minlength="10"
rows="1"
/>
<DButton
@action={{@controller.aiBotConversationsHiddenSubmit.submitToBot}}
@icon="paper-plane"
@title="discourse_ai.ai_bot.conversations.header"
class="ai-bot-button btn-primary ai-conversation-submit"
/>
</div>
<p class="ai-disclaimer">
{{i18n "discourse_ai.ai_bot.conversations.disclaimer"}}
</p>
</div>
</div>
</template>
);

View File

@ -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);
});
},
};

View File

@ -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;
}
}
}

View File

@ -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"

View File

@ -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"

View File

@ -390,3 +390,7 @@ discourse_ai:
ai_rag_images_enabled:
default: false
hidden: true
ai_enable_experimental_bot_ux:
default: false
client: true

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -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) {

View File

@ -29,6 +29,8 @@ acceptance("Topic - Summary", function (needs) {
done: false,
});
});
server.get("/discourse-ai/ai-bot/conversations.json", () => {});
});
needs.hooks.beforeEach(() => {