Compare commits

..
Author SHA1 Message Date
Joffrey JAFFEUX 91a97d75da FIX: prevents double escaping of filters
We were passing: `{"category": 4}` which was then escape by Ember, basically causing a double Json stringify and preventing code to work down the road.
2025-03-10 21:05:38 +01:00
502 changed files with 7776 additions and 22385 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.4.0.beta4-dev: a53719ab8eb071459f215227421b3ea4987e5f87
< 3.4.0.beta4-dev: 20612fde52d3f740cad64823ef8aadb0748b567f
-3
View File
@@ -5,6 +5,3 @@ node_modules
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
+45 -59
View File
@@ -1,93 +1,79 @@
GEM
remote: https://rubygems.org/
specs:
activesupport (8.0.2)
activesupport (7.1.3.4)
base64
benchmark (>= 0.3)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
concurrent-ruby (~> 1.0, >= 1.0.2)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2)
logger (>= 1.4.2)
minitest (>= 5.1)
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
uri (>= 0.13.1)
mutex_m
tzinfo (~> 2.0)
ast (2.4.2)
base64 (0.2.0)
benchmark (0.4.0)
bigdecimal (3.1.9)
concurrent-ruby (1.3.5)
connection_pool (2.5.0)
bigdecimal (3.1.8)
concurrent-ruby (1.3.3)
connection_pool (2.4.1)
drb (2.2.1)
i18n (1.14.7)
i18n (1.14.5)
concurrent-ruby (~> 1.0)
json (2.10.2)
language_server-protocol (3.17.0.4)
lint_roller (1.1.0)
logger (1.6.6)
minitest (5.25.5)
parallel (1.26.3)
parser (3.3.7.1)
json (2.7.2)
language_server-protocol (3.17.0.3)
minitest (5.24.1)
mutex_m (0.2.0)
parallel (1.25.1)
parser (3.3.3.0)
ast (~> 2.4.1)
racc
prettier_print (1.2.1)
racc (1.8.1)
rack (3.1.14)
racc (1.8.0)
rack (3.1.10)
rainbow (3.1.1)
regexp_parser (2.10.0)
rubocop (1.74.0)
regexp_parser (2.9.2)
rexml (3.3.9)
rubocop (1.64.1)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
language_server-protocol (>= 3.17.0)
parallel (~> 1.10)
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.38.0, < 2.0)
regexp_parser (>= 1.8, < 3.0)
rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.31.1, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.38.1)
unicode-display_width (>= 2.4.0, < 3.0)
rubocop-ast (1.31.3)
parser (>= 3.3.1.0)
rubocop-capybara (2.22.1)
lint_roller (~> 1.1)
rubocop (~> 1.72, >= 1.72.1)
rubocop-discourse (3.12.1)
rubocop-capybara (2.21.0)
rubocop (~> 1.41)
rubocop-discourse (3.8.1)
activesupport (>= 6.1)
lint_roller (>= 1.1.0)
rubocop (>= 1.73.2)
rubocop-capybara (>= 2.22.0)
rubocop-factory_bot (>= 2.27.0)
rubocop-rails (>= 2.30.3)
rubocop (>= 1.59.0)
rubocop-capybara (>= 2.0.0)
rubocop-factory_bot (>= 2.0.0)
rubocop-rails (>= 2.25.0)
rubocop-rspec (>= 3.0.1)
rubocop-rspec_rails (>= 2.31.0)
rubocop-factory_bot (2.27.1)
lint_roller (~> 1.1)
rubocop (~> 1.72, >= 1.72.1)
rubocop-rails (2.30.3)
rubocop-rspec_rails (>= 2.30.0)
rubocop-factory_bot (2.26.1)
rubocop (~> 1.61)
rubocop-rails (2.25.1)
activesupport (>= 4.2.0)
lint_roller (~> 1.1)
rack (>= 1.1)
rubocop (>= 1.72.1, < 2.0)
rubocop-ast (>= 1.38.0, < 2.0)
rubocop-rspec (3.5.0)
lint_roller (~> 1.1)
rubocop (~> 1.72, >= 1.72.1)
rubocop-rspec_rails (2.31.0)
lint_roller (~> 1.1)
rubocop (~> 1.72, >= 1.72.1)
rubocop-rspec (~> 3.5)
rubocop (>= 1.33.0, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0)
rubocop-rspec (3.0.2)
rubocop (~> 1.61)
rubocop-rspec_rails (2.30.0)
rubocop (~> 1.61)
rubocop-rspec (~> 3, >= 3.0.1)
ruby-progressbar (1.13.0)
securerandom (0.4.1)
syntax_tree (6.2.0)
prettier_print (>= 1.2.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode-display_width (3.1.4)
unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4)
uri (1.0.3)
unicode-display_width (2.5.0)
PLATFORMS
ruby
@@ -97,4 +83,4 @@ DEPENDENCIES
syntax_tree
BUNDLED WITH
2.6.6
2.5.10
+1 -2
View File
@@ -1,8 +1,7 @@
{
"tests": {
"requiredPlugins": [
"https://github.com/discourse/discourse-prometheus",
"https://github.com/discourse/discourse-solved.git"
"https://github.com/discourse/discourse-prometheus"
]
}
}
@@ -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 {
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 id = parseInt(params.id, 10);
const record = allLlms.findBy("id", id);
record.provider_params = record.provider_params || {};
return record;
@@ -1,10 +1,6 @@
import DiscourseRoute from "discourse/routes/discourse";
export default class DiscourseAiToolsNewRoute extends DiscourseRoute {
beforeModel(transition) {
this.preset = transition.to.queryParams.presetId || "empty_tool";
}
async model() {
return this.store.createRecord("ai-tool");
}
@@ -12,10 +8,10 @@ export default class DiscourseAiToolsNewRoute extends DiscourseRoute {
setupController(controller) {
super.setupController(...arguments);
const toolsModel = this.modelFor("adminPlugins.show.discourse-ai-tools");
controller.set("allTools", toolsModel);
controller.set("presets", toolsModel.resultSetMeta.presets);
controller.set("llms", toolsModel.resultSetMeta.llms);
controller.set("settings", toolsModel.resultSetMeta.settings);
controller.set("selectedPreset", this.preset);
}
}
@@ -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>
}
);
@@ -5,6 +5,5 @@
@presets={{this.presets}}
@llms={{this.llms}}
@settings={{this.settings}}
@selectedPreset={{this.selectedPreset}}
/>
</section>
@@ -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,
:tokenizer,
:max_prompt_tokens,
:max_output_tokens,
:api_key,
:enabled_chat_bot,
:vision_enabled,
:input_cost,
:cached_input_cost,
:output_cost,
)
provider = updating ? updating.provider : permitted[:provider]
@@ -15,7 +15,7 @@ module DiscourseAi
LocalizedAiPersonaSerializer.new(persona, root: false)
end
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)
end
AiTool
@@ -221,14 +221,6 @@ module DiscourseAi
permitted[:tools] = permit_tools(tools)
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
end
@@ -243,24 +235,6 @@ module DiscourseAi
[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
@@ -55,7 +55,7 @@ module DiscourseAi
# we need an llm so we have a tokenizer
# but will do without if none is available
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
if result.is_a?(Hash) && result[:error]
@@ -42,21 +42,6 @@ module DiscourseAi
<style>
#{artifact.css}
</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>
<body>
#{artifact.html}
@@ -89,19 +74,6 @@ module DiscourseAi
</head>
<body>
<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>
</html>
HTML
@@ -47,9 +47,9 @@ module DiscourseAi
def discover
ai_persona =
AiPersona
.all_personas(enabled_only: false)
.find { |persona| persona.id == SiteSetting.ai_bot_discover_persona.to_i }
AiPersona.all_personas.find do |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)
raise Discourse::InvalidAccess.new
@@ -69,40 +69,6 @@ module DiscourseAi
render json: {}, status: 200
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
@@ -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!
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
ensure_allowed_create!
@@ -43,7 +43,7 @@ module DiscourseAi
prompt,
input,
current_user,
force_default_locale: force_default_locale,
force_default_locale,
),
status: 200
end
@@ -53,12 +53,7 @@ module DiscourseAi
end
def suggest_title
if params[:topic_id]
topic = Topic.find_by(id: params[:topic_id])
input = DiscourseAi::Summarization::Strategies::TopicSummary.new(topic).targets_data
else
input = get_text_param!
end
input = get_text_param!
prompt = CompletionPrompt.enabled_by_name("generate_titles")
raise Discourse::InvalidParameters.new(:mode) if !prompt
@@ -78,26 +73,22 @@ module DiscourseAi
end
def suggest_category
if params[:topic_id]
opts = { topic_id: params[:topic_id] }
else
input = get_text_param!
opts = { text: input }
end
input = get_text_param!
input_hash = { text: input }
render json: DiscourseAi::AiHelper::SemanticCategorizer.new(current_user, opts).categories,
render json:
DiscourseAi::AiHelper::SemanticCategorizer.new(
input_hash,
current_user,
).categories,
status: 200
end
def suggest_tags
if params[:topic_id]
opts = { topic_id: params[:topic_id] }
else
input = get_text_param!
opts = { text: input }
end
input = get_text_param!
input_hash = { text: input }
render json: DiscourseAi::AiHelper::SemanticCategorizer.new(current_user, opts).tags,
render json: DiscourseAi::AiHelper::SemanticCategorizer.new(input_hash, current_user).tags,
status: 200
end
@@ -110,50 +101,26 @@ module DiscourseAi
end
def stream_suggestion
post_id = get_post_param!
text = get_text_param!
location = params[:location]
raise Discourse::InvalidParameters.new(:location) if !location
post = Post.includes(:topic).find_by(id: post_id)
prompt = CompletionPrompt.find_by(id: params[:mode])
raise Discourse::InvalidParameters.new(:mode) if !prompt || !prompt.enabled?
return suggest_thumbnails(input) if prompt.id == CompletionPrompt::ILLUSTRATE_POST
raise Discourse::InvalidParameters.new(:post_id) unless post
if prompt.id == CompletionPrompt::CUSTOM_PROMPT
raise Discourse::InvalidParameters.new(:custom_prompt) if params[:custom_prompt].blank?
end
# to stream we must have an appropriate client_id
# otherwise we may end up streaming the data to the wrong client
raise Discourse::InvalidParameters.new(:client_id) if params[:client_id].blank?
if location == "composer"
Jobs.enqueue(
:stream_composer_helper,
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
Jobs.enqueue(
:stream_post_helper,
post_id: post.id,
user_id: current_user.id,
text: text,
prompt: prompt.name,
custom_prompt: params[:custom_prompt],
)
render json: { success: true }, status: 200
rescue DiscourseAi::Completions::Endpoints::Base::CompletionFailed
@@ -46,19 +46,11 @@ module DiscourseAi
end
hijack do
begin
semantic_search
.search_for_topics(query, _page = 1, hyde: !skip_hyde)
.each { |topic_post| grouped_results.add(topic_post) }
semantic_search
.search_for_topics(query, _page = 1, hyde: !skip_hyde)
.each { |topic_post| grouped_results.add(topic_post) }
render_serialized(
grouped_results,
GroupedSearchResultSerializer,
result: grouped_results,
)
rescue Discourse::InvalidAccess
render_json_error(I18n.t("invalid_access"), status: 403)
end
render_serialized(grouped_results, GroupedSearchResultSerializer, result: grouped_results)
end
end
@@ -46,6 +46,10 @@ module DiscourseAi
u.name,
u.uploaded_avatar_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
WHEN (cr.classification::jsonb->'positive')::float > :threshold THEN 'positive'
WHEN (cr.classification::jsonb->'negative')::float > :threshold THEN 'negative'
+2 -2
View File
@@ -12,11 +12,11 @@ module ::Jobs
return if message.blank?
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?
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(
message,
+4 -4
View File
@@ -10,13 +10,13 @@ module ::Jobs
persona_id = args[:persona_id]
begin
persona = DiscourseAi::Personas::Persona.find_by(user: post.user, id: persona_id)
raise DiscourseAi::Personas::Bot::BOT_NOT_FOUND if persona.nil?
persona = DiscourseAi::AiBot::Personas::Persona.find_by(user: post.user, id: persona_id)
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)
rescue DiscourseAi::Personas::Bot::BOT_NOT_FOUND
rescue DiscourseAi::AiBot::Bot::BOT_NOT_FOUND
Rails.logger.warn(
"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)
interaction = args[:interaction]
return unless SiteSetting.ai_discord_search_enabled
if SiteSetting.ai_discord_search_mode == "persona"
DiscourseAi::Discord::Bot::PersonaReplier.new(interaction).handle_interaction!
else
+7 -11
View File
@@ -9,9 +9,9 @@ module Jobs
return if (query = args[:query]).blank?
ai_persona_klass =
AiPersona
.all_personas(enabled_only: false)
.find { |persona| persona.id == SiteSetting.ai_bot_discover_persona.to_i }
AiPersona.all_personas.find do |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)
return
@@ -19,7 +19,7 @@ module Jobs
return if (llm_model = LlmModel.find_by(id: ai_persona_klass.default_llm_id)).nil?
bot =
DiscourseAi::Personas::Bot.as(
DiscourseAi::AiBot::Bot.as(
Discourse.system_user,
persona: ai_persona_klass.new,
model: llm_model,
@@ -30,13 +30,9 @@ module Jobs
base = { query: query, model_used: llm_model.display_name }
context =
DiscourseAi::Personas::BotContext.new(
messages: [{ type: :user, content: query }],
skip_tool_details: true,
)
bot.reply(context) do |partial|
bot.reply(
{ conversation_context: [{ type: :user, content: query }], skip_tool_details: true },
) do |partial|
streamed_reply << partial
# Throttle updates.
+16 -55
View File
@@ -13,7 +13,6 @@ class AiPersona < ActiveRecord::Base
validate :system_persona_unchangeable, on: :update, if: :system
validate :chat_preconditions
validate :allowed_seeded_model, if: :default_llm_id
validate :well_formated_examples
validates :max_context_posts, numericality: { greater_than: 0 }, allow_nil: true
# leaves some room for growth but sets a maximum to avoid memory issues
# 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") }
def self.all_personas(enabled_only: true)
def self.all_personas
persona_cache[:value] ||= AiPersona
.ordered
.where(enabled: true)
.all
.limit(MAX_PERSONAS_PER_SITE)
.map(&:class_instance)
if enabled_only
persona_cache[:value].select { |p| p.enabled }
else
persona_cache[:value]
end
end
def self.persona_users(user: nil)
@@ -182,7 +176,6 @@ class AiPersona < ActiveRecord::Base
description
allowed_group_ids
tool_details
enabled
]
instance_attributes = {}
@@ -208,14 +201,14 @@ class AiPersona < ActiveRecord::Base
if inner_name.start_with?("custom-")
custom_tool_id = inner_name.split("-", 2).last.to_i
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
else
inner_name = inner_name.gsub("Tool", "")
inner_name = "List#{inner_name}" if %w[Categories Tags].include?(inner_name)
begin
klass = "DiscourseAi::Personas::Tools::#{inner_name}".constantize
klass = "DiscourseAi::AiBot::Tools::#{inner_name}".constantize
options[klass] = current_options if current_options
rescue StandardError
end
@@ -225,26 +218,19 @@ class AiPersona < ActiveRecord::Base
klass
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
return(
# we need a new copy so we don't leak information
# across sites
Class.new(persona_class) do
# required for localization
define_singleton_method(:to_s) { persona_class.to_s }
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
)
instance_attributes.each do |key, value|
# description/name are localized
persona_class.define_singleton_method(key) { value } if key != :description && key != :name
end
persona_class.define_method(:options) { options }
return persona_class
end
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 } }
define_singleton_method(:to_s) do
@@ -266,8 +252,6 @@ class AiPersona < ActiveRecord::Base
define_method(:top_p) { @ai_persona&.top_p }
define_method(:system_prompt) { @ai_persona&.system_prompt || "You are a helpful bot." }
define_method(:uploads) { @ai_persona&.uploads }
define_method(:response_format) { @ai_persona&.response_format }
define_method(:examples) { @ai_persona&.examples }
end
end
@@ -328,11 +312,9 @@ class AiPersona < ActiveRecord::Base
end
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? ||
description_changed?
errors.add(:base, error_msg)
errors.add(:base, I18n.t("discourse_ai.ai_bot.personas.cannot_edit_system_persona"))
elsif tools_changed?
old_tools = tools_change[0]
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
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
elsif response_format_changed?
old_format = response_format_change[0].map { |f| f["key"] }.to_set
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
if old_tool_names != new_tool_names
errors.add(:base, I18n.t("discourse_ai.ai_bot.personas.cannot_edit_system_persona"))
end
end
end
@@ -371,17 +345,6 @@ class AiPersona < ActiveRecord::Base
errors.add(:default_llm, I18n.t("discourse_ai.llm.configuration.invalid_seeded_model"))
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
# == Schema Information
@@ -419,8 +382,6 @@ end
# rag_llm_model_id :bigint
# default_llm_id :bigint
# question_consolidator_llm_id :bigint
# response_format :jsonb
# examples :jsonb
#
# Indexes
#
+82 -162
View File
@@ -35,8 +35,8 @@ class AiTool < ActiveRecord::Base
tool_name.presence || name
end
def runner(parameters, llm:, bot_user:, context: nil)
DiscourseAi::Personas::ToolRunner.new(
def runner(parameters, llm:, bot_user:, context: {})
DiscourseAi::AiBot::ToolRunner.new(
parameters: parameters,
llm: llm,
bot_user: bot_user,
@@ -59,166 +59,86 @@ class AiTool < ActiveRecord::Base
def self.preamble
<<~JS
/**
* Tool API Quick Reference
*
* Entry Functions
*
* invoke(parameters): Main function. Receives parameters defined in the tool's signature (Object).
* Must return a JSON-serializable value (e.g., string, number, object, array).
* Example:
* function invoke(parameters) { return { result: "Data processed", input: parameters.query }; }
*
* details(): Optional function. Returns a string (can include basic HTML) describing
* the tool's action after invocation, often using data from the invocation.
* This is displayed in the chat interface.
* Example:
* let lastUrl;
* function invoke(parameters) {
* lastUrl = parameters.url;
* // ... perform action ...
* return { success: true, content: "..." };
* }
* function details() {
* return `Browsed: <a href="${lastUrl}">${lastUrl}</a>`;
* }
*
* Provided Objects & Functions
*
* 1. http
* Performs HTTP requests. Max 20 requests per execution.
*
* http.get(url, options?): Performs GET request.
* Parameters:
* url (string): The request URL.
* options (Object, optional):
* headers (Object): Request headers (e.g., { "Authorization": "Bearer key" }).
* Returns: { status: number, body: string }
*
* http.post(url, options?): Performs POST request.
* Parameters:
* url (string): The request URL.
* options (Object, optional):
* headers (Object): Request headers.
* body (string | Object): Request body. If an object, it's stringified as JSON.
* Returns: { status: number, body: string }
*
* http.put(url, options?): Performs PUT request (similar to POST).
* http.patch(url, options?): Performs PATCH request (similar to POST).
* http.delete(url, options?): Performs DELETE request (similar to GET/POST).
*
* 2. llm
* Interacts with the Language Model.
*
* llm.truncate(text, length): Truncates text to a specified token length based on the configured LLM's tokenizer.
* Parameters:
* text (string): Text to truncate.
* length (number): Maximum number of tokens.
* Returns: string (truncated text)
*
* llm.generate(prompt): Generates text using the configured LLM associated with the tool runner.
* Parameters:
* prompt (string | Object): The prompt. Can be a simple string or an object
* like { messages: [{ type: "system", content: "..." }, { type: "user", content: "..." }] }.
* Returns: string (generated text)
*
* 3. index
* Searches attached RAG (Retrieval-Augmented Generation) documents linked to this tool.
*
* index.search(query, options?): Searches indexed document fragments.
* Parameters:
* query (string): The search query used for semantic search.
* options (Object, optional):
* filenames (Array<string>): Filter search to fragments from specific uploaded filenames.
* limit (number): Maximum number of fragments to return (default: 10, max: 200).
* Returns: Array<{ fragment: string, metadata: string | null }> - Ordered by relevance.
*
* 4. upload
* Handles file uploads within Discourse.
*
* upload.create(filename, base_64_content): Uploads a file created by the tool, making it available in Discourse.
* Parameters:
* 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.
*/
/**
* Tool API Quick Reference
*
* Entry Functions
*
* invoke(parameters): Main function. Receives parameters (Object). Must return a JSON-serializable value.
* Example:
* function invoke(parameters) { return "result"; }
*
* details(): Optional. Returns a string describing the tool.
* Example:
* function details() { return "Tool description."; }
*
* Provided Objects
*
* 1. http
* http.get(url, options?): Performs an HTTP GET request.
* Parameters:
* url (string): The request URL.
* options (Object, optional):
* headers (Object): Request headers.
* Returns:
* { status: number, body: string }
*
* http.post(url, options?): Performs an HTTP POST request.
* Parameters:
* url (string): The request URL.
* options (Object, optional):
* headers (Object): Request headers.
* body (string): Request body.
* Returns:
* { status: number, body: string }
*
* (also available: http.put, http.patch, http.delete)
*
* Note: Max 20 HTTP requests per execution.
*
* 2. llm
* llm.truncate(text, length): Truncates text to a specified token length.
* Parameters:
* text (string): Text to truncate.
* length (number): Max tokens.
* Returns:
* Truncated string.
*
* 3. index
* index.search(query, options?): Searches indexed documents.
* Parameters:
* query (string): Search query.
* options (Object, optional):
* filenames (Array): Limit search to specific files.
* limit (number): Max fragments (up to 200).
* Returns:
* Array of { fragment: string, metadata: string }
*
* 4. upload
* upload.create(filename, base_64_content): Uploads a file.
* Parameters:
* filename (string): Name of the file.
* base_64_content (string): Base64 encoded file content.
* Returns:
* { id: number, short_url: string }
*
* 5. chain
* chain.setCustomRaw(raw): Sets the body of the post and exist chain.
* Parameters:
* raw (string): raw content to add to post.
*
* Constraints
*
* Execution Time: ≤ 2000ms
* Memory: ≤ 10MB
* HTTP Requests: ≤ 20 per execution
* Exceeding limits will result in errors or termination.
*
* Security
*
* Sandboxed Environment: No access to system or global objects.
* No File System Access: Cannot read or write files.
*/
JS
end
-1
View File
@@ -23,7 +23,6 @@ class EmbeddingDefinition < ActiveRecord::Base
DiscourseAi::Tokenizer::GeminiTokenizer,
DiscourseAi::Tokenizer::MultilingualE5LargeTokenizer,
DiscourseAi::Tokenizer::OpenAiTokenizer,
DiscourseAi::Tokenizer::MixtralTokenizer,
].map(&:name)
end

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