diff --git a/assets/javascripts/discourse/components/post-menu/ai-cancel-streaming-button.gjs b/assets/javascripts/discourse/components/post-menu/ai-cancel-streaming-button.gjs new file mode 100644 index 00000000..aa3cef2c --- /dev/null +++ b/assets/javascripts/discourse/components/post-menu/ai-cancel-streaming-button.gjs @@ -0,0 +1,37 @@ +import Component from "@glimmer/component"; +import { action } from "@ember/object"; +import DButton from "discourse/components/d-button"; +import { ajax } from "discourse/lib/ajax"; +import { popupAjaxError } from "discourse/lib/ajax-error"; + +export default class AiCancelStreamingButton extends Component { + // TODO (glimmer-post-menu): Remove this static function and move the code into the button action after the widget code is removed + static async cancelStreaming(post) { + try { + await ajax(`/discourse-ai/ai-bot/post/${post.id}/stop-streaming`, { + type: "POST", + }); + + document + .querySelector(`#post_${post.post_number}`) + .classList.remove("streaming"); + } catch (e) { + popupAjaxError(e); + } + } + + @action + cancelStreaming() { + this.constructor.cancelStreaming(this.args.post); + } + + +} diff --git a/assets/javascripts/discourse/components/post-menu/ai-debug-button.gjs b/assets/javascripts/discourse/components/post-menu/ai-debug-button.gjs new file mode 100644 index 00000000..f4144d33 --- /dev/null +++ b/assets/javascripts/discourse/components/post-menu/ai-debug-button.gjs @@ -0,0 +1,34 @@ +import Component from "@glimmer/component"; +import { action } from "@ember/object"; +import { inject as service } from "@ember/service"; +import DButton from "discourse/components/d-button"; +import { isPostFromAiBot } from "../../lib/ai-bot-helper"; +import DebugAiModal from "../modal/debug-ai-modal"; + +export default class AiDebugButton extends Component { + static shouldRender(args) { + return isPostFromAiBot(args.post, args.state.currentUser); + } + + // TODO (glimmer-post-menu): Remove this static function and move the code into the button action after the widget code is removed + static debugAiResponse(post, modal) { + modal.show(DebugAiModal, { model: post }); + } + + @service modal; + + @action + debugAiResponse() { + this.constructor.debugAiResponse(this.args.post, this.modal); + } + + +} diff --git a/assets/javascripts/discourse/components/post-menu/ai-share-button.gjs b/assets/javascripts/discourse/components/post-menu/ai-share-button.gjs new file mode 100644 index 00000000..04c37833 --- /dev/null +++ b/assets/javascripts/discourse/components/post-menu/ai-share-button.gjs @@ -0,0 +1,46 @@ +import Component from "@glimmer/component"; +import { action } from "@ember/object"; +import { inject as service } from "@ember/service"; +import DButton from "discourse/components/d-button"; +import { isPostFromAiBot } from "../../lib/ai-bot-helper"; +import copyConversation from "../../lib/copy-conversation"; +import ShareModal from "../modal/share-modal"; + +const AUTO_COPY_THRESHOLD = 4; + +export default class AiDebugButton extends Component { + static shouldRender(args) { + return isPostFromAiBot(args.post, args.state.currentUser); + } + + // TODO (glimmer-post-menu): Remove this static function and move the code into the button action after the widget code is removed + static async shareAiResponse(post, modal, showFeedback) { + if (post.post_number <= AUTO_COPY_THRESHOLD) { + await copyConversation(post.topic, 1, post.post_number); + showFeedback("discourse_ai.ai_bot.conversation_shared"); + } else { + modal.show(ShareModal, { model: post }); + } + } + + @service modal; + + @action + shareAiResponse() { + this.constructor.shareAiResponse( + this.args.post, + this.modal, + this.args.showFeedback + ); + } + + +} diff --git a/assets/javascripts/discourse/lib/ai-bot-helper.js b/assets/javascripts/discourse/lib/ai-bot-helper.js index 73b312a2..071b141e 100644 --- a/assets/javascripts/discourse/lib/ai-bot-helper.js +++ b/assets/javascripts/discourse/lib/ai-bot-helper.js @@ -4,6 +4,17 @@ import Composer from "discourse/models/composer"; import I18n from "I18n"; import ShareFullTopicModal from "../components/modal/share-full-topic-modal"; +const MAX_PERSONA_USER_ID = -1200; + +export function isPostFromAiBot(post, currentUser) { + return ( + post.user_id <= MAX_PERSONA_USER_ID || + !!currentUser?.ai_enabled_chat_bots?.any( + (bot) => post.username === bot.username + ) + ); +} + export function showShareConversationModal(modal, topicId) { ajax(`/discourse-ai/ai-bot/shared-ai-conversations/preview/${topicId}.json`) .then((payload) => { diff --git a/assets/javascripts/initializers/ai-bot-replies.js b/assets/javascripts/initializers/ai-bot-replies.js index 733c595e..fa65c256 100644 --- a/assets/javascripts/initializers/ai-bot-replies.js +++ b/assets/javascripts/initializers/ai-bot-replies.js @@ -1,18 +1,20 @@ import { hbs } from "ember-cli-htmlbars"; -import { ajax } from "discourse/lib/ajax"; -import { popupAjaxError } from "discourse/lib/ajax-error"; import { withPluginApi } from "discourse/lib/plugin-api"; import { registerWidgetShim } from "discourse/widgets/render-glimmer"; -import DebugAiModal from "../discourse/components/modal/debug-ai-modal"; -import ShareModal from "../discourse/components/modal/share-modal"; -import { streamPostText } from "../discourse/lib/ai-streamer/progress-handlers"; -import copyConversation from "../discourse/lib/copy-conversation"; -const AUTO_COPY_THRESHOLD = 4; +import { withSilencedDeprecations } from "discourse-common/lib/deprecated"; import AiBotHeaderIcon from "../discourse/components/ai-bot-header-icon"; -import { showShareConversationModal } from "../discourse/lib/ai-bot-helper"; +import AiCancelStreamingButton from "../discourse/components/post-menu/ai-cancel-streaming-button"; +import AiDebugButton from "../discourse/components/post-menu/ai-debug-button"; +import AiShareButton from "../discourse/components/post-menu/ai-share-button"; +import { + isPostFromAiBot, + showShareConversationModal, +} from "../discourse/lib/ai-bot-helper"; +import { streamPostText } from "../discourse/lib/ai-streamer/progress-handlers"; let enabledChatBotIds = []; let allowDebug = false; + function isGPTBot(user) { return user && enabledChatBotIds.includes(user.id); } @@ -22,29 +24,7 @@ function attachHeaderIcon(api) { } function initializeAIBotReplies(api) { - api.addPostMenuButton("cancel-gpt", (post) => { - if (isGPTBot(post.user)) { - return { - icon: "pause", - action: "cancelStreaming", - title: "discourse_ai.ai_bot.cancel_streaming", - className: "btn btn-default cancel-streaming", - position: "first", - }; - } - }); - - api.attachWidgetAction("post", "cancelStreaming", function () { - ajax(`/discourse-ai/ai-bot/post/${this.model.id}/stop-streaming`, { - type: "POST", - }) - .then(() => { - document - .querySelector(`#post_${this.model.post_number}`) - .classList.remove("streaming"); - }) - .catch(popupAjaxError); - }); + initializePauseButton(api); api.modifyClass("controller:topic", { pluginId: "discourse-ai", @@ -102,7 +82,42 @@ function initializePersonaDecorator(api) { ); } -const MAX_PERSONA_USER_ID = -1200; +function initializePauseButton(api) { + const transformerRegistered = api.registerValueTransformer( + "post-menu-buttons", + ({ value: dag, context: { post, firstButtonKey } }) => { + if (isGPTBot(post.user)) { + dag.add("ai-cancel-gpt", AiCancelStreamingButton, { + before: firstButtonKey, + after: ["ai-share", "ai-debug"], + }); + } + } + ); + + const silencedKey = + transformerRegistered && "discourse.post-menu-widget-overrides"; + + withSilencedDeprecations(silencedKey, () => initializePauseWidgetButton(api)); +} + +function initializePauseWidgetButton(api) { + api.addPostMenuButton("cancel-gpt", (post) => { + if (isGPTBot(post.user)) { + return { + icon: "pause", + action: "cancelStreaming", + title: "discourse_ai.ai_bot.cancel_streaming", + className: "btn btn-default cancel-streaming", + position: "first", + }; + } + }); + + api.attachWidgetAction("post", "cancelStreaming", function () { + AiCancelStreamingButton.cancelStreaming(this.model); + }); +} function initializeDebugButton(api) { const currentUser = api.getCurrentUser(); @@ -110,10 +125,30 @@ function initializeDebugButton(api) { return; } + const transformerRegistered = api.registerValueTransformer( + "post-menu-buttons", + ({ value: dag, context: { post, firstButtonKey } }) => { + if (post.topic?.archetype === "private_message") { + dag.add("ai-debug", AiDebugButton, { + before: firstButtonKey, + after: "ai-share", + }); + } + } + ); + + const silencedKey = + transformerRegistered && "discourse.post-menu-widget-overrides"; + + withSilencedDeprecations(silencedKey, () => initializeDebugWidgetButton(api)); +} + +function initializeDebugWidgetButton(api) { + const currentUser = api.getCurrentUser(); + let debugAiResponse = async function ({ post }) { const modal = api.container.lookup("service:modal"); - - modal.show(DebugAiModal, { model: post }); + AiDebugButton.debugAiResponse(post, modal); }; api.addPostMenuButton("debugAi", (post) => { @@ -121,15 +156,8 @@ function initializeDebugButton(api) { return; } - if ( - !currentUser.ai_enabled_chat_bots.any( - (bot) => post.username === bot.username - ) - ) { - // special handling for personas (persona bot users start at ID -1200 and go down) - if (post.user_id > MAX_PERSONA_USER_ID) { - return; - } + if (!isPostFromAiBot(post, currentUser)) { + return; } return { @@ -148,14 +176,29 @@ function initializeShareButton(api) { return; } - let shareAiResponse = async function ({ post, showFeedback }) { - if (post.post_number <= AUTO_COPY_THRESHOLD) { - await copyConversation(post.topic, 1, post.post_number); - showFeedback("discourse_ai.ai_bot.conversation_shared"); - } else { - const modal = api.container.lookup("service:modal"); - modal.show(ShareModal, { model: post }); + const transformerRegistered = api.registerValueTransformer( + "post-menu-buttons", + ({ value: dag, context: { post, firstButtonKey } }) => { + if (post.topic?.archetype === "private_message") { + dag.add("ai-share", AiShareButton, { + before: firstButtonKey, + }); + } } + ); + + const silencedKey = + transformerRegistered && "discourse.post-menu-widget-overrides"; + + withSilencedDeprecations(silencedKey, () => initializeShareWidgetButton(api)); +} + +function initializeShareWidgetButton(api) { + const currentUser = api.getCurrentUser(); + + let shareAiResponse = async function ({ post, showFeedback }) { + const modal = api.container.lookup("service:modal"); + AiShareButton.shareAiResponse(post, modal, showFeedback); }; api.addPostMenuButton("share", (post) => { @@ -164,21 +207,14 @@ function initializeShareButton(api) { return; } - if ( - !currentUser.ai_enabled_chat_bots.any( - (bot) => post.username === bot.username - ) - ) { - // special handling for personas (persona bot users start at ID -1200 and go down) - if (post.user_id > MAX_PERSONA_USER_ID) { - return; - } + if (!isPostFromAiBot(post, currentUser)) { + return; } return { action: shareAiResponse, icon: "far-copy", - className: "post-action-menu__share", + className: "post-action-menu__share-ai", title: "discourse_ai.ai_bot.share", position: "first", }; @@ -218,10 +254,10 @@ export default { enabledChatBotIds = user.ai_enabled_chat_bots.map((bot) => bot.id); allowDebug = user.can_debug_ai_bot_conversations; withPluginApi("1.6.0", attachHeaderIcon); - withPluginApi("1.6.0", initializeAIBotReplies); + withPluginApi("1.34.0", initializeAIBotReplies); withPluginApi("1.6.0", initializePersonaDecorator); - withPluginApi("1.22.0", (api) => initializeDebugButton(api, container)); - withPluginApi("1.22.0", (api) => initializeShareButton(api, container)); + withPluginApi("1.34.0", (api) => initializeDebugButton(api, container)); + withPluginApi("1.34.0", (api) => initializeShareButton(api, container)); withPluginApi("1.22.0", (api) => initializeShareTopicButton(api, container) ); diff --git a/spec/system/ai_bot/share_spec.rb b/spec/system/ai_bot/share_spec.rb index f963fb32..06a5c4d1 100644 --- a/spec/system/ai_bot/share_spec.rb +++ b/spec/system/ai_bot/share_spec.rb @@ -43,114 +43,122 @@ RSpec.describe "Share conversation", type: :system do page.execute_script("window.navigator.clipboard.writeText('')") end - it "can share a conversation with a persona user" do - clip_text = nil + glimmer_post_menu_states = %w[enabled disabled] - persona = Fabricate(:ai_persona, name: "Tester") - persona.create_user! + glimmer_post_menu_states.each do |state| + context "with the glimmer post menu #{state}" do + before { SiteSetting.glimmer_post_menu_mode = state } - Fabricate(:post, topic: pm, user: admin, raw: "How do I do stuff?") - Fabricate(:post, topic: pm, user: persona.user, raw: "No idea") + it "can share a conversation with a persona user" do + clip_text = nil - visit(pm.url) + persona = Fabricate(:ai_persona, name: "Tester") + persona.create_user! - find("#post_2 .post-action-menu__share").click + Fabricate(:post, topic: pm, user: admin, raw: "How do I do stuff?") + Fabricate(:post, topic: pm, user: persona.user, raw: "No idea") - try_until_success do - clip_text = cdp.read_clipboard - expect(clip_text).not_to eq("") + visit(pm.url) + + find("#post_2 .post-action-menu__share-ai").click + + try_until_success do + clip_text = cdp.read_clipboard + expect(clip_text).not_to eq("") + end + + conversation = (<<~TEXT).strip +
+ + This is my special PM + AI + + + **ai_sharer:** + + How do I do stuff? + + **Tester_bot:** + + No idea +
+ TEXT + + expect(conversation).to eq(clip_text) + end + + it "can share a conversation" do + clip_text = nil + + pm + pm_posts + + visit(pm.url) + + find("#post_2 .post-action-menu__share-ai").click + + try_until_success do + clip_text = cdp.read_clipboard + expect(clip_text).not_to eq("") + end + + conversation = (<<~TEXT).strip +
+ + This is my special PM + AI + + + **ai_sharer:** + + test test test user reply 1 + + **gpt-4:** + + test test test bot reply 1 +
+ TEXT + + expect(conversation).to eq(clip_text) + + page.execute_script("window.navigator.clipboard.writeText('')") + + find("#post_6 .post-action-menu__share-ai").click + find(".ai-share-modal__slider input").set("2") + find(".ai-share-modal button.btn-primary").click + + try_until_success do + clip_text = cdp.read_clipboard + expect(clip_text).not_to eq("") + end + + conversation = (<<~TEXT).strip +
+ + This is my special PM + AI + + + **ai_sharer:** + + test test test user reply 2 + + **gpt-4:** + + test test test bot reply 2 + + **ai_sharer:** + + test test test user reply 3 + + **gpt-4:** + + test test test bot reply 3 +
+ TEXT + + expect(conversation).to eq(clip_text) + end end - - conversation = (<<~TEXT).strip -
- - This is my special PM - AI - - - **ai_sharer:** - - How do I do stuff? - - **Tester_bot:** - - No idea -
- TEXT - - expect(conversation).to eq(clip_text) - end - - it "can share a conversation" do - clip_text = nil - - pm - pm_posts - - visit(pm.url) - - find("#post_2 .post-action-menu__share").click - - try_until_success do - clip_text = cdp.read_clipboard - expect(clip_text).not_to eq("") - end - - conversation = (<<~TEXT).strip -
- - This is my special PM - AI - - - **ai_sharer:** - - test test test user reply 1 - - **gpt-4:** - - test test test bot reply 1 -
- TEXT - - expect(conversation).to eq(clip_text) - - page.execute_script("window.navigator.clipboard.writeText('')") - - find("#post_6 .post-action-menu__share").click - find(".ai-share-modal__slider input").set("2") - find(".ai-share-modal button.btn-primary").click - - try_until_success do - clip_text = cdp.read_clipboard - expect(clip_text).not_to eq("") - end - - conversation = (<<~TEXT).strip -
- - This is my special PM - AI - - - **ai_sharer:** - - test test test user reply 2 - - **gpt-4:** - - test test test bot reply 2 - - **ai_sharer:** - - test test test user reply 3 - - **gpt-4:** - - test test test bot reply 3 -
- TEXT - - expect(conversation).to eq(clip_text) end end