diff --git a/app/controllers/discourse_ai/ai_bot/bot_controller.rb b/app/controllers/discourse_ai/ai_bot/bot_controller.rb
index d80df452..cffa1fde 100644
--- a/app/controllers/discourse_ai/ai_bot/bot_controller.rb
+++ b/app/controllers/discourse_ai/ai_bot/bot_controller.rb
@@ -14,6 +14,15 @@ module DiscourseAi
render json: {}, status: 200
end
+
+ def show_bot_username
+ bot_user_id = DiscourseAi::AiBot::EntryPoint.map_bot_model_to_user_id(params[:username])
+ raise Discourse::InvalidParameters.new(:username) if !bot_user_id
+
+ bot_username_lower = User.find(bot_user_id).username_lower
+
+ render json: { bot_username: bot_username_lower }, status: 200
+ end
end
end
end
diff --git a/assets/javascripts/discourse/components/ai-bot-header-icon.hbs b/assets/javascripts/discourse/components/ai-bot-header-icon.hbs
new file mode 100644
index 00000000..8ffec16d
--- /dev/null
+++ b/assets/javascripts/discourse/components/ai-bot-header-icon.hbs
@@ -0,0 +1,31 @@
+{{#if this.singleBotEnabled}}
+
+{{else}}
+
+ {{#if this.open}}
+
+
+ {{#each this.enabledBotOptions as |modelName|}}
+
+ {{/each}}
+
+
+ {{/if}}
+{{/if}}
\ No newline at end of file
diff --git a/assets/javascripts/discourse/components/ai-bot-header-icon.js b/assets/javascripts/discourse/components/ai-bot-header-icon.js
new file mode 100644
index 00000000..349d7f18
--- /dev/null
+++ b/assets/javascripts/discourse/components/ai-bot-header-icon.js
@@ -0,0 +1,98 @@
+import { action } from "@ember/object";
+import { inject as service } from "@ember/service";
+import { ajax } from "discourse/lib/ajax";
+import Component from "@ember/component";
+import Composer from "discourse/models/composer";
+import { tracked } from "@glimmer/tracking";
+import { bind } from "discourse-common/utils/decorators";
+import I18n from "I18n";
+
+export default class AiBotHeaderIcon extends Component {
+ @service siteSettings;
+ @service composer;
+
+ @tracked open = false;
+
+ @action
+ async toggleBotOptions() {
+ this.open = !this.open;
+ }
+
+ @action
+ async composeMessageWithTargetBot(target) {
+ this._composeAiBotMessage(target);
+ }
+
+ @action
+ async singleComposeAiBotMessage() {
+ this._composeAiBotMessage(
+ this.siteSettings.ai_bot_enabled_chat_bots.split("|")[0]
+ );
+ }
+
+ @action
+ registerClickListener() {
+ this.#addClickEventListener();
+ }
+
+ @action
+ unregisterClickListener() {
+ this.#removeClickEventListener();
+ }
+
+ @bind
+ closeDetails(event) {
+ if (this.open) {
+ const isLinkClick = event.target.className.includes(
+ "ai-bot-toggle-available-bots"
+ );
+
+ if (isLinkClick || this.#isOutsideDetailsClick(event)) {
+ this.open = false;
+ }
+ }
+ }
+
+ #isOutsideDetailsClick(event) {
+ return !event.composedPath().some((element) => {
+ return element.className === "ai-bot-available-bot-options";
+ });
+ }
+
+ #removeClickEventListener() {
+ document.removeEventListener("click", this.closeDetails);
+ }
+
+ #addClickEventListener() {
+ document.addEventListener("click", this.closeDetails);
+ }
+
+ get enabledBotOptions() {
+ return this.siteSettings.ai_bot_enabled_chat_bots.split("|");
+ }
+
+ get singleBotEnabled() {
+ return this.enabledBotOptions.length === 1;
+ }
+
+ async _composeAiBotMessage(targetBot) {
+ let botUsername = await ajax("/discourse-ai/ai-bot/bot-username", {
+ data: { username: targetBot },
+ }).then((data) => {
+ return data.bot_username;
+ });
+
+ this.composer.open({
+ action: Composer.PRIVATE_MESSAGE,
+ recipients: botUsername,
+ topicTitle: `${I18n.t(
+ "discourse_ai.ai_bot.default_pm_prefix"
+ )} ${botUsername}`,
+ archetypeId: "private_message",
+ draftKey: Composer.NEW_PRIVATE_MESSAGE_KEY,
+ hasGroups: false,
+ });
+
+ this.open = false;
+ }
+}
diff --git a/assets/javascripts/discourse/widgets/ai-bot-header-icon.js b/assets/javascripts/discourse/widgets/ai-bot-header-icon.js
new file mode 100644
index 00000000..3a7e19c3
--- /dev/null
+++ b/assets/javascripts/discourse/widgets/ai-bot-header-icon.js
@@ -0,0 +1,28 @@
+import { createWidget } from "discourse/widgets/widget";
+import RenderGlimmer from "discourse/widgets/render-glimmer";
+import { hbs } from "ember-cli-htmlbars";
+
+export default createWidget("ai-bot-header-icon", {
+ tagName: "li.header-dropdown-toggle.ai-bot-header-icon",
+ title: "discourse_ai.ai_bot.shortcut_title",
+
+ services: ["siteSettings"],
+
+ html() {
+ const enabledBots = this.siteSettings.ai_bot_enabled_chat_bots
+ .split("|")
+ .filter(Boolean);
+
+ if (!enabledBots || enabledBots.length === 0) {
+ return;
+ }
+
+ return [
+ new RenderGlimmer(
+ this,
+ "div.widget-component-connector",
+ hbs``
+ ),
+ ];
+ },
+});
diff --git a/assets/javascripts/initializers/ai-bot-replies.js b/assets/javascripts/initializers/ai-bot-replies.js
index a056f014..531eeaaf 100644
--- a/assets/javascripts/initializers/ai-bot-replies.js
+++ b/assets/javascripts/initializers/ai-bot-replies.js
@@ -8,6 +8,14 @@ function isGPTBot(user) {
return user && [-110, -111, -112].includes(user.id);
}
+function attachHeaderIcon(api) {
+ const settings = api.container.lookup("service:site-settings");
+
+ if (settings.ai_helper_add_ai_pm_to_header) {
+ api.addToHeaderIcons("ai-bot-header-icon");
+ }
+}
+
function initializeAIBotReplies(api) {
api.addPostMenuButton("cancel-gpt", (post) => {
if (isGPTBot(post.user)) {
@@ -94,10 +102,19 @@ export default {
initialize(container) {
const settings = container.lookup("service:site-settings");
+ const user = container.lookup("service:current-user");
const aiBotEnaled =
settings.discourse_ai_enabled && settings.ai_bot_enabled;
- if (aiBotEnaled) {
+ const aiBotsAllowedGroups = settings.ai_bot_allowed_groups
+ .split("|")
+ .map(parseInt);
+ const canInteractWithAIBots = user?.groups.some((g) =>
+ aiBotsAllowedGroups.includes(g.id)
+ );
+
+ if (aiBotEnaled && canInteractWithAIBots) {
+ withPluginApi("1.6.0", attachHeaderIcon);
withPluginApi("1.6.0", initializeAIBotReplies);
}
},
diff --git a/assets/stylesheets/modules/ai-bot/common/bot-replies.scss b/assets/stylesheets/modules/ai-bot/common/bot-replies.scss
index de71dcde..8585a2cb 100644
--- a/assets/stylesheets/modules/ai-bot/common/bot-replies.scss
+++ b/assets/stylesheets/modules/ai-bot/common/bot-replies.scss
@@ -5,3 +5,21 @@ nav.post-controls .actions button.cancel-streaming {
article.streaming nav.post-controls .actions button.cancel-streaming {
display: inline-block;
}
+
+.ai-bot-available-bot-options {
+ position: absolute;
+ top: 100%;
+ z-index: z("modal", "content") + 1;
+ transition: background-color 0.25s;
+ background-color: var(--secondary);
+ min-width: 150px;
+
+ .ai-bot-available-bot-content {
+ color: var(--primary-high);
+ width: 100%;
+
+ &:hover {
+ background: var(--primary-low);
+ }
+ }
+}
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index f2250b48..673b9e70 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -27,6 +27,7 @@ en:
ai_bot:
cancel_streaming: Stop reply
+ default_pm_prefix: "[Untitled AI bot PM]"
review:
diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml
index 4bed6676..4b925921 100644
--- a/config/locales/server.en.yml
+++ b/config/locales/server.en.yml
@@ -50,6 +50,7 @@ en:
ai_embeddings_pg_connection_string: "PostgreSQL connection string for the embeddings module. Needs pgvector extension enabled and a series of tables created. See docs for more info."
ai_embeddings_semantic_search_model: "Model to use for semantic search."
ai_embeddings_semantic_search_enabled: "Enable full-page semantic search."
+ ai_embeddings_semantic_related_include_closed_topics: "Include closed topics in semantic search results"
ai_summarization_enabled: "Enable the summarization module."
ai_summarization_discourse_service_api_endpoint: "URL where the Discourse summarization API is running."
@@ -60,6 +61,7 @@ en:
ai_bot_enabled: "Enable the AI Bot module."
ai_bot_allowed_groups: "When the GPT Bot has access to the PM, it will reply to members of these groups."
ai_bot_enabled_chat_bots: "Available models to act as an AI Bot"
+ ai_helper_add_ai_pm_to_header: "Display a button in the header to start a PM with a AI Bot"
reviewables:
@@ -80,3 +82,6 @@ en:
generate_titles: Suggest topic titles
proofread: Proofread text
markdown_table: Generate Markdown table
+
+ ai_bot:
+ default_pm_prefix: "[Untitled AI bot PM]"
diff --git a/config/routes.rb b/config/routes.rb
index e3b24702..917a6510 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -16,6 +16,7 @@ DiscourseAi::Engine.routes.draw do
scope module: :ai_bot, path: "/ai-bot", defaults: { format: :json } do
post "post/:post_id/stop-streaming" => "bot#stop_streaming_response"
+ get "bot-username" => "bot#show_bot_username"
end
end
diff --git a/config/settings.yml b/config/settings.yml
index 8bb10fdf..db9fe67d 100644
--- a/config/settings.yml
+++ b/config/settings.yml
@@ -203,3 +203,6 @@ plugins:
- gpt-3.5-turbo
- gpt-4
- claude-v1
+ ai_helper_add_ai_pm_to_header:
+ default: true
+ client: true
diff --git a/lib/modules/ai_bot/anthropic_bot.rb b/lib/modules/ai_bot/anthropic_bot.rb
index 1e1e559b..d1c7c6c4 100644
--- a/lib/modules/ai_bot/anthropic_bot.rb
+++ b/lib/modules/ai_bot/anthropic_bot.rb
@@ -31,6 +31,15 @@ module DiscourseAi
partial[:completion]
end
+ def get_updated_title(prompt)
+ DiscourseAi::Inference::AnthropicCompletions.perform!(
+ prompt,
+ model_for,
+ temperature: 0.7,
+ max_tokens: 40,
+ ).dig(:completion)
+ end
+
def submit_prompt_and_stream_reply(prompt, &blk)
DiscourseAi::Inference::AnthropicCompletions.perform!(
prompt,
diff --git a/lib/modules/ai_bot/bot.rb b/lib/modules/ai_bot/bot.rb
index 2fa96ad0..259c6a87 100644
--- a/lib/modules/ai_bot/bot.rb
+++ b/lib/modules/ai_bot/bot.rb
@@ -20,6 +20,17 @@ module DiscourseAi
@bot_user = bot_user
end
+ def update_pm_title(post)
+ prompt = [title_prompt(post)]
+
+ new_title = get_updated_title(prompt)
+
+ PostRevisor.new(post.topic.first_post, post.topic).revise!(
+ bot_user,
+ title: new_title.sub(/\A"/, "").sub(/"\Z/, ""),
+ )
+ end
+
def reply_to(post)
prompt = bot_prompt_with_topic_context(post)
@@ -72,7 +83,7 @@ module DiscourseAi
Discourse.warn_exception(e, message: "ai-bot: Reply failed")
end
- def bot_prompt_with_topic_context(post)
+ def bot_prompt_with_topic_context(post, prompt: "topic")
messages = []
conversation = conversation_context(post)
@@ -106,10 +117,22 @@ module DiscourseAi
raise NotImplemented
end
+ def title_prompt(post)
+ build_message(bot_user.username, <<~TEXT)
+ Suggest a 7 word title for the following topic without quoting any of it:
+
+ #{post.topic.posts[1..-1].map(&:raw).join("\n\n")[0..prompt_limit]}
+ TEXT
+ end
+
protected
attr_reader :bot_user
+ def get_updated_title(prompt)
+ raise NotImplemented
+ end
+
def model_for(bot)
raise NotImplemented
end
diff --git a/lib/modules/ai_bot/entry_point.rb b/lib/modules/ai_bot/entry_point.rb
index e61bc022..82793d1a 100644
--- a/lib/modules/ai_bot/entry_point.rb
+++ b/lib/modules/ai_bot/entry_point.rb
@@ -12,8 +12,22 @@ module DiscourseAi
[CLAUDE_V1_ID, "claude_v1_bot"],
]
+ def self.map_bot_model_to_user_id(model_name)
+ case model_name
+ in "gpt-3.5-turbo"
+ GPT3_5_TURBO_ID
+ in "gpt-4"
+ GPT4_ID
+ in "claude-v1"
+ CLAUDE_V1_ID
+ else
+ nil
+ end
+ end
+
def load_files
require_relative "jobs/regular/create_ai_reply"
+ require_relative "jobs/regular/update_ai_bot_pm_title"
require_relative "bot"
require_relative "anthropic_bot"
require_relative "open_ai_bot"
@@ -24,6 +38,8 @@ module DiscourseAi
Rails.root.join("plugins", "discourse-ai", "db", "fixtures", "ai_bot"),
)
+ plugin.register_svg_icon("robot")
+
plugin.on(:post_created) do |post|
bot_ids = BOTS.map(&:first)
@@ -31,7 +47,15 @@ module DiscourseAi
if (SiteSetting.ai_bot_allowed_groups_map & post.user.group_ids).present?
bot_id = post.topic.topic_allowed_users.where(user_id: bot_ids).first&.user_id
- Jobs.enqueue(:create_ai_reply, post_id: post.id, bot_user_id: bot_id) if bot_id
+ if bot_id
+ Jobs.enqueue(:create_ai_reply, post_id: post.id, bot_user_id: bot_id)
+ Jobs.enqueue_in(
+ 5.minutes,
+ :update_ai_bot_pm_title,
+ post_id: post.id,
+ bot_user_id: bot_id,
+ )
+ end
end
end
end
diff --git a/lib/modules/ai_bot/jobs/regular/update_ai_bot_pm_title.rb b/lib/modules/ai_bot/jobs/regular/update_ai_bot_pm_title.rb
new file mode 100644
index 00000000..5e70338d
--- /dev/null
+++ b/lib/modules/ai_bot/jobs/regular/update_ai_bot_pm_title.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module ::Jobs
+ class UpdateAiBotPmTitle < ::Jobs::Base
+ sidekiq_options retry: false
+
+ def execute(args)
+ return unless bot_user = User.find_by(id: args[:bot_user_id])
+ return unless bot = DiscourseAi::AiBot::Bot.as(bot_user)
+ return unless post = Post.includes(:topic).find_by(id: args[:post_id])
+
+ return unless post.topic.title.start_with?(I18n.t("discourse_ai.ai_bot.default_pm_prefix"))
+
+ bot.update_pm_title(post)
+ end
+ end
+end
diff --git a/lib/modules/ai_bot/open_ai_bot.rb b/lib/modules/ai_bot/open_ai_bot.rb
index ada06b94..2d50310c 100644
--- a/lib/modules/ai_bot/open_ai_bot.rb
+++ b/lib/modules/ai_bot/open_ai_bot.rb
@@ -33,6 +33,16 @@ module DiscourseAi
current_delta + partial.dig(:choices, 0, :delta, :content).to_s
end
+ def get_updated_title(prompt)
+ DiscourseAi::Inference::OpenAiCompletions.perform!(
+ prompt,
+ model_for,
+ temperature: 0.7,
+ top_p: 0.9,
+ max_tokens: 40,
+ ).dig(:choices, 0, :message, :content)
+ end
+
def submit_prompt_and_stream_reply(prompt, &blk)
DiscourseAi::Inference::OpenAiCompletions.perform!(
prompt,
diff --git a/spec/lib/modules/ai_bot/bot_spec.rb b/spec/lib/modules/ai_bot/bot_spec.rb
new file mode 100644
index 00000000..434274b2
--- /dev/null
+++ b/spec/lib/modules/ai_bot/bot_spec.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+require_relative "../../../support/openai_completions_inference_stubs"
+
+RSpec.describe DiscourseAi::AiBot::Bot do
+ describe "#update_pm_title" do
+ fab!(:topic) { Fabricate(:topic) }
+ fab!(:post) { Fabricate(:post, topic: topic) }
+
+ let(:expected_response) { "This is a suggested title" }
+
+ before { SiteSetting.min_personal_message_post_length = 5 }
+
+ before { SiteSetting.min_personal_message_post_length = 5 }
+
+ it "updates the title using bot suggestions" do
+ bot_user = User.find(DiscourseAi::AiBot::EntryPoint::GPT3_5_TURBO_ID)
+ OpenAiCompletionsInferenceStubs.stub_response(
+ DiscourseAi::AiBot::OpenAiBot.new(bot_user).title_prompt(post),
+ expected_response,
+ req_opts: {
+ temperature: 0.7,
+ top_p: 0.9,
+ max_tokens: 40,
+ },
+ )
+
+ described_class.as(bot_user).update_pm_title(post)
+
+ expect(topic.reload.title).to eq(expected_response)
+ end
+ end
+end
diff --git a/spec/requests/ai_bot/bot_controller_spec.rb b/spec/requests/ai_bot/bot_controller_spec.rb
index 6efa385f..8cebaa7c 100644
--- a/spec/requests/ai_bot/bot_controller_spec.rb
+++ b/spec/requests/ai_bot/bot_controller_spec.rb
@@ -1,6 +1,9 @@
# frozen_string_literal: true
RSpec.describe DiscourseAi::AiBot::BotController do
+ fab!(:user) { Fabricate(:user) }
+ before { sign_in(user) }
+
describe "#stop_streaming_response" do
fab!(:pm_topic) { Fabricate(:private_message_topic) }
fab!(:pm_post) { Fabricate(:post, topic: pm_topic) }
@@ -10,8 +13,6 @@ RSpec.describe DiscourseAi::AiBot::BotController do
before { Discourse.redis.setex(redis_stream_key, 60, 1) }
it "returns a 403 when the user cannot see the PM" do
- sign_in(Fabricate(:user))
-
post "/discourse-ai/ai-bot/post/#{pm_post.id}/stop-streaming"
expect(response.status).to eq(403)
@@ -26,4 +27,16 @@ RSpec.describe DiscourseAi::AiBot::BotController do
expect(Discourse.redis.get(redis_stream_key)).to be_nil
end
end
+
+ describe "#show_bot_username" do
+ it "returns the username_lower of the selected bot" do
+ gpt_3_5_bot = "gpt-3.5-turbo"
+ expected_username = User.find(DiscourseAi::AiBot::EntryPoint::GPT3_5_TURBO_ID).username_lower
+
+ get "/discourse-ai/ai-bot/bot-username", params: { username: gpt_3_5_bot }
+
+ expect(response.status).to eq(200)
+ expect(response.parsed_body["bot_username"]).to eq(expected_username)
+ end
+ end
end