Skip to content

Commit 5fec8fe

Browse files
authored
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
1 parent d26c7ac commit 5fec8fe

19 files changed

+1149
-16
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# frozen_string_literal: true
2+
3+
module DiscourseAi
4+
module AiBot
5+
class ConversationsController < ::ApplicationController
6+
requires_plugin ::DiscourseAi::PLUGIN_NAME
7+
requires_login
8+
9+
def index
10+
page = params[:page].to_i
11+
per_page = params[:per_page]&.to_i || 40
12+
13+
bot_user_ids = EntryPoint.all_bot_ids
14+
base_query =
15+
Topic
16+
.private_messages_for_user(current_user)
17+
.joins(:topic_users)
18+
.where(topic_users: { user_id: bot_user_ids })
19+
.distinct
20+
total = base_query.count
21+
pms = base_query.order(last_posted_at: :desc).offset(page * per_page).limit(per_page)
22+
23+
render json: {
24+
conversations: serialize_data(pms, BasicTopicSerializer),
25+
meta: {
26+
total: total,
27+
page: page,
28+
per_page: per_page,
29+
has_more: total > (page + 1) * per_page,
30+
},
31+
}
32+
end
33+
end
34+
end
35+
end

assets/javascripts/discourse/components/ai-bot-header-icon.gjs

