Compare commits

..
Author SHA1 Message Date
Keegan George e7a3a50d86 DEV: Decorate boosted and AI results 2023-11-27 11:01:14 -08:00
Roman Rizzi 775610b1c2 FIX: Chat titler was still using the old code after LLM migration (#314) 2023-11-27 13:03:24 -03:00
Roman Rizzi 54a8dd9556 REFACTOR: Use LLM abstraction in the AI Helper. (#312)
It also removes the need for multiple versions of our seeded prompts per model, further simplifying the code.
2023-11-27 09:33:31 -03:00
Sam 5a4598a7b4 FEATURE: Azure OpenAI support for DALL*E 3 (#313)
* FEATURE: Azure OpenAI support for DALL*E 3

Previous to this there was no way to add an inference endpoint for
DALL*E on Azure cause it requires custom URLs

Also:

- On save, when editing a persona it would revert priority and enabled
- More forgiving parsing in command framework for array function calls
- By default generate HD images - they tend to be a bit better
- Improve DALL*E prompt which was getting very annoying and always echoing what it is about to do
- Add a bit of a sleep between retries on image generation
- Fix error handling in image_command
2023-11-27 13:01:05 +11:00
SamandMartin Brennan dff9f33a97 FEATURE: DALL-E-3 persona for image generation (#311)
* FIX: no selected persona should pick first prioritized one

Previously we were looking at `.personaId` but there is only an
id attribute so it failed

* FEATURE: new DALL-E-3 persona

This persona generates images using DALL-E-3 API and is enabled
by default

Keep in mind that we are still waiting on seeds/gen_id so we can
not retain style consistently between turns.

This will change as soon as a new Open AI API provides the missing
parameters

Co-authored-by: Martin Brennan <[email protected]>
2023-11-24 18:08:08 +11:00
Sam 6282b6d21f FIX: implement tools framework for Anthropic (#307)
Previous to this changeset we used a custom system for tools/command
support for Anthropic.

We defined commands by using !command as a signal to execute it

Following Anthropic Claude 2.1, there is an official supported syntax (beta)
for tools execution.

eg:

```
+      <function_calls>
+      <invoke>
+      <tool_name>image</tool_name>
+      <parameters>
+      <prompts>
+      [
+      "an oil painting",
+      "a cute fluffy orange",
+      "3 apple's",
+      "a cat"
+      ]
+      </prompts>
+      </parameters>
+      </invoke>
+      </function_calls>
```

This implements the spec per Anthropic, it should be stable enough
to also work on other LLMs.

Keep in mind that OpenAI is not impacted here at all, as it has its
own custom system for function calls.

Additionally:

- Fixes the title system prompt so it works with latest Anthropic
- Uses new spec for "system" messages by Anthropic
- Tweak forum helper persona to guide Anthropic a tiny be better

Overall results are pretty awesome and Anthropic Claude performs
really well now on Discourse
2023-11-24 06:39:56 +11:00
Roman Rizzi 419c43592a FIX: Make summaries more cohesive by tweaking prompt. (#310)
Other changes:

- Don't use Bedrock for non claude models if credentials are set.
- Remove extra sentence from HyDE prompt.
2023-11-23 16:33:37 -03:00
Keegan George df8804afcd DEV: Only allow semantic search on "Relevance" sort mode (#306) 2023-11-23 11:30:17 -08:00
Roman Rizzi 02efca162e FIX: Bedrock uses slightly different model names
* Revert "FIX: We don't need to prepend anthropic. to bedrock models (#308)"

This reverts commit 8a01751991.

* FIX: Bedrock uses slightly different model names
2023-11-23 15:49:24 -03:00
Roman Rizzi 8a01751991 FIX: We don't need to prepend anthropic. to bedrock models (#308) 2023-11-23 14:39:21 -03:00
Roman Rizzi 3064d4c288 REFACTOR: Summarization and HyDE now use an LLM abstraction. (#297)
* DEV: One LLM abstraction to rule them all

* REFACTOR: HyDE search uses new LLM abstraction

* REFACTOR: Summarization uses the LLM abstraction

* Updated documentation and made small fixes. Remove Bedrock claude-2 restriction
2023-11-23 12:58:54 -03:00
Jarek Radosz 53b7f031ba DEV: Remove gem "aws-eventstream" entry (#305)
Core already includes aws-eventstream through aws-sdk-s3 and aws-sdk-sns.

Specifying a dependency in a plugin was blocking an upgrade in core (https://github.com/discourse/discourse/pull/24518)
2023-11-23 10:00:25 +01:00
Keegan George 787cd1bf17 FIX: Error 500 from search with only filters (#304) 2023-11-22 11:05:56 -08:00
Keegan George 432590f8d9 DEV: Run AI search before submit if query params present (#303) 2023-11-21 16:51:08 -08:00
Keegan George c55014839f FIX: more results not appearing on scroll (#302) 2023-11-21 09:46:37 -08:00
Roman Rizzi e0691e70e8 DEV: Updates to the summarization strategy API (#301)
Introduced by discourse/discourse#24489

In the future, this change will let us log who requested the summary in the `AiApiAuditLog`.:
2023-11-21 13:27:35 -03:00
Discourse Translator Bot 493b48477a Update translations (#300) 2023-11-21 14:36:22 +01:00
SamandMartin Brennan 98c89953d3 FEATURE: remember previously selected persona (#299)
People tend to keep to 1 persona when working with the bot,
this adds local browser memory for the last persona you interacted
with so you do not need to select it over and over again.

This is per browser, not per user memory.

Also... clean up tests so they do not need to require stubs which
were breaking the build

---------

Co-authored-by: Martin Brennan <[email protected]>
2023-11-21 17:02:27 +10:00
5b5edb22c6 FEATURE: UI to update ai personas on admin page (#290)
Introduces a UI to manage customizable personas (admin only feature)

Part of the change was some extensive internal refactoring:

- AIBot now has a persona set in the constructor, once set it never changes
- Command now takes in bot as a constructor param, so it has the correct persona and is not generating AIBot objects on the fly
- Added a .prettierignore file, due to the way ALE is configured in nvim it is a pre-req for prettier to work
- Adds a bunch of validations on the AIPersona model, system personas (artist/creative etc...) are all seeded. We now ensure
- name uniqueness, and only allow certain properties to be touched for system personas.
- (JS note) the client side design takes advantage of nested routes, the parent route for personas gets all the personas via this.store.findAll("ai-persona") then child routes simply reach into this model to find a particular persona.
- (JS note) data is sideloaded into the ai-persona model the meta property supplied from the controller, resultSetMeta
- This removes ai_bot_enabled_personas and ai_bot_enabled_chat_commands, both should be controlled from the UI on a per persona basis
- Fixes a long standing bug in token accounting ... we were doing to_json.length instead of to_json.to_s.length
- Amended it so {commands} are always inserted at the end unconditionally, no need to add it to the template of the system message as it just confuses things
- Adds a concept of required_commands to stock personas, these are commands that must be configured for this stock persona to show up.
- Refactored tests so we stop requiring inference_stubs, it was very confusing to need it, added to plugin.rb for now which at least is clearer
- Migrates the persona selector to gjs

---------

Co-authored-by: Joffrey JAFFEUX <[email protected]>
Co-authored-by: Martin Brennan <[email protected]>
2023-11-21 16:56:43 +11:00
Keegan George 491111e5c8 FIX: Un-clickable AI Results (#298) 2023-11-20 11:37:00 -08:00
Keegan George f7277d244e DEV: Mix semantic search results with normal results (#278) 2023-11-17 12:46:59 -08:00
Keegan George d1f21c78f1 UX: Add copy button to generated suggestion (#296) 2023-11-17 09:25:41 -08:00
David Taylor e1a7d07a91 DEV: Update tests for core change (#293)
https://github.com/discourse/discourse/pull/23967
2023-11-15 09:23:27 +00:00
Discourse Translator Bot c6902c40ce Update translations (#294) 2023-11-14 14:30:22 +01:00
Roman Rizzi 1588ab99a6 FIX: ChatChannel no longer inherits from RestModel. (#292)
Trying to hydrate using the store will fail. We need to use the model create function instead.
2023-11-13 14:45:20 -03:00
Roman Rizzi 244a338558 UX: Graph surprise next to joy in post_emotion chart (#291) 2023-11-10 14:41:50 -03:00
Sam a4f419f54f FEATURE: basic infrastructure for custom personas (#288)
- New AiPersona model which can store custom personas
- Persona are restricted via group security
- They can contain custom system messages
- They can support a list of commands optionally

To avoid expensive DB calls in the serializer a Multisite friendly Hash was introduced (which can be expired on transaction commit)
2023-11-10 11:39:49 +11:00
Roman Rizzi d0198c5c5b FIX: Changes to the sentiment reports. (#289)
This PR aims to clarify sentiment reports by replacing averages with a count of posts that have one of their values above a threshold (60), meaning we have some level of confidence they are, in fact, positive or negative.

Same thing happen with post emotions, with the difference that a post can have multiple values above it (30). Additionally, we dropped the "Neutral" axis.

We also reworded the tooltip next to each report title, and added an early return to signal we have no data available instead of displaying an empty chart.
2023-11-09 17:23:25 -03:00
Martin Brennan 0c83963a28 DEV: Fix plugin.rb wording (#287)
This was not a complete sentence before
2023-11-09 11:23:43 +10:00
Roman Rizzi 458e66aef9 FIX: Filter classification type using the correct column (#286) 2023-11-08 14:58:35 -03:00
Roman Rizzi 231cf91cc2 FIX: Don't divide by zero if there is no emotion data for TL group (#285) 2023-11-08 13:05:36 -03:00
Roman Rizzi b172ef11c4 FEATURE: Expose sentiment classifications via the admin dashboard. (#284)
This PR adds new reports for displaying information about post sentiments grouped by date and emotions group by TL.

Depends on discourse/discourse#24274
2023-11-08 10:50:37 -03:00
Martin BrennanandAlexDev 30821badf2 Update plugin.rb metadata (#279)
Co-authored-by: AlexDev <[email protected]>
2023-11-07 15:45:02 -08:00
Ty Correll 3df6cb1ec9 UX: prevent suggestion button from wrapping (#282)
* UX: prevent suggestion button from wrapping

This PR addresses the visual bug where the suggestion button wraps on any screen size.

* UX: edge case screen size

This addition fixes screen size issues at specific screen sizes without affecting larger screen sizes, no media query necessary.
2023-11-04 12:52:51 -05:00
256 changed files with 6097 additions and 2740 deletions
+1
View File
@@ -1,3 +1,4 @@
< 3.2.0.beta4-dev: 493b48477a9288f2f1b6f021954088705a6673d6
< 3.2.0.beta2-dev: 3bced1c6f525553fe2b5b79d29bd66c1cf2a47d5
3.1.999: c0415bb7eb878b1b7abf112d65cba981030df8df
< 3.2.0.beta1-dev: 3e7c99de894b03e9161d9481da118fe893991a1d
+21
View File
@@ -0,0 +1,21 @@
app/assets/stylesheets/vendor/
documentation/
package.json
config/locales/**/*.yml
!config/locales/**/*.en*.yml
script/import_scripts/**/*.yml
plugins/**/lib/javascripts/locale
public/
!/app/assets/javascripts/discourse/public
vendor/
app/assets/javascripts/discourse/tests/fixtures
spec/
node_modules/
dist/
tmp/
**/*.rb
**/*.html
**/*.json
**/*.md
@@ -0,0 +1,71 @@
# frozen_string_literal: true
module DiscourseAi
module Admin
class AiPersonasController < ::Admin::AdminController
before_action :find_ai_persona, only: %i[show update destroy]
def index
ai_personas =
AiPersona.ordered.map do |persona|
# we use a special serializer here cause names and descriptions are
# localized for system personas
LocalizedAiPersonaSerializer.new(persona, root: false)
end
commands =
DiscourseAi::AiBot::Personas::Persona.all_available_commands.map do |command|
{ id: command.to_s.split("::").last, name: command.name.humanize.titleize }
end
render json: { ai_personas: ai_personas, meta: { commands: commands } }
end
def show
render json: LocalizedAiPersonaSerializer.new(@ai_persona)
end
def create
ai_persona = AiPersona.new(ai_persona_params)
if ai_persona.save
render json: { ai_persona: ai_persona }, status: :created
else
render_json_error ai_persona
end
end
def update
if @ai_persona.update(ai_persona_params)
render json: @ai_persona
else
render_json_error @ai_persona
end
end
def destroy
if @ai_persona.destroy
head :no_content
else
render_json_error @ai_persona
end
end
private
def find_ai_persona
@ai_persona = AiPersona.find(params[:id])
end
def ai_persona_params
params.require(:ai_persona).permit(
:name,
:description,
:enabled,
:system_prompt,
:enabled,
:priority,
allowed_group_ids: [],
commands: [],
)
end
end
end
end
@@ -0,0 +1,12 @@
# frozen_string_literal: true
module DiscourseAi
module Admin
class DashboardController < ::Admin::StaffController
requires_plugin DiscourseAi::PLUGIN_NAME
def sentiment
end
end
end
end
@@ -11,7 +11,7 @@ module DiscourseAi
def prompts
render json:
ActiveModel::ArraySerializer.new(
DiscourseAi::AiHelper::LlmPrompt.new.available_prompts,
DiscourseAi::AiHelper::Assistant.new.available_prompts,
root: false,
),
status: 200
@@ -29,39 +29,34 @@ module DiscourseAi
hijack do
render json:
DiscourseAi::AiHelper::LlmPrompt.new.generate_and_send_prompt(prompt, params),
DiscourseAi::AiHelper::Assistant.new.generate_and_send_prompt(
prompt,
input,
current_user,
),
status: 200
end
rescue ::DiscourseAi::Inference::OpenAiCompletions::CompletionFailed,
::DiscourseAi::Inference::HuggingFaceTextGeneration::CompletionFailed,
::DiscourseAi::Inference::AnthropicCompletions::CompletionFailed => e
rescue DiscourseAi::Completions::Endpoints::Base::CompletionFailed => e
render_json_error I18n.t("discourse_ai.ai_helper.errors.completion_request_failed"),
status: 502
end
def suggest_title
input = get_text_param!
input_hash = { text: input }
llm_prompt =
DiscourseAi::AiHelper::LlmPrompt
.new
.available_prompts(name_filter: "generate_titles")
.first
prompt = CompletionPrompt.find_by(id: llm_prompt[:id])
raise Discourse::InvalidParameters.new(:mode) if !prompt || !prompt.enabled?
prompt = CompletionPrompt.enabled_by_name("generate_titles")
raise Discourse::InvalidParameters.new(:mode) if !prompt
hijack do
render json:
DiscourseAi::AiHelper::LlmPrompt.new.generate_and_send_prompt(
DiscourseAi::AiHelper::Assistant.new.generate_and_send_prompt(
prompt,
input_hash,
input,
current_user,
),
status: 200
end
rescue ::DiscourseAi::Inference::OpenAiCompletions::CompletionFailed,
::DiscourseAi::Inference::HuggingFaceTextGeneration::CompletionFailed,
::DiscourseAi::Inference::AnthropicCompletions::CompletionFailed => e
rescue DiscourseAi::Completions::Endpoints::Base::CompletionFailed => e
render_json_error I18n.t("discourse_ai.ai_helper.errors.completion_request_failed"),
status: 502
end
@@ -98,21 +93,18 @@ module DiscourseAi
def explain
post_id = get_post_param!
text = get_text_param!
post = Post.find_by(id: post_id)
term_to_explain = get_text_param!
post = Post.includes(:topic).find_by(id: post_id)
raise Discourse::InvalidParameters.new(:post_id) unless post
render json:
DiscourseAi::AiHelper::TopicHelper.new(
{ text: text },
current_user,
post: post,
).explain,
DiscourseAi::AiHelper::TopicHelper.new(current_user).explain(
term_to_explain,
post,
),
status: 200
rescue ::DiscourseAi::Inference::OpenAiCompletions::CompletionFailed,
::DiscourseAi::Inference::HuggingFaceTextGeneration::CompletionFailed,
::DiscourseAi::Inference::AnthropicCompletions::CompletionFailed => e
rescue DiscourseAi::Completions::Endpoints::Base::CompletionFailed => e
render_json_error I18n.t("discourse_ai.ai_helper.errors.completion_request_failed"),
status: 502
end
+179
View File
@@ -0,0 +1,179 @@
# frozen_string_literal: true
class AiPersona < ActiveRecord::Base
# places a hard limit, so per site we cache a maximum of 500 classes
MAX_PERSONAS_PER_SITE = 500
validates :name, presence: true, uniqueness: true, length: { maximum: 100 }
validates :description, presence: true, length: { maximum: 2000 }
validates :system_prompt, presence: true, length: { maximum: 10_000_000 }
validate :system_persona_unchangeable, on: :update, if: :system
before_destroy :ensure_not_system
class MultisiteHash
def initialize(id)
@hash = Hash.new { |h, k| h[k] = {} }
@id = id
MessageBus.subscribe(channel_name) { |message| @hash[message.data] = {} }
end
def channel_name
"/multisite-hash-#{@id}"
end
def current_db
RailsMultisite::ConnectionManagement.current_db
end
def [](key)
@hash.dig(current_db, key)
end
def []=(key, val)
@hash[current_db][key] = val
end
def flush!
@hash[current_db] = {}
MessageBus.publish(channel_name, current_db)
end
end
def self.persona_cache
@persona_cache ||= MultisiteHash.new("persona_cache")
end
scope :ordered, -> { order("priority DESC, lower(name) ASC") }
def self.all_personas
persona_cache[:value] ||= AiPersona
.ordered
.where(enabled: true)
.all
.limit(MAX_PERSONAS_PER_SITE)
.map(&:class_instance)
end
after_commit :bump_cache
def bump_cache
self.class.persona_cache.flush!
end
def class_instance
allowed_group_ids = self.allowed_group_ids
id = self.id
system = self.system
persona_class = DiscourseAi::AiBot::Personas.system_personas_by_id[self.id]
if persona_class
persona_class.define_singleton_method :allowed_group_ids do
allowed_group_ids
end
persona_class.define_singleton_method :id do
id
end
persona_class.define_singleton_method :system do
system
end
return persona_class
end
name = self.name
description = self.description
ai_persona_id = self.id
commands =
self.commands.filter_map do |inner_name|
begin
("DiscourseAi::AiBot::Commands::#{inner_name}").constantize
rescue StandardError
nil
end
end
Class.new(DiscourseAi::AiBot::Personas::Persona) do
define_singleton_method :id do
id
end
define_singleton_method :name do
name
end
define_singleton_method :description do
description
end
define_singleton_method :system do
system
end
define_singleton_method :allowed_group_ids do
allowed_group_ids
end
define_singleton_method :to_s do
"#<DiscourseAi::AiBot::Personas::Persona::Custom @name=#{self.name} @allowed_group_ids=#{self.allowed_group_ids.join(",")}>"
end
define_singleton_method :inspect do
"#<DiscourseAi::AiBot::Personas::Persona::Custom @name=#{self.name} @allowed_group_ids=#{self.allowed_group_ids.join(",")}>"
end
define_method :initialize do |*args, **kwargs|
@ai_persona = AiPersona.find_by(id: ai_persona_id)
super(*args, **kwargs)
end
define_method :commands do
commands
end
define_method :system_prompt do
@ai_persona&.system_prompt || "You are a helpful bot."
end
end
end
private
def system_persona_unchangeable
if system_prompt_changed? || commands_changed? || name_changed? || description_changed?
errors.add(:base, I18n.t("discourse_ai.ai_bot.personas.cannot_edit_system_persona"))
end
end
def ensure_not_system
if system
errors.add(:base, I18n.t("discourse_ai.ai_bot.personas.cannot_delete_system_persona"))
throw :abort
end
end
end
# == Schema Information
#
# Table name: ai_personas
#
# id :bigint not null, primary key
# name :string(100) not null
# description :string(2000) not null
# commands :string default([]), not null, is an Array
# system_prompt :string(10000000) not null
# allowed_group_ids :integer default([]), not null, is an Array
# created_by_id :integer
# enabled :boolean default(TRUE), not null
# created_at :datetime not null
# updated_at :datetime not null
# system :boolean default(FALSE), not null
# priority :integer default(0), not null
#
# Indexes
#
# index_ai_personas_on_name (name) UNIQUE
#
+19 -23
View File
@@ -1,40 +1,37 @@
# frozen_string_literal: true
class CompletionPrompt < ActiveRecord::Base
# TODO(roman): Remove sept 2023.
self.ignored_columns = ["value"]
# TODO(roman): Remove may 2024.
self.ignored_columns = ["provider"]
TRANSLATE = -301
GENERATE_TITLES = -302
PROOFREAD = -303
MARKDOWN_TABLE = -304
CUSTOM_PROMPT = -305
EXPLAIN = -306
enum :prompt_type, { text: 0, list: 1, diff: 2 }
validates :messages, length: { maximum: 20 }
validate :each_message_length
def messages_with_user_input(user_input)
return messages unless user_input.present?
def self.enabled_by_name(name)
where(enabled: true).find_by(name: name)
end
if user_input[:custom_prompt].present?
case ::DiscourseAi::AiHelper::LlmPrompt.new.enabled_provider
when "huggingface"
self.messages.each { |msg| msg.sub!("{{custom_prompt}}", user_input[:custom_prompt]) }
else
self.messages.each do |msg|
msg["content"].sub!("{{custom_prompt}}", user_input[:custom_prompt])
end
end
end
def messages_with_input(input)
return unless input
case ::DiscourseAi::AiHelper::LlmPrompt.new.enabled_provider
when "openai"
self.messages << { role: "user", content: user_input[:text] }
when "anthropic"
self.messages << { "role" => "Input", "content" => "<input>#{user_input[:text]}</input>" }
when "huggingface"
self.messages.first.sub("{{user_input}}", user_input[:text])
end
messages_hash.merge(input: "<input>#{input}</input")
end
private
def messages_hash
@messages_hash ||= messages.symbolize_keys!
end
def each_message_length
messages.each_with_index do |msg, idx|
next if msg["content"].length <= 1000
@@ -56,7 +53,6 @@ end
# created_at :datetime not null
# updated_at :datetime not null
# messages :jsonb
# provider :text
#
# Indexes
#
@@ -0,0 +1,23 @@
# frozen_string_literal: true
class LocalizedAiPersonaSerializer < ApplicationSerializer
root "ai_persona"
attributes :id,
:name,
:description,
:enabled,
:system,
:priority,
:commands,
:system_prompt,
:allowed_group_ids
def name
object.class_instance.name
end
def description
object.class_instance.description
end
end
@@ -0,0 +1,14 @@
export default {
resource: "admin.adminPlugins",
path: "/plugins",
map() {
this.route("discourse-ai", { path: "discourse-ai" }, function () {
this.route("ai-personas", { path: "ai_personas" }, function () {
this.route("new", { path: "/new" });
this.route("show", { path: "/:id" });
});
});
},
};
@@ -0,0 +1,10 @@
export default {
resource: "admin.dashboard",
path: "/dashboard",
map() {
this.route("admin.dashboardSentiment", {
path: "/dashboard/sentiment",
resetNamespace: true,
});
},
};
@@ -0,0 +1,17 @@
import RestAdapter from "discourse/adapters/rest";
export default class Adapter extends RestAdapter {
jsonMode = true;
basePath() {
return "/admin/plugins/discourse-ai/";
}
pathFor() {
return super.pathFor(...arguments) + ".json";
}
apiNameFor() {
return "ai-persona";
}
}
@@ -0,0 +1,28 @@
import RestModel from "discourse/models/rest";
const ATTRIBUTES = [
"name",
"description",
"commands",
"system_prompt",
"allowed_group_ids",
"enabled",
"system",
"priority",
];
export default class AiPersona extends RestModel {
updateProperties() {
let attrs = this.getProperties(ATTRIBUTES);
attrs.id = this.id;
return attrs;
}
createProperties() {
return this.getProperties(ATTRIBUTES);
}
workingCopy() {
return AiPersona.create(this.createProperties());
}
}
@@ -0,0 +1,18 @@
import { computed, observer } from "@ember/object";
import MultiSelectComponent from "select-kit/components/multi-select";
export default MultiSelectComponent.extend({
_modelDisabledChanged: observer("attrs.disabled", function () {
this.selectKit.options.set("disabled", this.get("attrs.disabled.value"));
}),
content: computed(function () {
return this.attrs.commands.value;
}),
value: "",
selectKitOptions: {
filterable: true,
},
});
@@ -0,0 +1,226 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { Input } from "@ember/component";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import didUpdate from "@ember/render-modifiers/modifiers/did-update";
import { later } from "@ember/runloop";
import { inject as service } from "@ember/service";
import DButton from "discourse/components/d-button";
import Textarea from "discourse/components/d-textarea";
import DToggleSwitch from "discourse/components/d-toggle-switch";
import { popupAjaxError } from "discourse/lib/ajax-error";
import Group from "discourse/models/group";
import i18n from "discourse-common/helpers/i18n";
import I18n from "discourse-i18n";
import GroupChooser from "select-kit/components/group-chooser";
import DTooltip from "float-kit/components/d-tooltip";
import AiCommandSelector from "./ai-command-selector";
export default class PersonaEditor extends Component {
@service router;
@service store;
@service dialog;
@service toasts;
@tracked allGroups = [];
@tracked isSaving = false;
@tracked editingModel = null;
@tracked showDelete = false;
@action
updateModel() {
this.editingModel = this.args.model.workingCopy();
this.showDelete = !this.args.model.isNew && !this.args.model.system;
}
@action
async updateAllGroups() {
this.allGroups = await Group.findAll();
}
@action
async save() {
const isNew = this.args.model.isNew;
this.isSaving = true;
const backupModel = this.args.model.workingCopy();
this.args.model.setProperties(this.editingModel);
try {
await this.args.model.save();
this.#sortPersonas();
if (isNew) {
this.args.personas.addObject(this.args.model);
this.router.transitionTo(
"adminPlugins.discourse-ai.ai-personas.show",
this.args.model
);
} else {
this.toasts.success({
data: { message: I18n.t("discourse_ai.ai_persona.saved") },
duration: 2000,
});
}
} catch (e) {
this.args.model.setProperties(backupModel);
popupAjaxError(e);
} finally {
later(() => {
this.isSaving = false;
}, 1000);
}
}
@action
delete() {
return this.dialog.confirm({
message: I18n.t("discourse_ai.ai_persona.confirm_delete"),
didConfirm: () => {
return this.args.model.destroyRecord().then(() => {
this.args.personas.removeObject(this.args.model);
this.router.transitionTo(
"adminPlugins.discourse-ai.ai-personas.index"
);
});
},
});
}
@action
updateAllowedGroups(ids) {
this.editingModel.set("allowed_group_ids", ids);
}
@action
async toggleEnabled() {
this.args.model.set("enabled", !this.args.model.enabled);
this.editingModel.set("enabled", this.args.model.enabled);
if (!this.args.model.isNew) {
try {
await this.args.model.update({ enabled: this.args.model.enabled });
} catch (e) {
popupAjaxError(e);
}
}
}
@action
async togglePriority() {
this.args.model.set("priority", !this.args.model.priority);
this.editingModel.set("priority", this.args.model.priority);
if (!this.args.model.isNew) {
try {
await this.args.model.update({ priority: this.args.model.priority });
this.#sortPersonas();
} catch (e) {
popupAjaxError(e);
}
}
}
#sortPersonas() {
const sorted = this.args.personas.toArray().sort((a, b) => {
if (a.priority && !b.priority) {
return -1;
} else if (!a.priority && b.priority) {
return 1;
} else {
return a.name.localeCompare(b.name);
}
});
this.args.personas.clear();
this.args.personas.setObjects(sorted);
}
<template>
<form
class="form-horizontal ai-persona-editor"
{{didUpdate this.updateModel @model.id}}
{{didInsert this.updateModel @model.id}}
{{didInsert this.updateAllGroups @model.id}}
>
<div class="control-group">
<DToggleSwitch
class="ai-persona-editor__enabled"
@state={{@model.enabled}}
@label="discourse_ai.ai_persona.enabled"
{{on "click" this.toggleEnabled}}
/>
</div>
<div class="control-group ai-persona-editor__priority">
<DToggleSwitch
class="ai-persona-editor__priority"
@state={{@model.priority}}
@label="discourse_ai.ai_persona.priority"
{{on "click" this.togglePriority}}
/>
<DTooltip
@icon="question-circle"
@content={{i18n "discourse_ai.ai_persona.priority_help"}}
/>
</div>
<div class="control-group">
<label>{{I18n.t "discourse_ai.ai_persona.name"}}</label>
<Input
class="ai-persona-editor__name"
@type="text"
@value={{this.editingModel.name}}
disabled={{this.editingModel.system}}
/>
</div>
<div class="control-group">
<label>{{I18n.t "discourse_ai.ai_persona.description"}}</label>
<Textarea
class="ai-persona-editor__description"
@value={{this.editingModel.description}}
disabled={{this.editingModel.system}}
/>
</div>
<div class="control-group">
<label>{{I18n.t "discourse_ai.ai_persona.commands"}}</label>
<AiCommandSelector
class="ai-persona-editor__commands"
@value={{this.editingModel.commands}}
@disabled={{this.editingModel.system}}
@commands={{@personas.resultSetMeta.commands}}
/>
</div>
<div class="control-group">
<label>{{I18n.t "discourse_ai.ai_persona.allowed_groups"}}</label>
<GroupChooser
@value={{this.editingModel.allowed_group_ids}}
@content={{this.allGroups}}
@onChange={{this.updateAllowedGroups}}
/>
</div>
<div class="control-group">
<label for="ai-persona-editor__system_prompt">{{I18n.t
"discourse_ai.ai_persona.system_prompt"
}}</label>
<Textarea
class="ai-persona-editor__system_prompt"
@value={{this.editingModel.system_prompt}}
disabled={{this.editingModel.system}}
/>
</div>
<div class="control-group ai-persona-editor__action_panel">
<DButton
class="btn-primary ai-persona-editor__save"
@action={{this.save}}
@disabled={{this.isSaving}}
>{{I18n.t "discourse_ai.ai_persona.save"}}</DButton>
{{#if this.showDelete}}
<DButton
@action={{this.delete}}
class="btn-danger ai-persona-editor__delete"
>
{{I18n.t "discourse_ai.ai_persona.delete"}}
</DButton>
{{/if}}
</div>
</form>
</template>
}
@@ -0,0 +1,66 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { LinkTo } from "@ember/routing";
import concatClass from "discourse/helpers/concat-class";
import { cook } from "discourse/lib/text";
import icon from "discourse-common/helpers/d-icon";
import I18n from "discourse-i18n";
import AiPersonaEditor from "./ai-persona-editor";
export default class AiPersonaListEditor extends Component {
@tracked _noPersonaText = null;
get noPersonaText() {
if (this._noPersonaText === null) {
const raw = I18n.t("discourse_ai.ai_persona.no_persona_selected");
cook(raw).then((result) => {
this._noPersonaText = result;
});
}
return this._noPersonaText;
}
<template>
<div class="ai-persona-list-editor__header">
<h3>{{I18n.t "discourse_ai.ai_persona.title"}}</h3>
{{#unless @currentPersona.isNew}}
<LinkTo
@route="adminPlugins.discourse-ai.ai-personas.new"
class="btn btn-primary"
>
{{icon "plus"}}
<span>{{I18n.t "discourse_ai.ai_persona.new"}}</span>
</LinkTo>
{{/unless}}
</div>
<div class="content-list ai-persona-list-editor">
<ul>
{{#each @personas as |persona|}}
<li
class={{concatClass
(if persona.enabled "" "diabled")
(if persona.priority "priority")
}}
>
<LinkTo
@route="adminPlugins.discourse-ai.ai-personas.show"
current-when="true"
@model={{persona}}
>{{persona.name}}
</LinkTo>
</li>
{{/each}}
</ul>
</div>
<section class="ai-persona-list-editor__current content-body">
{{#if @currentPersona}}
<AiPersonaEditor @model={{@currentPersona}} @personas={{@personas}} />
{{else}}
<div class="ai-persona-list-editor__empty">
{{this.noPersonaText}}
</div>
{{/if}}
</section>
</template>
}
@@ -34,7 +34,7 @@ export default class AISuggestionDropdown extends Component {
const minCharacterCount = 40;
const isShowAIButton = this.composer.model.replyLength > minCharacterCount;
const composerFields = document.querySelector(".composer-fields");
if (composerFields) {
if (isShowAIButton) {
composerFields.classList.add("showing-ai-suggestions");
@@ -1,13 +1,8 @@
import Component from "@glimmer/component";
import { inject as service } from "@ember/service";
import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel";
export default class ReviewableAiChatMessage extends Component {
@service store;
get chatChannel() {
return this.store.createRecord(
"chat-channel",
this.args.reviewable.chat_channel
);
return ChatChannel.create(this.args.reviewable.chat_channel);
}
}
@@ -0,0 +1,19 @@
import Component from "@glimmer/component";
import { LinkTo } from "@ember/routing";
import I18n from "discourse-i18n";
const i18n = I18n.t.bind(I18n);
export default class AISentimentDashboard extends Component {
<template>
<li class="navigation-item sentiment">
<LinkTo @route="admin.dashboardSentiment" class="navigation-link">
{{i18n "discourse_ai.sentiments.dashboard.title"}}
</LinkTo>
</li>
</template>
static shouldRender(_outletArgs, helper) {
return helper.siteSettings.ai_sentiment_enabled;
}
}
@@ -0,0 +1,15 @@
import Component from '@glimmer/component';
import icon from "discourse-common/helpers/d-icon";
export default class SearchResultDecoration extends Component {
<template>
<div class="ai-result__icon">
{{icon "discourse-sparkles"}}
<span>AI</span>
</div>
<div class="boosted-result__icon">
{{icon "rocket"}}
<span>Boosted</span>
</div>
</template>
}
@@ -0,0 +1,89 @@
import Component from "@glimmer/component";
import { hash } from "@ember/helper";
import { inject as service } from "@ember/service";
import KeyValueStore from "discourse/lib/key-value-store";
import DropdownSelectBox from "select-kit/components/dropdown-select-box";
function isBotMessage(composer, currentUser) {
if (
composer &&
composer.targetRecipients &&
currentUser.ai_enabled_chat_bots
) {
const reciepients = composer.targetRecipients.split(",");
return currentUser.ai_enabled_chat_bots.any((bot) =>
reciepients.any((username) => username === bot.username)
);
}
return false;
}
export default class BotSelector extends Component {
static shouldRender(args, container) {
return (
container?.currentUser?.ai_enabled_personas &&
isBotMessage(args.model, container.currentUser)
);
}
@service currentUser;
STORE_NAMESPACE = "discourse_ai_persona_selector_";
preferredPersonaStore = new KeyValueStore(this.STORE_NAMESPACE);
constructor() {
super(...arguments);
if (this.botOptions && this.composer) {
let personaId = this.preferredPersonaStore.getObject("id");
this._value = this.botOptions[0].id;
if (personaId) {
personaId = parseInt(personaId, 10);
if (this.botOptions.any((bot) => bot.id === personaId)) {
this._value = personaId;
}
}
this.composer.metaData = { ai_persona_id: this._value };
}
}
get composer() {
return this.args?.outletArgs?.model;
}
get botOptions() {
if (this.currentUser.ai_enabled_personas) {
return this.currentUser.ai_enabled_personas.map((persona) => {
return {
id: persona.id,
name: persona.name,
description: persona.description,
};
});
}
}
get value() {
return this._value;
}
set value(newValue) {
this._value = newValue;
this.preferredPersonaStore.setObject({ key: "id", value: newValue });
this.composer.metaData = { ai_persona_id: newValue };
}
<template>
<div class="gpt-persona">
<DropdownSelectBox
class="persona-selector__dropdown"
@value={{this.value}}
@content={{this.botOptions}}
@options={{hash icon="robot"}}
/>
</div>
</template>
}
@@ -1,7 +0,0 @@
<div class="gpt-persona">
<DropdownSelectBox
@value={{this.value}}
@content={{this.botOptions}}
@options={{hash icon="robot"}}
/>
</div>
@@ -1,53 +0,0 @@
import Component from "@glimmer/component";
import { inject as service } from "@ember/service";
function isBotMessage(composer, currentUser) {
if (
composer &&
composer.targetRecipients &&
currentUser.ai_enabled_chat_bots
) {
const reciepients = composer.targetRecipients.split(",");
return currentUser.ai_enabled_chat_bots.any((bot) =>
reciepients.any((username) => username === bot.username)
);
}
return false;
}
export default class BotSelector extends Component {
static shouldRender(args, container) {
return (
container?.currentUser?.ai_enabled_personas &&
isBotMessage(args.model, container.currentUser)
);
}
@service currentUser;
get composer() {
return this.args?.outletArgs?.model;
}
get botOptions() {
if (this.currentUser.ai_enabled_personas) {
return this.currentUser.ai_enabled_personas.map((persona) => {
return {
id: persona.name,
name: persona.name,
description: persona.description,
};
});
}
}
get value() {
return this._value || this.botOptions[0].id;
}
set value(val) {
this._value = val;
this.composer.metaData = { ai_persona: val };
}
}
@@ -0,0 +1,183 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import didUpdate from "@ember/render-modifiers/modifiers/did-update";
import { inject as service } from "@ember/service";
import DToggleSwitch from "discourse/components/d-toggle-switch";
import { SEARCH_TYPE_DEFAULT } from "discourse/controllers/full-page-search";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { withPluginApi } from "discourse/lib/plugin-api";
import { isValidSearchTerm, translateResults } from "discourse/lib/search";
import icon from "discourse-common/helpers/d-icon";
import I18n from "I18n";
export default class SemanticSearch extends Component {
static shouldRender(_args, { siteSettings }) {
return siteSettings.ai_embeddings_semantic_search_enabled;
}
@service appEvents;
@service siteSettings;
@service searchPreferencesManager;
@tracked searching = false;
@tracked AIResults = [];
@tracked showingAIResults = false;
@tracked preventAISearch = false;
initialSearchTerm = this.args.outletArgs.search;
get disableToggleSwitch() {
if (this.searching || this.AIResults.length === 0 || this.preventAISearch) {
return true;
}
}
get searchStateText() {
if (this.preventAISearch) {
return I18n.t("discourse_ai.embeddings.semantic_search_disabled_sort");
}
if (this.searching) {
return I18n.t("discourse_ai.embeddings.semantic_search_loading");
}
if (this.AIResults.length === 0) {
return I18n.t("discourse_ai.embeddings.semantic_search_results.none");
}
if (this.AIResults.length > 0) {
if (this.showingAIResults) {
return I18n.t(
"discourse_ai.embeddings.semantic_search_results.toggle",
{
count: this.AIResults.length,
}
);
} else {
return I18n.t(
"discourse_ai.embeddings.semantic_search_results.toggle_hidden",
{
count: this.AIResults.length,
}
);
}
}
}
get searchTerm() {
if (this.initialSearchTerm !== this.args.outletArgs.search) {
this.initialSearchTerm = undefined;
this.resetAIResults();
}
return this.args.outletArgs.search;
}
get searchEnabled() {
return (
this.args.outletArgs.type === SEARCH_TYPE_DEFAULT &&
isValidSearchTerm(this.searchTerm, this.siteSettings)
);
}
@action
toggleAIResults() {
if (this.showingAIResults) {
this.args.outletArgs.addSearchResults([], "topic_id");
} else {
this.args.outletArgs.addSearchResults(this.AIResults, "topic_id");
}
this.showingAIResults = !this.showingAIResults;
}
@action
resetAIResults() {
this.AIResults = [];
this.showingAIResults = false;
}
@action
handleSearch() {
if (!this.searchEnabled) {
return;
}
if (this.searchPreferencesManager.sortOrder !== 0) {
this.preventAISearch = true;
return;
} else {
this.preventAISearch = false;
}
if (this.initialSearchTerm) {
return this.performHyDESearch();
}
withPluginApi("1.15.0", (api) => {
api.onAppEvent("full-page-search:trigger-search", () => {
return this.performHyDESearch();
});
});
}
performHyDESearch() {
this.searching = true;
this.resetAIResults();
ajax("/discourse-ai/embeddings/semantic-search", {
data: { q: this.searchTerm },
})
.then(async (results) => {
const model = (await translateResults(results)) || {};
if (model.posts?.length === 0) {
this.searching = false;
return;
}
model.posts.forEach((post) => {
post.generatedByAI = true;
});
this.AIResults = model.posts;
})
.catch(popupAjaxError)
.finally(() => (this.searching = false));
}
<template>
{{#if this.searchEnabled}}
<div class="semantic-search__container search-results" role="region">
<div class="semantic-search__results" {{didInsert this.handleSearch}} {{didUpdate this.handleSearch this.searchTerm}}>
<div
class="semantic-search__searching
{{if this.searching 'in-progress'}}"
>
<DToggleSwitch
disabled={{this.disableToggleSwitch}}
@state={{this.showingAIResults}}
title="AI search results hidden"
class="semantic-search__results-toggle"
{{on "click" this.toggleAIResults}}
/>
<div class="semantic-search__searching-text">
{{icon "discourse-sparkles"}}
{{this.searchStateText}}
</div>
{{#if this.searching}}
<span class="semantic-search__indicator-wave">
<span class="semantic-search__indicator-dot">.</span>
<span class="semantic-search__indicator-dot">.</span>
<span class="semantic-search__indicator-dot">.</span>
</span>
{{/if}}
</div>
</div>
</div>
{{/if}}
</template>
}
@@ -1,51 +0,0 @@
{{#if this.searchEnabled}}
<div class="semantic-search__container search-results" role="region">
<div
class="semantic-search__results"
{{did-insert this.setup}}
{{did-insert this.debouncedSearch}}
{{will-destroy this.teardown}}
>
{{#if this.searching}}
<div class="semantic-search__searching">
<div class="semantic-search__searching-text">
{{i18n "discourse_ai.embeddings.semantic_search_loading"}}
</div>
<span class="semantic-search__indicator-wave">
<span class="semantic-search__indicator-dot">.</span>
<span class="semantic-search__indicator-dot">.</span>
<span class="semantic-search__indicator-dot">.</span>
</span>
</div>
{{else}}
{{#if this.results.length}}
<div class="semantic-search__toggle-button-container">
<DButton
@translatedTitle={{this.collapsedResultsTitle}}
@translatedLabel={{this.collapsedResultsTitle}}
@action={{fn
(mut this.collapsedResults)
(not this.collapsedResults)
}}
@class="btn-flat"
@icon={{if this.collapsedResults "chevron-right" "chevron-down"}}
/>
</div>
{{#unless this.collapsedResults}}
<div class="semantic-search__entries">
<SearchResultEntries
@posts={{this.results}}
@highlightQuery={{this.highlightQuery}}
/>
</div>
{{/unless}}
{{else}}
<div class="semantic-search__searching">
{{i18n "discourse_ai.embeddings.semantic_search_results.none"}}
</div>
{{/if}}
{{/if}}
</div>
</div>
{{/if}}
@@ -1,86 +0,0 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action, computed } from "@ember/object";
import { inject as service } from "@ember/service";
import { SEARCH_TYPE_DEFAULT } from "discourse/controllers/full-page-search";
import { ajax } from "discourse/lib/ajax";
import { isValidSearchTerm, translateResults } from "discourse/lib/search";
import discourseDebounce from "discourse-common/lib/debounce";
import { bind } from "discourse-common/utils/decorators";
import I18n from "I18n";
export default class extends Component {
static shouldRender(_args, { siteSettings }) {
return siteSettings.ai_embeddings_semantic_search_enabled;
}
@service appEvents;
@service siteSettings;
@tracked searching = true;
@tracked collapsedResults = true;
@tracked results = [];
@computed("args.outletArgs.search")
get searchTerm() {
return this.args.outletArgs.search;
}
@computed("args.outletArgs.type", "searchTerm")
get searchEnabled() {
return (
this.args.outletArgs.type === SEARCH_TYPE_DEFAULT &&
isValidSearchTerm(this.searchTerm, this.siteSettings)
);
}
@computed("results")
get collapsedResultsTitle() {
return I18n.t("discourse_ai.embeddings.semantic_search_results.toggle", {
count: this.results.length,
});
}
@action
setup() {
this.appEvents.on(
"full-page-search:trigger-search",
this,
"debouncedSearch"
);
}
@action
teardown() {
this.appEvents.off(
"full-page-search:trigger-search",
this,
"debouncedSearch"
);
}
@bind
performHyDESearch() {
if (!this.searchEnabled) {
return;
}
this.searching = true;
this.collapsedResults = true;
this.results = [];
ajax("/discourse-ai/embeddings/semantic-search", {
data: { q: this.searchTerm },
})
.then(async (results) => {
const model = (await translateResults(results)) || {};
this.results = model.posts;
})
.finally(() => (this.searching = false));
}
@action
debouncedSearch() {
discourseDebounce(this, this.performHyDESearch, 500);
}
}
@@ -7,6 +7,8 @@ import { popupAjaxError } from "discourse/lib/ajax-error";
import I18n from "I18n";
import eq from "truth-helpers/helpers/eq";
import { showPostAIHelper } from "../../lib/show-ai-helper";
import not from "truth-helpers/helpers/not";
const i18n = I18n.t.bind(I18n);
@@ -20,6 +22,9 @@ export default class AIHelperOptionsMenu extends Component {
@tracked suggestion = "";
@tracked showMainButtons = true;
@tracked copyButtonIcon = "copy";
@tracked copyButtonLabel = "discourse_ai.ai_helper.post_options_menu.copy";
MENU_STATES = {
triggers: "TRIGGERS",
options: "OPTIONS",
@@ -90,6 +95,20 @@ export default class AIHelperOptionsMenu extends Component {
}
}
@action
copySuggestion() {
if (this.suggestion?.length > 0) {
navigator.clipboard.writeText(this.suggestion).then(() => {
this.copyButtonIcon = "check";
this.copyButtonLabel = "discourse_ai.ai_helper.post_options_menu.copied"
setTimeout(() => {
this.copyButtonIcon = "copy";
this.copyButtonLabel = "discourse_ai.ai_helper.post_options_menu.copy"
}, 3500);
});
}
}
async loadPrompts() {
let prompts = await ajax("/discourse-ai/ai-helper/prompts");
@@ -146,8 +165,19 @@ export default class AIHelperOptionsMenu extends Component {
/>
</div>
{{else if (eq this.menuState this.MENU_STATES.result)}}
<div class="ai-post-helper__suggestion">{{this.suggestion}}</div>
<div class="ai-post-helper__suggestion">
<div class="ai-post-helper__suggestion__text">
{{this.suggestion}}
</div>
<DButton
@class="btn-flat ai-post-helper__suggestion__copy"
@icon={{this.copyButtonIcon}}
@label={{this.copyButtonLabel}}
@action={{this.copySuggestion}}
@disabled={{not this.suggestion}}
/>
</div>
{{/if}}
</div>
</template>
}
}
@@ -0,0 +1,38 @@
import Controller from "@ember/controller";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import getURL from "discourse-common/lib/get-url";
import discourseComputed from "discourse-common/utils/decorators";
import CustomDateRangeModal from "admin/components/modal/custom-date-range";
import PeriodComputationMixin from "admin/mixins/period-computation";
export default class AdminDashboardSentiment extends Controller.extend(
PeriodComputationMixin
) {
@service modal;
@discourseComputed("startDate", "endDate")
filters(startDate, endDate) {
return { startDate, endDate };
}
_reportsForPeriodURL(period) {
return getURL(`/admin/dashboard/sentiment?period=${period}`);
}
@action
setCustomDateRange(startDate, endDate) {
this.setProperties({ startDate, endDate });
}
@action
openCustomDateRangeModal() {
this.modal.show(CustomDateRangeModal, {
model: {
startDate: this.startDate,
endDate: this.endDate,
setCustomDateRange: this.setCustomDateRange,
},
});
}
}
@@ -0,0 +1,7 @@
import DiscourseRoute from "discourse/routes/discourse";
export default DiscourseRoute.extend({
model() {
return this.modelFor("adminPlugins.discourse-ai.ai-personas");
},
});
@@ -0,0 +1,18 @@
import DiscourseRoute from "discourse/routes/discourse";
export default DiscourseRoute.extend({
async model() {
const record = this.store.createRecord("ai-persona");
// TL0
record.set("allowed_group_ids", [10]);
return record;
},
setupController(controller, model) {
this._super(controller, model);
controller.set(
"allPersonas",
this.modelFor("adminPlugins.discourse-ai.ai-personas")
);
},
});
@@ -0,0 +1,17 @@
import DiscourseRoute from "discourse/routes/discourse";
export default DiscourseRoute.extend({
async model(params) {
const allPersonas = this.modelFor("adminPlugins.discourse-ai.ai-personas");
const id = parseInt(params.id, 10);
return allPersonas.findBy("id", id);
},
setupController(controller, model) {
this._super(controller, model);
controller.set(
"allPersonas",
this.modelFor("adminPlugins.discourse-ai.ai-personas")
);
},
});

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