Skip to content

Commit

Permalink
DEV: Rewire AI bot internals to use LlmModel (#638)
Browse files Browse the repository at this point in the history
* DRAFT: Create AI Bot users dynamically and support custom LlmModels

* Get user associated to llm_model

* Track enabled bots with attribute

* Don't store bot username. Minor touches to migrate default values in settings

* Handle scenario where vLLM uses a SRV record

* Made 3.5-turbo-16k the default version so we can remove hack
  • Loading branch information
romanrizzi committed Jun 18, 2024
1 parent cc0b222 commit 8d5f901
Show file tree
Hide file tree
Showing 56 changed files with 822 additions and 382 deletions.
2 changes: 2 additions & 0 deletions app/controllers/discourse_ai/admin/ai_llms_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ def update
llm_model = LlmModel.find(params[:id])

if llm_model.update(ai_llm_params)
llm_model.toggle_companion_user
render json: llm_model
else
render_json_error llm_model
Expand Down Expand Up @@ -106,6 +107,7 @@ def ai_llm_params
:max_prompt_tokens,
:url,
:api_key,
:enabled_chat_bot,
)
end
end
Expand Down
8 changes: 3 additions & 5 deletions app/controllers/discourse_ai/ai_bot/bot_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,10 @@ def stop_streaming_response
end

def show_bot_username
bot_user_id = DiscourseAi::AiBot::EntryPoint.map_bot_model_to_user_id(params[:username])
raise Discourse::InvalidParameters.new(:username) if !bot_user_id
bot_user = DiscourseAi::AiBot::EntryPoint.find_user_from_model(params[:username])
raise Discourse::InvalidParameters.new(:username) if !bot_user

bot_username_lower = User.find(bot_user_id).username_lower

render json: { bot_username: bot_username_lower }, status: 200
render json: { bot_username: bot_user.username_lower }, status: 200
end
end
end
Expand Down
60 changes: 26 additions & 34 deletions app/models/ai_persona.rb
Original file line number Diff line number Diff line change
Expand Up @@ -252,40 +252,32 @@ def ensure_not_system
#
# Table name: ai_personas
#
# id :bigint not null, primary key
# name :string(100) not null
# description :string(2000) not null
# tools :json not null
# system_prompt :string(10000000) not null
# allowed_group_ids :integer default([]), not null, is an Array
# created_by_id :integer
# enabled :boolean default(TRUE), not null
# created_at :datetime not null
# updated_at :datetime not null
# system :boolean default(FALSE), not null
# priority :boolean default(FALSE), not null
# temperature :float
# top_p :float
# user_id :integer
# mentionable :boolean default(FALSE), not null
# default_llm :text
# max_context_posts :integer
# max_post_context_tokens :integer
# max_context_tokens :integer
# vision_enabled :boolean default(FALSE), not null
# vision_max_pixels :integer default(1048576), not null
# rag_chunk_tokens :integer default(374), not null
# rag_chunk_overlap_tokens :integer default(10), not null
# rag_conversation_chunks :integer default(10), not null
# role :enum default("bot"), not null
# role_category_ids :integer default([]), not null, is an Array
# role_tags :string default([]), not null, is an Array
# role_group_ids :integer default([]), not null, is an Array
# role_whispers :boolean default(FALSE), not null
# role_max_responses_per_hour :integer default(50), not null
# question_consolidator_llm :text
# allow_chat :boolean default(FALSE), not null
# tool_details :boolean default(TRUE), not null
# id :bigint not null, primary key
# name :string(100) not null
# description :string(2000) not null
# system_prompt :string(10000000) not null
# allowed_group_ids :integer default([]), not null, is an Array
# created_by_id :integer
# enabled :boolean default(TRUE), not null
# created_at :datetime not null
# updated_at :datetime not null
# system :boolean default(FALSE), not null
# priority :boolean default(FALSE), not null
# temperature :float
# top_p :float
# user_id :integer
# mentionable :boolean default(FALSE), not null
# default_llm :text
# max_context_posts :integer
# vision_enabled :boolean default(FALSE), not null
# vision_max_pixels :integer default(1048576), not null
# rag_chunk_tokens :integer default(374), not null
# rag_chunk_overlap_tokens :integer default(10), not null
# rag_conversation_chunks :integer default(10), not null
# question_consolidator_llm :text
# allow_chat :boolean default(FALSE), not null
# tool_details :boolean default(TRUE), not null
# tools :json not null
#
# Indexes
#
Expand Down
4 changes: 2 additions & 2 deletions app/models/chat_message_custom_prompt.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ class ChatMessageCustomPrompt < ActiveRecord::Base

