Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8f54e2709 |
@@ -1,4 +1,3 @@
|
||||
< 3.2.0.beta2-dev: 3bced1c6f525553fe2b5b79d29bd66c1cf2a47d5
|
||||
3.1.999: c0415bb7eb878b1b7abf112d65cba981030df8df
|
||||
< 3.2.0.beta1-dev: 3e7c99de894b03e9161d9481da118fe893991a1d
|
||||
3.1.0.beta3: 057fbe1ce6
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
module.exports = require("@discourse/lint-configs/eslint");
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -1 +0,0 @@
|
||||
module.exports = require("@discourse/lint-configs/prettier");
|
||||
@@ -1,4 +1,2 @@
|
||||
inherit_gem:
|
||||
rubocop-discourse: stree-compat.yml
|
||||
RSpec/NamedSubject:
|
||||
Enabled: false
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
--print-width=100
|
||||
--plugins=plugin/trailing_comma,plugin/disable_auto_ternary
|
||||
--plugins=plugin/trailing_comma,disable_ternary
|
||||
|
||||
+4
-1
@@ -1 +1,4 @@
|
||||
module.exports = require("@discourse/lint-configs/template-lint");
|
||||
module.exports = {
|
||||
plugins: ["ember-template-lint-plugin-discourse"],
|
||||
extends: "discourse:recommended",
|
||||
};
|
||||
|
||||
@@ -5,4 +5,5 @@ source "https://rubygems.org"
|
||||
group :development do
|
||||
gem "rubocop-discourse"
|
||||
gem "syntax_tree"
|
||||
gem "syntax_tree-disable_ternary"
|
||||
end
|
||||
|
||||
+20
-28
@@ -2,45 +2,36 @@ GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
ast (2.4.2)
|
||||
json (2.6.3)
|
||||
language_server-protocol (3.17.0.3)
|
||||
parallel (1.23.0)
|
||||
parser (3.2.2.4)
|
||||
json (2.6.2)
|
||||
parallel (1.22.1)
|
||||
parser (3.1.2.1)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
prettier_print (1.2.1)
|
||||
racc (1.7.2)
|
||||
prettier_print (1.2.0)
|
||||
rainbow (3.1.1)
|
||||
regexp_parser (2.8.2)
|
||||
rexml (3.2.6)
|
||||
rubocop (1.57.2)
|
||||
regexp_parser (2.6.0)
|
||||
rexml (3.2.5)
|
||||
rubocop (1.36.0)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (>= 3.17.0)
|
||||
parallel (~> 1.10)
|
||||
parser (>= 3.2.2.4)
|
||||
parser (>= 3.1.2.1)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 1.8, < 3.0)
|
||||
rexml (>= 3.2.5, < 4.0)
|
||||
rubocop-ast (>= 1.28.1, < 2.0)
|
||||
rubocop-ast (>= 1.20.1, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 3.0)
|
||||
rubocop-ast (1.30.0)
|
||||
parser (>= 3.2.1.0)
|
||||
rubocop-capybara (2.19.0)
|
||||
rubocop (~> 1.41)
|
||||
rubocop-discourse (3.4.0)
|
||||
unicode-display_width (>= 1.4.0, < 3.0)
|
||||
rubocop-ast (1.21.0)
|
||||
parser (>= 3.1.1.0)
|
||||
rubocop-discourse (3.0)
|
||||
rubocop (>= 1.1.0)
|
||||
rubocop-rspec (>= 2.0.0)
|
||||
rubocop-factory_bot (2.24.0)
|
||||
rubocop-rspec (2.13.2)
|
||||
rubocop (~> 1.33)
|
||||
rubocop-rspec (2.25.0)
|
||||
rubocop (~> 1.40)
|
||||
rubocop-capybara (~> 2.17)
|
||||
rubocop-factory_bot (~> 2.22)
|
||||
ruby-progressbar (1.13.0)
|
||||
syntax_tree (6.2.0)
|
||||
ruby-progressbar (1.11.0)
|
||||
syntax_tree (5.1.0)
|
||||
prettier_print (>= 1.2.0)
|
||||
unicode-display_width (2.5.0)
|
||||
syntax_tree-disable_ternary (1.0.0)
|
||||
unicode-display_width (2.3.0)
|
||||
|
||||
PLATFORMS
|
||||
arm64-darwin-20
|
||||
@@ -53,6 +44,7 @@ PLATFORMS
|
||||
DEPENDENCIES
|
||||
rubocop-discourse
|
||||
syntax_tree
|
||||
syntax_tree-disable_ternary
|
||||
|
||||
BUNDLED WITH
|
||||
2.4.21
|
||||
2.3.10
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# **Discourse AI** Plugin
|
||||
# **Plugin Name** Plugin
|
||||
|
||||
**Plugin Summary**
|
||||
|
||||
For more information, please see: https://meta.discourse.org/t/discourse-ai/259214?u=falco
|
||||
For more information, please see: **url to meta topic**
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module DiscourseAi
|
||||
module Admin
|
||||
class DashboardController < ::Admin::StaffController
|
||||
requires_plugin DiscourseAi::PLUGIN_NAME
|
||||
|
||||
def sentiment
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -6,7 +6,6 @@ module DiscourseAi
|
||||
requires_plugin ::DiscourseAi::PLUGIN_NAME
|
||||
requires_login
|
||||
before_action :ensure_can_request_suggestions
|
||||
before_action :rate_limiter_performed!, except: %i[prompts]
|
||||
|
||||
def prompts
|
||||
render json:
|
||||
@@ -18,100 +17,22 @@ module DiscourseAi
|
||||
end
|
||||
|
||||
def suggest
|
||||
input = get_text_param!
|
||||
raise Discourse::InvalidParameters.new(:text) if params[:text].blank?
|
||||
|
||||
prompt = CompletionPrompt.find_by(id: params[:mode])
|
||||
|
||||
raise Discourse::InvalidParameters.new(:mode) if !prompt || !prompt.enabled?
|
||||
if prompt.prompt_type == "custom_prompt" && params[:custom_prompt].blank?
|
||||
raise Discourse::InvalidParameters.new(:custom_prompt)
|
||||
end
|
||||
|
||||
hijack do
|
||||
render json:
|
||||
DiscourseAi::AiHelper::LlmPrompt.new.generate_and_send_prompt(prompt, params),
|
||||
status: 200
|
||||
end
|
||||
rescue ::DiscourseAi::Inference::OpenAiCompletions::CompletionFailed,
|
||||
::DiscourseAi::Inference::HuggingFaceTextGeneration::CompletionFailed,
|
||||
::DiscourseAi::Inference::AnthropicCompletions::CompletionFailed => e
|
||||
render_json_error I18n.t("discourse_ai.ai_helper.errors.completion_request_failed"),
|
||||
status: 502
|
||||
end
|
||||
|
||||
def suggest_title
|
||||
input = get_text_param!
|
||||
input_hash = { text: input }
|
||||
|
||||
llm_prompt =
|
||||
DiscourseAi::AiHelper::LlmPrompt
|
||||
.new
|
||||
.available_prompts(name_filter: "generate_titles")
|
||||
.first
|
||||
prompt = CompletionPrompt.find_by(id: llm_prompt[:id])
|
||||
raise Discourse::InvalidParameters.new(:mode) if !prompt || !prompt.enabled?
|
||||
RateLimiter.new(current_user, "ai_assistant", 6, 3.minutes).performed!
|
||||
|
||||
hijack do
|
||||
render json:
|
||||
DiscourseAi::AiHelper::LlmPrompt.new.generate_and_send_prompt(
|
||||
prompt,
|
||||
input_hash,
|
||||
params[:text],
|
||||
),
|
||||
status: 200
|
||||
end
|
||||
rescue ::DiscourseAi::Inference::OpenAiCompletions::CompletionFailed,
|
||||
::DiscourseAi::Inference::HuggingFaceTextGeneration::CompletionFailed,
|
||||
::DiscourseAi::Inference::AnthropicCompletions::CompletionFailed => e
|
||||
render_json_error I18n.t("discourse_ai.ai_helper.errors.completion_request_failed"),
|
||||
status: 502
|
||||
end
|
||||
|
||||
def suggest_category
|
||||
input = get_text_param!
|
||||
input_hash = { text: input }
|
||||
|
||||
render json:
|
||||
DiscourseAi::AiHelper::SemanticCategorizer.new(
|
||||
input_hash,
|
||||
current_user,
|
||||
).categories,
|
||||
status: 200
|
||||
end
|
||||
|
||||
def suggest_tags
|
||||
input = get_text_param!
|
||||
input_hash = { text: input }
|
||||
|
||||
render json: DiscourseAi::AiHelper::SemanticCategorizer.new(input_hash, current_user).tags,
|
||||
status: 200
|
||||
end
|
||||
|
||||
def suggest_thumbnails
|
||||
input = get_text_param!
|
||||
|
||||
hijack do
|
||||
thumbnails = DiscourseAi::AiHelper::Painter.new.commission_thumbnails(input, current_user)
|
||||
|
||||
render json: { thumbnails: thumbnails }, status: 200
|
||||
end
|
||||
end
|
||||
|
||||
def explain
|
||||
post_id = get_post_param!
|
||||
text = get_text_param!
|
||||
post = Post.find_by(id: post_id)
|
||||
|
||||
raise Discourse::InvalidParameters.new(:post_id) unless post
|
||||
|
||||
render json:
|
||||
DiscourseAi::AiHelper::TopicHelper.new(
|
||||
{ text: text },
|
||||
current_user,
|
||||
post: post,
|
||||
).explain,
|
||||
status: 200
|
||||
rescue ::DiscourseAi::Inference::OpenAiCompletions::CompletionFailed,
|
||||
::DiscourseAi::Inference::HuggingFaceTextGeneration::CompletionFailed,
|
||||
::DiscourseAi::Inference::AnthropicCompletions::CompletionFailed => e
|
||||
render_json_error I18n.t("discourse_ai.ai_helper.errors.completion_request_failed"),
|
||||
status: 502
|
||||
@@ -119,18 +40,6 @@ module DiscourseAi
|
||||
|
||||
private
|
||||
|
||||
def get_text_param!
|
||||
params[:text].tap { |t| raise Discourse::InvalidParameters.new(:text) if t.blank? }
|
||||
end
|
||||
|
||||
def get_post_param!
|
||||
params[:post_id].tap { |t| raise Discourse::InvalidParameters.new(:post_id) if t.blank? }
|
||||
end
|
||||
|
||||
def rate_limiter_performed!
|
||||
RateLimiter.new(current_user, "ai_assistant", 6, 3.minutes).performed!
|
||||
end
|
||||
|
||||
def ensure_can_request_suggestions
|
||||
user_group_ids = current_user.group_ids
|
||||
|
||||
|
||||
@@ -8,11 +8,8 @@ module DiscourseAi
|
||||
SEMANTIC_SEARCH_TYPE = "semantic_search"
|
||||
|
||||
def search
|
||||
query = params[:q].to_s
|
||||
|
||||
if query.length < SiteSetting.min_search_term_length
|
||||
raise Discourse::InvalidParameters.new(:q)
|
||||
end
|
||||
query = params[:q]
|
||||
page = (params[:page] || 1).to_i
|
||||
|
||||
grouped_results =
|
||||
Search::GroupedSearchResults.new(
|
||||
@@ -22,19 +19,12 @@ module DiscourseAi
|
||||
use_pg_headlines_for_excerpt: false,
|
||||
)
|
||||
|
||||
semantic_search = DiscourseAi::Embeddings::SemanticSearch.new(guardian)
|
||||
DiscourseAi::Embeddings::SemanticSearch
|
||||
.new(guardian)
|
||||
.search_for_topics(query, page)
|
||||
.each { |topic_post| grouped_results.add(topic_post) }
|
||||
|
||||
if !semantic_search.cached_query?(query)
|
||||
RateLimiter.new(current_user, "semantic-search", 4, 1.minutes).performed!
|
||||
end
|
||||
|
||||
hijack do
|
||||
semantic_search
|
||||
.search_for_topics(query)
|
||||
.each { |topic_post| grouped_results.add(topic_post) }
|
||||
|
||||
render_serialized(grouped_results, GroupedSearchResultSerializer, result: grouped_results)
|
||||
end
|
||||
render_serialized(grouped_results, GroupedSearchResultSerializer, result: grouped_results)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -15,8 +15,6 @@ end
|
||||
# id :bigint not null, primary key
|
||||
# provider_id :integer not null
|
||||
# user_id :integer
|
||||
# topic_id :integer
|
||||
# post_id :integer
|
||||
# request_tokens :integer
|
||||
# response_tokens :integer
|
||||
# raw_request_payload :string
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AiPersona < ActiveRecord::Base
|
||||
# places a hard limit, so per site we cache a maximum of 500 classes
|
||||
MAX_PERSONAS_PER_SITE = 500
|
||||
|
||||
class MultisiteHash
|
||||
def initialize(id)
|
||||
@hash = Hash.new { |h, k| h[k] = {} }
|
||||
@id = id
|
||||
|
||||
MessageBus.subscribe(channel_name) { |message| @hash[message.data] = {} }
|
||||
end
|
||||
|
||||
def channel_name
|
||||
"/multisite-hash-#{@id}"
|
||||
end
|
||||
|
||||
def current_db
|
||||
RailsMultisite::ConnectionManagement.current_db
|
||||
end
|
||||
|
||||
def [](key)
|
||||
@hash.dig(current_db, key)
|
||||
end
|
||||
|
||||
def []=(key, val)
|
||||
@hash[current_db][key] = val
|
||||
end
|
||||
|
||||
def flush!
|
||||
@hash[current_db] = {}
|
||||
MessageBus.publish(channel_name, current_db)
|
||||
end
|
||||
end
|
||||
|
||||
def self.persona_cache
|
||||
@persona_cache ||= MultisiteHash.new("persona_cache")
|
||||
end
|
||||
|
||||
def self.all_personas
|
||||
persona_cache[:value] ||= AiPersona
|
||||
.order(:name)
|
||||
.where(enabled: true)
|
||||
.all
|
||||
.limit(MAX_PERSONAS_PER_SITE)
|
||||
.map do |ai_persona|
|
||||
name = ai_persona.name
|
||||
description = ai_persona.description
|
||||
ai_persona_id = ai_persona.id
|
||||
allowed_group_ids = ai_persona.allowed_group_ids
|
||||
commands =
|
||||
ai_persona.commands.filter_map do |inner_name|
|
||||
begin
|
||||
("DiscourseAi::AiBot::Commands::#{inner_name}").constantize
|
||||
rescue StandardError
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
Class.new(DiscourseAi::AiBot::Personas::Persona) do
|
||||
define_singleton_method :name do
|
||||
name
|
||||
end
|
||||
|
||||
define_singleton_method :description do
|
||||
description
|
||||
end
|
||||
|
||||
define_singleton_method :allowed_group_ids do
|
||||
allowed_group_ids
|
||||
end
|
||||
|
||||
define_singleton_method :to_s do
|
||||
"#<DiscourseAi::AiBot::Personas::Persona::Custom @name=#{self.name} @allowed_group_ids=#{self.allowed_group_ids.join(",")}>"
|
||||
end
|
||||
|
||||
define_singleton_method :inspect do
|
||||
"#<DiscourseAi::AiBot::Personas::Persona::Custom @name=#{self.name} @allowed_group_ids=#{self.allowed_group_ids.join(",")}>"
|
||||
end
|
||||
|
||||
define_method :initialize do |*args, **kwargs|
|
||||
@ai_persona = AiPersona.find_by(id: ai_persona_id)
|
||||
super(*args, **kwargs)
|
||||
end
|
||||
|
||||
define_method :commands do
|
||||
commands
|
||||
end
|
||||
|
||||
define_method :system_prompt do
|
||||
@ai_persona&.system_prompt || "You are a helpful bot."
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
after_commit :bump_cache
|
||||
|
||||
def bump_cache
|
||||
self.class.persona_cache.flush!
|
||||
end
|
||||
end
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: ai_personas
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# name :string(100) not null
|
||||
# description :string(2000) not null
|
||||
# commands :string default([]), not null, is an Array
|
||||
# system_prompt :string not null
|
||||
# allowed_group_ids :integer default([]), not null, is an Array
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_ai_personas_on_name (name) UNIQUE
|
||||
#
|
||||
@@ -10,26 +10,10 @@ class CompletionPrompt < ActiveRecord::Base
|
||||
validate :each_message_length
|
||||
|
||||
def messages_with_user_input(user_input)
|
||||
return messages unless user_input.present?
|
||||
|
||||
if user_input[:custom_prompt].present?
|
||||
case ::DiscourseAi::AiHelper::LlmPrompt.new.enabled_provider
|
||||
when "huggingface"
|
||||
self.messages.each { |msg| msg.sub!("{{custom_prompt}}", user_input[:custom_prompt]) }
|
||||
else
|
||||
self.messages.each do |msg|
|
||||
msg["content"].sub!("{{custom_prompt}}", user_input[:custom_prompt])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
case ::DiscourseAi::AiHelper::LlmPrompt.new.enabled_provider
|
||||
when "openai"
|
||||
self.messages << { role: "user", content: user_input[:text] }
|
||||
when "anthropic"
|
||||
self.messages << { "role" => "Input", "content" => "<input>#{user_input[:text]}</input>" }
|
||||
when "huggingface"
|
||||
self.messages.first.sub("{{user_input}}", user_input[:text])
|
||||
if ::DiscourseAi::AiHelper::LlmPrompt.new.enabled_provider == "openai"
|
||||
self.messages << { role: "user", content: user_input }
|
||||
else
|
||||
self.messages << { "role" => "Input", "content" => "<input>#{user_input}</input>" }
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
export default {
|
||||
resource: "admin.dashboard",
|
||||
path: "/dashboard",
|
||||
map() {
|
||||
this.route("admin.dashboardSentiment", {
|
||||
path: "/dashboard/sentiment",
|
||||
resetNamespace: true,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -1,9 +1,10 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { action } from "@ember/object";
|
||||
import { inject as service } from "@ember/service";
|
||||
import I18n from "I18n";
|
||||
import Component from "@glimmer/component";
|
||||
import { composeAiBotMessage } from "discourse/plugins/discourse-ai/discourse/lib/ai-bot-helper";
|
||||
|
||||
import I18n from "I18n";
|
||||
|
||||
export default class AiBotHeaderPanel extends Component {
|
||||
@service siteSettings;
|
||||
@service composer;
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<DModalBody @title="discourse_ai.ai_helper.title">
|
||||
<span>{{i18n "discourse_ai.ai_helper.description"}}</span>
|
||||
|
||||
<ComboBox
|
||||
@value={{this.selected}}
|
||||
@content={{this.helperOptions}}
|
||||
@onChange={{action this.updateSelected}}
|
||||
@valueProperty="value"
|
||||
@class="ai-helper-mode"
|
||||
/>
|
||||
|
||||
<div class="text-preview">
|
||||
<Textarea
|
||||
@value={{this.composedMessage}}
|
||||
disabled="true"
|
||||
class="preview-area"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="selection-hint">{{i18n
|
||||
"discourse_ai.ai_helper.selection_hint"
|
||||
}}</div>
|
||||
|
||||
<div class="text-preview">
|
||||
<ConditionalLoadingSpinner @condition={{this.loading}} />
|
||||
|
||||
{{#unless this.loading}}
|
||||
{{#if this.selectingTopicTitle}}
|
||||
<div class="radios">
|
||||
{{#each this.generatedTitlesSuggestions as |title index|}}
|
||||
<label class="radio-label" for="title-suggestion-{{index}}">
|
||||
<RadioButton
|
||||
@id="title-suggestion-{{index}}"
|
||||
@name="title-suggestion"
|
||||
@value={{title}}
|
||||
@selection={{this.selectedTitle}}
|
||||
/>
|
||||
<b>{{title}}</b>
|
||||
</label>
|
||||
{{/each}}
|
||||
</div>
|
||||
{{else if this.proofreadingText}}
|
||||
{{html-safe this.proofreadDiff}}
|
||||
{{else if this.translatingText}}
|
||||
<Textarea
|
||||
@value={{this.translatedSuggestion}}
|
||||
disabled="true"
|
||||
class="preview-area"
|
||||
/>
|
||||
{{/if}}
|
||||
{{/unless}}
|
||||
</div>
|
||||
|
||||
</DModalBody>
|
||||
|
||||
<div class="modal-footer">
|
||||
{{#if this.canSave}}
|
||||
<DButton
|
||||
@class="btn-primary create"
|
||||
@action={{this.applySuggestion}}
|
||||
@label="save"
|
||||
/>
|
||||
<DModalCancel @close={{route-action "closeModal"}} />
|
||||
{{else}}
|
||||
<div class="ai-helper-waiting-selection">{{i18n
|
||||
"discourse_ai.modals.select_option"
|
||||
}}</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
@@ -0,0 +1,152 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { action, computed } from "@ember/object";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
|
||||
const LIST = "list";
|
||||
const TEXT = "text";
|
||||
const DIFF = "diff";
|
||||
|
||||
export default class AiHelper extends Component {
|
||||
@tracked selected = null;
|
||||
@tracked loading = false;
|
||||
|
||||
@tracked generatedTitlesSuggestions = [];
|
||||
|
||||
@tracked proofReadSuggestion = null;
|
||||
@tracked translatedSuggestion = null;
|
||||
@tracked selectedTitle = null;
|
||||
|
||||
@tracked proofreadDiff = null;
|
||||
|
||||
@tracked helperOptions = [];
|
||||
prompts = [];
|
||||
promptTypes = {};
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.loadPrompts();
|
||||
}
|
||||
|
||||
async loadPrompts() {
|
||||
let prompts = await ajax("/discourse-ai/ai-helper/prompts");
|
||||
|
||||
prompts.map((p) => {
|
||||
this.prompts[p.id] = p;
|
||||
});
|
||||
|
||||
this.promptTypes = prompts.reduce((memo, p) => {
|
||||
memo[p.name] = p.prompt_type;
|
||||
return memo;
|
||||
}, {});
|
||||
|
||||
this.helperOptions = prompts.map((p) => {
|
||||
return {
|
||||
name: p.translated_name,
|
||||
value: p.id,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
get composedMessage() {
|
||||
const editor = this.args.editor;
|
||||
|
||||
return editor.getSelected().value || editor.value;
|
||||
}
|
||||
|
||||
@computed("selected", "selectedTitle", "translatingText", "proofreadingText")
|
||||
get canSave() {
|
||||
return (
|
||||
(this.selected &&
|
||||
this.prompts[this.selected].prompt_type === LIST &&
|
||||
this.selectedTitle) ||
|
||||
this.translatingText ||
|
||||
this.proofreadingText
|
||||
);
|
||||
}
|
||||
|
||||
@computed("selected", "translatedSuggestion")
|
||||
get translatingText() {
|
||||
return (
|
||||
this.selected &&
|
||||
this.prompts[this.selected].prompt_type === TEXT &&
|
||||
this.translatedSuggestion
|
||||
);
|
||||
}
|
||||
|
||||
@computed("selected", "proofReadSuggestion")
|
||||
get proofreadingText() {
|
||||
return (
|
||||
this.selected &&
|
||||
this.prompts[this.selected].prompt_type === DIFF &&
|
||||
this.proofReadSuggestion
|
||||
);
|
||||
}
|
||||
|
||||
@computed("selected", "generatedTitlesSuggestions")
|
||||
get selectingTopicTitle() {
|
||||
return (
|
||||
this.selected &&
|
||||
this.prompts[this.selected].prompt_type === LIST &&
|
||||
this.generatedTitlesSuggestions.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
_updateSuggestedByAI(data) {
|
||||
switch (data.type) {
|
||||
case LIST:
|
||||
this.generatedTitlesSuggestions = data.suggestions;
|
||||
break;
|
||||
case TEXT:
|
||||
this.translatedSuggestion = data.suggestions[0];
|
||||
break;
|
||||
case DIFF:
|
||||
this.proofReadSuggestion = data.suggestions[0];
|
||||
this.proofreadDiff = data.diff;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
async updateSelected(value) {
|
||||
this.loading = true;
|
||||
this.selected = value;
|
||||
|
||||
if (value === LIST) {
|
||||
this.selectedTitle = null;
|
||||
}
|
||||
|
||||
if (this.hasSuggestion) {
|
||||
this.loading = false;
|
||||
} else {
|
||||
return ajax("/discourse-ai/ai-helper/suggest", {
|
||||
method: "POST",
|
||||
data: { mode: this.selected, text: this.composedMessage },
|
||||
})
|
||||
.then((data) => {
|
||||
this._updateSuggestedByAI(data);
|
||||
})
|
||||
.catch(popupAjaxError)
|
||||
.finally(() => (this.loading = false));
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
applySuggestion() {
|
||||
if (this.selectingTopicTitle) {
|
||||
const composer = this.args.editor.outletArgs?.composer;
|
||||
|
||||
if (composer) {
|
||||
composer.set("title", this.selectedTitle);
|
||||
}
|
||||
} else {
|
||||
const newText = this.proofreadingText
|
||||
? this.proofReadSuggestion
|
||||
: this.translatedSuggestion;
|
||||
this.args.editor.replaceText(this.composedMessage, newText);
|
||||
}
|
||||
|
||||
this.args.closeModal();
|
||||
}
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { action } from "@ember/object";
|
||||
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
|
||||
import { inject as service } from "@ember/service";
|
||||
import DButton from "discourse/components/d-button";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
import { bind } from "discourse-common/utils/decorators";
|
||||
import I18n from "I18n";
|
||||
|
||||
export default class AISuggestionDropdown extends Component {
|
||||
@service dialog;
|
||||
@service siteSettings;
|
||||
@service composer;
|
||||
@tracked loading = false;
|
||||
@tracked showMenu = false;
|
||||
@tracked generatedSuggestions = [];
|
||||
@tracked suggestIcon = "discourse-sparkles";
|
||||
@tracked showErrors = false;
|
||||
@tracked error = "";
|
||||
SUGGESTION_TYPES = {
|
||||
title: "suggest_title",
|
||||
category: "suggest_category",
|
||||
tag: "suggest_tags",
|
||||
};
|
||||
|
||||
willDestroy() {
|
||||
super.willDestroy(...arguments);
|
||||
document.removeEventListener("click", this.onClickOutside);
|
||||
}
|
||||
|
||||
get showAIButton() {
|
||||
const minCharacterCount = 40;
|
||||
const isShowAIButton = this.composer.model.replyLength > minCharacterCount;
|
||||
const composerFields = document.querySelector(".composer-fields");
|
||||
|
||||
if (composerFields) {
|
||||
if (isShowAIButton) {
|
||||
composerFields.classList.add("showing-ai-suggestions");
|
||||
} else {
|
||||
composerFields.classList.remove("showing-ai-suggestions");
|
||||
}
|
||||
}
|
||||
|
||||
return isShowAIButton;
|
||||
}
|
||||
|
||||
get disableSuggestionButton() {
|
||||
return this.loading;
|
||||
}
|
||||
|
||||
@action
|
||||
applyClasses() {
|
||||
if (this.showAIButton) {
|
||||
document.querySelector(".composer-fields")?.classList.add("showing-ai-suggestions");
|
||||
} else {
|
||||
document.querySelector(".composer-fields")?.classList.remove("showing-ai-suggestions");
|
||||
}
|
||||
}
|
||||
|
||||
@bind
|
||||
onClickOutside(event) {
|
||||
const menu = document.querySelector(".ai-title-suggestions-menu");
|
||||
|
||||
if (event.target === menu) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.#closeMenu();
|
||||
}
|
||||
|
||||
@action
|
||||
handleClickOutside() {
|
||||
document.addEventListener("click", this.onClickOutside);
|
||||
}
|
||||
|
||||
@action
|
||||
applySuggestion(suggestion) {
|
||||
if (!this.args.mode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const composer = this.args?.composer;
|
||||
if (!composer) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.args.mode === this.SUGGESTION_TYPES.title) {
|
||||
composer.set("title", suggestion);
|
||||
return this.#closeMenu();
|
||||
}
|
||||
|
||||
if (this.args.mode === this.SUGGESTION_TYPES.category) {
|
||||
const selectedCategoryId = this.composer.categories.find(
|
||||
(c) => c.slug === suggestion
|
||||
).id;
|
||||
composer.set("categoryId", selectedCategoryId);
|
||||
return this.#closeMenu();
|
||||
}
|
||||
|
||||
if (this.args.mode === this.SUGGESTION_TYPES.tag) {
|
||||
this.#updateTags(suggestion, composer);
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
async performSuggestion() {
|
||||
if (!this.args.mode) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.composer.model.replyLength === 0) {
|
||||
return this.dialog.alert(
|
||||
I18n.t("discourse_ai.ai_helper.missing_content")
|
||||
);
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
this.suggestIcon = "spinner";
|
||||
|
||||
return ajax(`/discourse-ai/ai-helper/${this.args.mode}`, {
|
||||
method: "POST",
|
||||
data: { text: this.composer.model.reply },
|
||||
})
|
||||
.then((data) => {
|
||||
this.#assignGeneratedSuggestions(data, this.args.mode);
|
||||
})
|
||||
.catch(popupAjaxError)
|
||||
.finally(() => {
|
||||
this.loading = false;
|
||||
this.suggestIcon = "sync-alt";
|
||||
this.showMenu = true;
|
||||
|
||||
if (this.args.mode === "suggest_category") {
|
||||
document.querySelector(".category-input")?.classList.add("showing-ai-suggestion-menu");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#closeMenu() {
|
||||
if (this.showMenu && this.args.mode === "suggest_category") {
|
||||
document.querySelector(".category-input")?.classList.remove("showing-ai-suggestion-menu");
|
||||
}
|
||||
|
||||
this.suggestIcon = "discourse-sparkles";
|
||||
this.showMenu = false;
|
||||
this.showErrors = false;
|
||||
this.errors = "";
|
||||
}
|
||||
|
||||
#updateTags(suggestion, composer) {
|
||||
const maxTags = this.siteSettings.max_tags_per_topic;
|
||||
|
||||
if (!composer.tags) {
|
||||
composer.set("tags", [suggestion]);
|
||||
// remove tag from the list of suggestions once added
|
||||
this.generatedSuggestions = this.generatedSuggestions.filter(
|
||||
(s) => s !== suggestion
|
||||
);
|
||||
return;
|
||||
}
|
||||
const tags = composer.tags;
|
||||
|
||||
if (tags?.length >= maxTags) {
|
||||
// Show error if trying to add more tags than allowed
|
||||
this.showErrors = true;
|
||||
this.error = I18n.t("select_kit.max_content_reached", { count: maxTags });
|
||||
return;
|
||||
}
|
||||
|
||||
tags.push(suggestion);
|
||||
composer.set("tags", [...tags]);
|
||||
// remove tag from the list of suggestions once added
|
||||
return (this.generatedSuggestions = this.generatedSuggestions.filter(
|
||||
(s) => s !== suggestion
|
||||
));
|
||||
}
|
||||
|
||||
#tagSelectorHasValues() {
|
||||
return this.args.composer?.tags && this.args.composer?.tags.length > 0;
|
||||
}
|
||||
|
||||
#assignGeneratedSuggestions(data, mode) {
|
||||
if (mode === this.SUGGESTION_TYPES.title) {
|
||||
return (this.generatedSuggestions = data.suggestions);
|
||||
}
|
||||
|
||||
const suggestions = data.assistant.map((s) => s.name);
|
||||
|
||||
if (mode === this.SUGGESTION_TYPES.tag) {
|
||||
if (this.#tagSelectorHasValues()) {
|
||||
// Filter out tags if they are already selected in the tag input
|
||||
return (this.generatedSuggestions = suggestions.filter(
|
||||
(t) => !this.args.composer.tags.includes(t)
|
||||
));
|
||||
} else {
|
||||
return (this.generatedSuggestions = suggestions);
|
||||
}
|
||||
}
|
||||
|
||||
return (this.generatedSuggestions = suggestions);
|
||||
}
|
||||
|
||||
<template>
|
||||
{{#if this.showAIButton}}
|
||||
<DButton
|
||||
@class="suggestion-button {{if this.loading 'is-loading'}}"
|
||||
@icon={{this.suggestIcon}}
|
||||
@title="discourse_ai.ai_helper.suggest"
|
||||
@action={{this.performSuggestion}}
|
||||
@disabled={{this.disableSuggestionButton}}
|
||||
...attributes
|
||||
/>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.showMenu}}
|
||||
{{! template-lint-disable modifier-name-case }}
|
||||
<ul
|
||||
class="popup-menu ai-suggestions-menu"
|
||||
{{didInsert this.handleClickOutside}}
|
||||
>
|
||||
{{#if this.showErrors}}
|
||||
<li class="ai-suggestions-menu__errors">{{this.error}}</li>
|
||||
{{/if}}
|
||||
{{#each this.generatedSuggestions as |suggestion index|}}
|
||||
<li data-name={{suggestion}} data-value={{index}}>
|
||||
<DButton
|
||||
@class="popup-menu-btn"
|
||||
@translatedLabel={{suggestion}}
|
||||
@action={{this.applySuggestion}}
|
||||
@actionParam={{suggestion}}
|
||||
/>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
@@ -1,25 +1,14 @@
|
||||
import Component from "@glimmer/component";
|
||||
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 DModalCancel from "discourse/components/d-modal-cancel";
|
||||
import I18n from "I18n";
|
||||
import { htmlSafe } from "@ember/template";
|
||||
|
||||
const t = I18n.t.bind(I18n);
|
||||
|
||||
export default class ModalDiffModal extends Component {
|
||||
@action
|
||||
triggerConfirmChanges() {
|
||||
this.args.closeModal();
|
||||
this.args.confirm();
|
||||
}
|
||||
|
||||
@action
|
||||
triggerRevertChanges() {
|
||||
this.args.closeModal();
|
||||
this.args.revert();
|
||||
}
|
||||
|
||||
<template>
|
||||
<DModal
|
||||
class="composer-ai-helper-modal"
|
||||
@@ -46,12 +35,14 @@ export default class ModalDiffModal extends Component {
|
||||
@action={{this.triggerConfirmChanges}}
|
||||
@label="discourse_ai.ai_helper.context_menu.confirm"
|
||||
/>
|
||||
<DButton
|
||||
class="btn-flat"
|
||||
@action={{this.triggerRevertChanges}}
|
||||
@label="discourse_ai.ai_helper.context_menu.revert"
|
||||
/>
|
||||
<DModalCancel @close={{@closeModal}} />
|
||||
</:footer>
|
||||
</DModal>
|
||||
</template>
|
||||
|
||||
@action
|
||||
triggerConfirmChanges() {
|
||||
this.args.closeModal();
|
||||
this.args.confirm();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import Component from "@glimmer/component";
|
||||
import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel";
|
||||
import { inject as service } from "@ember/service";
|
||||
|
||||
export default class ReviewableAiChatMessage extends Component {
|
||||
@service store;
|
||||
|
||||
get chatChannel() {
|
||||
return ChatChannel.create(this.args.reviewable.chat_channel);
|
||||
return this.store.createRecord(
|
||||
"chat-channel",
|
||||
this.args.reviewable.chat_channel
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { LinkTo } from "@ember/routing";
|
||||
import I18n from "discourse-i18n";
|
||||
|
||||
const i18n = I18n.t.bind(I18n);
|
||||
|
||||
export default class AISentimentDashboard extends Component {
|
||||
<template>
|
||||
<li class="navigation-item sentiment">
|
||||
<LinkTo @route="admin.dashboardSentiment" class="navigation-link">
|
||||
{{i18n "discourse_ai.sentiments.dashboard.title"}}
|
||||
</LinkTo>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
static shouldRender(_outletArgs, helper) {
|
||||
return helper.siteSettings.ai_sentiment_enabled;
|
||||
}
|
||||
}
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { inject as service } from "@ember/service";
|
||||
import AISuggestionDropdown from "../../components/ai-suggestion-dropdown";
|
||||
import { showComposerAIHelper } from "../../lib/show-ai-helper";
|
||||
|
||||
export default class AICategorySuggestion extends Component {
|
||||
static shouldRender(outletArgs, helper) {
|
||||
return showComposerAIHelper(outletArgs, helper);
|
||||
}
|
||||
|
||||
@service siteSettings;
|
||||
|
||||
<template>
|
||||
{{#if this.siteSettings.ai_embeddings_enabled}}
|
||||
<AISuggestionDropdown
|
||||
@mode="suggest_category"
|
||||
@composer={{@outletArgs.composer}}
|
||||
class="suggest-category-button"
|
||||
/>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { inject as service } from "@ember/service";
|
||||
import AISuggestionDropdown from "../../components/ai-suggestion-dropdown";
|
||||
import { showComposerAIHelper } from "../../lib/show-ai-helper";
|
||||
|
||||
export default class AITagSuggestion extends Component {
|
||||
static shouldRender(outletArgs, helper) {
|
||||
return showComposerAIHelper(outletArgs, helper);
|
||||
}
|
||||
|
||||
@service siteSettings;
|
||||
|
||||
<template>
|
||||
{{#if this.siteSettings.ai_embeddings_enabled}}
|
||||
<AISuggestionDropdown
|
||||
@mode="suggest_tags"
|
||||
@composer={{@outletArgs.composer}}
|
||||
class="suggest-tags-button"
|
||||
/>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
import Component from "@glimmer/component";
|
||||
import AISuggestionDropdown from "../../components/ai-suggestion-dropdown";
|
||||
import { showComposerAIHelper } from "../../lib/show-ai-helper";
|
||||
|
||||
export default class AITitleSuggestion extends Component {
|
||||
static shouldRender(outletArgs, helper) {
|
||||
return showComposerAIHelper(outletArgs, helper);
|
||||
}
|
||||
|
||||
<template>
|
||||
<AISuggestionDropdown
|
||||
@mode="suggest_title"
|
||||
@composer={{@outletArgs.composer}}
|
||||
class="suggest-titles-button"
|
||||
/>
|
||||
</template>
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
<ul class="ai-helper-context-menu__trigger">
|
||||
<li>
|
||||
<DButton
|
||||
@icon="discourse-sparkles"
|
||||
@icon="magic"
|
||||
@action={{this.toggleAiHelperOptions}}
|
||||
@label="discourse_ai.ai_helper.context_menu.trigger"
|
||||
class="btn-flat"
|
||||
@@ -16,55 +16,40 @@
|
||||
{{else if (eq this.menuState this.CONTEXT_MENU_STATES.options)}}
|
||||
<ul class="ai-helper-context-menu__options">
|
||||
{{#each this.helperOptions as |option|}}
|
||||
{{#if (eq option.name "custom_prompt")}}
|
||||
<div
|
||||
class="ai-custom-prompt"
|
||||
{{did-insert this.setupCustomPrompt}}
|
||||
>
|
||||
<Input
|
||||
@value={{this.customPromptValue}}
|
||||
placeholder={{i18n
|
||||
"discourse_ai.ai_helper.context_menu.custom_prompt.placeholder"
|
||||
}}
|
||||
class="ai-custom-prompt__input"
|
||||
@enter={{action (fn this.updateSelected option)}}
|
||||
/>
|
||||
<li data-name={{option.name}} data-value={{option.value}}>
|
||||
<DButton
|
||||
@class="btn-flat"
|
||||
@translatedLabel={{option.name}}
|
||||
@action={{this.updateSelected}}
|
||||
@actionParam={{option.value}}
|
||||
/>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
|
||||
<DButton
|
||||
@class="ai-custom-prompt__submit btn-primary"
|
||||
@icon="discourse-sparkles"
|
||||
@action={{this.updateSelected}}
|
||||
@actionParam={{option}}
|
||||
@disabled={{not this.customPromptValue.length}}
|
||||
/>
|
||||
</div>
|
||||
{{else}}
|
||||
<li data-name={{option.translated_name}} data-value={{option.id}}>
|
||||
<DButton
|
||||
@icon={{option.icon}}
|
||||
@class="btn-flat"
|
||||
@translatedLabel={{option.translated_name}}
|
||||
@action={{this.updateSelected}}
|
||||
@actionParam={{option}}
|
||||
/>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{else if (eq this.menuState this.CONTEXT_MENU_STATES.suggestions)}}
|
||||
<ul class="ai-helper-context-menu__suggestions">
|
||||
{{#each this.generatedTitleSuggestions as |suggestion index|}}
|
||||
<li data-name={{suggestion}} data-value={{index}}>
|
||||
<DButton
|
||||
@class="btn-flat"
|
||||
@translatedLabel={{suggestion}}
|
||||
@action={{this.updateTopicTitle}}
|
||||
@actionParam={{suggestion}}
|
||||
/>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
|
||||
{{else if (eq this.menuState this.CONTEXT_MENU_STATES.loading)}}
|
||||
<div class="ai-helper-context-menu__loading">
|
||||
<div class="dot-falling"></div>
|
||||
<span>
|
||||
{{i18n "discourse_ai.ai_helper.context_menu.loading"}}
|
||||
</span>
|
||||
<DButton
|
||||
@icon="times"
|
||||
@title="discourse_ai.ai_helper.context_menu.cancel"
|
||||
@action={{this.cancelAIAction}}
|
||||
class="btn-flat cancel-request"
|
||||
/>
|
||||
</div>
|
||||
<ul class="ai-helper-context-menu__loading">
|
||||
<li>
|
||||
<div class="dot-falling"></div>
|
||||
<span>
|
||||
{{i18n "discourse_ai.ai_helper.context_menu.loading"}}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{{else if (eq this.menuState this.CONTEXT_MENU_STATES.review)}}
|
||||
<ul class="ai-helper-context-menu__review">
|
||||
@@ -114,6 +99,7 @@
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
||||
@@ -125,7 +111,6 @@
|
||||
@diff={{this.diff}}
|
||||
@oldValue={{this.selectedText}}
|
||||
@newValue={{this.newSelectedText}}
|
||||
@revert={{this.undoAIAction}}
|
||||
@closeModal={{fn (mut this.showDiffModal) false}}
|
||||
/>
|
||||
{{/if}}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user