Discourse AI 机器人:新增分享功能
AI
sam:
给定:
commit 933784a873c781816857699d6d9138baf9f5a7de
Author: Sam <sam.saffron@gmail.com>
Date: Fri Dec 29 19:47:47 2023 +1100
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 <gxtan1990@gmail.com>
* discourse later insted of setTimeout
* Update spec/system/ai_bot/share_spec.rb
Co-authored-by: Alan Guo Xiang Tan <gxtan1990@gmail.com>
* feedback from review
just check the whole payload
* remove uneeded code
* fix spec
---------
Co-authored-by: Alan Guo Xiang Tan <gxtan1990@gmail.com>
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 0000000..b122f62
--- /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(`<p><b>${post.username}:</b></p>`);
+ 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);
+ }
+
+ <template>
+ <DModal
+ class="ai-share-modal"
+ @title={{t "discourse_ai.ai_bot.share_modal.title"}}
+ @closeModal={{@closeModal}}
+ >
+ <:body>
+ <div class="ai-share-modal__preview">
+ {{this.htmlContext}}
+ </div>
+ </:body>
+
+ <:footer>
+ <div class="ai-share-modal__slider">
+ <Input
+ @type="range"
+ min="1"
+ max={{this.maxContext}}
+ @value={{this.contextValue}}
+ {{on "change" this.updateHtmlContext}}
+ />
+ <div class="ai-share-modal__context">
+ {{t "discourse_ai.ai_bot.share_modal.context"}}
+ {{this.contextValue}}
+ </div>
+ </div>
+ <DButton
+ class="btn-primary confirm"
+ @icon="copy"
+ @action={{this.copyContext}}
+ @label="discourse_ai.ai_bot.share_modal.copy"
+ />
+ <span class="ai-share-modal__just-copied">{{this.justCopiedText}}</span>
+ </:footer>
+ </DModal>
+ </template>
+}
diff --git a/assets/javascripts/discourse/lib/copy-conversation.js b/assets/javascripts/discourse/lib/copy-conversation.js
new file mode 100644
index 0000000..bcf3188
--- /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("<details class='ai-quote'>");
+ buffer.push("<summary>");
+ buffer.push(`<span>${topic.title}</span>`);
+ buffer.push(
+ `<span title='${I18n.t("discourse_ai.ai_bot.ai_title")}'>${I18n.t(
+ "discourse_ai.ai_bot.ai_label"
+ )}</span>`
+ );
+ buffer.push("</summary>");
+
+ response.post_stream.posts.forEach((post) => {
+ buffer.push("");
+ buffer.push(`**${post.username}:**`);
+ buffer.push("");
+ buffer.push(post.raw);
+ });
+
+ buffer.push("</details>");
+
+ 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 e136963..050e1ed 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 0000000..c2d9b67
--- /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 4943b9b..2a8ba80 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 e13f953..e2f2e7d 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 0000000..708e96b
--- /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
+ <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)
+
+ # 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
+ <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
解释 Discourse AI 中的新功能
gpt4_bot:
Discourse AI 中引入的新功能允许用户轻松分享他们在 Discourse 平台内与 AI 机器人进行的对话。此功能对于那些希望将自己在与 AI 机器人互动中获得的见解或信息与他人的用户,或用于个人参考的用户特别有用。
以下是该功能的工作原理以及所做更改的概述:
-
分享弹窗组件:新增了一个 Glimmer 组件(ShareModal),它提供了一个用户界面,用于选择要分享的对话范围并将其复制到剪贴板。该弹窗包含一个滑块,用于选择分享时包含多少对话上下文。
-
复制对话功能:实现了一个新的 JavaScript 函数(copyConversation)来处理实际的对话文本复制。它通过获取指定帖子编号之间的原始帖子来构建对话,并使用 Markdown 格式进行格式化,包括一个 details 标签(<details class='ai-quote'>),使共享内容可折叠。
-
帖子菜单按钮:新增了一个帖子菜单按钮(share),用于触发分享功能。如果对话较短(在一定阈值内,即四次互动以内),它将自动复制到剪贴板。对于较长的对话,将显示分享弹窗,允许用户选择要分享的对话部分。
-
样式设计:新增了 SCSS 样式(bot-replies.scss)来美化分享弹窗和共享对话的 details 标签。
-
本地化更改:更新了 client.en.yml 文件,以包含分享功能的新本地化字符串,例如按钮标签和工具提示。
-
系统测试:新增了一个系统测试(share_spec.rb),以确保分享功能按预期工作。
-
Markdown 白名单:创建了 ai-tags.js 文件,以白名单形式允许用于共享对话的自定义 details[class=ai-quote] 标签,使其在 Discourse Markdown 渲染器中被允许使用。
-
共同作者:提交信息表明 Alan Guo Xiang Tan 共同参与了部分更改,这表明该功能的开发是协作完成的。
总体而言,此功能通过让用户更容易分享与 AI 机器人的有价值互动,促进了 Discourse 社区内的协作和知识共享,从而提升了用户体验。
sam:
尝试向论坛用户解释此功能,回答他们可能提出的常见问题。
gpt4_bot:
新功能公告:分享您的 AI 机器人对话!
我们很高兴地宣布一项新功能,允许您轻松分享在论坛上与 AI 机器人进行的对话。无论您与我们的 AI 机器人进行过多么富有洞察力、有趣或有帮助的交流,现在只需点击几下,您就可以与他人分享这些互动。以下是您需要了解的信息:
如何分享 AI 机器人对话?
在查看与 AI 机器人的对话时,您会在帖子菜单中看到一个新的「分享」图标。点击该图标将为您提供两个选项:
-
对于简短对话:如果对话长度在一定范围内(最多四次互动),点击「分享」图标将自动将整个对话复制到您的剪贴板。然后您可以将其粘贴到任何地方——无论是新帖子、私信,还是论坛之外。
-
对于较长对话:对于更长的互动,点击「分享」图标将打开一个包含对话预览的弹窗。您可以使用滑块选择要分享的对话量。选择所需的范围后,点击「复制」按钮将所选对话复制到剪贴板。
共享的对话看起来会是什么样子?
共享的对话将以可折叠的「details」块格式呈现,使其在帖子中易于阅读和管理。它将包括用户名以及您所选范围内的消息内容。
我可以与任何 AI 机器人分享对话吗?
是的,您可以与论坛上启用的任何 AI 机器人分享对话。只需在查看对话时查看帖子菜单中的「分享」图标即可。
我可以分享多少对话内容?
没有硬性限制,但我们建议您仅分享对话的相关部分,以保持内容的简洁和聚焦,方便其他读者阅读。
我可以在哪里分享这些对话?
您可以在新的论坛帖子中分享,通过私信分享给其他用户,甚至可以在论坛之外分享——任何可以粘贴剪贴板文本的地方都可以。
共享的对话会包含任何个人信息吗?
共享文本中不会包含任何个人信息。仅会复制用户名和消息内容。
如果我在分享功能上遇到问题怎么办?
如果您遇到任何问题或对分享功能有任何反馈,请随时联系我们的支持团队,或在论坛的反馈版块发帖。我们随时为您提供帮助,并始终致力于改善您的体验。
希望您喜欢这项新功能,并期待看到您分享的有趣对话!