Compare commits

..
Author SHA1 Message Date
Martin Brennan 9369d9d0d7 WIP: Adding Enum button back to AI tools parameters form 2025-03-18 16:41:41 +10:00
447 changed files with 5287 additions and 17883 deletions
-2
View File
@@ -1,5 +1,3 @@
< 3.5.0.beta3-dev: 09a68414804a1447f52e5d60691ba59742cda9ec
< 3.5.0.beta2-dev: de8624416a15b3d8e7ad350b083cc1420451ccec
< 3.5.0.beta1-dev: bdef136080074a993e7c4f5ca562edc31a8ba756 < 3.5.0.beta1-dev: bdef136080074a993e7c4f5ca562edc31a8ba756
< 3.4.0.beta4-dev: a53719ab8eb071459f215227421b3ea4987e5f87 < 3.4.0.beta4-dev: a53719ab8eb071459f215227421b3ea4987e5f87
< 3.4.0.beta4-dev: 20612fde52d3f740cad64823ef8aadb0748b567f < 3.4.0.beta4-dev: 20612fde52d3f740cad64823ef8aadb0748b567f
-3
View File
@@ -5,6 +5,3 @@ node_modules
evals/log evals/log
evals/cases evals/cases
config/eval-llms.local.yml 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 racc
prettier_print (1.2.1) prettier_print (1.2.1)
racc (1.8.1) racc (1.8.1)
rack (3.1.14) rack (3.1.12)
rainbow (3.1.1) rainbow (3.1.1)
regexp_parser (2.10.0) regexp_parser (2.10.0)
rubocop (1.74.0) rubocop (1.74.0)
+1 -2
View File
@@ -1,8 +1,7 @@
{ {
"tests": { "tests": {
"requiredPlugins": [ "requiredPlugins": [
"https://github.com/discourse/discourse-prometheus", "https://github.com/discourse/discourse-prometheus"
"https://github.com/discourse/discourse-solved.git"
] ]
} }
} }
@@ -1,27 +0,0 @@
import { ajax } from "discourse/lib/ajax";
import DiscourseRoute from "discourse/routes/discourse";
import SiteSetting from "admin/models/site-setting";
export default class AdminPluginsShowDiscourseAiFeaturesEdit extends DiscourseRoute {
async model(params) {
const allFeatures = this.modelFor(
"adminPlugins.show.discourse-ai-features"
);
const id = parseInt(params.id, 10);
const currentFeature = allFeatures.find((feature) => feature.id === id);
const { site_settings } = await ajax("/admin/config/site_settings.json", {
data: {
filter_area: `ai-features/${currentFeature.ref}`,
plugin: "discourse-ai",
category: "discourse_ai",
},
});
currentFeature.feature_settings = site_settings.map((setting) =>
SiteSetting.create(setting)
);
return currentFeature;
}
}
@@ -1,10 +0,0 @@
import { service } from "@ember/service";
import DiscourseRoute from "discourse/routes/discourse";
export default class AdminPluginsShowDiscourseAiFeatures extends DiscourseRoute {
@service store;
async model() {
return this.store.findAll("ai-feature");
}
}
@@ -2,17 +2,8 @@ import DiscourseRoute from "discourse/routes/discourse";
export default class AdminPluginsShowDiscourseAiLlmsEdit extends DiscourseRoute { export default class AdminPluginsShowDiscourseAiLlmsEdit extends DiscourseRoute {
async model(params) { async model(params) {
const id = parseInt(params.id, 10);
if (id < 0) {
// You shouldn't be able to access the edit page
// if the model is seeded
return this.router.transitionTo(
"adminPlugins.show.discourse-ai-llms.index"
);
}
const allLlms = this.modelFor("adminPlugins.show.discourse-ai-llms"); const allLlms = this.modelFor("adminPlugins.show.discourse-ai-llms");
const id = parseInt(params.id, 10);
const record = allLlms.findBy("id", id); const record = allLlms.findBy("id", id);
record.provider_params = record.provider_params || {}; record.provider_params = record.provider_params || {};
return record; return record;
@@ -1,24 +0,0 @@
import RouteTemplate from "ember-route-template";
import BackButton from "discourse/components/back-button";
import SiteSettingComponent from "admin/components/site-setting";
export default RouteTemplate(
<template>
<BackButton
@route="adminPlugins.show.discourse-ai-features"
@label="discourse_ai.features.back"
/>
<section class="ai-feature-editor__header">
<h2>{{@model.name}}</h2>
<p>{{@model.description}}</p>
</section>
<section class="ai-feature-editor">
{{#each @model.feature_settings as |setting|}}
<div>
<SiteSettingComponent @setting={{setting}} />
</div>
{{/each}}
</section>
</template>
);
@@ -1,156 +0,0 @@
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}.persona`),
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__persona"
>
<DButton
class="btn-flat btn-small ai-feature-list__row-item-persona"
@translatedLabel={{feature.persona.name}}
@route="adminPlugins.show.discourse-ai-personas.edit"
@routeModels={{feature.persona.id}}
/>
</td>
<td
class="d-admin-row__detail ai-feature-list__row-item ai-feature-list__groups"
>
{{#if (gt feature.persona.allowed_groups.length 0)}}
<ul class="ai-feature-list__row-item-groups">
{{#each feature.persona.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,36 +0,0 @@
# frozen_string_literal: true
module DiscourseAi
module Admin
class AiFeaturesController < ::Admin::AdminController
requires_plugin ::DiscourseAi::PLUGIN_NAME
def index
render json: serialize_features(DiscourseAi::Features.features)
end
def edit
raise Discourse::InvalidParameters.new(:id) if params[:id].blank?
render json: serialize_feature(DiscourseAi::Features.find_feature_by_id(params[:id].to_i))
end
private
def serialize_features(features)
features.map { |feature| feature.merge(persona: serialize_persona(feature[:persona])) }
end
def serialize_feature(feature)
return nil if feature.blank?
feature.merge(persona: serialize_persona(feature[:persona]))
end
def serialize_persona(persona)
return nil if persona.blank?
serialize_data(persona, AiFeaturesPersonaSerializer, root: false)
end
end
end
end
@@ -157,13 +157,9 @@ module DiscourseAi
:provider, :provider,
:tokenizer, :tokenizer,
:max_prompt_tokens, :max_prompt_tokens,
:max_output_tokens,
:api_key, :api_key,
:enabled_chat_bot, :enabled_chat_bot,
:vision_enabled, :vision_enabled,
:input_cost,
:cached_input_cost,
:output_cost,
) )
provider = updating ? updating.provider : permitted[:provider] provider = updating ? updating.provider : permitted[:provider]
@@ -15,7 +15,7 @@ module DiscourseAi
LocalizedAiPersonaSerializer.new(persona, root: false) LocalizedAiPersonaSerializer.new(persona, root: false)
end end
tools = tools =
DiscourseAi::Personas::Persona.all_available_tools.map do |tool| DiscourseAi::AiBot::Personas::Persona.all_available_tools.map do |tool|
AiToolSerializer.new(tool, root: false) AiToolSerializer.new(tool, root: false)
end end
AiTool AiTool
@@ -221,14 +221,6 @@ module DiscourseAi
permitted[:tools] = permit_tools(tools) permitted[:tools] = permit_tools(tools)
end end
if response_format = params.dig(:ai_persona, :response_format)
permitted[:response_format] = permit_response_format(response_format)
end
if examples = params.dig(:ai_persona, :examples)
permitted[:examples] = permit_examples(examples)
end
permitted permitted
end end
@@ -243,24 +235,6 @@ module DiscourseAi
[tool, options, !!force_tool] [tool, options, !!force_tool]
end end
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 end
end end
@@ -55,7 +55,7 @@ module DiscourseAi
# we need an llm so we have a tokenizer # we need an llm so we have a tokenizer
# but will do without if none is available # but will do without if none is available
llm = LlmModel.first&.to_llm llm = LlmModel.first&.to_llm
runner = @ai_tool.runner(parameters, llm: llm, bot_user: current_user) runner = @ai_tool.runner(parameters, llm: llm, bot_user: current_user, context: {})
result = runner.invoke result = runner.invoke
if result.is_a?(Hash) && result[:error] if result.is_a?(Hash) && result[:error]
@@ -42,21 +42,6 @@ module DiscourseAi
<style> <style>
#{artifact.css} #{artifact.css}
</style> </style>
<script>
window._discourse_user_data = {
#{current_user ? "username: #{current_user.username.to_json}" : "username: null"}
};
window.discourseArtifactReady = new Promise(resolve => {
window._resolveArtifactData = resolve;
});
window.addEventListener('message', function(event) {
if (event.data && event.data.type === 'discourse-artifact-data') {
window.discourseArtifactData = event.data.dataset || {};
Object.assign(window.discourseArtifactData, window._discourse_user_data);
window._resolveArtifactData(window.discourseArtifactData);
}
});
</script>
</head> </head>
<body> <body>
#{artifact.html} #{artifact.html}
@@ -89,19 +74,6 @@ module DiscourseAi
</head> </head>
<body> <body>
<iframe sandbox="allow-scripts allow-forms" height="100%" width="100%" srcdoc="#{ERB::Util.html_escape(untrusted_html)}" frameborder="0"></iframe> <iframe sandbox="allow-scripts allow-forms" height="100%" width="100%" srcdoc="#{ERB::Util.html_escape(untrusted_html)}" frameborder="0"></iframe>
<script>
document.querySelector('iframe').addEventListener('load', function() {
try {
const iframeWindow = this.contentWindow;
const message = { type: 'discourse-artifact-data', dataset: {} };
if (window.frameElement && window.frameElement.dataset) {
Object.assign(message.dataset, window.frameElement.dataset);
}
iframeWindow.postMessage(message, '*');
} catch (e) { console.error('Error passing data to artifact:', e); }
});
</script>
</body> </body>
</html> </html>
HTML HTML
@@ -47,9 +47,9 @@ module DiscourseAi
def discover def discover
ai_persona = ai_persona =
AiPersona AiPersona.all_personas.find do |persona|
.all_personas(enabled_only: false) persona.id == SiteSetting.ai_bot_discover_persona.to_i
.find { |persona| persona.id == SiteSetting.ai_bot_discover_persona.to_i } end
if ai_persona.nil? || !current_user.in_any_groups?(ai_persona.allowed_group_ids.to_a) if ai_persona.nil? || !current_user.in_any_groups?(ai_persona.allowed_group_ids.to_a)
raise Discourse::InvalidAccess.new raise Discourse::InvalidAccess.new
@@ -69,40 +69,6 @@ module DiscourseAi
render json: {}, status: 200 render json: {}, status: 200
end end
def discover_continue_convo
raise Discourse::InvalidParameters.new("user_id") if !params[:user_id]
raise Discourse::InvalidParameters.new("query") if !params[:query]
raise Discourse::InvalidParameters.new("context") if !params[:context]
user = User.find(params[:user_id])
bot_user_id = AiPersona.find_by(id: SiteSetting.ai_bot_discover_persona).user_id
bot_username = User.find_by(id: bot_user_id).username
query = params[:query]
context = "[quote]\n#{params[:context]}\n[/quote]"
post =
PostCreator.create!(
user,
title:
I18n.t("discourse_ai.ai_bot.discoveries.continue_conversation.title", query: query),
raw:
I18n.t(
"discourse_ai.ai_bot.discoveries.continue_conversation.raw",
query: query,
context: context,
),
archetype: Archetype.private_message,
target_usernames: bot_username,
skip_validations: true,
)
render json: success_json.merge(topic_id: post.topic_id)
rescue StandardError => e
render json: failed_json.merge(errors: [e.message]), status: 422
end
end end
end end
end end
@@ -1,39 +0,0 @@
# frozen_string_literal: true
module DiscourseAi
module AiBot
class ConversationsController < ::ApplicationController
requires_plugin ::DiscourseAi::PLUGIN_NAME
requires_login
def index
page = params[:page].to_i
per_page = params[:per_page]&.to_i || 40
base_query =
Topic
.private_messages_for_user(current_user)
.where(user: current_user) # Only show PMs where the current user is the author
.joins(
"INNER JOIN topic_custom_fields tcf ON tcf.topic_id = topics.id
AND tcf.name = '#{DiscourseAi::AiBot::TOPIC_AI_BOT_PM_FIELD}'
AND tcf.value = 't'",
)
.distinct
total = base_query.count
pms = base_query.order(last_posted_at: :desc).offset(page * per_page).limit(per_page)
render json: {
conversations: serialize_data(pms, ListableTopicSerializer),
meta: {
total: total,
page: page,
per_page: per_page,
has_more: total > (page + 1) * per_page,
},
}
end
end
end
end
@@ -8,7 +8,7 @@ module DiscourseAi
before_action :require_site_settings! before_action :require_site_settings!
skip_before_action :preload_json, :check_xhr, only: %i[show asset] skip_before_action :preload_json, :check_xhr, only: %i[show asset]
skip_before_action :redirect_to_login_if_required, :verify_authenticity_token, only: %i[asset] skip_before_action :verify_authenticity_token, only: ["asset"]
def create def create
ensure_allowed_create! ensure_allowed_create!
@@ -43,7 +43,7 @@ module DiscourseAi
prompt, prompt,
input, input,
current_user, current_user,
force_default_locale: force_default_locale, force_default_locale,
), ),
status: 200 status: 200
end end
@@ -110,50 +110,26 @@ module DiscourseAi
end end
def stream_suggestion def stream_suggestion
post_id = get_post_param!
text = get_text_param! text = get_text_param!
post = Post.includes(:topic).find_by(id: post_id)
location = params[:location]
raise Discourse::InvalidParameters.new(:location) if !location
prompt = CompletionPrompt.find_by(id: params[:mode]) prompt = CompletionPrompt.find_by(id: params[:mode])
raise Discourse::InvalidParameters.new(:mode) if !prompt || !prompt.enabled? raise Discourse::InvalidParameters.new(:mode) if !prompt || !prompt.enabled?
return suggest_thumbnails(input) if prompt.id == CompletionPrompt::ILLUSTRATE_POST raise Discourse::InvalidParameters.new(:post_id) unless post
if prompt.id == CompletionPrompt::CUSTOM_PROMPT if prompt.id == CompletionPrompt::CUSTOM_PROMPT
raise Discourse::InvalidParameters.new(:custom_prompt) if params[:custom_prompt].blank? raise Discourse::InvalidParameters.new(:custom_prompt) if params[:custom_prompt].blank?
end end
# to stream we must have an appropriate client_id Jobs.enqueue(
# otherwise we may end up streaming the data to the wrong client :stream_post_helper,
raise Discourse::InvalidParameters.new(:client_id) if params[:client_id].blank? post_id: post.id,
user_id: current_user.id,
if location == "composer" text: text,
Jobs.enqueue( prompt: prompt.name,
:stream_composer_helper, custom_prompt: params[:custom_prompt],
user_id: current_user.id, )
text: text,
prompt: prompt.name,
custom_prompt: params[:custom_prompt],
force_default_locale: params[:force_default_locale] || false,
client_id: params[:client_id],
)
else
post_id = get_post_param!
post = Post.includes(:topic).find_by(id: post_id)
raise Discourse::InvalidParameters.new(:post_id) unless post
Jobs.enqueue(
:stream_post_helper,
post_id: post.id,
user_id: current_user.id,
text: text,
prompt: prompt.name,
custom_prompt: params[:custom_prompt],
client_id: params[:client_id],
)
end
render json: { success: true }, status: 200 render json: { success: true }, status: 200
rescue DiscourseAi::Completions::Endpoints::Base::CompletionFailed rescue DiscourseAi::Completions::Endpoints::Base::CompletionFailed
@@ -46,19 +46,11 @@ module DiscourseAi
end end
hijack do hijack do
begin semantic_search
semantic_search .search_for_topics(query, _page = 1, hyde: !skip_hyde)
.search_for_topics(query, _page = 1, hyde: !skip_hyde) .each { |topic_post| grouped_results.add(topic_post) }
.each { |topic_post| grouped_results.add(topic_post) }
render_serialized( render_serialized(grouped_results, GroupedSearchResultSerializer, result: grouped_results)
grouped_results,
GroupedSearchResultSerializer,
result: grouped_results,
)
rescue Discourse::InvalidAccess
render_json_error(I18n.t("invalid_access"), status: 403)
end
end end
end end
@@ -46,6 +46,10 @@ module DiscourseAi
u.name, u.name,
u.uploaded_avatar_id, u.uploaded_avatar_id,
c.id AS category_id, c.id AS category_id,
c.name AS category_name,
c.color AS category_color,
c.slug AS category_slug,
c.description AS category_description,
(CASE (CASE
WHEN (cr.classification::jsonb->'positive')::float > :threshold THEN 'positive' WHEN (cr.classification::jsonb->'positive')::float > :threshold THEN 'positive'
WHEN (cr.classification::jsonb->'negative')::float > :threshold THEN 'negative' WHEN (cr.classification::jsonb->'negative')::float > :threshold THEN 'negative'
+2 -2
View File
@@ -12,11 +12,11 @@ module ::Jobs
return if message.blank? return if message.blank?
personaClass = personaClass =
DiscourseAi::Personas::Persona.find_by(id: args[:persona_id], user: message.user) DiscourseAi::AiBot::Personas::Persona.find_by(id: args[:persona_id], user: message.user)
return if personaClass.blank? return if personaClass.blank?
user = User.find_by(id: personaClass.user_id) user = User.find_by(id: personaClass.user_id)
bot = DiscourseAi::Personas::Bot.as(user, persona: personaClass.new) bot = DiscourseAi::AiBot::Bot.as(user, persona: personaClass.new)
DiscourseAi::AiBot::Playground.new(bot).reply_to_chat_message( DiscourseAi::AiBot::Playground.new(bot).reply_to_chat_message(
message, message,
+4 -4
View File
@@ -10,13 +10,13 @@ module ::Jobs
persona_id = args[:persona_id] persona_id = args[:persona_id]
begin begin
persona = DiscourseAi::Personas::Persona.find_by(user: post.user, id: persona_id) persona = DiscourseAi::AiBot::Personas::Persona.find_by(user: post.user, id: persona_id)
raise DiscourseAi::Personas::Bot::BOT_NOT_FOUND if persona.nil? raise DiscourseAi::AiBot::Bot::BOT_NOT_FOUND if persona.nil?
bot = DiscourseAi::Personas::Bot.as(bot_user, persona: persona.new) bot = DiscourseAi::AiBot::Bot.as(bot_user, persona: persona.new)
DiscourseAi::AiBot::Playground.new(bot).reply_to(post) DiscourseAi::AiBot::Playground.new(bot).reply_to(post)
rescue DiscourseAi::Personas::Bot::BOT_NOT_FOUND rescue DiscourseAi::AiBot::Bot::BOT_NOT_FOUND
Rails.logger.warn( Rails.logger.warn(
"Bot not found for post #{post.id} - perhaps persona was deleted or bot was disabled", "Bot not found for post #{post.id} - perhaps persona was deleted or bot was disabled",
) )
@@ -1,29 +0,0 @@
# frozen_string_literal: true
module Jobs
class StreamComposerHelper < ::Jobs::Base
sidekiq_options retry: false
def execute(args)
return unless args[:prompt]
return unless user = User.find_by(id: args[:user_id])
return unless args[:text]
return unless args[:client_id]
prompt = CompletionPrompt.enabled_by_name(args[:prompt])
if prompt.id == CompletionPrompt::CUSTOM_PROMPT
prompt.custom_instruction = args[:custom_prompt]
end
DiscourseAi::AiHelper::Assistant.new.stream_prompt(
prompt,
args[:text],
user,
"/discourse-ai/ai-helper/stream_composer_suggestion",
force_default_locale: args[:force_default_locale],
client_id: args[:client_id],
)
end
end
end
-2
View File
@@ -7,8 +7,6 @@ module Jobs
def execute(args) def execute(args)
interaction = args[:interaction] interaction = args[:interaction]
return unless SiteSetting.ai_discord_search_enabled
if SiteSetting.ai_discord_search_mode == "persona" if SiteSetting.ai_discord_search_mode == "persona"
DiscourseAi::Discord::Bot::PersonaReplier.new(interaction).handle_interaction! DiscourseAi::Discord::Bot::PersonaReplier.new(interaction).handle_interaction!
else else
+7 -11
View File
@@ -9,9 +9,9 @@ module Jobs
return if (query = args[:query]).blank? return if (query = args[:query]).blank?
ai_persona_klass = ai_persona_klass =
AiPersona AiPersona.all_personas.find do |persona|
.all_personas(enabled_only: false) persona.id == SiteSetting.ai_bot_discover_persona.to_i
.find { |persona| persona.id == SiteSetting.ai_bot_discover_persona.to_i } end
if ai_persona_klass.nil? || !user.in_any_groups?(ai_persona_klass.allowed_group_ids.to_a) if ai_persona_klass.nil? || !user.in_any_groups?(ai_persona_klass.allowed_group_ids.to_a)
return return
@@ -19,7 +19,7 @@ module Jobs
return if (llm_model = LlmModel.find_by(id: ai_persona_klass.default_llm_id)).nil? return if (llm_model = LlmModel.find_by(id: ai_persona_klass.default_llm_id)).nil?
bot = bot =
DiscourseAi::Personas::Bot.as( DiscourseAi::AiBot::Bot.as(
Discourse.system_user, Discourse.system_user,
persona: ai_persona_klass.new, persona: ai_persona_klass.new,
model: llm_model, model: llm_model,
@@ -30,13 +30,9 @@ module Jobs
base = { query: query, model_used: llm_model.display_name } base = { query: query, model_used: llm_model.display_name }
context = bot.reply(
DiscourseAi::Personas::BotContext.new( { conversation_context: [{ type: :user, content: query }], skip_tool_details: true },
messages: [{ type: :user, content: query }], ) do |partial|
skip_tool_details: true,
)
bot.reply(context) do |partial|
streamed_reply << partial streamed_reply << partial
# Throttle updates. # Throttle updates.
+16 -55
View File
@@ -13,7 +13,6 @@ class AiPersona < ActiveRecord::Base
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 :allowed_seeded_model, if: :default_llm_id
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
# we may want to revisit this in the future # we may want to revisit this in the future
@@ -47,18 +46,13 @@ class AiPersona < ActiveRecord::Base
scope :ordered, -> { order("priority DESC, lower(name) ASC") } scope :ordered, -> { order("priority DESC, lower(name) ASC") }
def self.all_personas(enabled_only: true) def self.all_personas
persona_cache[:value] ||= AiPersona persona_cache[:value] ||= AiPersona
.ordered .ordered
.where(enabled: true)
.all .all
.limit(MAX_PERSONAS_PER_SITE) .limit(MAX_PERSONAS_PER_SITE)
.map(&:class_instance) .map(&:class_instance)
if enabled_only
persona_cache[:value].select { |p| p.enabled }
else
persona_cache[:value]
end
end end
def self.persona_users(user: nil) def self.persona_users(user: nil)
@@ -182,7 +176,6 @@ class AiPersona < ActiveRecord::Base
description description
allowed_group_ids allowed_group_ids
tool_details tool_details
enabled
] ]
instance_attributes = {} instance_attributes = {}
@@ -208,14 +201,14 @@ class AiPersona < ActiveRecord::Base
if inner_name.start_with?("custom-") if inner_name.start_with?("custom-")
custom_tool_id = inner_name.split("-", 2).last.to_i custom_tool_id = inner_name.split("-", 2).last.to_i
if AiTool.exists?(id: custom_tool_id, enabled: true) if AiTool.exists?(id: custom_tool_id, enabled: true)
klass = DiscourseAi::Personas::Tools::Custom.class_instance(custom_tool_id) klass = DiscourseAi::AiBot::Tools::Custom.class_instance(custom_tool_id)
end end
else else
inner_name = inner_name.gsub("Tool", "") inner_name = inner_name.gsub("Tool", "")
inner_name = "List#{inner_name}" if %w[Categories Tags].include?(inner_name) inner_name = "List#{inner_name}" if %w[Categories Tags].include?(inner_name)
begin begin
klass = "DiscourseAi::Personas::Tools::#{inner_name}".constantize klass = "DiscourseAi::AiBot::Tools::#{inner_name}".constantize
options[klass] = current_options if current_options options[klass] = current_options if current_options
rescue StandardError rescue StandardError
end end
@@ -225,26 +218,19 @@ class AiPersona < ActiveRecord::Base
klass klass
end end
persona_class = DiscourseAi::Personas::Persona.system_personas_by_id[self.id] persona_class = DiscourseAi::AiBot::Personas::Persona.system_personas_by_id[self.id]
if persona_class if persona_class
return( instance_attributes.each do |key, value|
# we need a new copy so we don't leak information # description/name are localized
# across sites persona_class.define_singleton_method(key) { value } if key != :description && key != :name
Class.new(persona_class) do end
# required for localization persona_class.define_method(:options) { options }
define_singleton_method(:to_s) { persona_class.to_s } return persona_class
instance_attributes.each do |key, value|
# description/name are localized
define_singleton_method(key) { value } if key != :description && key != :name
end
define_method(:options) { options }
end
)
end end
ai_persona_id = self.id ai_persona_id = self.id
Class.new(DiscourseAi::Personas::Persona) do Class.new(DiscourseAi::AiBot::Personas::Persona) do
instance_attributes.each { |key, value| define_singleton_method(key) { value } } instance_attributes.each { |key, value| define_singleton_method(key) { value } }
define_singleton_method(:to_s) do define_singleton_method(:to_s) do
@@ -266,8 +252,6 @@ class AiPersona < ActiveRecord::Base
define_method(:top_p) { @ai_persona&.top_p } define_method(:top_p) { @ai_persona&.top_p }
define_method(:system_prompt) { @ai_persona&.system_prompt || "You are a helpful bot." } define_method(:system_prompt) { @ai_persona&.system_prompt || "You are a helpful bot." }
define_method(:uploads) { @ai_persona&.uploads } define_method(:uploads) { @ai_persona&.uploads }
define_method(:response_format) { @ai_persona&.response_format }
define_method(:examples) { @ai_persona&.examples }
end end
end end
@@ -328,11 +312,9 @@ class AiPersona < ActiveRecord::Base
end end
def system_persona_unchangeable def system_persona_unchangeable
error_msg = I18n.t("discourse_ai.ai_bot.personas.cannot_edit_system_persona")
if top_p_changed? || temperature_changed? || system_prompt_changed? || name_changed? || if top_p_changed? || temperature_changed? || system_prompt_changed? || name_changed? ||
description_changed? description_changed?
errors.add(:base, error_msg) errors.add(:base, I18n.t("discourse_ai.ai_bot.personas.cannot_edit_system_persona"))
elsif tools_changed? elsif tools_changed?
old_tools = tools_change[0] old_tools = tools_change[0]
new_tools = tools_change[1] new_tools = tools_change[1]
@@ -340,17 +322,9 @@ class AiPersona < ActiveRecord::Base
old_tool_names = old_tools.map { |t| t.is_a?(Array) ? t[0] : t }.to_set old_tool_names = old_tools.map { |t| t.is_a?(Array) ? t[0] : t }.to_set
new_tool_names = new_tools.map { |t| t.is_a?(Array) ? t[0] : t }.to_set new_tool_names = new_tools.map { |t| t.is_a?(Array) ? t[0] : t }.to_set
errors.add(:base, error_msg) if old_tool_names != new_tool_names if old_tool_names != new_tool_names
elsif response_format_changed? errors.add(:base, I18n.t("discourse_ai.ai_bot.personas.cannot_edit_system_persona"))
old_format = response_format_change[0].map { |f| f["key"] }.to_set end
new_format = response_format_change[1].map { |f| f["key"] }.to_set
errors.add(:base, error_msg) if old_format != new_format
elsif examples_changed?
old_examples = examples_change[0].flatten.to_set
new_examples = examples_change[1].flatten.to_set
errors.add(:base, error_msg) if old_examples != new_examples
end end
end end
@@ -371,17 +345,6 @@ class AiPersona < ActiveRecord::Base
errors.add(:default_llm, I18n.t("discourse_ai.llm.configuration.invalid_seeded_model")) errors.add(:default_llm, I18n.t("discourse_ai.llm.configuration.invalid_seeded_model"))
end end
def well_formated_examples
return if examples.blank?
if examples.is_a?(Array) &&
examples.all? { |e| e.is_a?(Array) && e.length == 2 && e.all?(&:present?) }
return
end
errors.add(:examples, I18n.t("discourse_ai.personas.malformed_examples"))
end
end end
# == Schema Information # == Schema Information
@@ -419,8 +382,6 @@ end
# rag_llm_model_id :bigint # rag_llm_model_id :bigint
# default_llm_id :bigint # default_llm_id :bigint
# question_consolidator_llm_id :bigint # question_consolidator_llm_id :bigint
# response_format :jsonb
# examples :jsonb
# #
# Indexes # Indexes
# #
+82 -162
View File
@@ -35,8 +35,8 @@ class AiTool < ActiveRecord::Base
tool_name.presence || name tool_name.presence || name
end end
def runner(parameters, llm:, bot_user:, context: nil) def runner(parameters, llm:, bot_user:, context: {})
DiscourseAi::Personas::ToolRunner.new( DiscourseAi::AiBot::ToolRunner.new(
parameters: parameters, parameters: parameters,
llm: llm, llm: llm,
bot_user: bot_user, bot_user: bot_user,
@@ -59,166 +59,86 @@ class AiTool < ActiveRecord::Base
def self.preamble def self.preamble
<<~JS <<~JS
/** /**
* Tool API Quick Reference * Tool API Quick Reference
* *
* Entry Functions * Entry Functions
* *
* invoke(parameters): Main function. Receives parameters defined in the tool's signature (Object). * invoke(parameters): Main function. Receives parameters (Object). Must return a JSON-serializable value.
* Must return a JSON-serializable value (e.g., string, number, object, array). * Example:
* Example: * function invoke(parameters) { return "result"; }
* function invoke(parameters) { return { result: "Data processed", input: parameters.query }; } *
* * details(): Optional. Returns a string describing the tool.
* details(): Optional function. Returns a string (can include basic HTML) describing * Example:
* the tool's action after invocation, often using data from the invocation. * function details() { return "Tool description."; }
* This is displayed in the chat interface. *
* Example: * Provided Objects
* let lastUrl; *
* function invoke(parameters) { * 1. http
* lastUrl = parameters.url; * http.get(url, options?): Performs an HTTP GET request.
* // ... perform action ... * Parameters:
* return { success: true, content: "..." }; * url (string): The request URL.
* } * options (Object, optional):
* function details() { * headers (Object): Request headers.
* return `Browsed: <a href="${lastUrl}">${lastUrl}</a>`; * Returns:
* } * { status: number, body: string }
* *
* Provided Objects & Functions * http.post(url, options?): Performs an HTTP POST request.
* * Parameters:
* 1. http * url (string): The request URL.
* Performs HTTP requests. Max 20 requests per execution. * options (Object, optional):
* * headers (Object): Request headers.
* http.get(url, options?): Performs GET request. * body (string): Request body.
* Parameters: * Returns:
* url (string): The request URL. * { status: number, body: string }
* options (Object, optional): *
* headers (Object): Request headers (e.g., { "Authorization": "Bearer key" }). * (also available: http.put, http.patch, http.delete)
* Returns: { status: number, body: string } *
* * Note: Max 20 HTTP requests per execution.
* http.post(url, options?): Performs POST request. *
* Parameters: * 2. llm
* url (string): The request URL. * llm.truncate(text, length): Truncates text to a specified token length.
* options (Object, optional): * Parameters:
* headers (Object): Request headers. * text (string): Text to truncate.
* body (string | Object): Request body. If an object, it's stringified as JSON. * length (number): Max tokens.
* Returns: { status: number, body: string } * Returns:
* * Truncated string.
* http.put(url, options?): Performs PUT request (similar to POST). *
* http.patch(url, options?): Performs PATCH request (similar to POST). * 3. index
* http.delete(url, options?): Performs DELETE request (similar to GET/POST). * index.search(query, options?): Searches indexed documents.
* * Parameters:
* 2. llm * query (string): Search query.
* Interacts with the Language Model. * options (Object, optional):
* * filenames (Array): Limit search to specific files.
* llm.truncate(text, length): Truncates text to a specified token length based on the configured LLM's tokenizer. * limit (number): Max fragments (up to 200).
* Parameters: * Returns:
* text (string): Text to truncate. * Array of { fragment: string, metadata: string }
* length (number): Maximum number of tokens. *
* Returns: string (truncated text) * 4. upload
* * upload.create(filename, base_64_content): Uploads a file.
* llm.generate(prompt): Generates text using the configured LLM associated with the tool runner. * Parameters:
* Parameters: * filename (string): Name of the file.
* prompt (string | Object): The prompt. Can be a simple string or an object * base_64_content (string): Base64 encoded file content.
* like { messages: [{ type: "system", content: "..." }, { type: "user", content: "..." }] }. * Returns:
* Returns: string (generated text) * { id: number, short_url: string }
* *
* 3. index * 5. chain
* Searches attached RAG (Retrieval-Augmented Generation) documents linked to this tool. * chain.setCustomRaw(raw): Sets the body of the post and exist chain.
* * Parameters:
* index.search(query, options?): Searches indexed document fragments. * raw (string): raw content to add to post.
* Parameters: *
* query (string): The search query used for semantic search. * Constraints
* options (Object, optional): *
* filenames (Array<string>): Filter search to fragments from specific uploaded filenames. * Execution Time: ≤ 2000ms
* limit (number): Maximum number of fragments to return (default: 10, max: 200). * Memory: ≤ 10MB
* Returns: Array<{ fragment: string, metadata: string | null }> - Ordered by relevance. * HTTP Requests: ≤ 20 per execution
* * Exceeding limits will result in errors or termination.
* 4. upload *
* Handles file uploads within Discourse. * Security
* *
* upload.create(filename, base_64_content): Uploads a file created by the tool, making it available in Discourse. * Sandboxed Environment: No access to system or global objects.
* Parameters: * No File System Access: Cannot read or write files.
* filename (string): The desired name for the file (basename is used for security). */
* base_64_content (string): Base64 encoded content of the file.
* Returns: { id: number, url: string, short_url: string } - Details of the created upload record.
*
* 5. chain
* Controls the execution flow.
*
* chain.setCustomRaw(raw): Sets the final raw content of the bot's post and immediately
* stops the tool execution chain. Useful for tools that directly
* generate the full response content (e.g., image generation tools attaching the image markdown).
* Parameters:
* raw (string): The raw Markdown content for the post.
* Returns: void
*
* 6. discourse
* Interacts with Discourse specific features. Access is generally performed as the SystemUser.
*
* discourse.search(params): Performs a Discourse search.
* Parameters:
* params (Object): Search parameters (e.g., { search_query: "keyword", with_private: true, max_results: 10 }).
* `with_private: true` searches across all posts visible to the SystemUser. `result_style: 'detailed'` is used by default.
* Returns: Object (Discourse search results structure, includes posts, topics, users etc.)
*
* discourse.getPost(post_id): Retrieves details for a specific post.
* Parameters:
* post_id (number): The ID of the post.
* Returns: Object (Post details including `raw`, nested `topic` object with ListableTopicSerializer structure) or null if not found/accessible.
*
* discourse.getTopic(topic_id): Retrieves details for a specific topic.
* Parameters:
* topic_id (number): The ID of the topic.
* Returns: Object (Topic details using ListableTopicSerializer structure) or null if not found/accessible.
*
* discourse.getUser(user_id_or_username): Retrieves details for a specific user.
* Parameters:
* user_id_or_username (number | string): The ID or username of the user.
* Returns: Object (User details using UserSerializer structure) or null if not found.
*
* discourse.getPersona(name): Gets an object representing another AI Persona configured on the site.
* Parameters:
* name (string): The name of the target persona.
* Returns: Object { respondTo: function(params) } or null if persona not found.
* respondTo(params): Instructs the target persona to generate a response within the current context (e.g., replying to the same post or chat message).
* Parameters:
* params (Object, optional): { instructions: string, whisper: boolean }
* Returns: { success: boolean, post_id?: number, post_number?: number, message_id?: number } or { error: string }
*
* discourse.createChatMessage(params): Creates a new message in a Discourse Chat channel.
* Parameters:
* params (Object): { channel_name: string, username: string, message: string }
* `channel_name` can be the channel name or slug.
* `username` specifies the user who should appear as the sender. The user must exist.
* The sending user must have permission to post in the channel.
* Returns: { success: boolean, message_id?: number, message?: string, created_at?: string } or { error: string }
*
* 7. context
* An object containing information about the environment where the tool is being run.
* Available properties depend on the invocation context, but may include:
* post_id (number): ID of the post triggering the tool (if in a Post context).
* topic_id (number): ID of the topic (if in a Post context).
* private_message (boolean): Whether the context is a private message (in Post context).
* message_id (number): ID of the chat message triggering the tool (if in Chat context).
* channel_id (number): ID of the chat channel (if in Chat context).
* user (Object): Details of the user invoking the tool/persona (structure may vary, often null or SystemUser details unless explicitly passed).
* participants (string): Comma-separated list of usernames in a PM (if applicable).
* // ... other potential context-specific properties added by the calling environment.
*
* Constraints
*
* Execution Time: ≤ 2000ms (default timeout in milliseconds) - This timer *pauses* during external HTTP requests or LLM calls initiated via `http.*` or `llm.generate`, but applies to the script's own processing time.
* Memory: ≤ 10MB (V8 heap limit)
* Stack Depth: ≤ 20 (Marshal stack depth limit for Ruby interop)
* HTTP Requests: ≤ 20 per execution
* Exceeding limits will result in errors or termination (e.g., timeout error, out-of-memory error, TooManyRequestsError).
*
* Security
*
* Sandboxed Environment: The script runs in a restricted V8 JavaScript environment (via MiniRacer).
* No direct access to browser or environment, browser globals (like `window` or `document`), or the host system's file system.
* Network requests are proxied through the Discourse backend, not made directly from the sandbox.
*/
JS JS
end end
-1
View File
@@ -23,7 +23,6 @@ class EmbeddingDefinition < ActiveRecord::Base
DiscourseAi::Tokenizer::GeminiTokenizer, DiscourseAi::Tokenizer::GeminiTokenizer,
DiscourseAi::Tokenizer::MultilingualE5LargeTokenizer, DiscourseAi::Tokenizer::MultilingualE5LargeTokenizer,
DiscourseAi::Tokenizer::OpenAiTokenizer, DiscourseAi::Tokenizer::OpenAiTokenizer,
DiscourseAi::Tokenizer::MixtralTokenizer,
].map(&:name) ].map(&:name)
end end
-14
View File
@@ -13,14 +13,6 @@ class LlmModel < ActiveRecord::Base
validates :url, presence: true, unless: -> { provider == BEDROCK_PROVIDER_NAME } validates :url, presence: true, unless: -> { provider == BEDROCK_PROVIDER_NAME }
validates_presence_of :name, :api_key validates_presence_of :name, :api_key
validates :max_prompt_tokens, numericality: { greater_than: 0 } validates :max_prompt_tokens, numericality: { greater_than: 0 }
validates :input_cost,
:cached_input_cost,
:output_cost,
:max_output_tokens,
numericality: {
greater_than_or_equal_to: 0,
},
allow_nil: true
validate :required_provider_params validate :required_provider_params
scope :in_use, scope :in_use,
-> do -> do
@@ -63,8 +55,6 @@ class LlmModel < ActiveRecord::Base
}, },
google: { google: {
disable_native_tools: :checkbox, disable_native_tools: :checkbox,
enable_thinking: :checkbox,
thinking_tokens: :number,
}, },
azure: { azure: {
disable_native_tools: :checkbox, disable_native_tools: :checkbox,
@@ -193,8 +183,4 @@ end
# enabled_chat_bot :boolean default(FALSE), not null # enabled_chat_bot :boolean default(FALSE), not null
# provider_params :jsonb # provider_params :jsonb
# vision_enabled :boolean default(FALSE), not null # vision_enabled :boolean default(FALSE), not null
# input_cost :float
# cached_input_cost :float
# output_cost :float
# max_output_tokens :integer
# #
@@ -1,12 +0,0 @@
# frozen_string_literal: true
class AiFeaturesPersonaSerializer < ApplicationSerializer
attributes :id, :name, :system_prompt, :allowed_groups, :enabled
def allowed_groups
Group
.where(id: object.allowed_group_ids)
.pluck(:id, :name)
.map { |id, name| { id: id, name: name } }
end
end

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