Compare commits

...
35 Commits
Author SHA1 Message Date
Roman Rizzi 822594f3e3 Update db/migrate/20250716005855_copy_ai_image_caption_model_to_persona_default.rb 2025-07-21 18:00:01 -03:00
Keegan George 7dee59c97f DEV: Remove no longer needed seeded model check 2025-07-21 08:08:20 -07:00
Keegan George fbbe01e576 DEV: Hide seeded LLMs in the enumerator for now 2025-07-21 07:45:27 -07:00
Keegan George f420261196 DEV: Apply feedback from review 2025-07-18 12:17:08 -07:00
Keegan George eb93e1736b Final spec fix 🤞 2025-07-17 14:46:15 -07:00
Keegan George cc34882074 FIX: rest of specs 2025-07-17 14:34:29 -07:00
Keegan George 4b03cdcc96 FIX: LLM controller spec 2025-07-17 13:31:26 -07:00
Keegan George d9b53b4a3d FIX: semantic search 2025-07-17 12:23:38 -07:00
Keegan George a927fd2270 fix 2025-07-17 12:00:05 -07:00
Keegan George f077d58abf FIX: automation spec 2025-07-17 11:48:28 -07:00
Keegan George 08d2f3ddf9 FIX: spam 2025-07-17 11:36:19 -07:00
Keegan George 273a1fa618 FIX: occurrences of old model setting 2025-07-17 09:43:45 -07:00
Keegan George 7af3ce820d DEV: Remove references to translation model 2025-07-17 09:27:08 -07:00
Keegan George 5841543bd9 FIX: validator 2025-07-16 14:51:32 -07:00
Keegan George 5ae80645bb Merge branch 'main' into default-llm-model 2025-07-16 12:07:10 -07:00
Keegan George b675c4c39b DEV: Remove custom prefix in specs 2025-07-16 11:39:55 -07:00
Keegan George 0fadf1da1a DEV: update enumerator 2025-07-16 10:56:18 -07:00
Keegan George c26d604072 FIX: dependency validator should depend on default LLM setting 2025-07-16 07:08:29 -07:00
Keegan George 7ae61ce877 DEV: update automation to remove custom: prefix 2025-07-15 18:08:41 -07:00
Keegan George f593ab6e1e DEV: remove custom: prefix
and update migrations for translations
2025-07-15 18:07:05 -07:00
Keegan George b58f198ee8 FIX: don't run_test in testing env 2025-07-15 11:08:39 -07:00
Keegan George 88811f97bf fixes 2025-07-15 10:23:58 -07:00
Keegan George 2377b286dd DEV: rely on default llm model in spec 2025-07-15 09:56:24 -07:00
Keegan George 8da4318021 DEV: assign_fake_provider 2025-07-11 12:32:36 -07:00
Keegan George b6a88ff03e spec 2025-07-11 12:02:50 -07:00
Keegan George f366ded03b DEV: Force default to be set if it was found not to be set! 2025-07-11 08:19:30 -07:00
Keegan George 87c42145d4 DEV: hide hyde model setting 2025-07-10 15:07:12 -07:00
Keegan George c49f1df2ba DEV: migrate hyde model to content creator persona 2025-07-10 15:00:34 -07:00
Keegan George b46940fc66 DEV: hide reset button
It resets it to default before the migration
2025-07-10 14:46:02 -07:00
Keegan George 249aab100c DEV: Use a simple validator
- no need for seeding checks or module dependant checks
2025-07-10 14:44:02 -07:00
Keegan George 5a29074799 DEV: Use default LLM model 2025-07-10 11:54:35 -07:00
Keegan George 29d415755a fix 2025-07-09 15:42:44 -07:00
Keegan George 5a590fddc1 DEV: updates... 2025-07-09 15:36:34 -07:00
Keegan George cad39c1aa4 DEV: client side 2025-07-09 15:29:50 -07:00
Keegan George 2592be65ff FEATURE: configure a default LLM model for all features
This update adds a setting to the configure a default LLM model to be used for all features (unless overridden).
2025-07-09 15:29:24 -07:00
122 changed files with 726 additions and 418 deletions
-12
View File
@@ -12,7 +12,6 @@ class AiPersona < ActiveRecord::Base
validates :system_prompt, presence: true, length: { maximum: 10_000_000 }
validate :system_persona_unchangeable, on: :update, if: :system
validate :chat_preconditions
validate :allowed_seeded_model, if: :default_llm_id
validate :well_formated_examples
validates :max_context_posts, numericality: { greater_than: 0 }, allow_nil: true
# leaves some room for growth but sets a maximum to avoid memory issues
@@ -365,17 +364,6 @@ class AiPersona < ActiveRecord::Base
end
end
def allowed_seeded_model
return if default_llm_id.blank?
return if default_llm.nil?
return if !default_llm.seeded?
return if SiteSetting.ai_bot_allowed_seeded_models_map.include?(default_llm.id.to_s)
errors.add(:default_llm, I18n.t("discourse_ai.llm.configuration.invalid_seeded_model"))
end
def well_formated_examples
return if examples.blank?
+1 -1
View File
@@ -99,7 +99,7 @@ class LlmModel < ActiveRecord::Base
end
def identifier
"custom:#{id}"
"#{id}"
end
def toggle_companion_user
@@ -0,0 +1,46 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { ajax } from "discourse/lib/ajax";
import { i18n } from "discourse-i18n";
import SiteSettingComponent from "admin/components/site-setting";
import SiteSetting from "admin/models/site-setting";
export default class AiDefaultLlmSelector extends Component {
@tracked defaultLlmSetting = null;
constructor() {
super(...arguments);
this.#loadDefaultLlmSetting();
}
async #loadDefaultLlmSetting() {
const { site_settings } = await ajax("/admin/config/site_settings.json", {
data: {
plugin: "discourse-ai",
category: "discourse_ai",
},
});
const defaultLlmSetting = site_settings.find(
(setting) => setting.setting === "ai_default_llm_model"
);
this.defaultLlmSetting = SiteSetting.create(defaultLlmSetting);
}
<template>
<div class="ai-configure-default-llm">
<div class="ai-configure-default-llm__header">
<h3>{{i18n "discourse_ai.default_llm.title"}}</h3>
<p>{{i18n "discourse_ai.default_llm.description"}}</p>
</div>
{{#if this.defaultLlmSetting}}
<SiteSettingComponent
@setting={{this.defaultLlmSetting}}
class="ai-configure-default-llm__setting"
/>
{{/if}}
</div>
</template>
}
@@ -9,6 +9,7 @@ import DPageSubheader from "discourse/components/d-page-subheader";
import DSelect from "discourse/components/d-select";
import FilterInput from "discourse/components/filter-input";
import { i18n } from "discourse-i18n";
import AiDefaultLlmSelector from "./ai-default-llm-selector";
import AiFeaturesList from "./ai-features-list";
const ALL = "all";
@@ -202,6 +203,8 @@ export default class AiFeatures extends Component {
/>
</div>
<AiDefaultLlmSelector />
{{#if this.filteredFeatures.length}}
<AiFeaturesList @modules={{this.filteredFeatures}} />
{{else}}
@@ -9,6 +9,7 @@ import I18n, { i18n } from "discourse-i18n";
import AdminSectionLandingItem from "admin/components/admin-section-landing-item";
import AdminSectionLandingWrapper from "admin/components/admin-section-landing-wrapper";
import DTooltip from "float-kit/components/d-tooltip";
import AiDefaultLlmSelector from "./ai-default-llm-selector";
import AiLlmEditor from "./ai-llm-editor";
function isPreseeded(llm) {
@@ -137,6 +138,9 @@ export default class AiLlmsListEditor extends Component {
}}
@learnMoreUrl="https://meta.discourse.org/t/discourse-ai-large-language-model-llm-settings-page/319903"
/>
<AiDefaultLlmSelector />
{{#if this.hasLlmElements}}
<section class="ai-llms-list-editor__configured">
<DPageSubheader
@@ -90,7 +90,7 @@ export default class AiSpam extends Component {
this.isEnabled = model.is_enabled;
if (model.llm_id) {
this.selectedLLM = "custom:" + model.llm_id;
this.selectedLLM = model.llm_id;
} else {
if (this.availableLLMs.length) {
this.selectedLLM = this.availableLLMs[0].id;
@@ -127,7 +127,7 @@ export default class AiSpam extends Component {
}
get llmId() {
return this.selectedLLM.toString().split(":")[1];
return this.selectedLLM;
}
@action
@@ -158,3 +158,51 @@
.ai-expanded-list__toggle-button {
padding: 0;
}
.ai-configure-default-llm {
display: flex;
align-items: center;
gap: var(--space-2);
background: var(--primary-very-low);
padding: 1rem;
margin-block: 1rem;
border-radius: var(--d-border-radius);
&__header {
flex: 3;
}
&__setting {
flex: 2;
display: flex;
gap: var(--space-2);
align-items: center;
justify-content: center;
.setting-controls__undo {
display: none;
}
}
h3 {
color: var(--primary);
font-size: var(--font-0);
margin-bottom: 0;
}
p {
margin-top: 0;
font-size: var(--font-down-1);
color: var(--primary-high-or-secondary-low);
}
.select-kit,
.btn {
font-size: var(--font-down-1);
}
.setting-label,
.desc {
display: none;
}
}
+5 -1
View File
@@ -184,6 +184,10 @@ en:
discourse_ai:
title: "AI"
default_llm:
title: "Default LLM model"
description: "The default LLM model to use for all AI features. This will be used if no LLM is specified in the feature configuration or persona. If no default LLM is specified, the last created LLM will be used."
features:
short_title: "Features"
description: "These are the AI features available to visitors on your site. These can be configured to use specific personas and LLMs, and can be access controlled by groups."
@@ -566,7 +570,7 @@ en:
max_usages_required: "Must be set if max tokens is not set"
usage:
ai_bot: "AI bot"
ai_helper: "Helper"
ai_helper: "Helper (%{persona})"
ai_helper_image_caption: "Image caption"
ai_persona: "Persona (%{persona})"
ai_summarization: "Summarize"
+2 -1
View File
@@ -25,6 +25,7 @@ en:
description: "Periodic report based on a large language model"
site_settings:
discourse_ai_enabled: "Enable the discourse AI plugin."
ai_default_llm_model: "The default LLM model to use for all AI features"
ai_artifact_security: "The AI artifact system generates IFRAMEs with runnable code. Strict mode forces an extra click to run code. Lax mode runs code immediately. Hybrid mode allows user to supply data-ai-artifact-autorun to show right away. Disabled mode disables the artifact system."
ai_toxicity_enabled: "Enable the toxicity module."
ai_toxicity_inference_service_api_endpoint: "URL where the API is running for the toxicity module"
@@ -582,7 +583,7 @@ en:
llm:
configuration:
create_llm: "You need to setup an LLM before enabling this feature"
disable_module_first: "You have to disable %{setting} first."
disable_modules_first: "You must disable these modules first: %{settings}"
set_llm_first: "Set %{setting} first"
model_unreachable: "We couldn't get a response from this model. Check your settings first."
invalid_seeded_model: "You can't use this model with this feature"
+12 -8
View File
@@ -11,6 +11,12 @@ discourse_ai:
- "lax"
- "hybrid"
- "strict"
ai_default_llm_model:
default: ""
type: enum
allow_any: false
enum: "DiscourseAi::Configuration::LlmEnumerator"
validator: "DiscourseAi::Configuration::LlmValidator"
ai_sentiment_enabled:
default: false
@@ -103,7 +109,7 @@ discourse_ai:
default: false
client: true
area: "ai-features/ai_helper"
ai_helper_model:
ai_helper_model: # Deprecated. TODO(keegan): Remove 2025-09-01
default: ""
allow_any: false
type: enum
@@ -149,7 +155,7 @@ discourse_ai:
- "context_menu"
- "image_caption"
area: "ai-features/ai_helper"
ai_helper_image_caption_model:
ai_helper_image_caption_model: # Deprecated. TODO(keegan): Remove 2025-09-01
default: ""
type: enum
enum: "DiscourseAi::Configuration::LlmVisionEnumerator"
@@ -260,12 +266,12 @@ discourse_ai:
client: true
validator: "DiscourseAi::Configuration::LlmDependencyValidator"
area: "ai-features/embeddings"
ai_embeddings_semantic_search_hyde_model:
ai_embeddings_semantic_search_hyde_model: # Deprecated. TODO(keegan): Remove 2025-09-01
hidden: true
default: ""
type: enum
allow_any: false
enum: "DiscourseAi::Configuration::LlmEnumerator"
validator: "DiscourseAi::Configuration::LlmValidator"
area: "ai-features/embeddings"
ai_embeddings_semantic_search_hyde_model_allowed_seeded_models:
default: ""
@@ -316,12 +322,11 @@ discourse_ai:
client: true
validator: "DiscourseAi::Configuration::LlmDependencyValidator"
area: "ai-features/summarization"
ai_summarization_model:
ai_summarization_model: # Deprecated. TODO(keegan): Remove 2025-09-01
default: ""
allow_any: false
type: enum
enum: "DiscourseAi::Configuration::LlmEnumerator"
validator: "DiscourseAi::Configuration::LlmValidator"
hidden: true
ai_summarization_persona:
default: "-11"
@@ -483,12 +488,11 @@ discourse_ai:
client: true
validator: "DiscourseAi::Configuration::LlmDependencyValidator"
area: "ai-features/translation"
ai_translation_model:
ai_translation_model: # Deprecated. TODO(keegan): Remove 2025-09-01
default: ""
type: enum
allow_any: false
enum: "DiscourseAi::Configuration::LlmEnumerator"
validator: "DiscourseAi::Configuration::LlmValidator"
area: "ai-features/translation"
ai_translation_locale_detector_persona:
default: "-27"
@@ -0,0 +1,16 @@
# frozen_string_literal: true
class SeedAiDefaultLlmModel < ActiveRecord::Migration[7.2]
def up
return if DB.query_single("SELECT 1 FROM llm_models LIMIT 1").empty?
last_model_id = DB.query_single("SELECT id FROM llm_models ORDER BY id DESC LIMIT 1").first
if last_model_id.present?
execute "UPDATE site_settings SET value = '#{last_model_id}' WHERE name = 'ai_default_llm_model' AND (value IS NULL OR value = '');"
end
end
def down
raise ActiveRecord::IrreversibleMigration
end
end
@@ -0,0 +1,33 @@
# frozen_string_literal: true
class CopyAiSummarizationModelToPersonaDefault < ActiveRecord::Migration[7.2]
def up
ai_summarization_model =
DB.query_single("SELECT value FROM site_settings WHERE name = 'ai_summarization_model'").first
if ai_summarization_model.present? && ai_summarization_model.start_with?("custom:")
# Extract the model ID from the setting value (e.g., "custom:-5" -> "-5")
model_id = ai_summarization_model.split(":").last
persona_settings = %w[ai_summarization_persona ai_summary_gists_persona]
default_persona_ids = [-11, -12]
persona_ids_query =
persona_settings
.map { |setting| "SELECT value FROM site_settings WHERE name = '#{setting}'" }
.join(" UNION ")
persona_ids = DB.query_single(persona_ids_query).compact
all_persona_ids = (default_persona_ids + persona_ids).map(&:to_i).uniq.join(",")
# Update the summarization personas with the extracted model ID
execute(<<~SQL)
UPDATE ai_personas
SET default_llm_id = #{model_id}
WHERE id IN (#{all_persona_ids}) AND default_llm_id IS NULL
SQL
end
end
def down
raise ActiveRecord::IrreversibleMigration
end
end
@@ -0,0 +1,43 @@
# frozen_string_literal: true
class CopyAiHelperModelToPersonaDefault < ActiveRecord::Migration[7.2]
def up
ai_helper_model =
DB.query_single("SELECT value FROM site_settings WHERE name = 'ai_helper_model'").first
if ai_helper_model.present? && ai_helper_model.start_with?("custom:")
# Extract the model ID from the setting value (e.g., "custom:1" -> "1")
model_id = ai_helper_model.split(":").last
# Get persona IDs from site settings
persona_settings = %w[
ai_helper_proofreader_persona
ai_helper_title_suggestions_persona
ai_helper_explain_persona
ai_helper_smart_dates_persona
ai_helper_translator_persona
ai_helper_markdown_tables_persona
ai_helper_custom_prompt_persona
]
default_persona_ids = [-18, -19, -20, -22, -23, -24, -25]
persona_ids_query =
persona_settings
.map { |setting| "SELECT value FROM site_settings WHERE name = '#{setting}'" }
.join(" UNION ")
persona_ids = DB.query_single(persona_ids_query).compact
all_persona_ids = (default_persona_ids + persona_ids).map(&:to_i).uniq.join(",")
# Update the helper personas with the extracted model ID
execute(<<~SQL)
UPDATE ai_personas
SET default_llm_id = #{model_id}
WHERE id IN (#{all_persona_ids}) AND default_llm_id IS NULL
SQL
end
end
def down
raise ActiveRecord::IrreversibleMigration
end
end
@@ -0,0 +1,35 @@
# frozen_string_literal: true
class CopyHydeModelToPersona < ActiveRecord::Migration[7.2]
def up
hyde_model =
DB.query_single(
"SELECT value FROM site_settings WHERE name = 'ai_embeddings_semantic_search_hyde_model'",
).first
if hyde_model.present? && hyde_model.start_with?("custom:")
# Extract the model ID from the setting value (e.g., "custom:1" -> "1")
model_id = hyde_model.split(":").last
persona_settings = %w[ai_embeddings_semantic_search_hyde_persona]
default_persona_ids = [-32]
persona_ids_query =
persona_settings
.map { |setting| "SELECT value FROM site_settings WHERE name = '#{setting}'" }
.join(" UNION ")
persona_ids = DB.query_single(persona_ids_query).compact
all_persona_ids = (default_persona_ids + persona_ids).map(&:to_i).uniq.join(",")
# Update the hyde persona with the extracted model ID
execute(<<~SQL)
UPDATE ai_personas
SET default_llm_id = #{model_id}
WHERE id IN (#{all_persona_ids}) AND default_llm_id IS NULL
SQL
end
end
def down
raise ActiveRecord::IrreversibleMigration
end
end
@@ -0,0 +1,39 @@
# frozen_string_literal: true
class CopyTranslationModelToPersona < ActiveRecord::Migration[7.2]
def up
ai_translation_model =
DB.query_single("SELECT value FROM site_settings WHERE name = 'ai_translation_model'").first
if ai_translation_model.present? && ai_translation_model.start_with?("custom:")
# Extract the model ID from the setting value (e.g., "custom:-5" -> "-5")
model_id = ai_translation_model.split(":").last
persona_settings = %w[
ai_translation_locale_detector_persona
ai_translation_post_raw_translator_persona
ai_translation_topic_title_translator_persona
ai_translation_short_text_translator_persona
]
default_persona_ids = [-27, -28, -29, -30]
persona_ids_query =
persona_settings
.map { |setting| "SELECT value FROM site_settings WHERE name = '#{setting}'" }
.join(" UNION ")
persona_ids = DB.query_single(persona_ids_query).compact
all_persona_ids = (default_persona_ids + persona_ids).map(&:to_i).uniq.join(",")
# Update the translation personas (IDs -27, -28, -29, -30) with the extracted model ID
execute(<<~SQL)
UPDATE ai_personas
SET default_llm_id = #{model_id}
WHERE id IN (#{all_persona_ids}) AND default_llm_id IS NULL
SQL
end
end
def down
raise ActiveRecord::IrreversibleMigration
end
end
@@ -0,0 +1,37 @@
# frozen_string_literal: true
class CopyAiImageCaptionModelToPersonaDefault < ActiveRecord::Migration[7.2]
def up
ai_helper_image_caption_model =
DB.query_single(
"SELECT value FROM site_settings WHERE name = 'ai_helper_image_caption_model'",
).first
if ai_helper_image_caption_model.present? &&
ai_helper_image_caption_model.start_with?("custom:")
# Extract the model ID from the setting value (e.g., "custom:1" -> "1")
model_id = ai_helper_image_caption_model.split(":").last
persona_settings = %w[ai_helper_post_illustrator_persona ai_helper_image_caption_persona]
default_persona_ids = [-21, -26]
persona_ids_query =
persona_settings
.map { |setting| "SELECT value FROM site_settings WHERE name = '#{setting}'" }
.join(" UNION ")
persona_ids = DB.query_single(persona_ids_query).compact
all_persona_ids = (default_persona_ids + persona_ids).map(&:to_i).uniq.join(",")
# Update the helper personas with the extracted model ID
execute(<<~SQL)
UPDATE ai_personas
SET default_llm_id = #{model_id}
WHERE id IN (#{all_persona_ids}) AND default_llm_id IS NULL
SQL
end
end
def down
raise ActiveRecord::IrreversibleMigration
end
end
+2 -11
View File
@@ -312,18 +312,9 @@ module DiscourseAi
# Priorities are:
# 1. Persona's default LLM
# 2. Hidden `ai_helper_model` setting, or `ai_helper_image_caption_model` for image_caption.
# 3. Newest LLM config
# 2. SiteSetting.ai_default_llm_id (or newest LLM if not set)
def self.find_ai_helper_model(helper_mode, persona_klass)
model_id = persona_klass.default_llm_id
if !model_id
if helper_mode == IMAGE_CAPTION
model_id = SiteSetting.ai_helper_image_caption_model&.split(":")&.last
else
model_id = SiteSetting.ai_helper_model&.split(":")&.last
end
end
model_id = persona_klass.default_llm_id || SiteSetting.ai_default_llm_model
if model_id.present?
LlmModel.find_by(id: model_id)
+5 -1
View File
@@ -12,10 +12,14 @@ module DiscourseAi
return nil if content.blank?
suggested_title = call_llm(content)
return nil if suggested_title.blank?
cleanup(suggested_title)
end
def call_llm(thread_content)
return nil if SiteSetting.ai_default_llm_model.blank?
chat = "<input>\n#{thread_content}\n</input>"
prompt =
@@ -30,7 +34,7 @@ module DiscourseAi
messages: [{ type: :user, content: chat, id: "User" }],
)
DiscourseAi::Completions::Llm.proxy(SiteSetting.ai_helper_model).generate(
DiscourseAi::Completions::Llm.proxy(SiteSetting.ai_default_llm_model).generate(
prompt,
user: Discourse.system_user,
stop_sequences: ["</input>"],
+7 -1
View File
@@ -57,6 +57,12 @@ module DiscourseAi
end
def diffusion_prompt(text, user)
llm_model =
AiPersona.find_by(id: SiteSetting.ai_helper_post_illustrator_persona)&.default_llm_id ||
SiteSetting.ai_default_llm_model
return nil if llm_model.blank?
prompt =
DiscourseAi::Completions::Prompt.new(
<<~TEXT.strip,
@@ -66,7 +72,7 @@ module DiscourseAi
messages: [{ type: :user, content: text, id: user.username }],
)
DiscourseAi::Completions::Llm.proxy(SiteSetting.ai_helper_model).generate(
DiscourseAi::Completions::Llm.proxy(llm_model).generate(
prompt,
user: user,
feature_name: "illustrate_post",
+1 -1
View File
@@ -37,7 +37,7 @@ module DiscourseAi
value_h["id"] > 0 ||
SiteSetting.ai_automation_allowed_seeded_models_map.include?(value_h["id"].to_s)
end
.each { |value_h| value_h["id"] = "custom:#{value_h["id"]}" }
.each { |value_h| value_h["id"] = "#{value_h["id"]}" }
values
end
+1 -1
View File
@@ -74,7 +74,7 @@ module DiscourseAi
else
I18n.t("discourse_automation.scriptables.llm_report.title")
end
@model = LlmModel.find_by(id: model.split(":")&.last)
@model = LlmModel.find_by(id: model)
@persona = AiPersona.find(persona_id).class_instance.new
@category_ids = category_ids
@tags = tags
+2
View File
@@ -292,6 +292,8 @@ module DiscourseAi
llm_model =
if model.is_a?(LlmModel)
model
elsif model.is_a?(Numeric)
LlmModel.find_by(id: model)
else
model_name_without_prov = model.split(":").last.to_i
@@ -10,16 +10,9 @@ module DiscourseAi
def valid_value?(val)
return true if val == "f"
if @opts[:name] == :ai_summarization_enabled || @opts[:name] == :ai_helper_enabled
has_llms = LlmModel.count > 0
@no_llms_configured = !has_llms
has_llms
else
@llm_dependency_setting_name =
DiscourseAi::Configuration::LlmValidator.new.choose_llm_setting_for(@opts[:name])
@llm_dependency_setting_name = :ai_default_llm_model
SiteSetting.public_send(@llm_dependency_setting_name).present?
end
SiteSetting.public_send(@llm_dependency_setting_name).present?
end
def error_message
+39 -15
View File
@@ -22,24 +22,53 @@ module DiscourseAi
.each { |llm_id, name, id| rval[llm_id] << { type: :ai_persona, name: name, id: id } }
if SiteSetting.ai_helper_enabled
model_id = SiteSetting.ai_helper_model.split(":").last.to_i
rval[model_id] << { type: :ai_helper } if model_id != 0
{
"#{I18n.t("js.discourse_ai.features.ai_helper.proofread")}" =>
SiteSetting.ai_helper_proofreader_persona,
"#{I18n.t("js.discourse_ai.features.ai_helper.title_suggestions")}" =>
SiteSetting.ai_helper_title_suggestions_persona,
"#{I18n.t("js.discourse_ai.features.ai_helper.explain")}" =>
SiteSetting.ai_helper_explain_persona,
"#{I18n.t("js.discourse_ai.features.ai_helper.illustrate_post")}" =>
SiteSetting.ai_helper_post_illustrator_persona,
"#{I18n.t("js.discourse_ai.features.ai_helper.smart_dates")}" =>
SiteSetting.ai_helper_smart_dates_persona,
"#{I18n.t("js.discourse_ai.features.ai_helper.translator")}" =>
SiteSetting.ai_helper_translator_persona,
"#{I18n.t("js.discourse_ai.features.ai_helper.markdown_tables")}" =>
SiteSetting.ai_helper_markdown_tables_persona,
"#{I18n.t("js.discourse_ai.features.ai_helper.custom_prompt")}" =>
SiteSetting.ai_helper_custom_prompt_persona,
}.each do |helper_type, persona_id|
next if persona_id.blank?
persona = AiPersona.find_by(id: persona_id)
next if persona.blank? || persona.default_llm_id.blank?
model_id = persona.default_llm_id || SiteSetting.ai_default_llm_model.to_i
rval[model_id] << { type: :ai_helper, name: helper_type }
end
end
if SiteSetting.ai_helper_image_caption_model
model_id = SiteSetting.ai_helper_image_caption_model.split(":").last.to_i
rval[model_id] << { type: :ai_helper_image_caption } if model_id != 0
if SiteSetting.ai_helper_enabled_features.split("|").include?("image_caption")
image_caption_persona = AiPersona.find_by(id: SiteSetting.ai_helper_image_caption_persona)
model_id = image_caption_persona.default_llm_id || SiteSetting.ai_default_llm_model.to_i
rval[model_id] << { type: :ai_helper_image_caption }
end
if SiteSetting.ai_summarization_enabled
summarization_persona = AiPersona.find_by(id: SiteSetting.ai_summarization_persona)
model_id = summarization_persona.default_llm_id || LlmModel.last&.id
model_id = summarization_persona.default_llm_id || SiteSetting.ai_default_llm_model.to_i
rval[model_id] << { type: :ai_summarization }
end
if SiteSetting.ai_embeddings_semantic_search_enabled
model_id = SiteSetting.ai_embeddings_semantic_search_hyde_model.split(":").last.to_i
search_persona =
AiPersona.find_by(id: SiteSetting.ai_embeddings_semantic_search_hyde_persona)
model_id = search_persona.default_llm_id || SiteSetting.ai_default_llm_model.to_i
rval[model_id] << { type: :ai_embeddings_semantic_search }
end
@@ -58,12 +87,8 @@ module DiscourseAi
)
.each do |model_text, name, id|
next if model_text.blank?
model_id = model_text.split("custom:").last.to_i
if model_id.present?
if model_text =~ /custom:(\d+)/
rval[model_id] << { type: :automation, name: name, id: id }
end
end
model_id = model_text.to_i
rval[model_id] << { type: :automation, name: name, id: id } if model_id.present?
end
end
@@ -94,7 +119,7 @@ module DiscourseAi
builder.query_hash.map(&:symbolize_keys)
end
def self.values(allowed_seeded_llms: nil)
def self.values(allowed_seeded_llms: [])
values = DB.query_hash(<<~SQL).map(&:symbolize_keys)
SELECT display_name AS name, id AS value
FROM llm_models
@@ -107,7 +132,6 @@ module DiscourseAi
end
end
values.each { |value_h| value_h[:value] = "custom:#{value_h[:value]}" }
values
end
end
+25 -44
View File
@@ -2,9 +2,6 @@
module DiscourseAi
module Configuration
class InvalidSeededModelError < StandardError
end
class LlmValidator
def initialize(opts = {})
@opts = opts
@@ -12,18 +9,21 @@ module DiscourseAi
def valid_value?(val)
if val == ""
@parent_module_name = modules_and_choose_llm_settings.invert[@opts[:name]]
if @opts[:name] == :ai_default_llm_model
@parent_module_names = []
@parent_enabled = SiteSetting.public_send(@parent_module_name)
return !@parent_enabled
enabled_settings.each do |setting_name|
if SiteSetting.public_send(setting_name) == true
@parent_module_names << setting_name
@parent_enabled = true
end
end
return !@parent_enabled
end
end
allowed_seeded_model?(val)
run_test(val).tap { |result| @unreachable = result }
rescue DiscourseAi::Configuration::InvalidSeededModelError => e
@unreachable = true
false
rescue StandardError => e
raise e if Rails.env.test?
@unreachable = true
@@ -38,54 +38,35 @@ module DiscourseAi
end
def modules_using(llm_model)
choose_llm_settings = modules_and_choose_llm_settings.values
in_use_llms = AiPersona.where.not(default_llm_id: nil).pluck(:default_llm_id)
default_llm = SiteSetting.ai_default_llm_model.presence&.to_i
choose_llm_settings.select { |s| SiteSetting.public_send(s) == "custom:#{llm_model.id}" }
combined_llms = (in_use_llms + [default_llm]).compact.uniq
combined_llms
end
def error_message
if @parent_enabled
if @parent_enabled && @parent_module_names.present?
return(
I18n.t(
"discourse_ai.llm.configuration.disable_module_first",
setting: @parent_module_name,
"discourse_ai.llm.configuration.disable_modules_first",
settings: @parent_module_names.join(", "),
)
)
end
if @invalid_seeded_model
return I18n.t("discourse_ai.llm.configuration.invalid_seeded_model")
end
return unless @unreachable
I18n.t("discourse_ai.llm.configuration.model_unreachable")
end
def choose_llm_setting_for(module_enabler_setting)
modules_and_choose_llm_settings[module_enabler_setting]
end
def modules_and_choose_llm_settings
{
ai_embeddings_semantic_search_enabled: :ai_embeddings_semantic_search_hyde_model,
ai_helper_enabled: :ai_helper_model,
ai_summarization_enabled: :ai_summarization_model,
ai_translation_enabled: :ai_translation_model,
}
end
def allowed_seeded_model?(val)
id = val.split(":").last
return true if id.to_i > 0
setting = @opts[:name]
allowed_list = SiteSetting.public_send("#{setting}_allowed_seeded_models")
if allowed_list.split("|").exclude?(id)
@invalid_seeded_model = true
raise DiscourseAi::Configuration::InvalidSeededModelError.new
end
def enabled_settings
%i[
ai_embeddings_semantic_search_enabled
ai_helper_enabled
ai_summarization_enabled
ai_translation_enabled
]
end
end
end
@@ -16,8 +16,6 @@ module DiscourseAi
WHERE vision_enabled
SQL
values.each { |value_h| value_h[:value] = "custom:#{value_h[:value]}" }
values
end
end
+28 -24
View File
@@ -6,8 +6,8 @@ module DiscourseAi
def self.clear_cache_for(query)
digest = OpenSSL::Digest::SHA1.hexdigest(query)
hyde_key =
"semantic-search-#{digest}-#{SiteSetting.ai_embeddings_semantic_search_hyde_model}"
hyde_model_id = find_ai_hyde_model_id
hyde_key = "semantic-search-#{digest}-#{hyde_model_id}"
Discourse.cache.delete(hyde_key)
Discourse.cache.delete("#{hyde_key}-#{SiteSetting.ai_embeddings_selected_model}")
@@ -20,12 +20,9 @@ module DiscourseAi
def cached_query?(query)
digest = OpenSSL::Digest::SHA1.hexdigest(query)
hyde_model_id = self.class.find_ai_hyde_model_id
embedding_key =
build_embedding_key(
digest,
SiteSetting.ai_embeddings_semantic_search_hyde_model,
SiteSetting.ai_embeddings_selected_model,
)
build_embedding_key(digest, hyde_model_id, SiteSetting.ai_embeddings_selected_model)
Discourse.cache.read(embedding_key).present?
end
@@ -36,14 +33,11 @@ module DiscourseAi
def hyde_embedding(search_term)
digest = OpenSSL::Digest::SHA1.hexdigest(search_term)
hyde_key = build_hyde_key(digest, SiteSetting.ai_embeddings_semantic_search_hyde_model)
hyde_model_id = self.class.find_ai_hyde_model_id
hyde_key = build_hyde_key(digest, hyde_model_id)
embedding_key =
build_embedding_key(
digest,
SiteSetting.ai_embeddings_semantic_search_hyde_model,
SiteSetting.ai_embeddings_selected_model,
)
build_embedding_key(digest, hyde_model_id, SiteSetting.ai_embeddings_selected_model)
hypothetical_post =
Discourse
@@ -111,6 +105,7 @@ module DiscourseAi
max_semantic_results_per_page = 100
search = Search.new(query, { guardian: guardian })
search_term = search.term
hyde_model_id = self.class.find_ai_hyde_model_id
return [] if search_term.nil? || search_term.length < SiteSetting.min_search_term_length
@@ -119,11 +114,7 @@ module DiscourseAi
digest = OpenSSL::Digest::SHA1.hexdigest(search_term)
embedding_key =
build_embedding_key(
digest,
SiteSetting.ai_embeddings_semantic_search_hyde_model,
SiteSetting.ai_embeddings_selected_model,
)
build_embedding_key(digest, hyde_model_id, SiteSetting.ai_embeddings_selected_model)
search_term_embedding =
Discourse
@@ -210,15 +201,28 @@ module DiscourseAi
# Priorities are:
# 1. Persona's default LLM
# 2. `ai_embeddings_semantic_search_hyde_model` setting.
# 2. SiteSetting.ai_default_llm_id (or newest LLM if not set)
def find_ai_hyde_model(persona_klass)
model_id =
persona_klass.default_llm_id ||
SiteSetting.ai_embeddings_semantic_search_hyde_model&.split(":")&.last
model_id = persona_klass.default_llm_id || SiteSetting.ai_default_llm_model
return if model_id.blank?
if model_id.present?
LlmModel.find_by(id: model_id)
else
LlmModel.last
end
end
LlmModel.find_by(id: model_id)
def self.find_ai_hyde_model_id
persona_llm_id =
AiPersona.find_by(
id: SiteSetting.ai_embeddings_semantic_search_hyde_persona,
)&.default_llm_id
if persona_llm_id.present?
persona_llm_id
else
SiteSetting.ai_default_llm_model.to_i || LlmModel.last&.id
end
end
private
+3 -5
View File
@@ -54,11 +54,9 @@ module DiscourseAi
# Priorities are:
# 1. Persona's default LLM
# 2. Hidden `ai_summarization_model` setting
# 3. Newest LLM config
# 2. SiteSetting.ai_default_llm_id (or newest LLM if not set)
def find_summarization_model(persona_klass)
model_id =
persona_klass.default_llm_id || SiteSetting.ai_summarization_model&.split(":")&.last # Remove legacy custom provider.
model_id = persona_klass.default_llm_id || SiteSetting.ai_default_llm_model
if model_id.present?
LlmModel.find_by(id: model_id)
@@ -73,7 +71,7 @@ module DiscourseAi
persona = persona_klass.new
user = User.find_by(id: persona_klass.user_id) || Discourse.system_user
bot = DiscourseAi::Personas::Bot.as(user, persona: persona, model: llm_model)
DiscourseAi::Personas::Bot.as(user, persona: persona, model: llm_model)
end
end
end
+1 -1
View File
@@ -88,7 +88,7 @@ namespace :ai do
messages: [{ type: :user, content: prompt, id: "user" }],
)
DiscourseAi::Completions::Llm.proxy(SiteSetting.ai_helper_model).generate(
DiscourseAi::Completions::Llm.proxy(SiteSetting.ai_defaulit_llm_model).generate(
prompt,
user: Discourse.system_user,
feature_name: "topic-generator",
+19 -2
View File
@@ -3,11 +3,28 @@
module DiscourseAi
module Translation
def self.enabled?
SiteSetting.discourse_ai_enabled && SiteSetting.ai_translation_enabled &&
SiteSetting.ai_translation_model.present? &&
SiteSetting.discourse_ai_enabled && SiteSetting.ai_translation_enabled && has_llm_model? &&
SiteSetting.content_localization_supported_locales.present?
end
def self.has_llm_model?
persona_ids = [
SiteSetting.ai_translation_locale_detector_persona,
SiteSetting.ai_translation_post_raw_translator_persona,
SiteSetting.ai_translation_topic_title_translator_persona,
SiteSetting.ai_translation_short_text_translator_persona,
]
persona_default_llms = AiPersona.where(id: persona_ids).pluck(:default_llm_id)
default_llm_model = SiteSetting.ai_default_llm_model
if persona_default_llms.any?(&:blank?) && default_llm_model.blank?
false
else
true
end
end
def self.backfill_enabled?
enabled? && SiteSetting.ai_translation_backfill_hourly_rate > 0 &&
SiteSetting.ai_translation_backfill_max_age_days > 0

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