mirror of
https://github.com/discourse/discourse-ai.git
synced 2025-06-27 10:02:16 +00:00
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:
parent
d26c7ac48d
commit
5fec8fe79e
@ -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
|
@ -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);
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
}
|
@ -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";
|
||||
}
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
export default function () {
|
||||
this.route("discourse-ai-bot-conversations", {
|
||||
path: "/discourse-ai/ai-bot/conversations",
|
||||
});
|
||||
}
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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>
|
||||
);
|
256
assets/javascripts/initializers/ai-conversations-sidebar.js
Normal file
256
assets/javascripts/initializers/ai-conversations-sidebar.js
Normal 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);
|
||||
});
|
||||
},
|
||||
};
|
325
assets/stylesheets/modules/ai-bot-conversations/common.scss
Normal file
325
assets/stylesheets/modules/ai-bot-conversations/common.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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"
|
||||
|
@ -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"
|
||||
|
@ -390,3 +390,7 @@ discourse_ai:
|
||||
ai_rag_images_enabled:
|
||||
default: false
|
||||
hidden: true
|
||||
ai_enable_experimental_bot_ux:
|
||||
default: false
|
||||
client: true
|
||||
|
||||
|
@ -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"
|
||||
|
||||
|
185
spec/system/ai_bot/personal_message_spec.rb
Normal file
185
spec/system/ai_bot/personal_message_spec.rb
Normal 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
|
32
spec/system/page_objects/components/ai_pm_homepage.rb
Normal file
32
spec/system/page_objects/components/ai_pm_homepage.rb
Normal 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
|
@ -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) {
|
||||
|
@ -29,6 +29,8 @@ acceptance("Topic - Summary", function (needs) {
|
||||
done: false,
|
||||
});
|
||||
});
|
||||
|
||||
server.get("/discourse-ai/ai-bot/conversations.json", () => {});
|
||||
});
|
||||
|
||||
needs.hooks.beforeEach(() => {
|
||||
|
Loading…
x
Reference in New Issue
Block a user