Skip to content

Commit 3775f4d

Browse files
markvanlanjanzenisaac
authored andcommitted
FEATURE: Bot Conversation Homepage
1 parent d26c7ac commit 3775f4d

20 files changed

+1147
-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+
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,66 @@
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 filterable() {
35+
return this.personaOptions.length > 4;
36+
}
37+
38+
@action
39+
selectedPersonaChanged(username) {
40+
this.selectedPersona = username;
41+
this.aiBotConversationsHiddenSubmit.personaUsername = username;
42+
}
43+
44+
@action
45+
updateInputValue(event) {
46+
this._autoExpandTextarea();
47+
this.aiBotConversationsHiddenSubmit.inputValue = event.target.value;
48+
}
49+
50+
@action
51+
handleKeyDown(event) {
52+
if (event.key === "Enter" && !event.shiftKey) {
53+
this.aiBotConversationsHiddenSubmit.submitToBot();
54+
}
55+
}
56+
57+
@action
58+
setTextArea(element) {
59+
this.textarea = element;
60+
}
61+
62+
_autoExpandTextarea() {
63+
this.textarea.style.height = "auto";
64+
this.textarea.style.height = this.textarea.scrollHeight + "px";
65+
}
66+
}
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,3 @@
1+
import DiscourseRoute from "discourse/routes/discourse";
2+
3+
export default class DiscourseAiBotConversationsRoute extends DiscourseRoute {}
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 { i18n } from "discourse-i18n";
5+
import { composeAiBotMessage } from "../lib/ai-bot-helper";
6+
7+
export default class AiBotConversationsHiddenSubmit extends Service {
8+
@service composer;
9+
@service aiConversationsSidebarManager;
10+
@service dialog;
11+
12+
personaUsername;
13+
14+
inputValue = "";
15+
16+
@action
17+
focusInput() {
18+
this.composer.destroyDraft();
19+
this.composer.close();
20+
next(() => {
21+
document.getElementById("custom-homepage-input").focus();
22+
});
23+
}
24+
25+
@action
26+
async submitToBot() {
27+
this.composer.destroyDraft();
28+
this.composer.close();
29+
30+
if (this.inputValue.length < 10) {
31+
return this.dialog.alert({
32+
message: i18n(
33+
"discourse_ai.ai_bot.conversations.min_input_length_message"
34+
),
35+
didConfirm: () => this.focusInput(),
36+
didCancel: () => this.focusInput(),
37+
});
38+
}
39+
40+
// we are intentionally passing null as the targetBot to allow for the
41+
// function to select the first available bot. This will be refactored in the
42+
// future to allow for selecting a specific bot.
43+
await composeAiBotMessage(null, this.composer, {
44+
skipFocus: true,
45+
topicBody: this.inputValue,
46+
personaUsername: this.personaUsername,
47+
});
48+
49+
try {
50+
await this.composer.save();
51+
this.aiConversationsSidebarManager.newTopicForceSidebar = true;
52+
if (this.inputValue.length > 10) {
53+
// prevents submitting same message again when returning home
54+
// but avoids deleting too-short message on submit
55+
this.inputValue = "";
56+
}
57+
} catch (error) {
58+
// eslint-disable-next-line no-console
59+
console.error("Failed to submit message:", error);
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,37 @@
1+
<div class="ai-bot-conversations">
2+
<div class="ai-bot-conversations__persona-selector">
3+
<DropdownSelectBox
4+
class="persona-llm-selector__persona-dropdown"
5+
@value={{this.selectedPersona}}
6+
@valueProperty="username"
7+
@content={{this.personaOptions}}
8+
@options={{hash icon="robot" filterable=this.filterable}}
9+
@onChange={{this.selectedPersonaChanged}}
10+
/>
11+
</div>
12+
13+
<div class="ai-bot-conversations__content-wrapper">
14+
15+
<h1>{{i18n "discourse_ai.ai_bot.conversations.header"}}</h1>
16+
<div class="ai-bot-conversations__input-wrapper">
17+
<textarea
18+
{{didInsert this.setTextArea}}
19+
{{on "input" this.updateInputValue}}
20+
{{on "keydown" this.handleKeyDown}}
21+
id="ai-bot-conversations-input"
22+
placeholder={{i18n "discourse_ai.ai_bot.conversations.placeholder"}}
23+
minlength="10"
24+
rows="1"
25+
/>
26+
<DButton
27+
@action={{this.aiBotConversationsHiddenSubmit.submitToBot}}
28+
@icon="paper-plane"
29+
@title="discourse_ai.ai_bot.conversations.header"
30+
class="ai-bot-button btn-primary ai-conversation-submit"
31+
/>
32+
</div>
33+
<p class="ai-disclaimer">
34+
{{i18n "discourse_ai.ai_bot.conversations.disclaimer"}}
35+
</p>
36+
</div>
37+
</div>

0 commit comments

Comments
 (0)