Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Experimental bot ux #1159

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions app/controllers/discourse_ai/ai_bot/conversations_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# frozen_string_literal: true

module DiscourseAi
module AiBot
class ConversationsController < ::ApplicationController
requires_plugin ::DiscourseAi::PLUGIN_NAME
requires_login

def index
# Step 1: Retrieve all AI bot user IDs
bot_user_ids = EntryPoint.all_bot_ids

# Step 2: Query for PM topics including current_user and any bot ID
pms =
Topic
.joins(:topic_users)
.private_messages
.where("topic_users.user_id IN (?)", bot_user_ids + [current_user.id])
.group("topics.id") # Group by topic to ensure distinct results
.having("COUNT(topic_users.user_id) > 1") # Ensure multiple participants in the PM

# Step 3: Serialize (empty array if no results)
serialized_pms = serialize_data(pms, BasicTopicSerializer)

render json: serialized_pms, status: 200
end
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import Component from "@glimmer/component";
import { service } from "@ember/service";
import DButton from "discourse/components/d-button";

export default class AiBotSidebarNewConversation extends Component {
@service router;

get show() {
return this.router.currentRouteName !== "discourse-ai-bot-conversations";
}

<template>
{{#if this.show}}
<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>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import bodyClass from "discourse/helpers/body-class";

Check failure on line 1 in assets/javascripts/discourse/connectors/topic-above-post-stream/ai-bot-conversation.gjs

View workflow job for this annotation

GitHub Actions / ci / linting

Run autofix to sort these imports!
import Component from "@glimmer/component";

export default class AiBotConversaion extends Component {
get show() {
return this.args.outletArgs.model?.ai_persona_name

Check failure on line 6 in assets/javascripts/discourse/connectors/topic-above-post-stream/ai-bot-conversation.gjs

View workflow job for this annotation

GitHub Actions / ci / linting

Missing semicolon
}

<template>
{{#if this.show}}
{{bodyClass "discourse-ai-bot-conversations-page"}}
{{/if}}
</template>
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import Controller from "@ember/controller";
import { on } from "@ember/modifier";

Check failure on line 2 in assets/javascripts/discourse/controllers/discourse-ai-bot-conversations.js

View workflow job for this annotation

GitHub Actions / ci / linting

'on' is defined but never used
import { computed } from "@ember/object";

Check failure on line 3 in assets/javascripts/discourse/controllers/discourse-ai-bot-conversations.js

View workflow job for this annotation

GitHub Actions / ci / linting

'computed' is defined but never used
import { action } from "@ember/object";

Check failure on line 4 in assets/javascripts/discourse/controllers/discourse-ai-bot-conversations.js

View workflow job for this annotation

GitHub Actions / ci / linting

'@ember/object' import is duplicated
import didInsert from "@ember/render-modifiers/modifiers/did-insert";

Check failure on line 5 in assets/javascripts/discourse/controllers/discourse-ai-bot-conversations.js

View workflow job for this annotation

GitHub Actions / ci / linting

'didInsert' is defined but never used
import { service } from "@ember/service";
import DButton from "discourse/components/d-button";

Check failure on line 7 in assets/javascripts/discourse/controllers/discourse-ai-bot-conversations.js

View workflow job for this annotation

GitHub Actions / ci / linting

'DButton' is defined but never used
import bodyClass from "discourse/helpers/body-class";

Check failure on line 8 in assets/javascripts/discourse/controllers/discourse-ai-bot-conversations.js

View workflow job for this annotation

GitHub Actions / ci / linting

'bodyClass' is defined but never used
import { i18n } from "discourse-i18n";

Check failure on line 9 in assets/javascripts/discourse/controllers/discourse-ai-bot-conversations.js

View workflow job for this annotation

GitHub Actions / ci / linting

'i18n' is defined but never used
import SimpleTextareaInteractor from "../lib/simple-textarea-interactor";

export default class DiscourseAiBotConversations extends Controller {
@service aiBotConversationsHiddenSubmit;

textareaInteractor = null;

@action
updateInputValue(event) {
this.aiBotConversationsHiddenSubmit.inputValue = event.target.value;
}

@action
handleKeyDown(event) {
if (event.key === "Enter" && !event.shiftKey) {
this.aiBotConversationsHiddenSubmit.submitToBot();
}
}

@action
initializeTextarea(element) {
this.textareaInteractor = new SimpleTextareaInteractor(element);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export default function () {
this.route("discourse-ai-bot-conversations", {
path: "/discourse-ai/ai-bot/conversations",
});
}
29 changes: 29 additions & 0 deletions assets/javascripts/discourse/lib/simple-textarea-interactor.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { schedule } from "@ember/runloop";

export default class SimpleTextareaInteractor {
// lifted from "discourse/plugins/chat/discourse/lib/textarea-interactor"
// because the chat plugin isn't active on this site
constructor(textarea) {
this.textarea = textarea;
this.init();
this.refreshHeightBound = this.refreshHeight.bind(this);
this.textarea.addEventListener("input", this.refreshHeightBound);
}

init() {
schedule("afterRender", () => {
this.refreshHeight();
});
}

teardown() {
this.textarea.removeEventListener("input", this.refreshHeightBound);
}

refreshHeight() {
schedule("afterRender", () => {
this.textarea.style.height = "auto";
this.textarea.style.height = `${this.textarea.scrollHeight + 2}px`;
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import DiscourseRoute from "discourse/routes/discourse";

export default class DiscourseAiBotConversationsRoute extends DiscourseRoute {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { action } from "@ember/object";
import { next } from "@ember/runloop";
import Service, { service } from "@ember/service";
import Composer from "discourse/models/composer";
import { i18n } from "discourse-i18n";

export default class AiBotConversationsHiddenSubmit extends Service {
@service composer;
@service dialog;

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) {
// TODO: Translate
this.dialog.alert({
message: "Message must be longer than 10 characters",
didConfirm: () => this.focusInput(),
didCancel: () => this.focusInput(),
});
}

// this is a total hack, the composer is hidden on the homepage with CSS
await this.composer.open({
action: Composer.PRIVATE_MESSAGE,
draftKey: "private_message_ai",
recipients: "DiscourseHelper", // TODO: Figure out how to grab the right bot
topicTitle: i18n("discourse_ai.ai_bot.default_pm_prefix"),
topicBody: this.inputValue,
archetypeId: "private_message",
disableDrafts: true,
});

try {
await this.composer.save();
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);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{{body-class "discourse-ai-bot-conversations-page"}}

<div class="custom-homepage__content-wrapper">
<h1>{{i18n "discourse_ai.ai_bot.conversations.header"}}</h1>
<div class="custom-homepage__input-wrapper">
<textarea
{{didInsert this.initializeTextarea}}
{{on "input" this.updateInputValue}}
{{on "keydown" this.handleKeyDown}}
id="custom-homepage-input"
placeholder={{i18n "discourse_ai.ai_bot.conversations.placeholder"}}
minlength="10"
rows="1"
/>
<DButton
@action={{this.aiBotConversationsHiddenSubmit.submitToBot}}
@icon="paper-plane"
@title="discourse_ai.ai_bot.conversations.header"
class="ai-bot-button btn-primary"
/>
</div>
<p class="ai-disclaimer">
{{i18n "discourse_ai.ai_bot.conversations.disclaimer"}}
</p>
</div>
140 changes: 140 additions & 0 deletions assets/javascripts/initializers/ai-bot-sidebar.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { tracked } from "@glimmer/tracking";
import { ajax } from "discourse/lib/ajax";
import { withPluginApi } from "discourse/lib/plugin-api";
import { i18n } from "discourse-i18n";

Check failure on line 4 in assets/javascripts/initializers/ai-bot-sidebar.js

View workflow job for this annotation

GitHub Actions / ci / linting

'i18n' is defined but never used
import AiBotSidebarNewConversation from "../discourse/components/ai-bot-sidebar-new-conversation";

export default {
name: "custom-sidebar-bot-messages",
initialize() {
withPluginApi("1.37.1", (api) => {
const currentUser = api.container.lookup("service:current-user");
const appEvents = api.container.lookup("service:app-events");
const messageBus = api.container.lookup("service:message-bus");

if (!currentUser) {
return;
}

// TODO: Replace
const recentConversations = 10;

api.renderInOutlet("after-sidebar-sections", AiBotSidebarNewConversation);

api.addSidebarSection(
(BaseCustomSidebarSection, BaseCustomSidebarSectionLink) => {
return class extends BaseCustomSidebarSection {
@tracked links = [];
@tracked topics = [];
isFetching = false;
totalTopicsCount = 0;

constructor() {
super(...arguments);
this.fetchMessages();

appEvents.on("topic:created", (topic) => {
// when asking a new question
this.addNewMessage(topic);
this.watchForTitleUpdate(topic);
});
}

fetchMessages() {
if (this.isFetching) {
return;
}

this.isFetching = true;

ajax("/discourse-ai/ai-bot/conversations.json")
.then((data) => {
this.topics = data.conversations.slice(
0,
recentConversations
);
this.isFetching = false;
this.buildSidebarLinks();
})
.catch((e) => {
debugger;
this.isFetching = false;
});
}

addNewMessage(newTopic) {
// the pm endpoint isn't fast enough include the newly created topic
// so this adds the new topic to the existing list
const builtTopic =
new (class extends BaseCustomSidebarSectionLink {
name = newTopic.title;
route = "topic.fromParamsNear";
models = [newTopic.topic_slug, newTopic.topic_id, 0];
title = newTopic.title;
text = newTopic.title;
prefixType = "icon";
prefixValue = "robot";
})();

this.links = [builtTopic, ...this.links];
}

buildSidebarLinks() {
this.links = this.topics.map((topic) => {
return new (class extends BaseCustomSidebarSectionLink {
name = topic.title;
route = "topic.fromParamsNear";
models = [
topic.slug,
topic.id,
topic.last_read_post_number || 0,
];
title = topic.title;
text = topic.title;
prefixType = "icon";
prefixValue = "robot";
})();
});

if (this.totalTopicsCount > recentConversations) {
this.links.push(
new (class extends BaseCustomSidebarSectionLink {
name = "View All";
route = "userPrivateMessages.user.index";
models = [currentUser.username];
title = "View all...";
text = "View all...";
prefixType = "icon";
prefixValue = "list";
})()
);
}
}

watchForTitleUpdate(topic) {
const channel = `/discourse-ai/ai-bot/topic/${topic.topic_id}`;
messageBus.subscribe(channel, () => {
this.fetchMessages();
messageBus.unsubscribe(channel);
});
}

get name() {
return "custom-messages";
}

get text() {
// TODO: FIX
//return i18n(themePrefix("messages_sidebar.title"));
return "Conversations";
}

get displaySection() {
return this.links?.length > 0;
}
};
}
);
});
},
};
Loading
Loading