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

@ -102,3 +102,11 @@
@closeModal={{fn (mut this.showDiffModal) false}} @closeModal={{fn (mut this.showDiffModal) false}}
/> />
{{/if}} {{/if}}
{{#if this.showThumbnailModal}}
<Modal::ThumbnailSuggestions
@composer={{@outletArgs.composer}}
@thumbnails={{this.thumbnailSuggestions}}
@closeModal={{fn (mut this.showThumbnailModal) false}}
/>
{{/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

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