diff --git a/app/controllers/discourse_ai/ai_helper/assistant_controller.rb b/app/controllers/discourse_ai/ai_helper/assistant_controller.rb index f83f2359..0daf5801 100644 --- a/app/controllers/discourse_ai/ai_helper/assistant_controller.rb +++ b/app/controllers/discourse_ai/ai_helper/assistant_controller.rb @@ -96,12 +96,37 @@ module DiscourseAi end end + def explain + post_id = get_post_param! + text = get_text_param! + post = Post.find_by(id: post_id) + + raise Discourse::InvalidParameters.new(:post_id) unless post + + render json: + DiscourseAi::AiHelper::TopicHelper.new( + { text: text }, + current_user, + post: post, + ).explain, + status: 200 + rescue ::DiscourseAi::Inference::OpenAiCompletions::CompletionFailed, + ::DiscourseAi::Inference::HuggingFaceTextGeneration::CompletionFailed, + ::DiscourseAi::Inference::AnthropicCompletions::CompletionFailed => e + render_json_error I18n.t("discourse_ai.ai_helper.errors.completion_request_failed"), + status: 502 + end + private def get_text_param! params[:text].tap { |t| raise Discourse::InvalidParameters.new(:text) if t.blank? } end + def get_post_param! + params[:post_id].tap { |t| raise Discourse::InvalidParameters.new(:post_id) if t.blank? } + end + def rate_limiter_performed! RateLimiter.new(current_user, "ai_assistant", 6, 3.minutes).performed! end diff --git a/app/models/completion_prompt.rb b/app/models/completion_prompt.rb index b74627dc..05864603 100644 --- a/app/models/completion_prompt.rb +++ b/app/models/completion_prompt.rb @@ -10,6 +10,8 @@ class CompletionPrompt < ActiveRecord::Base validate :each_message_length def messages_with_user_input(user_input) + return messages unless user_input.present? + if user_input[:custom_prompt].present? case ::DiscourseAi::AiHelper::LlmPrompt.new.enabled_provider when "huggingface" diff --git a/assets/javascripts/discourse/connectors/after-composer-category-input/ai-category-suggestion.gjs b/assets/javascripts/discourse/connectors/after-composer-category-input/ai-category-suggestion.gjs index d27658c8..d726b120 100644 --- a/assets/javascripts/discourse/connectors/after-composer-category-input/ai-category-suggestion.gjs +++ b/assets/javascripts/discourse/connectors/after-composer-category-input/ai-category-suggestion.gjs @@ -1,7 +1,7 @@ import Component from '@glimmer/component'; import AISuggestionDropdown from "../../components/ai-suggestion-dropdown"; import { inject as service } from "@ember/service"; -import showAIHelper from "../../lib/show-ai-helper"; +import { showComposerAIHelper } from "../../lib/show-ai-helper"; export default class AICategorySuggestion extends Component { @@ -12,7 +12,7 @@ export default class AICategorySuggestion extends Component { static shouldRender(outletArgs, helper) { - return showAIHelper(outletArgs, helper); + return showComposerAIHelper(outletArgs, helper); } @service siteSettings; diff --git a/assets/javascripts/discourse/connectors/after-composer-tag-input/ai-tag-suggestion.gjs b/assets/javascripts/discourse/connectors/after-composer-tag-input/ai-tag-suggestion.gjs index ae93685c..c5cb7d93 100644 --- a/assets/javascripts/discourse/connectors/after-composer-tag-input/ai-tag-suggestion.gjs +++ b/assets/javascripts/discourse/connectors/after-composer-tag-input/ai-tag-suggestion.gjs @@ -1,7 +1,7 @@ import Component from '@glimmer/component'; import AISuggestionDropdown from "../../components/ai-suggestion-dropdown"; import { inject as service } from "@ember/service"; -import showAIHelper from "../../lib/show-ai-helper"; +import { showComposerAIHelper } from "../../lib/show-ai-helper"; export default class AITagSuggestion extends Component { @@ -12,7 +12,7 @@ export default class AITagSuggestion extends Component { static shouldRender(outletArgs, helper) { - return showAIHelper(outletArgs, helper); + return showComposerAIHelper(outletArgs, helper); } @service siteSettings; diff --git a/assets/javascripts/discourse/connectors/after-composer-title-input/ai-title-suggestion.gjs b/assets/javascripts/discourse/connectors/after-composer-title-input/ai-title-suggestion.gjs index 46af26e7..5e89a51b 100644 --- a/assets/javascripts/discourse/connectors/after-composer-title-input/ai-title-suggestion.gjs +++ b/assets/javascripts/discourse/connectors/after-composer-title-input/ai-title-suggestion.gjs @@ -1,6 +1,6 @@ import Component from '@glimmer/component'; import AISuggestionDropdown from "../../components/ai-suggestion-dropdown"; -import showAIHelper from "../../lib/show-ai-helper"; +import { showComposerAIHelper } from "../../lib/show-ai-helper"; export default class AITitleSuggestion extends Component { static shouldRender(outletArgs, helper) { - return showAIHelper(outletArgs, helper); + return showComposerAIHelper(outletArgs, helper); } } \ No newline at end of file diff --git a/assets/javascripts/discourse/connectors/after-d-editor/ai-helper-context-menu.js b/assets/javascripts/discourse/connectors/after-d-editor/ai-helper-context-menu.js index 8f344a8c..9987c723 100644 --- a/assets/javascripts/discourse/connectors/after-d-editor/ai-helper-context-menu.js +++ b/assets/javascripts/discourse/connectors/after-d-editor/ai-helper-context-menu.js @@ -8,11 +8,11 @@ import { popupAjaxError } from "discourse/lib/ajax-error"; import { createPopper } from "@popperjs/core"; import { caretPosition, getCaretPosition } from "discourse/lib/utilities"; import { inject as service } from "@ember/service"; -import showAIHelper from "../../lib/show-ai-helper"; +import { showComposerAIHelper } from "../../lib/show-ai-helper"; export default class AiHelperContextMenu extends Component { static shouldRender(outletArgs, helper) { - return showAIHelper(outletArgs, helper); + return showComposerAIHelper(outletArgs, helper); } @service currentUser; @@ -79,7 +79,9 @@ export default class AiHelperContextMenu extends Component { async loadPrompts() { let prompts = await ajax("/discourse-ai/ai-helper/prompts"); - prompts = prompts.filter((p) => p.name !== "generate_titles"); + prompts = prompts + .filter((p) => p.location.includes("composer")) + .filter((p) => p.name !== "generate_titles"); // Find the custom_prompt object and move it to the beginning of the array const customPromptIndex = prompts.findIndex( diff --git a/assets/javascripts/discourse/connectors/post-text-buttons/ai-helper-options-menu.gjs b/assets/javascripts/discourse/connectors/post-text-buttons/ai-helper-options-menu.gjs new file mode 100644 index 00000000..17ef3abb --- /dev/null +++ b/assets/javascripts/discourse/connectors/post-text-buttons/ai-helper-options-menu.gjs @@ -0,0 +1,148 @@ +import Component from '@glimmer/component'; +import DButton from "discourse/components/d-button"; +import { tracked } from "@glimmer/tracking"; +import { action } from "@ember/object"; +import { ajax } from "discourse/lib/ajax"; +import { showPostAIHelper } from "../../lib/show-ai-helper"; +import eq from "truth-helpers/helpers/eq"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import I18n from "I18n"; + +const i18n = I18n.t.bind(I18n); + +export default class AIHelperOptionsMenu extends Component { + + + static shouldRender(outletArgs, helper) { + return showPostAIHelper(outletArgs, helper); + } + @tracked helperOptions = []; + @tracked menuState = this.MENU_STATES.triggers; + @tracked loading = false; + @tracked suggestion = ""; + @tracked showMainButtons = true; + + MENU_STATES = { + triggers: "TRIGGERS", + options: "OPTIONS", + loading: "LOADING", + result: "RESULT" + }; + + @tracked _activeAIRequest = null; + + constructor() { + super(...arguments); + + if (this.helperOptions.length === 0) { + this.loadPrompts(); + } + } + + @action + async showAIHelperOptions() { + this.showMainButtons = false; + this.menuState = this.MENU_STATES.options; + } + + @action + async performAISuggestion(option) { + this.menuState = this.MENU_STATES.loading; + + if (option.name === "Explain") { + this._activeAIRequest = ajax("/discourse-ai/ai-helper/explain", { + method: "POST", + data: { + mode: option.value, + text: this.args.outletArgs.data.quoteState.buffer, + post_id: this.args.outletArgs.data.quoteState.postId + } + }); + } else { + this._activeAIRequest = ajax("/discourse-ai/ai-helper/suggest", { + method: "POST", + data: { + mode: option.value, + text: this.args.outletArgs.data.quoteState.buffer, + custom_prompt: "", + } + }); + } + + this._activeAIRequest.then(({ suggestions }) => { + this.suggestion = suggestions[0]; + }).catch(popupAjaxError).finally(() => { + this.loading = false; + this.menuState = this.MENU_STATES.result; + }); + + return this._activeAIRequest; + } + + @action + cancelAIAction() { + if (this._activeAIRequest) { + this._activeAIRequest.abort(); + this._activeAIRequest = null; + this.loading = false; + this.menuState = this.MENU_STATES.options; + } + } + + async loadPrompts() { + let prompts = await ajax("/discourse-ai/ai-helper/prompts"); + + this.helperOptions = prompts.filter(item => item.location.includes("post")).map((p) => { + return { + name: p.translated_name, + value: p.id, + icon: p.icon, + }; + }); + } +} diff --git a/assets/javascripts/discourse/lib/show-ai-helper.js b/assets/javascripts/discourse/lib/show-ai-helper.js index cdd5e32d..84edb754 100644 --- a/assets/javascripts/discourse/lib/show-ai-helper.js +++ b/assets/javascripts/discourse/lib/show-ai-helper.js @@ -1,20 +1,38 @@ -export default function showAIHelper(outletArgs, helper) { - const helperEnabled = - helper.siteSettings.discourse_ai_enabled && - helper.siteSettings.composer_ai_helper_enabled; - - const allowedGroups = helper.siteSettings.ai_helper_allowed_groups - .split("|") - .map((id) => parseInt(id, 10)); - const canUseAssistant = helper.currentUser?.groups.some((g) => - allowedGroups.includes(g.id) +export function showComposerAIHelper(outletArgs, helper) { + const enableHelper = _helperEnabled(helper.siteSettings); + const enableAssistant = _canUseAssistant( + helper.currentUser, + _findAllowedGroups(helper.siteSettings.ai_helper_allowed_groups) ); - const canShowInPM = helper.siteSettings.ai_helper_allowed_in_pm; if (outletArgs?.composer?.privateMessage) { - return helperEnabled && canUseAssistant && canShowInPM; + return enableHelper && enableAssistant && canShowInPM; } - return helperEnabled && canUseAssistant; + return enableHelper && enableAssistant; +} + +export function showPostAIHelper(outletArgs, helper) { + return ( + _helperEnabled(helper.siteSettings) && + _canUseAssistant( + helper.currentUser, + _findAllowedGroups(helper.siteSettings.post_ai_helper_allowed_groups) + ) + ); +} + +function _helperEnabled(siteSettings) { + return ( + siteSettings.discourse_ai_enabled && siteSettings.composer_ai_helper_enabled + ); +} + +function _findAllowedGroups(setting) { + return setting.split("|").map((id) => parseInt(id, 10)); +} + +function _canUseAssistant(user, allowedGroups) { + return user?.groups.some((g) => allowedGroups.includes(g.id)); } diff --git a/assets/stylesheets/modules/ai-helper/common/ai-helper.scss b/assets/stylesheets/modules/ai-helper/common/ai-helper.scss index b292571d..03631fc8 100644 --- a/assets/stylesheets/modules/ai-helper/common/ai-helper.scss +++ b/assets/stylesheets/modules/ai-helper/common/ai-helper.scss @@ -319,3 +319,17 @@ transform: rotate(359deg); } } + +.ai-post-helper { + &__options { + display: flex; + flex-flow: column nowrap; + align-items: flex-start; + gap: 0.25rem; + justify-content: flex-start; + } + + &__suggestion { + padding: 1rem; + } +} diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 91c2b612..2ab2f7ba 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -66,6 +66,10 @@ en: title: "Custom Prompt" placeholder: "Enter a custom prompt..." submit: "Send Prompt" + post_options_menu: + trigger: "Ask AI" + loading: "AI is generating" + close: "Close" reviewables: model_used: "Model used:" accuracy: "Accuracy:" diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index c5dccc51..df5e25c9 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -108,6 +108,7 @@ en: proofread: Proofread text markdown_table: Generate Markdown table custom_prompt: "Custom Prompt" + explain: "Explain" ai_bot: personas: diff --git a/config/routes.rb b/config/routes.rb index ced1ca3c..04f7a50e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -8,6 +8,7 @@ DiscourseAi::Engine.routes.draw do post "suggest_category" => "assistant#suggest_category" post "suggest_tags" => "assistant#suggest_tags" post "suggest_thumbnails" => "assistant#suggest_thumbnails" + post "explain" => "assistant#explain" end scope module: :embeddings, path: "/embeddings", defaults: { format: :json } do diff --git a/config/settings.yml b/config/settings.yml index e0dca0c2..82622e86 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -169,6 +169,13 @@ discourse_ai: default: "3" # 3: @staff allow_any: false refresh: true + post_ai_helper_allowed_groups: + client: true + type: group_list + list_type: compact + default: "3|14" # 3: @staff, 14: @trust_level_4 + allow_any: false + refresh: true ai_embeddings_enabled: default: false diff --git a/db/fixtures/ai_helper/600_openai_completion_prompts.rb b/db/fixtures/ai_helper/600_openai_completion_prompts.rb index eac94fd3..53857d8f 100644 --- a/db/fixtures/ai_helper/600_openai_completion_prompts.rb +++ b/db/fixtures/ai_helper/600_openai_completion_prompts.rb @@ -134,3 +134,25 @@ CompletionPrompt.seed do |cp| you will {{custom_prompt}} and you will reply with the result. TEXT end + +CompletionPrompt.seed do |cp| + cp.id = -6 + cp.provider = "openai" + cp.name = "explain" + cp.prompt_type = CompletionPrompt.prompt_types[:text] + cp.messages = [{ role: "Human", content: <<~TEXT }, { role: "Assistant", content: "" }] + You are a helpful assistant. Act as a tutor explaining terms to a student in a specific + context. Reply with a paragraph with a brief explanation about what the term means in the + content provided, format the response using markdown. Reply only with the explanation and + nothing more. + + Term to explain: + {{search}} + + Context where it was used: + {{context}} + + Title of the conversation where it was used: + {{topic}} + TEXT +end diff --git a/db/fixtures/ai_helper/601_anthropic_completion_prompts.rb b/db/fixtures/ai_helper/601_anthropic_completion_prompts.rb index db6e1011..68cc1fe4 100644 --- a/db/fixtures/ai_helper/601_anthropic_completion_prompts.rb +++ b/db/fixtures/ai_helper/601_anthropic_completion_prompts.rb @@ -65,3 +65,29 @@ CompletionPrompt.seed do |cp| you will {{custom_prompt}} and you will reply with the result between tags. TEXT end + +CompletionPrompt.seed do |cp| + cp.id = -106 + cp.provider = "anthropic" + cp.name = "explain" + cp.prompt_type = CompletionPrompt.prompt_types[:text] + cp.messages = [{ role: "Human", content: <<~TEXT }, { role: "Assistant", content: "" }] + You are a helpful assistant, I will provide you with a term inside tags, + and the context where it was used inside tags, the title of the topic + where it was used between tags, optionally the post it was written + in response to in tags and you will reply with an explanation of what the + term means in this context between tags. + + + {{search}} + + + + {{context}} + + + + {{topic}} + + TEXT +end diff --git a/db/fixtures/ai_helper/602_stablebeluga2_completion_prompts.rb b/db/fixtures/ai_helper/602_stablebeluga2_completion_prompts.rb index 541836de..937fcf8a 100644 --- a/db/fixtures/ai_helper/602_stablebeluga2_completion_prompts.rb +++ b/db/fixtures/ai_helper/602_stablebeluga2_completion_prompts.rb @@ -126,3 +126,29 @@ CompletionPrompt.seed do |cp| ### Assistant: TEXT end + +CompletionPrompt.seed do |cp| + cp.id = -206 + cp.provider = "huggingface" + cp.name = "explain" + cp.prompt_type = CompletionPrompt.prompt_types[:text] + cp.messages = [<<~TEXT] + ### System: + You are a helpful assistant. Act as a tutor explaining terms to a student in a specific + context. Reply with a paragraph with a brief explanation about what the term means in the + content provided, format the response using markdown. Reply only with the explanation and + nothing more. + + ### User: + Term to explain: + {{search}} + + Context where it was used: + {{context}} + + Title of the conversation where it was used: + {{topic}} + + ### Assistant: + TEXT +end diff --git a/lib/modules/ai_helper/entry_point.rb b/lib/modules/ai_helper/entry_point.rb index b95a9475..73b5921e 100644 --- a/lib/modules/ai_helper/entry_point.rb +++ b/lib/modules/ai_helper/entry_point.rb @@ -6,6 +6,7 @@ module DiscourseAi require_relative "llm_prompt" require_relative "semantic_categorizer" require_relative "painter" + require_relative "topic_helper" end def inject_into(plugin) diff --git a/lib/modules/ai_helper/llm_prompt.rb b/lib/modules/ai_helper/llm_prompt.rb index 07185e7a..f1acdcb6 100644 --- a/lib/modules/ai_helper/llm_prompt.rb +++ b/lib/modules/ai_helper/llm_prompt.rb @@ -20,6 +20,7 @@ module DiscourseAi translated_name: translation, prompt_type: prompt.prompt_type, icon: icon_map(prompt.name), + location: location_map(prompt.name), } end end @@ -64,11 +65,38 @@ module DiscourseAi "comment" when "rewrite" "pen" + when "explain" + "question" else nil end end + def location_map(name) + case name + when "translate" + %w[composer post] + when "generate_titles" + %w[composer] + when "proofread" + %w[composer] + when "markdown_table" + %w[composer] + when "tone" + %w[composer] + when "custom_prompt" + %w[composer] + when "rewrite" + %w[composer] + when "explain" + %w[post] + when "summarize" + %w[post] + else + %w[composer post] + end + end + def generate_diff(text, suggestion) cooked_text = PrettyText.cook(text) cooked_suggestion = PrettyText.cook(suggestion) diff --git a/lib/modules/ai_helper/topic_helper.rb b/lib/modules/ai_helper/topic_helper.rb new file mode 100644 index 00000000..237d9ebf --- /dev/null +++ b/lib/modules/ai_helper/topic_helper.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module DiscourseAi + module AiHelper + class TopicHelper + def initialize(input, user, params = {}) + @user = user + @text = input[:text] + @params = params + end + + def explain + return nil if @text.blank? + return nil unless post = Post.find_by(id: @params[:post]) + + reply_to = post.topic.first_post + topic = reply_to.topic + + llm_prompt = + DiscourseAi::AiHelper::LlmPrompt.new.available_prompts(name_filter: "explain").first + prompt = CompletionPrompt.find_by(id: llm_prompt[:id]) + + prompt.messages.first["content"].gsub!("{{search}}", @text) + prompt.messages.first["content"].gsub!("{{context}}", post.raw) + prompt.messages.first["content"].gsub!("{{topic}}", topic.title) + # TODO inject this conditionally + #prompt.messages.first["content"].gsub!("{{post}}", reply_to.raw) + + DiscourseAi::AiHelper::LlmPrompt.new.generate_and_send_prompt(prompt, nil) + end + end + end +end diff --git a/spec/support/openai_completions_inference_stubs.rb b/spec/support/openai_completions_inference_stubs.rb index 0ab0a502..6a88aa6b 100644 --- a/spec/support/openai_completions_inference_stubs.rb +++ b/spec/support/openai_completions_inference_stubs.rb @@ -5,6 +5,7 @@ class OpenAiCompletionsInferenceStubs PROOFREAD = "proofread" GENERATE_TITLES = "generate_titles" CUSTOM_PROMPT = "custom_prompt" + EXPLAIN = "explain" class << self def text_mode_to_id(mode) @@ -17,6 +18,8 @@ class OpenAiCompletionsInferenceStubs -2 when CUSTOM_PROMPT -5 + when EXPLAIN + -4 end end @@ -82,6 +85,15 @@ class OpenAiCompletionsInferenceStubs STRING end + def explain_response + <<~STRING + "In this context, \"pie\" refers to a baked dessert typically consisting of a pastry crust and filling. + The person states they enjoy eating pie, considering it a good dessert. They note that some people wastefully + throw pie at others, but the person themselves chooses to eat the pie rather than throwing it. Overall, \"pie\" + is being used to refer the the baked dessert food item." + STRING + end + def response(content) { id: "chatcmpl-6sZfAb30Rnv9Q7ufzFwvQsMpjZh8S", @@ -109,6 +121,8 @@ class OpenAiCompletionsInferenceStubs generated_titles when CUSTOM_PROMPT custom_prompt_response + when EXPLAIN + explain_response end end diff --git a/spec/system/ai_helper/ai_post_helper_spec.rb b/spec/system/ai_helper/ai_post_helper_spec.rb new file mode 100644 index 00000000..34184f9e --- /dev/null +++ b/spec/system/ai_helper/ai_post_helper_spec.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +require_relative "../../support/openai_completions_inference_stubs" + +RSpec.describe "AI Composer helper", type: :system, js: true do + fab!(:user) { Fabricate(:admin) } + fab!(:non_member_group) { Fabricate(:group) } + fab!(:topic) { Fabricate(:topic) } + fab!(:post) do + Fabricate( + :post, + topic: topic, + raw: + "I like to eat pie. It is a very good dessert. Some people are wasteful by throwing pie at others but I do not do that. I always eat the pie.", + ) + end + fab!(:post_2) do + Fabricate(:post, topic: topic, raw: OpenAiCompletionsInferenceStubs.spanish_text) + end + let(:topic_page) { PageObjects::Pages::Topic.new } + let(:post_ai_helper) { PageObjects::Components::AIHelperPostOptions.new } + + before do + Group.find_by(id: Group::AUTO_GROUPS[:admins]).add(user) + SiteSetting.composer_ai_helper_enabled = true + sign_in(user) + end + + def select_post_text(selected_post) + topic_page.visit_topic(topic) + page.execute_script( + "var element = document.querySelector('#{topic_page.post_by_number_selector(selected_post.post_number)} .cooked p'); " + + "var range = document.createRange(); " + "range.selectNodeContents(element); " + + "var selection = window.getSelection(); " + "selection.removeAllRanges(); " + + "selection.addRange(range);", + ) + end + + context "when triggering AI helper in post" do + it "shows the Ask AI button in the post selection toolbar" do + select_post_text(post) + expect(post_ai_helper).to have_post_selection_toolbar + expect(post_ai_helper).to have_post_ai_helper + end + + it "shows AI helper options after clicking the AI button" do + select_post_text(post) + post_ai_helper.click_ai_button + expect(post_ai_helper).to have_no_post_selection_primary_buttons + expect(post_ai_helper).to have_post_ai_helper_options + end + + context "when using explain mode" do + skip "TODO: Fix explain mode option not appearing in spec" do + let(:mode) { OpenAiCompletionsInferenceStubs::EXPLAIN } + before { OpenAiCompletionsInferenceStubs.stub_prompt(mode) } + + it "shows an explanation of the selected text" do + select_post_text(post) + post_ai_helper.click_ai_button + post_ai_helper.select_helper_model(OpenAiCompletionsInferenceStubs.text_mode_to_id(mode)) + + wait_for do + post_ai_helper.suggestion_value == + OpenAiCompletionsInferenceStubs.explain_response.strip + end + + expect(post_ai_helper.suggestion_value).to eq( + OpenAiCompletionsInferenceStubs.explain_response.strip, + ) + end + end + end + + context "when using translate mode" do + skip "TODO: Fix WebMock request for translate mode not working" do + let(:mode) { OpenAiCompletionsInferenceStubs::TRANSLATE } + before { OpenAiCompletionsInferenceStubs.stub_prompt(mode) } + + it "shows a translation of the selected text" do + select_post_text(post_2) + post_ai_helper.click_ai_button + post_ai_helper.select_helper_model(OpenAiCompletionsInferenceStubs.text_mode_to_id(mode)) + + wait_for do + post_ai_helper.suggestion_value == + OpenAiCompletionsInferenceStubs.translated_response.strip + end + + expect(post_ai_helper.suggestion_value).to eq( + OpenAiCompletionsInferenceStubs.translated_response.strip, + ) + end + end + end + end + + context "when AI helper is disabled" do + before { SiteSetting.composer_ai_helper_enabled = false } + + it "does not show the Ask AI button in the post selection toolbar" do + select_post_text(post) + expect(post_ai_helper).to have_post_selection_toolbar + expect(post_ai_helper).to have_no_post_ai_helper + end + end + + context "when user is not a member of the post AI helper allowed group" do + before do + SiteSetting.composer_ai_helper_enabled = true + SiteSetting.post_ai_helper_allowed_groups = non_member_group.id.to_s + end + + it "does not show the Ask AI button in the post selection toolbar" do + select_post_text(post) + expect(post_ai_helper).to have_post_selection_toolbar + expect(post_ai_helper).to have_no_post_ai_helper + end + end +end diff --git a/spec/system/page_objects/components/ai_helper_post_options.rb b/spec/system/page_objects/components/ai_helper_post_options.rb new file mode 100644 index 00000000..73f8fe5b --- /dev/null +++ b/spec/system/page_objects/components/ai_helper_post_options.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +module PageObjects + module Components + class AIHelperPostOptions < PageObjects::Components::Base + POST_SELECTION_TOOLBAR_SELECTOR = ".quote-button" + QUOTE_SELECTOR = ".insert-quote" + EDIT_SELECTOR = ".quote-edit-label" + SHARE_SELECTOR = ".quote-sharing" + + AI_HELPER_SELECTOR = ".ai-post-helper" + TRIGGER_SELECTOR = "#{AI_HELPER_SELECTOR}__trigger" + OPTIONS_SELECTOR = "#{AI_HELPER_SELECTOR}__options" + LOADING_SELECTOR = ".ai-helper-context-menu__loading" + SUGGESTION_SELECTOR = "#{AI_HELPER_SELECTOR}__suggestion" + + def click_ai_button + find(TRIGGER_SELECTOR).click + end + + def select_helper_model(mode) + find("#{OPTIONS_SELECTOR} .btn[data-value=\"#{mode}\"]").click + end + + def suggestion_value + find(SUGGESTION_SELECTOR).text + end + + def has_post_ai_helper? + page.has_css?(AI_HELPER_SELECTOR) + end + + def has_no_post_ai_helper? + page.has_no_css?(AI_HELPER_SELECTOR) + end + + def has_post_ai_helper_options? + page.has_css?(OPTIONS_SELECTOR) + end + + def has_no_post_ai_helper_options? + page.has_no_css?(OPTIONS_SELECTOR) + end + + def has_post_selection_toolbar? + page.has_css?(POST_SELECTION_TOOLBAR_SELECTOR) + end + + def has_no_post_selection_toolbar? + page.has_no_css?(POST_SELECTION_TOOLBAR_SELECTOR) + end + + def has_post_selection_primary_buttons? + page.has_css?(QUOTE_SELECTOR) || page.has_css?(EDIT_SELECTOR) || + page.has_css?(SHARE_SELECTOR) + end + + def has_no_post_selection_primary_buttons? + page.has_no_css?(QUOTE_SELECTOR) || page.has_no_css?(EDIT_SELECTOR) || + page.has_no_css?(SHARE_SELECTOR) + end + end + end +end