あなたの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