FEATURE: Generate post illustrations (#367)

This commit is contained in:
Keegan George 2023-12-19 11:17:34 -08:00 committed by GitHub
parent 529703b5ec
commit 7b4710d5c9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 194 additions and 17 deletions

View File

@ -32,6 +32,8 @@ module DiscourseAi
prompt.custom_instruction = params[:custom_prompt] prompt.custom_instruction = params[:custom_prompt]
end end
suggest_thumbnails(input) if prompt.id == CompletionPrompt::ILLUSTRATE_POST
hijack do hijack do
render json: render json:
DiscourseAi::AiHelper::Assistant.new.generate_and_send_prompt( DiscourseAi::AiHelper::Assistant.new.generate_and_send_prompt(
@ -86,9 +88,7 @@ module DiscourseAi
status: 200 status: 200
end end
def suggest_thumbnails def suggest_thumbnails(input)
input = get_text_param!
hijack do hijack do
thumbnails = DiscourseAi::AiHelper::Painter.new.commission_thumbnails(input, current_user) thumbnails = DiscourseAi::AiHelper::Painter.new.commission_thumbnails(input, current_user)

View File

@ -10,6 +10,7 @@ class CompletionPrompt < ActiveRecord::Base
MARKDOWN_TABLE = -304 MARKDOWN_TABLE = -304
CUSTOM_PROMPT = -305 CUSTOM_PROMPT = -305
EXPLAIN = -306 EXPLAIN = -306
ILLUSTRATE_POST = -308
enum :prompt_type, { text: 0, list: 1, diff: 2 } enum :prompt_type, { text: 0, list: 1, diff: 2 }

View File

@ -0,0 +1,75 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import DButton from "discourse/components/d-button";
import DModal from "discourse/components/d-modal";
import DModalCancel from "discourse/components/d-modal-cancel";
import i18n from "discourse-common/helpers/i18n";
import ThumbnailSuggestionItem from "../thumbnail-suggestion-item";
export default class ThumbnailSuggestions extends Component {
@tracked selectedImages = [];
get isDisabled() {
return this.selectedImages.length === 0;
}
@action
addSelection(selection) {
const thumbnailMarkdown = `![${selection.original_filename}|${selection.width}x${selection.height}](${selection.short_url})`;
this.selectedImages = [...this.selectedImages, thumbnailMarkdown];
}
@action
removeSelection(selection) {
const thumbnailMarkdown = `![${selection.original_filename}|${selection.width}x${selection.height}](${selection.short_url})`;
this.selectedImages = this.selectedImages.filter((thumbnail) => {
if (thumbnail !== thumbnailMarkdown) {
return thumbnail;
}
});
}
@action
appendSelectedImages() {
const composerValue = this.args.composer?.reply || "";
const newValue = composerValue.concat(
"\n\n",
this.selectedImages.join("\n")
);
this.args.composer.set("reply", newValue);
this.args.closeModal();
}
<template>
<DModal
class="thumbnail-suggestions-modal"
@title={{i18n "discourse_ai.ai_helper.thumbnail_suggestions.title"}}
@closeModal={{@closeModal}}
>
<:body>
<div class="ai-thumbnail-suggestions">
{{#each @thumbnails as |thumbnail|}}
<ThumbnailSuggestionItem
@thumbnail={{thumbnail}}
@addSelection={{this.addSelection}}
@removeSelection={{this.removeSelection}}
/>
{{/each}}
</div>
</:body>
<:footer>
<DButton
@action={{this.appendSelectedImages}}
@label="save"
@disabled={{this.isDisabled}}
class="btn-primary create"
/>
<DModalCancel @close={{@closeModal}} />
</:footer>
</DModal>
</template>
}

View File

@ -0,0 +1,43 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import DButton from "discourse/components/d-button";
export default class ThumbnailSuggestionItem extends Component {
@tracked selected = false;
@tracked selectIcon = "far-circle";
@tracked selectLabel = "discourse_ai.ai_helper.thumbnail_suggestions.select";
@action
toggleSelection(thumbnail) {
if (this.selected) {
this.selectIcon = "far-circle";
this.selectLabel = "discourse_ai.ai_helper.thumbnail_suggestions.select";
this.selected = false;
return this.args.removeSelection(thumbnail);
}
this.selectIcon = "check-circle";
this.selectLabel = "discourse_ai.ai_helper.thumbnail_suggestions.selected";
this.selected = true;
return this.args.addSelection(thumbnail);
}
<template>
<div class="ai-thumbnail-suggestions__item">
<DButton
class={{if this.selected "btn-primary" ""}}
@icon={{this.selectIcon}}
@label={{this.selectLabel}}
@action={{this.toggleSelection}}
@actionParam={{@thumbnail}}
/>
<img
src={{@thumbnail.url}}
loading="lazy"
width={{@thumbnail.thumbnail_width}}
height={{@thumbnail.thumbnail_height}}
/>
</div>
</template>
}

View File

@ -101,4 +101,12 @@
@revert={{this.undoAIAction}} @revert={{this.undoAIAction}}
@closeModal={{fn (mut this.showDiffModal) false}} @closeModal={{fn (mut this.showDiffModal) false}}
/> />
{{/if}}
{{#if this.showThumbnailModal}}
<Modal::ThumbnailSuggestions
@composer={{@outletArgs.composer}}
@thumbnails={{this.thumbnailSuggestions}}
@closeModal={{fn (mut this.showThumbnailModal) false}}
/>
{{/if}} {{/if}}

View File

@ -17,6 +17,7 @@ export default class AiHelperContextMenu extends Component {
@service currentUser; @service currentUser;
@service siteSettings; @service siteSettings;
@service modal;
@tracked helperOptions = []; @tracked helperOptions = [];
@tracked showContextMenu = false; @tracked showContextMenu = false;
@tracked caretCoords; @tracked caretCoords;
@ -28,6 +29,7 @@ export default class AiHelperContextMenu extends Component {
@tracked newEditorValue; @tracked newEditorValue;
@tracked lastUsedOption = null; @tracked lastUsedOption = null;
@tracked showDiffModal = false; @tracked showDiffModal = false;
@tracked showThumbnailModal = false;
@tracked diff; @tracked diff;
@tracked popperPlacement = "top-start"; @tracked popperPlacement = "top-start";
@tracked previousMenuState = null; @tracked previousMenuState = null;
@ -361,7 +363,15 @@ export default class AiHelperContextMenu extends Component {
// resets the values if new suggestion is started: // resets the values if new suggestion is started:
this.diff = null; this.diff = null;
this.newSelectedText = null; this.newSelectedText = null;
this._updateSuggestedByAI(data);
if (option.name === "illustrate_post") {
this._toggleLoadingState(false);
this.closeContextMenu();
this.showThumbnailModal = true;
this.thumbnailSuggestions = data.thumbnails;
} else {
this._updateSuggestedByAI(data);
}
}) })
.catch(popupAjaxError) .catch(popupAjaxError)
.finally(() => { .finally(() => {

View File

@ -432,3 +432,28 @@
display: none; display: none;
} }
} }
.thumbnail-suggestions-modal {
.ai-thumbnail-suggestions {
display: flex;
flex-flow: row wrap;
position: relative;
gap: 0.5em;
&__item {
flex: 35%;
position: relative;
}
img {
width: 100%;
height: auto;
}
.btn {
position: absolute;
top: 0.5rem;
left: 0.5rem;
}
}
}

View File

@ -153,6 +153,10 @@ en:
cancel: "Cancel" cancel: "Cancel"
fast_edit: fast_edit:
suggest_button: "Suggest Edit" suggest_button: "Suggest Edit"
thumbnail_suggestions:
title: "Suggested Thumbnails"
select: "Select"
selected: "Selected"
reviewables: reviewables:
model_used: "Model used:" model_used: "Model used:"
accuracy: "Accuracy:" accuracy: "Accuracy:"
@ -186,8 +190,6 @@ en:
label: "sentiment" label: "sentiment"
title: "Experimental AI-powered sentiment analysis of this person's most recent posts." title: "Experimental AI-powered sentiment analysis of this person's most recent posts."
review: review:
types: types:
reviewable_ai_post: reviewable_ai_post:

View File

@ -128,6 +128,7 @@ en:
markdown_table: Generate Markdown table markdown_table: Generate Markdown table
custom_prompt: "Custom Prompt" custom_prompt: "Custom Prompt"
explain: "Explain" explain: "Explain"
illustrate_post: "Illustrate Post"
ai_bot: ai_bot:
personas: personas:

View File

@ -7,7 +7,6 @@ DiscourseAi::Engine.routes.draw do
post "suggest_title" => "assistant#suggest_title" post "suggest_title" => "assistant#suggest_title"
post "suggest_category" => "assistant#suggest_category" post "suggest_category" => "assistant#suggest_category"
post "suggest_tags" => "assistant#suggest_tags" post "suggest_tags" => "assistant#suggest_tags"
post "suggest_thumbnails" => "assistant#suggest_thumbnails"
post "explain" => "assistant#explain" post "explain" => "assistant#explain"
end end

View File

@ -47,9 +47,9 @@ CompletionPrompt.seed do |cp|
<input> <input>
Intensity 1: Intensity 1:
Hello, Hello,
Sometimes the logo isn't changing automatically when color scheme changes. Sometimes the logo isn't changing automatically when color scheme changes.
![Screen Recording 2023-03-17 at 18.04.22|video](upload://2rcVL0ZMxHPNtPWQbZjwufKpWVU.mov) ![Screen Recording 2023-03-17 at 18.04.22|video](upload://2rcVL0ZMxHPNtPWQbZjwufKpWVU.mov)
</input> </input>
TEXT TEXT
@ -139,13 +139,13 @@ CompletionPrompt.seed do |cp|
cp.prompt_type = CompletionPrompt.prompt_types[:text] cp.prompt_type = CompletionPrompt.prompt_types[:text]
cp.messages = { insts: <<~TEXT } cp.messages = { insts: <<~TEXT }
You are a tutor explaining a term to a student in a specific context. You are a tutor explaining a term to a student in a specific context.
I will provide everything you need to know inside <input> tags, which consists of the term I want you I will provide everything you need to know inside <input> tags, which consists of the term I want you
to explain inside <term> tags, the context of where it was used inside <context> tags, the title of to explain inside <term> tags, the context of where it was used inside <context> tags, the title of
the topic where it was used inside <topic> tags, and optionally, the previous post in the conversation the topic where it was used inside <topic> tags, and optionally, the previous post in the conversation
in <replyTo> tags. in <replyTo> tags.
Using all this information, write a paragraph with a brief explanation Using all this information, write a paragraph with a brief explanation
of what the term means. Format the response using Markdown. Reply only with the explanation and of what the term means. Format the response using Markdown. Reply only with the explanation and
nothing more. nothing more.
TEXT TEXT
@ -173,3 +173,10 @@ CompletionPrompt.seed do |cp|
post_insts: "Wrap each title between <item></item> XML tags.", post_insts: "Wrap each title between <item></item> XML tags.",
} }
end end
CompletionPrompt.seed do |cp|
cp.id = -308
cp.name = "illustrate_post"
cp.prompt_type = CompletionPrompt.prompt_types[:list]
cp.messages = {}
end

View File

@ -114,6 +114,8 @@ module DiscourseAi
"pen" "pen"
when "explain" when "explain"
"question" "question"
when "illustrate_post"
"images"
else else
nil nil
end end
@ -139,6 +141,8 @@ module DiscourseAi
%w[post] %w[post]
when "summarize" when "summarize"
%w[post] %w[post]
when "illustrate_posts"
%w[composer]
else else
%w[composer post] %w[composer post]
end end

View File

@ -20,10 +20,10 @@ module DiscourseAi
f.binmode f.binmode
f.write(Base64.decode64(artifact)) f.write(Base64.decode64(artifact))
f.rewind f.rewind
upload = UploadCreator.new(f, "ai_helper_image.png").create_for(user.id) upload = UploadCreator.new(f, "ai_helper_image_#{i}.png").create_for(user.id)
f.unlink f.unlink
upload.short_url UploadSerializer.new(upload, root: false)
end end
end end

View File

@ -40,7 +40,9 @@ RSpec.describe DiscourseAi::AiHelper::Painter do
thumbnail_urls = Upload.last(4).map(&:short_url) thumbnail_urls = Upload.last(4).map(&:short_url)
expect(thumbnails).to contain_exactly(*thumbnail_urls) expect(thumbnails.map { |upload_serializer| upload_serializer.short_url }).to contain_exactly(
*thumbnail_urls,
)
end end
end end
end end

View File

@ -143,7 +143,7 @@ RSpec.describe DiscourseAi::AiHelper::AssistantController do
it "returns a list of prompts when no name_filter is provided" do it "returns a list of prompts when no name_filter is provided" do
get "/discourse-ai/ai-helper/prompts" get "/discourse-ai/ai-helper/prompts"
expect(response.status).to eq(200) expect(response.status).to eq(200)
expect(response.parsed_body.length).to eq(6) expect(response.parsed_body.length).to eq(7)
end end
it "returns a list with with filtered prompts when name_filter is provided" do it "returns a list with with filtered prompts when name_filter is provided" do