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 } validates :system_prompt, presence: true, length: { maximum: 10_000_000 }
validate :system_persona_unchangeable, on: :update, if: :system validate :system_persona_unchangeable, on: :update, if: :system
validate :chat_preconditions validate :chat_preconditions
validate :allowed_seeded_model, if: :default_llm_id
validate :well_formated_examples validate :well_formated_examples
validates :max_context_posts, numericality: { greater_than: 0 }, allow_nil: true validates :max_context_posts, numericality: { greater_than: 0 }, allow_nil: true
# leaves some room for growth but sets a maximum to avoid memory issues # leaves some room for growth but sets a maximum to avoid memory issues
@@ -365,17 +364,6 @@ class AiPersona < ActiveRecord::Base
end end
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 def well_formated_examples
return if examples.blank? return if examples.blank?
+1 -1
View File
@@ -99,7 +99,7 @@ class LlmModel < ActiveRecord::Base
end end
def identifier def identifier
"custom:#{id}" "#{id}"
end end
def toggle_companion_user 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 DSelect from "discourse/components/d-select";
import FilterInput from "discourse/components/filter-input"; import FilterInput from "discourse/components/filter-input";
import { i18n } from "discourse-i18n"; import { i18n } from "discourse-i18n";
import AiDefaultLlmSelector from "./ai-default-llm-selector";
import AiFeaturesList from "./ai-features-list"; import AiFeaturesList from "./ai-features-list";
const ALL = "all"; const ALL = "all";
@@ -202,6 +203,8 @@ export default class AiFeatures extends Component {
/> />
</div> </div>
<AiDefaultLlmSelector />
{{#if this.filteredFeatures.length}} {{#if this.filteredFeatures.length}}
<AiFeaturesList @modules={{this.filteredFeatures}} /> <AiFeaturesList @modules={{this.filteredFeatures}} />
{{else}} {{else}}
@@ -9,6 +9,7 @@ import I18n, { i18n } from "discourse-i18n";
import AdminSectionLandingItem from "admin/components/admin-section-landing-item"; import AdminSectionLandingItem from "admin/components/admin-section-landing-item";
import AdminSectionLandingWrapper from "admin/components/admin-section-landing-wrapper"; import AdminSectionLandingWrapper from "admin/components/admin-section-landing-wrapper";
import DTooltip from "float-kit/components/d-tooltip"; import DTooltip from "float-kit/components/d-tooltip";
import AiDefaultLlmSelector from "./ai-default-llm-selector";
import AiLlmEditor from "./ai-llm-editor"; import AiLlmEditor from "./ai-llm-editor";
function isPreseeded(llm) { 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" @learnMoreUrl="https://meta.discourse.org/t/discourse-ai-large-language-model-llm-settings-page/319903"
/> />
<AiDefaultLlmSelector />
{{#if this.hasLlmElements}} {{#if this.hasLlmElements}}
<section class="ai-llms-list-editor__configured"> <section class="ai-llms-list-editor__configured">
<DPageSubheader <DPageSubheader
@@ -90,7 +90,7 @@ export default class AiSpam extends Component {
this.isEnabled = model.is_enabled; this.isEnabled = model.is_enabled;
if (model.llm_id) { if (model.llm_id) {
this.selectedLLM = "custom:" + model.llm_id; this.selectedLLM = model.llm_id;
} else { } else {
if (this.availableLLMs.length) { if (this.availableLLMs.length) {
this.selectedLLM = this.availableLLMs[0].id; this.selectedLLM = this.availableLLMs[0].id;
@@ -127,7 +127,7 @@ export default class AiSpam extends Component {
} }
get llmId() { get llmId() {
return this.selectedLLM.toString().split(":")[1]; return this.selectedLLM;
} }
@action @action
@@ -158,3 +158,51 @@
.ai-expanded-list__toggle-button { .ai-expanded-list__toggle-button {
padding: 0; 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: discourse_ai:
title: "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: features:
short_title: "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." 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" max_usages_required: "Must be set if max tokens is not set"
usage: usage:
ai_bot: "AI bot" ai_bot: "AI bot"
ai_helper: "Helper" ai_helper: "Helper (%{persona})"
ai_helper_image_caption: "Image caption" ai_helper_image_caption: "Image caption"
ai_persona: "Persona (%{persona})" ai_persona: "Persona (%{persona})"
ai_summarization: "Summarize" ai_summarization: "Summarize"
+2 -1
View File
@@ -25,6 +25,7 @@ en:
description: "Periodic report based on a large language model" description: "Periodic report based on a large language model"
site_settings: site_settings:
discourse_ai_enabled: "Enable the discourse AI plugin." 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_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_enabled: "Enable the toxicity module."
ai_toxicity_inference_service_api_endpoint: "URL where the API is running for 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: llm:
configuration: configuration:
create_llm: "You need to setup an LLM before enabling this feature" 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" set_llm_first: "Set %{setting} first"
model_unreachable: "We couldn't get a response from this model. Check your settings 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" invalid_seeded_model: "You can't use this model with this feature"
+12 -8
View File
@@ -11,6 +11,12 @@ discourse_ai:
- "lax" - "lax"
- "hybrid" - "hybrid"
- "strict" - "strict"
ai_default_llm_model:
default: ""
type: enum
allow_any: false
enum: "DiscourseAi::Configuration::LlmEnumerator"
validator: "DiscourseAi::Configuration::LlmValidator"
ai_sentiment_enabled: ai_sentiment_enabled:
default: false default: false
@@ -103,7 +109,7 @@ discourse_ai:
default: false default: false
client: true client: true
area: "ai-features/ai_helper" area: "ai-features/ai_helper"
ai_helper_model: ai_helper_model: # Deprecated. TODO(keegan): Remove 2025-09-01
default: "" default: ""
allow_any: false allow_any: false
type: enum type: enum
@@ -149,7 +155,7 @@ discourse_ai:
- "context_menu" - "context_menu"
- "image_caption" - "image_caption"
area: "ai-features/ai_helper" area: "ai-features/ai_helper"
ai_helper_image_caption_model: ai_helper_image_caption_model: # Deprecated. TODO(keegan): Remove 2025-09-01
default: "" default: ""
type: enum type: enum
enum: "DiscourseAi::Configuration::LlmVisionEnumerator" enum: "DiscourseAi::Configuration::LlmVisionEnumerator"
@@ -260,12 +266,12 @@ discourse_ai:
client: true client: true
validator: "DiscourseAi::Configuration::LlmDependencyValidator" validator: "DiscourseAi::Configuration::LlmDependencyValidator"
area: "ai-features/embeddings" 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: "" default: ""
type: enum type: enum
allow_any: false allow_any: false
enum: "DiscourseAi::Configuration::LlmEnumerator" enum: "DiscourseAi::Configuration::LlmEnumerator"
validator: "DiscourseAi::Configuration::LlmValidator"
area: "ai-features/embeddings" area: "ai-features/embeddings"
ai_embeddings_semantic_search_hyde_model_allowed_seeded_models: ai_embeddings_semantic_search_hyde_model_allowed_seeded_models:
default: "" default: ""
@@ -316,12 +322,11 @@ discourse_ai:
client: true client: true
validator: "DiscourseAi::Configuration::LlmDependencyValidator" validator: "DiscourseAi::Configuration::LlmDependencyValidator"
area: "ai-features/summarization" area: "ai-features/summarization"
ai_summarization_model: ai_summarization_model: # Deprecated. TODO(keegan): Remove 2025-09-01
default: "" default: ""
allow_any: false allow_any: false
type: enum type: enum
enum: "DiscourseAi::Configuration::LlmEnumerator" enum: "DiscourseAi::Configuration::LlmEnumerator"
validator: "DiscourseAi::Configuration::LlmValidator"
hidden: true hidden: true
ai_summarization_persona: ai_summarization_persona:
default: "-11" default: "-11"
@@ -483,12 +488,11 @@ discourse_ai:
client: true client: true
validator: "DiscourseAi::Configuration::LlmDependencyValidator" validator: "DiscourseAi::Configuration::LlmDependencyValidator"
area: "ai-features/translation" area: "ai-features/translation"
ai_translation_model: ai_translation_model: # Deprecated. TODO(keegan): Remove 2025-09-01
default: "" default: ""
type: enum type: enum
allow_any: false allow_any: false
enum: "DiscourseAi::Configuration::LlmEnumerator" enum: "DiscourseAi::Configuration::LlmEnumerator"
validator: "DiscourseAi::Configuration::LlmValidator"
area: "ai-features/translation" area: "ai-features/translation"
ai_translation_locale_detector_persona: ai_translation_locale_detector_persona:
default: "-27" 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: # Priorities are:
# 1. Persona's default LLM # 1. Persona's default LLM
# 2. Hidden `ai_helper_model` setting, or `ai_helper_image_caption_model` for image_caption. # 2. SiteSetting.ai_default_llm_id (or newest LLM if not set)
# 3. Newest LLM config
def self.find_ai_helper_model(helper_mode, persona_klass) def self.find_ai_helper_model(helper_mode, persona_klass)
model_id = persona_klass.default_llm_id model_id = persona_klass.default_llm_id || SiteSetting.ai_default_llm_model
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
if model_id.present? if model_id.present?
LlmModel.find_by(id: model_id) LlmModel.find_by(id: model_id)
+5 -1
View File
@@ -12,10 +12,14 @@ module DiscourseAi
return nil if content.blank? return nil if content.blank?
suggested_title = call_llm(content) suggested_title = call_llm(content)
return nil if suggested_title.blank?
cleanup(suggested_title) cleanup(suggested_title)
end end
def call_llm(thread_content) def call_llm(thread_content)
return nil if SiteSetting.ai_default_llm_model.blank?
chat = "<input>\n#{thread_content}\n</input>" chat = "<input>\n#{thread_content}\n</input>"
prompt = prompt =
@@ -30,7 +34,7 @@ module DiscourseAi
messages: [{ type: :user, content: chat, id: "User" }], 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, prompt,
user: Discourse.system_user, user: Discourse.system_user,
stop_sequences: ["</input>"], stop_sequences: ["</input>"],
+7 -1
View File
@@ -57,6 +57,12 @@ module DiscourseAi
end end
def diffusion_prompt(text, user) 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 = prompt =
DiscourseAi::Completions::Prompt.new( DiscourseAi::Completions::Prompt.new(
<<~TEXT.strip, <<~TEXT.strip,
@@ -66,7 +72,7 @@ module DiscourseAi
messages: [{ type: :user, content: text, id: user.username }], 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, prompt,
user: user, user: user,
feature_name: "illustrate_post", feature_name: "illustrate_post",
+1 -1
View File
@@ -37,7 +37,7 @@ module DiscourseAi
value_h["id"] > 0 || value_h["id"] > 0 ||
SiteSetting.ai_automation_allowed_seeded_models_map.include?(value_h["id"].to_s) SiteSetting.ai_automation_allowed_seeded_models_map.include?(value_h["id"].to_s)
end end
.each { |value_h| value_h["id"] = "custom:#{value_h["id"]}" } .each { |value_h| value_h["id"] = "#{value_h["id"]}" }
values values
end end
+1 -1
View File
@@ -74,7 +74,7 @@ module DiscourseAi
else else
I18n.t("discourse_automation.scriptables.llm_report.title") I18n.t("discourse_automation.scriptables.llm_report.title")
end end
@model = LlmModel.find_by(id: model.split(":")&.last) @model = LlmModel.find_by(id: model)
@persona = AiPersona.find(persona_id).class_instance.new @persona = AiPersona.find(persona_id).class_instance.new
@category_ids = category_ids @category_ids = category_ids
@tags = tags @tags = tags
+2
View File
@@ -292,6 +292,8 @@ module DiscourseAi
llm_model = llm_model =
if model.is_a?(LlmModel) if model.is_a?(LlmModel)
model model
elsif model.is_a?(Numeric)
LlmModel.find_by(id: model)
else else
model_name_without_prov = model.split(":").last.to_i model_name_without_prov = model.split(":").last.to_i
@@ -10,16 +10,9 @@ module DiscourseAi
def valid_value?(val) def valid_value?(val)
return true if val == "f" return true if val == "f"
if @opts[:name] == :ai_summarization_enabled || @opts[:name] == :ai_helper_enabled @llm_dependency_setting_name = :ai_default_llm_model
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])
SiteSetting.public_send(@llm_dependency_setting_name).present? SiteSetting.public_send(@llm_dependency_setting_name).present?
end
end end
def error_message 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 } } .each { |llm_id, name, id| rval[llm_id] << { type: :ai_persona, name: name, id: id } }
if SiteSetting.ai_helper_enabled 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 end
if SiteSetting.ai_helper_image_caption_model if SiteSetting.ai_helper_enabled_features.split("|").include?("image_caption")
model_id = SiteSetting.ai_helper_image_caption_model.split(":").last.to_i image_caption_persona = AiPersona.find_by(id: SiteSetting.ai_helper_image_caption_persona)
rval[model_id] << { type: :ai_helper_image_caption } if model_id != 0 model_id = image_caption_persona.default_llm_id || SiteSetting.ai_default_llm_model.to_i
rval[model_id] << { type: :ai_helper_image_caption }
end end
if SiteSetting.ai_summarization_enabled if SiteSetting.ai_summarization_enabled
summarization_persona = AiPersona.find_by(id: SiteSetting.ai_summarization_persona) 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 } rval[model_id] << { type: :ai_summarization }
end end
if SiteSetting.ai_embeddings_semantic_search_enabled 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 } rval[model_id] << { type: :ai_embeddings_semantic_search }
end end
@@ -58,12 +87,8 @@ module DiscourseAi
) )
.each do |model_text, name, id| .each do |model_text, name, id|
next if model_text.blank? next if model_text.blank?
model_id = model_text.split("custom:").last.to_i model_id = model_text.to_i
if model_id.present? rval[model_id] << { type: :automation, name: name, id: id } if model_id.present?
if model_text =~ /custom:(\d+)/
rval[model_id] << { type: :automation, name: name, id: id }
end
end
end end
end end
@@ -94,7 +119,7 @@ module DiscourseAi
builder.query_hash.map(&:symbolize_keys) builder.query_hash.map(&:symbolize_keys)
end end
def self.values(allowed_seeded_llms: nil) def self.values(allowed_seeded_llms: [])
values = DB.query_hash(<<~SQL).map(&:symbolize_keys) values = DB.query_hash(<<~SQL).map(&:symbolize_keys)
SELECT display_name AS name, id AS value SELECT display_name AS name, id AS value
FROM llm_models FROM llm_models
@@ -107,7 +132,6 @@ module DiscourseAi
end end
end end
values.each { |value_h| value_h[:value] = "custom:#{value_h[:value]}" }
values values
end end
end end
+25 -44
View File
@@ -2,9 +2,6 @@
module DiscourseAi module DiscourseAi
module Configuration module Configuration
class InvalidSeededModelError < StandardError
end
class LlmValidator class LlmValidator
def initialize(opts = {}) def initialize(opts = {})
@opts = opts @opts = opts
@@ -12,18 +9,21 @@ module DiscourseAi
def valid_value?(val) def valid_value?(val)
if 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) enabled_settings.each do |setting_name|
return !@parent_enabled if SiteSetting.public_send(setting_name) == true
@parent_module_names << setting_name
@parent_enabled = true
end
end
return !@parent_enabled
end
end end
allowed_seeded_model?(val)
run_test(val).tap { |result| @unreachable = result } run_test(val).tap { |result| @unreachable = result }
rescue DiscourseAi::Configuration::InvalidSeededModelError => e
@unreachable = true
false
rescue StandardError => e rescue StandardError => e
raise e if Rails.env.test? raise e if Rails.env.test?
@unreachable = true @unreachable = true
@@ -38,54 +38,35 @@ module DiscourseAi
end end
def modules_using(llm_model) 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 end
def error_message def error_message
if @parent_enabled if @parent_enabled && @parent_module_names.present?
return( return(
I18n.t( I18n.t(
"discourse_ai.llm.configuration.disable_module_first", "discourse_ai.llm.configuration.disable_modules_first",
setting: @parent_module_name, settings: @parent_module_names.join(", "),
) )
) )
end end
if @invalid_seeded_model
return I18n.t("discourse_ai.llm.configuration.invalid_seeded_model")
end
return unless @unreachable return unless @unreachable
I18n.t("discourse_ai.llm.configuration.model_unreachable") I18n.t("discourse_ai.llm.configuration.model_unreachable")
end end
def choose_llm_setting_for(module_enabler_setting) def enabled_settings
modules_and_choose_llm_settings[module_enabler_setting] %i[
end ai_embeddings_semantic_search_enabled
ai_helper_enabled
def modules_and_choose_llm_settings ai_summarization_enabled
{ ai_translation_enabled
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
end end
end end
end end
@@ -16,8 +16,6 @@ module DiscourseAi
WHERE vision_enabled WHERE vision_enabled
SQL SQL
values.each { |value_h| value_h[:value] = "custom:#{value_h[:value]}" }
values values
end end
end end
+28 -24
View File
@@ -6,8 +6,8 @@ module DiscourseAi
def self.clear_cache_for(query) def self.clear_cache_for(query)
digest = OpenSSL::Digest::SHA1.hexdigest(query) digest = OpenSSL::Digest::SHA1.hexdigest(query)
hyde_key = hyde_model_id = find_ai_hyde_model_id
"semantic-search-#{digest}-#{SiteSetting.ai_embeddings_semantic_search_hyde_model}" hyde_key = "semantic-search-#{digest}-#{hyde_model_id}"
Discourse.cache.delete(hyde_key) Discourse.cache.delete(hyde_key)
Discourse.cache.delete("#{hyde_key}-#{SiteSetting.ai_embeddings_selected_model}") Discourse.cache.delete("#{hyde_key}-#{SiteSetting.ai_embeddings_selected_model}")
@@ -20,12 +20,9 @@ module DiscourseAi
def cached_query?(query) def cached_query?(query)
digest = OpenSSL::Digest::SHA1.hexdigest(query) digest = OpenSSL::Digest::SHA1.hexdigest(query)
hyde_model_id = self.class.find_ai_hyde_model_id
embedding_key = embedding_key =
build_embedding_key( build_embedding_key(digest, hyde_model_id, SiteSetting.ai_embeddings_selected_model)
digest,
SiteSetting.ai_embeddings_semantic_search_hyde_model,
SiteSetting.ai_embeddings_selected_model,
)
Discourse.cache.read(embedding_key).present? Discourse.cache.read(embedding_key).present?
end end
@@ -36,14 +33,11 @@ module DiscourseAi
def hyde_embedding(search_term) def hyde_embedding(search_term)
digest = OpenSSL::Digest::SHA1.hexdigest(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 = embedding_key =
build_embedding_key( build_embedding_key(digest, hyde_model_id, SiteSetting.ai_embeddings_selected_model)
digest,
SiteSetting.ai_embeddings_semantic_search_hyde_model,
SiteSetting.ai_embeddings_selected_model,
)
hypothetical_post = hypothetical_post =
Discourse Discourse
@@ -111,6 +105,7 @@ module DiscourseAi
max_semantic_results_per_page = 100 max_semantic_results_per_page = 100
search = Search.new(query, { guardian: guardian }) search = Search.new(query, { guardian: guardian })
search_term = search.term 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 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) digest = OpenSSL::Digest::SHA1.hexdigest(search_term)
embedding_key = embedding_key =
build_embedding_key( build_embedding_key(digest, hyde_model_id, SiteSetting.ai_embeddings_selected_model)
digest,
SiteSetting.ai_embeddings_semantic_search_hyde_model,
SiteSetting.ai_embeddings_selected_model,
)
search_term_embedding = search_term_embedding =
Discourse Discourse
@@ -210,15 +201,28 @@ module DiscourseAi
# Priorities are: # Priorities are:
# 1. Persona's default LLM # 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) def find_ai_hyde_model(persona_klass)
model_id = model_id = persona_klass.default_llm_id || SiteSetting.ai_default_llm_model
persona_klass.default_llm_id ||
SiteSetting.ai_embeddings_semantic_search_hyde_model&.split(":")&.last
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 end
private private
+3 -5
View File
@@ -54,11 +54,9 @@ module DiscourseAi
# Priorities are: # Priorities are:
# 1. Persona's default LLM # 1. Persona's default LLM
# 2. Hidden `ai_summarization_model` setting # 2. SiteSetting.ai_default_llm_id (or newest LLM if not set)
# 3. Newest LLM config
def find_summarization_model(persona_klass) def find_summarization_model(persona_klass)
model_id = model_id = persona_klass.default_llm_id || SiteSetting.ai_default_llm_model
persona_klass.default_llm_id || SiteSetting.ai_summarization_model&.split(":")&.last # Remove legacy custom provider.
if model_id.present? if model_id.present?
LlmModel.find_by(id: model_id) LlmModel.find_by(id: model_id)
@@ -73,7 +71,7 @@ module DiscourseAi
persona = persona_klass.new persona = persona_klass.new
user = User.find_by(id: persona_klass.user_id) || Discourse.system_user 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 end
end end
+1 -1
View File
@@ -88,7 +88,7 @@ namespace :ai do
messages: [{ type: :user, content: prompt, id: "user" }], 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, prompt,
user: Discourse.system_user, user: Discourse.system_user,
feature_name: "topic-generator", feature_name: "topic-generator",
+19 -2
View File
@@ -3,11 +3,28 @@
module DiscourseAi module DiscourseAi
module Translation module Translation
def self.enabled? def self.enabled?
SiteSetting.discourse_ai_enabled && SiteSetting.ai_translation_enabled && SiteSetting.discourse_ai_enabled && SiteSetting.ai_translation_enabled && has_llm_model? &&
SiteSetting.ai_translation_model.present? &&
SiteSetting.content_localization_supported_locales.present? SiteSetting.content_localization_supported_locales.present?
end 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? def self.backfill_enabled?
enabled? && SiteSetting.ai_translation_backfill_hourly_rate > 0 && enabled? && SiteSetting.ai_translation_backfill_hourly_rate > 0 &&
SiteSetting.ai_translation_backfill_max_age_days > 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