DEV: Added compatibility with the Glimmer Post Menu (#887)

This commit is contained in:
Sérgio Saquetim 2024-11-12 15:46:17 -03:00 committed by GitHub
parent 2fc05685bb
commit 9583964676
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 338 additions and 166 deletions

View File

@ -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);
}
<template>
<DButton
class="post-action-menu__ai-cancel-streaming cancel-streaming"
...attributes
@action={{this.cancelStreaming}}
@icon="pause"
@title="discourse_ai.ai_bot.cancel_streaming"
/>
</template>
}

View File

@ -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);
}
<template>
<DButton
class="post-action-menu__debug-ai"
...attributes
@action={{this.debugAiResponse}}
@icon="info"
@title="discourse_ai.ai_bot.debug_ai"
/>
</template>
}

View File

@ -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
);
}
<template>
<DButton
class="post-action-menu__share-ai"
...attributes
@action={{this.shareAiResponse}}
@icon="far-copy"
@title="discourse_ai.ai_bot.share"
/>
</template>
}

View File

@ -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) => {

View File

@ -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)
);

View File

@ -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
<details class='ai-quote'>
<summary>
<span>This is my special PM</span>
<span title='Conversation with AI'>AI</span>
</summary>
**ai_sharer:**
How do I do stuff?
**Tester_bot:**
No idea
</details>
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
<details class='ai-quote'>
<summary>
<span>This is my special PM</span>
<span title='Conversation with AI'>AI</span>
</summary>
**ai_sharer:**
test test test user reply 1
**gpt-4:**
test test test bot reply 1
</details>
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
<details class='ai-quote'>
<summary>
<span>This is my special PM</span>
<span title='Conversation with AI'>AI</span>
</summary>
**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
</details>
TEXT
expect(conversation).to eq(clip_text)
end
end
conversation = (<<~TEXT).strip
<details class='ai-quote'>
<summary>
<span>This is my special PM</span>
<span title='Conversation with AI'>AI</span>
</summary>
**ai_sharer:**
How do I do stuff?
**Tester_bot:**
No idea
</details>
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
<details class='ai-quote'>
<summary>
<span>This is my special PM</span>
<span title='Conversation with AI'>AI</span>
</summary>
**ai_sharer:**
test test test user reply 1
**gpt-4:**
test test test bot reply 1
</details>
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
<details class='ai-quote'>
<summary>
<span>This is my special PM</span>
<span title='Conversation with AI'>AI</span>
</summary>
**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
</details>
TEXT
expect(conversation).to eq(clip_text)
end
end