From 933784a873c781816857699d6d9138baf9f5a7de Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 29 Dec 2023 19:47:47 +1100 Subject: [PATCH] FEATURE: allow easy sharing of bot conversations (#385) * FEATURE: allow easy sharing of bot conversations * Lean on new core API i * Added system spec for copy functionality * Update assets/javascripts/initializers/ai-bot-replies.js Co-authored-by: Alan Guo Xiang Tan * discourse later insted of setTimeout * Update spec/system/ai_bot/share_spec.rb Co-authored-by: Alan Guo Xiang Tan * feedback from review just check the whole payload * remove uneeded code * fix spec --------- Co-authored-by: Alan Guo Xiang Tan --- .../components/modal/share-modal.gjs | 109 ++++++++++++++++ .../discourse/lib/copy-conversation.js | 55 ++++++++ .../initializers/ai-bot-replies.js | 42 +++++++ .../lib/discourse-markdown/ai-tags.js | 3 + .../modules/ai-bot/common/bot-replies.scss | 34 +++++ config/locales/client.en.yml | 10 ++ spec/system/ai_bot/share_spec.rb | 118 ++++++++++++++++++ 7 files changed, 371 insertions(+) create mode 100644 assets/javascripts/discourse/components/modal/share-modal.gjs create mode 100644 assets/javascripts/discourse/lib/copy-conversation.js create mode 100644 assets/javascripts/lib/discourse-markdown/ai-tags.js create mode 100644 spec/system/ai_bot/share_spec.rb diff --git a/assets/javascripts/discourse/components/modal/share-modal.gjs b/assets/javascripts/discourse/components/modal/share-modal.gjs new file mode 100644 index 00000000..b122f62d --- /dev/null +++ b/assets/javascripts/discourse/components/modal/share-modal.gjs @@ -0,0 +1,109 @@ +import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; +import { Input } from "@ember/component"; +import { on } from "@ember/modifier"; +import { action } from "@ember/object"; +import { htmlSafe } from "@ember/template"; +import DButton from "discourse/components/d-button"; +import DModal from "discourse/components/d-modal"; +import discourseLater from "discourse-common/lib/later"; +import I18n from "I18n"; +import copyConversation from "../../lib/copy-conversation"; + +const t = I18n.t.bind(I18n); + +export default class ShareModal extends Component { + @tracked contextValue = 1; + @tracked htmlContext = ""; + @tracked maxContext = 0; + @tracked allPosts = []; + @tracked justCopiedText = ""; + + constructor() { + super(...arguments); + + const postStream = this.args.model.topic.get("postStream"); + + let postNumbers = []; + // simpler to understand than Array.from + for (let i = 1; i <= this.args.model.post_number; i++) { + postNumbers.push(i); + } + + this.allPosts = postNumbers + .map((postNumber) => { + let postId = postStream.findPostIdForPostNumber(postNumber); + if (postId) { + return postStream.findLoadedPost(postId); + } + }) + .filter((post) => post); + + this.maxContext = this.allPosts.length / 2; + this.contextValue = 1; + + this.updateHtmlContext(); + } + + @action + updateHtmlContext() { + let context = []; + + const start = this.allPosts.length - this.contextValue * 2; + for (let i = start; i < this.allPosts.length; i++) { + const post = this.allPosts[i]; + context.push(`

${post.username}:

`); + context.push(post.cooked); + } + this.htmlContext = htmlSafe(context.join("\n")); + } + + @action + async copyContext() { + const from = + this.allPosts[this.allPosts.length - this.contextValue * 2].post_number; + const to = this.args.model.post_number; + await copyConversation(this.args.model.topic, from, to); + this.justCopiedText = t("discourse_ai.ai_bot.conversation_shared"); + discourseLater(() => { + this.justCopiedText = ""; + }, 2000); + } + + +} diff --git a/assets/javascripts/discourse/lib/copy-conversation.js b/assets/javascripts/discourse/lib/copy-conversation.js new file mode 100644 index 00000000..bcf3188e --- /dev/null +++ b/assets/javascripts/discourse/lib/copy-conversation.js @@ -0,0 +1,55 @@ +import { ajax } from "discourse/lib/ajax"; +import { clipboardCopy } from "discourse/lib/utilities"; +import I18n from "discourse-i18n"; + +export default async function (topic, fromPostNumber, toPostNumber) { + const stream = topic.get("postStream"); + + let postNumbers = []; + // simpler to understand than Array.from + for (let i = fromPostNumber; i <= toPostNumber; i++) { + postNumbers.push(i); + } + + const postIds = postNumbers.map((postNumber) => { + return stream.findPostIdForPostNumber(postNumber); + }); + + // we need raw to construct so post stream will not help + + const url = `/t/${topic.id}/posts.json`; + const data = { + post_ids: postIds, + include_raw: true, + }; + + const response = await ajax(url, { data }); + + let buffer = []; + buffer.push("
"); + buffer.push(""); + buffer.push(`${topic.title}`); + buffer.push( + `${I18n.t( + "discourse_ai.ai_bot.ai_label" + )}` + ); + buffer.push(""); + + response.post_stream.posts.forEach((post) => { + buffer.push(""); + buffer.push(`**${post.username}:**`); + buffer.push(""); + buffer.push(post.raw); + }); + + buffer.push("
"); + + const text = buffer.join("\n"); + + if (window.discourseAiTestClipboard) { + window.discourseAiClipboard = text; + } + + await clipboardCopy(text); +} diff --git a/assets/javascripts/initializers/ai-bot-replies.js b/assets/javascripts/initializers/ai-bot-replies.js index e136963e..050e1edc 100644 --- a/assets/javascripts/initializers/ai-bot-replies.js +++ b/assets/javascripts/initializers/ai-bot-replies.js @@ -6,6 +6,10 @@ import { withPluginApi } from "discourse/lib/plugin-api"; import { cook } from "discourse/lib/text"; import { registerWidgetShim } from "discourse/widgets/render-glimmer"; import { composeAiBotMessage } from "discourse/plugins/discourse-ai/discourse/lib/ai-bot-helper"; +import ShareModal from "../discourse/components/modal/share-modal"; +import copyConversation from "../discourse/lib/copy-conversation"; + +const AUTO_COPY_THRESHOLD = 4; function isGPTBot(user) { return user && [-110, -111, -112, -113].includes(user.id); @@ -166,6 +170,43 @@ function initializePersonaDecorator(api) { ); } +function initializeShareButton(api) { + const currentUser = api.getCurrentUser(); + if (!currentUser || !currentUser.ai_enabled_chat_bots) { + 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 { + modal.show(ShareModal, { model: post }); + } + }; + + api.addPostMenuButton("share", (post) => { + // very hacky and ugly, but there is no `.topic` in attrs + if ( + !currentUser.ai_enabled_chat_bots.any( + (bot) => post.username === bot.username + ) + ) { + return; + } + + return { + action: shareAiResponse, + icon: "share", + className: "post-action-menu__share", + title: "discourse_ai.ai_bot.share", + position: "first", + }; + }); + + const modal = api.container.lookup("service:modal"); +} + export default { name: "discourse-ai-bot-replies", @@ -179,6 +220,7 @@ export default { } withPluginApi("1.6.0", initializeAIBotReplies); withPluginApi("1.6.0", initializePersonaDecorator); + withPluginApi("1.22.0", (api) => initializeShareButton(api, container)); } }, }; diff --git a/assets/javascripts/lib/discourse-markdown/ai-tags.js b/assets/javascripts/lib/discourse-markdown/ai-tags.js new file mode 100644 index 00000000..c2d9b672 --- /dev/null +++ b/assets/javascripts/lib/discourse-markdown/ai-tags.js @@ -0,0 +1,3 @@ +export function setup(helper) { + helper.allowList(["details[class=ai-quote]"]); +} diff --git a/assets/stylesheets/modules/ai-bot/common/bot-replies.scss b/assets/stylesheets/modules/ai-bot/common/bot-replies.scss index 4943b9bf..2a8ba805 100644 --- a/assets/stylesheets/modules/ai-bot/common/bot-replies.scss +++ b/assets/stylesheets/modules/ai-bot/common/bot-replies.scss @@ -67,3 +67,37 @@ article.streaming nav.post-controls .actions button.cancel-streaming { font-size: var(--font-down-1); padding-top: 3px; } + +details.ai-quote { + > summary { + display: flex; + justify-content: space-between; + align-items: center; + span:first-child { + margin-right: auto; + } + span:nth-child(2) { + font-size: var(--font-down-2); + background: var(--primary-medium); + padding: 2px 6px 0; + color: var(--secondary); + } + } +} + +.ai-share-modal { + .d-modal__footer { + position: relative; + padding: 10px 20px 25px; + .btn-primary { + margin-left: auto; + } + } + &__just-copied { + position: absolute; + font-size: var(--font-down-1); + right: 20px; + bottom: 5px; + color: var(--success); + } +} diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index e13f9531..e2f2e7d7 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -180,6 +180,16 @@ en: cancel_streaming: "Stop reply" default_pm_prefix: "[Untitled AI bot PM]" shortcut_title: "Start a PM with an AI bot" + share: "Share AI conversation" + conversation_shared: "Conversation copied to clipboard" + + ai_label: "AI" + ai_title: "Conversation with AI" + + share_modal: + title: "Share AI conversation" + copy: "Copy" + context: "Interactions to share:" bot_names: gpt-4: "GPT-4" diff --git a/spec/system/ai_bot/share_spec.rb b/spec/system/ai_bot/share_spec.rb new file mode 100644 index 00000000..708e96bb --- /dev/null +++ b/spec/system/ai_bot/share_spec.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true +RSpec.describe "Share conversation", type: :system do + fab!(:admin) { Fabricate(:admin, username: "ai_sharer") } + let(:bot_user) { User.find(DiscourseAi::AiBot::EntryPoint::GPT4_ID) } + + let(:pm) do + Fabricate( + :private_message_topic, + title: "This is my special PM", + user: admin, + topic_allowed_users: [ + Fabricate.build(:topic_allowed_user, user: admin), + Fabricate.build(:topic_allowed_user, user: bot_user), + ], + ) + end + + let(:pm_posts) do + posts = [] + i = 1 + 3.times do + posts << Fabricate(:post, topic: pm, user: admin, raw: "test test test user reply #{i}") + posts << Fabricate(:post, topic: pm, user: bot_user, raw: "test test test bot reply #{i}") + i += 1 + end + + posts + end + + before do + SiteSetting.ai_bot_enabled = true + SiteSetting.ai_bot_enabled_chat_bots = "gpt-4" + sign_in(admin) + + bot_user.update!(username: "gpt-4") + + Group.refresh_automatic_groups! + pm + pm_posts + end + + it "can share a conversation" do + clip_text = nil + + visit(pm.url) + + # clipboard functionality is extremely hard to test + # we would need special permissions in chrome driver to enable full access + # instead we use a secret variable to signal that we want to store clipboard + # data in window.discourseAiClipboard + page.execute_script("window.discourseAiTestClipboard = true") + + find("#post_2 .post-action-menu__share").click + + try_until_success do + clip_text = page.evaluate_script("window.discourseAiClipboard") + expect(clip_text).to be_present + 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) + + # Test modal functionality as well + page.evaluate_script("window.discourseAiClipboard = null") + + 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 = page.evaluate_script("window.discourseAiClipboard") + expect(clip_text).to be_present + 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