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:
parent
2ed1f874c2
commit
362f6167d1
|
@ -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
|
||||
|
|
|
@ -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}}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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 />`
|
||||
),
|
||||
];
|
||||
},
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -27,6 +27,7 @@ en:
|
|||
|
||||
ai_bot:
|
||||
cancel_streaming: Stop reply
|
||||
default_pm_prefix: "[Untitled AI bot PM]"
|
||||
|
||||
|
||||
review:
|
||||
|
|
|
@ -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]"
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -203,3 +203,6 @@ plugins:
|
|||
- gpt-3.5-turbo
|
||||
- gpt-4
|
||||
- claude-v1
|
||||
ai_helper_add_ai_pm_to_header:
|
||||
default: true
|
||||
client: true
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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,
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue