WIP
This commit is contained in:
parent
f75b13c4fa
commit
69d1486639
|
@ -131,6 +131,7 @@ export default class AISuggestionDropdown extends Component {
|
|||
data: { text: this.composer.model.reply },
|
||||
})
|
||||
.then((data) => {
|
||||
console.log(data);
|
||||
this.#assignGeneratedSuggestions(data, this.args.mode);
|
||||
})
|
||||
.catch(popupAjaxError)
|
||||
|
@ -198,6 +199,7 @@ export default class AISuggestionDropdown extends Component {
|
|||
}
|
||||
|
||||
const suggestions = data.assistant.map((s) => s.name);
|
||||
// console.log("suggest", suggestions);
|
||||
|
||||
if (mode === this.SUGGESTION_TYPES.tag) {
|
||||
if (this.#tagSelectorHasValues()) {
|
||||
|
|
|
@ -0,0 +1,142 @@
|
|||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { fn } from "@ember/helper";
|
||||
import { on } from "@ember/modifier";
|
||||
import { action } from "@ember/object";
|
||||
import { inject as service } from "@ember/service";
|
||||
import DButton from "discourse/components/d-button";
|
||||
import DropdownMenu from "discourse/components/dropdown-menu";
|
||||
import categoryBadge from "discourse/helpers/category-badge";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
import i18n from "discourse-common/helpers/i18n";
|
||||
import DMenu from "float-kit/components/d-menu";
|
||||
|
||||
export default class AiCategorySuggester extends Component {
|
||||
@service siteSettings;
|
||||
@tracked loading = false;
|
||||
@tracked suggestions = null;
|
||||
@tracked untriggers = [];
|
||||
@tracked triggerIcon = "discourse-sparkles";
|
||||
|
||||
get referenceText() {
|
||||
if (this.args.composer?.reply) {
|
||||
return this.args.composer.reply;
|
||||
}
|
||||
|
||||
console.log(this.args);
|
||||
ajax(`/raw/${this.args.topic.id}/1.json`).then((response) => {
|
||||
console.log(response);
|
||||
});
|
||||
|
||||
return "abcdefhg";
|
||||
}
|
||||
|
||||
get showSuggestionButton() {
|
||||
const MIN_CHARACTER_COUNT = 40;
|
||||
const composerFields = document.querySelector(".composer-fields");
|
||||
const showTrigger = this.referenceText.length > MIN_CHARACTER_COUNT;
|
||||
|
||||
if (composerFields) {
|
||||
if (showTrigger) {
|
||||
composerFields.classList.add("showing-ai-suggestions");
|
||||
} else {
|
||||
composerFields.classList.remove("showing-ai-suggestions");
|
||||
}
|
||||
}
|
||||
|
||||
return this.siteSettings.ai_embeddings_enabled && showTrigger;
|
||||
}
|
||||
|
||||
@action
|
||||
async loadSuggestions() {
|
||||
if (this.suggestions && !this.dMenu.expanded) {
|
||||
return this.suggestions;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
this.triggerIcon = "spinner";
|
||||
|
||||
try {
|
||||
const { assistant } = await ajax(
|
||||
"/discourse-ai/ai-helper/suggest_category",
|
||||
{
|
||||
method: "POST",
|
||||
data: { text: this.args.composer.reply },
|
||||
}
|
||||
);
|
||||
this.suggestions = assistant;
|
||||
} catch (error) {
|
||||
popupAjaxError(error);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
this.triggerIcon = "sync-alt";
|
||||
}
|
||||
|
||||
return this.suggestions;
|
||||
}
|
||||
|
||||
@action
|
||||
applySuggestion(suggestion) {
|
||||
const composer = this.args.composer;
|
||||
if (!composer) {
|
||||
return;
|
||||
}
|
||||
|
||||
composer.set("categoryId", suggestion.id);
|
||||
this.dMenu.close();
|
||||
}
|
||||
|
||||
@action
|
||||
onRegisterApi(api) {
|
||||
this.dMenu = api;
|
||||
}
|
||||
|
||||
@action
|
||||
onClose() {
|
||||
this.triggerIcon = "discourse-sparkles";
|
||||
}
|
||||
|
||||
<template>
|
||||
{{#if this.showSuggestionButton}}
|
||||
<DMenu
|
||||
@title={{i18n "discourse_ai.ai_helper.suggest"}}
|
||||
@icon={{this.triggerIcon}}
|
||||
@identifier="ai-category-suggester"
|
||||
@onClose={{this.onClose}}
|
||||
@triggerClass="suggestion-button suggest-category-button {{if
|
||||
this.loading
|
||||
'is-loading'
|
||||
}}"
|
||||
@onRegisterApi={{this.onRegisterApi}}
|
||||
@modalForMobile={{true}}
|
||||
@untriggers={{this.untriggers}}
|
||||
{{on "click" this.loadSuggestions}}
|
||||
>
|
||||
<:content>
|
||||
{{#unless this.loading}}
|
||||
<DropdownMenu as |dropdown|>
|
||||
{{#each this.suggestions as |suggestion|}}
|
||||
<dropdown.item>
|
||||
<DButton
|
||||
class="category-row"
|
||||
data-title={{suggestion.name}}
|
||||
data-value={{suggestion.id}}
|
||||
title={{suggestion.name}}
|
||||
@action={{fn this.applySuggestion suggestion}}
|
||||
>
|
||||
<div class="category-status">
|
||||
{{categoryBadge suggestion}}
|
||||
<span class="topic-count" aria-label="">x
|
||||
{{suggestion.topicCount}}</span>
|
||||
</div>
|
||||
</DButton>
|
||||
</dropdown.item>
|
||||
{{/each}}
|
||||
</DropdownMenu>
|
||||
{{/unless}}
|
||||
</:content>
|
||||
</DMenu>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
|
@ -1,6 +1,5 @@
|
|||
import Component from "@glimmer/component";
|
||||
import { inject as service } from "@ember/service";
|
||||
import AISuggestionDropdown from "../../components/ai-suggestion-dropdown";
|
||||
import AiCategorySuggester from "../../components/suggestion-menus/ai-category-suggester";
|
||||
import { showComposerAiHelper } from "../../lib/show-ai-helper";
|
||||
|
||||
export default class AiCategorySuggestion extends Component {
|
||||
|
@ -13,15 +12,7 @@ export default class AiCategorySuggestion extends Component {
|
|||
);
|
||||
}
|
||||
|
||||
@service siteSettings;
|
||||
|
||||
<template>
|
||||
{{#if this.siteSettings.ai_embeddings_enabled}}
|
||||
<AISuggestionDropdown
|
||||
@mode="suggest_category"
|
||||
@composer={{@outletArgs.composer}}
|
||||
class="suggest-category-button"
|
||||
/>
|
||||
{{/if}}
|
||||
<AiCategorySuggester @composer={{@outletArgs.composer}} />
|
||||
</template>
|
||||
}
|
||||
|
|
|
@ -1,6 +1,16 @@
|
|||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { fn } from "@ember/helper";
|
||||
import { on } from "@ember/modifier";
|
||||
import { action } from "@ember/object";
|
||||
import { inject as service } from "@ember/service";
|
||||
import AISuggestionDropdown from "../../components/ai-suggestion-dropdown";
|
||||
import DButton from "discourse/components/d-button";
|
||||
import DropdownMenu from "discourse/components/dropdown-menu";
|
||||
import discourseTag from "discourse/helpers/discourse-tag";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
import i18n from "discourse-common/helpers/i18n";
|
||||
import DMenu from "float-kit/components/d-menu";
|
||||
import { showComposerAiHelper } from "../../lib/show-ai-helper";
|
||||
|
||||
export default class AiTagSuggestion extends Component {
|
||||
|
@ -14,14 +24,188 @@ export default class AiTagSuggestion extends Component {
|
|||
}
|
||||
|
||||
@service siteSettings;
|
||||
@service toasts;
|
||||
@tracked loading = false;
|
||||
@tracked suggestions = null;
|
||||
@tracked untriggers = [];
|
||||
@tracked triggerIcon = "discourse-sparkles";
|
||||
|
||||
get showSuggestionButton() {
|
||||
const MIN_CHARACTER_COUNT = 40;
|
||||
const composerFields = document.querySelector(".composer-fields");
|
||||
const showTrigger =
|
||||
this.args.outletArgs.composer.reply?.length > MIN_CHARACTER_COUNT;
|
||||
|
||||
if (composerFields) {
|
||||
if (showTrigger) {
|
||||
composerFields.classList.add("showing-ai-suggestions");
|
||||
} else {
|
||||
composerFields.classList.remove("showing-ai-suggestions");
|
||||
}
|
||||
}
|
||||
|
||||
return this.siteSettings.ai_embeddings_enabled && showTrigger;
|
||||
}
|
||||
|
||||
get showDropdown() {
|
||||
if (this.suggestions?.length <= 0) {
|
||||
this.dMenu.close();
|
||||
}
|
||||
return !this.loading && this.suggestions?.length > 0;
|
||||
}
|
||||
|
||||
@action
|
||||
async loadSuggestions() {
|
||||
if (
|
||||
this.suggestions &&
|
||||
this.suggestions?.length > 0 &&
|
||||
!this.dMenu.expanded
|
||||
) {
|
||||
return this.suggestions;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
this.triggerIcon = "spinner";
|
||||
|
||||
try {
|
||||
const { assistant } = await ajax("/discourse-ai/ai-helper/suggest_tags", {
|
||||
method: "POST",
|
||||
data: { text: this.args.outletArgs.composer.reply },
|
||||
});
|
||||
this.suggestions = assistant;
|
||||
|
||||
if (this.#tagSelectorHasValues()) {
|
||||
this.suggestions = this.suggestions.filter(
|
||||
(s) => !this.args.outletArgs.composer.tags.includes(s.name)
|
||||
);
|
||||
}
|
||||
|
||||
if (this.suggestions?.length <= 0) {
|
||||
this.toasts.error({
|
||||
class: "ai-suggestion-error",
|
||||
duration: 3000,
|
||||
data: {
|
||||
message: i18n(
|
||||
"discourse_ai.ai_helper.suggest_errors.no_suggestions"
|
||||
),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
popupAjaxError(error);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
this.triggerIcon = "sync-alt";
|
||||
}
|
||||
|
||||
return this.suggestions;
|
||||
}
|
||||
|
||||
#tagSelectorHasValues() {
|
||||
return (
|
||||
this.args.outletArgs.composer?.tags &&
|
||||
this.args.outletArgs.composer?.tags.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
#removedAppliedTag(suggestion) {
|
||||
return (this.suggestions = this.suggestions.filter(
|
||||
(s) => s.id !== suggestion.id
|
||||
));
|
||||
}
|
||||
|
||||
@action
|
||||
applySuggestion(suggestion) {
|
||||
const maxTags = this.siteSettings.max_tags_per_topic;
|
||||
const composer = this.args.outletArgs.composer;
|
||||
if (!composer) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!composer.tags) {
|
||||
composer.set("tags", [suggestion.name]);
|
||||
this.#removedAppliedTag(suggestion);
|
||||
return;
|
||||
}
|
||||
|
||||
const tags = composer.tags;
|
||||
|
||||
if (tags?.length >= maxTags) {
|
||||
return this.toasts.error({
|
||||
class: "ai-suggestion-error",
|
||||
duration: 3000,
|
||||
data: {
|
||||
message: i18n("discourse_ai.ai_helper.suggest_errors.too_many_tags", {
|
||||
count: maxTags,
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
tags.push(suggestion.name);
|
||||
composer.set("tags", [...tags]);
|
||||
suggestion.disabled = true;
|
||||
this.#removedAppliedTag(suggestion);
|
||||
}
|
||||
|
||||
@action
|
||||
onRegisterApi(api) {
|
||||
this.dMenu = api;
|
||||
}
|
||||
|
||||
@action
|
||||
onClose() {
|
||||
if (this.suggestions?.length > 0) {
|
||||
// If all suggestions have been used,
|
||||
// re-triggering when no suggestions present
|
||||
// will cause computation issues with
|
||||
// setting the icon, so we prevent it
|
||||
this.triggerIcon = "discourse-sparkles";
|
||||
}
|
||||
}
|
||||
|
||||
<template>
|
||||
{{#if this.siteSettings.ai_embeddings_enabled}}
|
||||
<AISuggestionDropdown
|
||||
@mode="suggest_tags"
|
||||
@composer={{@outletArgs.composer}}
|
||||
class="suggest-tags-button"
|
||||
/>
|
||||
{{#if this.showSuggestionButton}}
|
||||
<DMenu
|
||||
@title={{i18n "discourse_ai.ai_helper.suggest"}}
|
||||
@icon={{this.triggerIcon}}
|
||||
@identifier="ai-tag-suggester"
|
||||
@onClose={{this.onClose}}
|
||||
@triggerClass="suggestion-button suggest-tags-button {{if
|
||||
this.loading
|
||||
'is-loading'
|
||||
}}"
|
||||
@onRegisterApi={{this.onRegisterApi}}
|
||||
@modalForMobile={{true}}
|
||||
@untriggers={{this.untriggers}}
|
||||
{{on "click" this.loadSuggestions}}
|
||||
>
|
||||
<:content>
|
||||
{{#if this.showDropdown}}
|
||||
<DropdownMenu as |dropdown|>
|
||||
{{#each this.suggestions as |suggestion|}}
|
||||
<dropdown.item>
|
||||
<DButton
|
||||
class="tag-row"
|
||||
data-title={{suggestion.name}}
|
||||
data-value={{suggestion.id}}
|
||||
title={{suggestion.name}}
|
||||
@disabled={{this.isDisabled suggestion}}
|
||||
@action={{fn this.applySuggestion suggestion}}
|
||||
>
|
||||
{{discourseTag
|
||||
suggestion.name
|
||||
count=suggestion.count
|
||||
noHref=true
|
||||
}}
|
||||
</DButton>
|
||||
</dropdown.item>
|
||||
{{/each}}
|
||||
</DropdownMenu>
|
||||
{{/if}}
|
||||
</:content>
|
||||
</DMenu>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
import Component from "@glimmer/component";
|
||||
import AiCategorySuggester from "../../components/suggestion-menus/ai-category-suggester";
|
||||
import { showComposerAiHelper } from "../../lib/show-ai-helper";
|
||||
|
||||
export default class AiCategorySuggestion extends Component {
|
||||
static shouldRender(outletArgs, helper) {
|
||||
return showComposerAiHelper(
|
||||
outletArgs?.composer,
|
||||
helper.siteSettings,
|
||||
helper.currentUser,
|
||||
"suggestions"
|
||||
);
|
||||
}
|
||||
|
||||
<template>
|
||||
{{log @outletArgs}}
|
||||
<AiCategorySuggester
|
||||
@composer={{@outletArgs.topic.category}}
|
||||
@topic={{@outletArgs.topic}}
|
||||
@buffered={{@outletArgs.buffered}}
|
||||
/>
|
||||
</template>
|
||||
}
|
|
@ -228,25 +228,32 @@
|
|||
border-bottom-left-radius: 0;
|
||||
}
|
||||
|
||||
.ai-suggestions-menu {
|
||||
list-style: none;
|
||||
margin-left: 0;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 1.5rem;
|
||||
max-width: 25rem;
|
||||
width: unset;
|
||||
z-index: 999;
|
||||
.ai-category-suggester-content,
|
||||
.ai-tag-suggester-content {
|
||||
z-index: z("composer", "dropdown");
|
||||
}
|
||||
|
||||
&__errors {
|
||||
background: var(--danger);
|
||||
padding: 0.25rem 1em;
|
||||
color: var(--secondary);
|
||||
.ai-category-suggester-content {
|
||||
.category-row {
|
||||
padding: 0.25em 0.5em;
|
||||
color: var(--primary-high);
|
||||
|
||||
&:hover {
|
||||
background: var(--d-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.category-input.showing-ai-suggestion-menu {
|
||||
position: relative;
|
||||
.topic-count {
|
||||
font-size: var(--font-down-2);
|
||||
}
|
||||
}
|
||||
|
||||
.ai-tag-suggester-content {
|
||||
.tag-row {
|
||||
.discourse-tag-count {
|
||||
margin-left: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent suggestion button from wrapping
|
||||
|
|
|
@ -340,6 +340,11 @@ en:
|
|||
description: "Choose one of the options below, and the AI will suggest you a new version of the text."
|
||||
selection_hint: "Hint: You can also select a portion of the text before opening the helper to rewrite only that."
|
||||
suggest: "Suggest with AI"
|
||||
suggest_errors:
|
||||
too_many_tags:
|
||||
one: "You can only have up to %{count} tag"
|
||||
other: "You can only have up to %{count} tags"
|
||||
no_suggestions: "No suggestions available"
|
||||
missing_content: "Please enter some content to generate suggestions."
|
||||
context_menu:
|
||||
trigger: "Ask AI"
|
||||
|
|
|
@ -19,15 +19,30 @@ module DiscourseAi
|
|||
.where(id: candidate_ids)
|
||||
.where("categories.id IN (?)", Category.topic_create_allowed(@user.guardian).pluck(:id))
|
||||
.order("array_position(ARRAY#{candidate_ids}, topics.id)")
|
||||
.pluck("categories.slug")
|
||||
.pluck(
|
||||
"categories.id",
|
||||
"categories.name",
|
||||
"categories.slug",
|
||||
"categories.color",
|
||||
"categories.topic_count",
|
||||
)
|
||||
.map
|
||||
.with_index { |category, index| { name: category, score: candidates[index].last } }
|
||||
.with_index do |(id, name, slug, color, topic_count), index|
|
||||
{
|
||||
id: id,
|
||||
name: name,
|
||||
slug: slug,
|
||||
color: color,
|
||||
topicCount: topic_count,
|
||||
score: candidates[index].last,
|
||||
}
|
||||
end
|
||||
.map do |c|
|
||||
c[:score] = 1 / (c[:score] + 1) # inverse of the distance
|
||||
c
|
||||
end
|
||||
.group_by { |c| c[:name] }
|
||||
.map { |name, scores| { name: name, score: scores.sum { |s| s[:score] } } }
|
||||
.map { |name, scores| scores.first.merge(score: scores.sum { |s| s[:score] }) }
|
||||
.sort_by { |c| -c[:score] }
|
||||
.take(5)
|
||||
end
|
||||
|
@ -39,24 +54,35 @@ module DiscourseAi
|
|||
candidates = nearest_neighbors(limit: 100)
|
||||
candidate_ids = candidates.map(&:first)
|
||||
|
||||
count_column = Tag.topic_count_column(@user.guardian) # Determine the count column
|
||||
|
||||
::Topic
|
||||
.joins(:topic_tags, :tags)
|
||||
.where(id: candidate_ids)
|
||||
.where("tags.id IN (?)", DiscourseTagging.visible_tags(@user.guardian).pluck(:id))
|
||||
.group("topics.id")
|
||||
.group("topics.id, tags.id, tags.name") # Group by topics.id and tags.id
|
||||
.order("array_position(ARRAY#{candidate_ids}, topics.id)")
|
||||
.pluck("array_agg(tags.name)")
|
||||
.map(&:uniq)
|
||||
.pluck(
|
||||
"tags.id",
|
||||
"tags.name",
|
||||
"tags.#{count_column}",
|
||||
"MIN(array_position(ARRAY#{candidate_ids}, topics.id))", # Get minimum index for ordering
|
||||
)
|
||||
.uniq # Ensure unique tags per topic
|
||||
.map
|
||||
.with_index { |tag_list, index| { tags: tag_list, score: candidates[index].last } }
|
||||
.flat_map { |c| c[:tags].map { |t| { name: t, score: c[:score] } } }
|
||||
.map do |c|
|
||||
c[:score] = 1 / (c[:score] + 1) # inverse of the distance
|
||||
c
|
||||
.with_index do |(id, name, count, index), idx|
|
||||
{
|
||||
id: id,
|
||||
name: name,
|
||||
count: count,
|
||||
score: 1 / (candidates[idx].last + 1), # Inverse of the distance for score
|
||||
}
|
||||
end
|
||||
.group_by { |c| c[:name] }
|
||||
.map { |name, scores| { name: name, score: scores.sum { |s| s[:score] } } }
|
||||
.sort_by { |c| -c[:score] }
|
||||
.group_by { |tag| tag[:name] }
|
||||
.map do |name, tags|
|
||||
tags.first.merge(score: tags.sum { |t| t[:score] })
|
||||
end # Aggregate scores per tag
|
||||
.sort_by { |tag| -tag[:score] }
|
||||
.take(5)
|
||||
end
|
||||
|
||||
|
|
Loading…
Reference in New Issue