# == Schema Information
#
# Table name: message_custom_prompts
# Table name: chat_message_custom_prompts
#
# id :bigint not null, primary key
# message_id :bigint not null
Expand All @@ -16,5 +16,5 @@ class ChatMessageCustomPrompt < ActiveRecord::Base
#
# Indexes
#
# index_message_custom_prompts_on_message_id (message_id) UNIQUE
# index_chat_message_custom_prompts_on_message_id (message_id) UNIQUE
#
71 changes: 71 additions & 0 deletions app/models/llm_model.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,75 @@
# frozen_string_literal: true

class LlmModel < ActiveRecord::Base
FIRST_BOT_USER_ID = -1200
RESERVED_VLLM_SRV_URL = "https://vllm.shadowed-by-srv.invalid"

belongs_to :user

validates :url, exclusion: { in: [RESERVED_VLLM_SRV_URL] }

def self.enable_or_disable_srv_llm!
srv_model = find_by(url: RESERVED_VLLM_SRV_URL)
if SiteSetting.ai_vllm_endpoint_srv.present? && srv_model.blank?
record =
new(
display_name: "vLLM SRV LLM",
name: "mistralai/Mixtral",
provider: "vllm",
tokenizer: "DiscourseAi::Tokenizer::MixtralTokenizer",
url: RESERVED_VLLM_SRV_URL,
vllm_key: "",
user_id: nil,
enabled_chat_bot: false,
)

record.save(validate: false) # Ignore reserved URL validation
elsif srv_model.present?
srv_model.destroy!
end
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_#{name.underscore}",
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.update!(active: true)
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
Expand All @@ -20,4 +89,6 @@ def tokenizer_class
# updated_at :datetime not null
# url :string
# api_key :string
# user_id :integer
# enabled_chat_bot :boolean default(FALSE), not null
#
14 changes: 5 additions & 9 deletions app/models/shared_ai_conversation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -133,12 +133,10 @@ def formatted_excerpt
end

def self.build_conversation_data(topic, max_posts: DEFAULT_MAX_POSTS, include_usernames: false)
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
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 = ActiveSupport::Inflector.humanize(llm_name) if llm_name
llm_name ||= I18n.t("discourse_ai.unknown_model")
Expand Down Expand Up @@ -170,9 +168,7 @@ def self.build_conversation_data(topic, max_posts: DEFAULT_MAX_POSTS, include_us
cooked: post.cooked,
}

mapped[:persona] = persona if ::DiscourseAi::AiBot::EntryPoint::BOT_USER_IDS.include?(
post.user_id,
)
mapped[:persona] = persona if ai_bot_participant&.id == post.user_id
mapped[:username] = post.user&.username if include_usernames
mapped
end,
Expand Down
15 changes: 14 additions & 1 deletion app/serializers/llm_model_serializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,18 @@
class LlmModelSerializer < ApplicationSerializer
root "llm"

attributes :id, :display_name, :name, :provider, :max_prompt_tokens, :tokenizer, :api_key, :url
attributes :id,
:display_name,
:name,
:provider,
:max_prompt_tokens,
:tokenizer,
:api_key,
:url,
:enabled_chat_bot,
:url_editable

def url_editable
object.url != LlmModel::RESERVED_VLLM_SRV_URL
end
end
3 changes: 2 additions & 1 deletion assets/javascripts/discourse/admin/models/ai-llm.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ export default class AiLlm extends RestModel {
"tokenizer",
"max_prompt_tokens",
"url",
"api_key"
"api_key",
"enabled_chat_bot"
);
}

