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

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);
}
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);
}
},

View File

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

View File

@ -27,6 +27,7 @@ en:
ai_bot:
cancel_streaming: Stop reply
default_pm_prefix: "[Untitled AI bot PM]"
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_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]"

View File

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

View File

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

View File

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

View File

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

View File

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

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

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