Compare commits

...
Author SHA1 Message Date
Nat f10af7b167 DEV: Remove the short-lived prompt for locale detection 2024-11-28 20:18:55 +08:00
Keegan George 54ebbbe769 UX: Ensure suggestions are left aligned (#971)
This PR ensures that suggestions are aligned to the left. By default, they are center aligned which looks odd when there are long lines of text.
2024-11-27 20:53:30 -08:00
Keegan George 8b33ba3d19 FIX: Suggestion menus not showing in mobile (#970)
The suggestion menu z-index was not enough for mobile so it isn't being shown when it's used in `modalForMobile` bottom menus.
2024-11-27 19:51:03 -08:00
Keegan George f1c7ee8624 DEV: Better control what prompts can appear in post/composer (#969)
This PR updates the logic for the location map so it permits only the desired prompts through to the composer/post menu. Anything else won't be shown by default.

This PR also adds relevant tests to prevent regression.
2024-11-27 16:14:21 -08:00
Keegan George 4da033c667 FIX: Double render error with thumbnail suggestions (#968)
This PR fixes a bug where a double render error appears in the logs when thumbnails are suggested
2024-11-27 15:12:27 -08:00
Keegan George dabef02919 DEV: Prevent detect_text_locale from appearing in menus (#967)
### 🔍 Overview
With the recent changes to allow DiscourseAi in the translator plugin, `detect_text_locale` was needed as a CompletionPrompt. However, it is leaking into composer/post helper menus. This PR ensures we don't not show it in those menus.
2024-11-28 09:27:08 +11:00
Sam fbcc8e493a FEATURE: Skip PM scanning in LLM triage by default (#966)
Usually people do not want to scan personal messages. Sometimes
they may. In that case they can enable triage on personal messages.
2024-11-28 09:25:29 +11:00
Keegan George 6b7d7c1179 REFACTOR: Helper suggestions (#914)
This PR adds some updates to the Helper suggestions to improve it's functionality and modernize some of the codebase.
2024-11-27 12:21:03 -08:00
Rafael dos Santos SilvaandRoman Rizzi 0d3e6b2726 FIX: Fix ordering of random post embeddings backfill (#965)
* FIX: Fix ordering of random post embeddings backfill

* fix annotations

---------

Co-authored-by: Roman Rizzi <[email protected]>
2024-11-27 17:01:54 -03:00
Martin Brennan be05e28679 UX: Add table style for LLM list (#963)
Followup 2f7895bb91,
I forgot to commit this minor change
2024-11-27 13:45:01 +10:00
Martin Brennan 2f7895bb91 UX: Applying more admin UI guidelines (#956)
This commit applies further admin UI guidelines, now that they have been more
fleshed out in core, to the AI admin UI:

* Tools
* LLMs
* Personas

The changes include but are not limited to:

* Applying the table CSS classes, for desktop and mobile
* Adding a description and learn more link for each tab
* Adding an empty list placeholder with CTA using `AdminConfigAreaEmptyList`
* Replacing custom headings with `AdminPageSubheader`
2024-11-27 13:34:56 +10:00
Discourse Translator Bot 7a094cde18 Update translations (#962) 2024-11-26 23:15:10 +01:00
Roman Rizzi 251628bfa1 FIX: Shutdown embeddings thread pool after processing (#961) 2024-11-26 18:12:03 -03:00
Roman Rizzi ef07fcb308 FIX: Skip records without content to classify (#960) 2024-11-26 15:54:20 -03:00
Roman Rizzi ddf2bf7034 DEV: Backfill embeddings concurrently. (#941)
We are adding a new method for generating and storing embeddings in bulk, which relies on `Concurrent::Promises::Future`. Generating an embedding consists of three steps:

Prepare text
HTTP call to retrieve the vector
Save to DB.
Each one is independently executed on whatever thread the pool gives us.

We are bringing a custom thread pool instead of the global executor since we want control over how many threads we spawn to limit concurrency. We also avoid firing thousands of HTTP requests when working with large batches.
2024-11-26 14:12:32 -03:00
Rafael dos Santos Silva 23193ee6f2 FEATURE: Calculate gists from non hot topics too (#958)
Also renames some settings to remove 'hot' references.
2024-11-26 13:44:12 -03:00
Sam 54f2d34ccb DEV: skip flakey spec (#955)
This spec fails inconsistently with:

-fragment-n14
       +You are a helpful Discourse assistant.
       +You _understand_ and **generate** Discourse Markdown.
       +You live in a Discourse Forum Message.
       +
       +You live in the forum with the URL: http://test.localhost
       +The title of your site: test site title
       +The description is: test site description
       +The participants in this conversation are: joe, jane
       +The date now is: 2024-11-25 20:23:02 UTC, much has changed since you were trained.
       +
       +You were trained on OLD data, lean on search to get up to date information about this forum
       +When searching try to SIMPLIFY search terms
       +Discourse search joins all terms with AND. Reduce and simplify terms to find more results.<guidance>
       +The following texts will give you additional guidance for your response.
       +We included them because we believe they are relevant to this conversation topic.
       +
       +Texts:
       +
       +fragment-n10
       +fragment-n9
       +fragment-n8
       +fragment-n7
       +fragment-n6
       +fragment-n5
       +fragment-n4
       +fragment-n3
       +fragment-n2
       +fragment-n1
       +</guidance>
2024-11-26 07:49:33 +11:00
Sam 616b990894 FEATURE: LLM mentions and auto silence (#949)
* FEATURE: allow mentioning an LLM mid conversation to switch

This is a edgecase feature that allow you to start a conversation
in a PM with LLM1 and then use LLM2 to evaluation or continue
the conversation

* FEATURE: allow auto silencing of spam accounts

New rule can also allow for silencing an account automatically

This can prevent spammers from creating additional posts.
2024-11-26 07:19:56 +11:00
Rafael dos Santos Silva 6c25718a7f FEATURE: Add links to filtered emotion view on emotion dashboard table (#953) 2024-11-25 15:51:01 -03:00
Roman Rizzi 79021252e9 REFACTOR: Tidy-up embedding endpoints config. (#937)
Two changes worth mentioning:

`#instance` returns a fully configured embedding endpoint ready to use.
All endpoints respond to the same method and have the same signature - `perform!(text)`

This makes it easier to reuse them when generating embeddings in bulk.
2024-11-25 13:12:43 -03:00
Rafael dos Santos Silva aeba72441d FIX: Switch up start/end dates in emotion dashboard report (#951) 2024-11-25 12:25:48 -03:00
Roman Rizzi 95762723de PERF: Preload only gists when including summaries in topic list (#948)
* PERF: Preload only gists when including summaries in topic list

* Add unique index on summaries and dedup existing records

* Make hot topics batch size setting hidden
2024-11-25 12:24:02 -03:00
Rafael dos Santos Silva 5fb1177f7b FEATURE: Refinements to Emotion in dashboard (#947)
* FEATURE: Refinements to Emotion in dashboard

- Added descriptions to individual reports
- Made reports work with data older than 60 days
2024-11-25 11:31:51 -03:00
David Taylor ea24328415 FIX: Correctly register 'info' icon (#950)
This icon is not part of the core set. Previously, d-ai was relying on other installed plugins (e.g. data-explorer) to include the icon
2024-11-25 10:20:17 -03:00
Natalie Tay f8231d259b FEATURE: Add locale detection prompt from translator (#946) 2024-11-25 08:33:54 +11:00
Roman Rizzi e54f2da1a5 FIX: Unnecessary complex preloading accidentally filters some topics. (#945)
The `topic_query_create_list_topics` modifier we append was always meant to avoid an N+1 situation when serializing gists. However, I tried to be too smart and only preload these, which resulted in some topics with *only* regular summaries getting removed from the list. This issue became apparent now we are adding gists to other lists besides hot.

Let's simplify the preloading, which still solves the N+1 issue, and let the serializer get the needed summary.
2024-11-22 12:07:27 -03:00
Rafael dos Santos Silva 1921e4e90d UX: Add emojis to the admin dash emotion table (#944) 2024-11-22 12:02:18 -03:00
Joffrey JAFFEUX 2cc8115b48 FIX: disables temporarily ai_summaries filtering (#943) 2024-11-22 08:34:54 +01:00
Sam 86cf4ccba7 FIX: automatically bust cache for share ai assets (#942)
* FIX: automatically bust cache for share ai assets

CDNs can be configured to strip query params in Discourse
hosting. This is generally safe, but in this case we had
no way of busting the cache using the path.

New design properly caches and properly breaks busts the
cache if asset changes so we don't need to worry about versions

* one day I will set up conditional lint on save :)
2024-11-22 11:23:15 +11:00
Rafael dos Santos Silva 2c78961bed FIX: Properly capture period for /filter emotion ordering (#940) 2024-11-21 16:54:30 -03:00
Rafael dos Santos Silva 8e00e036e1 FEATURE: Make emotion /filter ordering match the dashboard table (#939)
* FEATURE: Make emotion /filter ordering match the dashboard table

This change makes the /filter endpoint use the same criteria we use
in the dashboard table for emotion, so it is not confusing for users.
It means that only posts made in the period with the emotion shall be
shown in the /filter, and the order is simply a count of posts that
match the emotion in the period.

It also uses a trick to extract the filter period, and apply it to
the CTE clause that calculates post emotion count on the period, making
it a bit more efficient. Downside is that /filter filters are evaluated
from left to right, so it will only get the speed-up if the emotion
order is last. As we do this on the dashboard table, it should cover
most uses of the ordering, kicking the need for materialized views
down the road.

* Remove zero score in filter

* add table tooltip

* lint
2024-11-21 15:18:31 -03:00
Sam d56ed53eb1 FIX: cancel functionality regressed (#938)
The cancel messaging was not floating correctly to the HTTP call leading to impossible to cancel completions 

This is now fully tested as well.
2024-11-21 17:51:45 +11:00
Kris d83248cf68 UX: force long words to break in topic list gists (#936) 2024-11-20 15:49:20 -05:00
David Taylor 872e43e849 DEV: Update linting (#935) 2024-11-20 18:50:50 +01:00
David Taylor 2900d2c87d DEV: Update linting (#933) 2024-11-20 15:43:28 +01:00
Kris b68a4ca718 UX: ensure gists occupy full row (#934) 2024-11-20 09:33:49 -05:00
David Taylor 1a10680818 DEV: Replace DSection with body-class helper (#924) 2024-11-20 14:11:15 +00:00
Sam 52c644798d DEV: improve artifact presentation (#932)
1. Keep source in a "details" block after rendered so it does
not overwhelm users

2. Ensure artifacts are never indexed by robots

3. Cache break our CSS that changed recently
2024-11-20 18:53:19 +11:00
Sam 2652716398 UX: improve artifact styling add direct share link (#930)
Also remove uneeded sandboxing give this is all handled by
artifacts directly
2024-11-20 13:13:03 +11:00
Sam a0aec48606 FIX: gists are not html safe (#931)
Also allow "Everyone" in ai_hot_topic_gists_allowed_groups
2024-11-20 10:54:49 +11:00
Discourse Translator Bot f09e74c05e Update translations (#929) 2024-11-20 00:21:34 +01:00
Rafael dos Santos Silva 2c8d81827f FIX: Misc fixes for sentiment in the admin dashboard (#928)
* FIX: Misc fixes for sentiment in the admin dashboard

- Fixes missing filters for the main graph

- Fixes previous 30 days trend in emotion table

Also moves links to individual cells in emotion table, so admins can
drill down to the specific time period on their reports.

* lints
2024-11-19 19:16:21 -03:00
Kris a9afa04329 UX: update gist toggle styles (#926) 2024-11-19 15:33:34 -05:00
Roman Rizzi 530a795d43 FIX: Instruct AR that we want to use ai_summaries for filtering. (#927)
We use `includes` instead of `joins` because we want to eager-load summaries, avoiding an extra query when summarizing. However, Rails will complain unless you explicitly inform them you plan to use that inside a `WHERE` clause.
2024-11-19 17:32:13 -03:00
Roman Rizzi dcde94a393 FIX: Reduce scope of topic gists inclusion. (#925)
The topic query is used differently, and we can't assume the modifier will always receive an AR relation. Let's scope it to `Discourse#filters` instead of most lists.
2024-11-19 15:37:19 -03:00
Roman Rizzi 3c91f374ac FIX: Skip gists from PM topic lists (#923) 2024-11-19 12:51:19 -03:00
Roman Rizzi fb80d776d8 FEATURE: Enable gists on all topic lists (#922) 2024-11-19 11:04:34 -03:00
Rafael dos Santos Silva 48d08dedd4 FEATURE: Emotion activity metrics table (#916) 2024-11-19 10:01:10 -03:00
David Taylor b10be23533 FIX: Ensure artifacts are sandboxed, even when visited directly (#921)
It's important that artifacts are never given 'same origin' access to the forum domain, so that they cannot access cookies, or make authenticated HTTP requests. So even when visiting the URL directly, we need to wrap them in a sandboxed iframe.
2024-11-19 11:44:17 +00:00
David Taylor 6b9c66054c DEV: Update eslint config (#917)
* DEV: Update eslint config

* fixup

* pnpm upgrade
2024-11-19 11:57:40 +01:00
Sam 3ae1e4eaf0 FIX: properly bypass CSP for artifacts (#920)
Was meant to be bypassed but was not implemented correctly
2024-11-19 20:25:07 +11:00
Sam 755b63f31f FEATURE: Add support for Mistral models (#919)
Adds support for mistral models (pixtral and mistral large now have presets)

Also corrects token accounting in AWS bedrock models
2024-11-19 17:28:09 +11:00
Sam 0d7f353284 FEATURE: AI artifacts (#898)
This is a significant PR that introduces AI Artifacts functionality to the discourse-ai plugin along with several other improvements. Here are the key changes:

1. AI Artifacts System:
   - Adds a new `AiArtifact` model and database migration
   - Allows creation of web artifacts with HTML, CSS, and JavaScript content
   - Introduces security settings (`strict`, `lax`, `disabled`) for controlling artifact execution
   - Implements artifact rendering in iframes with sandbox protection
   - New `CreateArtifact` tool for AI to generate interactive content

2. Tool System Improvements:
   - Adds support for partial tool calls, allowing incremental updates during generation
   - Better handling of tool call states and progress tracking
   - Improved XML tool processing with CDATA support
   - Fixes for tool parameter handling and duplicate invocations

3. LLM Provider Updates:
   - Updates for Anthropic Claude models with correct token limits
   - Adds support for native/XML tool modes in Gemini integration
   - Adds new model configurations including Llama 3.1 models
   - Improvements to streaming response handling

4. UI Enhancements:
   - New artifact viewer component with expand/collapse functionality
   - Security controls for artifact execution (click-to-run in strict mode)
   - Improved dialog and response handling
   - Better error management for tool execution

5. Security Improvements:
   - Sandbox controls for artifact execution
   - Public/private artifact sharing controls
   - Security settings to control artifact behavior
   - CSP and frame-options handling for artifacts

6. Technical Improvements:
   - Better post streaming implementation
   - Improved error handling in completions
   - Better memory management for partial tool calls
   - Enhanced testing coverage

7. Configuration:
   - New site settings for artifact security
   - Extended LLM model configurations
   - Additional tool configuration options

This PR significantly enhances the plugin's capabilities for generating and displaying interactive content while maintaining security and providing flexible configuration options for administrators.
2024-11-19 09:22:39 +11:00
Rafael dos Santos Silva 4fb686a548 FIX: Move emotion /filter logic into a CTE to keep cardinality sane (#915) 2024-11-14 17:16:48 -03:00
Rafael dos Santos Silva 5026ab52d0 FEATURE: Order by emotion on /filter (#913) 2024-11-14 12:45:40 -03:00
Sam 823e8ef490 FEATURE: partial tool call support for OpenAI and Anthropic (#908)
Implement streaming tool call implementation for Anthropic and Open AI.

When calling:

llm.generate(..., partial_tool_calls: true) do ...
Partials may contain ToolCall instances with partial: true, These tool calls are partially populated with json partially parsed.

So for example when performing a search you may get:

ToolCall(..., {search: "hello" })
ToolCall(..., {search: "hello world" })

The library used to parse json is:

https://github.com/dgraham/json-stream

We use a fork cause we need access to the internal buffer.

This prepares internals to perform partial tool calls, but does not implement it yet.
2024-11-14 06:58:24 +11:00
Keegan George f75b13c4fa FIX: results not being reset when appending to query param (#912)
This PR fixes an issue where the AI search results were not being reset when you append your search to an existing query param (typically when you've come from quick search). This is because `handleSearch()` doesn't get called in this situation. So here we explicitly check for query params, trigger a reset and search for those occasions.
2024-11-13 07:19:34 -08:00
268 changed files with 7192 additions and 3546 deletions
-1
View File
@@ -1 +0,0 @@
module.exports = require("@discourse/lint-configs/eslint");
@@ -1,5 +1,7 @@
<AiToolEditor
@tools={{this.allTools}}
@model={{this.model}}
@presets={{this.presets}}
/>
<section class="ai-persona-tool-editor__current admin-detail pull-left">
<AiToolEditor
@tools={{this.allTools}}
@model={{this.model}}
@presets={{this.presets}}
/>
</section>
@@ -1,5 +1,7 @@
<AiToolEditor
@tools={{this.allTools}}
@model={{this.model}}
@presets={{this.presets}}
/>
<section class="ai-persona-tool-editor__current admin-detail pull-left">
<AiToolEditor
@tools={{this.allTools}}
@model={{this.model}}
@presets={{this.presets}}
/>
</section>
@@ -0,0 +1,87 @@
# frozen_string_literal: true
module DiscourseAi
module AiBot
class ArtifactsController < ApplicationController
requires_plugin DiscourseAi::PLUGIN_NAME
before_action :require_site_settings!
skip_before_action :preload_json, :check_xhr, only: %i[show]
def show
artifact = AiArtifact.find(params[:id])
post = Post.find_by(id: artifact.post_id)
if artifact.metadata&.dig("public")
# no guardian needed
else
raise Discourse::NotFound if !post&.topic&.private_message?
raise Discourse::NotFound if !guardian.can_see?(post)
end
# Prepare the inner (untrusted) HTML document
untrusted_html = <<~HTML
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>#{ERB::Util.html_escape(artifact.name)}</title>
<style>
#{artifact.css}
</style>
</head>
<body>
#{artifact.html}
<script>
#{artifact.js}
</script>
</body>
</html>
HTML
# Prepare the outer (trusted) HTML document
trusted_html = <<~HTML
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>#{ERB::Util.html_escape(artifact.name)}</title>
<style>
html, body, iframe {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
border: 0;
overflow: hidden;
}
iframe {
overflow: auto;
}
</style>
</head>
<body>
<iframe sandbox="allow-scripts allow-forms" height="100%" width="100%" srcdoc="#{ERB::Util.html_escape(untrusted_html)}" frameborder="0"></iframe>
</body>
</html>
HTML
response.headers.delete("X-Frame-Options")
response.headers["Content-Security-Policy"] = "script-src 'unsafe-inline';"
response.headers["X-Robots-Tag"] = "noindex"
# Render the content
render html: trusted_html.html_safe, layout: false, content_type: "text/html"
end
private
def require_site_settings!
if !SiteSetting.discourse_ai_enabled ||
!SiteSetting.ai_artifact_security.in?(%w[lax strict])
raise Discourse::NotFound
end
end
end
end
end
@@ -7,7 +7,8 @@ module DiscourseAi
requires_login only: %i[create update destroy]
before_action :require_site_settings!
skip_before_action :preload_json, :check_xhr, only: %i[show]
skip_before_action :preload_json, :check_xhr, only: %i[show asset]
skip_before_action :verify_authenticity_token, only: ["asset"]
def create
ensure_allowed_create!
@@ -50,6 +51,30 @@ module DiscourseAi
end
end
def asset
no_cookies
name = params[:name]
path, content_type =
if name == "share"
%w[share.css text/css]
elsif name == "highlight"
%w[highlight.min.js application/javascript]
else
raise Discourse::NotFound
end
content = File.read(DiscourseAi.public_asset_path("ai-share/#{path}"))
# note, path contains a ":version" which automatically busts the cache
# based on file content, so this is safe
response.headers["Last-Modified"] = 10.years.ago.httpdate
response.headers["Content-Length"] = content.bytesize.to_s
immutable_for 1.year
render plain: content, disposition: :nil, content_type: content_type
end
def preview
ensure_allowed_preview!
data = SharedAiConversation.build_conversation_data(@topic, include_usernames: true)
@@ -35,7 +35,7 @@ module DiscourseAi
prompt.custom_instruction = params[:custom_prompt]
end
suggest_thumbnails(input) if prompt.id == CompletionPrompt::ILLUSTRATE_POST
return suggest_thumbnails(input) if prompt.id == CompletionPrompt::ILLUSTRATE_POST
hijack do
render json:
@@ -2,13 +2,29 @@
module DiscourseAi
module AiBot
module SharedAiConversationsHelper
# bump up version when assets change
# long term we may want to change this cause it is hard to remember
# to bump versions, but for now this does the job
VERSION = "1"
# keeping it here for caching
def self.share_asset_url(asset_name)
if !%w[share.css highlight.js].include?(asset_name)
raise StandardError, "unknown asset type #{asset_name}"
end
def share_asset_url(short_path)
::UrlHelper.absolute("/plugins/discourse-ai/ai-share/#{short_path}?#{VERSION}")
@urls ||= {}
url = @urls[asset_name]
return url if url
path = asset_name
path = "highlight.min.js" if asset_name == "highlight.js"
content = File.read(DiscourseAi.public_asset_path("ai-share/#{path}"))
sha1 = Digest::SHA1.hexdigest(content)
url = "/discourse-ai/ai-bot/shared-ai-conversations/asset/#{sha1}/#{asset_name}"
@urls[asset_name] = GlobalPath.cdn_path(url)
end
def share_asset_url(asset_name)
DiscourseAi::AiBot::SharedAiConversationsHelper.share_asset_url(asset_name)
end
end
end
@@ -1,23 +1,20 @@
# frozen_string_literal: true
module ::Jobs
class UpdateHotTopicGist < ::Jobs::Base
class FastTrackTopicGist < ::Jobs::Base
sidekiq_options retry: false
def execute(args)
return if !SiteSetting.discourse_ai_enabled
return if !SiteSetting.ai_summarization_enabled
return if SiteSetting.ai_summarize_max_hot_topics_gists_per_batch.zero?
return if !SiteSetting.ai_summary_gists_enabled
topic = Topic.find_by(id: args[:topic_id])
return if topic.blank?
return if !TopicHotScore.where(topic: topic).exists?
summarizer = DiscourseAi::Summarization.topic_gist(topic)
gist = summarizer.existing_summary
return if gist.blank?
return if !gist.outdated
return if gist.present? && !gist.outdated
summarizer.force_summarize(Discourse.system_user)
end
-22
View File
@@ -1,22 +0,0 @@
# frozen_string_literal: true
module ::Jobs
class HotTopicsGistBatch < ::Jobs::Base
def execute(_args)
return if !SiteSetting.discourse_ai_enabled
return if !SiteSetting.ai_summarization_enabled
return if SiteSetting.ai_summarize_max_hot_topics_gists_per_batch.zero?
Topic
.joins("JOIN topic_hot_scores on topics.id = topic_hot_scores.topic_id")
.order("topic_hot_scores.score DESC")
.limit(SiteSetting.ai_summarize_max_hot_topics_gists_per_batch)
.each do |topic|
summarizer = DiscourseAi::Summarization.topic_gist(topic)
gist = summarizer.existing_summary
summarizer.force_summarize(Discourse.system_user) if gist.blank? || gist.outdated
end
end
end
end
+25 -21
View File
@@ -70,14 +70,15 @@ module Jobs
Post
.joins("LEFT JOIN #{table_name} ON #{table_name}.post_id = posts.id")
.where(deleted_at: nil)
.where(post_type: Post.types[:regular])
.limit(limit - rebaked)
# First, we'll try to backfill embeddings for posts that have none
posts
.where("#{table_name}.post_id IS NULL")
.find_each do |t|
vector_rep.generate_representation_from(t)
rebaked += 1
.find_in_batches do |batch|
vector_rep.gen_bulk_reprensentations(batch)
rebaked += batch.size
end
return if rebaked >= limit
@@ -90,24 +91,28 @@ module Jobs
OR
#{table_name}.strategy_version < #{strategy.version}
SQL
.find_each do |t|
vector_rep.generate_representation_from(t)
rebaked += 1
.find_in_batches do |batch|
vector_rep.gen_bulk_reprensentations(batch)
rebaked += batch.size
end
return if rebaked >= limit
# Finally, we'll try to backfill embeddings for posts that have outdated
# embeddings due to edits. Here we only do 10% of the limit
posts
.where("#{table_name}.updated_at < ?", 7.days.ago)
.order("random()")
.limit((limit - rebaked) / 10)
.pluck(:id)
.each do |id|
vector_rep.generate_representation_from(Post.find_by(id: id))
rebaked += 1
end
posts_batch_size = 1000
outdated_post_ids =
posts
.where("#{table_name}.updated_at < ?", 7.days.ago)
.order("random()")
.limit((limit - rebaked) / 10)
.pluck(:id)
outdated_post_ids.each_slice(posts_batch_size) do |batch|
vector_rep.gen_bulk_reprensentations(Post.where(id: batch))
rebaked += batch.length
end
rebaked
end
@@ -120,14 +125,13 @@ module Jobs
topics = topics.where("#{vector_rep.topic_table_name}.topic_id IS NULL") if !force
ids = topics.pluck("topics.id")
batch_size = 1000
ids.each do |id|
topic = Topic.find_by(id: id)
if topic
vector_rep.generate_representation_from(topic)
done += 1
end
ids.each_slice(batch_size) do |batch|
vector_rep.gen_bulk_reprensentations(Topic.where(id: batch).order("topics.bumped_at DESC"))
done += batch.length
end
done
end
end
+5 -3
View File
@@ -19,6 +19,8 @@ module ::Jobs
DiscourseAi::Summarization.topic_summary(topic).force_summarize(system_user)
end
return unless SiteSetting.ai_summary_gists_enabled
gist_t = AiSummary.summary_types[:gist]
backfill_candidates(gist_t)
.limit(current_budget(gist_t))
@@ -29,9 +31,9 @@ module ::Jobs
Topic
.where("topics.word_count >= ?", SiteSetting.ai_summary_backfill_minimum_word_count)
.joins(<<~SQL)
LEFT OUTER JOIN ai_summaries ais ON
topics.id = ais.target_id AND
ais.target_type = 'Topic' AND
LEFT OUTER JOIN ai_summaries ais ON
topics.id = ais.target_id AND
ais.target_type = 'Topic' AND
ais.summary_type = '#{summary_type}'
SQL
.where(
+1
View File
@@ -13,6 +13,7 @@ class AiApiAuditLog < ActiveRecord::Base
Cohere = 6
Ollama = 7
SambaNova = 8
Mistral = 9
end
def next_log_id
+52
View File
@@ -0,0 +1,52 @@
# frozen_string_literal: true
class AiArtifact < ActiveRecord::Base
belongs_to :user
belongs_to :post
validates :html, length: { maximum: 65_535 }
validates :css, length: { maximum: 65_535 }
validates :js, length: { maximum: 65_535 }
def self.iframe_for(id)
<<~HTML
<div class='ai-artifact'>
<iframe src='#{url(id)}' frameborder="0" height="100%" width="100%"></iframe>
<a href='#{url(id)}' target='_blank'>#{I18n.t("discourse_ai.ai_artifact.link")}</a>
</div>
HTML
end
def self.url(id)
Discourse.base_url + "/discourse-ai/ai-bot/artifacts/#{id}"
end
def self.share_publicly(id:, post:)
artifact = AiArtifact.find_by(id: id)
artifact.update!(metadata: { public: true }) if artifact&.post&.topic&.id == post.topic.id
end
def self.unshare_publicly(id:)
artifact = AiArtifact.find_by(id: id)
artifact&.update!(metadata: { public: false })
end
def url
self.class.url(id)
end
end
# == Schema Information
#
# Table name: ai_artifacts
#
# id :bigint not null, primary key
# user_id :integer not null
# post_id :integer not null
# name :string(255) not null
# html :string(65535)
# css :string(65535)
# js :string(65535)
# metadata :jsonb
# created_at :datetime not null
# updated_at :datetime not null
#
+2 -1
View File
@@ -51,5 +51,6 @@ end
#
# Indexes
#
# index_ai_summaries_on_target_type_and_target_id (target_type,target_id)
# idx_on_target_id_target_type_summary_type_3355609fbb (target_id,target_type,summary_type) UNIQUE
# index_ai_summaries_on_target_type_and_target_id (target_type,target_id)
#
+10
View File
@@ -26,6 +26,16 @@ class LlmModel < ActiveRecord::Base
},
open_ai: {
organization: :text,
disable_native_tools: :checkbox,
},
mistral: {
disable_native_tools: :checkbox,
},
google: {
disable_native_tools: :checkbox,
},
azure: {
disable_native_tools: :checkbox,
},
hugging_face: {
disable_system_prompt: :checkbox,
+1
View File
@@ -172,6 +172,7 @@ end
# updated_at :datetime not null
# force_review :boolean default(FALSE), not null
# reject_reason :text
# potentially_illegal :boolean default(FALSE)
#
# Indexes
#
+1
View File
@@ -229,6 +229,7 @@ end
# updated_at :datetime not null
# force_review :boolean default(FALSE), not null
# reject_reason :text
# potentially_illegal :boolean default(FALSE)
#
# Indexes
#
+25 -1
View File
@@ -34,6 +34,12 @@ class SharedAiConversation < ActiveRecord::Base
def self.destroy_conversation(conversation)
conversation.destroy
maybe_topic = conversation.target
if maybe_topic.is_a?(Topic)
AiArtifact.where(post: maybe_topic.posts).update_all(metadata: { public: false })
end
::Jobs.enqueue(
:shared_conversation_adjust_upload_security,
target_id: conversation.target_id,
@@ -165,7 +171,7 @@ class SharedAiConversation < ActiveRecord::Base
id: post.id,
user_id: post.user_id,
created_at: post.created_at,
cooked: post.cooked,
cooked: cook_artifacts(post),
}
mapped[:persona] = persona if ai_bot_participant&.id == post.user_id
@@ -175,6 +181,24 @@ class SharedAiConversation < ActiveRecord::Base
}
end
def self.cook_artifacts(post)
html = post.cooked
return html if !%w[lax strict].include?(SiteSetting.ai_artifact_security)
doc = Nokogiri::HTML5.fragment(html)
doc
.css("div.ai-artifact")
.each do |node|
id = node["data-ai-artifact-id"].to_i
if id > 0
AiArtifact.share_publicly(id: id, post: post)
node.replace(AiArtifact.iframe_for(id))
end
end
doc.to_s
end
private
def populate_user_info!(posts)
@@ -10,7 +10,7 @@
<meta name="twitter:title" content="<%= I18n.t("discourse_ai.share_ai.title", title: @shared_conversation.title, site_name: SiteSetting.title) %>">
<meta name="twitter:description" content="<%= @shared_conversation.formatted_excerpt %>">
<meta name="viewport" content="width=device-width">
<link rel="stylesheet" href="<%= ::UrlHelper.absolute("/plugins/discourse-ai/ai-share/share.css") %>">
<link rel="stylesheet" href="<%= share_asset_url("share.css") %>">
</head>
<body>
<header class="site-header">
@@ -54,7 +54,7 @@
</article>
<% end %>
</section>
<script src="<%= share_asset_url("highlight.min.js") %>" nonce="<%= csp_nonce_placeholder %>" ></script>
<script src="<%= share_asset_url("highlight.js") %>" nonce="<%= csp_nonce_placeholder %>" ></script>
<script nonce="<%= csp_nonce_placeholder %>">
document.querySelectorAll('pre code').forEach((el) => {
hljs.highlightElement(el);
@@ -0,0 +1,45 @@
<div class="cell title">
{{#if this.model.icon}}
{{d-icon this.model.icon}}
{{/if}}
{{this.model.title}}
</div>
<div class="cell value today-count">
<a href={{this.todayLink}}>
{{number this.model.todayCount}}
</a>
</div>
<div
class="cell value yesterday-count {{this.model.yesterdayTrend}}"
title={{this.model.yesterdayCountTitle}}
>
<a href={{this.yesterdayLink}}>
{{number this.model.yesterdayCount}}
</a>
{{d-icon this.model.yesterdayTrendIcon}}
</div>
<div
class="cell value sevendays-count {{this.model.sevenDaysTrend}}"
title={{this.model.sevenDaysCountTitle}}
>
<a href={{this.lastSevenDaysLink}}>
{{number this.model.lastSevenDaysCount}}
</a>
{{d-icon this.model.sevenDaysTrendIcon}}
</div>
<div
class="cell value thirty-days-count {{this.model.thirtyDaysTrend}}"
title={{this.model.thirtyDaysCountTitle}}
>
<a href={{this.lastThirtyDaysLink}}>
{{number this.model.lastThirtyDaysCount}}
</a>
{{#if this.model.canDisplayTrendIcon}}
{{d-icon this.model.thirtyDaysTrendIcon}}
{{/if}}
</div>
@@ -0,0 +1,39 @@
import Component from "@ember/component";
import { attributeBindings, classNames } from "@ember-decorators/component";
import getURL from "discourse-common/lib/get-url";
@classNames("admin-report-counters")
@attributeBindings("model.description:title")
export default class AdminReportEmotion extends Component {
get todayLink() {
let date = moment().format("YYYY-MM-DD");
return this._filterURL(date);
}
get yesterdayLink() {
let date = moment().subtract(1, "day").format("YYYY-MM-DD");
return this._filterURL(date);
}
get lastSevenDaysLink() {
let date = moment().subtract(1, "week").format("YYYY-MM-DD");
return this._filterURL(date);
}
get lastThirtyDaysLink() {
let date = moment().subtract(1, "month").format("YYYY-MM-DD");
return this._filterURL(date);
}
_baseFilter() {
return "/filter?q=activity-after%3A";
}
_model() {
return "%20order%3A" + this.model.type;
}
_filterURL(date) {
return getURL(`${this._baseFilter()}${date}${this._model()}`);
}
}
@@ -0,0 +1,121 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { service } from "@ember/service";
import DButton from "discourse/components/d-button";
import htmlClass from "discourse/helpers/html-class";
import getURL from "discourse-common/lib/get-url";
export default class AiArtifactComponent extends Component {
@service siteSettings;
@tracked expanded = false;
@tracked showingArtifact = false;
constructor() {
super(...arguments);
this.keydownHandler = this.handleKeydown.bind(this);
}
willDestroy() {
super.willDestroy(...arguments);
window.removeEventListener("keydown", this.keydownHandler);
}
@action
handleKeydown(event) {
if (event.key === "Escape" || event.key === "Esc") {
this.expanded = false;
}
}
get requireClickToRun() {
if (this.showingArtifact) {
return false;
}
return this.siteSettings.ai_artifact_security === "strict";
}
get artifactUrl() {
return getURL(`/discourse-ai/ai-bot/artifacts/${this.args.artifactId}`);
}
@action
showArtifact() {
this.showingArtifact = true;
}
@action
toggleView() {
this.expanded = !this.expanded;
if (this.expanded) {
window.addEventListener("keydown", this.keydownHandler);
} else {
window.removeEventListener("keydown", this.keydownHandler);
}
}
get wrapperClasses() {
return `ai-artifact__wrapper ${
this.expanded ? "ai-artifact__expanded" : ""
}`;
}
@action
artifactPanelHover() {
// retrrigger animation
const panel = document.querySelector(".ai-artifact__panel");
panel.style.animation = "none"; // Stop the animation
setTimeout(() => {
panel.style.animation = ""; // Re-trigger the animation by removing the none style
}, 0);
}
<template>
{{#if this.expanded}}
{{htmlClass "ai-artifact-expanded"}}
{{/if}}
<div class={{this.wrapperClasses}}>
<div
class="ai-artifact__panel--wrapper"
{{on "mouseleave" this.artifactPanelHover}}
>
<div class="ai-artifact__panel">
<DButton
class="btn-flat btn-icon-text"
@icon="discourse-compress"
@label="discourse_ai.ai_artifact.collapse_view_label"
@action={{this.toggleView}}
/>
</div>
</div>
{{#if this.requireClickToRun}}
<div class="ai-artifact__click-to-run">
<DButton
class="btn btn-primary"
@icon="play"
@label="discourse_ai.ai_artifact.click_to_run_label"
@action={{this.showArtifact}}
/>
</div>
{{else}}
<iframe
title="AI Artifact"
src={{this.artifactUrl}}
width="100%"
frameborder="0"
></iframe>
{{/if}}
{{#unless this.requireClickToRun}}
<div class="ai-artifact__footer">
<DButton
class="btn-flat btn-icon-text ai-artifact__expand-button"
@icon="discourse-expand"
@label="discourse_ai.ai_artifact.expand_view_label"
@action={{this.toggleView}}
/>
</div>
{{/unless}}
</div>
</template>
}
@@ -4,35 +4,43 @@ import { action } from "@ember/object";
import { service } from "@ember/service";
import DButton from "discourse/components/d-button";
import DropdownMenu from "discourse/components/dropdown-menu";
import bodyClass from "discourse/helpers/body-class";
import icon from "discourse-common/helpers/d-icon";
import i18n from "discourse-common/helpers/i18n";
import DMenu from "float-kit/components/d-menu";
import eq from "truth-helpers/helpers/eq";
export default class AiGistToggle extends Component {
@service router;
@service gistPreference;
get shouldShow() {
return this.router.currentRoute.attributes?.list?.topics?.some(
(topic) => topic.ai_topic_gist
);
}
@service gists;
get buttons() {
return [
{
id: "gists_enabled",
label: "discourse_ai.summarization.gists_enabled_long",
icon: "discourse-sparkles",
id: "table",
label: "discourse_ai.summarization.topic_list_layout.button.compact",
icon: "discourse-table",
},
{
id: "gists_disabled",
label: "discourse_ai.summarization.gists_disabled",
icon: "far-eye-slash",
id: "table-ai",
label: "discourse_ai.summarization.topic_list_layout.button.expanded",
icon: "discourse-table-sparkles",
description:
"discourse_ai.summarization.topic_list_layout.button.expanded_description",
},
];
}
get selectedOptionId() {
return this.gists.get("preference");
}
get currentButton() {
const buttonPreference = this.buttons.find(
(button) => button.id === this.selectedOptionId
);
return buttonPreference || this.buttons[0];
}
@action
onRegisterApi(api) {
this.dMenu = api;
@@ -40,29 +48,22 @@ export default class AiGistToggle extends Component {
@action
onSelect(optionId) {
this.gistPreference.setPreference(optionId);
this.gists.setPreference(optionId);
this.dMenu.close();
}
<template>
{{#if this.shouldShow}}
{{#if this.gists.shouldShow}}
{{bodyClass (concat "topic-list-layout-" this.gists.preference)}}
<DMenu
@modalForMobile={{true}}
@autofocus={{true}}
@identifier="ai-gists-dropdown"
@identifier="topic-list-layout"
@onRegisterApi={{this.onRegisterApi}}
@triggerClass="btn-transparent"
@triggerClass="btn-default btn-icon"
>
<:trigger>
<span class="d-button-label">
{{i18n
(concat
"discourse_ai.summarization." this.gistPreference.preference
)
}}
</span>
{{icon "angle-down"}}
{{icon this.currentButton.icon}}
</:trigger>
<:content>
<DropdownMenu as |dropdown|>
@@ -71,9 +72,17 @@ export default class AiGistToggle extends Component {
<DButton
@label={{button.label}}
@icon={{button.icon}}
class="btn-transparent"
class="btn-transparent
{{if button.description '--with-description'}}
{{if (eq this.currentButton.id button.id) '--active'}}"
@action={{fn this.onSelect button.id}}
/>
>
{{#if button.description}}
<div class="btn__description">
{{i18n button.description}}
</div>
{{/if}}
</DButton>
</dropdown.item>
{{/each}}
</DropdownMenu>
@@ -6,7 +6,7 @@ import { on } from "@ember/modifier";
import { action, computed } from "@ember/object";
import { LinkTo } from "@ember/routing";
import { later } from "@ember/runloop";
import { inject as service } from "@ember/service";
import { service } from "@ember/service";
import { eq } from "truth-helpers";
import DButton from "discourse/components/d-button";
import Avatar from "discourse/helpers/bound-avatar-template";
@@ -2,9 +2,8 @@ import Component from "@glimmer/component";
import { concat, fn } from "@ember/helper";
import { action } from "@ember/object";
import { LinkTo } from "@ember/routing";
import { inject as service } from "@ember/service";
import { service } from "@ember/service";
import DBreadcrumbsItem from "discourse/components/d-breadcrumbs-item";
import icon from "discourse-common/helpers/d-icon";
import i18n from "discourse-common/helpers/i18n";
import I18n from "discourse-i18n";
import AdminPageSubheader from "admin/components/admin-page-subheader";
@@ -124,8 +123,10 @@ export default class AiLlmsListEditor extends Component {
<section class="ai-llms-list-editor__configured">
<AdminPageSubheader
@titleLabel="discourse_ai.llms.configured.title"
@descriptionLabel="discourse_ai.llms.preconfigured.description"
@learnMoreUrl="https://meta.discourse.org/t/discourse-ai-large-language-model-llm-settings-page/319903"
/>
<table>
<table class="d-admin-table">
<thead>
<tr>
<th>{{i18n "discourse_ai.llms.display_name"}}</th>
@@ -135,12 +136,19 @@ export default class AiLlmsListEditor extends Component {
</thead>
<tbody>
{{#each @llms as |llm|}}
<tr data-llm-id={{llm.name}} class="ai-llm-list__row">
<td class="column-name">
<h3>{{llm.display_name}}</h3>
<p>
<tr
data-llm-id={{llm.name}}
class="ai-llm-list__row d-admin-row__content"
>
<td class="d-admin-row__overview">
<div class="ai-llm-list__name">
<strong>
{{llm.display_name}}
</strong>
</div>
<div class="ai-llm-list__description">
{{this.modelDescription llm}}
</p>
</div>
{{#if llm.used_by}}
<ul class="ai-llm-list-editor__usages">
{{#each llm.used_by as |usage|}}
@@ -149,18 +157,20 @@ export default class AiLlmsListEditor extends Component {
</ul>
{{/if}}
</td>
<td class="column-provider">
<td class="d-admin-row__detail">
<div class="d-admin-row__mobile-label">
{{i18n "discourse_ai.llms.provider"}}
</div>
{{i18n
(concat "discourse_ai.llms.providers." llm.provider)
}}
</td>
<td class="column-edit">
<td class="d-admin-row__controls">
<LinkTo
@route="adminPlugins.show.discourse-ai-llms.show"
class="btn btn-default"
class="btn btn-default btn-small ai-llm-list__edit-button"
@model={{llm.id}}
>
{{icon "wrench"}}
<div class="d-button-label">
{{i18n "discourse_ai.llms.edit"}}
</div>
@@ -173,7 +183,17 @@ export default class AiLlmsListEditor extends Component {
</section>
{{/if}}
<section class="ai-llms-list-editor__templates">
<AdminPageSubheader @titleLabel={{this.preconfiguredTitle}} />
<AdminPageSubheader
@titleLabel={{this.preconfiguredTitle}}
@descriptionLabel={{unless
this.hasLlmElements
"discourse_ai.llms.preconfigured.description"
}}
@learnMoreUrl={{unless
this.hasLlmElements
"https://meta.discourse.org/t/discourse-ai-large-language-model-llm-settings-page/319903"
}}
/>
<AdminSectionLandingWrapper
class="ai-llms-list-editor__templates-list"
@@ -7,7 +7,7 @@ import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import didUpdate from "@ember/render-modifiers/modifiers/did-update";
import { LinkTo } from "@ember/routing";
import { later } from "@ember/runloop";
import { inject as service } from "@ember/service";
import { service } from "@ember/service";
import BackButton from "discourse/components/back-button";
import DButton from "discourse/components/d-button";
import Textarea from "discourse/components/d-textarea";
@@ -1,34 +1,20 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { fn } from "@ember/helper";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { LinkTo } from "@ember/routing";
import { inject as service } from "@ember/service";
import { service } from "@ember/service";
import DBreadcrumbsItem from "discourse/components/d-breadcrumbs-item";
import DToggleSwitch from "discourse/components/d-toggle-switch";
import concatClass from "discourse/helpers/concat-class";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { cook } from "discourse/lib/text";
import icon from "discourse-common/helpers/d-icon";
import i18n from "discourse-common/helpers/i18n";
import I18n from "discourse-i18n";
import AdminConfigAreaEmptyList from "admin/components/admin-config-area-empty-list";
import AdminPageSubheader from "admin/components/admin-page-subheader";
import AiPersonaEditor from "./ai-persona-editor";
export default class AiPersonaListEditor extends Component {
@service adminPluginNavManager;
@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;
}
@action
async toggleEnabled(persona) {
@@ -53,74 +39,76 @@ export default class AiPersonaListEditor extends Component {
{{#if @currentPersona}}
<AiPersonaEditor @model={{@currentPersona}} @personas={{@personas}} />
{{else}}
<div class="ai-persona-list-editor__header">
<h3>{{i18n "discourse_ai.ai_persona.short_title"}}</h3>
{{#unless @currentPersona.isNew}}
<LinkTo
<AdminPageSubheader
@titleLabel="discourse_ai.ai_persona.short_title"
@descriptionLabel="discourse_ai.ai_persona.persona_description"
@learnMoreUrl="https://meta.discourse.org/t/ai-bot-personas/306099"
>
<:actions as |actions|>
<actions.Primary
@label="discourse_ai.ai_persona.new"
@route="adminPlugins.show.discourse-ai-personas.new"
class="btn btn-small btn-primary"
>
{{icon "plus"}}
<span>{{I18n.t "discourse_ai.ai_persona.new"}}</span>
</LinkTo>
{{/unless}}
</div>
@icon="plus"
class="ai-persona-list-editor__new-button"
/>
</:actions>
</AdminPageSubheader>
<div class="ai-persona-list-editor__empty">
<details class="details__boxed">
<summary>{{i18n
"discourse_ai.ai_persona.what_are_personas"
}}</summary>
{{this.noPersonaText}}
</details>
</div>
<table class="content-list ai-persona-list-editor">
<thead>
<tr>
<th>{{i18n "discourse_ai.ai_persona.name"}}</th>
<th>{{i18n "discourse_ai.ai_persona.enabled"}}</th>
<th></th>
</tr>
</thead>
<tbody>
{{#each @personas as |persona|}}
<tr
data-persona-id={{persona.id}}
class={{concatClass
"ai-persona-list__row"
(if persona.priority "priority")
}}
>
<td>
<div class="ai-persona-list__name-with-description">
<div class="ai-persona-list__name">
<strong>
{{persona.name}}
</strong>
</div>
<div class="ai-persona-list__description">
{{persona.description}}
</div>
</div>
</td>
<td>
<DToggleSwitch
@state={{persona.enabled}}
{{on "click" (fn this.toggleEnabled persona)}}
/>
</td>
<td>
<LinkTo
@route="adminPlugins.show.discourse-ai-personas.show"
@model={{persona}}
class="btn btn-text btn-small"
>{{i18n "discourse_ai.ai_persona.edit"}} </LinkTo>
</td>
{{#if @personas}}
<table class="content-list ai-persona-list-editor d-admin-table">
<thead>
<tr>
<th>{{i18n "discourse_ai.ai_persona.name"}}</th>
<th>{{i18n "discourse_ai.ai_persona.enabled"}}</th>
<th></th>
</tr>
{{/each}}
</tbody>
</table>
</thead>
<tbody>
{{#each @personas as |persona|}}
<tr
data-persona-id={{persona.id}}
class={{concatClass
"ai-persona-list__row d-admin-row__content"
(if persona.priority "priority")
}}
>
<td class="d-admin-row__overview">
<div class="ai-persona-list__name-with-description">
<div class="ai-persona-list__name">
<strong>
{{persona.name}}
</strong>
</div>
<div class="ai-persona-list__description">
{{persona.description}}
</div>
</div>
</td>
<td class="d-admin-row__detail">
<DToggleSwitch
@state={{persona.enabled}}
{{on "click" (fn this.toggleEnabled persona)}}
/>
</td>
<td class="d-admin-row__controls">
<LinkTo
@route="adminPlugins.show.discourse-ai-personas.show"
@model={{persona}}
class="btn btn-text btn-small"
>{{i18n "discourse_ai.ai_persona.edit"}} </LinkTo>
</td>
</tr>
{{/each}}
</tbody>
</table>
{{else}}
<AdminConfigAreaEmptyList
@ctaLabel="discourse_ai.ai_persona.new"
@ctaRoute="adminPlugins.show.discourse-ai-personas.new"
@ctaClass="ai-persona-list-editor__empty-new-button"
@emptyLabel="discourse_ai.ai_persona.no_personas"
/>
{{/if}}
{{/if}}
</section>
</template>
@@ -3,7 +3,7 @@ import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import willDestroy from "@ember/render-modifiers/modifiers/will-destroy";
import { inject as service } from "@ember/service";
import { service } from "@ember/service";
import { and } from "truth-helpers";
import CookText from "discourse/components/cook-text";
import DButton from "discourse/components/d-button";
@@ -3,7 +3,7 @@ import { tracked } from "@glimmer/tracking";
import { fn } from "@ember/helper";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import { service } from "@ember/service";
import DButton from "discourse/components/d-button";
import categoryBadge from "discourse/helpers/category-badge";
import { ajax } from "discourse/lib/ajax";
@@ -54,8 +54,13 @@ export default class AiSplitTopicSuggester extends Component {
suggestions.includes(item.name.toLowerCase())
);
this.suggestions = suggestedCategories;
} else {
this.suggestions = result.assistant.map((s) => s.name);
} else if (this.args.mode === this.SUGGESTION_TYPES.tag) {
this.suggestions = result.assistant.map((s) => {
return {
name: s.name,
count: s.count,
};
});
}
})
.catch(popupAjaxError)
@@ -132,6 +137,17 @@ export default class AiSplitTopicSuggester extends Component {
{{on "click" (fn this.applySuggestion suggestion menu)}}
>
{{categoryBadge suggestion}}
<span class="topic-count">x
{{suggestion.totalTopicCount}}</span>
</li>
{{else if (eq @mode "suggest_tags")}}
<li data-name={{suggestion.name}} data-value={{index}}>
<DButton
@translatedLabel={{suggestion.name}}
@action={{fn this.applySuggestion suggestion.name menu}}
>
<span class="topic-count">x{{suggestion.count}}</span>
</DButton>
</li>
{{else}}
<li data-name={{suggestion}} data-value={{index}}>
@@ -1,249 +0,0 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { fn } from "@ember/helper";
import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import { inject as service } from "@ember/service";
import DButton from "discourse/components/d-button";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { bind } from "discourse-common/utils/decorators";
import I18n from "I18n";
export default class AISuggestionDropdown extends Component {
@service dialog;
@service siteSettings;
@service composer;
@tracked loading = false;
@tracked showMenu = false;
@tracked generatedSuggestions = [];
@tracked suggestIcon = "discourse-sparkles";
@tracked showErrors = false;
@tracked error = "";
SUGGESTION_TYPES = {
title: "suggest_title",
category: "suggest_category",
tag: "suggest_tags",
};
willDestroy() {
super.willDestroy(...arguments);
document.removeEventListener("click", this.onClickOutside);
}
get showAIButton() {
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");
} else {
composerFields.classList.remove("showing-ai-suggestions");
}
}
return isShowAIButton;
}
get disableSuggestionButton() {
return this.loading;
}
@action
applyClasses() {
if (this.showAIButton) {
document
.querySelector(".composer-fields")
?.classList.add("showing-ai-suggestions");
} else {
document
.querySelector(".composer-fields")
?.classList.remove("showing-ai-suggestions");
}
}
@bind
onClickOutside(event) {
const menu = document.querySelector(".ai-title-suggestions-menu");
if (event.target === menu) {
return;
}
return this.#closeMenu();
}
@action
handleClickOutside() {
document.addEventListener("click", this.onClickOutside);
}
@action
applySuggestion(suggestion) {
if (!this.args.mode) {
return;
}
const composer = this.args?.composer;
if (!composer) {
return;
}
if (this.args.mode === this.SUGGESTION_TYPES.title) {
composer.set("title", suggestion);
return this.#closeMenu();
}
if (this.args.mode === this.SUGGESTION_TYPES.category) {
const selectedCategoryId = this.composer.categories.find(
(c) => c.slug === suggestion
).id;
composer.set("categoryId", selectedCategoryId);
return this.#closeMenu();
}
if (this.args.mode === this.SUGGESTION_TYPES.tag) {
this.#updateTags(suggestion, composer);
}
}
@action
async performSuggestion() {
if (!this.args.mode) {
return;
}
if (this.composer.model.replyLength === 0) {
return this.dialog.alert(
I18n.t("discourse_ai.ai_helper.missing_content")
);
}
this.loading = true;
this.suggestIcon = "spinner";
return ajax(`/discourse-ai/ai-helper/${this.args.mode}`, {
method: "POST",
data: { text: this.composer.model.reply },
})
.then((data) => {
this.#assignGeneratedSuggestions(data, this.args.mode);
})
.catch(popupAjaxError)
.finally(() => {
this.loading = false;
this.suggestIcon = "sync-alt";
this.showMenu = true;
if (this.args.mode === "suggest_category") {
document
.querySelector(".category-input")
?.classList.add("showing-ai-suggestion-menu");
}
});
}
#closeMenu() {
if (this.showMenu && this.args.mode === "suggest_category") {
document
.querySelector(".category-input")
?.classList.remove("showing-ai-suggestion-menu");
}
this.suggestIcon = "discourse-sparkles";
this.showMenu = false;
this.showErrors = false;
this.errors = "";
}
#updateTags(suggestion, composer) {
const maxTags = this.siteSettings.max_tags_per_topic;
if (!composer.tags) {
composer.set("tags", [suggestion]);
// remove tag from the list of suggestions once added
this.generatedSuggestions = this.generatedSuggestions.filter(
(s) => s !== suggestion
);
return;
}
const tags = composer.tags;
if (tags?.length >= maxTags) {
// Show error if trying to add more tags than allowed
this.showErrors = true;
this.error = I18n.t("select_kit.max_content_reached", { count: maxTags });
return;
}
tags.push(suggestion);
composer.set("tags", [...tags]);
// remove tag from the list of suggestions once added
return (this.generatedSuggestions = this.generatedSuggestions.filter(
(s) => s !== suggestion
));
}
#tagSelectorHasValues() {
return this.args.composer?.tags && this.args.composer?.tags.length > 0;
}
#assignGeneratedSuggestions(data, mode) {
if (mode === this.SUGGESTION_TYPES.title) {
return (this.generatedSuggestions = data.suggestions);
}
const suggestions = data.assistant.map((s) => s.name);
if (mode === this.SUGGESTION_TYPES.tag) {
if (this.#tagSelectorHasValues()) {
// Filter out tags if they are already selected in the tag input
return (this.generatedSuggestions = suggestions.filter(
(t) => !this.args.composer.tags.includes(t)
));
} else {
return (this.generatedSuggestions = suggestions);
}
}
return (this.generatedSuggestions = suggestions);
}
<template>
{{#if this.showAIButton}}
<DButton
@icon={{this.suggestIcon}}
@title="discourse_ai.ai_helper.suggest"
@action={{this.performSuggestion}}
@disabled={{this.disableSuggestionButton}}
class="suggestion-button {{if this.loading 'is-loading'}}"
...attributes
/>
{{/if}}
{{#if this.showMenu}}
{{! template-lint-disable modifier-name-case }}
<ul
class="popup-menu ai-suggestions-menu"
{{didInsert this.handleClickOutside}}
>
{{#if this.showErrors}}
<li class="ai-suggestions-menu__errors">{{this.error}}</li>
{{/if}}
{{#each this.generatedSuggestions as |suggestion index|}}
<li data-name={{suggestion}} data-value={{index}}>
<DButton
@translatedLabel={{suggestion}}
@action={{fn this.applySuggestion suggestion}}
class="popup-menu-btn"
/>
</li>
{{/each}}
</ul>
{{/if}}
</template>
}

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