Compare commits

..
Author SHA1 Message Date
Keegan George 3ce8d943a7 DEV: First version
showing message bus error, but it kinda works? wip on a fix.
2024-11-12 20:42:19 -08:00
269 changed files with 3620 additions and 7237 deletions
+1
View File
@@ -0,0 +1 @@
module.exports = require("@discourse/lint-configs/eslint");
@@ -1,7 +1,5 @@
<section class="ai-persona-tool-editor__current admin-detail pull-left">
<AiToolEditor
@tools={{this.allTools}}
@model={{this.model}}
@presets={{this.presets}}
/>
</section>
<AiToolEditor
@tools={{this.allTools}}
@model={{this.model}}
@presets={{this.presets}}
/>
@@ -1,7 +1,5 @@
<section class="ai-persona-tool-editor__current admin-detail pull-left">
<AiToolEditor
@tools={{this.allTools}}
@model={{this.model}}
@presets={{this.presets}}
/>
</section>
<AiToolEditor
@tools={{this.allTools}}
@model={{this.model}}
@presets={{this.presets}}
/>
@@ -1,87 +0,0 @@
# 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,8 +7,7 @@ module DiscourseAi
requires_login only: %i[create update destroy]
before_action :require_site_settings!
skip_before_action :preload_json, :check_xhr, only: %i[show asset]
skip_before_action :verify_authenticity_token, only: ["asset"]
skip_before_action :preload_json, :check_xhr, only: %i[show]
def create
ensure_allowed_create!
@@ -51,30 +50,6 @@ 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
return suggest_thumbnails(input) if prompt.id == CompletionPrompt::ILLUSTRATE_POST
suggest_thumbnails(input) if prompt.id == CompletionPrompt::ILLUSTRATE_POST
hijack do
render json:
@@ -2,29 +2,13 @@
module DiscourseAi
module AiBot
module SharedAiConversationsHelper
# 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
# 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"
@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)
def share_asset_url(short_path)
::UrlHelper.absolute("/plugins/discourse-ai/ai-share/#{short_path}?#{VERSION}")
end
end
end
+22
View File
@@ -0,0 +1,22 @@
# 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
@@ -1,20 +1,23 @@
# frozen_string_literal: true
module ::Jobs
class FastTrackTopicGist < ::Jobs::Base
class UpdateHotTopicGist < ::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_summary_gists_enabled
return if SiteSetting.ai_summarize_max_hot_topics_gists_per_batch.zero?
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.present? && !gist.outdated
return if gist.blank?
return if !gist.outdated
summarizer.force_summarize(Discourse.system_user)
end
+21 -25
View File
@@ -70,15 +70,14 @@ 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_in_batches do |batch|
vector_rep.gen_bulk_reprensentations(batch)
rebaked += batch.size
.find_each do |t|
vector_rep.generate_representation_from(t)
rebaked += 1
end
return if rebaked >= limit
@@ -91,28 +90,24 @@ module Jobs
OR
#{table_name}.strategy_version < #{strategy.version}
SQL
.find_in_batches do |batch|
vector_rep.gen_bulk_reprensentations(batch)
rebaked += batch.size
.find_each do |t|
vector_rep.generate_representation_from(t)
rebaked += 1
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_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
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
rebaked
end
@@ -125,13 +120,14 @@ 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_slice(batch_size) do |batch|
vector_rep.gen_bulk_reprensentations(Topic.where(id: batch).order("topics.bumped_at DESC"))
done += batch.length
ids.each do |id|
topic = Topic.find_by(id: id)
if topic
vector_rep.generate_representation_from(topic)
done += 1
end
end
done
end
end
+3 -5
View File
@@ -19,8 +19,6 @@ 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))
@@ -31,9 +29,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,7 +13,6 @@ class AiApiAuditLog < ActiveRecord::Base
Cohere = 6
Ollama = 7
SambaNova = 8
Mistral = 9
end
def next_log_id
-52
View File
@@ -1,52 +0,0 @@
# 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
#
+1 -2
View File
@@ -51,6 +51,5 @@ end
#
# Indexes
#
# 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)
# index_ai_summaries_on_target_type_and_target_id (target_type,target_id)
#
-10
View File
@@ -26,16 +26,6 @@ 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,7 +172,6 @@ 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,7 +229,6 @@ end
# updated_at :datetime not null
# force_review :boolean default(FALSE), not null
# reject_reason :text
# potentially_illegal :boolean default(FALSE)
#
# Indexes
#
+1 -25
View File
@@ -34,12 +34,6 @@ 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,
@@ -171,7 +165,7 @@ class SharedAiConversation < ActiveRecord::Base
id: post.id,
user_id: post.user_id,
created_at: post.created_at,
cooked: cook_artifacts(post),
cooked: post.cooked,
}
mapped[:persona] = persona if ai_bot_participant&.id == post.user_id
@@ -181,24 +175,6 @@ 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="<%= share_asset_url("share.css") %>">
<link rel="stylesheet" href="<%= ::UrlHelper.absolute("/plugins/discourse-ai/ai-share/share.css") %>">
</head>
<body>
<header class="site-header">
@@ -54,7 +54,7 @@
</article>
<% end %>
</section>
<script src="<%= share_asset_url("highlight.js") %>" nonce="<%= csp_nonce_placeholder %>" ></script>
<script src="<%= share_asset_url("highlight.min.js") %>" nonce="<%= csp_nonce_placeholder %>" ></script>
<script nonce="<%= csp_nonce_placeholder %>">
document.querySelectorAll('pre code').forEach((el) => {
hljs.highlightElement(el);
@@ -1,45 +0,0 @@
<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>
@@ -1,39 +0,0 @@
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()}`);
}
}
@@ -1,121 +0,0 @@
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,43 +4,35 @@ 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 gists;
@service router;
@service gistPreference;
get shouldShow() {
return this.router.currentRoute.attributes?.list?.topics?.some(
(topic) => topic.ai_topic_gist
);
}
get buttons() {
return [
{
id: "table",
label: "discourse_ai.summarization.topic_list_layout.button.compact",
icon: "discourse-table",
id: "gists_enabled",
label: "discourse_ai.summarization.gists_enabled_long",
icon: "discourse-sparkles",
},
{
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",
id: "gists_disabled",
label: "discourse_ai.summarization.gists_disabled",
icon: "far-eye-slash",
},
];
}
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;
@@ -48,22 +40,29 @@ export default class AiGistToggle extends Component {
@action
onSelect(optionId) {
this.gists.setPreference(optionId);
this.gistPreference.setPreference(optionId);
this.dMenu.close();
}
<template>
{{#if this.gists.shouldShow}}
{{bodyClass (concat "topic-list-layout-" this.gists.preference)}}
{{#if this.shouldShow}}
<DMenu
@modalForMobile={{true}}
@autofocus={{true}}
@identifier="topic-list-layout"
@identifier="ai-gists-dropdown"
@onRegisterApi={{this.onRegisterApi}}
@triggerClass="btn-default btn-icon"
@triggerClass="btn-transparent"
>
<:trigger>
{{icon this.currentButton.icon}}
<span class="d-button-label">
{{i18n
(concat
"discourse_ai.summarization." this.gistPreference.preference
)
}}
</span>
{{icon "angle-down"}}
</:trigger>
<:content>
<DropdownMenu as |dropdown|>
@@ -72,17 +71,9 @@ export default class AiGistToggle extends Component {
<DButton
@label={{button.label}}
@icon={{button.icon}}
class="btn-transparent
{{if button.description '--with-description'}}
{{if (eq this.currentButton.id button.id) '--active'}}"
class="btn-transparent"
@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 { service } from "@ember/service";
import { inject as 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,8 +2,9 @@ import Component from "@glimmer/component";
import { concat, fn } from "@ember/helper";
import { action } from "@ember/object";
import { LinkTo } from "@ember/routing";
import { service } from "@ember/service";
import { inject as 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";
@@ -123,10 +124,8 @@ 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 class="d-admin-table">
<table>
<thead>
<tr>
<th>{{i18n "discourse_ai.llms.display_name"}}</th>
@@ -136,19 +135,12 @@ export default class AiLlmsListEditor extends Component {
</thead>
<tbody>
{{#each @llms as |llm|}}
<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">
<tr data-llm-id={{llm.name}} class="ai-llm-list__row">
<td class="column-name">
<h3>{{llm.display_name}}</h3>
<p>
{{this.modelDescription llm}}
</div>
</p>
{{#if llm.used_by}}
<ul class="ai-llm-list-editor__usages">
{{#each llm.used_by as |usage|}}
@@ -157,20 +149,18 @@ export default class AiLlmsListEditor extends Component {
</ul>
{{/if}}
</td>
<td class="d-admin-row__detail">
<div class="d-admin-row__mobile-label">
{{i18n "discourse_ai.llms.provider"}}
</div>
<td class="column-provider">
{{i18n
(concat "discourse_ai.llms.providers." llm.provider)
}}
</td>
<td class="d-admin-row__controls">
<td class="column-edit">
<LinkTo
@route="adminPlugins.show.discourse-ai-llms.show"
class="btn btn-default btn-small ai-llm-list__edit-button"
class="btn btn-default"
@model={{llm.id}}
>
{{icon "wrench"}}
<div class="d-button-label">
{{i18n "discourse_ai.llms.edit"}}
</div>
@@ -183,17 +173,7 @@ export default class AiLlmsListEditor extends Component {
</section>
{{/if}}
<section class="ai-llms-list-editor__templates">
<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"
}}
/>
<AdminPageSubheader @titleLabel={{this.preconfiguredTitle}} />
<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 { service } from "@ember/service";
import { inject as 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,20 +1,34 @@
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 { service } from "@ember/service";
import { inject as 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 AdminConfigAreaEmptyList from "admin/components/admin-config-area-empty-list";
import AdminPageSubheader from "admin/components/admin-page-subheader";
import I18n from "discourse-i18n";
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) {
@@ -39,76 +53,74 @@ export default class AiPersonaListEditor extends Component {
{{#if @currentPersona}}
<AiPersonaEditor @model={{@currentPersona}} @personas={{@personas}} />
{{else}}
<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"
<div class="ai-persona-list-editor__header">
<h3>{{i18n "discourse_ai.ai_persona.short_title"}}</h3>
{{#unless @currentPersona.isNew}}
<LinkTo
@route="adminPlugins.show.discourse-ai-personas.new"
@icon="plus"
class="ai-persona-list-editor__new-button"
/>
</:actions>
</AdminPageSubheader>
class="btn btn-small btn-primary"
>
{{icon "plus"}}
<span>{{I18n.t "discourse_ai.ai_persona.new"}}</span>
</LinkTo>
{{/unless}}
</div>
{{#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>
</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 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>
</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}}
<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>
</tr>
{{/each}}
</tbody>
</table>
{{/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 { service } from "@ember/service";
import { inject as 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 { service } from "@ember/service";
import { inject as 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,13 +54,8 @@ export default class AiSplitTopicSuggester extends Component {
suggestions.includes(item.name.toLowerCase())
);
this.suggestions = suggestedCategories;
} else if (this.args.mode === this.SUGGESTION_TYPES.tag) {
this.suggestions = result.assistant.map((s) => {
return {
name: s.name,
count: s.count,
};
});
} else {
this.suggestions = result.assistant.map((s) => s.name);
}
})
.catch(popupAjaxError)
@@ -137,17 +132,6 @@ 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}}>
@@ -0,0 +1,249 @@
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