Compare commits

..
419 changed files with 5797 additions and 17774 deletions
+6 -4
View File
@@ -1,6 +1,8 @@
< 3.4.0.beta1-dev: 9d887ad4ace8e33c3fe7dbb39237e882c08b4f0b
< 3.3.0.beta5-dev: 4d8090002f6dcd8e34d41033606bf131fa221475
< 3.3.0.beta2-dev: 61890b667c06299841ae88946f84a112f00060e1
< 3.3.0.beta1-dev: d94234d89f50733678a5c62809445198a9fda74b
3.2.999: d94234d89f50733678a5c62809445198a9fda74b
< 3.3.0.beta1-dev: a65c4076a63104b61d213534db7f89aa3c8acd9e
< 3.2.0.beta5-dev: c70f43f130fb98e4a1adb1afd9b621aaa49088e3
< 3.2.0.beta4-dev: 493b48477a9288f2f1b6f021954088705a6673d6
< 3.2.0.beta2-dev: 3bced1c6f525553fe2b5b79d29bd66c1cf2a47d5
3.1.999: c0415bb7eb878b1b7abf112d65cba981030df8df
< 3.2.0.beta1-dev: 3e7c99de894b03e9161d9481da118fe893991a1d
3.1.0.beta3: 057fbe1ce6
+26 -35
View File
@@ -1,7 +1,7 @@
GEM
remote: https://rubygems.org/
specs:
activesupport (7.1.3.4)
activesupport (7.1.3.2)
base64
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
@@ -13,28 +13,27 @@ GEM
tzinfo (~> 2.0)
ast (2.4.2)
base64 (0.2.0)
bigdecimal (3.1.8)
concurrent-ruby (1.3.3)
bigdecimal (3.1.6)
concurrent-ruby (1.2.3)
connection_pool (2.4.1)
drb (2.2.1)
i18n (1.14.5)
i18n (1.14.1)
concurrent-ruby (~> 1.0)
json (2.7.2)
json (2.7.1)
language_server-protocol (3.17.0.3)
minitest (5.24.1)
minitest (5.22.2)
mutex_m (0.2.0)
parallel (1.25.1)
parser (3.3.3.0)
parallel (1.24.0)
parser (3.3.0.5)
ast (~> 2.4.1)
racc
prettier_print (1.2.1)
racc (1.8.0)
rack (3.1.6)
racc (1.7.3)
rainbow (3.1.1)
regexp_parser (2.9.2)
rexml (3.3.3)
strscan
rubocop (1.64.1)
regexp_parser (2.9.0)
rexml (3.2.8)
strscan (>= 3.0.9)
rubocop (1.61.0)
json (~> 2.3)
language_server-protocol (>= 3.17.0)
parallel (~> 1.10)
@@ -42,33 +41,25 @@ GEM
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0)
rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.31.1, < 2.0)
rubocop-ast (>= 1.30.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 3.0)
rubocop-ast (1.31.3)
parser (>= 3.3.1.0)
rubocop-capybara (2.21.0)
rubocop-ast (1.31.1)
parser (>= 3.3.0.4)
rubocop-capybara (2.20.0)
rubocop (~> 1.41)
rubocop-discourse (3.8.1)
rubocop-discourse (3.7.1)
activesupport (>= 6.1)
rubocop (>= 1.59.0)
rubocop-capybara (>= 2.0.0)
rubocop-factory_bot (>= 2.0.0)
rubocop-rails (>= 2.25.0)
rubocop-rspec (>= 3.0.1)
rubocop-rspec_rails (>= 2.30.0)
rubocop-factory_bot (2.26.1)
rubocop (~> 1.61)
rubocop-rails (2.25.1)
activesupport (>= 4.2.0)
rack (>= 1.1)
rubocop (>= 1.33.0, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0)
rubocop-rspec (3.0.2)
rubocop (~> 1.61)
rubocop-rspec_rails (2.30.0)
rubocop (~> 1.61)
rubocop-rspec (~> 3, >= 3.0.1)
rubocop-rspec (>= 2.25.0)
rubocop-factory_bot (2.25.1)
rubocop (~> 1.41)
rubocop-rspec (2.27.1)
rubocop (~> 1.40)
rubocop-capybara (~> 2.17)
rubocop-factory_bot (~> 2.22)
ruby-progressbar (1.13.0)
strscan (3.1.0)
syntax_tree (6.2.0)
@@ -85,4 +76,4 @@ DEPENDENCIES
syntax_tree
BUNDLED WITH
2.5.10
2.5.4
@@ -3,7 +3,6 @@ import DiscourseRoute from "discourse/routes/discourse";
export default DiscourseRoute.extend({
async model() {
const record = this.store.createRecord("ai-llm");
record.provider_params = {};
return record;
},
@@ -4,9 +4,7 @@ export default DiscourseRoute.extend({
async model(params) {
const allLlms = this.modelFor("adminPlugins.show.discourse-ai-llms");
const id = parseInt(params.id, 10);
const record = allLlms.findBy("id", id);
record.provider_params = record.provider_params || {};
return record;
return allLlms.findBy("id", id);
},
setupController(controller, model) {
@@ -1,15 +0,0 @@
import DiscourseRoute from "discourse/routes/discourse";
export default class DiscourseAiToolsNewRoute extends DiscourseRoute {
async model() {
return this.store.createRecord("ai-tool");
}
setupController(controller) {
super.setupController(...arguments);
const toolsModel = this.modelFor("adminPlugins.show.discourse-ai-tools");
controller.set("allTools", toolsModel);
controller.set("presets", toolsModel.resultSetMeta.presets);
}
}
@@ -1,18 +0,0 @@
import DiscourseRoute from "discourse/routes/discourse";
export default class DiscourseAiToolsShowRoute extends DiscourseRoute {
async model(params) {
const allTools = this.modelFor("adminPlugins.show.discourse-ai-tools");
const id = parseInt(params.id, 10);
return allTools.find((tool) => tool.id === id);
}
setupController(controller) {
super.setupController(...arguments);
const toolsModel = this.modelFor("adminPlugins.show.discourse-ai-tools");
controller.set("allTools", toolsModel);
controller.set("presets", toolsModel.resultSetMeta.presets);
}
}
@@ -1,10 +0,0 @@
import { service } from "@ember/service";
import DiscourseRoute from "discourse/routes/discourse";
export default class DiscourseAiToolsRoute extends DiscourseRoute {
@service store;
model() {
return this.store.findAll("ai-tool");
}
}
@@ -1,3 +1,9 @@
<DBreadcrumbsItem as |linkClass|>
<LinkTo @route="adminPlugins.show.discourse-ai-personas" class={{linkClass}}>
{{@model.plugin.nameTitleized}}
</LinkTo>
</DBreadcrumbsItem>
<AiPersonaListEditor
@personas={{this.allPersonas}}
@currentPersona={{this.model}}
@@ -1 +0,0 @@
<AiToolListEditor @tools={{this.model}} />
@@ -1,5 +0,0 @@
<AiToolEditor
@tools={{this.allTools}}
@model={{this.model}}
@presets={{this.presets}}
/>
@@ -1,5 +0,0 @@
<AiToolEditor
@tools={{this.allTools}}
@model={{this.model}}
@presets={{this.presets}}
/>
@@ -6,7 +6,7 @@ module DiscourseAi
requires_plugin ::DiscourseAi::PLUGIN_NAME
def index
llms = LlmModel.all.order(:display_name)
llms = LlmModel.all
render json: {
ai_llms:
@@ -16,8 +16,6 @@ module DiscourseAi
root: false,
).as_json,
meta: {
provider_params: LlmModel.provider_params,
presets: DiscourseAi::Completions::Llm.presets,
providers: DiscourseAi::Completions::Llm.provider_names,
tokenizers:
DiscourseAi::Completions::Llm.tokenizer_names.map { |tn|
@@ -35,8 +33,7 @@ module DiscourseAi
def create
llm_model = LlmModel.new(ai_llm_params)
if llm_model.save
llm_model.toggle_companion_user
render json: { ai_persona: LlmModelSerializer.new(llm_model) }, status: :created
render json: { ai_persona: llm_model }, status: :created
else
render_json_error llm_model
end
@@ -45,46 +42,8 @@ module DiscourseAi
def update
llm_model = LlmModel.find(params[:id])
if llm_model.seeded?
return render_json_error(I18n.t("discourse_ai.llm.cannot_edit_builtin"), status: 403)
end
if llm_model.update(ai_llm_params(updating: llm_model))
llm_model.toggle_companion_user
render json: LlmModelSerializer.new(llm_model)
else
render_json_error llm_model
end
end
def destroy
llm_model = LlmModel.find(params[:id])
if llm_model.seeded?
return render_json_error(I18n.t("discourse_ai.llm.cannot_delete_builtin"), status: 403)
end
in_use_by = DiscourseAi::Configuration::LlmValidator.new.modules_using(llm_model)
if !in_use_by.empty?
return(
render_json_error(
I18n.t(
"discourse_ai.llm.delete_failed",
settings: in_use_by.join(", "),
count: in_use_by.length,
),
status: 409,
)
)
end
# Clean up companion users
llm_model.enabled_chat_bot = false
llm_model.toggle_companion_user
if llm_model.destroy
head :no_content
if llm_model.update(ai_llm_params)
render json: llm_model
else
render_json_error llm_model
end
@@ -95,7 +54,11 @@ module DiscourseAi
llm_model = LlmModel.new(ai_llm_params)
DiscourseAi::Configuration::LlmValidator.new.run_test(llm_model)
DiscourseAi::Completions::Llm.proxy_from_obj(llm_model).generate(
"How much is 1 + 1?",
user: current_user,
feature_name: "llm_validator",
)
render json: { success: true }
rescue DiscourseAi::Completions::Endpoints::Base::CompletionFailed => e
@@ -104,44 +67,16 @@ module DiscourseAi
private
def ai_llm_params(updating: nil)
return {} if params[:ai_llm].blank?
permitted =
params.require(:ai_llm).permit(
:display_name,
:name,
:provider,
:tokenizer,
:max_prompt_tokens,
:api_key,
:enabled_chat_bot,
:vision_enabled,
)
provider = updating ? updating.provider : permitted[:provider]
permit_url = provider != LlmModel::BEDROCK_PROVIDER_NAME
new_url = params.dig(:ai_llm, :url)
permitted[:url] = new_url if permit_url && new_url
extra_field_names = LlmModel.provider_params.dig(provider&.to_sym)
if extra_field_names.present?
received_prov_params =
params.dig(:ai_llm, :provider_params)&.slice(*extra_field_names.keys)
if received_prov_params.present?
received_prov_params.each do |pname, value|
if extra_field_names[pname.to_sym] == :checkbox
received_prov_params[pname] = ActiveModel::Type::Boolean.new.cast(value)
end
end
permitted[:provider_params] = received_prov_params.permit!
end
end
permitted
def ai_llm_params
params.require(:ai_llm).permit(
:display_name,
:name,
:provider,
:tokenizer,
:max_prompt_tokens,
:url,
:api_key,
)
end
end
end
@@ -19,19 +19,11 @@ module DiscourseAi
DiscourseAi::AiBot::Personas::Persona.all_available_tools.map do |tool|
AiToolSerializer.new(tool, root: false)
end
AiTool
.where(enabled: true)
.each do |tool|
tools << {
id: "custom-#{tool.id}",
name: I18n.t("discourse_ai.tools.custom_name", name: tool.name.capitalize),
}
end
llms =
DiscourseAi::Configuration::LlmEnumerator.values.map do |hash|
{ id: hash[:value], name: hash[:name] }
end
render json: { ai_personas: ai_personas, meta: { tools: tools, llms: llms } }
render json: { ai_personas: ai_personas, meta: { commands: tools, llms: llms } }
end
def show
@@ -43,10 +35,7 @@ module DiscourseAi
if ai_persona.save
RagDocumentFragment.link_persona_and_uploads(ai_persona, attached_upload_ids)
render json: {
ai_persona: LocalizedAiPersonaSerializer.new(ai_persona, root: false),
},
status: :created
render json: { ai_persona: ai_persona }, status: :created
else
render_json_error ai_persona
end
@@ -61,7 +50,7 @@ module DiscourseAi
if @ai_persona.update(ai_persona_params.except(:rag_uploads))
RagDocumentFragment.update_persona_uploads(@ai_persona, attached_upload_ids)
render json: LocalizedAiPersonaSerializer.new(@ai_persona, root: false)
render json: @ai_persona
else
render_json_error @ai_persona
end
@@ -137,29 +126,28 @@ module DiscourseAi
:rag_conversation_chunks,
:question_consolidator_llm,
:allow_chat,
:tool_details,
allowed_group_ids: [],
rag_uploads: [:id],
)
if tools = params.dig(:ai_persona, :tools)
permitted[:tools] = permit_tools(tools)
if commands = params.dig(:ai_persona, :commands)
permitted[:commands] = permit_commands(commands)
end
permitted
end
def permit_tools(tools)
return [] if !tools.is_a?(Array)
def permit_commands(commands)
return [] if !commands.is_a?(Array)
tools.filter_map do |tool, options|
break nil if !tool.is_a?(String)
commands.filter_map do |command, options|
break nil if !command.is_a?(String)
options&.permit! if options && options.is_a?(ActionController::Parameters)
if options
[tool, options]
[command, options]
else
tool
command
end
end
end
@@ -1,89 +0,0 @@
# frozen_string_literal: true
module DiscourseAi
module Admin
class AiToolsController < ::Admin::AdminController
requires_plugin ::DiscourseAi::PLUGIN_NAME
before_action :find_ai_tool, only: %i[show update destroy]
def index
ai_tools = AiTool.all
render_serialized({ ai_tools: ai_tools }, AiCustomToolListSerializer, root: false)
end
def show
render_serialized(@ai_tool, AiCustomToolSerializer)
end
def create
ai_tool = AiTool.new(ai_tool_params)
ai_tool.created_by_id = current_user.id
if ai_tool.save
render_serialized(ai_tool, AiCustomToolSerializer, status: :created)
else
render_json_error ai_tool
end
end
def update
if @ai_tool.update(ai_tool_params)
render_serialized(@ai_tool, AiCustomToolSerializer)
else
render_json_error @ai_tool
end
end
def destroy
if @ai_tool.destroy
head :no_content
else
render_json_error @ai_tool
end
end
def test
if params[:id].present?
ai_tool = AiTool.find(params[:id])
else
ai_tool = AiTool.new(ai_tool_params)
end
parameters = params[:parameters].to_unsafe_h
# we need an llm so we have a tokenizer
# but will do without if none is available
llm = LlmModel.first&.to_llm
runner = ai_tool.runner(parameters, llm: llm, bot_user: current_user, context: {})
result = runner.invoke
if result.is_a?(Hash) && result[:error]
render_json_error result[:error]
else
render json: { output: result }
end
rescue ActiveRecord::RecordNotFound => e
render_json_error e.message, status: 400
rescue => e
render_json_error "Error executing the tool: #{e.message}", status: 400
end
private
def find_ai_tool
@ai_tool = AiTool.find(params[:id])
end
def ai_tool_params
params.require(:ai_tool).permit(
:name,
:description,
:script,
:summary,
parameters: [:name, :type, :description, :required, enum: []],
)
end
end
end
end
@@ -18,7 +18,7 @@ module DiscourseAi
debug_info = AiApiAuditLog.where(post: posts).order(created_at: :desc).first
render json: AiApiAuditLogSerializer.new(debug_info, root: false), status: 200
render json: debug_info, status: 200
end
def stop_streaming_response
@@ -31,10 +31,12 @@ module DiscourseAi
end
def show_bot_username
bot_user = DiscourseAi::AiBot::EntryPoint.find_user_from_model(params[:username])
raise Discourse::InvalidParameters.new(:username) if !bot_user
bot_user_id = DiscourseAi::AiBot::EntryPoint.map_bot_model_to_user_id(params[:username])
raise Discourse::InvalidParameters.new(:username) if !bot_user_id
render json: { bot_username: bot_user.username_lower }, status: 200
bot_username_lower = User.find(bot_user_id).username_lower
render json: { bot_username: bot_username_lower }, status: 200
end
end
end
@@ -7,7 +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]
skip_before_action :preload_json, :check_xhr, :redirect_to_login_if_required, only: %i[show]
def create
ensure_allowed_create!
@@ -12,7 +12,6 @@ module DiscourseAi
def suggest
input = get_text_param!
force_default_locale = params[:force_default_locale] || false
prompt = CompletionPrompt.find_by(id: params[:mode])
@@ -32,7 +31,6 @@ module DiscourseAi
prompt,
input,
current_user,
force_default_locale,
),
status: 200
end
@@ -89,26 +87,18 @@ module DiscourseAi
end
end
def stream_suggestion
def explain
post_id = get_post_param!
text = get_text_param!
term_to_explain = get_text_param!
post = Post.includes(:topic).find_by(id: post_id)
prompt = CompletionPrompt.find_by(id: params[:mode])
raise Discourse::InvalidParameters.new(:mode) if !prompt || !prompt.enabled?
raise Discourse::InvalidParameters.new(:post_id) unless post
if prompt.id == CompletionPrompt::CUSTOM_PROMPT
raise Discourse::InvalidParameters.new(:custom_prompt) if params[:custom_prompt].blank?
end
Jobs.enqueue(
:stream_post_helper,
post_id: post.id,
user_id: current_user.id,
text: text,
prompt: prompt.name,
custom_prompt: params[:custom_prompt],
term_to_explain: term_to_explain,
)
render json: { success: true }, status: 200
@@ -117,6 +107,21 @@ module DiscourseAi
status: 502
end
def random_caption
captions = [
"A beautiful landscape",
"An adorable puppy",
"A delicious meal",
"A cozy fireplace",
"A stunning sunset",
"A charming cityscape",
"A peaceful garden",
"A majestic mountain range",
"A captivating work of art",
]
captions.sample
end
def caption_image
image_url = params[:image_url]
image_url_type = params[:image_url_type]
@@ -133,12 +138,19 @@ module DiscourseAi
end
raise Discourse::NotFound if image.blank?
check_secure_upload_permission(image) if image.secure?
user = current_user
final_image_url = get_caption_url(image, image_url)
hijack do
caption = DiscourseAi::AiHelper::Assistant.new.generate_image_caption(image, user)
if Rails.env.development?
sleep 2 # Simulate a delay of 2 seconds
caption = random_caption
else
caption =
DiscourseAi::AiHelper::Assistant.new.generate_image_caption(
final_image_url,
current_user,
)
end
render json: {
caption:
"#{caption} (#{I18n.t("discourse_ai.ai_helper.image_caption.attribution")})",
@@ -165,13 +177,18 @@ module DiscourseAi
end
def ensure_can_request_suggestions
allowed_groups =
(
SiteSetting.composer_ai_helper_allowed_groups_map |
SiteSetting.post_ai_helper_allowed_groups_map
)
if !current_user.in_any_groups?(SiteSetting.ai_helper_allowed_groups_map)
raise Discourse::InvalidAccess
end
end
raise Discourse::InvalidAccess if !current_user.in_any_groups?(allowed_groups)
def get_caption_url(image_upload, image_url)
if image_upload.secure?
check_secure_upload_permission(image_upload)
return Discourse.store.url_for(image_upload)
end
UrlHelper.absolute(image_url)
end
end
end
@@ -9,7 +9,6 @@ module DiscourseAi
def search
query = params[:q].to_s
skip_hyde = params[:hyde].downcase.to_s == "false" || params[:hyde].to_s == "0"
if query.length < SiteSetting.min_search_term_length
raise Discourse::InvalidParameters.new(:q)
@@ -32,7 +31,7 @@ module DiscourseAi
hijack do
semantic_search
.search_for_topics(query, _page = 1, hyde: !skip_hyde)
.search_for_topics(query)
.each { |topic_post| grouped_results.add(topic_post) }
render_serialized(grouped_results, GroupedSearchResultSerializer, result: grouped_results)
@@ -40,9 +39,6 @@ module DiscourseAi
end
def quick_search
# this search function searches posts (vs: topics)
# it requires post embeddings and a reranker
# it will not perform a hyde expantion
query = params[:q].to_s
if query.length < SiteSetting.min_search_term_length
@@ -1,48 +0,0 @@
# frozen_string_literal: true
module DiscourseAi
module Summarization
class ChatSummaryController < ::Chat::ApiController
requires_plugin ::DiscourseAi::PLUGIN_NAME
requires_plugin ::Chat::PLUGIN_NAME
VALID_SINCE_VALUES = [1, 3, 6, 12, 24, 72, 168]
def show
since = params[:since].to_i
raise Discourse::InvalidParameters.new(:since) if !VALID_SINCE_VALUES.include?(since)
channel = ::Chat::Channel.find(params[:channel_id])
guardian.ensure_can_join_chat_channel!(channel)
strategy = DiscourseAi::Summarization.default_strategy
raise Discourse::NotFound.new unless strategy
guardian.ensure_can_request_summary!
RateLimiter.new(current_user, "channel_summary", 6, 5.minutes).performed!
hijack do
content = { content_title: channel.name }
content[:contents] = channel
.chat_messages
.where("chat_messages.created_at > ?", since.hours.ago)
.includes(:user)
.order(created_at: :asc)
.pluck(:id, :username_lower, :message)
.map { { id: _1, poster: _2, text: _3 } }
summarized_text =
if content[:contents].empty?
I18n.t("discourse_ai.summarization.chat.no_targets")
else
strategy.summarize(content, current_user).dig(:summary)
end
render json: { summary: summarized_text }
end
end
end
end
end
@@ -1,49 +0,0 @@
# frozen_string_literal: true
module DiscourseAi
module Summarization
class SummaryController < ::ApplicationController
requires_plugin ::DiscourseAi::PLUGIN_NAME
def show
topic = Topic.find(params[:topic_id])
guardian.ensure_can_see!(topic)
raise Discourse::NotFound if !guardian.can_see_summary?(topic)
RateLimiter.new(current_user, "summary", 6, 5.minutes).performed! if current_user
opts = params.permit(:skip_age_check)
skip_age_check = opts[:skip_age_check] == "true"
if params[:stream] && current_user
cached_summary = DiscourseAi::TopicSummarization.cached_summary(topic, current_user)
if cached_summary && !skip_age_check
render_serialized(cached_summary, AiTopicSummarySerializer)
return
end
Jobs.enqueue(
:stream_topic_ai_summary,
topic_id: topic.id,
user_id: current_user.id,
skip_age_check: skip_age_check,
)
render json: success_json
else
hijack do
summary =
DiscourseAi::TopicSummarization.summarize(
topic,
current_user,
skip_age_check: skip_age_check,
)
render_serialized(summary, AiTopicSummarySerializer)
end
end
end
end
end
end
@@ -8,7 +8,7 @@ module DiscourseAi
VERSION = "1"
def share_asset_url(short_path)
::UrlHelper.absolute("/plugins/discourse-ai/ai-share/#{short_path}?#{VERSION}")
::UrlHelper.local_cdn_url("/plugins/discourse-ai/ai-share/#{short_path}?#{VERSION}")
end
end
end
+1 -1
View File
@@ -12,7 +12,7 @@ module Jobs
topic = target.is_a?(Topic) ? target : target.topic
post = target.is_a?(Post) ? target : target.first_post
return if topic.blank? || post.blank?
return unless topic.present? && post.present?
return if topic.private_message? && !SiteSetting.ai_embeddings_generate_for_pms
return if post.raw.blank?
+5 -13
View File
@@ -7,35 +7,27 @@ module Jobs
def execute(args)
return unless post = Post.includes(:topic).find_by(id: args[:post_id])
return unless user = User.find_by(id: args[:user_id])
return unless args[:text]
return unless args[:term_to_explain]
topic = post.topic
reply_to = post.reply_to_post
return unless user.guardian.can_see?(post)
prompt = CompletionPrompt.enabled_by_name(args[:prompt])
prompt = CompletionPrompt.enabled_by_name("explain")
if prompt.id == CompletionPrompt::CUSTOM_PROMPT
prompt.custom_instruction = args[:custom_prompt]
end
if prompt.name == "explain"
input = <<~TEXT
<term>#{args[:text]}</term>
input = <<~TEXT
<term>#{args[:term_to_explain]}</term>
<context>#{post.raw}</context>
<topic>#{topic.title}</topic>
#{reply_to ? "<replyTo>#{reply_to.raw}</replyTo>" : nil}
TEXT
else
input = args[:text]
end
DiscourseAi::AiHelper::Assistant.new.stream_prompt(
prompt,
input,
user,
"/discourse-ai/ai-helper/stream_suggestion/#{post.id}",
"/discourse-ai/ai-helper/explain/#{post.id}",
)
end
end
@@ -1,50 +0,0 @@
# frozen_string_literal: true
module Jobs
class StreamTopicAiSummary < ::Jobs::Base
sidekiq_options retry: false
def execute(args)
return unless topic = Topic.find_by(id: args[:topic_id])
return unless user = User.find_by(id: args[:user_id])
strategy = DiscourseAi::Summarization.default_strategy
return if strategy.nil? || !Guardian.new(user).can_see_summary?(topic)
guardian = Guardian.new(user)
return unless guardian.can_see?(topic)
skip_age_check = !!args[:skip_age_check]
streamed_summary = +""
start = Time.now
summary =
DiscourseAi::TopicSummarization
.new(strategy, topic, user)
.summarize(skip_age_check: skip_age_check) do |partial_summary|
streamed_summary << partial_summary
# Throttle updates.
if (Time.now - start > 0.5) || Rails.env.test?
payload = { done: false, ai_topic_summary: { summarized_text: streamed_summary } }
publish_update(topic, user, payload)
start = Time.now
end
end
publish_update(
topic,
user,
AiTopicSummarySerializer.new(summary, { scope: guardian }).as_json.merge(done: true),
)
end
private
def publish_update(topic, user, payload)
MessageBus.publish("/discourse-ai/summaries/topic/#{topic.id}", payload, user_ids: [user.id])
end
end
end
@@ -12,13 +12,6 @@ module ::Jobs
return unless post.topic.custom_fields[DiscourseAi::AiBot::EntryPoint::REQUIRE_TITLE_UPDATE]
DiscourseAi::AiBot::Playground.new(bot).title_playground(post)
publish_update(post.topic, { title: post.topic.title })
end
def publish_update(topic, payload)
allowed_users = topic.topic_allowed_users.pluck(:user_id)
MessageBus.publish("/discourse-ai/ai-bot/topic/#{topic.id}", payload, user_ids: allowed_users)
end
end
end
+208 -87
View File
File diff suppressed because it is too large Load Diff
-32
View File
@@ -1,32 +0,0 @@
# frozen_string_literal: true
class AiSummary < ActiveRecord::Base
belongs_to :target, polymorphic: true
def mark_as_outdated
@outdated = true
end
def outdated
@outdated || false
end
end
# == Schema Information
#
# Table name: ai_summaries
#
# id :bigint not null, primary key
# target_id :integer not null
# target_type :string not null
# content_range :int4range
# summarized_text :string not null
# original_content_sha :string not null
# algorithm :string not null
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_ai_summaries_on_target_type_and_target_id (target_type,target_id)
#
-186
View File
@@ -1,186 +0,0 @@
# frozen_string_literal: true
class AiTool < ActiveRecord::Base
validates :name, presence: true, length: { maximum: 100 }
validates :description, presence: true, length: { maximum: 1000 }
validates :summary, presence: true, length: { maximum: 255 }
validates :script, presence: true, length: { maximum: 100_000 }
validates :created_by_id, presence: true
belongs_to :created_by, class_name: "User"
def signature
{ name: name, description: description, parameters: parameters.map(&:symbolize_keys) }
end
def runner(parameters, llm:, bot_user:, context: {})
DiscourseAi::AiBot::ToolRunner.new(
parameters: parameters,
llm: llm,
bot_user: bot_user,
context: context,
tool: self,
)
end
after_commit :bump_persona_cache
def bump_persona_cache
AiPersona.persona_cache.flush!
end
def self.presets
[
{
preset_id: "browse_web_jina",
name: "browse_web",
description: "Browse the web as a markdown document",
parameters: [
{ name: "url", type: "string", required: true, description: "The URL to browse" },
],
script: <<~SCRIPT,
let url;
function invoke(p) {
url = p.url;
result = http.get(`https://r.jina.ai/${url}`);
// truncates to 15000 tokens
return llm.truncate(result.body, 15000);
}
function details() {
return "Read: " + url
}
SCRIPT
},
{
preset_id: "exchange_rate",
name: "exchange_rate",
description: "Get current exchange rates for various currencies",
parameters: [
{
name: "base_currency",
type: "string",
required: true,
description: "The base currency code (e.g., USD, EUR)",
},
{
name: "target_currency",
type: "string",
required: true,
description: "The target currency code (e.g., EUR, JPY)",
},
{ name: "amount", type: "number", description: "Amount to convert eg: 123.45" },
],
script: <<~SCRIPT,
// note: this script uses the open.er-api.com service, it is only updated
// once every 24 hours, for more up to date rates see: https://www.exchangerate-api.com
function invoke(params) {
const url = `https://open.er-api.com/v6/latest/${params.base_currency}`;
const result = http.get(url);
if (result.status !== 200) {
return { error: "Failed to fetch exchange rates" };
}
const data = JSON.parse(result.body);
const rate = data.rates[params.target_currency];
if (!rate) {
return { error: "Target currency not found" };
}
const rval = {
base_currency: params.base_currency,
target_currency: params.target_currency,
exchange_rate: rate,
last_updated: data.time_last_update_utc
};
if (params.amount) {
rval.original_amount = params.amount;
rval.converted_amount = params.amount * rate;
}
return rval;
}
function details() {
return "<a href='https://www.exchangerate-api.com'>Rates By Exchange Rate API</a>";
}
SCRIPT
summary: "Get current exchange rates between two currencies",
},
{
preset_id: "stock_quote",
name: "stock_quote",
description: "Get real-time stock quote information using AlphaVantage API",
parameters: [
{
name: "symbol",
type: "string",
required: true,
description: "The stock symbol (e.g., AAPL, GOOGL)",
},
],
script: <<~SCRIPT,
function invoke(params) {
const apiKey = 'YOUR_ALPHAVANTAGE_API_KEY'; // Replace with your actual API key
const url = `https://www.alphavantage.co/query?function=GLOBAL_QUOTE&symbol=${params.symbol}&apikey=${apiKey}`;
const result = http.get(url);
if (result.status !== 200) {
return { error: "Failed to fetch stock quote" };
}
const data = JSON.parse(result.body);
if (data['Error Message']) {
return { error: data['Error Message'] };
}
const quote = data['Global Quote'];
if (!quote || Object.keys(quote).length === 0) {
return { error: "No data found for the given symbol" };
}
return {
symbol: quote['01. symbol'],
price: parseFloat(quote['05. price']),
change: parseFloat(quote['09. change']),
change_percent: quote['10. change percent'],
volume: parseInt(quote['06. volume']),
latest_trading_day: quote['07. latest trading day']
};
}
function details() {
return "<a href='https://www.alphavantage.co'>Stock data provided by AlphaVantage</a>";
}
SCRIPT
summary: "Get real-time stock quotes using AlphaVantage API",
},
{ preset_id: "empty_tool", script: <<~SCRIPT },
function invoke(params) {
// logic here
return params;
}
function details() {
return "Details about this tool";
}
SCRIPT
].map do |preset|
preset[:preset_name] = I18n.t("discourse_ai.tools.presets.#{preset[:preset_id]}.name")
preset
end
end
end
# == Schema Information
#
# Table name: ai_tools
#
# id :bigint not null, primary key
# name :string not null
# description :string not null
# summary :string not null
# parameters :jsonb not null
# script :text not null
# created_by_id :integer not null
# enabled :boolean default(TRUE), not null
# created_at :datetime not null
# updated_at :datetime not null
#
+2 -2
View File
@@ -6,7 +6,7 @@ end
# == Schema Information
#
# Table name: chat_message_custom_prompts
# Table name: message_custom_prompts
#
# id :bigint not null, primary key
# message_id :bigint not null
@@ -16,5 +16,5 @@ end
#
# Indexes
#
# index_chat_message_custom_prompts_on_message_id (message_id) UNIQUE
# index_message_custom_prompts_on_message_id (message_id) UNIQUE
#
-104
View File
@@ -1,109 +1,9 @@
# frozen_string_literal: true
class LlmModel < ActiveRecord::Base
FIRST_BOT_USER_ID = -1200
BEDROCK_PROVIDER_NAME = "aws_bedrock"
belongs_to :user
validates :display_name, presence: true, length: { maximum: 100 }
validates :tokenizer, presence: true, inclusion: DiscourseAi::Completions::Llm.tokenizer_names
validates :provider, presence: true, inclusion: DiscourseAi::Completions::Llm.provider_names
validates :url, presence: true, unless: -> { provider == BEDROCK_PROVIDER_NAME }
validates_presence_of :name, :api_key
validates :max_prompt_tokens, numericality: { greater_than: 0 }
validate :required_provider_params
def self.provider_params
{
aws_bedrock: {
access_key_id: :text,
region: :text,
},
open_ai: {
organization: :text,
},
hugging_face: {
disable_system_prompt: :checkbox,
},
vllm: {
disable_system_prompt: :checkbox,
},
ollama: {
disable_system_prompt: :checkbox,
},
}
end
def to_llm
DiscourseAi::Completions::Llm.proxy("custom:#{id}")
end
def toggle_companion_user
return if name == "fake" && Rails.env.production?
enable_check = SiteSetting.ai_bot_enabled && enabled_chat_bot
if enable_check
if !user
next_id = DB.query_single(<<~SQL).first
SELECT min(id) - 1 FROM users
SQL
new_user =
User.new(
id: [FIRST_BOT_USER_ID, next_id].min,
email: "no_email_#{SecureRandom.hex}",
name: name.titleize,
username: UserNameSuggester.suggest(name),
active: true,
approved: true,
admin: true,
moderator: true,
trust_level: TrustLevel[4],
)
new_user.save!(validate: false)
self.update!(user: new_user)
else
user.active = true
user.save!(validate: false)
end
elsif user
# will include deleted
has_posts = DB.query_single("SELECT 1 FROM posts WHERE user_id = #{user.id} LIMIT 1").present?
if has_posts
user.update!(active: false) if user.active
else
user.destroy!
self.update!(user: nil)
end
end
end
def tokenizer_class
tokenizer.constantize
end
def lookup_custom_param(key)
provider_params&.dig(key)
end
def seeded?
id < 0
end
private
def required_provider_params
return if provider != BEDROCK_PROVIDER_NAME
%w[access_key_id region].each do |field|
if lookup_custom_param(field).blank?
errors.add(:base, I18n.t("discourse_ai.llm_models.missing_provider_param", param: field))
end
end
end
end
# == Schema Information
@@ -120,8 +20,4 @@ end
# updated_at :datetime not null
# url :string
# api_key :string
# user_id :integer
# enabled_chat_bot :boolean default(FALSE), not null
# provider_params :jsonb
# vision_enabled :boolean default(FALSE), not null
#

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