diff --git a/app/controllers/discourse_ai/ai_bot/bot_controller.rb b/app/controllers/discourse_ai/ai_bot/bot_controller.rb index 5ea13795..3430e1db 100644 --- a/app/controllers/discourse_ai/ai_bot/bot_controller.rb +++ b/app/controllers/discourse_ai/ai_bot/bot_controller.rb @@ -44,6 +44,31 @@ module DiscourseAi render json: { bot_username: bot_user.username_lower }, status: 200 end + + def discover + ai_persona = + AiPersona.all_personas.find do |persona| + persona.id == SiteSetting.ai_bot_discover_persona.to_i + end + + if ai_persona.nil? || !current_user.in_any_groups?(ai_persona.allowed_group_ids.to_a) + raise Discourse::InvalidAccess.new + end + + if ai_persona.default_llm_id.blank? + render_json_error "Discover persona is missing a default LLM model.", status: 503 + return + end + + query = params[:query] + raise Discourse::InvalidParameters.new("Missing query to discover") if query.blank? + + RateLimiter.new(current_user, "ai_bot_discover_#{current_user.id}", 3, 1.minute).performed! + + Jobs.enqueue(:stream_discover_reply, user_id: current_user.id, query: query) + + render json: {}, status: 200 + end end end end diff --git a/app/jobs/regular/stream_discover_reply.rb b/app/jobs/regular/stream_discover_reply.rb new file mode 100644 index 00000000..b242616e --- /dev/null +++ b/app/jobs/regular/stream_discover_reply.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module Jobs + class StreamDiscoverReply < ::Jobs::Base + sidekiq_options retry: false + + def execute(args) + return if (user = User.find_by(id: args[:user_id])).nil? + return if (query = args[:query]).blank? + + ai_persona_klass = + AiPersona.all_personas.find do |persona| + persona.id == SiteSetting.ai_bot_discover_persona.to_i + end + + if ai_persona_klass.nil? || !user.in_any_groups?(ai_persona_klass.allowed_group_ids.to_a) + return + end + return if (llm_model = LlmModel.find_by(id: ai_persona_klass.default_llm_id)).nil? + + bot = + DiscourseAi::AiBot::Bot.as( + Discourse.system_user, + persona: ai_persona_klass.new, + model: llm_model, + ) + + streamed_reply = +"" + start = Time.now + + base = { query: query, model_used: llm_model.display_name } + + bot.reply( + { conversation_context: [{ type: :user, content: query }], skip_tool_details: true }, + ) do |partial| + streamed_reply << partial + + # Throttle updates. + if (Time.now - start > 0.3) || Rails.env.test? + payload = base.merge(done: false, ai_discover_reply: streamed_reply) + publish_update(user, payload) + start = Time.now + end + end + + publish_update(user, base.merge(done: true, ai_discover_reply: streamed_reply)) + end + + def publish_update(user, payload) + MessageBus.publish("/discourse-ai/ai-bot/discover", payload, user_ids: [user.id]) + end + end +end diff --git a/assets/javascripts/discourse/components/ai-blinking-animation.gjs b/assets/javascripts/discourse/components/ai-blinking-animation.gjs new file mode 100644 index 00000000..5b9ed1d2 --- /dev/null +++ b/assets/javascripts/discourse/components/ai-blinking-animation.gjs @@ -0,0 +1,115 @@ +import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; +import { fn } from "@ember/helper"; +import { action } from "@ember/object"; +import didInsert from "@ember/render-modifiers/modifiers/did-insert"; +import didUpdate from "@ember/render-modifiers/modifiers/did-update"; +import willDestroy from "@ember/render-modifiers/modifiers/will-destroy"; +import { cancel } from "@ember/runloop"; +import concatClass from "discourse/helpers/concat-class"; +import discourseLater from "discourse/lib/later"; + +class Block { + @tracked show = false; + @tracked shown = false; + @tracked blinking = false; + + constructor(args = {}) { + this.show = args.show ?? false; + this.shown = args.shown ?? false; + } +} + +const BLOCKS_SIZE = 20; // changing this requires to change css accordingly + +export default class AiBlinkingAnimation extends Component { + blocks = [...Array.from({ length: BLOCKS_SIZE }, () => new Block())]; + + #nextBlockBlinkingTimer; + #blockBlinkingTimer; + #blockShownTimer; + + @action + setupAnimation() { + this.blocks.firstObject.show = true; + this.blocks.firstObject.shown = true; + } + + @action + onBlinking(block) { + if (!block.blinking) { + return; + } + + block.show = false; + + this.#nextBlockBlinkingTimer = discourseLater( + this, + () => { + this.#nextBlock(block).blinking = true; + }, + 250 + ); + + this.#blockBlinkingTimer = discourseLater( + this, + () => { + block.blinking = false; + }, + 500 + ); + } + + @action + onShowing(block) { + if (!block.show) { + return; + } + + this.#blockShownTimer = discourseLater( + this, + () => { + this.#nextBlock(block).show = true; + this.#nextBlock(block).shown = true; + + if (this.blocks.lastObject === block) { + this.blocks.firstObject.blinking = true; + } + }, + 250 + ); + } + + @action + teardownAnimation() { + cancel(this.#blockShownTimer); + cancel(this.#nextBlockBlinkingTimer); + cancel(this.#blockBlinkingTimer); + } + + #nextBlock(currentBlock) { + if (currentBlock === this.blocks.lastObject) { + return this.blocks.firstObject; + } else { + return this.blocks.objectAt(this.blocks.indexOf(currentBlock) + 1); + } + } + + +} diff --git a/assets/javascripts/discourse/components/ai-search-discoveries.gjs b/assets/javascripts/discourse/components/ai-search-discoveries.gjs new file mode 100644 index 00000000..be5a409d --- /dev/null +++ b/assets/javascripts/discourse/components/ai-search-discoveries.gjs @@ -0,0 +1,204 @@ +import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; +import { action } from "@ember/object"; +import didInsert from "@ember/render-modifiers/modifiers/did-insert"; +import willDestroy from "@ember/render-modifiers/modifiers/will-destroy"; +import { cancel, later } from "@ember/runloop"; +import { service } from "@ember/service"; +import CookText from "discourse/components/cook-text"; +import DButton from "discourse/components/d-button"; +import { ajax } from "discourse/lib/ajax"; +import { bind } from "discourse/lib/decorators"; +import { i18n } from "discourse-i18n"; +import AiBlinkingAnimation from "./ai-blinking-animation"; + +const DISCOVERY_TIMEOUT_MS = 10000; +const BUFFER_WORDS_COUNT = 50; + +function setUpBuffer(discovery, bufferTarget) { + const paragraphs = discovery.split(/\n+/); + let wordCount = 0; + const paragraphBuffer = []; + + for (const paragraph of paragraphs) { + const wordsInParagraph = paragraph.split(/\s+/); + wordCount += wordsInParagraph.length; + + if (wordCount >= bufferTarget) { + paragraphBuffer.push(paragraph.concat("...")); + return paragraphBuffer.join("\n"); + } else { + paragraphBuffer.push(paragraph); + paragraphBuffer.push("\n"); + } + } + + return null; +} + +export default class AiSearchDiscoveries extends Component { + @service search; + @service messageBus; + @service discobotDiscoveries; + + @tracked loadingDiscoveries = false; + @tracked hideDiscoveries = false; + @tracked fulldiscoveryToggled = false; + + discoveryTimeout = null; + + @bind + async _updateDiscovery(update) { + if (this.query === update.query) { + if (this.discoveryTimeout) { + cancel(this.discoveryTimeout); + } + + if (!this.discobotDiscoveries.discoveryPreview) { + const buffered = setUpBuffer( + update.ai_discover_reply, + BUFFER_WORDS_COUNT + ); + if (buffered) { + this.discobotDiscoveries.discoveryPreview = buffered; + this.loadingDiscoveries = false; + } + } + + this.discobotDiscoveries.modelUsed = update.model_used; + this.discobotDiscoveries.discovery = update.ai_discover_reply; + + // Handling short replies. + if (update.done) { + if (!this.discobotDiscoveries.discoveryPreview) { + this.discobotDiscoveries.discoveryPreview = update.ai_discover_reply; + } + + this.discobotDiscoveries.discovery = update.ai_discover_reply; + this.loadingDiscoveries = false; + } + } + } + + @bind + unsubscribe() { + this.messageBus.unsubscribe( + "/discourse-ai/ai-bot/discover", + this._updateDiscovery + ); + } + + @bind + subscribe() { + this.messageBus.subscribe( + "/discourse-ai/ai-bot/discover", + this._updateDiscovery + ); + } + + get query() { + return this.args?.searchTerm || this.search.activeGlobalSearchTerm; + } + + get toggleLabel() { + if (this.fulldiscoveryToggled) { + return "discourse_ai.discobot_discoveries.collapse"; + } else { + return "discourse_ai.discobot_discoveries.tell_me_more"; + } + } + + get toggleIcon() { + if (this.fulldiscoveryToggled) { + return "chevron-up"; + } else { + return ""; + } + } + + get toggleMakesSense() { + return ( + this.discobotDiscoveries.discoveryPreview && + this.discobotDiscoveries.discoveryPreview !== + this.discobotDiscoveries.discovery + ); + } + + @action + async triggerDiscovery() { + if (this.discobotDiscoveries.lastQuery === this.query) { + this.hideDiscoveries = false; + return; + } else { + this.discobotDiscoveries.resetDiscovery(); + } + + this.hideDiscoveries = false; + this.loadingDiscoveries = true; + this.discoveryTimeout = later( + this, + this.timeoutDiscovery, + DISCOVERY_TIMEOUT_MS + ); + + try { + this.discobotDiscoveries.lastQuery = this.query; + await ajax("/discourse-ai/ai-bot/discover", { + data: { query: this.query }, + }); + } catch { + this.hideDiscoveries = true; + } + } + + @action + toggleDiscovery() { + this.fulldiscoveryToggled = !this.fulldiscoveryToggled; + } + + timeoutDiscovery() { + this.loadingDiscoveries = false; + this.discobotDiscoveries.discoveryPreview = ""; + this.discobotDiscoveries.discovery = ""; + + this.discobotDiscoveries.discoveryTimedOut = true; + } + + +} diff --git a/assets/javascripts/discourse/components/ai-summary-skeleton.gjs b/assets/javascripts/discourse/components/ai-summary-skeleton.gjs index 8d2483ee..dc5f3207 100644 --- a/assets/javascripts/discourse/components/ai-summary-skeleton.gjs +++ b/assets/javascripts/discourse/components/ai-summary-skeleton.gjs @@ -1,126 +1,18 @@ -import Component from "@glimmer/component"; -import { tracked } from "@glimmer/tracking"; -import { fn } from "@ember/helper"; -import { action } from "@ember/object"; -import didInsert from "@ember/render-modifiers/modifiers/did-insert"; -import didUpdate from "@ember/render-modifiers/modifiers/did-update"; -import willDestroy from "@ember/render-modifiers/modifiers/will-destroy"; -import { cancel } from "@ember/runloop"; -import concatClass from "discourse/helpers/concat-class"; -import discourseLater from "discourse/lib/later"; import { i18n } from "discourse-i18n"; +import AiBlinkingAnimation from "./ai-blinking-animation"; import AiIndicatorWave from "./ai-indicator-wave"; -class Block { - @tracked show = false; - @tracked shown = false; - @tracked blinking = false; +const AiSummarySkeleton = ; -const BLOCKS_SIZE = 20; // changing this requires to change css accordingly - -export default class AiSummarySkeleton extends Component { - blocks = [...Array.from({ length: BLOCKS_SIZE }, () => new Block())]; - - #nextBlockBlinkingTimer; - #blockBlinkingTimer; - #blockShownTimer; - - @action - setupAnimation() { - this.blocks.firstObject.show = true; - this.blocks.firstObject.shown = true; - } - - @action - onBlinking(block) { - if (!block.blinking) { - return; - } - - block.show = false; - - this.#nextBlockBlinkingTimer = discourseLater( - this, - () => { - this.#nextBlock(block).blinking = true; - }, - 250 - ); - - this.#blockBlinkingTimer = discourseLater( - this, - () => { - block.blinking = false; - }, - 500 - ); - } - - @action - onShowing(block) { - if (!block.show) { - return; - } - - this.#blockShownTimer = discourseLater( - this, - () => { - this.#nextBlock(block).show = true; - this.#nextBlock(block).shown = true; - - if (this.blocks.lastObject === block) { - this.blocks.firstObject.blinking = true; - } - }, - 250 - ); - } - - @action - teardownAnimation() { - cancel(this.#blockShownTimer); - cancel(this.#nextBlockBlinkingTimer); - cancel(this.#blockBlinkingTimer); - } - - #nextBlock(currentBlock) { - if (currentBlock === this.blocks.lastObject) { - return this.blocks.firstObject; - } else { - return this.blocks.objectAt(this.blocks.indexOf(currentBlock) + 1); - } - } - - -} +export default AiSummarySkeleton; diff --git a/assets/javascripts/discourse/connectors/full-page-search-below-search-header/ai-full-page-discobot-discoveries.gjs b/assets/javascripts/discourse/connectors/full-page-search-below-search-header/ai-full-page-discobot-discoveries.gjs new file mode 100644 index 00000000..3e07efde --- /dev/null +++ b/assets/javascripts/discourse/connectors/full-page-search-below-search-header/ai-full-page-discobot-discoveries.gjs @@ -0,0 +1,34 @@ +import Component from "@glimmer/component"; +import { service } from "@ember/service"; +import icon from "discourse/helpers/d-icon"; +import { i18n } from "discourse-i18n"; +import AiSearchDiscoveries from "../../components/ai-search-discoveries"; + +export default class AiFullPageDiscobotDiscoveries extends Component { + static shouldRender(_args, { siteSettings, currentUser }) { + return ( + siteSettings.ai_bot_discover_persona && + currentUser.can_use_ai_bot_discover_persona + ); + } + + @service discobotDiscoveries; + + get hasDiscoveries() { + return this.args.outletArgs?.model?.topics?.length > 0; + } + + +} diff --git a/assets/javascripts/discourse/connectors/search-menu-results-type-top/ai-discobot-discoveries.gjs b/assets/javascripts/discourse/connectors/search-menu-results-type-top/ai-discobot-discoveries.gjs new file mode 100644 index 00000000..2ad49d34 --- /dev/null +++ b/assets/javascripts/discourse/connectors/search-menu-results-type-top/ai-discobot-discoveries.gjs @@ -0,0 +1,33 @@ +import Component from "@glimmer/component"; +import { service } from "@ember/service"; +import icon from "discourse/helpers/d-icon"; +import { i18n } from "discourse-i18n"; +import AiSearchDiscoveries from "../../components/ai-search-discoveries"; + +export default class AiDiscobotDiscoveries extends Component { + static shouldRender(args, { siteSettings, currentUser }) { + return ( + args.resultType.type === "topic" && + siteSettings.ai_bot_discover_persona && + currentUser.can_use_ai_bot_discover_persona + ); + } + + @service discobotDiscoveries; + + +} diff --git a/assets/javascripts/discourse/services/discobot-discoveries.js b/assets/javascripts/discourse/services/discobot-discoveries.js new file mode 100644 index 00000000..1d682f9d --- /dev/null +++ b/assets/javascripts/discourse/services/discobot-discoveries.js @@ -0,0 +1,19 @@ +import { tracked } from "@glimmer/tracking"; +import Service from "@ember/service"; + +export default class DiscobotDiscoveries extends Service { + // We use this to retain state after search menu gets closed. + // Similar to discourse/discourse#25504 + @tracked discoveryPreview = ""; + @tracked discovery = ""; + @tracked lastQuery = ""; + @tracked discoveryTimedOut = false; + @tracked modelUsed = ""; + + resetDiscovery() { + this.discoveryPreview = ""; + this.discovery = ""; + this.discoveryTimedOut = false; + this.modelUsed = ""; + } +} diff --git a/assets/stylesheets/common/ai-blinking-animation.scss b/assets/stylesheets/common/ai-blinking-animation.scss new file mode 100644 index 00000000..4d33d626 --- /dev/null +++ b/assets/stylesheets/common/ai-blinking-animation.scss @@ -0,0 +1,131 @@ +.ai-blinking-animation { + list-style: none; + display: flex; + flex-wrap: wrap; + padding: 0; + margin: 0; + + &__list-item { + background: var(--primary-300); + border-radius: var(--d-border-radius); + margin-right: 8px; + margin-bottom: 8px; + height: 1em; + opacity: 0; + display: block; + &:nth-child(1) { + width: 10%; + } + + &:nth-child(2) { + width: 12%; + } + + &:nth-child(3) { + width: 18%; + } + + &:nth-child(4) { + width: 14%; + } + + &:nth-child(5) { + width: 18%; + } + + &:nth-child(6) { + width: 14%; + } + + &:nth-child(7) { + width: 22%; + } + + &:nth-child(8) { + width: 5%; + } + + &:nth-child(9) { + width: 25%; + } + + &:nth-child(10) { + width: 14%; + } + + &:nth-child(11) { + width: 18%; + } + + &:nth-child(12) { + width: 12%; + } + + &:nth-child(13) { + width: 22%; + } + + &:nth-child(14) { + width: 18%; + } + + &:nth-child(15) { + width: 13%; + } + + &:nth-child(16) { + width: 22%; + } + + &:nth-child(17) { + width: 19%; + } + + &:nth-child(18) { + width: 13%; + } + + &:nth-child(19) { + width: 22%; + } + + &:nth-child(20) { + width: 25%; + } + &.is-shown { + opacity: 1; + } + &.show { + animation: appear 0.5s cubic-bezier(0.445, 0.05, 0.55, 0.95) 0s forwards; + @media (prefers-reduced-motion) { + animation-duration: 0s; + } + } + @media (prefers-reduced-motion: no-preference) { + &.blink { + animation: blink 0.5s cubic-bezier(0.55, 0.085, 0.68, 0.53) both; + } + } + } + + @keyframes appear { + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } + } + + @keyframes blink { + 0% { + opacity: 1; + } + 50% { + opacity: 0.5; + } + 100% { + opacity: 1; + } + } +} diff --git a/assets/stylesheets/modules/ai-bot/common/ai-discobot-discoveries.scss b/assets/stylesheets/modules/ai-bot/common/ai-discobot-discoveries.scss new file mode 100644 index 00000000..edfcf6b6 --- /dev/null +++ b/assets/stylesheets/modules/ai-bot/common/ai-discobot-discoveries.scss @@ -0,0 +1,33 @@ +.ai-search-discoveries { + &__regular-results-title { + margin-bottom: 0; + } + + &__completion { + margin: 1em 0; + } + + &__discovery-preview { + @include ellipsis; + max-height: 300px; + } + + &__discoveries-title, + &__regular-results-title { + padding-bottom: 0.5em; + border-bottom: 1px solid var(--primary-low); + } + + &__toggle { + padding-left: 0; + margin-bottom: 0.5em; + } +} + +.ai-discobot-discoveries { + padding: 0.5em; +} + +.full-page-discoveries { + padding: 1em 10%; +} diff --git a/assets/stylesheets/modules/summarization/common/ai-summary.scss b/assets/stylesheets/modules/summarization/common/ai-summary.scss index 02221ec3..9afa82d5 100644 --- a/assets/stylesheets/modules/summarization/common/ai-summary.scss +++ b/assets/stylesheets/modules/summarization/common/ai-summary.scss @@ -28,115 +28,6 @@ .ai-summary-modal { .ai-summary { - &__list { - list-style: none; - display: flex; - flex-wrap: wrap; - padding: 0; - margin: 0; - } - &__list-item { - background: var(--primary-300); - border-radius: var(--d-border-radius); - margin-right: 8px; - margin-bottom: 8px; - height: 1em; - opacity: 0; - display: block; - &:nth-child(1) { - width: 10%; - } - - &:nth-child(2) { - width: 12%; - } - - &:nth-child(3) { - width: 18%; - } - - &:nth-child(4) { - width: 14%; - } - - &:nth-child(5) { - width: 18%; - } - - &:nth-child(6) { - width: 14%; - } - - &:nth-child(7) { - width: 22%; - } - - &:nth-child(8) { - width: 05%; - } - - &:nth-child(9) { - width: 25%; - } - - &:nth-child(10) { - width: 14%; - } - - &:nth-child(11) { - width: 18%; - } - - &:nth-child(12) { - width: 12%; - } - - &:nth-child(13) { - width: 22%; - } - - &:nth-child(14) { - width: 18%; - } - - &:nth-child(15) { - width: 13%; - } - - &:nth-child(16) { - width: 22%; - } - - &:nth-child(17) { - width: 19%; - } - - &:nth-child(18) { - width: 13%; - } - - &:nth-child(19) { - width: 22%; - } - - &:nth-child(20) { - width: 25%; - } - &.is-shown { - opacity: 1; - } - &.show { - animation: appear 0.5s cubic-bezier(0.445, 0.05, 0.55, 0.95) 0s forwards; - @media (prefers-reduced-motion) { - animation-duration: 0s; - } - } - @media (prefers-reduced-motion: no-preference) { - &.blink { - animation: blink 0.5s cubic-bezier(0.55, 0.085, 0.68, 0.53) both; - } - } - } &__generating-text { display: inline-block; margin-left: 3px; @@ -204,24 +95,3 @@ } } } - -@keyframes appear { - 0% { - opacity: 0; - } - 100% { - opacity: 1; - } -} - -@keyframes blink { - 0% { - opacity: 1; - } - 50% { - opacity: 0.5; - } - 100% { - opacity: 1; - } -} diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 6a9c91bd..598fd15e 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -672,6 +672,16 @@ en: compact: "Compact" expanded: "Expanded" expanded_description: "with AI summaries" + + discobot_discoveries: + main_title: "Discobot discoveries" + regular_results: "Topics" + tell_me_more: "Tell me more..." + collapse: "Collapse" + timed_out: "Discobot couldn't find any discoveries. Something went wrong." + tooltip: + title: "AI powered search" + body: "Natural language search powered by %{model}" review: types: reviewable_ai_post: diff --git a/config/routes.rb b/config/routes.rb index 6004fe91..7c67e8e2 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -24,6 +24,8 @@ DiscourseAi::Engine.routes.draw do get "post/:post_id/show-debug-info" => "bot#show_debug_info" get "show-debug-info/:id" => "bot#show_debug_info_by_id" post "post/:post_id/stop-streaming" => "bot#stop_streaming_response" + + get "discover" => "bot#discover" end scope module: :ai_bot, path: "/ai-bot/shared-ai-conversations" do diff --git a/config/settings.yml b/config/settings.yml index d2e212fb..665c2c83 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -311,6 +311,12 @@ discourse_ai: hidden: true type: list list_type: compact + ai_bot_discover_persona: + default: "" + type: enum + hidden: true + client: true + enum: "DiscourseAi::Configuration::PersonaEnumerator" ai_automation_max_triage_per_minute: default: 60 hidden: true diff --git a/lib/ai_bot/entry_point.rb b/lib/ai_bot/entry_point.rb index 134422d2..cb815b7b 100644 --- a/lib/ai_bot/entry_point.rb +++ b/lib/ai_bot/entry_point.rb @@ -166,8 +166,19 @@ module DiscourseAi scope.user.in_any_groups?(SiteSetting.ai_bot_public_sharing_allowed_groups_map) end - plugin.register_svg_icon("robot") - plugin.register_svg_icon("info") + plugin.add_to_serializer( + :current_user, + :can_use_ai_bot_discover_persona, + include_condition: -> do + SiteSetting.ai_bot_enabled && scope.authenticated? && + SiteSetting.ai_bot_discover_persona.present? + end, + ) do + persona_allowed_groups = + AiPersona.find_by(id: SiteSetting.ai_bot_discover_persona)&.allowed_group_ids.to_a + + scope.user.in_any_groups?(persona_allowed_groups) + end plugin.add_to_serializer( :topic_view, diff --git a/lib/ai_helper/entry_point.rb b/lib/ai_helper/entry_point.rb index 9b447398..cef7e2a4 100644 --- a/lib/ai_helper/entry_point.rb +++ b/lib/ai_helper/entry_point.rb @@ -7,9 +7,6 @@ module DiscourseAi Rails.root.join("plugins", "discourse-ai", "db", "fixtures", "ai_helper"), ) - additional_icons = %w[spell-check language images far-copy] - additional_icons.each { |icon| plugin.register_svg_icon(icon) } - plugin.add_to_serializer(:current_user, :can_use_assistant) do scope.user.in_any_groups?(SiteSetting.composer_ai_helper_allowed_groups_map) end diff --git a/lib/embeddings/entry_point.rb b/lib/embeddings/entry_point.rb index 632ce81f..67e2f3a3 100644 --- a/lib/embeddings/entry_point.rb +++ b/lib/embeddings/entry_point.rb @@ -4,9 +4,6 @@ module DiscourseAi module Embeddings class EntryPoint def inject_into(plugin) - # far-circle-question used by semantic search unavailable tooltip - plugin.register_svg_icon "far-circle-question" if plugin.respond_to?(:register_svg_icon) - # Include random topics in the suggested list *only* if there are no related topics. plugin.register_modifier( :topic_view_suggested_topics_options, diff --git a/plugin.rb b/plugin.rb index 4b4726eb..7d17fc24 100644 --- a/plugin.rb +++ b/plugin.rb @@ -25,6 +25,7 @@ gem "pdf-reader", "2.14.1", require: false enabled_site_setting :discourse_ai_enabled register_asset "stylesheets/common/streaming.scss" +register_asset "stylesheets/common/ai-blinking-animation.scss" register_asset "stylesheets/modules/ai-helper/common/ai-helper.scss" register_asset "stylesheets/modules/ai-helper/desktop/ai-helper-fk-modals.scss", :desktop @@ -37,6 +38,7 @@ register_asset "stylesheets/modules/summarization/common/ai-gists.scss" register_asset "stylesheets/modules/ai-bot/common/bot-replies.scss" register_asset "stylesheets/modules/ai-bot/common/ai-persona.scss" +register_asset "stylesheets/modules/ai-bot/common/ai-discobot-discoveries.scss" register_asset "stylesheets/modules/ai-bot/mobile/ai-persona.scss", :mobile register_asset "stylesheets/modules/embeddings/common/semantic-related-topics.scss" @@ -119,4 +121,16 @@ after_initialize do nil end end + + plugin_icons = %w[ + spell-check + language + images + far-copy + robot + info + bars-staggered + far-circle-question + ] + plugin_icons.each { |icon| register_svg_icon(icon) } end diff --git a/spec/jobs/regular/stream_discover_reply_spec.rb b/spec/jobs/regular/stream_discover_reply_spec.rb new file mode 100644 index 00000000..4736f03e --- /dev/null +++ b/spec/jobs/regular/stream_discover_reply_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +RSpec.describe Jobs::StreamDiscoverReply do + subject(:job) { described_class.new } + + describe "#execute" do + fab!(:user) + fab!(:llm_model) + fab!(:group) + fab!(:ai_persona) do + Fabricate(:ai_persona, allowed_group_ids: [group.id], default_llm_id: llm_model.id) + end + + before do + SiteSetting.ai_bot_discover_persona = ai_persona.id + group.add(user) + end + + def with_responses(responses) + DiscourseAi::Completions::Llm.with_prepared_responses(responses) { yield } + end + + it "publishes updates with a partial summary" do + with_responses(["dummy"]) do + messages = + MessageBus.track_publish("/discourse-ai/ai-bot/discover") do + job.execute(user_id: user.id, query: "Testing search") + end + + partial_update = messages.first.data + expect(partial_update[:done]).to eq(false) + expect(partial_update[:model_used]).to eq(llm_model.display_name) + expect(partial_update[:ai_discover_reply]).to eq("dummy") + end + end + + it "publishes a final update to signal we're done" do + with_responses(["dummy"]) do + messages = + MessageBus.track_publish("/discourse-ai/ai-bot/discover") do + job.execute(user_id: user.id, query: "Testing search") + end + + final_update = messages.last.data + expect(final_update[:done]).to eq(true) + + expect(final_update[:model_used]).to eq(llm_model.display_name) + expect(final_update[:ai_discover_reply]).to eq("dummy") + end + end + end +end diff --git a/spec/requests/ai_bot/bot_controller_spec.rb b/spec/requests/ai_bot/bot_controller_spec.rb index 007e868b..dbe8fe8d 100644 --- a/spec/requests/ai_bot/bot_controller_spec.rb +++ b/spec/requests/ai_bot/bot_controller_spec.rb @@ -122,4 +122,50 @@ RSpec.describe DiscourseAi::AiBot::BotController do expect(response.parsed_body["bot_username"]).to eq(expected_username) end end + + describe "#discover" do + before { SiteSetting.ai_bot_enabled = true } + + fab!(:group) + fab!(:ai_persona) { Fabricate(:ai_persona, allowed_group_ids: [group.id], default_llm_id: 1) } + + context "when no persona is selected" do + it "returns a 403" do + get "/discourse-ai/ai-bot/discover", params: { query: "What is Discourse?" } + + expect(response.status).to eq(403) + end + end + + context "when the user doesn't have access to the persona" do + before { SiteSetting.ai_bot_discover_persona = ai_persona.id } + + it "returns a 403" do + get "/discourse-ai/ai-bot/discover", params: { query: "What is Discourse?" } + + expect(response.status).to eq(403) + end + end + + context "when the user is allowed to use discover" do + before do + SiteSetting.ai_bot_discover_persona = ai_persona.id + group.add(user) + end + + it "returns a 200 and queues a job to reply" do + expect { + get "/discourse-ai/ai-bot/discover", params: { query: "What is Discourse?" } + }.to change(Jobs::StreamDiscoverReply.jobs, :size).by(1) + + expect(response.status).to eq(200) + end + + it "retues a 400 if the query is missing" do + get "/discourse-ai/ai-bot/discover" + + expect(response.status).to eq(400) + end + end + end end