Compare commits

..
Author SHA1 Message Date
Sam Saffron 399feafc4f WIP: migrate persona to agent 2025-05-29 15:40:46 +10:00
640 changed files with 3379447 additions and 23380 deletions
-2
View File
@@ -1,5 +1,3 @@
< 3.5.0.beta7-dev: cd14b0c0bee0cf63c59b64b6f7213e31a37f11a7
< 3.5.0.beta6-dev: 3e74eea1e5e3143888d67a8d8a11206df214dc24
< 3.5.0.beta3-dev: 09a68414804a1447f52e5d60691ba59742cda9ec
< 3.5.0.beta2-dev: de8624416a15b3d8e7ad350b083cc1420451ccec
< 3.5.0.beta1-dev: bdef136080074a993e7c4f5ca562edc31a8ba756
+1
View File
@@ -6,4 +6,5 @@ evals/log
evals/cases
config/eval-llms.local.yml
# this gets rid of search results from ag, ripgrep, etc
tokenizers/
public/ai-share/highlight.min.js
+1 -1
View File
@@ -34,7 +34,7 @@ GEM
racc
prettier_print (1.2.1)
racc (1.8.1)
rack (3.1.16)
rack (3.1.14)
rainbow (3.1.1)
regexp_parser (2.10.0)
rubocop (1.74.0)
@@ -0,0 +1,19 @@
import DiscourseRoute from "discourse/routes/discourse";
export default class AdminPluginsShowDiscourseAiAgentsEdit extends DiscourseRoute {
async model(params) {
const allAgents = this.modelFor(
"adminPlugins.show.discourse-ai-agents"
);
const id = parseInt(params.id, 10);
return allAgents.findBy("id", id);
}
setupController(controller, model) {
super.setupController(controller, model);
controller.set(
"allAgents",
this.modelFor("adminPlugins.show.discourse-ai-agents")
);
}
}
@@ -1,16 +1,16 @@
import { AUTO_GROUPS } from "discourse/lib/constants";
import DiscourseRoute from "discourse/routes/discourse";
export default class AdminPluginsShowDiscourseAiPersonasNew extends DiscourseRoute {
export default class AdminPluginsShowDiscourseAiAgentsNew extends DiscourseRoute {
async model() {
const record = this.store.createRecord("ai-persona");
const record = this.store.createRecord("ai-agent");
record.set("allowed_group_ids", [AUTO_GROUPS.trust_level_0.id]);
record.set("rag_uploads", []);
// these match the defaults on the table
record.set("rag_chunk_tokens", 374);
record.set("rag_chunk_overlap_tokens", 10);
record.set("rag_conversation_chunks", 10);
record.set("allow_personal_messages", true);
record.set("allow_agentl_messages", true);
record.set("tool_details", false);
return record;
}
@@ -18,8 +18,8 @@ export default class AdminPluginsShowDiscourseAiPersonasNew extends DiscourseRou
setupController(controller, model) {
super.setupController(controller, model);
controller.set(
"allPersonas",
this.modelFor("adminPlugins.show.discourse-ai-personas")
"allAgents",
this.modelFor("adminPlugins.show.discourse-ai-agents")
);
}
}
@@ -0,0 +1,7 @@
import DiscourseRoute from "discourse/routes/discourse";
export default class DiscourseAiAiAgentsRoute extends DiscourseRoute {
model() {
return this.store.findAll("ai-agent");
}
}
@@ -1,4 +1,3 @@
import { action } from "@ember/object";
import { ajax } from "discourse/lib/ajax";
import DiscourseRoute from "discourse/routes/discourse";
import SiteSetting from "admin/models/site-setting";
@@ -13,7 +12,7 @@ export default class AdminPluginsShowDiscourseAiFeaturesEdit extends DiscourseRo
const { site_settings } = await ajax("/admin/config/site_settings.json", {
data: {
filter_area: `ai-features/${currentFeature.module_name}`,
filter_area: `ai-features/${currentFeature.ref}`,
plugin: "discourse-ai",
category: "discourse_ai",
},
@@ -25,11 +24,4 @@ export default class AdminPluginsShowDiscourseAiFeaturesEdit extends DiscourseRo
return currentFeature;
}
@action
willTransition() {
// site settings may amend if a feature is enabled or disabled, so refresh the model
// even on back button
this.router.refresh("adminPlugins.show.discourse-ai-features");
}
}
@@ -1,19 +0,0 @@
import DiscourseRoute from "discourse/routes/discourse";
export default class AdminPluginsShowDiscourseAiPersonasEdit extends DiscourseRoute {
async model(params) {
const allPersonas = this.modelFor(
"adminPlugins.show.discourse-ai-personas"
);
const id = parseInt(params.id, 10);
return allPersonas.findBy("id", id);
}
setupController(controller, model) {
super.setupController(controller, model);
controller.set(
"allPersonas",
this.modelFor("adminPlugins.show.discourse-ai-personas")
);
}
}
@@ -1,7 +0,0 @@
import DiscourseRoute from "discourse/routes/discourse";
export default class DiscourseAiAiPersonasRoute extends DiscourseRoute {
model() {
return this.store.findAll("ai-persona");
}
}
@@ -0,0 +1,4 @@
<AiAgentListEditor
@agents={{this.allAgents}}
@currentAgent={{this.model}}
/>
@@ -0,0 +1 @@
<AiAgentListEditor @agents={{this.model}} />
@@ -0,0 +1,4 @@
<AiAgentListEditor
@agents={{this.allAgents}}
@currentAgent={{this.model}}
/>
@@ -0,0 +1,156 @@
import Component from "@glimmer/component";
import { service } from "@ember/service";
import RouteTemplate from "ember-route-template";
import { gt } from "truth-helpers";
import DBreadcrumbsItem from "discourse/components/d-breadcrumbs-item";
import DButton from "discourse/components/d-button";
import DPageSubheader from "discourse/components/d-page-subheader";
import { i18n } from "discourse-i18n";
export default RouteTemplate(
class extends Component {
@service adminPluginNavManager;
get tableHeaders() {
const prefix = "discourse_ai.features.list.header";
return [
i18n(`${prefix}.name`),
i18n(`${prefix}.agent`),
i18n(`${prefix}.groups`),
"",
];
}
get configuredFeatures() {
return this.args.model.filter(
(feature) => feature.enable_setting.value === true
);
}
get unconfiguredFeatures() {
return this.args.model.filter(
(feature) => feature.enable_setting.value === false
);
}
<template>
<DBreadcrumbsItem
@path="/admin/plugins/{{this.adminPluginNavManager.currentPlugin.name}}/ai-features"
@label={{i18n "discourse_ai.features.short_title"}}
/>
<section class="ai-feature-list admin-detail">
<DPageSubheader
@titleLabel={{i18n "discourse_ai.features.short_title"}}
@descriptionLabel={{i18n "discourse_ai.features.description"}}
@learnMoreUrl="todo"
/>
{{#if (gt this.configuredFeatures.length 0)}}
<div class="ai-feature-list__configured-features">
<h3>{{i18n "discourse_ai.features.list.configured_features"}}</h3>
<table class="d-admin-table">
<thead>
<tr>
{{#each this.tableHeaders as |header|}}
<th>{{header}}</th>
{{/each}}
</tr>
</thead>
<tbody>
{{#each this.configuredFeatures as |feature|}}
<tr
class="ai-feature-list__row d-admin-row__content"
data-feature-name={{feature.name}}
>
<td class="d-admin-row__overview ai-feature-list__row-item">
<span class="ai-feature-list__row-item-name">
<strong>
{{feature.name}}
</strong>
</span>
<span class="ai-feature-list__row-item-description">
{{feature.description}}
</span>
</td>
<td
class="d-admin-row__detail ai-feature-list__row-item ai-feature-list__agent"
>
<DButton
class="btn-flat btn-small ai-feature-list__row-item-agent"
@translatedLabel={{feature.agent.name}}
@route="adminPlugins.show.discourse-ai-agents.edit"
@routeModels={{feature.agent.id}}
/>
</td>
<td
class="d-admin-row__detail ai-feature-list__row-item ai-feature-list__groups"
>
{{#if (gt feature.agent.allowed_groups.length 0)}}
<ul class="ai-feature-list__row-item-groups">
{{#each feature.agent.allowed_groups as |group|}}
<li>{{group.name}}</li>
{{/each}}
</ul>
{{/if}}
</td>
<td class="d-admin-row_controls">
<DButton
class="btn-small edit"
@label="discourse_ai.features.list.edit"
@route="adminPlugins.show.discourse-ai-features.edit"
@routeModels={{feature.id}}
/>
</td>
</tr>
{{/each}}
</tbody>
</table>
</div>
{{/if}}
{{#if (gt this.unconfiguredFeatures.length 0)}}
<div class="ai-feature-list__unconfigured-features">
<h3>{{i18n "discourse_ai.features.list.unconfigured_features"}}</h3>
<table class="d-admin-table">
<thead>
<tr>
<th>{{i18n "discourse_ai.features.list.header.name"}}</th>
<th></th>
</tr>
</thead>
<tbody>
{{#each this.unconfiguredFeatures as |feature|}}
<tr class="ai-feature-list__row d-admin-row__content">
<td class="d-admin-row__overview ai-feature-list__row-item">
<span class="ai-feature-list__row-item-name">
<strong>
{{feature.name}}
</strong>
</span>
<span class="ai-feature-list__row-item-description">
{{feature.description}}
</span>
</td>
<td class="d-admin-row_controls">
<DButton
class="btn-small"
@label="discourse_ai.features.list.set_up"
@route="adminPlugins.show.discourse-ai-features.edit"
@routeModels={{feature.id}}
/>
</td>
</tr>
{{/each}}
</tbody>
</table>
</div>
{{/if}}
</section>
</template>
}
);
@@ -1 +0,0 @@
<AiFeatures @features={{this.model}} />
@@ -1,4 +0,0 @@
<AiPersonaListEditor
@personas={{this.allPersonas}}
@currentPersona={{this.model}}
/>
@@ -1 +0,0 @@
<AiPersonaListEditor @personas={{this.model}} />
@@ -1,4 +0,0 @@
<AiPersonaListEditor
@personas={{this.allPersonas}}
@currentPersona={{this.model}}
/>
@@ -1,4 +1,4 @@
<section class="ai-persona-tool-editor__current admin-detail pull-left">
<section class="ai-agent-tool-editor__current admin-detail pull-left">
<AiToolEditor
@tools={{this.allTools}}
@model={{this.model}}
@@ -1,4 +1,4 @@
<section class="ai-persona-tool-editor__current admin-detail pull-left">
<section class="ai-agent-tool-editor__current admin-detail pull-left">
<AiToolEditor
@tools={{this.allTools}}
@model={{this.model}}
@@ -0,0 +1,266 @@
# frozen_string_literal: true
module DiscourseAi
module Admin
class AiAgentsController < ::Admin::AdminController
requires_plugin ::DiscourseAi::PLUGIN_NAME
before_action :find_ai_agent, only: %i[edit update destroy create_user]
def index
ai_agents =
AiAgent.ordered.map do |agent|
# we use a special serializer here cause names and descriptions are
# localized for system agents
LocalizedAiAgentSerializer.new(agent, root: false)
end
tools =
DiscourseAi::Agents::Agent.all_available_tools.map do |tool|
AiToolSerializer.new(tool, root: false)
end
AiTool
.where(enabled: true)
.each do |tool|
tools << {
id: "custom-#{tool.id}",
name:
I18n.t(
"discourse_ai.tools.custom_name",
name: tool.name.capitalize,
tool_name: tool.tool_name,
),
}
end
llms =
DiscourseAi::Configuration::LlmEnumerator.values_for_serialization(
allowed_seeded_llm_ids: SiteSetting.ai_bot_allowed_seeded_models_map,
)
render json: {
ai_agents: ai_agents,
meta: {
tools: tools,
llms: llms,
settings: {
rag_images_enabled: SiteSetting.ai_rag_images_enabled,
},
},
}
end
def new
end
def edit
render json: LocalizedAiAgentSerializer.new(@ai_agent)
end
def create
ai_agent = AiAgent.new(ai_agent_params.except(:rag_uploads))
if ai_agent.save
RagDocumentFragment.link_target_and_uploads(ai_agent, attached_upload_ids)
render json: {
ai_agent: LocalizedAiAgentSerializer.new(ai_agent, root: false),
},
status: :created
else
render_json_error ai_agent
end
end
def create_user
user = @ai_agent.create_user!
render json: BasicUserSerializer.new(user, root: "user")
end
def update
if @ai_agent.update(ai_agent_params.except(:rag_uploads))
RagDocumentFragment.update_target_uploads(@ai_agent, attached_upload_ids)
render json: LocalizedAiAgentSerializer.new(@ai_agent, root: false)
else
render_json_error @ai_agent
end
end
def destroy
if @ai_agent.destroy
head :no_content
else
render_json_error @ai_agent
end
end
def stream_reply
agent =
AiAgent.find_by(name: params[:agent_name]) ||
AiAgent.find_by(id: params[:agent_id])
return render_json_error(I18n.t("discourse_ai.errors.agent_not_found")) if agent.nil?
return render_json_error(I18n.t("discourse_ai.errors.agent_disabled")) if !agent.enabled
if agent.default_llm.blank?
return render_json_error(I18n.t("discourse_ai.errors.no_default_llm"))
end
if params[:query].blank?
return render_json_error(I18n.t("discourse_ai.errors.no_query_specified"))
end
if !agent.user_id
return render_json_error(I18n.t("discourse_ai.errors.no_user_for_agent"))
end
if !params[:username] && !params[:user_unique_id]
return render_json_error(I18n.t("discourse_ai.errors.no_user_specified"))
end
user = nil
if params[:username]
user = User.find_by_username(params[:username])
return render_json_error(I18n.t("discourse_ai.errors.user_not_found")) if user.nil?
elsif params[:user_unique_id]
user = stage_user
end
raise Discourse::NotFound if user.nil?
topic_id = params[:topic_id].to_i
topic = nil
if topic_id > 0
topic = Topic.find(topic_id)
if topic.topic_allowed_users.where(user_id: user.id).empty?
return render_json_error(I18n.t("discourse_ai.errors.user_not_allowed"))
end
end
hijack = request.env["rack.hijack"]
io = hijack.call
DiscourseAi::AiBot::ResponseHttpStreamer.queue_streamed_reply(
io: io,
agent: agent,
user: user,
topic: topic,
query: params[:query].to_s,
custom_instructions: params[:custom_instructions].to_s,
current_user: current_user,
)
end
private
AI_STREAM_CONVERSATION_UNIQUE_ID = "ai-stream-conversation-unique-id"
def stage_user
unique_id = params[:user_unique_id].to_s
field = UserCustomField.find_by(name: AI_STREAM_CONVERSATION_UNIQUE_ID, value: unique_id)
if field
field.user
else
preferred_username = params[:preferred_username]
username = UserNameSuggester.suggest(preferred_username || unique_id)
user =
User.new(
username: username,
email: "#{SecureRandom.hex}@invalid.com",
staged: true,
active: false,
)
user.custom_fields[AI_STREAM_CONVERSATION_UNIQUE_ID] = unique_id
user.save!
user
end
end
def find_ai_agent
@ai_agent = AiAgent.find(params[:id])
end
def attached_upload_ids
ai_agent_params[:rag_uploads].to_a.map { |h| h[:id] }
end
def ai_agent_params
permitted =
params.require(:ai_agent).permit(
:name,
:description,
:enabled,
:system_prompt,
:priority,
:top_p,
:temperature,
:default_llm_id,
:user_id,
:max_context_posts,
:vision_enabled,
:vision_max_pixels,
:rag_chunk_tokens,
:rag_chunk_overlap_tokens,
:rag_conversation_chunks,
:rag_llm_model_id,
:question_consolidator_llm_id,
:allow_chat_channel_mentions,
:allow_chat_direct_messages,
:allow_topic_mentions,
:allow_agentl_messages,
:tool_details,
:forced_tool_count,
:force_default_llm,
allowed_group_ids: [],
rag_uploads: [:id],
)
if tools = params.dig(:ai_agent, :tools)
permitted[:tools] = permit_tools(tools)
end
if response_format = params.dig(:ai_agent, :response_format)
permitted[:response_format] = permit_response_format(response_format)
end
if examples = params.dig(:ai_agent, :examples)
permitted[:examples] = permit_examples(examples)
end
permitted
end
def permit_tools(tools)
return [] if !tools.is_a?(Array)
tools.filter_map do |tool, options, force_tool|
break nil if !tool.is_a?(String)
options&.permit! if options && options.is_a?(ActionController::Parameters)
# this is simpler from a storage perspective, 1 way to store tools
[tool, options, !!force_tool]
end
end
def permit_response_format(response_format)
return [] if !response_format.is_a?(Array)
response_format.map do |element|
if element && element.is_a?(ActionController::Parameters)
element.permit!
else
false
end
end
end
def permit_examples(examples)
return [] if !examples.is_a?(Array)
examples.map { |example_arr| example_arr.take(2).map(&:to_s) }
end
end
end
end
@@ -40,7 +40,6 @@ module DiscourseAi
embedding_def = EmbeddingDefinition.new(ai_embeddings_params)
if embedding_def.save
log_ai_embedding_creation(embedding_def)
render json: AiEmbeddingDefinitionSerializer.new(embedding_def), status: :created
else
render_json_error embedding_def
@@ -56,10 +55,7 @@ module DiscourseAi
)
end
initial_attributes = embedding_def.attributes.dup
if embedding_def.update(ai_embeddings_params.except(:dimensions))
log_ai_embedding_update(embedding_def, initial_attributes)
render json: AiEmbeddingDefinitionSerializer.new(embedding_def)
else
render_json_error embedding_def
@@ -79,16 +75,7 @@ module DiscourseAi
return render_json_error(I18n.t("discourse_ai.embeddings.delete_failed"), status: 409)
end
embedding_details = {
embedding_id: embedding_def.id,
display_name: embedding_def.display_name,
provider: embedding_def.provider,
dimensions: embedding_def.dimensions,
subject: embedding_def.display_name,
}
if embedding_def.destroy
log_ai_embedding_deletion(embedding_details)
head :no_content
else
render_json_error embedding_def
@@ -141,60 +128,6 @@ module DiscourseAi
permitted
end
def ai_embeddings_logger_fields
{
display_name: {
},
provider: {
},
dimensions: {
},
url: {
},
tokenizer_class: {
},
max_sequence_length: {
},
embed_prompt: {
type: :large_text,
},
search_prompt: {
type: :large_text,
},
matryoshka_dimensions: {
},
api_key: {
type: :sensitive,
},
# JSON fields should be tracked as simple changes
json_fields: [:provider_params],
}
end
def log_ai_embedding_creation(embedding_def)
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
entity_details = { embedding_id: embedding_def.id, subject: embedding_def.display_name }
logger.log_creation("embedding", embedding_def, ai_embeddings_logger_fields, entity_details)
end
def log_ai_embedding_update(embedding_def, initial_attributes)
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
entity_details = { embedding_id: embedding_def.id, subject: embedding_def.display_name }
logger.log_update(
"embedding",
embedding_def,
initial_attributes,
ai_embeddings_logger_fields,
entity_details,
)
end
def log_ai_embedding_deletion(embedding_details)
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
logger.log_deletion("embedding", embedding_details)
end
end
end
end
@@ -6,60 +6,30 @@ module DiscourseAi
requires_plugin ::DiscourseAi::PLUGIN_NAME
def index
render json: serialize_modules(DiscourseAi::Configuration::Module.all)
render json: serialize_features(DiscourseAi::Features.features)
end
def edit
raise Discourse::InvalidParameters.new(:id) if params[:id].blank?
a_module = DiscourseAi::Configuration::Module.find_by(id: params[:id].to_i)
render json: serialize_module(a_module)
render json: serialize_feature(DiscourseAi::Features.find_feature_by_id(params[:id].to_i))
end
private
def serialize_modules(modules)
modules.map { |a_module| serialize_module(a_module) }
end
def serialize_module(a_module)
return nil if a_module.blank?
{
id: a_module.id,
module_name: a_module.name,
module_enabled: a_module.enabled?,
features: a_module.features.map { |f| serialize_feature(f) },
}
def serialize_features(features)
features.map { |feature| feature.merge(agent: serialize_agent(feature[:agent])) }
end
def serialize_feature(feature)
{
name: feature.name,
personas: feature.persona_ids.map { |id| serialize_persona(persona_id_obj_hash[id]) },
llm_models:
feature.llm_models.map do |llm_model|
{ id: llm_model.id, name: llm_model.display_name }
end,
enabled: feature.enabled?,
}
return nil if feature.blank?
feature.merge(agent: serialize_agent(feature[:agent]))
end
def serialize_persona(persona)
return nil if persona.blank?
def serialize_agent(agent)
return nil if agent.blank?
serialize_data(persona, AiFeaturesPersonaSerializer, root: false)
end
private
def persona_id_obj_hash
@persona_id_obj_hash ||=
begin
ids = DiscourseAi::Configuration::Feature.all.map(&:persona_ids).flatten.uniq
AiPersona.where(id: ids).index_by(&:id)
end
serialize_data(agent, AiFeaturesAgentSerializer, root: false)
end
end
end
@@ -47,7 +47,6 @@ module DiscourseAi
if llm_model.save
llm_model.toggle_companion_user
log_llm_model_creation(llm_model)
render json: LlmModelSerializer.new(llm_model), status: :created
else
render_json_error llm_model
@@ -57,10 +56,6 @@ module DiscourseAi
def update
llm_model = LlmModel.find(params[:id])
# Capture initial state for logging
initial_attributes = llm_model.attributes.dup
initial_quotas = llm_model.llm_quotas.map(&:attributes)
if params[:ai_llm].key?(:llm_quotas)
if quota_params
existing_quota_group_ids = llm_model.llm_quotas.pluck(:group_id)
@@ -86,7 +81,6 @@ module DiscourseAi
if llm_model.update(ai_llm_params(updating: llm_model))
llm_model.toggle_companion_user
log_llm_model_update(llm_model, initial_attributes, initial_quotas)
render json: LlmModelSerializer.new(llm_model)
else
render_json_error llm_model
@@ -115,20 +109,11 @@ module DiscourseAi
)
end
# Capture model details for logging before destruction
model_details = {
model_id: llm_model.id,
display_name: llm_model.display_name,
name: llm_model.name,
provider: llm_model.provider,
}
# Clean up companion users
llm_model.enabled_chat_bot = false
llm_model.toggle_companion_user
if llm_model.destroy
log_llm_model_deletion(model_details)
head :no_content
else
render_json_error llm_model
@@ -205,89 +190,6 @@ module DiscourseAi
permitted
end
def ai_llm_logger_fields
{
display_name: {
},
name: {
},
provider: {
},
tokenizer: {
},
url: {
},
max_prompt_tokens: {
},
max_output_tokens: {
},
enabled_chat_bot: {
},
vision_enabled: {
},
api_key: {
type: :sensitive,
},
input_cost: {
},
output_cost: {
},
# JSON fields should be tracked as simple changes
json_fields: [:provider_params],
}
end
def log_llm_model_creation(llm_model)
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
entity_details = { model_id: llm_model.id, subject: llm_model.display_name }
# Add quota information as a special case
if llm_model.llm_quotas.any?
entity_details[:quotas] = llm_model
.llm_quotas
.map do |quota|
"Group #{quota.group_id}: #{quota.max_tokens} tokens, #{quota.max_usages} usages, #{quota.duration_seconds}s"
end
.join("; ")
end
logger.log_creation("llm_model", llm_model, ai_llm_logger_fields, entity_details)
end
def log_llm_model_update(llm_model, initial_attributes, initial_quotas)
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
entity_details = { model_id: llm_model.id, subject: llm_model.display_name }
# Track quota changes separately as they're a special case
current_quotas = llm_model.llm_quotas.reload.map(&:attributes)
if initial_quotas != current_quotas
initial_quota_summary =
initial_quotas
.map { |q| "Group #{q["group_id"]}: #{q["max_tokens"]} tokens" }
.join("; ")
current_quota_summary =
current_quotas
.map { |q| "Group #{q["group_id"]}: #{q["max_tokens"]} tokens" }
.join("; ")
entity_details[:quotas_changed] = true
entity_details[:quotas] = "#{initial_quota_summary} → #{current_quota_summary}"
end
logger.log_update(
"llm_model",
llm_model,
initial_attributes,
ai_llm_logger_fields,
entity_details,
)
end
def log_llm_model_deletion(model_details)
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
model_details[:subject] = model_details[:display_name]
logger.log_deletion("llm_model", model_details)
end
end
end
end
File diff suppressed because it is too large Load Diff
@@ -10,17 +10,6 @@ module DiscourseAi
end
def update
initial_settings = AiModerationSetting.spam
initial_data = {
custom_instructions: initial_settings&.data&.dig("custom_instructions"),
llm_model_id: initial_settings&.llm_model_id,
ai_persona_id: initial_settings&.ai_persona_id,
}
initial_custom_instructions = initial_settings&.data&.dig("custom_instructions")
initial_llm_model_id = initial_settings&.llm_model_id
updated_params = {}
if allowed_params.key?(:llm_model_id)
llm_model_id = updated_params[:llm_model_id] = allowed_params[:llm_model_id]
@@ -36,22 +25,6 @@ module DiscourseAi
)
end
end
if allowed_params.key?(:ai_persona_id)
updated_params[:ai_persona_id] = allowed_params[:ai_persona_id]
persona = AiPersona.find_by(id: allowed_params[:ai_persona_id])
if persona.nil? ||
persona.response_format.to_a.none? { |rf|
rf["key"] == "spam" && rf["type"] == "boolean"
}
return(
render_json_error(
I18n.t("discourse_ai.llm.configuration.invalid_persona_response_format"),
status: 422,
)
)
end
end
updated_params[:data] = {
custom_instructions: allowed_params[:custom_instructions],
} if allowed_params.key?(:custom_instructions)
@@ -63,8 +36,6 @@ module DiscourseAi
else
AiModerationSetting.create!(updated_params.merge(setting_type: :spam))
end
log_ai_spam_update(initial_data, allowed_params)
end
is_enabled = ActiveModel::Type::Boolean.new.cast(allowed_params[:is_enabled])
@@ -142,44 +113,8 @@ module DiscourseAi
private
def log_ai_spam_update(initial_data, params)
changes_to_log = {}
initial_llm_model_id = initial_data[:llm_model_id]
if params.key?(:llm_model_id) && initial_llm_model_id.to_s != params[:llm_model_id].to_s
old_model_name =
LlmModel.find_by(id: initial_llm_model_id)&.display_name || initial_llm_model_id
new_model_name =
LlmModel.find_by(id: params[:llm_model_id])&.display_name || params[:llm_model_id]
changes_to_log[:llm_model_id] = "#{old_model_name} → #{new_model_name}"
end
initial_custom_instructions = initial_data[:custom_instructions]
if params.key?(:custom_instructions) &&
initial_custom_instructions != params[:custom_instructions]
changes_to_log[:custom_instructions] = params[:custom_instructions]
end
initial_ai_persona_id = initial_data[:ai_persona_id]
if params.key?(:ai_persona_id) && initial_ai_persona_id.to_s != params[:ai_persona_id].to_s
old_persona_name =
AiPersona.find_by(id: initial_ai_persona_id)&.name || initial_ai_persona_id
new_persona_name =
AiPersona.find_by(id: params[:ai_persona_id])&.name || params[:ai_persona_id]
changes_to_log[:ai_persona_id] = "#{old_persona_name} → #{new_persona_name}"
end
if changes_to_log.present?
changes_to_log[:subject] = I18n.t("discourse_ai.spam_detection.logging_subject")
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
logger.log_custom("update_ai_spam_settings", changes_to_log)
end
end
def allowed_params
params.permit(:is_enabled, :llm_model_id, :custom_instructions, :ai_persona_id)
params.permit(:is_enabled, :llm_model_id, :custom_instructions)
end
def spam_config
@@ -5,7 +5,7 @@ module DiscourseAi
class AiToolsController < ::Admin::AdminController
requires_plugin ::DiscourseAi::PLUGIN_NAME
before_action :find_ai_tool, only: %i[test edit update destroy export]
before_action :find_ai_tool, only: %i[test edit update destroy]
def index
ai_tools = AiTool.all
@@ -25,58 +25,15 @@ module DiscourseAi
if ai_tool.save
RagDocumentFragment.link_target_and_uploads(ai_tool, attached_upload_ids)
log_ai_tool_creation(ai_tool)
render_serialized(ai_tool, AiCustomToolSerializer, status: :created)
else
render_json_error ai_tool
end
end
def export
response.headers[
"Content-Disposition"
] = "attachment; filename=\"#{@ai_tool.tool_name}.json\""
render_serialized(@ai_tool, AiCustomToolSerializer)
end
def import
existing_tool = AiTool.find_by(tool_name: ai_tool_params[:tool_name])
force_update = params[:force].present? && params[:force].to_s.downcase == "true"
if existing_tool && !force_update
return(
render_json_error "Tool with tool_name '#{ai_tool_params[:tool_name]}' already exists. Use force=true to overwrite.",
status: :conflict
)
end
if existing_tool && force_update
initial_attributes = existing_tool.attributes.dup
if existing_tool.update(ai_tool_params)
log_ai_tool_update(existing_tool, initial_attributes)
render_serialized(existing_tool, AiCustomToolSerializer)
else
render_json_error existing_tool
end
else
ai_tool = AiTool.new(ai_tool_params)
ai_tool.created_by_id = current_user.id
if ai_tool.save
log_ai_tool_creation(ai_tool)
render_serialized(ai_tool, AiCustomToolSerializer, status: :created)
else
render_json_error ai_tool
end
end
end
def update
initial_attributes = @ai_tool.attributes.dup
if @ai_tool.update(ai_tool_params)
RagDocumentFragment.update_target_uploads(@ai_tool, attached_upload_ids)
log_ai_tool_update(@ai_tool, initial_attributes)
render_serialized(@ai_tool, AiCustomToolSerializer)
else
render_json_error @ai_tool
@@ -84,15 +41,7 @@ module DiscourseAi
end
def destroy
tool_logger_details = {
tool_id: @ai_tool.id,
name: @ai_tool.name,
tool_name: @ai_tool.tool_name,
subject: @ai_tool.name,
}
if @ai_tool.destroy
log_ai_tool_deletion(tool_logger_details)
head :no_content
else
render_json_error @ai_tool
@@ -147,60 +96,6 @@ module DiscourseAi
)
.except(:rag_uploads)
end
def ai_tool_logger_fields
{
name: {
},
tool_name: {
},
description: {
},
summary: {
},
enabled: {
},
rag_chunk_tokens: {
},
rag_chunk_overlap_tokens: {
},
rag_llm_model_id: {
},
script: {
type: :large_text,
},
parameters: {
type: :large_text,
},
}
end
def log_ai_tool_creation(ai_tool)
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
entity_details = { tool_id: ai_tool.id, subject: ai_tool.name }
entity_details[:parameter_count] = ai_tool.parameters.size if ai_tool.parameters.present?
logger.log_creation("tool", ai_tool, ai_tool_logger_fields, entity_details)
end
def log_ai_tool_update(ai_tool, initial_attributes)
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
entity_details = { tool_id: ai_tool.id, subject: ai_tool.name }
logger.log_update(
"tool",
ai_tool,
initial_attributes,
ai_tool_logger_fields,
entity_details,
)
end
def log_ai_tool_deletion(tool_details)
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
logger.log_deletion("tool", tool_details)
end
end
end
end
@@ -6,8 +6,8 @@ module DiscourseAi
requires_plugin ::DiscourseAi::PLUGIN_NAME
def indexing_status_check
if params[:target_type] == "AiPersona"
@target = AiPersona.find(params[:target_id])
if params[:target_type] == "AiAgent"
@target = AiAgent.find(params[:target_id])
elsif params[:target_type] == "AiTool"
@target = AiTool.find(params[:target_id])
else
@@ -1,124 +0,0 @@
# frozen_string_literal: true
module DiscourseAi
module AiBot
class ArtifactKeyValuesController < ::ApplicationController
requires_plugin DiscourseAi::PLUGIN_NAME
before_action :ensure_logged_in, only: %i[set destroy]
before_action :find_artifact
PER_PAGE_MAX = 100
def index
page = index_params[:page].to_i
page = 1 if page < 1
per_page = index_params[:per_page].to_i
per_page = PER_PAGE_MAX if per_page < 1 || per_page > PER_PAGE_MAX
query = build_index_query
total_count = query.count
key_values =
query
.includes(:user)
.order(:user_id, :key, :created_at)
.offset((page - 1) * per_page)
.limit(per_page + 1)
has_more = key_values.length > per_page
key_values = key_values.first(per_page) if has_more
render json: {
key_values:
ActiveModel::ArraySerializer.new(
key_values,
each_serializer: AiArtifactKeyValueSerializer,
keys_only: params[:keys_only] == "true",
).as_json,
has_more: has_more,
total_count: total_count,
users:
key_values
.map { |kv| kv.user }
.uniq
.map { |u| BasicUserSerializer.new(u, root: nil).as_json },
}
end
def destroy
if params[:key].blank?
render json: { error: "Key parameter is required" }, status: :bad_request
return
end
key_value = @artifact.key_values.find_by(user_id: current_user.id, key: params[:key])
if key_value.nil?
render json: { error: "Key not found" }, status: :not_found
elsif key_value.destroy
head :ok
else
render json: { errors: key_value.errors.full_messages }, status: :unprocessable_entity
end
end
def set
key_value =
@artifact.key_values.find_or_initialize_by(
user: current_user,
key: key_value_params[:key],
)
key_value.assign_attributes(key_value_params.except(:key))
if key_value.save
render json: AiArtifactKeyValueSerializer.new(key_value).as_json
else
render json: { errors: key_value.errors.full_messages }, status: :unprocessable_entity
end
end
private
def key_value_params
params.permit(:key, :value, :public)
end
def index_params
@index_params ||= params.permit(:page, :per_page, :key, :keys_only, :all_users)
end
def build_index_query
query = @artifact.key_values
query =
if current_user&.admin?
query
elsif current_user
query.where("user_id = ? OR public = true", current_user.id)
else
query.where(public: true)
end
query = query.where("key = ?", index_params[:key]) if index_params[:key].present?
if index_params[:all_users].to_s != "true"
if current_user
query = query.where(user_id: current_user.id)
else
query = query.where("1 = 0")
end
end
query
end
def find_artifact
@artifact = AiArtifact.find_by(id: params[:artifact_id])
raise Discourse::NotFound if !@artifact
raise Discourse::NotFound if [email protected]? && guardian.anonymous?
raise Discourse::NotFound if [email protected]? && !guardian.can_see?(@artifact.post)
end
end
end
end
File diff suppressed because it is too large Load Diff
@@ -46,17 +46,17 @@ module DiscourseAi
end
def discover
ai_persona =
AiPersona
.all_personas(enabled_only: false)
.find { |persona| persona.id == SiteSetting.ai_bot_discover_persona.to_i }
ai_agent =
AiAgent
.all_agents(enabled_only: false)
.find { |agent| agent.id == SiteSetting.ai_bot_discover_agent.to_i }
if ai_persona.nil? || !current_user.in_any_groups?(ai_persona.allowed_group_ids.to_a)
if ai_agent.nil? || !current_user.in_any_groups?(ai_agent.allowed_group_ids.to_a)
raise Discourse::InvalidAccess.new
end
if ai_persona.default_llm_id.blank?
render_json_error "Discover persona is missing a default LLM model.", status: 503
if ai_agent.default_llm_id.blank?
render_json_error "Discover agent is missing a default LLM model.", status: 503
return
end
@@ -77,7 +77,7 @@ module DiscourseAi
user = User.find(params[:user_id])
bot_user_id = AiPersona.find_by(id: SiteSetting.ai_bot_discover_persona).user_id
bot_user_id = AiAgent.find_by(id: SiteSetting.ai_bot_discover_agent).user_id
bot_username = User.find_by(id: bot_user_id).username
query = params[:query]

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