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>
This commit is contained in:
Sam 2023-12-29 19:47:47 +11:00 committed by GitHub
parent 2636efcd1b
commit 933784a873
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 371 additions and 0 deletions

View File

@ -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>
}

View File

@ -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);
}

View File

@ -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));
}
},
};

View File

@ -0,0 +1,3 @@
export function setup(helper) {
helper.allowList(["details[class=ai-quote]"]);
}

View File

@ -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);
}
}

View File

@ -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"

View File

@ -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