This commit is contained in:
Keegan George 2024-11-14 10:04:07 -08:00
parent f75b13c4fa
commit 69d1486639
No known key found for this signature in database
GPG Key ID: 91B40E38537AC000
8 changed files with 427 additions and 47 deletions

View File

@ -131,6 +131,7 @@ export default class AISuggestionDropdown extends Component {
data: { text: this.composer.model.reply }, data: { text: this.composer.model.reply },
}) })
.then((data) => { .then((data) => {
console.log(data);
this.#assignGeneratedSuggestions(data, this.args.mode); this.#assignGeneratedSuggestions(data, this.args.mode);
}) })
.catch(popupAjaxError) .catch(popupAjaxError)
@ -198,6 +199,7 @@ export default class AISuggestionDropdown extends Component {
} }
const suggestions = data.assistant.map((s) => s.name); const suggestions = data.assistant.map((s) => s.name);
// console.log("suggest", suggestions);
if (mode === this.SUGGESTION_TYPES.tag) { if (mode === this.SUGGESTION_TYPES.tag) {
if (this.#tagSelectorHasValues()) { if (this.#tagSelectorHasValues()) {

View File

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

View File

@ -1,6 +1,5 @@
import Component from "@glimmer/component"; import Component from "@glimmer/component";
import { inject as service } from "@ember/service"; import AiCategorySuggester from "../../components/suggestion-menus/ai-category-suggester";
import AISuggestionDropdown from "../../components/ai-suggestion-dropdown";
import { showComposerAiHelper } from "../../lib/show-ai-helper"; import { showComposerAiHelper } from "../../lib/show-ai-helper";
export default class AiCategorySuggestion extends Component { export default class AiCategorySuggestion extends Component {
@ -13,15 +12,7 @@ export default class AiCategorySuggestion extends Component {
); );
} }
@service siteSettings;
<template> <template>
{{#if this.siteSettings.ai_embeddings_enabled}} <AiCategorySuggester @composer={{@outletArgs.composer}} />
<AISuggestionDropdown
@mode="suggest_category"
@composer={{@outletArgs.composer}}
class="suggest-category-button"
/>
{{/if}}
</template> </template>
} }

View File

@ -1,6 +1,16 @@
import Component from "@glimmer/component"; 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 { 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"; import { showComposerAiHelper } from "../../lib/show-ai-helper";
export default class AiTagSuggestion extends Component { export default class AiTagSuggestion extends Component {
@ -14,14 +24,188 @@ export default class AiTagSuggestion extends Component {
} }
@service siteSettings; @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> <template>
{{#if this.siteSettings.ai_embeddings_enabled}} {{#if this.showSuggestionButton}}
<AISuggestionDropdown <DMenu
@mode="suggest_tags" @title={{i18n "discourse_ai.ai_helper.suggest"}}
@composer={{@outletArgs.composer}} @icon={{this.triggerIcon}}
class="suggest-tags-button" @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}} {{/if}}
</template> </template>
} }

View File

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

View File

@ -228,25 +228,32 @@
border-bottom-left-radius: 0; border-bottom-left-radius: 0;
} }
.ai-suggestions-menu { .ai-category-suggester-content,
list-style: none; .ai-tag-suggester-content {
margin-left: 0; z-index: z("composer", "dropdown");
position: absolute; }
right: 0;
top: 1.5rem;
max-width: 25rem;
width: unset;
z-index: 999;
&__errors { .ai-category-suggester-content {
background: var(--danger); .category-row {
padding: 0.25rem 1em; padding: 0.25em 0.5em;
color: var(--secondary); color: var(--primary-high);
&:hover {
background: var(--d-hover);
} }
} }
.category-input.showing-ai-suggestion-menu { .topic-count {
position: relative; font-size: var(--font-down-2);
}
}
.ai-tag-suggester-content {
.tag-row {
.discourse-tag-count {
margin-left: 5px;
}
}
} }
// Prevent suggestion button from wrapping // Prevent suggestion button from wrapping

View File

@ -340,6 +340,11 @@ en:
description: "Choose one of the options below, and the AI will suggest you a new version of the text." 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." 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: "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." missing_content: "Please enter some content to generate suggestions."
context_menu: context_menu:
trigger: "Ask AI" trigger: "Ask AI"

View File

@ -19,15 +19,30 @@ module DiscourseAi
.where(id: candidate_ids) .where(id: candidate_ids)
.where("categories.id IN (?)", Category.topic_create_allowed(@user.guardian).pluck(:id)) .where("categories.id IN (?)", Category.topic_create_allowed(@user.guardian).pluck(:id))
.order("array_position(ARRAY#{candidate_ids}, topics.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 .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| .map do |c|
c[:score] = 1 / (c[:score] + 1) # inverse of the distance c[:score] = 1 / (c[:score] + 1) # inverse of the distance
c c
end end
.group_by { |c| c[:name] } .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] } .sort_by { |c| -c[:score] }
.take(5) .take(5)
end end
@ -39,24 +54,35 @@ module DiscourseAi
candidates = nearest_neighbors(limit: 100) candidates = nearest_neighbors(limit: 100)
candidate_ids = candidates.map(&:first) candidate_ids = candidates.map(&:first)
count_column = Tag.topic_count_column(@user.guardian) # Determine the count column
::Topic ::Topic
.joins(:topic_tags, :tags) .joins(:topic_tags, :tags)
.where(id: candidate_ids) .where(id: candidate_ids)
.where("tags.id IN (?)", DiscourseTagging.visible_tags(@user.guardian).pluck(:id)) .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)") .order("array_position(ARRAY#{candidate_ids}, topics.id)")
.pluck("array_agg(tags.name)") .pluck(
.map(&:uniq) "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 .map
.with_index { |tag_list, index| { tags: tag_list, score: candidates[index].last } } .with_index do |(id, name, count, index), idx|
.flat_map { |c| c[:tags].map { |t| { name: t, score: c[:score] } } } {
.map do |c| id: id,
c[:score] = 1 / (c[:score] + 1) # inverse of the distance name: name,
c count: count,
score: 1 / (candidates[idx].last + 1), # Inverse of the distance for score
}
end end
.group_by { |c| c[:name] } .group_by { |tag| tag[:name] }
.map { |name, scores| { name: name, score: scores.sum { |s| s[:score] } } } .map do |name, tags|
.sort_by { |c| -c[:score] } tags.first.merge(score: tags.sum { |t| t[:score] })
end # Aggregate scores per tag
.sort_by { |tag| -tag[:score] }
.take(5) .take(5)
end end