Compare commits

..
331 changed files with 2910 additions and 10766 deletions
+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.1)
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
@@ -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,48 +33,17 @@ 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 LlmModelSerializer.new(llm_model)
render_json_error llm_model
end
end
def update
llm_model = LlmModel.find(params[:id])
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 LlmModelSerializer.new(llm_model)
end
end
def destroy
llm_model = LlmModel.find(params[:id])
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
@@ -87,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
@@ -96,32 +67,16 @@ module DiscourseAi
private
def ai_llm_params(updating: nil)
permitted =
params.require(:ai_llm).permit(
:display_name,
:name,
:provider,
:tokenizer,
:max_prompt_tokens,
:api_key,
:enabled_chat_bot,
)
provider = updating ? updating.provider : permitted[:provider]
permit_url =
(updating && updating.url != LlmModel::RESERVED_VLLM_SRV_URL) ||
provider != LlmModel::BEDROCK_PROVIDER_NAME
permitted[:url] = params.dig(:ai_llm, :url) if permit_url
extra_field_names = LlmModel.provider_params.dig(provider&.to_sym, :fields).to_a
received_prov_params = params.dig(:ai_llm, :provider_params)
permitted[:provider_params] = received_prov_params.slice(
*extra_field_names,
).permit! if !extra_field_names.empty? && received_prov_params.present?
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,9 +50,9 @@ 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 LocalizedAiPersonaSerializer.new(@ai_persona, root: false)
render_json_error @ai_persona
end
end
@@ -71,7 +60,7 @@ module DiscourseAi
if @ai_persona.destroy
head :no_content
else
render_json_error LocalizedAiPersonaSerializer.new(@ai_persona, root: false)
render_json_error @ai_persona
end
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, enum_values: []],
)
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
@@ -109,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]
@@ -125,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")})",
@@ -161,6 +181,15 @@ module DiscourseAi
raise Discourse::InvalidAccess
end
end
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
end
@@ -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,36 +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)
if params[:stream] && current_user
Jobs.enqueue(
:stream_topic_ai_summary,
topic_id: topic.id,
user_id: current_user.id,
opts: opts.as_json,
)
render json: success_json
else
hijack do
summary = DiscourseAi::TopicSummarization.summarize(topic, current_user, opts)
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?
@@ -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)
opts = args[:opts] || {}
streamed_summary = +""
start = Time.now
summary =
DiscourseAi::TopicSummarization
.new(strategy)
.summarize(topic, user, opts) 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
+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,111 +1,9 @@
# frozen_string_literal: true
class LlmModel < ActiveRecord::Base
FIRST_BOT_USER_ID = -1200
RESERVED_VLLM_SRV_URL = "https://vllm.shadowed-by-srv.invalid"
BEDROCK_PROVIDER_NAME = "aws_bedrock"
belongs_to :user
validates :url, exclusion: { in: [RESERVED_VLLM_SRV_URL] }, if: :url_changed?
def self.seed_srv_backed_model
srv = SiteSetting.ai_vllm_endpoint_srv
srv_model = find_by(url: RESERVED_VLLM_SRV_URL)
if srv.present?
if srv_model.present?
current_key = SiteSetting.ai_vllm_api_key
srv_model.update!(api_key: current_key) if current_key != srv_model.api_key
else
record =
new(
display_name: "vLLM SRV LLM",
name: "mistralai/Mixtral-8x7B-Instruct-v0.1",
provider: "vllm",
tokenizer: "DiscourseAi::Tokenizer::MixtralTokenizer",
url: RESERVED_VLLM_SRV_URL,
max_prompt_tokens: 8000,
api_key: SiteSetting.ai_vllm_api_key,
)
record.save(validate: false) # Ignore reserved URL validation
end
else
# Clean up companion users
srv_model&.enabled_chat_bot = false
srv_model&.toggle_companion_user
srv_model&.destroy!
end
end
def self.provider_params
{
aws_bedrock: {
url_editable: false,
fields: %i[access_key_id region],
},
open_ai: {
url_editable: true,
fields: %i[organization],
},
}
end
def to_llm
DiscourseAi::Completions::Llm.proxy_from_obj(self)
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
end
# == Schema Information
@@ -122,6 +20,4 @@ end
# updated_at :datetime not null
# url :string
# api_key :string
# user_id :integer
# enabled_chat_bot :boolean default(FALSE), not null
#
+2 -2
View File
@@ -2,8 +2,8 @@
class ModelAccuracy < ActiveRecord::Base
def self.adjust_model_accuracy(new_status, reviewable)
return if %i[approved rejected].exclude?(new_status)
return if [ReviewableAiPost, ReviewableAiChatMessage].exclude?(reviewable.class)
return unless %i[approved rejected].include?(new_status)
return unless [ReviewableAiPost, ReviewableAiChatMessage].include?(reviewable.class)
verdicts = reviewable.payload.to_h["verdicts"] || {}
+9 -5
View File
@@ -133,10 +133,12 @@ class SharedAiConversation < ActiveRecord::Base
end
def self.build_conversation_data(topic, max_posts: DEFAULT_MAX_POSTS, include_usernames: false)
allowed_user_ids = topic.topic_allowed_users.pluck(:user_id)
ai_bot_participant = DiscourseAi::AiBot::EntryPoint.find_participant_in(allowed_user_ids)
llm_name = ai_bot_participant&.llm
llm_name = nil
topic.topic_allowed_users.each do |tu|
if DiscourseAi::AiBot::EntryPoint::BOT_USER_IDS.include?(tu.user_id)
llm_name = DiscourseAi::AiBot::EntryPoint.find_bot_by_id(tu.user_id)&.llm
end
end
llm_name = ActiveSupport::Inflector.humanize(llm_name) if llm_name
llm_name ||= I18n.t("discourse_ai.unknown_model")
@@ -168,7 +170,9 @@ class SharedAiConversation < ActiveRecord::Base
cooked: post.cooked,
}
mapped[:persona] = persona if ai_bot_participant&.id == post.user_id
mapped[:persona] = persona if ::DiscourseAi::AiBot::EntryPoint::BOT_USER_IDS.include?(
post.user_id,
)
mapped[:username] = post.user&.username if include_usernames
mapped
end,
@@ -1,16 +0,0 @@
# frozen_string_literal: true
class AiApiAuditLogSerializer < ApplicationSerializer
attributes :id,
:provider_id,
:user_id,
:request_tokens,
:response_tokens,
:raw_request_payload,
:raw_response_payload,
:topic_id,
:post_id,
:feature_name,
:language_model,
:created_at
end
@@ -3,17 +3,6 @@
class AiChatChannelSerializer < ApplicationSerializer
attributes :id, :chatable, :chatable_type, :chatable_url, :slug
def chatable
case object.chatable_type
when "Category"
BasicCategorySerializer.new(object.chatable, root: false).as_json
when "DirectMessage"
Chat::DirectMessageSerializer.new(object.chatable, scope: scope, root: false).as_json
when "Site"
nil
end
end
def title
# Display all participants for a DM.
# For category channels, the argument is ignored.
@@ -1,15 +0,0 @@
# frozen_string_literal: true
class AiCustomToolListSerializer < ApplicationSerializer
attributes :meta
has_many :ai_tools, serializer: AiCustomToolSerializer, embed: :objects
def meta
{ presets: AiTool.presets }
end
def ai_tools
object[:ai_tools]
end
end
@@ -1,15 +0,0 @@
# frozen_string_literal: true
class AiCustomToolSerializer < ApplicationSerializer
attributes :id,
:name,
:description,
:summary,
:parameters,
:script,
:created_by_id,
:created_at,
:updated_at
self.root = "ai_tool"
end

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