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)が実装されました。この関数は、指定された投稿番号間の生投稿を取得して会話を構築し、共有コンテンツを折りたたみ可能にする details タグ(<details class='ai-quote'>)を含む Markdown 形式でフォーマットします。
-
投稿メニューボタン: 共有機能をトリガーする新しい投稿メニューボタン(share)が追加されました。会話が短い場合(一定の閾値以内、具体的には最大 4 つのやり取りまで)は、クリップボードに自動的にコピーされます。より長い会話の場合は共有モーダルが表示され、ユーザーは共有したい会話の一部を選択できます。
-
スタイル: 共有モーダルと共有された会話の details タグのスタイルを設定するための新しい SCSS スタイル(bot-replies.scss)が追加されました。
-
ロケールの変更: ボタンラベルやツールチップなどの共有機能に関連する新しいロケール文字列を含むように client.en.yml ファイルが更新されました。
-
システムテスト: 共有機能が期待通りに動作することを確認するための新しいシステムテスト(share_spec.rb)が追加されました。
-
Markdown のホワイトリスト設定: 共有された会話に使用されるカスタム details[class=ai-quote] タグをホワイトリストに追加する ai-tags.js ファイルが作成され、Discourse の Markdown レンダラーで許可されるようになりました。
-
共著: コミットメッセージには、Alan Guo Xiang Tan が一部の変更の共著者であることが示されており、この機能の開発において協力的な取り組みが行われたことが伺えます。
全体として、この機能は AI ボットとの貴重なやり取りを共有しやすくすることでユーザー体験を向上させ、Discourse コミュニティ内でのコラボレーションと知識共有を促進します。
sam:
フォーラムのユーザーが抱くであろう一般的な質問に答える形で、この機能を説明してみてください。
gpt4_bot:
新機能のお知らせ:AI ボットとの会話を共有しよう!
私たちは、フォーラム上で AI ボットとの会話を簡単に共有できる新しい機能を導入できたことを嬉しく思います。AI ボットとのやり取りが示唆に富んでいたり、面白かったり、役立ったりした場合でも、数クリックだけでその対話を他の人と共有できるようになりました。以下に知っておくべきことをまとめます:
AI ボットとの会話を共有するにはどうすればよいですか?
AI ボットとの会話を表示している際、投稿メニューに新しい「共有」アイコンが表示されます。このアイコンをクリックすると、以下の 2 つのオプションが表示されます:
-
短い会話の場合: 会話が一定の長さ以内(最大 4 つのやり取りまで)であれば、「共有」アイコンをクリックするだけで、会話全体がクリップボードに自動的にコピーされます。その後、新しい投稿、プライベートメッセージ、またはフォーラム外など、好きな場所に貼り付けることができます。
-
長い会話の場合: より長い対話の場合は、「共有」アイコンをクリックすると、会話のプレビューが表示されるモーダルが開きます。スライダーを使用して、共有したい会話の範囲を選択できます。希望の範囲を選択したら、「コピー」ボタンをクリックして、選択した会話をクリップボードにコピーします。
共有された会話はどのような見た目になりますか?
共有された会話は、折りたたみ可能な「details」ブロック形式でフォーマットされます。これにより、投稿内で読みやすく管理しやすくなります。選択した範囲内のユーザー名とメッセージの内容が含まれます。
どの AI ボットとの会話でも共有できますか?
はい、フォーラムで有効になっているすべての AI ボットとの会話を共有できます。会話を表示している際に、投稿メニューに「共有」アイコンがあるか確認してください。
共有できる会話の量に制限はありますか?
厳密な制限はありませんが、他の読者のために簡潔で焦点の絞られた内容にするよう、会話の関連する部分のみを共有することを推奨しています。
どこでこれらの会話を共有できますか?
新しいフォーラム投稿、他のユーザーへのプライベートメッセージ、あるいはフォーラム外でも、クリップボードからテキストを貼り付けられる場所であればどこでも共有できます。
共有された会話に個人情報が含まれますか?
共有されたテキストに個人情報が含まれることはありません。コピーされるのはユーザー名とメッセージの内容のみです。
共有機能で問題が発生した場合どうすればよいですか?
共有機能で問題が発生した場合やフィードバックがある場合は、サポートチームに連絡するか、フォーラムのフィードバックセクションに投稿してください。私たちはサポートに全力を尽くしており、常にあなたの体験を改善する方法を探しています。
この新機能をお楽しみいただき、皆さんが共有してくれる興味深い会話を楽しみにしています!