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