Compare commits
69
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a84e253c5b | ||
|
|
fdadfa029e | ||
|
|
94f6c632bf | ||
|
|
41054c4fb8 | ||
|
|
943504049c | ||
|
|
a08d168740 | ||
|
|
0687ec75c3 | ||
|
|
72607c3560 | ||
|
|
6af415f7f0 | ||
|
|
1b209f5fb1 | ||
|
|
50c2d56aff | ||
|
|
f148452f4c | ||
|
|
c6aeabbfc0 | ||
|
|
eac83eb619 | ||
|
|
9019e90b87 | ||
|
|
3c7bd9bbd3 | ||
|
|
324af23b72 | ||
|
|
da9e192949 | ||
|
|
10dae65740 | ||
|
|
64641b6175 | ||
|
|
97fc822cb6 | ||
|
|
c0328267be | ||
|
|
dd63ade348 | ||
|
|
4a40531143 | ||
|
|
bfe3b1c3b8 | ||
|
|
f789d3ee96 | ||
|
|
867cd54556 | ||
|
|
23b88537d9 | ||
|
|
72980a3d84 | ||
|
|
14443bf890 | ||
|
|
f72ab12761 | ||
|
|
a1eb1ba180 | ||
|
|
cf1e15ef12 | ||
|
|
a7c0ddba97 | ||
|
|
0be292f247 | ||
|
|
1d6a6c9f8f | ||
|
|
1686a8a683 | ||
|
|
20efc9285e | ||
|
|
3b5245dc54 | ||
|
|
1254d7c7d0 | ||
|
|
7b4c099673 | ||
|
|
d17bbc2dc4 | ||
|
|
5c9f610fa4 | ||
|
|
2ff3fe3a9f | ||
|
|
acc3f817e1 | ||
|
|
948cf893a9 | ||
|
|
b7ac229547 | ||
|
|
7dc0a2a20a | ||
|
|
9d887ad4ac | ||
|
|
d4c90bc2ba | ||
|
|
c16c622b53 | ||
|
|
bed044448c | ||
|
|
73a2b15e91 | ||
|
|
7cd7f71857 | ||
|
|
665637fbad | ||
|
|
d58913b227 | ||
|
|
d9dad56c6a | ||
|
|
5c196bca89 | ||
|
|
06e239321b | ||
|
|
738cd99ad6 | ||
|
|
3502f0f1cd | ||
|
|
7f2c3a1ab9 | ||
|
|
e4145ad6a6 | ||
|
|
4d8090002f | ||
|
|
f328b81c78 | ||
|
|
0a8195242b | ||
|
|
5c1ab85583 | ||
|
|
08355ea5d8 | ||
|
|
8d4a67fbe2 |
@@ -1,8 +1,6 @@
|
||||
< 3.4.0.beta1-dev: 9d887ad4ace8e33c3fe7dbb39237e882c08b4f0b
|
||||
< 3.3.0.beta5-dev: 4d8090002f6dcd8e34d41033606bf131fa221475
|
||||
< 3.3.0.beta2-dev: 61890b667c06299841ae88946f84a112f00060e1
|
||||
< 3.3.0.beta1-dev: a65c4076a63104b61d213534db7f89aa3c8acd9e
|
||||
< 3.2.0.beta5-dev: c70f43f130fb98e4a1adb1afd9b621aaa49088e3
|
||||
< 3.2.0.beta4-dev: 493b48477a9288f2f1b6f021954088705a6673d6
|
||||
< 3.2.0.beta2-dev: 3bced1c6f525553fe2b5b79d29bd66c1cf2a47d5
|
||||
< 3.3.0.beta1-dev: d94234d89f50733678a5c62809445198a9fda74b
|
||||
3.2.999: d94234d89f50733678a5c62809445198a9fda74b
|
||||
3.1.999: c0415bb7eb878b1b7abf112d65cba981030df8df
|
||||
< 3.2.0.beta1-dev: 3e7c99de894b03e9161d9481da118fe893991a1d
|
||||
3.1.0.beta3: 057fbe1ce6
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ GEM
|
||||
rack (3.1.6)
|
||||
rainbow (3.1.1)
|
||||
regexp_parser (2.9.2)
|
||||
rexml (3.3.1)
|
||||
rexml (3.3.3)
|
||||
strscan
|
||||
rubocop (1.64.1)
|
||||
json (~> 2.3)
|
||||
|
||||
@@ -3,6 +3,7 @@ import DiscourseRoute from "discourse/routes/discourse";
|
||||
export default DiscourseRoute.extend({
|
||||
async model() {
|
||||
const record = this.store.createRecord("ai-llm");
|
||||
record.provider_params = {};
|
||||
return record;
|
||||
},
|
||||
|
||||
|
||||
+3
-1
@@ -4,7 +4,9 @@ export default DiscourseRoute.extend({
|
||||
async model(params) {
|
||||
const allLlms = this.modelFor("adminPlugins.show.discourse-ai-llms");
|
||||
const id = parseInt(params.id, 10);
|
||||
return allLlms.findBy("id", id);
|
||||
const record = allLlms.findBy("id", id);
|
||||
record.provider_params = record.provider_params || {};
|
||||
return record;
|
||||
},
|
||||
|
||||
setupController(controller, model) {
|
||||
|
||||
@@ -38,24 +38,32 @@ module DiscourseAi
|
||||
llm_model.toggle_companion_user
|
||||
render json: { ai_persona: LlmModelSerializer.new(llm_model) }, status: :created
|
||||
else
|
||||
render_json_error LlmModelSerializer.new(llm_model)
|
||||
render_json_error llm_model
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
llm_model = LlmModel.find(params[:id])
|
||||
|
||||
if llm_model.seeded?
|
||||
return render_json_error(I18n.t("discourse_ai.llm.cannot_edit_builtin"), status: 403)
|
||||
end
|
||||
|
||||
if llm_model.update(ai_llm_params(updating: llm_model))
|
||||
llm_model.toggle_companion_user
|
||||
render json: LlmModelSerializer.new(llm_model)
|
||||
else
|
||||
render_json_error LlmModelSerializer.new(llm_model)
|
||||
render_json_error llm_model
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
llm_model = LlmModel.find(params[:id])
|
||||
|
||||
if llm_model.seeded?
|
||||
return render_json_error(I18n.t("discourse_ai.llm.cannot_delete_builtin"), status: 403)
|
||||
end
|
||||
|
||||
in_use_by = DiscourseAi::Configuration::LlmValidator.new.modules_using(llm_model)
|
||||
|
||||
if !in_use_by.empty?
|
||||
@@ -97,6 +105,8 @@ module DiscourseAi
|
||||
private
|
||||
|
||||
def ai_llm_params(updating: nil)
|
||||
return {} if params[:ai_llm].blank?
|
||||
|
||||
permitted =
|
||||
params.require(:ai_llm).permit(
|
||||
:display_name,
|
||||
@@ -106,20 +116,30 @@ module DiscourseAi
|
||||
:max_prompt_tokens,
|
||||
:api_key,
|
||||
:enabled_chat_bot,
|
||||
:vision_enabled,
|
||||
)
|
||||
|
||||
provider = updating ? updating.provider : permitted[:provider]
|
||||
permit_url =
|
||||
(updating && updating.url != LlmModel::RESERVED_VLLM_SRV_URL) ||
|
||||
provider != LlmModel::BEDROCK_PROVIDER_NAME
|
||||
permit_url = provider != LlmModel::BEDROCK_PROVIDER_NAME
|
||||
|
||||
permitted[:url] = params.dig(:ai_llm, :url) if permit_url
|
||||
new_url = params.dig(:ai_llm, :url)
|
||||
permitted[:url] = new_url if permit_url && new_url
|
||||
|
||||
extra_field_names = LlmModel.provider_params.dig(provider&.to_sym, :fields).to_a
|
||||
received_prov_params = params.dig(:ai_llm, :provider_params)
|
||||
permitted[:provider_params] = received_prov_params.slice(
|
||||
*extra_field_names,
|
||||
).permit! if !extra_field_names.empty? && received_prov_params.present?
|
||||
extra_field_names = LlmModel.provider_params.dig(provider&.to_sym)
|
||||
if extra_field_names.present?
|
||||
received_prov_params =
|
||||
params.dig(:ai_llm, :provider_params)&.slice(*extra_field_names.keys)
|
||||
|
||||
if received_prov_params.present?
|
||||
received_prov_params.each do |pname, value|
|
||||
if extra_field_names[pname.to_sym] == :checkbox
|
||||
received_prov_params[pname] = ActiveModel::Type::Boolean.new.cast(value)
|
||||
end
|
||||
end
|
||||
|
||||
permitted[:provider_params] = received_prov_params.permit!
|
||||
end
|
||||
end
|
||||
|
||||
permitted
|
||||
end
|
||||
|
||||
@@ -63,7 +63,7 @@ module DiscourseAi
|
||||
|
||||
render json: LocalizedAiPersonaSerializer.new(@ai_persona, root: false)
|
||||
else
|
||||
render_json_error LocalizedAiPersonaSerializer.new(@ai_persona, root: false)
|
||||
render_json_error @ai_persona
|
||||
end
|
||||
end
|
||||
|
||||
@@ -71,7 +71,7 @@ module DiscourseAi
|
||||
if @ai_persona.destroy
|
||||
head :no_content
|
||||
else
|
||||
render_json_error LocalizedAiPersonaSerializer.new(@ai_persona, root: false)
|
||||
render_json_error @ai_persona
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ module DiscourseAi
|
||||
:description,
|
||||
:script,
|
||||
:summary,
|
||||
parameters: [:name, :type, :description, :required, :enum, enum_values: []],
|
||||
parameters: [:name, :type, :description, :required, enum: []],
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -89,18 +89,26 @@ module DiscourseAi
|
||||
end
|
||||
end
|
||||
|
||||
def explain
|
||||
def stream_suggestion
|
||||
post_id = get_post_param!
|
||||
term_to_explain = get_text_param!
|
||||
text = get_text_param!
|
||||
post = Post.includes(:topic).find_by(id: post_id)
|
||||
prompt = CompletionPrompt.find_by(id: params[:mode])
|
||||
|
||||
raise Discourse::InvalidParameters.new(:mode) if !prompt || !prompt.enabled?
|
||||
raise Discourse::InvalidParameters.new(:post_id) unless post
|
||||
|
||||
if prompt.id == CompletionPrompt::CUSTOM_PROMPT
|
||||
raise Discourse::InvalidParameters.new(:custom_prompt) if params[:custom_prompt].blank?
|
||||
end
|
||||
|
||||
Jobs.enqueue(
|
||||
:stream_post_helper,
|
||||
post_id: post.id,
|
||||
user_id: current_user.id,
|
||||
term_to_explain: term_to_explain,
|
||||
text: text,
|
||||
prompt: prompt.name,
|
||||
custom_prompt: params[:custom_prompt],
|
||||
)
|
||||
|
||||
render json: { success: true }, status: 200
|
||||
@@ -157,9 +165,13 @@ module DiscourseAi
|
||||
end
|
||||
|
||||
def ensure_can_request_suggestions
|
||||
if !current_user.in_any_groups?(SiteSetting.ai_helper_allowed_groups_map)
|
||||
raise Discourse::InvalidAccess
|
||||
end
|
||||
allowed_groups =
|
||||
(
|
||||
SiteSetting.composer_ai_helper_allowed_groups_map |
|
||||
SiteSetting.post_ai_helper_allowed_groups_map
|
||||
)
|
||||
|
||||
raise Discourse::InvalidAccess if !current_user.in_any_groups?(allowed_groups)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -9,6 +9,7 @@ module DiscourseAi
|
||||
|
||||
def search
|
||||
query = params[:q].to_s
|
||||
skip_hyde = params[:hyde].downcase.to_s == "false" || params[:hyde].to_s == "0"
|
||||
|
||||
if query.length < SiteSetting.min_search_term_length
|
||||
raise Discourse::InvalidParameters.new(:q)
|
||||
@@ -31,7 +32,7 @@ module DiscourseAi
|
||||
|
||||
hijack do
|
||||
semantic_search
|
||||
.search_for_topics(query)
|
||||
.search_for_topics(query, _page = 1, hyde: !skip_hyde)
|
||||
.each { |topic_post| grouped_results.add(topic_post) }
|
||||
|
||||
render_serialized(grouped_results, GroupedSearchResultSerializer, result: grouped_results)
|
||||
@@ -39,6 +40,9 @@ module DiscourseAi
|
||||
end
|
||||
|
||||
def quick_search
|
||||
# this search function searches posts (vs: topics)
|
||||
# it requires post embeddings and a reranker
|
||||
# it will not perform a hyde expantion
|
||||
query = params[:q].to_s
|
||||
|
||||
if query.length < SiteSetting.min_search_term_length
|
||||
|
||||
@@ -14,19 +14,32 @@ module DiscourseAi
|
||||
RateLimiter.new(current_user, "summary", 6, 5.minutes).performed! if current_user
|
||||
|
||||
opts = params.permit(:skip_age_check)
|
||||
skip_age_check = opts[:skip_age_check] == "true"
|
||||
|
||||
if params[:stream] && current_user
|
||||
cached_summary = DiscourseAi::TopicSummarization.cached_summary(topic, current_user)
|
||||
|
||||
if cached_summary && !skip_age_check
|
||||
render_serialized(cached_summary, AiTopicSummarySerializer)
|
||||
return
|
||||
end
|
||||
|
||||
Jobs.enqueue(
|
||||
:stream_topic_ai_summary,
|
||||
topic_id: topic.id,
|
||||
user_id: current_user.id,
|
||||
opts: opts.as_json,
|
||||
skip_age_check: skip_age_check,
|
||||
)
|
||||
|
||||
render json: success_json
|
||||
else
|
||||
hijack do
|
||||
summary = DiscourseAi::TopicSummarization.summarize(topic, current_user, opts)
|
||||
summary =
|
||||
DiscourseAi::TopicSummarization.summarize(
|
||||
topic,
|
||||
current_user,
|
||||
skip_age_check: skip_age_check,
|
||||
)
|
||||
render_serialized(summary, AiTopicSummarySerializer)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -7,27 +7,35 @@ module Jobs
|
||||
def execute(args)
|
||||
return unless post = Post.includes(:topic).find_by(id: args[:post_id])
|
||||
return unless user = User.find_by(id: args[:user_id])
|
||||
return unless args[:term_to_explain]
|
||||
return unless args[:text]
|
||||
|
||||
topic = post.topic
|
||||
reply_to = post.reply_to_post
|
||||
|
||||
return unless user.guardian.can_see?(post)
|
||||
|
||||
prompt = CompletionPrompt.enabled_by_name("explain")
|
||||
prompt = CompletionPrompt.enabled_by_name(args[:prompt])
|
||||
|
||||
input = <<~TEXT
|
||||
<term>#{args[:term_to_explain]}</term>
|
||||
if prompt.id == CompletionPrompt::CUSTOM_PROMPT
|
||||
prompt.custom_instruction = args[:custom_prompt]
|
||||
end
|
||||
|
||||
if prompt.name == "explain"
|
||||
input = <<~TEXT
|
||||
<term>#{args[:text]}</term>
|
||||
<context>#{post.raw}</context>
|
||||
<topic>#{topic.title}</topic>
|
||||
#{reply_to ? "<replyTo>#{reply_to.raw}</replyTo>" : nil}
|
||||
TEXT
|
||||
else
|
||||
input = args[:text]
|
||||
end
|
||||
|
||||
DiscourseAi::AiHelper::Assistant.new.stream_prompt(
|
||||
prompt,
|
||||
input,
|
||||
user,
|
||||
"/discourse-ai/ai-helper/explain/#{post.id}",
|
||||
"/discourse-ai/ai-helper/stream_suggestion/#{post.id}",
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -14,15 +14,15 @@ module Jobs
|
||||
guardian = Guardian.new(user)
|
||||
return unless guardian.can_see?(topic)
|
||||
|
||||
opts = args[:opts] || {}
|
||||
skip_age_check = !!args[:skip_age_check]
|
||||
|
||||
streamed_summary = +""
|
||||
start = Time.now
|
||||
|
||||
summary =
|
||||
DiscourseAi::TopicSummarization
|
||||
.new(strategy)
|
||||
.summarize(topic, user, opts) do |partial_summary|
|
||||
.new(strategy, topic, user)
|
||||
.summarize(skip_age_check: skip_age_check) do |partial_summary|
|
||||
streamed_summary << partial_summary
|
||||
|
||||
# Throttle updates.
|
||||
|
||||
@@ -12,6 +12,13 @@ module ::Jobs
|
||||
return unless post.topic.custom_fields[DiscourseAi::AiBot::EntryPoint::REQUIRE_TITLE_UPDATE]
|
||||
|
||||
DiscourseAi::AiBot::Playground.new(bot).title_playground(post)
|
||||
|
||||
publish_update(post.topic, { title: post.topic.title })
|
||||
end
|
||||
|
||||
def publish_update(topic, payload)
|
||||
allowed_users = topic.topic_allowed_users.pluck(:user_id)
|
||||
MessageBus.publish("/discourse-ai/ai-bot/topic/#{topic.id}", payload, user_ids: allowed_users)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
+38
-38
@@ -2,59 +2,41 @@
|
||||
|
||||
class LlmModel < ActiveRecord::Base
|
||||
FIRST_BOT_USER_ID = -1200
|
||||
RESERVED_VLLM_SRV_URL = "https://vllm.shadowed-by-srv.invalid"
|
||||
BEDROCK_PROVIDER_NAME = "aws_bedrock"
|
||||
|
||||
belongs_to :user
|
||||
|
||||
validates :url, exclusion: { in: [RESERVED_VLLM_SRV_URL] }, if: :url_changed?
|
||||
|
||||
def self.seed_srv_backed_model
|
||||
srv = SiteSetting.ai_vllm_endpoint_srv
|
||||
srv_model = find_by(url: RESERVED_VLLM_SRV_URL)
|
||||
|
||||
if srv.present?
|
||||
if srv_model.present?
|
||||
current_key = SiteSetting.ai_vllm_api_key
|
||||
srv_model.update!(api_key: current_key) if current_key != srv_model.api_key
|
||||
else
|
||||
record =
|
||||
new(
|
||||
display_name: "vLLM SRV LLM",
|
||||
name: "mistralai/Mixtral-8x7B-Instruct-v0.1",
|
||||
provider: "vllm",
|
||||
tokenizer: "DiscourseAi::Tokenizer::MixtralTokenizer",
|
||||
url: RESERVED_VLLM_SRV_URL,
|
||||
max_prompt_tokens: 8000,
|
||||
api_key: SiteSetting.ai_vllm_api_key,
|
||||
)
|
||||
|
||||
record.save(validate: false) # Ignore reserved URL validation
|
||||
end
|
||||
else
|
||||
# Clean up companion users
|
||||
srv_model&.enabled_chat_bot = false
|
||||
srv_model&.toggle_companion_user
|
||||
|
||||
srv_model&.destroy!
|
||||
end
|
||||
end
|
||||
validates :display_name, presence: true, length: { maximum: 100 }
|
||||
validates :tokenizer, presence: true, inclusion: DiscourseAi::Completions::Llm.tokenizer_names
|
||||
validates :provider, presence: true, inclusion: DiscourseAi::Completions::Llm.provider_names
|
||||
validates :url, presence: true, unless: -> { provider == BEDROCK_PROVIDER_NAME }
|
||||
validates_presence_of :name, :api_key
|
||||
validates :max_prompt_tokens, numericality: { greater_than: 0 }
|
||||
validate :required_provider_params
|
||||
|
||||
def self.provider_params
|
||||
{
|
||||
aws_bedrock: {
|
||||
url_editable: false,
|
||||
fields: %i[access_key_id region],
|
||||
access_key_id: :text,
|
||||
region: :text,
|
||||
},
|
||||
open_ai: {
|
||||
url_editable: true,
|
||||
fields: %i[organization],
|
||||
organization: :text,
|
||||
},
|
||||
hugging_face: {
|
||||
disable_system_prompt: :checkbox,
|
||||
},
|
||||
vllm: {
|
||||
disable_system_prompt: :checkbox,
|
||||
},
|
||||
ollama: {
|
||||
disable_system_prompt: :checkbox,
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
def to_llm
|
||||
DiscourseAi::Completions::Llm.proxy_from_obj(self)
|
||||
DiscourseAi::Completions::Llm.proxy("custom:#{id}")
|
||||
end
|
||||
|
||||
def toggle_companion_user
|
||||
@@ -106,6 +88,22 @@ class LlmModel < ActiveRecord::Base
|
||||
def lookup_custom_param(key)
|
||||
provider_params&.dig(key)
|
||||
end
|
||||
|
||||
def seeded?
|
||||
id < 0
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def required_provider_params
|
||||
return if provider != BEDROCK_PROVIDER_NAME
|
||||
|
||||
%w[access_key_id region].each do |field|
|
||||
if lookup_custom_param(field).blank?
|
||||
errors.add(:base, I18n.t("discourse_ai.llm_models.missing_provider_param", param: field))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# == Schema Information
|
||||
@@ -124,4 +122,6 @@ end
|
||||
# api_key :string
|
||||
# user_id :integer
|
||||
# enabled_chat_bot :boolean default(FALSE), not null
|
||||
# provider_params :jsonb
|
||||
# vision_enabled :boolean default(FALSE), not null
|
||||
#
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AiTopicSummarySerializer < ApplicationSerializer
|
||||
attributes :summarized_text, :algorithm, :outdated, :can_regenerate, :new_posts_since_summary
|
||||
attributes :summarized_text,
|
||||
:algorithm,
|
||||
:outdated,
|
||||
:can_regenerate,
|
||||
:new_posts_since_summary,
|
||||
:updated_at
|
||||
|
||||
def can_regenerate
|
||||
scope.can_request_summary?
|
||||
|
||||
@@ -12,12 +12,25 @@ class LlmModelSerializer < ApplicationSerializer
|
||||
:api_key,
|
||||
:url,
|
||||
:enabled_chat_bot,
|
||||
:shadowed_by_srv,
|
||||
:provider_params
|
||||
:provider_params,
|
||||
:vision_enabled,
|
||||
:used_by
|
||||
|
||||
has_one :user, serializer: BasicUserSerializer, embed: :object
|
||||
|
||||
def shadowed_by_srv
|
||||
object.url == LlmModel::RESERVED_VLLM_SRV_URL
|
||||
def used_by
|
||||
DiscourseAi::Configuration::LlmValidator.new.modules_using(object)
|
||||
end
|
||||
|
||||
def api_key
|
||||
object.seeded? ? "********" : object.api_key
|
||||
end
|
||||
|
||||
def url
|
||||
object.seeded? ? "********" : object.url
|
||||
end
|
||||
|
||||
def provider
|
||||
object.seeded? ? "CDCK" : object.provider
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,33 +2,36 @@
|
||||
|
||||
module DiscourseAi
|
||||
class TopicSummarization
|
||||
def self.summarize(topic, user, opts = {}, &on_partial_blk)
|
||||
new(DiscourseAi::Summarization.default_strategy).summarize(topic, user, opts, &on_partial_blk)
|
||||
def self.summarize(topic, user, skip_age_check: false, &on_partial_blk)
|
||||
new(DiscourseAi::Summarization.default_strategy, topic, user).summarize(
|
||||
skip_age_check: skip_age_check,
|
||||
&on_partial_blk
|
||||
)
|
||||
end
|
||||
|
||||
def initialize(strategy)
|
||||
def self.cached_summary(topic, user)
|
||||
new(DiscourseAi::Summarization.default_strategy, topic, user).cached_summary
|
||||
end
|
||||
|
||||
def initialize(strategy, topic, user)
|
||||
@strategy = strategy
|
||||
@topic = topic
|
||||
@user = user
|
||||
end
|
||||
|
||||
def summarize(topic, user, opts = {}, &on_partial_blk)
|
||||
existing_summary = AiSummary.find_by(target: topic)
|
||||
attr_reader :strategy, :topic, :user
|
||||
|
||||
def cached_summary
|
||||
existing_summary
|
||||
end
|
||||
|
||||
def summarize(skip_age_check: false, &on_partial_blk)
|
||||
# Existing summary shouldn't be nil in this scenario because the controller checks its existence.
|
||||
return if !user && !existing_summary
|
||||
|
||||
targets_data = summary_targets(topic).pluck(:post_number, :raw, :username)
|
||||
return existing_summary if use_cached?(skip_age_check)
|
||||
|
||||
current_topic_sha = build_sha(targets_data.map(&:first))
|
||||
can_summarize = Guardian.new(user).can_request_summary?
|
||||
|
||||
if use_cached?(existing_summary, can_summarize, current_topic_sha, !!opts[:skip_age_check])
|
||||
# It's important that we signal a cached summary is outdated
|
||||
existing_summary.mark_as_outdated if new_targets?(existing_summary, current_topic_sha)
|
||||
|
||||
return existing_summary
|
||||
end
|
||||
|
||||
delete_cached_summaries_of(topic) if existing_summary
|
||||
delete_cached_summaries! if existing_summary
|
||||
|
||||
content = {
|
||||
resource_path: "#{Discourse.base_path}/t/-/#{topic.id}",
|
||||
@@ -36,7 +39,7 @@ module DiscourseAi
|
||||
contents: [],
|
||||
}
|
||||
|
||||
targets_data.map do |(pn, raw, username)|
|
||||
summary_targets_data.map do |(pn, raw, username)|
|
||||
raw_text = raw
|
||||
|
||||
if pn == 1 && topic.topic_embed&.embed_content_cache.present?
|
||||
@@ -47,19 +50,34 @@ module DiscourseAi
|
||||
end
|
||||
|
||||
summarization_result = strategy.summarize(content, user, &on_partial_blk)
|
||||
|
||||
cache_summary(summarization_result, targets_data.map(&:first), topic)
|
||||
cache_summary(summarization_result)
|
||||
end
|
||||
|
||||
def summary_targets(topic)
|
||||
topic.has_summary? ? best_replies(topic) : pick_selection(topic)
|
||||
def summary_targets
|
||||
topic.has_summary? ? best_replies : pick_selection
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :strategy
|
||||
def summary_sha
|
||||
@summary_sha ||= build_sha(summary_targets_data.map(&:first))
|
||||
end
|
||||
|
||||
def best_replies(topic)
|
||||
def summary_targets_data
|
||||
@summary_targets_data ||= summary_targets.pluck(:post_number, :raw, :username)
|
||||
end
|
||||
|
||||
def existing_summary
|
||||
if !defined?(@existing_summary)
|
||||
@existing_summary = AiSummary.find_by(target: topic)
|
||||
if @existing_summary && existing_summary.original_content_sha != summary_sha
|
||||
@existing_summary.mark_as_outdated
|
||||
end
|
||||
end
|
||||
@existing_summary
|
||||
end
|
||||
|
||||
def best_replies
|
||||
Post
|
||||
.summary(topic.id)
|
||||
.where("post_type = ?", Post.types[:regular])
|
||||
@@ -68,7 +86,7 @@ module DiscourseAi
|
||||
.order(:post_number)
|
||||
end
|
||||
|
||||
def pick_selection(topic)
|
||||
def pick_selection
|
||||
posts =
|
||||
Post
|
||||
.where(topic_id: topic.id)
|
||||
@@ -87,31 +105,34 @@ module DiscourseAi
|
||||
.order(:post_number)
|
||||
end
|
||||
|
||||
def delete_cached_summaries_of(topic)
|
||||
def delete_cached_summaries!
|
||||
AiSummary.where(target: topic).destroy_all
|
||||
end
|
||||
|
||||
# For users without permissions to generate a summary or fresh summaries, we return what we have cached.
|
||||
def use_cached?(existing_summary, can_summarize, current_sha, skip_age_check)
|
||||
def use_cached?(skip_age_check)
|
||||
can_summarize = Guardian.new(user).can_request_summary?
|
||||
|
||||
existing_summary &&
|
||||
!(
|
||||
can_summarize && new_targets?(existing_summary, current_sha) &&
|
||||
can_summarize && new_targets? &&
|
||||
(skip_age_check || existing_summary.created_at < 1.hour.ago)
|
||||
)
|
||||
end
|
||||
|
||||
def new_targets?(summary, current_sha)
|
||||
summary.original_content_sha != current_sha
|
||||
def new_targets?
|
||||
existing_summary&.original_content_sha != summary_sha
|
||||
end
|
||||
|
||||
def cache_summary(result, post_numbers, topic)
|
||||
def cache_summary(result)
|
||||
post_numbers = summary_targets_data.map(&:first)
|
||||
|
||||
cached_summary =
|
||||
AiSummary.create!(
|
||||
target: topic,
|
||||
algorithm: strategy.display_name,
|
||||
content_range: (post_numbers.first..post_numbers.last),
|
||||
summarized_text: result[:summary],
|
||||
original_content_sha: build_sha(post_numbers),
|
||||
original_content_sha: summary_sha,
|
||||
)
|
||||
|
||||
cached_summary
|
||||
|
||||
@@ -13,7 +13,8 @@ export default class AiLlm extends RestModel {
|
||||
"url",
|
||||
"api_key",
|
||||
"enabled_chat_bot",
|
||||
"provider_params"
|
||||
"provider_params",
|
||||
"vision_enabled"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,22 +20,25 @@ export default class AiTool extends RestModel {
|
||||
return this.getProperties(CREATE_ATTRIBUTES);
|
||||
}
|
||||
|
||||
workingCopy() {
|
||||
const attrs = this.getProperties(CREATE_ATTRIBUTES);
|
||||
|
||||
attrs.parameters = new TrackedArray(
|
||||
attrs.parameters?.map((p) => {
|
||||
trackParameters(parameters) {
|
||||
return new TrackedArray(
|
||||
parameters?.map((p) => {
|
||||
const parameter = new TrackedObject(p);
|
||||
|
||||
if (parameter.enum_values) {
|
||||
parameter.enumValues = new TrackedArray(parameter.enum_values);
|
||||
delete parameter.enum_values;
|
||||
if (parameter.enum && parameter.enum.length) {
|
||||
parameter.enum = new TrackedArray(parameter.enum);
|
||||
} else {
|
||||
parameter.enum = null;
|
||||
}
|
||||
|
||||
return parameter;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
workingCopy() {
|
||||
const attrs = this.getProperties(CREATE_ATTRIBUTES);
|
||||
attrs.parameters = this.trackParameters(attrs.parameters);
|
||||
return this.store.createRecord("ai-tool", attrs);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
import DButton from "discourse/components/d-button";
|
||||
import concatClass from "discourse/helpers/concat-class";
|
||||
|
||||
const AiHelperButtonGroup = <template>
|
||||
<ul class="ai-helper-button-group" ...attributes>
|
||||
{{#each @buttons as |button|}}
|
||||
<li>
|
||||
<DButton
|
||||
@icon={{button.icon}}
|
||||
@label={{button.label}}
|
||||
@action={{button.action}}
|
||||
class={{concatClass "btn-flat" button.classes}}
|
||||
/>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
</template>;
|
||||
|
||||
export default AiHelperButtonGroup;
|
||||
@@ -1,38 +1,40 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { Input } from "@ember/component";
|
||||
import { fn } from "@ember/helper";
|
||||
import { on } from "@ember/modifier";
|
||||
import { action } from "@ember/object";
|
||||
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
|
||||
import DButton from "discourse/components/d-button";
|
||||
import withEventValue from "discourse/helpers/with-event-value";
|
||||
import autoFocus from "discourse/modifiers/auto-focus";
|
||||
import i18n from "discourse-common/helpers/i18n";
|
||||
import not from "truth-helpers/helpers/not";
|
||||
|
||||
export default class AiHelperCustomPrompt extends Component {
|
||||
@tracked _customPromptInput;
|
||||
|
||||
@action
|
||||
setupCustomPrompt() {
|
||||
this._customPromptInput = document.querySelector(
|
||||
".ai-custom-prompt__input"
|
||||
);
|
||||
this._customPromptInput.focus();
|
||||
}
|
||||
|
||||
@action
|
||||
sendInput() {
|
||||
sendInput(event) {
|
||||
if (event.key !== "Enter") {
|
||||
return;
|
||||
}
|
||||
return this.args.submit(this.args.promptArgs);
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="ai-custom-prompt" {{didInsert this.setupCustomPrompt}}>
|
||||
<Input
|
||||
@value={{@value}}
|
||||
<div class="ai-custom-prompt">
|
||||
|
||||
<input
|
||||
{{on "input" (withEventValue (fn (mut @value)))}}
|
||||
{{on "keydown" this.sendInput}}
|
||||
value={{@value}}
|
||||
placeholder={{i18n
|
||||
"discourse_ai.ai_helper.context_menu.custom_prompt.placeholder"
|
||||
}}
|
||||
class="ai-custom-prompt__input"
|
||||
@enter={{this.sendInput}}
|
||||
type="text"
|
||||
{{!-- Using {{autoFocus}} helper instead of built in autofocus="autofocus"
|
||||
because built in autofocus doesn't work consistently when component is
|
||||
invoked twice separetly without a page refresh.
|
||||
(i.e. trigger in post AI helper followed by trigger in composer AI helper)
|
||||
--}}
|
||||
{{autoFocus}}
|
||||
/>
|
||||
|
||||
<DButton
|
||||
|
||||
@@ -2,7 +2,7 @@ import DButton from "discourse/components/d-button";
|
||||
import i18n from "discourse-common/helpers/i18n";
|
||||
|
||||
const AiHelperLoading = <template>
|
||||
<div class="ai-helper-context-menu__loading">
|
||||
<div class="ai-helper-loading">
|
||||
<div class="dot-falling"></div>
|
||||
<span>
|
||||
{{i18n "discourse_ai.ai_helper.context_menu.loading"}}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { fn } from "@ember/helper";
|
||||
import DButton from "discourse/components/d-button";
|
||||
import eq from "truth-helpers/helpers/eq";
|
||||
import AiHelperCustomPrompt from "../components/ai-helper-custom-prompt";
|
||||
|
||||
const AiHelperOptionsList = <template>
|
||||
<ul class="ai-helper-options">
|
||||
{{#each @options as |option|}}
|
||||
{{#if (eq option.name "custom_prompt")}}
|
||||
<AiHelperCustomPrompt
|
||||
@value={{@customPromptValue}}
|
||||
@promptArgs={{option}}
|
||||
@submit={{@performAction}}
|
||||
/>
|
||||
{{else}}
|
||||
<li data-name={{option.translated_name}} data-value={{option.id}}>
|
||||
<DButton
|
||||
@icon={{option.icon}}
|
||||
@translatedLabel={{option.translated_name}}
|
||||
@action={{fn @performAction option}}
|
||||
data-name={{option.name}}
|
||||
data-value={{option.id}}
|
||||
class="btn-flat ai-helper-options__button"
|
||||
/>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
</ul>
|
||||
</template>;
|
||||
|
||||
export default AiHelperOptionsList;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -6,13 +6,13 @@ import { action } from "@ember/object";
|
||||
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
|
||||
import didUpdate from "@ember/render-modifiers/modifiers/did-update";
|
||||
import { service } from "@ember/service";
|
||||
import AceEditor from "discourse/components/ace-editor";
|
||||
import BackButton from "discourse/components/back-button";
|
||||
import DButton from "discourse/components/d-button";
|
||||
import DTooltip from "discourse/components/d-tooltip";
|
||||
import withEventValue from "discourse/helpers/with-event-value";
|
||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
import I18n from "discourse-i18n";
|
||||
import AceEditor from "admin/components/ace-editor";
|
||||
import ComboBox from "select-kit/components/combo-box";
|
||||
import AiToolParameterEditor from "./ai-tool-parameter-editor";
|
||||
import AiToolTestModal from "./modal/ai-tool-test-modal";
|
||||
@@ -25,6 +25,7 @@ export default class AiToolEditor extends Component {
|
||||
@service dialog;
|
||||
@service modal;
|
||||
@service toasts;
|
||||
@service store;
|
||||
|
||||
@tracked isSaving = false;
|
||||
@tracked editingModel = null;
|
||||
@@ -53,8 +54,9 @@ export default class AiToolEditor extends Component {
|
||||
@action
|
||||
configurePreset() {
|
||||
this.selectedPreset = this.args.presets.findBy("preset_id", this.presetId);
|
||||
this.editingModel = this.args.model.workingCopy();
|
||||
this.editingModel.setProperties(this.selectedPreset);
|
||||
this.editingModel = this.store
|
||||
.createRecord("ai-tool", this.selectedPreset)
|
||||
.workingCopy();
|
||||
this.showDelete = false;
|
||||
}
|
||||
|
||||
@@ -71,13 +73,6 @@ export default class AiToolEditor extends Component {
|
||||
"summary"
|
||||
);
|
||||
|
||||
for (const p of data.parameters) {
|
||||
if (p.enumValues) {
|
||||
p.enum_values = p.enumValues;
|
||||
delete p.enumValues;
|
||||
}
|
||||
}
|
||||
|
||||
await this.args.model.save(data);
|
||||
|
||||
this.toasts.success({
|
||||
@@ -124,7 +119,7 @@ export default class AiToolEditor extends Component {
|
||||
<template>
|
||||
<BackButton
|
||||
@route="adminPlugins.show.discourse-ai-tools"
|
||||
@label="discourse_ai.ai_tool.back"
|
||||
@label="discourse_ai.tools.back"
|
||||
/>
|
||||
|
||||
<form
|
||||
@@ -199,7 +194,7 @@ export default class AiToolEditor extends Component {
|
||||
<label>{{I18n.t "discourse_ai.tools.script"}}</label>
|
||||
<AceEditor
|
||||
@content={{this.editingModel.script}}
|
||||
@onChange={{withEventValue (fn (mut this.editingModel.script))}}
|
||||
@onChange={{fn (mut this.editingModel.script)}}
|
||||
@mode={{ACE_EDITOR_MODE}}
|
||||
@theme={{ACE_EDITOR_THEME}}
|
||||
@editorId="ai-tool-script-editor"
|
||||
|
||||
@@ -24,8 +24,7 @@ export default class AiToolParameterEditor extends Component {
|
||||
description: "",
|
||||
type: "string",
|
||||
required: false,
|
||||
enum: false,
|
||||
enumValues: null,
|
||||
enum: null,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -43,26 +42,30 @@ export default class AiToolParameterEditor extends Component {
|
||||
|
||||
@action
|
||||
toggleEnum(parameter) {
|
||||
parameter.enum = !parameter.enum;
|
||||
if (!parameter.enum) {
|
||||
parameter.enumValues = null;
|
||||
if (parameter.enum) {
|
||||
parameter.enum = null;
|
||||
} else {
|
||||
this.addEnumValue(parameter);
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
addEnumValue(parameter) {
|
||||
parameter.enumValues ||= new TrackedArray();
|
||||
parameter.enumValues.push("");
|
||||
parameter.enum ||= new TrackedArray();
|
||||
parameter.enum.push("");
|
||||
}
|
||||
|
||||
@action
|
||||
removeEnumValue(parameter, index) {
|
||||
parameter.enumValues.splice(index, 1);
|
||||
parameter.enum.splice(index, 1);
|
||||
if (parameter.enum.length === 0) {
|
||||
parameter.enum = null;
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
updateEnumValue(parameter, index, event) {
|
||||
parameter.enumValues[index] = event.target.value;
|
||||
parameter.enum[index] = event.target.value;
|
||||
}
|
||||
|
||||
<template>
|
||||
@@ -93,6 +96,7 @@ export default class AiToolParameterEditor extends Component {
|
||||
{{on "input" (fn this.toggleRequired parameter)}}
|
||||
checked={{parameter.required}}
|
||||
type="checkbox"
|
||||
class="parameter-row__required-toggle"
|
||||
/>
|
||||
{{I18n.t "discourse_ai.tools.parameter_required"}}
|
||||
</label>
|
||||
@@ -102,6 +106,7 @@ export default class AiToolParameterEditor extends Component {
|
||||
{{on "input" (fn this.toggleEnum parameter)}}
|
||||
checked={{parameter.enum}}
|
||||
type="checkbox"
|
||||
class="parameter-row__enum-toggle"
|
||||
/>
|
||||
{{I18n.t "discourse_ai.tools.parameter_enum"}}
|
||||
</label>
|
||||
@@ -115,7 +120,7 @@ export default class AiToolParameterEditor extends Component {
|
||||
|
||||
{{#if parameter.enum}}
|
||||
<div class="parameter-enum-values">
|
||||
{{#each parameter.enumValues as |enumValue enumIndex|}}
|
||||
{{#each parameter.enum as |enumValue enumIndex|}}
|
||||
<div class="enum-value-row">
|
||||
<input
|
||||
{{on "change" (fn this.updateEnumValue parameter enumIndex)}}
|
||||
|
||||
@@ -1,21 +1,86 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { action } from "@ember/object";
|
||||
import { next } from "@ember/runloop";
|
||||
import { inject as service } from "@ember/service";
|
||||
import { htmlSafe } from "@ember/template";
|
||||
import CookText from "discourse/components/cook-text";
|
||||
import DButton from "discourse/components/d-button";
|
||||
import DModal from "discourse/components/d-modal";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
import i18n from "discourse-common/helpers/i18n";
|
||||
|
||||
export default class ModalDiffModal extends Component {
|
||||
@service currentUser;
|
||||
@tracked loading = false;
|
||||
@tracked diff;
|
||||
suggestion = "";
|
||||
|
||||
PROOFREAD_ID = -303;
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.diff = this.args.model.diff;
|
||||
|
||||
next(() => {
|
||||
if (this.args.model.toolbarEvent) {
|
||||
this.loadDiff();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async loadDiff() {
|
||||
this.loading = true;
|
||||
|
||||
try {
|
||||
const suggestion = await ajax("/discourse-ai/ai-helper/suggest", {
|
||||
method: "POST",
|
||||
data: {
|
||||
mode: this.PROOFREAD_ID,
|
||||
text: this.selectedText,
|
||||
force_default_locale: true,
|
||||
},
|
||||
});
|
||||
|
||||
this.diff = suggestion.diff;
|
||||
this.suggestion = suggestion.suggestions[0];
|
||||
} catch (e) {
|
||||
popupAjaxError(e);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
get selectedText() {
|
||||
const selected = this.args.model.toolbarEvent.selected;
|
||||
|
||||
if (selected.value === "") {
|
||||
return selected.pre + selected.post;
|
||||
}
|
||||
|
||||
return selected.value;
|
||||
}
|
||||
|
||||
@action
|
||||
triggerConfirmChanges() {
|
||||
this.args.closeModal();
|
||||
this.args.confirm();
|
||||
if (this.args.model.confirm) {
|
||||
this.args.model.confirm();
|
||||
}
|
||||
|
||||
if (this.args.model.toolbarEvent && this.suggestion) {
|
||||
this.args.model.toolbarEvent.replaceText(
|
||||
this.selectedText,
|
||||
this.suggestion
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
triggerRevertChanges() {
|
||||
this.args.model.revert();
|
||||
this.args.closeModal();
|
||||
this.args.revert();
|
||||
}
|
||||
|
||||
<template>
|
||||
@@ -25,30 +90,46 @@ export default class ModalDiffModal extends Component {
|
||||
@closeModal={{@closeModal}}
|
||||
>
|
||||
<:body>
|
||||
{{#if @diff}}
|
||||
{{htmlSafe @diff}}
|
||||
{{#if this.loading}}
|
||||
<div class="composer-ai-helper-modal__loading">
|
||||
<CookText @rawText={{this.selectedText}} />
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="composer-ai-helper-modal__old-value">
|
||||
{{@oldValue}}
|
||||
</div>
|
||||
{{#if this.diff}}
|
||||
{{htmlSafe this.diff}}
|
||||
{{else}}
|
||||
<div class="composer-ai-helper-modal__old-value">
|
||||
{{@model.oldValue}}
|
||||
</div>
|
||||
|
||||
<div class="composer-ai-helper-modal__new-value">
|
||||
{{@newValue}}
|
||||
</div>
|
||||
<div class="composer-ai-helper-modal__new-value">
|
||||
{{@model.newValue}}
|
||||
</div>
|
||||
{{/if}}
|
||||
{{/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"
|
||||
/>
|
||||
{{#if this.loading}}
|
||||
<DButton
|
||||
class="btn-primary"
|
||||
@label="discourse_ai.ai_helper.context_menu.loading"
|
||||
@disabled={{true}}
|
||||
/>
|
||||
{{else}}
|
||||
<DButton
|
||||
class="btn-primary confirm"
|
||||
@action={{this.triggerConfirmChanges}}
|
||||
@label="discourse_ai.ai_helper.context_menu.confirm"
|
||||
/>
|
||||
{{#if @model.revert}}
|
||||
<DButton
|
||||
class="btn-flat revert"
|
||||
@action={{this.triggerRevertChanges}}
|
||||
@label="discourse_ai.ai_helper.context_menu.revert"
|
||||
/>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</:footer>
|
||||
</DModal>
|
||||
</template>
|
||||
|
||||
@@ -53,7 +53,7 @@ export default class ThumbnailSuggestions extends Component {
|
||||
>
|
||||
<:body>
|
||||
<div class="ai-thumbnail-suggestions">
|
||||
{{#each @thumbnails as |thumbnail|}}
|
||||
{{#each @model.thumbnails as |thumbnail|}}
|
||||
<ThumbnailSuggestionItem
|
||||
@thumbnail={{thumbnail}}
|
||||
@addSelection={{this.addSelection}}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user