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 { cook } from "discourse/lib/text";
|
||||||
import { registerWidgetShim } from "discourse/widgets/render-glimmer";
|
import { registerWidgetShim } from "discourse/widgets/render-glimmer";
|
||||||
import { composeAiBotMessage } from "discourse/plugins/discourse-ai/discourse/lib/ai-bot-helper";
|
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) {
|
function isGPTBot(user) {
|
||||||
return user && [-110, -111, -112, -113].includes(user.id);
|
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 {
|
export default {
|
||||||
name: "discourse-ai-bot-replies",
|
name: "discourse-ai-bot-replies",
|
||||||
|
|
||||||
|
@ -179,6 +220,7 @@ export default {
|
||||||
}
|
}
|
||||||
withPluginApi("1.6.0", initializeAIBotReplies);
|
withPluginApi("1.6.0", initializeAIBotReplies);
|
||||||
withPluginApi("1.6.0", initializePersonaDecorator);
|
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);
|
font-size: var(--font-down-1);
|
||||||
padding-top: 3px;
|
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"
|
cancel_streaming: "Stop reply"
|
||||||
default_pm_prefix: "[Untitled AI bot PM]"
|
default_pm_prefix: "[Untitled AI bot PM]"
|
||||||
shortcut_title: "Start a PM with an AI bot"
|
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:
|
bot_names:
|
||||||
gpt-4: "GPT-4"
|
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