分享你的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 个赞

FWIW, I worked with GPT-4 to create this post (with some minor changes)

Discourse AI Bot: New Sharing Feature AI

sam:

Given:

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

Explain the new feature in Discourse AI

gpt4_bot:

The new feature introduced in Discourse AI allows users to easily share conversations they’ve had with AI bots within the Discourse platform. This feature is particularly useful for users who want to share the insights or information they’ve received from interacting with AI bots with other users or for their own reference.

Here’s a breakdown of how the feature works and what changes have been made:

  1. Share Modal Component: A new Glimmer component (ShareModal) has been added, which provides a user interface for selecting the range of the conversation to share and copying it to the clipboard. This modal includes a slider to choose how much context from the conversation to include when sharing.

  2. Copy Conversation Functionality: A new JavaScript function (copyConversation) has been implemented to handle the actual copying of the conversation text. It constructs the conversation by fetching the raw posts between specified post numbers and formats it with Markdown, including a details tag (<details class='ai-quote'>) to make the shared content collapsible.

  3. Post Menu Button: A new post menu button (share) has been added that triggers the share functionality. If the conversation is short (up to a certain threshold), it will be automatically copied to the clipboard. For longer conversations, the share modal will be displayed, allowing the user to select the portion of the conversation to share.

  4. Styling: New SCSS styles have been added (bot-replies.scss) to style the share modal and the shared conversation details tag.

  5. Locale Changes: The client.en.yml file has been updated to include new locale strings for the share feature, such as button labels and tooltips.

  6. System Test: A new system test (share_spec.rb) has been added to ensure that the sharing functionality works as expected.

  7. Markdown Whitelisting: The ai-tags.js file has been created to whitelist the custom details[class=ai-quote] tag used for the shared conversation, so it’s allowed in the Discourse Markdown renderer.

  8. Co-authorship: The commit message indicates that Alan Guo Xiang Tan co-authored some of the changes, suggesting a collaborative effort in developing this feature.

Overall, this feature enhances the user experience by making it easier to share valuable interactions with AI bots, fostering collaboration, and knowledge sharing within the Discourse community.

sam:

Try to explain the feature to forum users answering common questions they will have about it.

gpt4_bot:

New Feature Announcement: Share Your AI Bot Conversations!

We’re excited to announce a new feature that allows you to easily share your conversations with AI bots on our forum. Whether you’ve had an insightful, funny, or helpful exchange with one of our AI bots, you can now share that interaction with others in just a few clicks. Here’s what you need to know:

How do I share an AI bot conversation?

When viewing a conversation with an AI bot, you’ll see a new “Share” icon in the post menu. Clicking this icon will give you two options:

  1. For short conversations: If the conversation is within a certain length (up to four interactions), clicking the “Share” icon will automatically copy the entire conversation to your clipboard. You can then paste it wherever you like—be it in a new post, a private message, or outside the forum.

  2. For longer conversations: For more extended interactions, clicking the “Share” icon will open a modal with a preview of your conversation. You can use a slider to select how much of the conversation you want to share. Once you’ve chosen the desired range, click the “Copy” button to copy the selected conversation to your clipboard.

What will the shared conversation look like?

The shared conversation will be formatted in a collapsible “details” block, making it easy to read and manage in posts. It will include the usernames and the content of the messages within the range you’ve selected.

Can I share conversations with any AI bot?

Yes, you can share conversations with any of the AI bots enabled on our forum. Just look for the “Share” icon in the post menu when you’re viewing the conversation.

Is there a limit to how much of the conversation I can share?

There’s no hard limit, but we encourage you to share only the relevant parts of the conversation to keep things concise and focused for other readers.

Where can I share these conversations?

You can share them in a new forum post, in a private message to other users, or even outside the forum—anywhere you can paste text from your clipboard.

Will the shared conversation include any personal information?

No personal information will be included in the shared text. Only the usernames and the content of the messages will be copied.

What if I encounter any issues with the sharing feature?

If you run into any problems or have feedback about the sharing feature, please don’t hesitate to reach out to our support team or post in the feedback section of our forum. We’re here to help and always looking for ways to improve your experience.

We hope you enjoy this new feature and look forward to seeing the interesting conversations you’ll share!

12 个赞