From bf5ccb452cfef45101ea18a08e262e4efc65e030 Mon Sep 17 00:00:00 2001 From: Keegan George Date: Tue, 1 Apr 2025 10:22:39 -0700 Subject: [PATCH] FEATURE: Continue conversation from Discobot discovery (#1234) This feature update allows for continuing the conversation with Discobot Discoveries in an AI bot chat. After discoveries gives you a response to your search you can continue with the existing context. --- .../discourse_ai/ai_bot/bot_controller.rb | 34 ++++++++ .../components/ai-search-discoveries.gjs | 79 +++++++++++++++++++ .../ai-discobot-discoveries.gjs | 1 + .../common/ai-discobot-discoveries.scss | 4 + config/locales/client.en.yml | 2 + config/locales/server.en.yml | 4 + config/routes.rb | 1 + spec/requests/ai_bot/bot_controller_spec.rb | 67 ++++++++++++++++ 8 files changed, 192 insertions(+) diff --git a/app/controllers/discourse_ai/ai_bot/bot_controller.rb b/app/controllers/discourse_ai/ai_bot/bot_controller.rb index 3430e1db..f405dd65 100644 --- a/app/controllers/discourse_ai/ai_bot/bot_controller.rb +++ b/app/controllers/discourse_ai/ai_bot/bot_controller.rb @@ -69,6 +69,40 @@ module DiscourseAi render json: {}, status: 200 end + + def discover_continue_convo + raise Discourse::InvalidParameters.new("user_id") if !params[:user_id] + raise Discourse::InvalidParameters.new("query") if !params[:query] + raise Discourse::InvalidParameters.new("context") if !params[:context] + + user = User.find(params[:user_id]) + + bot_user_id = AiPersona.find_by(id: SiteSetting.ai_bot_discover_persona).user_id + bot_username = User.find_by(id: bot_user_id).username + + query = params[:query] + context = "[quote]\n#{params[:context]}\n[/quote]" + + post = + PostCreator.create!( + user, + title: + I18n.t("discourse_ai.ai_bot.discoveries.continue_conversation.title", query: query), + raw: + I18n.t( + "discourse_ai.ai_bot.discoveries.continue_conversation.raw", + query: query, + context: context, + ), + archetype: Archetype.private_message, + target_usernames: bot_username, + skip_validations: true, + ) + + render json: success_json.merge(topic_id: post.topic_id) + rescue StandardError => e + render json: failed_json.merge(errors: [e.message]), status: 422 + end end end end diff --git a/assets/javascripts/discourse/components/ai-search-discoveries.gjs b/assets/javascripts/discourse/components/ai-search-discoveries.gjs index 4af4249d..93ddb84e 100644 --- a/assets/javascripts/discourse/components/ai-search-discoveries.gjs +++ b/assets/javascripts/discourse/components/ai-search-discoveries.gjs @@ -10,11 +10,15 @@ import CookText from "discourse/components/cook-text"; import DButton from "discourse/components/d-button"; import concatClass from "discourse/helpers/concat-class"; import { ajax } from "discourse/lib/ajax"; +import { popupAjaxError } from "discourse/lib/ajax-error"; import { bind } from "discourse/lib/decorators"; import { withPluginApi } from "discourse/lib/plugin-api"; +import DiscourseURL from "discourse/lib/url"; +import Topic from "discourse/models/topic"; import { i18n } from "discourse-i18n"; import SmoothStreamer from "../lib/smooth-streamer"; import AiBlinkingAnimation from "./ai-blinking-animation"; +import AiIndicatorWave from "./ai-indicator-wave"; const DISCOVERY_TIMEOUT_MS = 10000; @@ -23,7 +27,11 @@ export default class AiSearchDiscoveries extends Component { @service messageBus; @service discobotDiscoveries; @service appEvents; + @service currentUser; + @service siteSettings; + @service composer; + @tracked loadingConversationTopic = false; @tracked hideDiscoveries = false; @tracked fullDiscoveryToggled = false; @tracked discoveryPreviewLength = this.args.discoveryPreviewLength || 150; @@ -145,6 +153,28 @@ export default class AiSearchDiscoveries extends Component { return !this.fullDiscoveryToggled && this.canShowExpandtoggle; } + get canContinueConversation() { + const personas = this.currentUser?.ai_enabled_personas; + const discoverPersona = personas.find( + (persona) => persona.id === this.siteSettings?.ai_bot_discover_persona + ); + const discoverPersonaHasBot = discoverPersona?.username; + + return ( + this.discobotDiscoveries.discovery?.length > 0 && + !this.smoothStreamer.isStreaming && + discoverPersonaHasBot + ); + } + + get continueConvoBtnLabel() { + if (this.loadingConversationTopic) { + return "discourse_ai.discobot_discoveries.loading_convo"; + } + + return "discourse_ai.discobot_discoveries.continue_convo"; + } + @action async triggerDiscovery() { if (this.discobotDiscoveries.lastQuery === this.query) { @@ -180,6 +210,43 @@ export default class AiSearchDiscoveries extends Component { this.fullDiscoveryToggled = !this.fullDiscoveryToggled; } + @action + async continueConversation() { + const data = { + user_id: this.currentUser.id, + query: this.query, + context: this.discobotDiscoveries.discovery, + }; + try { + this.loadingConversationTopic = true; + const continueRequest = await ajax( + `/discourse-ai/ai-bot/discover/continue-convo`, + { + type: "POST", + data, + } + ); + const topicJSON = await Topic.find(continueRequest.topic_id, {}); + const topic = Topic.create(topicJSON); + + DiscourseURL.routeTo(`/t/${continueRequest.topic_id}`, { + afterRouteComplete: () => { + if (this.args.closeSearchMenu) { + this.args.closeSearchMenu(); + } + + this.composer.focusComposer({ + topic, + }); + }, + }); + } catch (e) { + popupAjaxError(e); + } finally { + this.loadingConversationTopic = false; + } + } + timeoutDiscovery() { this.discobotDiscoveries.loadingDiscoveries = false; this.discobotDiscoveries.discovery = ""; @@ -226,6 +293,18 @@ export default class AiSearchDiscoveries extends Component { {{/if}} {{/if}} + + {{#if this.canContinueConversation}} +
+ + + +
+ {{/if}} } diff --git a/assets/javascripts/discourse/connectors/search-menu-results-top/ai-discobot-discoveries.gjs b/assets/javascripts/discourse/connectors/search-menu-results-top/ai-discobot-discoveries.gjs index 694db93d..6256656f 100644 --- a/assets/javascripts/discourse/connectors/search-menu-results-top/ai-discobot-discoveries.gjs +++ b/assets/javascripts/discourse/connectors/search-menu-results-top/ai-discobot-discoveries.gjs @@ -33,6 +33,7 @@ export default class AiDiscobotDiscoveries extends Component { {{#if this.search.results.topics.length}} diff --git a/assets/stylesheets/modules/ai-bot/common/ai-discobot-discoveries.scss b/assets/stylesheets/modules/ai-bot/common/ai-discobot-discoveries.scss index 7cb957ad..c6b71784 100644 --- a/assets/stylesheets/modules/ai-bot/common/ai-discobot-discoveries.scss +++ b/assets/stylesheets/modules/ai-bot/common/ai-discobot-discoveries.scss @@ -62,6 +62,10 @@ .cooked p:first-child { margin-top: 0; } + + &__continue-conversation { + margin-block: 1rem; + } } .ai-search-discoveries-tooltip { diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 324f59b3..5f4eb92c 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -731,6 +731,8 @@ en: main_title: "Discobot discoveries" regular_results: "Topics" tell_me_more: "Tell me more..." + continue_convo: "Continue conversation..." + loading_convo: "Loading conversation" collapse: "Collapse" timed_out: "Discobot couldn't find any discoveries. Something went wrong." user_setting: "Enable search discoveries" diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 13a3685d..574fb7f7 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -422,6 +422,10 @@ en: search_settings: one: "Found %{count} result for '%{query}'" other: "Found %{count} results for '%{query}'" + discoveries: + continue_conversation: + title: "Discovery conversation: Search for %{query}" + raw: "In my search for %{query}, you showed me the following information:\n\n%{context}\n\nLet's continue the conversation." summarization: configuration_hint: diff --git a/config/routes.rb b/config/routes.rb index 7c67e8e2..29fa2658 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -26,6 +26,7 @@ DiscourseAi::Engine.routes.draw do post "post/:post_id/stop-streaming" => "bot#stop_streaming_response" get "discover" => "bot#discover" + post "discover/continue-convo" => "bot#discover_continue_convo" end scope module: :ai_bot, path: "/ai-bot/shared-ai-conversations" do diff --git a/spec/requests/ai_bot/bot_controller_spec.rb b/spec/requests/ai_bot/bot_controller_spec.rb index dbe8fe8d..385c37e0 100644 --- a/spec/requests/ai_bot/bot_controller_spec.rb +++ b/spec/requests/ai_bot/bot_controller_spec.rb @@ -168,4 +168,71 @@ RSpec.describe DiscourseAi::AiBot::BotController do end end end + + describe "#discover_continue_convo" do + before { SiteSetting.ai_bot_enabled = true } + fab!(:group) + fab!(:llm_model) + fab!(:ai_persona) do + persona = Fabricate(:ai_persona, allowed_group_ids: [group.id], default_llm_id: llm_model.id) + persona.create_user! + persona + end + let(:query) { "What is Discourse?" } + let(:context) { "Discourse is an open-source discussion platform." } + + context "when the user is allowed to discover" do + before do + SiteSetting.ai_bot_discover_persona = ai_persona.id + group.add(user) + end + + it "returns a 200 and creates a private message topic" do + expect { + post "/discourse-ai/ai-bot/discover/continue-convo", + params: { + user_id: user.id, + query: query, + context: context, + } + }.to change(Topic, :count).by(1) + + expect(response.status).to eq(200) + expect(response.parsed_body["topic_id"]).to be_present + end + + it "returns invalid parameters if the user_id is missing" do + post "/discourse-ai/ai-bot/discover/continue-convo", + params: { + query: query, + context: context, + } + + expect(response.status).to eq(422) + expect(response.parsed_body["errors"]).to include("user_id") + end + + it "returns invalid parameters if the query is missing" do + post "/discourse-ai/ai-bot/discover/continue-convo", + params: { + user_id: user.id, + context: context, + } + + expect(response.status).to eq(422) + expect(response.parsed_body["errors"]).to include("query") + end + + it "returns invalid parameters if the context is missing" do + post "/discourse-ai/ai-bot/discover/continue-convo", + params: { + user_id: user.id, + query: query, + } + + expect(response.status).to eq(422) + expect(response.parsed_body["errors"]).to include("context") + end + end + end end