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
154 changed files with 2756 additions and 2338 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
+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"
]
}
}
@@ -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);
}
}
@@ -5,6 +5,5 @@
@presets={{this.presets}}
@llms={{this.llms}}
@settings={{this.settings}}
@selectedPreset={{this.selectedPreset}}
/>
</section>
@@ -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
+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
@@ -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,10 +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: JSON.parse(currentQueryParams.filters), // avoids a double escaping
filters: currentFilters, // avoids a double escaping
selectedChart: null,
},
});
@@ -294,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}}
@@ -288,6 +288,11 @@ export default class AiEmbeddingEditor extends Component {
});
}
@action
providerKeys(providerParams) {
return providerParams ? Object.keys(providerParams) : [];
}
<template>
{{#if this.showPresets}}
<BackButton
@@ -493,8 +498,8 @@ export default class AiEmbeddingEditor extends Component {
{{! provider-specific content }}
{{#if this.currentProvider}}
{{#if data.provider_params}}
<form.Object @name="provider_params" as |object name|>
<form.Object @name="provider_params" as |object providerData|>
{{#each (this.providerKeys providerData) as |name|}}
{{#let (get this.providerParams name) as |params|}}
{{#if params}}
<object.Field
@@ -523,8 +528,8 @@ export default class AiEmbeddingEditor extends Component {
</object.Field>
{{/if}}
{{/let}}
</form.Object>
{{/if}}
{{/each}}
</form.Object>
{{/if}}
<form.Actions class="ai-embedding-editor__action_panel">
@@ -550,33 +555,24 @@ export default class AiEmbeddingEditor extends Component {
</form.Actions>
{{#if this.displayTestResult}}
<form.Field
@showTitle={{false}}
@name="test_results"
@title="test_results"
@format="full"
class="ai-embedding-editor-tests"
as |field|
>
<field.Custom>
<ConditionalLoadingSpinner
@size="small"
@condition={{this.testRunning}}
>
{{#if this.testResult}}
<div class="ai-embedding-editor-tests__success">
{{icon "check"}}
{{i18n "discourse_ai.embeddings.tests.success"}}
</div>
{{else}}
<div class="ai-embedding-editor-tests__failure">
{{icon "xmark"}}
{{this.testErrorMessage}}
</div>
{{/if}}
</ConditionalLoadingSpinner>
</field.Custom>
</form.Field>
<form.Container @format="full" class="ai-embedding-editor-tests">
<ConditionalLoadingSpinner
@size="small"
@condition={{this.testRunning}}
>
{{#if this.testResult}}
<div class="ai-embedding-editor-tests__success">
{{icon "check"}}
{{i18n "discourse_ai.embeddings.tests.success"}}
</div>
{{else}}
<div class="ai-embedding-editor-tests__failure">
{{icon "xmark"}}
{{this.testErrorMessage}}
</div>
{{/if}}
</ConditionalLoadingSpinner>
</form.Container>
{{/if}}
</Form>
{{/if}}
@@ -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,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;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,36 +1,13 @@
import Component from "@glimmer/component";
import { fn } from "@ember/helper";
import { action } from "@ember/object";
import { LinkTo } from "@ember/routing";
import { service } from "@ember/service";
import { eq } 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 DropdownMenu from "discourse/components/dropdown-menu";
import { i18n } from "discourse-i18n";
import AdminConfigAreaEmptyList from "admin/components/admin-config-area-empty-list";
import DMenu from "float-kit/components/d-menu";
export default class AiToolListEditor extends Component {
@service adminPluginNavManager;
@service router;
get lastIndexOfPresets() {
return this.args.tools.resultSetMeta.presets.length - 1;
}
@action
routeToNewTool(preset) {
return this.router.transitionTo(
"adminPlugins.show.discourse-ai-tools.new",
{
queryParams: {
presetId: preset.preset_id,
},
}
);
}
<template>
<DBreadcrumbsItem
@@ -43,33 +20,13 @@ export default class AiToolListEditor extends Component {
@learnMoreUrl="https://meta.discourse.org/t/ai-bot-custom-tools/314103"
@descriptionLabel={{i18n "discourse_ai.tools.subheader_description"}}
>
<:actions>
<DMenu
@triggerClass="btn-primary btn-small ai-tool-list-editor__new-button"
@label={{i18n "discourse_ai.tools.new"}}
<:actions as |actions|>
<actions.Primary
@label="discourse_ai.tools.new"
@route="adminPlugins.show.discourse-ai-tools.new"
@icon="plus"
@placement="bottom-end"
>
<:content>
<DropdownMenu as |dropdown|>
{{#each @tools.resultSetMeta.presets as |preset index|}}
{{#if (eq index this.lastIndexOfPresets)}}
<dropdown.divider />
{{/if}}
<dropdown.item>
<DButton
@translatedLabel={{preset.preset_name}}
@action={{fn this.routeToNewTool preset}}
class="btn-transparent"
data-option={{preset.preset_id}}
/>
</dropdown.item>
{{/each}}
</DropdownMenu>
</:content>
</DMenu>
class="ai-tool-list-editor__new-button"
/>
</:actions>
</DPageSubheader>
@@ -0,0 +1,155 @@
import Component from "@glimmer/component";
import { fn } from "@ember/helper";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { TrackedArray, TrackedObject } from "@ember-compat/tracked-built-ins";
import DButton from "discourse/components/d-button";
import withEventValue from "discourse/helpers/with-event-value";
import { i18n } from "discourse-i18n";
import ComboBox from "select-kit/components/combo-box";
const PARAMETER_TYPES = [
{ name: "string", id: "string" },
{ name: "number", id: "number" },
{ name: "boolean", id: "boolean" },
{ name: "array", id: "array" },
];
export default class AiToolParameterEditor extends Component {
@action
addParameter() {
this.args.parameters.push(
new TrackedObject({
name: "",
description: "",
type: "string",
required: false,
enum: null,
})
);
}
@action
removeParameter(parameter) {
const index = this.args.parameters.indexOf(parameter);
this.args.parameters.splice(index, 1);
}
@action
toggleRequired(parameter, event) {
parameter.required = event.target.checked;
}
@action
toggleEnum(parameter) {
if (parameter.enum) {
parameter.enum = null;
} else {
this.addEnumValue(parameter);
}
}
@action
addEnumValue(parameter) {
parameter.enum ||= new TrackedArray();
parameter.enum.push("");
}
@action
removeEnumValue(parameter, index) {
parameter.enum.splice(index, 1);
if (parameter.enum.length === 0) {
parameter.enum = null;
}
}
@action
updateEnumValue(parameter, index, event) {
parameter.enum[index] = event.target.value;
}
<template>
{{#each @parameters as |parameter|}}
<div class="ai-tool-parameter">
<div class="parameter-row">
<input
{{on "input" (withEventValue (fn (mut parameter.name)))}}
value={{parameter.name}}
type="text"
placeholder={{i18n "discourse_ai.tools.parameter_name"}}
/>
<ComboBox @value={{parameter.type}} @content={{PARAMETER_TYPES}} />
</div>
<div class="parameter-row">
<input
{{on "input" (withEventValue (fn (mut parameter.description)))}}
value={{parameter.description}}
type="text"
placeholder={{i18n "discourse_ai.tools.parameter_description"}}
/>
</div>
<div class="parameter-row">
<label>
<input
{{on "input" (fn this.toggleRequired parameter)}}
checked={{parameter.required}}
type="checkbox"
class="parameter-row__required-toggle"
/>
{{i18n "discourse_ai.tools.parameter_required"}}
</label>
<label>
<input
{{on "input" (fn this.toggleEnum parameter)}}
checked={{parameter.enum}}
type="checkbox"
class="parameter-row__enum-toggle"
/>
{{i18n "discourse_ai.tools.parameter_enum"}}
</label>
<DButton
@action={{fn this.removeParameter parameter}}
@icon="trash-can"
class="btn-danger"
/>
</div>
{{#if parameter.enum}}
<div class="parameter-enum-values">
{{#each parameter.enum as |enumValue enumIndex|}}
<div class="enum-value-row">
<input
{{on "change" (fn this.updateEnumValue parameter enumIndex)}}
value={{enumValue}}
type="text"
placeholder={{i18n "discourse_ai.tools.enum_value"}}
/>
<DButton
@action={{fn this.removeEnumValue parameter enumIndex}}
@icon="trash-can"
class="btn-danger"
/>
</div>
{{/each}}
<DButton
@action={{fn this.addEnumValue parameter}}
@label="discourse_ai.tools.add_enum_value"
@icon="plus"
/>
</div>
{{/if}}
</div>
{{/each}}
<DButton
@action={{this.addParameter}}
@label="discourse_ai.tools.add_parameter"
@icon="plus"
/>
</template>
}
@@ -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"));
}
}
@@ -0,0 +1,81 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { i18n } from "discourse-i18n";
import AiLlmSelector from "./ai-llm-selector";
export default class RagOptionsFk extends Component {
@tracked showIndexingOptions = false;
@action
toggleIndexingOptions(event) {
this.showIndexingOptions = !this.showIndexingOptions;
event.preventDefault();
event.stopPropagation();
}
get indexingOptionsText() {
return this.showIndexingOptions
? i18n("discourse_ai.rag.options.hide_indexing_options")
: i18n("discourse_ai.rag.options.show_indexing_options");
}
get visionLlms() {
return this.args.llms.filter((llm) => llm.vision_enabled);
}
<template>
{{#if @data.rag_uploads}}
<a
href="#"
class="rag-options__indexing-options"
{{on "click" this.toggleIndexingOptions}}
>{{this.indexingOptionsText}}</a>
{{/if}}
{{#if this.showIndexingOptions}}
<@form.Field
@name="rag_chunk_tokens"
@title={{i18n "discourse_ai.rag.options.rag_chunk_tokens"}}
@tooltip={{i18n "discourse_ai.rag.options.rag_chunk_tokens_help"}}
@format="large"
as |field|
>
<field.Input @type="number" step="any" lang="en" />
</@form.Field>
<@form.Field
@name="rag_chunk_overlap_tokens"
@title={{i18n "discourse_ai.rag.options.rag_chunk_tokens"}}
@tooltip={{i18n
"discourse_ai.rag.options.rag_chunk_overlap_tokens_help"
}}
@format="large"
as |field|
>
<field.Input @type="number" step="any" lang="en" />
</@form.Field>
{{#if @allowImages}}
<@form.Field
@name="rag_llm_model_id"
@title={{i18n "discourse_ai.rag.options.rag_llm_model"}}
@tooltip={{i18n "discourse_ai.rag.options.rag_llm_model_help"}}
@format="large"
as |field|
>
<field.Custom>
<AiLlmSelector
@value={{field.value}}
@llms={{this.visionLlms}}
@onChange={{field.set}}
@class="ai-persona-editor__llms"
/>
</field.Custom>
</@form.Field>
{{/if}}
{{yield}}
{{/if}}
</template>
}
@@ -54,11 +54,10 @@ export default class RagUploader extends Component {
this.uppyUpload.cancelAllUploads();
}
this.ragUploads = this.target?.rag_uploads || [];
this.ragUploads = this.target?.rag_uploads?.slice() || [];
this.filteredUploads = this.ragUploads;
const targetName = this.target?.constructor?.name;
const targetName = this.targetName || this.target?.constructor?.name;
if (this.ragUploads?.length && this.target?.id) {
ajax(
`/admin/plugins/discourse-ai/rag-document-fragments/files/status.json?target_type=${targetName}&target_id=${this.target.id}`
@@ -127,7 +126,6 @@ export default class RagUploader extends Component {
<template>
<div class="rag-uploader">
<h3>{{i18n "discourse_ai.rag.uploads.title"}}</h3>
{{#if @allowImages}}
<p>{{i18n "discourse_ai.rag.uploads.description_with_images"}}</p>
{{else}}
@@ -137,10 +135,7 @@ export default class RagUploader extends Component {
{{#if this.ragUploads}}
<div class="rag-uploader__search-input-container">
<div class="rag-uploader__search-input">
{{icon
"magnifying-glass"
class="rag-uploader__search-input__search-icon"
}}
{{icon "search" class="rag-uploader__search-input__search-icon"}}
<Input
class="rag-uploader__search-input__input"
placeholder={{i18n "discourse_ai.rag.uploads.filter"}}
@@ -42,17 +42,8 @@
.ai-persona-editor {
padding-left: 0.5em;
.fk-d-tooltip__icon {
padding-left: 0.25em;
color: var(--primary-medium);
}
label {
display: block;
}
&__tool-options {
padding: 5px 10px 5px;
padding: 1em;
border: 1px solid var(--primary-low-mid);
width: 480px;
}
@@ -61,27 +52,6 @@
margin-bottom: 10px;
font-size: var(--font-down-1);
}
&__description {
width: 500px;
}
&__system_prompt {
width: 500px;
height: 400px;
}
&__tool-details,
&__vision_enabled,
&__allow_chat_direct_messages,
&__allow_chat_channel_mentions,
&__allow_topic_mentions,
&__allow_personal_messages,
&__force_default_llm,
&__priority {
display: flex;
align-items: center;
}
}
.rag-options {
@@ -134,9 +104,7 @@
}
&__uploads-list {
&:has(tr) {
margin-bottom: 20px;
}
margin-bottom: 20px;
tbody {
border-top: none;
@@ -165,9 +133,4 @@
&__rag-file-icon {
margin-right: 5px;
}
.hidden-upload-field {
visibility: hidden;
position: absolute;
}
}

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