From 362f6167d156806ec1f64584d856b8b77d0dd82a Mon Sep 17 00:00:00 2001 From: Roman Rizzi Date: Tue, 16 May 2023 14:38:21 -0300 Subject: [PATCH] FEATURE: Less friction for starting a conversation with an AI bot. (#63) * FEATURE: Less friction for starting a conversation with an AI bot. This PR adds a new header icon as a shortcut to start a conversation with one of our AI Bots. After clicking and selecting one from the dropdown menu, we'll open the composer with some fields already filled (recipients and title). If you leave the title as is, we'll queue a job after five minutes to update it using a bot suggestion. * Update assets/javascripts/initializers/ai-bot-replies.js Co-authored-by: Rafael dos Santos Silva * Update assets/javascripts/initializers/ai-bot-replies.js Co-authored-by: Rafael dos Santos Silva --------- Co-authored-by: Rafael dos Santos Silva --- .../discourse_ai/ai_bot/bot_controller.rb | 9 ++ .../components/ai-bot-header-icon.hbs | 31 ++++++ .../components/ai-bot-header-icon.js | 98 +++++++++++++++++++ .../discourse/widgets/ai-bot-header-icon.js | 28 ++++++ .../initializers/ai-bot-replies.js | 19 +++- .../modules/ai-bot/common/bot-replies.scss | 18 ++++ config/locales/client.en.yml | 1 + config/locales/server.en.yml | 5 + config/routes.rb | 1 + config/settings.yml | 3 + lib/modules/ai_bot/anthropic_bot.rb | 9 ++ lib/modules/ai_bot/bot.rb | 25 ++++- lib/modules/ai_bot/entry_point.rb | 26 ++++- .../jobs/regular/update_ai_bot_pm_title.rb | 17 ++++ lib/modules/ai_bot/open_ai_bot.rb | 10 ++ spec/lib/modules/ai_bot/bot_spec.rb | 33 +++++++ spec/requests/ai_bot/bot_controller_spec.rb | 17 +++- 17 files changed, 345 insertions(+), 5 deletions(-) create mode 100644 assets/javascripts/discourse/components/ai-bot-header-icon.hbs create mode 100644 assets/javascripts/discourse/components/ai-bot-header-icon.js create mode 100644 assets/javascripts/discourse/widgets/ai-bot-header-icon.js create mode 100644 lib/modules/ai_bot/jobs/regular/update_ai_bot_pm_title.rb create mode 100644 spec/lib/modules/ai_bot/bot_spec.rb 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