分享你的AI机器人对话!

我们很高兴地宣布一项新功能,允许您在我们的论坛上轻松分享您与 AI 机器人的对话。无论您与我们的某个 AI 机器人进行了富有见地、有趣或有帮助的交流,现在都可以轻松地与他人分享该互动。以下是您需要了解的信息:

如何分享 AI 机器人对话?

在查看与 AI 机器人的对话时,您会在帖子菜单中看到一个新的“分享”图标。

点击它的操作方式如下:

  1. **对于简短的对话:**如果对话长度在一定范围内(最多 2 次互动),点击“分享”图标将自动将整个对话复制到您的剪贴板。然后,您可以将其粘贴到任何您想要的地方——无论是新帖子、私人消息,还是论坛之外。

  2. **对于较长的对话:**对于更长的互动,点击“分享”图标将打开一个带有对话预览的模态框。您可以使用滑块选择要分享的对话部分。选择所需的范围后,点击“复制”按钮将选定的对话复制到您的剪贴板。

分享的对话会是什么样子?

分享的对话将格式化为可折叠的“details”块,方便在帖子中阅读和管理。它将包含您选择范围内的用户名和消息内容。

示例:

<details class='ai-quote'>
<summary>
<span>Greetings</span>
<span title='Conversation with AI'>AI</span>
</summary>

**sam:**

say hello

**gpt4_bot:**

Hello! How can I assist you today?
</details>

它将渲染为:

Greetings AI

sam:

say hello

gpt4_bot:

Hello! How can I assist you today?

我可以分享与任何 AI 机器人的对话吗?

是的,您可以分享与我们论坛上启用的任何 AI 机器人的对话。只需在查看对话时查找帖子菜单中的“分享”图标即可。

我可以分享的对话内容有限制吗?

没有硬性限制,但我们鼓励您只分享对话的相关部分,以保持内容简洁明了,方便其他读者。

我可以在哪里分享这些对话?

您可以在新论坛帖子、发送给其他用户的私人消息中分享,甚至可以在论坛之外分享——任何您可以从剪贴板粘贴文本的地方。

分享的对话会包含任何个人信息吗?

分享的文本中不会包含任何个人信息。只会复制用户名和消息内容。

14 个赞

仅供参考,这篇帖子是我与 GPT-4 合作创作的(仅做了少量修改)

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 机器人互动中获得的见解或信息与他人的用户,或用于个人参考的用户特别有用。

以下是该功能的工作原理以及所做更改的概述:

  1. 分享弹窗组件:新增了一个 Glimmer 组件(ShareModal),它提供了一个用户界面,用于选择要分享的对话范围并将其复制到剪贴板。该弹窗包含一个滑块,用于选择分享时包含多少对话上下文。

  2. 复制对话功能:实现了一个新的 JavaScript 函数(copyConversation)来处理实际的对话文本复制。它通过获取指定帖子编号之间的原始帖子来构建对话,并使用 Markdown 格式进行格式化,包括一个 details 标签(<details class='ai-quote'>),使共享内容可折叠。

  3. 帖子菜单按钮:新增了一个帖子菜单按钮(share),用于触发分享功能。如果对话较短(在一定阈值内,即四次互动以内),它将自动复制到剪贴板。对于较长的对话,将显示分享弹窗,允许用户选择要分享的对话部分。

  4. 样式设计:新增了 SCSS 样式(bot-replies.scss)来美化分享弹窗和共享对话的 details 标签。

  5. 本地化更改:更新了 client.en.yml 文件,以包含分享功能的新本地化字符串,例如按钮标签和工具提示。

  6. 系统测试:新增了一个系统测试(share_spec.rb),以确保分享功能按预期工作。

  7. Markdown 白名单:创建了 ai-tags.js 文件,以白名单形式允许用于共享对话的自定义 details[class=ai-quote] 标签,使其在 Discourse Markdown 渲染器中被允许使用。

  8. 共同作者:提交信息表明 Alan Guo Xiang Tan 共同参与了部分更改,这表明该功能的开发是协作完成的。

总体而言,此功能通过让用户更容易分享与 AI 机器人的有价值互动,促进了 Discourse 社区内的协作和知识共享,从而提升了用户体验。

sam

尝试向论坛用户解释此功能,回答他们可能提出的常见问题。

gpt4_bot

新功能公告:分享您的 AI 机器人对话!

我们很高兴地宣布一项新功能,允许您轻松分享在论坛上与 AI 机器人进行的对话。无论您与我们的 AI 机器人进行过多么富有洞察力、有趣或有帮助的交流,现在只需点击几下,您就可以与他人分享这些互动。以下是您需要了解的信息:

如何分享 AI 机器人对话?

在查看与 AI 机器人的对话时,您会在帖子菜单中看到一个新的「分享」图标。点击该图标将为您提供两个选项:

  1. 对于简短对话:如果对话长度在一定范围内(最多四次互动),点击「分享」图标将自动将整个对话复制到您的剪贴板。然后您可以将其粘贴到任何地方——无论是新帖子、私信,还是论坛之外。

  2. 对于较长对话:对于更长的互动,点击「分享」图标将打开一个包含对话预览的弹窗。您可以使用滑块选择要分享的对话量。选择所需的范围后,点击「复制」按钮将所选对话复制到剪贴板。

共享的对话看起来会是什么样子?

共享的对话将以可折叠的「details」块格式呈现,使其在帖子中易于阅读和管理。它将包括用户名以及您所选范围内的消息内容。

我可以与任何 AI 机器人分享对话吗?

是的,您可以与论坛上启用的任何 AI 机器人分享对话。只需在查看对话时查看帖子菜单中的「分享」图标即可。

我可以分享多少对话内容?

没有硬性限制,但我们建议您仅分享对话的相关部分,以保持内容的简洁和聚焦,方便其他读者阅读。

我可以在哪里分享这些对话?

您可以在新的论坛帖子中分享,通过私信分享给其他用户,甚至可以在论坛之外分享——任何可以粘贴剪贴板文本的地方都可以。

共享的对话会包含任何个人信息吗?

共享文本中不会包含任何个人信息。仅会复制用户名和消息内容。

如果我在分享功能上遇到问题怎么办?

如果您遇到任何问题或对分享功能有任何反馈,请随时联系我们的支持团队,或在论坛的反馈版块发帖。我们随时为您提供帮助,并始终致力于改善您的体验。

希望您喜欢这项新功能,并期待看到您分享的有趣对话!

12 个赞