참고로, 이 게시글은 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에 도입된 새로운 기능은 사용자가 AI 봇과의 대화를 Discourse 플랫폼 내에서 쉽게 공유할 수 있도록 합니다. 이 기능은 AI 봇과의 상호작용에서 얻은 통찰력이나 정보를 다른 사용자와 공유하거나 개인적인 참고용으로 저장하고 싶어 하는 사용자에게 특히 유용합니다.
기능의 작동 방식과 이루어진 변경 사항에 대한 분석은 다음과 같습니다:
-
공유 모달 컴포넌트: 대화 범위를 선택하고 클립보드에 복사하기 위한 사용자 인터페이스를 제공하는 새로운 Glimmer 컴포넌트(ShareModal)가 추가되었습니다. 이 모달에는 공유할 대화의 컨텍스트 양을 선택하기 위한 슬라이더가 포함되어 있습니다.
-
대화 복사 기능: 대화 텍스트를 실제로 복사하는 작업을 처리하기 위한 새로운 JavaScript 함수(copyConversation)가 구현되었습니다. 이 함수는 지정된 게시물 번호 사이의 원본(raw) 게시물을 가져와 대화를 구성하고, 공유된 내용을 접을 수 있도록 하는 details 태그(<details class='ai-quote'>)를 포함하여 마크다운 형식으로 포맷팅합니다.
-
게시물 메뉴 버튼: 공유 기능을 트리거하는 새로운 게시물 메뉴 버튼(share)이 추가되었습니다. 대화가 짧은 경우(특정 임계값 이하)에는 자동으로 클립보드에 복사됩니다. 더 긴 대화의 경우, 사용자는 공유할 대화 부분을 선택할 수 있는 공유 모달이 표시됩니다.
-
스타일링: 공유 모달과 공유된 대화 details 태그를 스타일링하기 위한 새로운 SCSS 스타일(bot-replies.scss)이 추가되었습니다.
-
로케일 변경: client.en.yml 파일에 공유 기능에 대한 새로운 로케일 문자열(예: 버튼 라벨과 툴팁)이 포함되도록 업데이트되었습니다.
-
시스템 테스트: 공유 기능이 의도대로 작동하는지 확인하기 위한 새로운 시스템 테스트(share_spec.rb)가 추가되었습니다.
-
마크다운 화이트리스트: 공유된 대화에 사용되는 사용자 정의 details[class=ai-quote] 태그를 화이트리스트에 등록하여 Discourse 마크다운 렌더러에서 허용되도록 하는 ai-tags.js 파일이 생성되었습니다.
-
공동 저자: 커밋 메시지는 Alan Guo Xiang Tan이 일부 변경 사항의 공동 저자임을 나타내며, 이 기능 개발에 대한 협업 노력이 있었음을 시사합니다.
전반적으로, 이 기능은 AI 봇과의 가치 있는 상호작용을 공유하기 쉬워져 Discourse 커뮤니티 내의 협업과 지식 공유를 촉진함으로써 사용자 경험을 향상시킵니다.
sam:
포럼 사용자들을 대상으로 이 기능에 대해 그들이 가질 공통적인 질문을 답하는 형태로 기능을 설명해 보세요.
gpt4_bot:
새로운 기능 공지: AI 봇 대화 공유하기!
포럼에서 AI 봇과의 대화를 쉽게 공유할 수 있는 새로운 기능을 발표하게 되어 기쁩니다. AI 봇 중 하나와 통찰력 있는, 재미있는, 또는 도움이 되는 대화를 나눴다면 이제 몇 번의 클릭만으로 그 상호작용을 다른 사람들과 나눌 수 있습니다. 알아두어야 할 사항은 다음과 같습니다:
AI 봇 대화를 어떻게 공유하나요?
AI 봇과의 대화를 보고 있을 때, 게시물 메뉴에 새로운 “공유” 아이콘이 표시됩니다. 이 아이콘을 클릭하면 두 가지 옵션이 제공됩니다:
-
짧은 대화의 경우: 대화가 특정 길이(4회 상호작용 이하) 이내라면, “공유” 아이콘을 클릭하면 전체 대화가 자동으로 클립보드에 복사됩니다. 이후 원하는 곳(새 게시물, 개인 메시지, 또는 포럼 외부 등)에 붙여넣을 수 있습니다.
-
긴 대화의 경우: 더 긴 상호작용의 경우, “공유” 아이콘을 클릭하면 대화 미리보기가 포함된 모달이 열립니다. 슬라이더를 사용하여 공유할 대화의 양을 선택할 수 있습니다. 원하는 범위를 선택한 후 “복사” 버튼을 클릭하면 선택한 대화가 클립보드에 복사됩니다.
공유된 대화는 어떤 모습일까요?
공유된 대화는 게시물에서 읽고 관리하기 쉽게 접을 수 있는 “details” 블록 형식으로 포맷팅됩니다. 선택한 범위 내의 사용자 이름과 메시지 내용이 포함됩니다.
어떤 AI 봇과든 대화를 공유할 수 있나요?
네, 포럼에서 활성화된 모든 AI 봇과의 대화를 공유할 수 있습니다. 대화를 보고 있을 때 게시물 메뉴의 “공유” 아이콘을 찾으시면 됩니다.
공유할 수 있는 대화의 양에 제한이 있나요?
엄격한 제한은 없지만, 다른 독자들을 위해 간결하고 집중된 내용을 유지하도록 대화의 관련 부분만 공유할 것을 권장합니다.
이러한 대화를 어디에 공유할 수 있나요?
새 포럼 게시물, 다른 사용자에게 보내는 개인 메시지, 또는 클립보드에서 텍스트를 붙여넣을 수 있는 포럼 외부의 어디든 공유할 수 있습니다.
공유된 대화에 개인 정보가 포함되나요?
공유된 텍스트에는 개인 정보가 포함되지 않습니다. 사용자 이름과 메시지 내용만 복사됩니다.
공유 기능에 문제가 발생하면 어떻게 하나요?
공유 기능과 관련하여 문제가 발생하거나 피드백이 있다면, 주저하지 말고 지원 팀에게 문의하거나 포럼의 피드백 섹션에 게시물을 작성해 주세요. 우리는 도움을 드리고 항상 경험을 개선하기 위한 방법을 찾고 있습니다.
이 새로운 기능을 즐기시길 바라며, 여러분이 공유할 흥미로운 대화를 기대합니다!