Compare commits

..
Author SHA1 Message Date
Roman Rizzi 5bfe231486 REFACTOR: Move personas into it's own module. 2025-03-27 15:10:53 -03:00
Roman Rizzi a6b08270c0 DEV: GH CI needs discourse-solved (#1220) 2025-03-26 10:12:55 -03:00
Joffrey JAFFEUX 9a752eff29 FIX: do not error when tools are empty (#1218) 2025-03-26 10:42:43 +01:00
Joffrey JAFFEUX 913314b67d FIX: prevents to call Object.keys on null (#1217)
This was causing errors in the forms under specific cases.
2025-03-25 22:35:41 +01:00
Keegan George 9dfae3d472 UX: Convert sentiment analysis overview to horizontal bars (#1216)
The sentiment analysis report page initially showcases sentiments via category/tag by doughnut visualizations. However, this isn't an optimal view for quickly scanning and comparing each result. This PR updates the overview to include a table visualization with horizontal bars to represent sentiment analysis instead of doughnuts. Doughnut visualizations are still maintained however when accessing the sentiment data in the drill down for individual entries.

This approach is an intermediary step, as we will eventually add whole clustering and sizing visualization instead of a table. As such, no relevant tests are added in this PR.
2025-03-25 13:36:52 -07:00
Discourse Translator Bot 76a48786d9 Update translations (#1215) 2025-03-25 17:23:09 +01:00
Natalie Tay f7536181af DEV: Update solved report due to changes in solved plugin (#1212) 2025-03-25 15:15:30 +08:00
SamandNatalie Tay 1dde82eb58 FEATURE: allow specifying tool use none in completion prompt
This PR adds support for disabling further tool calls by setting tool_choice to :none across all supported LLM providers:

- OpenAI: Uses "none" tool_choice parameter
- Anthropic: Uses {type: "none"} and adds a prefill message to prevent confusion
- Gemini: Sets function_calling_config mode to "NONE"
- AWS Bedrock: Doesn't natively support tool disabling, so adds a prefill message

We previously used to disable tool calls by simply removing tool definitions, but this would cause errors with some providers. This implementation uses the supported method appropriate for each provider while providing a fallback for Bedrock.

Co-authored-by: Natalie Tay <[email protected]>

* remove stray puts

* cleaner chain breaker for last tool call (works in thinking)

remove unused code

* improve test

---------

Co-authored-by: Natalie Tay <[email protected]>
2025-03-25 08:06:43 +11:00
Keegan George 50e1bc774a FIX: unable to click doughnut when no filters (#1213)
This PR fixes an issue where you are unable to click to see the sentiment drill-down when there are no current filters applied. This is due to trying to `JSON.parse()` the filters when there are no filters. This fix ensures there are filters first before trying to parse the JSON.
2025-03-24 10:10:22 -07:00
Régis Hanol 2dd7068b46 DEV: add guardian argument to TopicsFilter callback (#1206)
To match the updates in discourse/discourse#31908
2025-03-24 10:33:18 +01:00
Joffrey JAFFEUX 7e4f93724d FIX: bump system prompt limit (#1210)
300 was too low, bumped it to 100_000
2025-03-24 08:47:35 +11:00
Isaac Janzen 95160c80e3 FIX: Remove incorrect full-page-search app event (#1209)
This appEvent is not firing on click, but instead on `resetAiResults`. This action is fired many times during page load.
2025-03-21 14:13:22 -05:00
Roman RizziandJoffrey JAFFEUX 2a8be6e2d7 REFACTOR: Migrate Personas' form to FormKit (#1178)
* REFACTOR: Migrate Personas' form to FormKit

We re-arranged fields into sections so we can better differentiate which options are specific to the AI bot.

* few form-kit improvements

https://github.com/discourse/discourse/pull/31934

---------

Co-authored-by: Joffrey JAFFEUX <[email protected]>
2025-03-21 14:46:33 -03:00
Rafael dos Santos Silva 0e2dd7378f DEV: Support for extra model params in LLM completions (#1208)
This will be useful to start experiment with non-standard features like
structured outputs.
2025-03-21 13:49:23 -03:00
Alan Guo Xiang Tan 01eced74a3 FIX: Ensure that we shutdown thread pool (#1207) 2025-03-21 11:08:36 +08:00
Joffrey JAFFEUX 451f7662b2 FIX: prevents fields in Object to re-render (#1205) 2025-03-20 08:32:37 +01:00
Joffrey JAFFEUX 75a1ade441 DEV: supports for form-kit changes (#1203)
form.Object and form.Collection have been changed. Most importantly formObject doesnt yield name but data. It's not your responsibility to build an array of fields.
2025-03-19 15:01:14 +01:00
Discourse Translator Bot de8624416a Update translations (#1202) 2025-03-18 15:08:26 +01:00
Sam b6483e416d Revert "DEV: Convert tool editor to form kit (#1135)" (#1201)
This reverts commit 107f14456b.

enum was not handled, so reverting for now
2025-03-18 18:07:04 +11:00
Natalie Tay 5bf61ef9e1 DEV: Support multiple tests per eval and followups per test (#1199)
See https://github.com/discourse/discourse-ai-evals/pull/9 for format of prompts
2025-03-18 11:42:05 +08:00
Keegan George 107f14456b DEV: Convert tool editor to form kit (#1135)
* DEV: Make tool presets a dropdown

* DEV: Select tool presets via DMenu instead

* WIP

* WIP: Add parameter types, uploader, script, etc.

* WIP

* updates

* fix lint

* FIX: spec

* fixes
2025-03-17 11:38:25 -03:00
Sam 269f169abf DEV: add API to get topic info from a custom tool (#1197)
Previously we could only get post info
2025-03-17 18:47:23 +11:00
Jarek Radosz 7d7c169afa DEV: Update linting (#1194) 2025-03-17 15:14:53 +11:00
SamandNatalie Tay 9211b211f5 FEATURE: silent triage using ai persona (#1193)
This allows for a new mode in persona triage where nothing is posted on topics.

This allows people to perform all triage actions using tools

Additionally introduces new APIs to create chat messages from tools which can be useful in certain moderation scenarios

Co-authored-by: Natalie Tay <[email protected]>

* remove TODO code

---------

Co-authored-by: Natalie Tay <[email protected]>
2025-03-17 15:14:09 +11:00
Kris 24e6aa52bb UX: try AI search to side on large screens (#1196) 2025-03-14 14:11:02 -04:00
Keegan George 48fff48aa2 FIX: failing image caption spec (#1195) 2025-03-14 10:19:04 -07:00
Kris bd40076dc1 FIX: need to create model when testing new embedding (#1192) 2025-03-14 13:11:44 -04:00
Natalie Tay 3533cd1acc DEV: Allow prompt-type evals to take in several prompts and messages (#1190)
* DEV: Allow prompt-type evals to take in several prompts and messages

* ❄️
2025-03-14 15:46:22 +11:00
Kris 51ca942d7d REFACTOR: update embeddings to formkit (#1188) 2025-03-13 11:27:38 -04:00
Jarek Radosz ec8018333e DEV: Update linting (#1191) 2025-03-13 13:25:38 +00:00
Keegan George 6aaf8a0619 DEV: Use existing topic embeddings when suggesting tags/categories on edit (#1189)
When editing a topic (instead of creating one) and using the
tag/category suggestion buttons. We want to use existing topic
embeddings instead of creating new ones.
2025-03-12 18:52:07 -07:00
Penar Musaraj ac29d3080f DEV: Fix SCSS linting issue (#1187) 2025-03-12 13:25:17 -04:00
Sam 65503a5038 FIX: various issues with llm and triage management (#1186)
- Fix search API to only include column_names when present to make the API less confusing
- Ensure correct LLM is used in PMs by tracking and preferring the last bot user
- Fix persona_id conversion from string to integer in custom fields
- Add missing test for PM triage with no replies - ensure we don't try to auto title topic 
- Ensure bot users are properly added to PMs
- Make title setting optional when replying to posts
- Add ability to control stream_reply behavior

These changes improve reliability and fix edge cases in bot interactions,
particularly in private messages with multiple LLMs and while triaging posts using personas
2025-03-12 18:32:02 +11:00
Keegan George b17c688162 DEV: Improve title suggester suggestions when editing topic (#1182)
This update ensures topic title suggestions when suggesting from edit topic take into account the whole topic for more accurate title suggestions.
2025-03-11 11:16:06 -07:00
Discourse Translator Bot e59184b65c Update translations (#1185) 2025-03-11 17:47:51 +01:00
Sam 8f4cd2fcbd FEATURE: allow disabling of top_p and temp for thinking models (#1184)
thinking models such as Claude 3.7 Thinking and o1 / o3 do not
support top_p or temp.

Previously you would have to carefully remove it from everywhere
by having it be a provider param we now support blanker removing
without forcing people to update automation rules or personas
2025-03-11 16:54:02 +11:00
Guhyoun Nam f4708d4178 DEV: Ai summary utilizing name instead of username (#1180)
* DEV: Ai summary utilizing name instead of username

* utilize username if name not available

* test added

* test added 2
2025-03-11 14:29:13 +11:00
dependabot[bot] 833f914e34 Build(deps-dev): Bump rack from 3.1.10 to 3.1.12 (#1181)
Bumps [rack](https://github.com/rack/rack) from 3.1.10 to 3.1.12.
- [Release notes](https://github.com/rack/rack/releases)
- [Changelog](https://github.com/rack/rack/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rack/rack/compare/v3.1.10...v3.1.12)

---
updated-dependencies:
- dependency-name: rack
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-11 14:27:34 +11:00
Sam 168d9d8eb9 FIX: AI triage support and refactor search functionality (#1175)
* FIX: do not add bot user to PM when using responders

* Allow AI tool to call search directly

* remove stray p
2025-03-11 14:26:07 +11:00
Krzysztof Kotlarek 3da4f5eac3 DEV: fix Zeitwerk issue with SpamScanner class (#1183)
When spam scanner is enabled and code is reloaded, developer experience this error:

```
NameError at /posts
===================

uninitialized constant DiscourseAi::AiModeration::EntryPoint::SpamScanner

> To access an interactive console with this error, point your browser to: /__better_errors

plugins/discourse-ai/lib/ai_moderation/entry_point.rb, line 7
```

It is because when we call `SpamScanner` it is searched within parent `DiscourseAi::AiModeration::EntryPoint` namespace.

We can help a bit Zeitwerk by calling SpamScanner more explicitly.
2025-03-11 13:24:27 +11:00
Keegan George bb32d0d737 FEATURE: Add ability to disable search discoveries (#1177)
This update adds the ability to disable search discoveries. This can be done through a tooltip when search discoveries are shown. It can also be done in the AI user preferences, which has also been updated to accommodate more than just the one image caption setting.
2025-03-10 14:17:58 -07:00
Joffrey JAFFEUXandKeegan George 339251a371 FIX: prevents double escaping of filters (#1179)
---------

Co-authored-by: Keegan George <[email protected]>
2025-03-10 14:17:24 -07:00
263 changed files with 6048 additions and 3980 deletions
+1
View File
@@ -1,3 +1,4 @@
< 3.5.0.beta2-dev: de8624416a15b3d8e7ad350b083cc1420451ccec
< 3.5.0.beta1-dev: bdef136080074a993e7c4f5ca562edc31a8ba756
< 3.4.0.beta4-dev: a53719ab8eb071459f215227421b3ea4987e5f87
< 3.4.0.beta4-dev: 20612fde52d3f740cad64823ef8aadb0748b567f
+59 -45
View File
@@ -1,79 +1,93 @@
GEM
remote: https://rubygems.org/
specs:
activesupport (7.1.3.4)
activesupport (8.0.2)
base64
benchmark (>= 0.3)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
concurrent-ruby (~> 1.0, >= 1.3.1)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2)
logger (>= 1.4.2)
minitest (>= 5.1)
mutex_m
tzinfo (~> 2.0)
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
uri (>= 0.13.1)
ast (2.4.2)
base64 (0.2.0)
bigdecimal (3.1.8)
concurrent-ruby (1.3.3)
connection_pool (2.4.1)
benchmark (0.4.0)
bigdecimal (3.1.9)
concurrent-ruby (1.3.5)
connection_pool (2.5.0)
drb (2.2.1)
i18n (1.14.5)
i18n (1.14.7)
concurrent-ruby (~> 1.0)
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)
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)
ast (~> 2.4.1)
racc
prettier_print (1.2.1)
racc (1.8.0)
rack (3.1.10)
racc (1.8.1)
rack (3.1.12)
rainbow (3.1.1)
regexp_parser (2.9.2)
rexml (3.3.9)
rubocop (1.64.1)
regexp_parser (2.10.0)
rubocop (1.74.0)
json (~> 2.3)
language_server-protocol (>= 3.17.0)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
parallel (~> 1.10)
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0)
rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.31.1, < 2.0)
regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.38.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 3.0)
rubocop-ast (1.31.3)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.38.1)
parser (>= 3.3.1.0)
rubocop-capybara (2.21.0)
rubocop (~> 1.41)
rubocop-discourse (3.8.1)
rubocop-capybara (2.22.1)
lint_roller (~> 1.1)
rubocop (~> 1.72, >= 1.72.1)
rubocop-discourse (3.12.1)
activesupport (>= 6.1)
rubocop (>= 1.59.0)
rubocop-capybara (>= 2.0.0)
rubocop-factory_bot (>= 2.0.0)
rubocop-rails (>= 2.25.0)
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-rspec (>= 3.0.1)
rubocop-rspec_rails (>= 2.30.0)
rubocop-factory_bot (2.26.1)
rubocop (~> 1.61)
rubocop-rails (2.25.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)
activesupport (>= 4.2.0)
lint_roller (~> 1.1)
rack (>= 1.1)
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)
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)
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 (2.5.0)
unicode-display_width (3.1.4)
unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4)
uri (1.0.3)
PLATFORMS
ruby
@@ -83,4 +97,4 @@ DEPENDENCIES
syntax_tree
BUNDLED WITH
2.5.10
2.6.6
+2 -1
View File
@@ -1,7 +1,8 @@
{
"tests": {
"requiredPlugins": [
"https://github.com/discourse/discourse-prometheus"
"https://github.com/discourse/discourse-prometheus",
"https://github.com/discourse/discourse-solved.git"
]
}
}
@@ -15,7 +15,7 @@ module DiscourseAi
LocalizedAiPersonaSerializer.new(persona, root: false)
end
tools =
DiscourseAi::AiBot::Personas::Persona.all_available_tools.map do |tool|
DiscourseAi::Personas::Persona.all_available_tools.map do |tool|
AiToolSerializer.new(tool, root: false)
end
AiTool
@@ -53,7 +53,12 @@ module DiscourseAi
end
def suggest_title
input = get_text_param!
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
prompt = CompletionPrompt.enabled_by_name("generate_titles")
raise Discourse::InvalidParameters.new(:mode) if !prompt
@@ -73,22 +78,26 @@ module DiscourseAi
end
def suggest_category
input = get_text_param!
input_hash = { text: input }
if params[:topic_id]
opts = { topic_id: params[:topic_id] }
else
input = get_text_param!
opts = { text: input }
end
render json:
DiscourseAi::AiHelper::SemanticCategorizer.new(
input_hash,
current_user,
).categories,
render json: DiscourseAi::AiHelper::SemanticCategorizer.new(current_user, opts).categories,
status: 200
end
def suggest_tags
input = get_text_param!
input_hash = { text: input }
if params[:topic_id]
opts = { topic_id: params[:topic_id] }
else
input = get_text_param!
opts = { text: input }
end
render json: DiscourseAi::AiHelper::SemanticCategorizer.new(input_hash, current_user).tags,
render json: DiscourseAi::AiHelper::SemanticCategorizer.new(current_user, opts).tags,
status: 200
end
+2 -2
View File
@@ -12,11 +12,11 @@ module ::Jobs
return if message.blank?
personaClass =
DiscourseAi::AiBot::Personas::Persona.find_by(id: args[:persona_id], user: message.user)
DiscourseAi::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::AiBot::Bot.as(user, persona: personaClass.new)
bot = DiscourseAi::Personas::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::AiBot::Personas::Persona.find_by(user: post.user, id: persona_id)
raise DiscourseAi::AiBot::Bot::BOT_NOT_FOUND if persona.nil?
persona = DiscourseAi::Personas::Persona.find_by(user: post.user, id: persona_id)
raise DiscourseAi::Personas::Bot::BOT_NOT_FOUND if persona.nil?
bot = DiscourseAi::AiBot::Bot.as(bot_user, persona: persona.new)
bot = DiscourseAi::Personas::Bot.as(bot_user, persona: persona.new)
DiscourseAi::AiBot::Playground.new(bot).reply_to(post)
rescue DiscourseAi::AiBot::Bot::BOT_NOT_FOUND
rescue DiscourseAi::Personas::Bot::BOT_NOT_FOUND
Rails.logger.warn(
"Bot not found for post #{post.id} - perhaps persona was deleted or bot was disabled",
)
+1 -1
View File
@@ -19,7 +19,7 @@ module Jobs
return if (llm_model = LlmModel.find_by(id: ai_persona_klass.default_llm_id)).nil?
bot =
DiscourseAi::AiBot::Bot.as(
DiscourseAi::Personas::Bot.as(
Discourse.system_user,
persona: ai_persona_klass.new,
model: llm_model,
+4 -4
View File
@@ -201,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::AiBot::Tools::Custom.class_instance(custom_tool_id)
klass = DiscourseAi::Personas::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::AiBot::Tools::#{inner_name}".constantize
klass = "DiscourseAi::Personas::Tools::#{inner_name}".constantize
options[klass] = current_options if current_options
rescue StandardError
end
@@ -218,7 +218,7 @@ class AiPersona < ActiveRecord::Base
klass
end
persona_class = DiscourseAi::AiBot::Personas::Persona.system_personas_by_id[self.id]
persona_class = DiscourseAi::Personas::Persona.system_personas_by_id[self.id]
if persona_class
instance_attributes.each do |key, value|
# description/name are localized
@@ -230,7 +230,7 @@ class AiPersona < ActiveRecord::Base
ai_persona_id = self.id
Class.new(DiscourseAi::AiBot::Personas::Persona) do
Class.new(DiscourseAi::Personas::Persona) do
instance_attributes.each { |key, value| define_singleton_method(key) { value } }
define_singleton_method(:to_s) do
+8
View File
@@ -26,17 +26,23 @@ class LlmModel < ActiveRecord::Base
access_key_id: :text,
region: :text,
disable_native_tools: :checkbox,
disable_temperature: :checkbox,
disable_top_p: :checkbox,
enable_reasoning: :checkbox,
reasoning_tokens: :number,
},
anthropic: {
disable_native_tools: :checkbox,
disable_temperature: :checkbox,
disable_top_p: :checkbox,
enable_reasoning: :checkbox,
reasoning_tokens: :number,
},
open_ai: {
organization: :text,
disable_native_tools: :checkbox,
disable_temperature: :checkbox,
disable_top_p: :checkbox,
disable_streaming: :checkbox,
reasoning_effort: {
type: :enum,
@@ -69,6 +75,8 @@ class LlmModel < ActiveRecord::Base
provider_order: :text,
provider_quantizations: :text,
disable_streaming: :checkbox,
disable_temperature: :checkbox,
disable_top_p: :checkbox,
},
}
end
@@ -1,4 +1,3 @@
import { tracked } from "@glimmer/tracking";
import { ajax } from "discourse/lib/ajax";
import RestModel from "discourse/models/rest";
@@ -63,40 +62,7 @@ const SYSTEM_ATTRIBUTES = [
"allow_chat_direct_messages",
];
class ToolOption {
@tracked value = null;
}
export default class AiPersona extends RestModel {
// this code is here to convert the wire schema to easier to work with object
// on the wire we pass in/out tools as an Array.
// [[ToolName, {option1: value, option2: value}, force], ToolName2, ToolName3]
// So we rework this into a "tools" property and nested toolOptions
init(properties) {
this.forcedTools = [];
if (properties.tools) {
properties.tools = properties.tools.map((tool) => {
if (typeof tool === "string") {
return tool;
} else {
let [toolId, options, force] = tool;
for (let optionId in options) {
if (!options.hasOwnProperty(optionId)) {
continue;
}
this.getToolOption(toolId, optionId).value = options[optionId];
}
if (force) {
this.forcedTools.push(toolId);
}
return toolId;
}
});
}
super.init(properties);
this.tools = properties.tools;
}
async createUser() {
const result = await ajax(
`/admin/plugins/discourse-ai/ai-personas/${this.id}/create-user.json`,
@@ -109,63 +75,79 @@ export default class AiPersona extends RestModel {
return this.user;
}
getToolOption(toolId, optionId) {
this.toolOptions ||= {};
this.toolOptions[toolId] ||= {};
return (this.toolOptions[toolId][optionId] ||= new ToolOption());
flattenedToolStructure(data) {
return (data.tools || []).map((tName) => {
return [tName, data.toolOptions[tName], data.forcedTools.includes(tName)];
});
}
populateToolOptions(attrs) {
if (!attrs.tools) {
return;
}
let toolsWithOptions = [];
attrs.tools.forEach((toolId) => {
if (typeof toolId !== "string") {
toolId = toolId[0];
}
// this code is here to convert the wire schema to easier to work with object
// on the wire we pass in/out tools as an Array.
// [[ToolName, {option1: value, option2: value}, force], ToolName2, ToolName3]
// We split it into tools, options and a list of forced ones.
populateTools(attrs) {
const forcedTools = [];
const toolOptions = {};
let force = this.forcedTools.includes(toolId);
if (this.toolOptions && this.toolOptions[toolId]) {
let options = this.toolOptions[toolId];
let optionsWithValues = {};
for (let optionId in options) {
const flatTools = attrs.tools?.map((tool) => {
if (typeof tool === "string") {
return tool;
} else {
let [toolId, options, force] = tool;
const mappedOptions = {};
for (const optionId in options) {
if (!options.hasOwnProperty(optionId)) {
continue;
}
let option = options[optionId];
optionsWithValues[optionId] = option.value;
mappedOptions[optionId] = options[optionId];
}
toolsWithOptions.push([toolId, optionsWithValues, force]);
} else {
toolsWithOptions.push([toolId, {}, force]);
if (Object.keys(mappedOptions).length > 0) {
toolOptions[toolId] = mappedOptions;
}
if (force) {
forcedTools.push(toolId);
}
return toolId;
}
});
attrs.tools = toolsWithOptions;
attrs.tools = flatTools;
attrs.forcedTools = forcedTools;
attrs.toolOptions = toolOptions;
}
updateProperties() {
let attrs = this.system
const attrs = this.system
? this.getProperties(SYSTEM_ATTRIBUTES)
: this.getProperties(CREATE_ATTRIBUTES);
attrs.id = this.id;
this.populateToolOptions(attrs);
return attrs;
}
createProperties() {
let attrs = this.getProperties(CREATE_ATTRIBUTES);
this.populateToolOptions(attrs);
return attrs;
return this.getProperties(CREATE_ATTRIBUTES);
}
workingCopy() {
let attrs = this.getProperties(CREATE_ATTRIBUTES);
this.populateToolOptions(attrs);
fromPOJO(data) {
const dataClone = JSON.parse(JSON.stringify(data));
const persona = AiPersona.create(dataClone);
persona.tools = this.flattenedToolStructure(dataClone);
const persona = AiPersona.create(attrs);
persona.forcedTools = (this.forcedTools || []).slice();
persona.forced_tool_count = this.forced_tool_count || -1;
return persona;
}
toPOJO() {
const attrs = this.getProperties(CREATE_ATTRIBUTES);
this.populateTools(attrs);
attrs.forced_tool_count = this.forced_tool_count || -1;
return attrs;
}
}
@@ -23,6 +23,7 @@ import closeOnClickOutside from "discourse/modifiers/close-on-click-outside";
import { i18n } from "discourse-i18n";
import DTooltip from "float-kit/components/d-tooltip";
import DoughnutChart from "discourse/plugins/discourse-ai/discourse/components/doughnut-chart";
import AiSentimentHorizontalBar from "../components/ai-sentiment-horizontal-bar";
export default class AdminReportSentimentAnalysis extends Component {
@service router;
@@ -82,6 +83,15 @@ export default class AdminReportSentimentAnalysis extends Component {
}
}
get groupingType() {
const dataSample = this.args.model.data[0];
const localePrefix =
"discourse_ai.sentiments.sentiment_analysis.group_types";
return dataSample.category_name
? i18n(`${localePrefix}.category`)
: i18n(`${localePrefix}.tag`);
}
get colors() {
return ["#2ecc71", "#95a5a6", "#e74c3c"];
}
@@ -107,7 +117,17 @@ export default class AdminReportSentimentAnalysis extends Component {
this.calculateNeutralScore(data),
data.negative_count,
],
score_map: {
positive: data.positive_count,
neutral: this.calculateNeutralScore(data),
negative: data.negative_count,
},
total_score: data.total_count,
widths: {
positive: (data.positive_count / data.total_count) * 100,
neutral: (this.calculateNeutralScore(data) / data.total_count) * 100,
negative: (data.negative_count / data.total_count) * 100,
},
};
});
}
@@ -216,10 +236,15 @@ export default class AdminReportSentimentAnalysis extends Component {
}
const currentQueryParams = this.router.currentRoute.queryParams;
const currentFilters = currentQueryParams?.filters
? JSON.parse(currentQueryParams.filters)
: {};
this.router.transitionTo(this.router.currentRoute.name, {
queryParams: {
...currentQueryParams,
filters: JSON.parse(currentQueryParams.filters), // avoids a double escaping
filters: currentFilters, // avoids a double escaping
selectedChart: data.title,
},
});
@@ -265,9 +290,13 @@ export default class AdminReportSentimentAnalysis extends Component {
this.posts = [];
const currentQueryParams = this.router.currentRoute.queryParams;
const currentFilters = currentQueryParams?.filters
? JSON.parse(currentQueryParams.filters)
: {};
this.router.transitionTo(this.router.currentRoute.name, {
queryParams: {
...currentQueryParams,
filters: currentFilters, // avoids a double escaping
selectedChart: null,
},
});
@@ -293,29 +322,56 @@ export default class AdminReportSentimentAnalysis extends Component {
{{#unless this.showingSelectedChart}}
<div class="admin-report-sentiment-analysis">
{{#each this.transformedData as |data|}}
<div
class="admin-report-sentiment-analysis__chart-wrapper"
role="button"
{{on "click" (fn this.showDetails data)}}
{{closeOnClickOutside
(fn (mut this.selectedChart) null)
(hash
targetSelector=".admin-report-sentiment-analysis-details"
secondaryTargetSelector=".admin-report-sentiment-analysis"
)
}}
>
<DoughnutChart
@labels={{@model.labels}}
@colors={{this.colors}}
@data={{data.scores}}
@totalScore={{data.total_score}}
@doughnutTitle={{data.title}}
@displayLegend={{true}}
/>
</div>
{{/each}}
<table class="sentiment-analysis-table md-table">
<thead>
<th>{{this.groupingType}}</th>
<th>{{i18n
"discourse_ai.sentiments.sentiment_analysis.table.total_count"
}}</th>
<th>{{i18n
"discourse_ai.sentiments.sentiment_analysis.table.sentiment"
}}</th>
</thead>
<tbody>
{{#each this.transformedData as |data|}}
<tr
class="sentiment-analysis-table__row"
role="button"
{{on "click" (fn this.showDetails data)}}
{{closeOnClickOutside
(fn (mut this.selectedChart) null)
(hash
targetSelector=".admin-report-sentiment-analysis-details"
secondaryTargetSelector=".admin-report-sentiment-analysis"
)
}}
>
<td class="sentiment-analysis-table__title">{{data.title}}</td>
<td
class="sentiment-analysis-table__total-score"
>{{data.total_score}}</td>
<td class="sentiment-horizontal-bar">
<AiSentimentHorizontalBar
@type="positive"
@score={{data.score_map.positive}}
@width={{data.widths.positive}}
/>
<AiSentimentHorizontalBar
@type="negative"
@score={{data.score_map.negative}}
@width={{data.widths.negative}}
/>
<AiSentimentHorizontalBar
@type="neutral"
@score={{data.score_map.neutral}}
@width={{data.widths.neutral}}
/>
</td>
</tr>
{{/each}}
</tbody>
</table>
</div>
{{/unless}}
@@ -13,6 +13,7 @@ import getURL from "discourse/lib/get-url";
export default class AiArtifactComponent extends Component {
@service siteSettings;
@tracked expanded = false;
@tracked showingArtifact = false;
@@ -15,6 +15,7 @@ export default class AiComposerHelperMenu extends Component {
@service siteSettings;
@service currentUser;
@service site;
@tracked newSelectedText;
@tracked diff;
@tracked customPromptValue = "";
File diff suppressed because it is too large Load Diff
@@ -1,29 +0,0 @@
import { computed } from "@ember/object";
import { i18n } from "discourse-i18n";
import ComboBox from "select-kit/components/combo-box";
export default ComboBox.extend({
content: computed(function () {
const content = [
{
id: -1,
name: i18n("discourse_ai.ai_persona.tool_strategies.all"),
},
];
[1, 2, 5].forEach((i) => {
content.push({
id: i,
name: i18n("discourse_ai.ai_persona.tool_strategies.replies", {
count: i,
}),
});
});
return content;
}),
selectKitOptions: {
filterable: false,
},
});
@@ -172,9 +172,6 @@ export default class AiFullPageSearch extends Component {
this.AiResults = [];
this.showingAiResults = false;
this.args.addSearchResults([], "topic_id");
this.appEvents.trigger(AI_RESULTS_TOGGLED, {
enabled: false,
});
}
performHyDESearch() {
@@ -250,6 +250,11 @@ export default class AiLlmEditorForm extends Component {
});
}
@action
providerParamsKeys(providerParams) {
return providerParams ? Object.keys(providerParams) : [];
}
<template>
<Form
@onSubmit={{this.save}}
@@ -334,32 +339,36 @@ export default class AiLlmEditorForm extends Component {
<field.Password />
</form.Field>
<form.Object @name="provider_params" as |object name|>
{{#let
(get (this.metaProviderParams data.provider) name)
as |params|
}}
<object.Field
@name={{name}}
@title={{i18n (concat "discourse_ai.llms.provider_fields." name)}}
@format="large"
as |field|
>
{{#if (eq params.type "enum")}}
<field.Select @includeNone={{false}} as |select|>
{{#each params.values as |option|}}
<select.Option
@value={{option.id}}
>{{option.name}}</select.Option>
{{/each}}
</field.Select>
{{else if (eq params.type "checkbox")}}
<field.Checkbox />
{{else}}
<field.Input @type={{params.type}} />
{{/if}}
</object.Field>
{{/let}}
<form.Object @name="provider_params" as |object providerParamsData|>
{{#each (this.providerParamsKeys providerParamsData) as |name|}}
{{#let
(get (this.metaProviderParams data.provider) name)
as |params|
}}
<object.Field
@name={{name}}
@title={{i18n
(concat "discourse_ai.llms.provider_fields." name)
}}
@format="large"
as |field|
>
{{#if (eq params.type "enum")}}
<field.Select @includeNone={{false}} as |select|>
{{#each params.values as |option|}}
<select.Option
@value={{option.id}}
>{{option.name}}</select.Option>
{{/each}}
</field.Select>
{{else if (eq params.type "checkbox")}}
<field.Checkbox />
{{else}}
<field.Input @type={{params.type}} />
{{/if}}
</object.Field>
{{/let}}
{{/each}}
</form.Object>
<form.Field
@@ -555,32 +564,24 @@ export default class AiLlmEditorForm extends Component {
{{/unless}}
{{#if this.displayTestResult}}
<form.Field
@showTitle={{false}}
@name="test_results"
@title="test_results"
@format="full"
as |field|
>
<field.Custom>
<ConditionalLoadingSpinner
@size="small"
@condition={{this.testRunning}}
>
{{#if this.testResult}}
<div class="ai-llm-editor-tests__success">
{{icon "check"}}
{{i18n "discourse_ai.llms.tests.success"}}
</div>
{{else}}
<div class="ai-llm-editor-tests__failure">
{{icon "xmark"}}
{{this.testErrorMessage}}
</div>
{{/if}}
</ConditionalLoadingSpinner>
</field.Custom>
</form.Field>
<form.Container @format="full">
<ConditionalLoadingSpinner
@size="small"
@condition={{this.testRunning}}
>
{{#if this.testResult}}
<div class="ai-llm-editor-tests__success">
{{icon "check"}}
{{i18n "discourse_ai.llms.tests.success"}}
</div>
{{else}}
<div class="ai-llm-editor-tests__failure">
{{icon "xmark"}}
{{this.testErrorMessage}}
</div>
{{/if}}
</ConditionalLoadingSpinner>
</form.Container>
{{/if}}
</Form>
</template>
@@ -0,0 +1,17 @@
import { hash } from "@ember/helper";
import ComboBox from "select-kit/components/combo-box";
const AiLlmSelector = <template>
<ComboBox
@value={{@value}}
@content={{@llms}}
@onChange={{@onChange}}
@options={{hash
filterable=true
none="discourse_ai.ai_persona.no_llm_selected"
}}
class={{@class}}
/>
</template>;
export default AiLlmSelector;
@@ -1,27 +0,0 @@
import { computed } from "@ember/object";
import { observes } from "@ember-decorators/object";
import { i18n } from "discourse-i18n";
import ComboBox from "select-kit/components/combo-box";
import { selectKitOptions } from "select-kit/components/select-kit";
@selectKitOptions({
filterable: true,
})
export default class AiLlmSelector extends ComboBox {
@observes("attrs.disabled")
_modelDisabledChanged() {
this.selectKit.options.set("disabled", this.get("attrs.disabled.value"));
}
@computed
get content() {
const blankName =
this.attrs.blankName || i18n("discourse_ai.ai_persona.no_llm_selected");
return [
{
id: "blank",
name: blankName,
},
].concat(this.llms);
}
}
File diff suppressed because it is too large Load Diff
@@ -1,91 +0,0 @@
import Component from "@glimmer/component";
import { Input } from "@ember/component";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { eq } from "truth-helpers";
import { i18n } from "discourse-i18n";
import AiLlmSelector from "./ai-llm-selector";
export default class AiPersonaToolOptionEditor extends Component {
get isBoolean() {
return this.args.option.type === "boolean";
}
get isEnum() {
return this.args.option.type === "enum";
}
get isLlm() {
return this.args.option.type === "llm";
}
get selectedValue() {
return this.args.option.value.value === "true";
}
get selectedLlm() {
if (this.args.option.value.value) {
return `custom:${this.args.option.value.value}`;
} else {
return "blank";
}
}
set selectedLlm(value) {
if (value === "blank") {
this.args.option.value.value = null;
} else {
this.args.option.value.value = value.replace("custom:", "");
}
}
@action
onCheckboxChange(event) {
this.args.option.value.value = event.target.checked ? "true" : "false";
}
@action
onSelectOption(event) {
this.args.option.value.value = event.target.value;
}
<template>
<div class="control-group ai-persona-tool-option-editor">
<label>
{{@option.name}}
</label>
<div class="">
{{#if this.isEnum}}
<select name="input" {{on "change" this.onSelectOption}}>
{{#each @option.values as |value|}}
<option value={{value}} selected={{eq value @option.value.value}}>
{{value}}
</option>
{{/each}}
</select>
{{else if this.isLlm}}
<AiLlmSelector
class="ai-persona-tool-option-editor__llms"
@value={{this.selectedLlm}}
@llms={{@llms}}
@blankName={{i18n "discourse_ai.ai_persona.use_parent_llm"}}
/>
{{else if this.isBoolean}}
<input
type="checkbox"
checked={{this.selectedValue}}
{{on "click" this.onCheckboxChange}}
/>
{{@option.description}}
{{else}}
<Input @value={{@option.value.value}} />
{{/if}}
</div>
{{#unless this.isBoolean}}
<div class="ai-persona-tool-option-editor__instructions">
{{@option.description}}
</div>
{{/unless}}
</div>
</template>
}
@@ -1,82 +1,95 @@
import Component from "@glimmer/component";
import { action, get } from "@ember/object";
import { eq } from "truth-helpers";
import { i18n } from "discourse-i18n";
import AiPersonaToolOptionEditor from "./ai-persona-tool-option-editor";
import AiLlmSelector from "./ai-llm-selector";
export default class AiPersonaToolOptions extends Component {
get showToolOptions() {
const allTools = this.args.allTools;
if (!allTools) {
if (!allTools || !this.args.data.tools) {
return false;
}
return this.toolNames.any((tool) => allTools.findBy("id", tool)?.options);
return this.args.data?.tools.any(
(tool) => allTools.findBy("id", tool)?.options
);
}
get toolNames() {
if (!this.args.tools) {
return [];
}
return this.args.tools.map((tool) => {
if (typeof tool === "string") {
return tool;
} else {
return tool[0];
}
get toolsMetadata() {
const metatada = {};
this.args.allTools.map((t) => {
metatada[t.id] = {
name: t.name,
...t?.options,
};
});
return metatada;
}
get toolOptions() {
if (!this.args.tools) {
return [];
}
const allTools = this.args.allTools;
if (!allTools) {
return [];
}
const options = [];
this.toolNames.forEach((toolId) => {
const tool = allTools.findBy("id", toolId);
const toolName = tool?.name;
const toolOptions = tool?.options;
if (toolOptions) {
const mappedOptions = Object.keys(toolOptions).map((key) => {
const value = this.args.persona.getToolOption(toolId, key);
return Object.assign({}, toolOptions[key], { id: key, value });
});
options.push({ toolName, options: mappedOptions });
}
});
return options;
@action
formObjectKeys(toolOptions) {
return toolOptions ? Object.keys(toolOptions) : [];
}
<template>
{{#if this.showToolOptions}}
<div class="control-group">
<label>{{i18n "discourse_ai.ai_persona.tool_options"}}</label>
<div>
{{#each this.toolOptions as |toolOption|}}
<@form.Container
@title={{i18n "discourse_ai.ai_persona.tool_options"}}
@direction="column"
@format="full"
>
<@form.Object
@name="toolOptions"
@title={{i18n "discourse_ai.ai_persona.tool_options"}}
as |toolObj optsPerTool|
>
{{#each (this.formObjectKeys optsPerTool) as |toolId|}}
<div class="ai-persona-editor__tool-options">
<div class="ai-persona-editor__tool-options-name">
{{toolOption.toolName}}
</div>
<div class="ai-persona-editor__tool-option-options">
{{#each toolOption.options as |option|}}
<AiPersonaToolOptionEditor
@option={{option}}
@llms={{@llms}}
/>
{{/each}}
</div>
{{#let (get this.toolsMetadata toolId) as |toolMeta|}}
<div class="ai-persona-editor__tool-options-name">
{{toolMeta.name}}
</div>
<toolObj.Object @name={{toolId}} as |optionsObj optionData|>
{{#each (this.formObjectKeys optionData) as |optionName|}}
{{#let (get toolMeta optionName) as |optionMeta|}}
<optionsObj.Field
@name={{optionName}}
@title={{optionMeta.name}}
@helpText={{optionMeta.description}}
@format="full"
as |field|
>
{{#if (eq optionMeta.type "enum")}}
<field.Select @includeNone={{false}} as |select|>
{{#each optionsObj.values as |v|}}
<select.Option @value={{v}}>{{v}}</select.Option>
{{/each}}
</field.Select>
{{else if (eq optionMeta.type "llm")}}
<field.Custom>
<AiLlmSelector
@value={{field.value}}
@llms={{@llms}}
@onChange={{field.set}}
@class="ai-persona-tool-option-editor__llms"
/>
</field.Custom>
{{else if (eq optionMeta.type "boolean")}}
<field.Checkbox />
{{else}}
<field.Input />
{{/if}}
</optionsObj.Field>
{{/let}}
{{/each}}
</toolObj.Object>
{{/let}}
</div>
{{/each}}
</div>
</div>
</@form.Object>
</@form.Container>
{{/if}}
</template>
}
@@ -0,0 +1,49 @@
import Component from "@glimmer/component";
import { service } from "@ember/service";
import DButton from "discourse/components/d-button";
import icon from "discourse/helpers/d-icon";
import { i18n } from "discourse-i18n";
import DTooltip from "float-kit/components/d-tooltip";
export default class AiSearchDiscoveriesTooltip extends Component {
@service discobotDiscoveries;
<template>
<span class="ai-search-discoveries-tooltip">
<DTooltip @placement="top-end" @interactive={{true}}>
<:trigger>
{{icon "circle-info"}}
</:trigger>
<:content>
<div class="ai-search-discoveries-tooltip__content">
<div class="ai-search-discoveries-tooltip__header">
{{i18n "discourse_ai.discobot_discoveries.tooltip.header"}}
</div>
<div class="ai-search-discoveries-tooltip__description">
{{#if this.discobotDiscoveries.modelUsed}}
{{i18n
"discourse_ai.discobot_discoveries.tooltip.content"
model=this.discobotDiscoveries.modelUsed
}}
{{/if}}
</div>
<div class="ai-search-discoveries-tooltip__actions">
<DButton
class="btn-transparent btn-primary"
@label="discourse_ai.discobot_discoveries.tooltip.actions.info"
@href="https://meta.discourse.org/t/conversational-ai-search-coming-to-discourse-ai/355939"
/>
<DButton
class="btn-transparent btn-danger"
@label="discourse_ai.discobot_discoveries.tooltip.actions.disable"
@action={{this.discobotDiscoveries.disableDiscoveries}}
/>
</div>
</div>
</:content>
</DTooltip>
</span>
</template>
}
@@ -0,0 +1,30 @@
import { concat } from "@ember/helper";
import { htmlSafe } from "@ember/template";
import { gt } from "truth-helpers";
import { i18n } from "discourse-i18n";
import DTooltip from "float-kit/components/d-tooltip";
const AiSentimentHorizontalBar = <template>
{{#if (gt @score 0)}}
<DTooltip
class={{concat "sentiment-horizontal-bar__" @type}}
style={{htmlSafe (concat "width: " @width "%")}}
>
<:trigger>
<span class="sentiment-horizontal-bar__count">
{{@score}}
</span>
</:trigger>
<:content>
{{i18n
(concat
"discourse_ai.sentiments.sentiment_analysis.filter_types." @type
)
}}:
{{@score}}
</:content>
</DTooltip>
{{/if}}
</template>;
export default AiSentimentHorizontalBar;
@@ -0,0 +1,13 @@
import { hash } from "@ember/helper";
import MultiSelect from "select-kit/components/multi-select";
const AiToolSelector = <template>
<MultiSelect
@value={{@value}}
@onChange={{@onChange}}
@content={{@content}}
@options={{hash filterable=true allowAny=false disabled=@disabled}}
/>
</template>;
export default AiToolSelector;
@@ -1,18 +0,0 @@
import { readOnly } from "@ember/object/computed";
import { observes } from "@ember-decorators/object";
import MultiSelectComponent from "select-kit/components/multi-select";
import { selectKitOptions } from "select-kit/components/select-kit";
@selectKitOptions({
filterable: true,
})
export default class AiToolSelector extends MultiSelectComponent {
@readOnly("tools") content;
value = "";
@observes("attrs.disabled")
_modelDisabledChanged() {
this.selectKit.options.set("disabled", this.get("attrs.disabled.value"));
}
}
@@ -23,6 +23,7 @@ import ComboBox from "select-kit/components/combo-box";
export default class AiUsage extends Component {
@service store;
@tracked startDate = moment().subtract(30, "days").toDate();
@tracked endDate = new Date();
@tracked data = this.args.model;
@@ -13,6 +13,7 @@ import AiIndicatorWave from "../ai-indicator-wave";
export default class ModalDiffModal extends Component {
@service currentUser;
@tracked loading = false;
@tracked diff;
@tracked suggestion = "";
@@ -13,6 +13,7 @@ import { i18n } from "discourse-i18n";
export default class ShareModal extends Component {
@service toasts;
@tracked shareKey = "";
constructor() {

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