Кстати, я работал с GPT-4, чтобы создать этот пост (с некоторыми незначительными правками)
Discourse AI Bot: Новая функция обмена
ИИ
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 = [];
+ // проще для понимания, чем 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 = [];
+ // проще для понимания, чем Array.from
+ for (let i = fromPostNumber; i <= toPostNumber; i++) {
+ postNumbers.push(i);
+ }
+
+ const postIds = postNumbers.map((postNumber) => {
+ return stream.findPostIdForPostNumber(postNumber);
+ });
+
+ // нам нужен raw для построения, поэтому postStream не поможет
+
+ 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) => {
+ // очень хакерское и неуклюжее решение, но в attrs нет `.topic`
+ 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)
+
+ # функционал буфера обмена крайне сложно тестировать
+ # нам потребовались бы специальные разрешения в chrome driver для полного доступа
+ # вместо этого мы используем секретную переменную, чтобы сигнализировать о необходимости
+ # сохранения данных буфера обмена в 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)
+
+ # Также тестируем функционал модального окна
+ 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. Эта функция особенно полезна для тех, кто хочет поделиться полученными от взаимодействия с ИИ-ботами инсайтами или информацией с другими пользователями или сохранить их для себя.
Вот подробное описание того, как работает функция и какие изменения были внесены:
-
Компонент модального окна обмена: Добавлен новый компонент Glimmer (ShareModal), предоставляющий интерфейс для выбора диапазона диалога для обмена и копирования его в буфер обмена. Это модальное окно включает ползунок для выбора того, какой объем контекста из диалога включить при обмене.
-
Функциональность копирования диалога: Реализована новая JavaScript-функция (copyConversation) для обработки самого процесса копирования текста диалога. Она формирует диалог, получая необработанные сообщения между указанными номерами постов, и форматирует их в Markdown, включая тег details (<details class='ai-quote'>), чтобы сделать общий контент сворачиваемым.
-
Кнопка в меню поста: Добавлена новая кнопка в меню поста (share), запускающая функцию обмена. Если диалог короткий (до определенного порога), он автоматически копируется в буфер обмена. Для более длинных диалогов открывается модальное окно обмена, позволяющее пользователю выбрать часть диалога для публикации.
-
Стилизация: В файл bot-replies.scss добавлены новые стили SCSS для оформления модального окна обмена и тега details для общих диалогов.
-
Изменения в локализации: Файл client.en.yml обновлен новыми строками локализации для функции обмена, такими как подписи к кнопкам и подсказки.
-
Системный тест: Добавлен новый системный тест (share_spec.rb) для проверки того, что функционал обмена работает корректно.
-
Белый список Markdown: Создан файл ai-tags.js для добавления в белый список пользовательского тега details[class=ai-quote], используемого для общих диалогов, чтобы он разрешался в рендерере Markdown Discourse.
-
Соавторство: Сообщение коммита указывает на то, что Алан Гуо Сян Тан был соавтором некоторых изменений, что свидетельствует о совместной разработке этой функции.
В целом, эта функция улучшает пользовательский опыт, облегчая обмен ценными взаимодействиями с ИИ-ботами, способствуя сотрудничеству и обмену знаниями внутри сообщества Discourse.
sam:
Попробуйте объяснить функцию пользователям форума, ответив на распространенные вопросы о ней.
gpt4_bot:
Объявление о новой функции: Делитесь своими диалогами с ИИ-ботами!
Мы рады сообщить о новой функции, которая позволяет вам легко делиться своими диалогами с ИИ-ботами на нашем форуме. Будь то содержательный, забавный или полезный обмен мнениями с одним из наших ИИ-ботов, теперь вы можете поделиться этим взаимодействием с другими всего в несколько кликов. Вот что вам нужно знать:
Как поделиться диалогом с ИИ-ботом?
При просмотре диалога с ИИ-ботом вы увидите новую иконку «Поделиться» в меню поста. Нажатие на эту иконку предоставит вам два варианта:
-
Для коротких диалогов: Если диалог укладывается в определенную длину (до четырех взаимодействий), нажатие иконки «Поделиться» автоматически скопирует весь диалог в ваш буфер обмена. Затем вы можете вставить его куда угодно — будь то новый пост, личное сообщение или за пределами форума.
-
Для длинных диалогов: Для более протяженных взаимодействий нажатие иконки «Поделиться» откроет модальное окно с предпросмотром вашего диалога. Вы можете использовать ползунок, чтобы выбрать, какую часть диалога вы хотите поделиться. После выбора желаемого диапазона нажмите кнопку «Копировать», чтобы скопировать выбранный диалог в буфер обмена.
Как будет выглядеть общий диалог?
Общий диалог будет отформатирован в виде сворачиваемого блока «details», что делает его удобным для чтения и управления в постах. Он будет включать имена пользователей и содержание сообщений в выбранном вами диапазоне.
Могу ли я делиться диалогами с любым ИИ-ботом?
Да, вы можете делиться диалогами с любым из ИИ-ботов, включенных на нашем форуме. Просто ищите иконку «Поделиться» в меню поста, когда просматриваете диалог.
Есть ли ограничение на объем диалога, которым я могу поделиться?
Жесткого ограничения нет, но мы рекомендуем делиться только релевантными частями диалога, чтобы сохранить краткость и фокус для других читателей.
Где я могу делиться этими диалогами?
Вы можете делиться ими в новом посте форума, в личном сообщении другим пользователям или даже за пределами форума — везде, где можно вставить текст из буфера обмена.
Будет ли в общем диалоге содержаться какая-либо личная информация?
Личная информация в общем тексте не будет содержаться. Будут скопированы только имена пользователей и содержание сообщений.
Что делать, если у меня возникнут проблемы с функцией обмена?
Если вы столкнетесь с какими-либо проблемами или у вас есть отзывы о функции обмена, не стесняйтесь обращаться в нашу службу поддержки или опубликовать сообщение в разделе обратной связи нашего форума. Мы здесь, чтобы помочь, и всегда ищем способы улучшить ваш опыт.
Надеемся, вам понравится эта новая функция, и мы с нетерпением ждем интересных диалогов, которыми вы поделитесь!