+4
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export default class AiBotHeaderIcon extends Component {
99
@service currentUser;
1010
@service siteSettings;
1111
@service composer;
12+
@service router;
1213

1314
get bots() {
1415
const availableBots = this.currentUser.ai_enabled_chat_bots
@@ -24,6 +25,9 @@ export default class AiBotHeaderIcon extends Component {
2425

2526
@action
2627
compose() {
28+
if (this.siteSettings.ai_enable_experimental_bot_ux) {
29+
return this.router.transitionTo("discourse-ai-bot-conversations");
30+
}
2731
composeAiBotMessage(this.bots[0], this.composer);
2832
}
2933

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import Component from "@glimmer/component";
2+
import { service } from "@ember/service";
3+
import DButton from "discourse/components/d-button";
4+
import { AI_CONVERSATIONS_PANEL } from "../services/ai-conversations-sidebar-manager";
5+
6+
export default class AiBotSidebarNewConversation extends Component {
7+
@service router;
8+
@service sidebarState;
9+
10+
get shouldRender() {
11+
return (
12+
this.router.currentRouteName !== "discourse-ai-bot-conversations" &&
13+
this.sidebarState.isCurrentPanel(AI_CONVERSATIONS_PANEL)
14+
);
15+
}
16+
17+
<template>
18+
{{#if this.shouldRender}}
19+
<DButton
20+
@route="/discourse-ai/ai-bot/conversations"
21+
@label="discourse_ai.ai_bot.conversations.new"
22+
@icon="plus"
23+
class="ai-new-question-button btn-default"
24+
/>
25+
{{/if}}
26+
</template>
27+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import Controller from "@ember/controller";
2+
import { action } from "@ember/object";
3+
import { service } from "@ember/service";
4+
import { tracked } from "@ember-compat/tracked-built-ins";
5+
6+
export default class DiscourseAiBotConversations extends Controller {
7+
@service aiBotConversationsHiddenSubmit;
8+
@service currentUser;
9+
10+
@tracked selectedPersona = this.personaOptions[0].username;
11+
12+
textarea = null;
13+
14+
init() {
15+
super.init(...arguments);
16+
this.selectedPersonaChanged(this.selectedPersona);
17+
}
18+
19+
get personaOptions() {
20+
if (this.currentUser.ai_enabled_personas) {
21+
return this.currentUser.ai_enabled_personas
22+
.filter((persona) => persona.username)
23+
.map((persona) => {
24+
return {
25+
id: persona.id,
26+
username: persona.username,
27+
name: persona.name,
28+
description: persona.description,
29+
};
30+
});
31+
}
32+
}
33+
34+
get displayPersonaSelector() {
35+
return this.personaOptions.length > 1;
36+
}
37+
38+
get filterable() {
39+
return this.personaOptions.length > 4;
40+
}
41+
42+
@action
43+
selectedPersonaChanged(username) {
44+
this.selectedPersona = username;
45+
this.aiBotConversationsHiddenSubmit.personaUsername = username;
46+
}
47+
48+
@action
49+
updateInputValue(event) {
50+
this._autoExpandTextarea();
51+
this.aiBotConversationsHiddenSubmit.inputValue = event.target.value;
52+
}
53+
54+
@action
55+
handleKeyDown(event) {
56+
if (event.key === "Enter" && !event.shiftKey) {
57+
this.aiBotConversationsHiddenSubmit.submitToBot();
58+
}
59+
}
60+
61+
@action
62+
setTextArea(element) {
63+
this.textarea = element;
64+
}
65+
66+
_autoExpandTextarea() {
67+
this.textarea.style.height = "auto";
68+
this.textarea.style.height = this.textarea.scrollHeight + "px";
69+
}
70+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export default function () {
2+
this.route("discourse-ai-bot-conversations", {
3+
path: "/discourse-ai/ai-bot/conversations",
4+
});
5+
}

assets/javascripts/discourse/lib/ai-bot-helper.js

+35-16
Original file line numberDiff line numberDiff line change
@@ -23,24 +23,43 @@ export function showShareConversationModal(modal, topicId) {
2323
.catch(popupAjaxError);
2424
}
2525

26-
export function composeAiBotMessage(targetBot, composer) {
26+
export async function composeAiBotMessage(
27+
targetBot,
28+
composer,
29+
options = {
30+
skipFocus: false,
31+
topicBody: "",
32+
personaUsername: null,
33+
}
34+
) {
2735
const currentUser = composer.currentUser;
2836
const draftKey = "new_private_message_ai_" + new Date().getTime();
2937

30-
let botUsername = currentUser.ai_enabled_chat_bots.find(
31-
(bot) => bot.model_name === targetBot
32-
).username;
38+
let botUsername;
39+
if (targetBot) {
40+
botUsername = currentUser.ai_enabled_chat_bots.find(
41+
(bot) => bot.model_name === targetBot
42+
)?.username;
43+
} else if (options.personaUsername) {
44+
botUsername = options.personaUsername;
45+
} else {
46+
botUsername = currentUser.ai_enabled_chat_bots[0].username;
47+
}
3348

34-
composer.focusComposer({
35-
fallbackToNewTopic: true,
36-
openOpts: {
37-
action: Composer.PRIVATE_MESSAGE,
38-
recipients: botUsername,
39-
topicTitle: i18n("discourse_ai.ai_bot.default_pm_prefix"),
40-
archetypeId: "private_message",
41-
draftKey,
42-
hasGroups: false,
43-
warningsDisabled: true,
44-
},
45-
});
49+
const data = {
50+
action: Composer.PRIVATE_MESSAGE,
51+
recipients: botUsername,
52+
topicTitle: i18n("discourse_ai.ai_bot.default_pm_prefix"),
53+
archetypeId: "private_message",
54+
draftKey,
55+
hasGroups: false,
56+
warningsDisabled: true,
57+
};
58+
59+
if (options.skipFocus) {
60+
data.topicBody = options.topicBody;
61+
await composer.open(data);
62+
} else {
63+
composer.focusComposer({ fallbackToNewTopic: true, openOpts: data });
64+
}
4665
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { action } from "@ember/object";
2+
import { next } from "@ember/runloop";
3+
import Service, { service } from "@ember/service";
4+
import { popupAjaxError } from "discourse/lib/ajax-error";
5+
import { i18n } from "discourse-i18n";
6+
import { composeAiBotMessage } from "../lib/ai-bot-helper";
7+
8+
export default class AiBotConversationsHiddenSubmit extends Service {
9+
@service composer;
10+
@service aiConversationsSidebarManager;
11+
@service dialog;
12+
13+
personaUsername;
14+
15+
inputValue = "";
16+
17+
@action
18+
focusInput() {
19+
this.composer.destroyDraft();
20+
this.composer.close();
21+
next(() => {
22+
document.getElementById("custom-homepage-input").focus();
23+
});
24+
}
25+
26+
@action
27+
async submitToBot() {
28+
this.composer.destroyDraft();
29+
this.composer.close();
30+
31+
if (this.inputValue.length < 10) {
32+
return this.dialog.alert({
33+
message: i18n(
34+
"discourse_ai.ai_bot.conversations.min_input_length_message"
35+
),
36+
didConfirm: () => this.focusInput(),
37+
didCancel: () => this.focusInput(),
38+
});
39+
}
40+
41+
// we are intentionally passing null as the targetBot to allow for the
42+
// function to select the first available bot. This will be refactored in the
43+
// future to allow for selecting a specific bot.
44+
await composeAiBotMessage(null, this.composer, {
45+
skipFocus: true,
46+
topicBody: this.inputValue,
47+
personaUsername: this.personaUsername,
48+
});
49+
50+
try {
51+
await this.composer.save();
52+
this.aiConversationsSidebarManager.newTopicForceSidebar = true;
53+
if (this.inputValue.length > 10) {
54+
// prevents submitting same message again when returning home
55+
// but avoids deleting too-short message on submit
56+
this.inputValue = "";
57+
}
58+
} catch (e) {
59+
popupAjaxError(e);
60+
}
61+
}
62+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { tracked } from "@glimmer/tracking";
2+
import Service, { service } from "@ember/service";
3+
import { ADMIN_PANEL, MAIN_PANEL } from "discourse/lib/sidebar/panels";
4+
5+
export const AI_CONVERSATIONS_PANEL = "ai-conversations";
6+
7+
export default class AiConversationsSidebarManager extends Service {
8+
@service sidebarState;
9+
10+
@tracked newTopicForceSidebar = false;
11+
12+
forceCustomSidebar() {
13+
// Set the panel to your custom panel
14+
this.sidebarState.setPanel(AI_CONVERSATIONS_PANEL);
15+
16+
// Use separated mode to ensure independence from hamburger menu
17+
this.sidebarState.setSeparatedMode();
18+
19+
// Hide panel switching buttons to keep UI clean
20+
this.sidebarState.hideSwitchPanelButtons();
21+
22+
this.sidebarState.isForcingSidebar = true;
23+
document.body.classList.add("has-ai-conversations-sidebar");
24+
return true;
25+
}
26+
27+
stopForcingCustomSidebar() {
28+
// This method is called when leaving your route
29+
// Only restore main panel if we previously forced ours
30+
document.body.classList.remove("has-ai-conversations-sidebar");
31+
const isAdminSidebarActive =
32+
this.sidebarState.currentPanel?.key === ADMIN_PANEL;
33+
// only restore main panel if we previously forced our sidebar
34+
// and not if we are in admin sidebar
35+
if (this.sidebarState.isForcingSidebar && !isAdminSidebarActive) {
36+
this.sidebarState.setPanel(MAIN_PANEL); // Return to main sidebar panel
37+
this.sidebarState.isForcingSidebar = false;
38+
}
39+
}
40+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { hash } from "@ember/helper";
2+
import { on } from "@ember/modifier";
3+
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
4+
import RouteTemplate from "ember-route-template";
5+
import DButton from "discourse/components/d-button";
6+
import { i18n } from "discourse-i18n";
7+
import DropdownSelectBox from "select-kit/components/dropdown-select-box";
8+
9+
export default RouteTemplate(
10+
<template>
11+
<div class="ai-bot-conversations">
12+
{{#if @controller.displayPersonaSelector}}
13+
<div class="ai-bot-conversations__persona-selector">
14+
<DropdownSelectBox
15+
class="persona-llm-selector__persona-dropdown"
16+
@value={{@controller.selectedPersona}}
17+
@valueProperty="username"
18+
@content={{@controller.personaOptions}}
19+
@options={{hash icon="robot" filterable=@controller.filterable}}
20+
@onChange={{@controller.selectedPersonaChanged}}
21+
/>
22+
</div>
23+
{{/if}}
24+
25+
<div class="ai-bot-conversations__content-wrapper">
26+
27+
<h1>{{i18n "discourse_ai.ai_bot.conversations.header"}}</h1>
28+
<div class="ai-bot-conversations__input-wrapper">
29+
<textarea
30+
{{didInsert @controller.setTextArea}}
31+
{{on "input" @controller.updateInputValue}}
32+
{{on "keydown" @controller.handleKeyDown}}
33+
id="ai-bot-conversations-input"
34+
placeholder={{i18n "discourse_ai.ai_bot.conversations.placeholder"}}
35+
minlength="10"
36+
rows="1"
37+
/>
38+
<DButton
39+
@action={{@controller.aiBotConversationsHiddenSubmit.submitToBot}}
40+
@icon="paper-plane"
41+
@title="discourse_ai.ai_bot.conversations.header"
42+
class="ai-bot-button btn-primary ai-conversation-submit"
43+
/>
44+
</div>
45+
<p class="ai-disclaimer">
46+
{{i18n "discourse_ai.ai_bot.conversations.disclaimer"}}
47+
</p>
48+
</div>
49+
</div>
50+
</template>
51+
);

0 commit comments

Comments
 (0)