Compare commits

..
1 Commits
Author SHA1 Message Date
Jordan Vidrine 47fdb1acbf FIX: Adjust for overlap in AI buttons 2023-11-04 12:48:46 -05:00
368 changed files with 1149166 additions and 2 deletions
+4
View File
@@ -0,0 +1,4 @@
< 3.2.0.beta2-dev: 3bced1c6f525553fe2b5b79d29bd66c1cf2a47d5
3.1.999: c0415bb7eb878b1b7abf112d65cba981030df8df
< 3.2.0.beta1-dev: 3e7c99de894b03e9161d9481da118fe893991a1d
3.1.0.beta3: 057fbe1ce6
+1
View File
@@ -0,0 +1 @@
module.exports = require("@discourse/lint-configs/eslint");
+11
View File
@@ -0,0 +1,11 @@
name: Discourse Plugin
on:
push:
branches:
- main
pull_request:
jobs:
ci:
uses: discourse/.github/.github/workflows/discourse-plugin.yml@v1
+3
View File
@@ -0,0 +1,3 @@
node_modules
/gems
/auto_generated
+1
View File
@@ -0,0 +1 @@
module.exports = require("@discourse/lint-configs/prettier");
+4
View File
@@ -0,0 +1,4 @@
inherit_gem:
rubocop-discourse: stree-compat.yml
RSpec/NamedSubject:
Enabled: false
+2
View File
@@ -0,0 +1,2 @@
--print-width=100
--plugins=plugin/trailing_comma,plugin/disable_auto_ternary
+1
View File
@@ -0,0 +1 @@
module.exports = require("@discourse/lint-configs/template-lint");
+8
View File
@@ -0,0 +1,8 @@
# frozen_string_literal: true
source "https://rubygems.org"
group :development do
gem "rubocop-discourse"
gem "syntax_tree"
end
+58
View File
@@ -0,0 +1,58 @@
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)
ast (~> 2.4.1)
racc
prettier_print (1.2.1)
racc (1.7.2)
rainbow (3.1.1)
regexp_parser (2.8.2)
rexml (3.2.6)
rubocop (1.57.2)
json (~> 2.3)
language_server-protocol (>= 3.17.0)
parallel (~> 1.10)
parser (>= 3.2.2.4)
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)
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)
rubocop (>= 1.1.0)
rubocop-rspec (>= 2.0.0)
rubocop-factory_bot (2.24.0)
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)
prettier_print (>= 1.2.0)
unicode-display_width (2.5.0)
PLATFORMS
arm64-darwin-20
ruby
x86_64-darwin-18
x86_64-darwin-19
x86_64-darwin-20
x86_64-linux
DEPENDENCIES
rubocop-discourse
syntax_tree
BUNDLED WITH
2.4.21
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2021 Civilized Discourse Construction Kit
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+4 -2
View File
@@ -1,3 +1,5 @@
# Discourse AI is now shipped with Discourse core
# **Discourse AI** Plugin
https://github.com/discourse/discourse/tree/main/plugins/discourse-ai
**Plugin Summary**
For more information, please see: https://meta.discourse.org/t/discourse-ai/259214?u=falco
@@ -0,0 +1,28 @@
# frozen_string_literal: true
module DiscourseAi
module AiBot
class BotController < ::ApplicationController
requires_plugin ::DiscourseAi::PLUGIN_NAME
requires_login
def stop_streaming_response
post = Post.find(params[:post_id])
guardian.ensure_can_see!(post)
Discourse.redis.del("gpt_cancel:#{post.id}")
render json: {}, status: 200
end
def show_bot_username
bot_user_id = DiscourseAi::AiBot::EntryPoint.map_bot_model_to_user_id(params[:username])
raise Discourse::InvalidParameters.new(:username) if !bot_user_id
bot_username_lower = User.find(bot_user_id).username_lower
render json: { bot_username: bot_username_lower }, status: 200
end
end
end
end
@@ -0,0 +1,146 @@
# frozen_string_literal: true
module DiscourseAi
module AiHelper
class AssistantController < ::ApplicationController
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:
ActiveModel::ArraySerializer.new(
DiscourseAi::AiHelper::LlmPrompt.new.available_prompts,
root: false,
),
status: 200
end
def suggest
input = get_text_param!
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?
hijack do
render json:
DiscourseAi::AiHelper::LlmPrompt.new.generate_and_send_prompt(
prompt,
input_hash,
),
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
end
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
allowed =
SiteSetting.ai_helper_allowed_groups_map.any? do |group_id|
user_group_ids.include?(group_id)
end
raise Discourse::InvalidAccess if !allowed
end
end
end
end
@@ -0,0 +1,41 @@
# frozen_string_literal: true
module DiscourseAi
module Embeddings
class EmbeddingsController < ::ApplicationController
requires_plugin ::DiscourseAi::PLUGIN_NAME
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
grouped_results =
Search::GroupedSearchResults.new(
type_filter: SEMANTIC_SEARCH_TYPE,
term: query,
search_context: guardian,
use_pg_headlines_for_excerpt: false,
)
semantic_search = DiscourseAi::Embeddings::SemanticSearch.new(guardian)
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
end
end
end
end
+26
View File
@@ -0,0 +1,26 @@
# frozen_string_literal: true
class AiApiAuditLog < ActiveRecord::Base
module Provider
OpenAI = 1
Anthropic = 2
HuggingFaceTextGeneration = 3
end
end
# == Schema Information
#
# Table name: ai_api_audit_logs
#
# 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
# raw_response_payload :string
# created_at :datetime not null
# updated_at :datetime not null
#
+23
View File
@@ -0,0 +1,23 @@
# frozen_string_literal: true
class ClassificationResult < ActiveRecord::Base
belongs_to :target, polymorphic: true
end
# == Schema Information
#
# Table name: classification_results
#
# id :bigint not null, primary key
# model_used :string
# classification_type :string
# target_id :integer
# target_type :string
# classification :jsonb
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# unique_classification_target_per_type (target_id,target_type,model_used) UNIQUE
#
+64
View File
@@ -0,0 +1,64 @@
# frozen_string_literal: true
class CompletionPrompt < ActiveRecord::Base
# TODO(roman): Remove sept 2023.
self.ignored_columns = ["value"]
enum :prompt_type, { text: 0, list: 1, diff: 2 }
validates :messages, length: { maximum: 20 }
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])
end
end
private
def each_message_length
messages.each_with_index do |msg, idx|
next if msg["content"].length <= 1000
errors.add(:messages, I18n.t("errors.prompt_message_length", idx: idx + 1))
end
end
end
# == Schema Information
#
# Table name: completion_prompts
#
# id :bigint not null, primary key
# name :string not null
# translated_name :string
# prompt_type :integer default("text"), not null
# enabled :boolean default(TRUE), not null
# created_at :datetime not null
# updated_at :datetime not null
# messages :jsonb
# provider :text
#
# Indexes
#
# index_completion_prompts_on_name (name)
#
+52
View File
@@ -0,0 +1,52 @@
# frozen_string_literal: true
class ModelAccuracy < ActiveRecord::Base
def self.adjust_model_accuracy(new_status, reviewable)
return unless %i[approved rejected].include?(new_status)
return unless [ReviewableAiPost, ReviewableAiChatMessage].include?(reviewable.class)
verdicts = reviewable.payload.to_h["verdicts"] || {}
verdicts.each do |model_name, verdict|
accuracy_model = find_by(model: model_name)
attribute =
if verdict
new_status == :approved ? :flags_agreed : :flags_disagreed
else
new_status == :rejected ? :flags_agreed : :flags_disagreed
end
accuracy_model.increment!(attribute)
end
end
def calculate_accuracy
return 0 if total_flags.zero?
(flags_agreed * 100) / total_flags
end
private
def total_flags
flags_agreed + flags_disagreed
end
end
# == Schema Information
#
# Table name: model_accuracies
#
# id :bigint not null, primary key
# model :string not null
# classification_type :string not null
# flags_agreed :integer default(0), not null
# flags_disagreed :integer default(0), not null
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_model_accuracies_on_model (model) UNIQUE
#
+9
View File
@@ -0,0 +1,9 @@
# frozen_string_literal: true
class PostCustomPrompt < ActiveRecord::Base
belongs_to :post
end
class ::Post
has_one :post_custom_prompt, dependent: :destroy
end
+186
View File
@@ -0,0 +1,186 @@
# frozen_string_literal: true
require_dependency "reviewable"
class ReviewableAiChatMessage < Reviewable
def self.action_aliases
{
agree_and_keep_hidden: :agree_and_delete,
agree_and_silence: :agree_and_delete,
agree_and_suspend: :agree_and_delete,
delete_and_agree: :agree_and_delete,
}
end
def self.score_to_silence_user
sensitivity_score(SiteSetting.chat_silence_user_sensitivity, scale: 0.6)
end
def chat_message
@chat_message ||= (target || Chat::Message.with_deleted.find_by(id: target_id))
end
def chat_message_creator
@chat_message_creator ||= chat_message.user
end
def flagged_by_user_ids
@flagged_by_user_ids ||= reviewable_scores.map(&:user_id)
end
def post
nil
end
def build_actions(actions, guardian, args)
return unless pending?
return if chat_message.blank?
agree =
actions.add_bundle("#{id}-agree", icon: "thumbs-up", label: "reviewables.actions.agree.title")
if chat_message.deleted_at?
build_action(actions, :agree_and_restore, icon: "far-eye", bundle: agree)
build_action(actions, :agree_and_keep_deleted, icon: "thumbs-up", bundle: agree)
build_action(actions, :disagree_and_restore, icon: "thumbs-down")
else
build_action(actions, :agree_and_delete, icon: "far-eye-slash", bundle: agree)
build_action(actions, :agree_and_keep_message, icon: "thumbs-up", bundle: agree)
build_action(actions, :disagree, icon: "thumbs-down")
end
if guardian.can_suspend?(chat_message_creator)
build_action(
actions,
:agree_and_suspend,
icon: "ban",
bundle: agree,
client_action: "suspend",
)
build_action(
actions,
:agree_and_silence,
icon: "microphone-slash",
bundle: agree,
client_action: "silence",
)
end
build_action(actions, :ignore, icon: "external-link-alt")
build_action(actions, :delete_and_agree, icon: "far-trash-alt") unless chat_message.deleted_at?
end
def perform_agree_and_keep_message(performed_by, args)
agree
end
def perform_agree_and_restore(performed_by, args)
agree { chat_message.recover! }
end
def perform_agree_and_delete(performed_by, args)
agree { chat_message.trash!(performed_by) }
end
def perform_disagree_and_restore(performed_by, args)
disagree { chat_message.recover! }
end
def perform_disagree(performed_by, args)
disagree
end
def perform_ignore(performed_by, args)
ignore
end
def perform_delete_and_ignore(performed_by, args)
ignore { chat_message.trash!(performed_by) }
end
private
def agree
yield if block_given?
create_result(:success, :approved) do |result|
result.update_flag_stats = { status: :agreed, user_ids: flagged_by_user_ids }
result.recalculate_score = true
end
end
def disagree
yield if block_given?
UserSilencer.unsilence(chat_message_creator)
create_result(:success, :rejected) do |result|
result.update_flag_stats = { status: :disagreed, user_ids: flagged_by_user_ids }
result.recalculate_score = true
end
end
def ignore
yield if block_given?
create_result(:success, :ignored) do |result|
result.update_flag_stats = { status: :ignored, user_ids: flagged_by_user_ids }
end
end
def build_action(
actions,
id,
icon:,
button_class: nil,
bundle: nil,
client_action: nil,
confirm: false
)
actions.add(id, bundle: bundle) do |action|
prefix = "reviewables.actions.#{id}"
action.icon = icon
action.button_class = button_class
action.label = "chat.#{prefix}.title"
action.description = "chat.#{prefix}.description"
action.client_action = client_action
action.confirm_message = "#{prefix}.confirm" if confirm
end
end
end
# == Schema Information
#
# Table name: reviewables
#
# id :bigint not null, primary key
# type :string not null
# status :integer default("pending"), not null
# created_by_id :integer not null
# reviewable_by_moderator :boolean default(FALSE), not null
# reviewable_by_group_id :integer
# category_id :integer
# topic_id :integer
# score :float default(0.0), not null
# potential_spam :boolean default(FALSE), not null
# target_id :integer
# target_type :string
# target_created_by_id :integer
# payload :json
# version :integer default(0), not null
# latest_score :datetime
# created_at :datetime not null
# updated_at :datetime not null
# force_review :boolean default(FALSE), not null
# reject_reason :text
#
# Indexes
#
# idx_reviewables_score_desc_created_at_desc (score,created_at)
# index_reviewables_on_reviewable_by_group_id (reviewable_by_group_id)
# index_reviewables_on_status_and_created_at (status,created_at)
# index_reviewables_on_status_and_score (status,score)
# index_reviewables_on_status_and_type (status,type)
# index_reviewables_on_target_id_where_post_type_eq_post (target_id) WHERE ((target_type)::text = 'Post'::text)
# index_reviewables_on_topic_id_and_status_and_created_by_id (topic_id,status,created_by_id)
# index_reviewables_on_type_and_target_id (type,target_id) UNIQUE
#
+244
View File
@@ -0,0 +1,244 @@
# frozen_string_literal:true
require_dependency "reviewable"
class ReviewableAiPost < Reviewable
# Penalties are handled by the modal after the action is performed
def self.action_aliases
{
agree_and_keep_hidden: :agree_and_keep,
agree_and_silence: :agree_and_keep,
agree_and_suspend: :agree_and_keep,
disagree_and_restore: :disagree,
}
end
def build_actions(actions, guardian, args)
return actions if !pending? || post.blank?
agree =
actions.add_bundle("#{id}-agree", icon: "thumbs-up", label: "reviewables.actions.agree.title")
if !post.user_deleted? && !post.hidden?
build_action(actions, :agree_and_hide, icon: "far-eye-slash", bundle: agree)
end
if post.hidden?
build_action(actions, :agree_and_keep_hidden, icon: "thumbs-up", bundle: agree)
else
build_action(actions, :agree_and_keep, icon: "thumbs-up", bundle: agree)
end
if guardian.can_suspend?(target_created_by)
build_action(
actions,
:agree_and_suspend,
icon: "ban",
bundle: agree,
client_action: "suspend",
)
build_action(
actions,
:agree_and_silence,
icon: "microphone-slash",
bundle: agree,
client_action: "silence",
)
end
build_action(actions, :agree_and_restore, icon: "far-eye", bundle: agree) if post.user_deleted?
if post.hidden?
build_action(actions, :disagree_and_restore, icon: "thumbs-down")
else
build_action(actions, :disagree, icon: "thumbs-down")
end
if guardian.can_delete_post_or_topic?(post)
delete =
actions.add_bundle(
"#{id}-delete",
icon: "far-trash-alt",
label: "reviewables.actions.delete.title",
)
build_action(actions, :delete_and_ignore, icon: "external-link-alt", bundle: delete)
if post.reply_count > 0
build_action(
actions,
:delete_and_ignore_replies,
icon: "external-link-alt",
confirm: true,
bundle: delete,
)
end
build_action(actions, :delete_and_agree, icon: "thumbs-up", bundle: delete)
if post.reply_count > 0
build_action(
actions,
:delete_and_agree_replies,
icon: "external-link-alt",
bundle: delete,
confirm: true,
)
end
end
delete_user_actions(actions) if guardian.can_delete_user?(target_created_by)
build_action(actions, :ignore, icon: "external-link-alt")
end
def perform_agree_and_hide(performed_by, args)
post.hide!(reviewable_scores.first.reviewable_score_type)
agree
end
def perform_agree_and_keep(_performed_by, _args)
agree
end
def perform_agree_and_restore(performed_by, args)
destroyer(performed_by).recover
agree
end
def perform_disagree(performed_by, args)
# Undo hide/silence if applicable
post.unhide! if post.hidden?
create_result(:success, :rejected) do |result|
result.update_flag_stats = { status: :disagreed, user_ids: [created_by_id] }
end
end
def perform_ignore(performed_by, args)
create_result(:success, :ignored) do |result|
result.update_flag_stats = { status: :ignored, user_ids: [created_by_id] }
end
end
def perform_delete_and_ignore(performed_by, args)
destroyer(performed_by).destroy
perform_ignore(performed_by, args)
end
def perform_delete_and_agree(performed_by, args)
destroyer(performed_by).destroy
agree
end
def perform_delete_and_ignore_replies(performed_by, args)
PostDestroyer.delete_with_replies(performed_by, post, self)
perform_ignore(performed_by, args)
end
def perform_delete_and_agree_replies(performed_by, args)
PostDestroyer.delete_with_replies(performed_by, post, self)
agree
end
def perform_delete_user(performed_by, args)
UserDestroyer.new(performed_by).destroy(post.user, delete_opts)
agree
end
def perform_delete_user_block(performed_by, args)
delete_options = delete_opts
delete_options.merge!(block_email: true, block_ip: true) if Rails.env.production?
UserDestroyer.new(performed_by).destroy(post.user, delete_options)
agree
end
private
def post
@post ||= (target || Post.with_deleted.find_by(id: target_id))
end
def destroyer(performed_by)
PostDestroyer.new(performed_by, post, reviewable: self)
end
def agree
create_result(:success, :approved) do |result|
result.update_flag_stats = { status: :agreed, user_ids: [created_by_id] }
result.recalculate_score = true
end
end
def delete_opts
{
delete_posts: true,
prepare_for_destroy: true,
block_urls: true,
delete_as_spammer: true,
context: "review",
}
end
def build_action(
actions,
id,
icon:,
button_class: nil,
bundle: nil,
client_action: nil,
confirm: false
)
actions.add(id, bundle: bundle) do |action|
prefix = "reviewables.actions.#{id}"
action.icon = icon
action.button_class = button_class
action.label = "#{prefix}.title"
action.description = "#{prefix}.description"
action.client_action = client_action
action.confirm_message = "#{prefix}.confirm" if confirm
end
end
end
# == Schema Information
#
# Table name: reviewables
#
# id :bigint not null, primary key
# type :string not null
# status :integer default("pending"), not null
# created_by_id :integer not null
# reviewable_by_moderator :boolean default(FALSE), not null
# reviewable_by_group_id :integer
# category_id :integer
# topic_id :integer
# score :float default(0.0), not null
# potential_spam :boolean default(FALSE), not null
# target_id :integer
# target_type :string
# target_created_by_id :integer
# payload :json
# version :integer default(0), not null
# latest_score :datetime
# created_at :datetime not null
# updated_at :datetime not null
# force_review :boolean default(FALSE), not null
# reject_reason :text
#
# Indexes
#
# idx_reviewables_score_desc_created_at_desc (score,created_at)
# index_reviewables_on_reviewable_by_group_id (reviewable_by_group_id)
# index_reviewables_on_status_and_created_at (status,created_at)
# index_reviewables_on_status_and_score (status,score)
# index_reviewables_on_status_and_type (status,type)
# index_reviewables_on_target_id_where_post_type_eq_post (target_id) WHERE ((target_type)::text = 'Post'::text)
# index_reviewables_on_topic_id_and_status_and_created_by_id (topic_id,status,created_by_id)
# index_reviewables_on_type_and_target_id (type,target_id) UNIQUE
#
@@ -0,0 +1,11 @@
# frozen_string_literal: true
class AiChatChannelSerializer < ApplicationSerializer
attributes :id, :chatable, :chatable_type, :chatable_url, :slug
def title
# Display all participants for a DM.
# For category channels, the argument is ignored.
object.title(nil)
end
end
@@ -0,0 +1,19 @@
# frozen_string_literal: true
require_dependency "reviewable_serializer"
class ReviewableAiChatMessageSerializer < ReviewableSerializer
payload_attributes :accuracies, :message_cooked
target_attributes :cooked
attributes :target_id
has_one :chat_channel, serializer: AiChatChannelSerializer, root: false, embed: :objects
def chat_channel
object.chat_message.chat_channel
end
def target_id
object.target&.id
end
end
@@ -0,0 +1,7 @@
# frozen_string_literal: true
require_dependency "reviewable_flagged_post_serializer"
class ReviewableAiPostSerializer < ReviewableFlaggedPostSerializer
payload_attributes :accuracies
end
@@ -0,0 +1,18 @@
<div class="bot-panel ai-bot-available-bot-options">
<div class="menu-panel drop-down">
<div class="panel-body">
<div class="panel-body-contents">
<div class="sidebar-hamburger-dropdown">
{{#each this.botNames as |bot|}}
<DButton
@class="btn-flat ai-bot-available-bot-content"
@translatedTitle={{bot.humanized}}
@translatedLabel={{bot.humanized}}
@action={{action "composeMessageWithTargetBot" bot.modelName}}
/>
{{/each}}
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,33 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import I18n from "I18n";
import { composeAiBotMessage } from "discourse/plugins/discourse-ai/discourse/lib/ai-bot-helper";
export default class AiBotHeaderPanel extends Component {
@service siteSettings;
@service composer;
@action
composeMessageWithTargetBot(target) {
this.#composeAiBotMessage(target);
}
get botNames() {
return this.enabledBotOptions.map((bot) => {
return {
humanized: I18n.t(`discourse_ai.ai_bot.bot_names.${bot}`),
modelName: bot,
};
});
}
get enabledBotOptions() {
return this.siteSettings.ai_bot_enabled_chat_bots.split("|");
}
#composeAiBotMessage(targetBot) {
this.args.closePanel();
composeAiBotMessage(targetBot, this.composer);
}
}
@@ -0,0 +1,239 @@
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>
}
@@ -0,0 +1,57 @@
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 I18n from "I18n";
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"
@title={{t "discourse_ai.ai_helper.context_menu.changes"}}
@closeModal={{@closeModal}}
>
<:body>
{{#if @diff}}
{{htmlSafe @diff}}
{{else}}
<div class="composer-ai-helper-modal__old-value">
{{@oldValue}}
</div>
<div class="composer-ai-helper-modal__new-value">
{{@newValue}}
</div>
{{/if}}
</:body>
<:footer>
<DButton
class="btn-primary confirm"
@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"
/>
</:footer>
</DModal>
</template>
}
@@ -0,0 +1,14 @@
{{#if @accuracies}}
<table class="reviewable-scores">
<tbody>
{{#each-in @accuracies as |model acc|}}
<tr>
<td colspan="4">{{i18n "discourse_ai.reviewables.model_used"}}</td>
<td colspan="3">{{model}}</td>
<td colspan="4">{{i18n "discourse_ai.reviewables.accuracy"}}</td>
<td colspan="3">{{acc}}%</td>
</tr>
{{/each-in}}
</tbody>
</table>
{{/if}}

Some files were not shown because too many files have changed in this diff Show More