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