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:
parent
2636efcd1b
commit
933784a873
|
@ -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>
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
export function setup(helper) {
|
||||
helper.allowList(["details[class=ai-quote]"]);
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
Loading…
Reference in New Issue