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 <xfalcox@gmail.com>

* Update assets/javascripts/initializers/ai-bot-replies.js

Co-authored-by: Rafael dos Santos Silva <xfalcox@gmail.com>

---------

Co-authored-by: Rafael dos Santos Silva <xfalcox@gmail.com>
This commit is contained in:
Roman Rizzi 2023-05-16 14:38:21 -03:00 committed by GitHub
parent 2ed1f874c2
commit 362f6167d1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 345 additions and 5 deletions

View File

@ -14,6 +14,15 @@ module DiscourseAi
render json: {}, status: 200 render json: {}, status: 200
end 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 end
end end

View File

@ -0,0 +1,31 @@
{{#if this.singleBotEnabled}}
<DButton
@class="icon btn-flat"
@action={{this.singleComposeAiBotMessage}}
@icon="robot"
/>
{{else}}
<DButton
@class="icon btn-flat ai-bot-toggle-available-bots"
@action={{this.toggleBotOptions}}
@icon="robot"
/>
{{#if this.open}}
<div class="ai-bot-available-bot-options">
<div
class="ai-bot-available-bot-options-wrapper"
{{did-insert this.registerClickListener}}
{{will-destroy this.unregisterClickListener}}
>
{{#each this.enabledBotOptions as |modelName|}}
<DButton
@class="btn-flat ai-bot-available-bot-content"
@translatedTitle={{modelName}}
@translatedLabel={{modelName}}
@action={{action "composeMessageWithTargetBot" modelName}}
/>
{{/each}}
</div>
</div>
{{/if}}
{{/if}}

View File

@ -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;
}
}

View File

@ -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`<AiBotHeaderIcon />`
),
];
},
});

View File