Expand Down
16 changes: 11 additions & 5 deletions assets/javascripts/discourse/components/ai-bot-header-icon.gjs
Original file line number Diff line number Diff line change
@@ -1,19 +1,25 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
import { service } from "@ember/service";
import { gt } from "truth-helpers";
import DButton from "discourse/components/d-button";
import i18n from "discourse-common/helpers/i18n";
import { composeAiBotMessage } from "../lib/ai-bot-helper";

export default class AiBotHeaderIcon extends Component {
@service currentUser;
@service siteSettings;
@service composer;

get bots() {
return this.siteSettings.ai_bot_add_to_header
? this.siteSettings.ai_bot_enabled_chat_bots.split("|").filter(Boolean)
: [];
const availableBots = this.currentUser.ai_enabled_chat_bots
.filter((bot) => !bot.is_persosna)
.filter(Boolean);

return availableBots ? availableBots.map((bot) => bot.model_name) : [];
}

get showHeaderButton() {
return this.bots.length > 0 && this.siteSettings.ai_bot_add_to_header;
}

@action
Expand All @@ -22,7 +28,7 @@ export default class AiBotHeaderIcon extends Component {
}

<template>
{{#if (gt this.bots.length 0)}}
{{#if this.showHeaderButton}}
<li>
<DButton
@action={{this.compose}}
Expand Down
49 changes: 40 additions & 9 deletions assets/javascripts/discourse/components/ai-llm-editor.gjs
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { Input } from "@ember/component";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { later } from "@ember/runloop";
import { inject as service } from "@ember/service";
import BackButton from "discourse/components/back-button";
import DButton from "discourse/components/d-button";
import DToggleSwitch from "discourse/components/d-toggle-switch";
import { popupAjaxError } from "discourse/lib/ajax-error";
import icon from "discourse-common/helpers/d-icon";
import i18n from "discourse-common/helpers/i18n";
Expand Down Expand Up @@ -110,11 +112,31 @@ export default class AiLlmEditor extends Component {
});
}

@action
async toggleEnabledChatBot() {
this.args.model.set("enabled_chat_bot", !this.args.model.enabled_chat_bot);
if (!this.args.model.isNew) {
try {
await this.args.model.update({
enabled_chat_bot: this.args.model.enabled_chat_bot,
});
} catch (e) {
popupAjaxError(e);
}
}
}

<template>
<BackButton
@route="adminPlugins.show.discourse-ai-llms"
@label="discourse_ai.llms.back"
/>
{{#unless @model.url_editable}}
<div class="alert alert-info">
{{icon "exclamation-circle"}}
{{I18n.t "discourse_ai.llms.srv_warning"}}
</div>
{{/unless}}
<form class="form-horizontal ai-llm-editor">
<div class="control-group">
<label>{{i18n "discourse_ai.llms.display_name"}}</label>
Expand Down Expand Up @@ -143,14 +165,16 @@ export default class AiLlmEditor extends Component {
@content={{this.selectedProviders}}
/>
</div>
<div class="control-group">
<label>{{I18n.t "discourse_ai.llms.url"}}</label>
<Input
class="ai-llm-editor-input ai-llm-editor__url"
@type="text"
@value={{@model.url}}
/>
</div>
{{#if @model.url_editable}}
<div class="control-group">
<label>{{I18n.t "discourse_ai.llms.url"}}</label>
<Input
class="ai-llm-editor-input ai-llm-editor__url"
@type="text"
@value={{@model.url}}
/>
</div>
{{/if}}
<div class="control-group">
<label>{{I18n.t "discourse_ai.llms.api_key"}}</label>
<Input
Expand Down Expand Up @@ -181,7 +205,14 @@ export default class AiLlmEditor extends Component {
@content={{I18n.t "discourse_ai.llms.hints.max_prompt_tokens"}}
/>
</div>

<div class="control-group">
<DToggleSwitch
class="ai-llm-editor__enabled-chat-bot"
@state={{@model.enabled_chat_bot}}
@label="discourse_ai.llms.enabled_chat_bot"
{{on "click" this.toggleEnabledChatBot}}
/>
</div>
<div class="control-group ai-llm-editor__action_panel">
<DButton
class="ai-llm-editor__test"
Expand Down
Loading

0 comments on commit 8d5f901

Please sign in to comment.