@ -8,6 +8,14 @@ function isGPTBot(user) {
return user && [-110, -111, -112].includes(user.id); 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) { function initializeAIBotReplies(api) {
api.addPostMenuButton("cancel-gpt", (post) => { api.addPostMenuButton("cancel-gpt", (post) => {
if (isGPTBot(post.user)) { if (isGPTBot(post.user)) {
@ -94,10 +102,19 @@ export default {
initialize(container) { initialize(container) {
const settings = container.lookup("service:site-settings"); const settings = container.lookup("service:site-settings");
const user = container.lookup("service:current-user");
const aiBotEnaled = const aiBotEnaled =
settings.discourse_ai_enabled && settings.ai_bot_enabled; 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); withPluginApi("1.6.0", initializeAIBotReplies);
} }
}, },

View File

@ -5,3 +5,21 @@ nav.post-controls .actions button.cancel-streaming {
article.streaming nav.post-controls .actions button.cancel-streaming { article.streaming nav.post-controls .actions button.cancel-streaming {
display: inline-block; 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);
}
}
}

View File

@ -27,6 +27,7 @@ en:
ai_bot: ai_bot:
cancel_streaming: Stop reply cancel_streaming: Stop reply
default_pm_prefix: "[Untitled AI bot PM]"
review: review:

View File

@ -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_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_model: "Model to use for semantic search."
ai_embeddings_semantic_search_enabled: "Enable full-page 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_enabled: "Enable the summarization module."
ai_summarization_discourse_service_api_endpoint: "URL where the Discourse summarization API is running." 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_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_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_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: reviewables:
@ -80,3 +82,6 @@ en:
generate_titles: Suggest topic titles generate_titles: Suggest topic titles
proofread: Proofread text proofread: Proofread text
markdown_table: Generate Markdown table markdown_table: Generate Markdown table
ai_bot:
default_pm_prefix: "[Untitled AI bot PM]"

View File

@ -16,6 +16,7 @@ DiscourseAi::Engine.routes.draw do
scope module: :ai_bot, path: "/ai-bot", defaults: { format: :json } do scope module: :ai_bot, path: "/ai-bot", defaults: { format: :json } do
post "post/:post_id/stop-streaming" => "bot#stop_streaming_response" post "post/:post_id/stop-streaming" => "bot#stop_streaming_response"
get "bot-username" => "bot#show_bot_username"
end end
end end

View File

@ -203,3 +203,6 @@ plugins:
- gpt-3.5-turbo - gpt-3.5-turbo
- gpt-4 - gpt-4
- claude-v1 - claude-v1
ai_helper_add_ai_pm_to_header:
default: true
client: true

View File

@ -31,6 +31,15 @@ module DiscourseAi
partial[:completion] partial[:completion]
end 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) def submit_prompt_and_stream_reply(prompt, &blk)
DiscourseAi::Inference::AnthropicCompletions.perform!( DiscourseAi::Inference::AnthropicCompletions.perform!(
prompt, prompt,

View File

@ -20,6 +20,17 @@ module DiscourseAi
@bot_user = bot_user @bot_user = bot_user
end 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) def reply_to(post)
prompt = bot_prompt_with_topic_context(post) prompt = bot_prompt_with_topic_context(post)
@ -72,7 +83,7 @@ module DiscourseAi
Discourse.warn_exception(e, message: "ai-bot: Reply failed") Discourse.warn_exception(e, message: "ai-bot: Reply failed")
end end
def bot_prompt_with_topic_context(post) def bot_prompt_with_topic_context(post, prompt: "topic")
messages = [] messages = []
conversation = conversation_context(post) conversation = conversation_context(post)
@ -106,10 +117,22 @@ module DiscourseAi
raise NotImplemented raise NotImplemented
end 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 protected
attr_reader :bot_user attr_reader :bot_user
def get_updated_title(prompt)
raise NotImplemented
end
def model_for(bot) def model_for(bot)
raise NotImplemented raise NotImplemented
end end

View File

@ -12,8 +12,22 @@ module DiscourseAi
[CLAUDE_V1_ID, "claude_v1_bot"], [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 def load_files
require_relative "jobs/regular/create_ai_reply" require_relative "jobs/regular/create_ai_reply"
require_relative "jobs/regular/update_ai_bot_pm_title"
require_relative "bot" require_relative "bot"
require_relative "anthropic_bot" require_relative "anthropic_bot"
require_relative "open_ai_bot" require_relative "open_ai_bot"
@ -24,6 +38,8 @@ module DiscourseAi
Rails.root.join("plugins", "discourse-ai", "db", "fixtures", "ai_bot"), Rails.root.join("plugins", "discourse-ai", "db", "fixtures", "ai_bot"),
) )
plugin.register_svg_icon("robot")
plugin.on(:post_created) do |post| plugin.on(:post_created) do |post|
bot_ids = BOTS.map(&:first) bot_ids = BOTS.map(&:first)
@ -31,7 +47,15 @@ module DiscourseAi
if (SiteSetting.ai_bot_allowed_groups_map & post.user.group_ids).present? 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 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 end
end end

View File

@ -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

View File

@ -33,6 +33,16 @@ module DiscourseAi
current_delta + partial.dig(:choices, 0, :delta, :content).to_s current_delta + partial.dig(:choices, 0, :delta, :content).to_s
end 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) def submit_prompt_and_stream_reply(prompt, &blk)
DiscourseAi::Inference::OpenAiCompletions.perform!( DiscourseAi::Inference::OpenAiCompletions.perform!(
prompt, prompt,

View File

@ -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

View File

@ -1,6 +1,9 @@
# frozen_string_literal: true # frozen_string_literal: true
RSpec.describe DiscourseAi::AiBot::BotController do RSpec.describe DiscourseAi::AiBot::BotController do
fab!(:user) { Fabricate(:user) }
before { sign_in(user) }
describe "#stop_streaming_response" do describe "#stop_streaming_response" do
fab!(:pm_topic) { Fabricate(:private_message_topic) } fab!(:pm_topic) { Fabricate(:private_message_topic) }
fab!(:pm_post) { Fabricate(:post, topic: pm_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) } before { Discourse.redis.setex(redis_stream_key, 60, 1) }
it "returns a 403 when the user cannot see the PM" do 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" post "/discourse-ai/ai-bot/post/#{pm_post.id}/stop-streaming"
expect(response.status).to eq(403) 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 expect(Discourse.redis.get(redis_stream_key)).to be_nil
end end
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 end