diff --git a/README.md b/README.md index 78a5cff5b..fa952715d 100644 --- a/README.md +++ b/README.md @@ -66,11 +66,11 @@ If you encountered an error while waiting for the services to be deployed on Ren # Deploy the app on Fly -Deploying to Fly.io is another great option. It's not quite one-click like Render and it's not 100% free. But we've made the configuration really easy for you and the cost should be about $2 per month, and Render costs $7 per month after 90 days of free servie so Fly is actually less expensive over the long term. +Deploying to Fly.io is another great option. It's not quite one-click like Render and it's not 100% free. But we've made the configuration really easy for you and the cost should be about $2 per month, and Render costs $7 per month after 90 days of free service so Fly is actually less expensive over the long term. 1. Click Fork > Create New Fork at the top of this repository. Pull your forked repository down to your computer (the usual git clone ...). 1. Install the Fly command-line tool [view instructions](https://fly.io/docs/hands-on/install-flyctl/) -1. In the root directory of the repoistory you pulled down, run `fly launch --build-only` and say `Yes` to copy the existing fly.toml, but note that it will generate the wrong settings. +1. In the root directory of the repository you pulled down, run `fly launch --build-only` and say `Yes` to copy the existing fly.toml, but note that it will generate the wrong settings. 1. **The settings it shows are INCORRECT** so tell it you want to make changes 1. When it opens your browser, change the Database to `Fly Postgres` with a name such as `hostedgpt-db` and you can set the configuration to `Development`. 1. Click `Confirm Settings` at the bottom of the page and close the browser. @@ -81,7 +81,7 @@ Deploying to Fly.io is another great option. It's not quite one-click like Rende # Deploy the app on Heroku -Heroku is a one-click option that will cost $10/monnth for the compute (dyno) and database. By default, apps use Eco dynos ($5) if you are subscribed to Eco. Otherwise, it defaults to Basic dynos ($7). The Eco dynos plan is shared across all Eco dynos in your account and is recommended if you plan on deploying many small apps to Heroku. Eco dynos "sleep" after 30 minutes of inactivity and take a few seconds to wake up. Basic dynos do not sleep. +Heroku is a one-click option that will cost $10/month for the compute (dyno) and database. By default, apps use Eco dynos ($5) if you are subscribed to Eco. Otherwise, it defaults to Basic dynos ($7). The Eco dynos plan is shared across all Eco dynos in your account and is recommended if you plan on deploying many small apps to Heroku. Eco dynos "sleep" after 30 minutes of inactivity and take a few seconds to wake up. Basic dynos do not sleep. Eligible students can apply for Heroku platform credits through [Heroku for GitHub Students program](https://blog.heroku.com/github-student-developer-program). @@ -94,7 +94,7 @@ Eligible students can apply for Heroku platform credits through [Heroku for GitH # Contribute as a developer -We welcome contributors! After you get your developoment environment setup, review the list of Issues. We organize the issues into Milestones and are currently working on v0.7. [View 0.7 Milestone](https://github.com/allyourbot/hostedgpt/milestone/6). Look for any issues tagged with **Good first issue** and add a comment so we know you're working on it. +We welcome contributors! After you get your development environment setup, review the list of Issues. We organize the issues into Milestones and are currently working on v0.7. [View 0.7 Milestone](https://github.com/allyourbot/hostedgpt/milestone/6). Look for any issues tagged with **Good first issue** and add a comment so we know you're working on it. ## Setting up development diff --git a/app/controllers/assistants_controller.rb b/app/controllers/assistants_controller.rb index 2f666065c..b882e545e 100644 --- a/app/controllers/assistants_controller.rb +++ b/app/controllers/assistants_controller.rb @@ -35,7 +35,7 @@ def update end def destroy - @assistant.destroy! + @assistant.soft_delete redirect_to assistants_url, notice: "Assistant was successfully destroyed.", status: :see_other end @@ -46,6 +46,6 @@ def set_assistant end def assistant_params - params.require(:assistant).permit(:user_id, :model, :name, :description, :instructions, :tools) + params.require(:assistant).permit(:user_id, :language_model_id, :name, :description, :instructions, :tools) end end diff --git a/app/controllers/messages_controller.rb b/app/controllers/messages_controller.rb index b16573ebd..bf0279860 100644 --- a/app/controllers/messages_controller.rb +++ b/app/controllers/messages_controller.rb @@ -56,8 +56,6 @@ def create end def update - # Clicking edit beneath a message actually submits to create and not here. This action is only used for next/prev conversation. - # In order to force a morph we PATCH to here and redirect. if @message.update(message_params) redirect_to conversation_messages_path(@message.conversation, version: @version || @message.version) else @@ -76,7 +74,7 @@ def set_conversation end def set_assistant - @assistant = Current.user.assistants.find_by(id: params[:assistant_id]) + @assistant = Current.user.assistants_including_deleted.find_by(id: params[:assistant_id]) @assistant ||= @conversation.latest_message_for_version(@version).assistant end diff --git a/app/controllers/settings/application_controller.rb b/app/controllers/settings/application_controller.rb index 6ceccc585..087db0c60 100644 --- a/app/controllers/settings/application_controller.rb +++ b/app/controllers/settings/application_controller.rb @@ -7,15 +7,16 @@ class Settings::ApplicationController < ApplicationController def set_settings_menu # controller_name => array of items @settings_menu = { + people: { + "Your Account": edit_settings_person_path + }, + assistants: Current.user.assistants.ordered.map { |assistant| [ assistant, edit_settings_assistant_path(assistant) ] }.to_h.merge({ - #'New Assistant': new_settings_assistant_path(assistant) - }), + "New Assistant": new_settings_assistant_path + }) - people: { - 'Account': edit_settings_person_path - } } end end diff --git a/app/controllers/settings/assistants_controller.rb b/app/controllers/settings/assistants_controller.rb index 6a254a243..4c53a4f5f 100644 --- a/app/controllers/settings/assistants_controller.rb +++ b/app/controllers/settings/assistants_controller.rb @@ -1,5 +1,6 @@ class Settings::AssistantsController < Settings::ApplicationController before_action :set_assistant, only: [:edit, :update, :destroy] + before_action :set_last_assistant, except: [:destroy] def new @assistant = Assistant.new @@ -27,17 +28,27 @@ def update end def destroy - @assistant.destroy! - redirect_to new_settings_assistant_url, notice: "Deleted", status: :see_other + if @assistant.soft_delete + redirect_to new_settings_assistant_url, notice: "Deleted", status: :see_other + else + redirect_to new_settings_assistant_url, alert: "Cannot delete your last assistant", status: :see_other + end end private def set_assistant - @assistant = Current.user.assistants.find(params[:id]) + @assistant = Current.user.assistants.find_by(id: params[:id]) + if @assistant.nil? + redirect_to new_settings_assistant_url, notice: "The assistant was deleted", status: :see_other + end + end + + def set_last_assistant + @last_assistant = Current.user.assistants.count <= 1 end def assistant_params - params.require(:assistant).permit(:name, :description, :instructions) + params.require(:assistant).permit(:name, :description, :instructions, :language_model_id) end end diff --git a/app/jobs/get_next_ai_message_job.rb b/app/jobs/get_next_ai_message_job.rb index e8e418e2d..46679c0a6 100644 --- a/app/jobs/get_next_ai_message_job.rb +++ b/app/jobs/get_next_ai_message_job.rb @@ -5,11 +5,7 @@ class WaitForPrevious < StandardError; end retry_on WaitForPrevious, wait: ->(run) { (2**run - 1).seconds }, attempts: 3 def ai_backend - if @assistant.model.starts_with?('gpt-') - AIBackend::OpenAI - else - AIBackend::Anthropic - end + @assistant.language_model.ai_backend end def perform(user_id, message_id, assistant_id, attempt = 1) diff --git a/app/models/assistant.rb b/app/models/assistant.rb index 56371f76f..3eb4f33de 100644 --- a/app/models/assistant.rb +++ b/app/models/assistant.rb @@ -1,13 +1,20 @@ class Assistant < ApplicationRecord + MAX_LIST_DISPLAY = 5 + belongs_to :user has_many :conversations, dependent: :destroy has_many :documents, dependent: :destroy has_many :runs, dependent: :destroy has_many :steps, dependent: :destroy - has_many :messages # TODO: What should happen if an assistant is deleted? + has_many :messages, dependent: :destroy + + delegate :supports_images?, to: :language_model + + belongs_to :language_model validates :tools, presence: true, allow_blank: true + validates :name, presence: true scope :ordered, -> { order(:id) } @@ -20,6 +27,16 @@ def initials parts[1]&.try(:[], 0)&.capitalize.to_s end + def soft_delete + return false if user.assistants.count <= 1 + update!(deleted_at: Time.now) + return true + end + + def soft_delete! + raise "Can't delete user's last assistant" if !soft_delete + end + def to_s name end diff --git a/app/models/concerns/user/registerable.rb b/app/models/concerns/user/registerable.rb index 13438cabc..7e4b1fd86 100644 --- a/app/models/concerns/user/registerable.rb +++ b/app/models/concerns/user/registerable.rb @@ -8,9 +8,9 @@ module User::Registerable private def create_initial_assistants - assistants.create! name: "GPT-4", model: "gpt-4o-2024-05-13", images: true - assistants.create! name: "GPT-3.5", model: "gpt-3.5-turbo-0125", images: false - assistants.create! name: "Claude 3 Opus", model: "claude-3-opus-20240229", images: true - assistants.create! name: "Claude 3 Sonnet", model: "claude-3-sonnet-20240229", images: true + assistants.create! name: "GPT-4o", language_model: LanguageModel.find_by(name: 'gpt-4o') + assistants.create! name: "GPT-3.5", language_model: LanguageModel.find_by(name: 'gpt-3.5-turbo') + assistants.create! name: "Claude 3 Opus", language_model: LanguageModel.find_by(name: 'claude-3-opus-20240229') + assistants.create! name: "Claude 3 Sonnet", language_model: LanguageModel.find_by(name: 'claude-3-sonnet-20240229') end end diff --git a/app/models/language_model.rb b/app/models/language_model.rb new file mode 100644 index 000000000..8a241c152 --- /dev/null +++ b/app/models/language_model.rb @@ -0,0 +1,27 @@ +# We don't care about large or not +class LanguageModel < ApplicationRecord + BEST_MODELS = { + 'gpt-best' => 'gpt-4o-2024-05-13', + 'claude-best' => 'claude-3-opus-20240229' + } + + scope :ordered, -> { order(:position) } + + has_many :assistants + + def readonly? + !new_record? + end + + def provider_name + BEST_MODELS[name] || name + end + + def ai_backend + if name.starts_with?('gpt-') + AIBackend::OpenAI + else + AIBackend::Anthropic + end + end +end diff --git a/app/models/run.rb b/app/models/run.rb index a68d8b6d2..16b8a92e1 100644 --- a/app/models/run.rb +++ b/app/models/run.rb @@ -7,6 +7,13 @@ class Run < ApplicationRecord enum status: %w[queued in_progress requires_action cancelling cancelled failed completed expired].index_by(&:to_sym) + before_validation :set_model, on: :create validates :status, :expired_at, :model, :instructions, presence: true validates :tools, :file_ids, presence: true, allow_blank: true + + private + + def set_model + self.model = assistant&.language_model&.name + end end diff --git a/app/models/user.rb b/app/models/user.rb index 9b21115ea..34e79a478 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -10,7 +10,8 @@ class User < ApplicationRecord validates :first_name, presence: true validates :last_name, presence: true, on: :create - has_many :assistants, dependent: :destroy + has_many :assistants, -> { not_deleted } + has_many :assistants_including_deleted, class_name: "Assistant", dependent: :destroy has_many :conversations, dependent: :destroy belongs_to :last_cancelled_message, class_name: "Message", optional: true @@ -19,4 +20,17 @@ class User < ApplicationRecord def preferences attributes["preferences"].with_defaults(dark_mode: "system") end + + def destroy_in_progress? + @destroy_in_progress + end + + def destroy + @destroy_in_progress = true + begin + super + ensure + @destroy_in_progress = false + end + end end diff --git a/app/services/ai_backend/anthropic.rb b/app/services/ai_backend/anthropic.rb index dec823f6b..873c1e8c1 100644 --- a/app/services/ai_backend/anthropic.rb +++ b/app/services/ai_backend/anthropic.rb @@ -47,7 +47,7 @@ def get_next_chat_message(&chunk_received_handler) begin response = @client.messages( - model: @assistant.model, + model: @assistant.language_model.provider_name, system: @assistant.instructions, messages: preceding_messages, parameters: { @@ -76,7 +76,7 @@ def get_next_chat_message(&chunk_received_handler) def preceding_messages @conversation.messages.for_conversation_version(@message.version).where("messages.index < ?", @message.index).collect do |message| - if @assistant.images && message.documents.present? + if @assistant.supports_images? && message.documents.present? content = [{ type: "text", text: message.content_text }] content += message.documents.collect do |document| diff --git a/app/services/ai_backend/open_ai.rb b/app/services/ai_backend/open_ai.rb index 955dd7169..6fbdc43e6 100644 --- a/app/services/ai_backend/open_ai.rb +++ b/app/services/ai_backend/open_ai.rb @@ -68,7 +68,7 @@ def get_next_chat_message(&chunk_handler) begin response = @client.chat(parameters: { - model: @assistant.model, + model: @assistant.language_model.provider_name, messages: system_message + preceding_messages, tools: Toolbox.tools, stream: response_handler, @@ -124,7 +124,7 @@ def system_message def preceding_messages @conversation.messages.for_conversation_version(@message.version).where("messages.index < ?", @message.index).collect do |message| - if @assistant.images && message.documents.present? + if @assistant.supports_images? && message.documents.present? content_with_images = [{ type: "text", text: message.content_text }] content_with_images += message.documents.collect do |document| diff --git a/app/services/test_client/anthropic.rb b/app/services/test_client/anthropic.rb index 104dddb0a..cf36836ff 100644 --- a/app/services/test_client/anthropic.rb +++ b/app/services/test_client/anthropic.rb @@ -10,6 +10,8 @@ def self.text # # Stub this method to respond with something more specific if needed. def messages(**args) + model = args.dig(:model) || "no model" + system_message = args.dig(:system) if proc = args.dig(:parameters, :stream) proc.call({ "id"=>"msg_01LtHY4sJVd7WBdPCsYb8kHQ", @@ -17,8 +19,8 @@ def messages(**args) "role"=>"assistant", "delta"=> {"type"=>"text", - "text"=> self.class.text || "Hello! It's nice to meet you. How can I assist you today?"}, - "model"=>"claude-3-opus-20240229", + "text"=> self.class.text || "Hello this is model #{model} with instruction #{system_message.to_s.inspect}! How can I assist you today?"}, + "model"=>model, "stop_reason"=>"end_turn", "stop_sequence"=>nil, "usage"=>{"input_tokens"=>10, "output_tokens"=>19} @@ -30,8 +32,8 @@ def messages(**args) "role"=>"assistant", "content"=> [{"type"=>"text", - "text"=> self.class.text || "Hello! It's nice to meet you. How can I assist you today?"}], - "model"=>"claude-3-opus-20240229", + "text"=> self.class.text || "Hello this is model #{model} with instruction #{system_message.to_s.inspect}! How can I assist you today?"}], + "model"=> model, "stop_reason"=>"end_turn", "stop_sequence"=>nil, "usage"=>{"input_tokens"=>10, "output_tokens"=>19} diff --git a/app/services/test_client/open_ai.rb b/app/services/test_client/open_ai.rb index 7ebb48e68..3086dbe08 100644 --- a/app/services/test_client/open_ai.rb +++ b/app/services/test_client/open_ai.rb @@ -10,18 +10,22 @@ def self.text raise "When using the OpenAI test client for api_text_response you need to stub the .text method" end + def self.default_text + "Hello this is model #{@@model} with instruction nil! How can I assist you today?" + end + def self.api_text_response { "id"=> "chatcmpl-abc123abc123abc123abc123abc12", "object"=>"chat.completion", "created"=>1707429030, - "model"=>"gpt-3.5-turbo-0613", + "model"=> @@model, "choices"=> [ { "index"=>0, "delta"=>{ "role"=>"assistant", - "content"=> text + "content"=> text || default_text }, "logprobs"=>nil, "finish_reason"=>"stop" @@ -63,6 +67,8 @@ def self.api_function_response end def chat(**args) + @@model = args.dig(:parameters, :model) || "no model" + proc = args.dig(:parameters, :stream) raise "No stream proc provided. When calling get_next_chat_message in tests be sure to include a block" if proc.nil? proc.call(self.class.api_response) diff --git a/app/views/assistants/_assistant.html.erb b/app/views/assistants/_assistant.html.erb index 1a06efc6f..1aa616cdd 100644 --- a/app/views/assistants/_assistant.html.erb +++ b/app/views/assistants/_assistant.html.erb @@ -1,6 +1,7 @@ <%# locals: (assistant:, settings: true, assistant_counter: -1) %> <% selected = assistant == @assistant %> <% first = assistant_counter.zero? %> +<% visible = assistant_counter <= Assistant::MAX_LIST_DISPLAY-1 || selected %>
<%# This extra div ^ is needed because of the absolute positioning. It doesn't lay out properly if added to the div below. %> @@ -8,16 +9,18 @@ flex justify-between items-center mb-1 p-1 pl-2 pr-2 mr-5 hover:bg-gray-100 dark:hover:bg-gray-700 - bg-gray-50 dark:bg-transparent + bg-gray-50 dark:bg-transparent dark:bg-gray-700 group cursor-pointer text-sm rounded-lg <%= selected && 'relationship' %> + <%= !visible && 'hidden' %> " data-role="assistant" data-radio-behavior-target="radio" data-action="radio-changed@window->radio-behavior#select" data-radio-behavior-id-param="<%= assistant.id %>" + data-transition-target="<%= !visible && 'transitionable' %>" > <%= link_to new_assistant_message_path(assistant), class: "flex-1 flex items-center text-gray-950 dark:text-gray-100 font-medium truncate", data: { role: "name" } do %> <%= render partial: "layouts/assistant_avatar", locals: { assistant: assistant, size: 7, classes: "mr-2" } %> diff --git a/app/views/assistants/_form.html.erb b/app/views/assistants/_form.html.erb index 4b057d41c..fc88471dc 100644 --- a/app/views/assistants/_form.html.erb +++ b/app/views/assistants/_form.html.erb @@ -17,8 +17,8 @@
- <%= form.label :model %> - <%= form.text_field :model, value: "gpt-4", class: "block shadow rounded-md border border-gray-200 outline-none px-3 py-2 mt-2 w-full" %> + <%= form.label :language_model_id %>
+ <%= form.select :language_model_id, LanguageModel.order(:description).all.pluck(:description, :id), class: "block shadow rounded-md border border-gray-200 outline-none px-3 py-2 mt-2 w-full dark:text-black" %>
diff --git a/app/views/assistants/show.html.erb b/app/views/assistants/show.html.erb index 394918308..273cf6d04 100644 --- a/app/views/assistants/show.html.erb +++ b/app/views/assistants/show.html.erb @@ -9,7 +9,7 @@

Model: - <%= @assistant.model %> + <%= @assistant.language_model.description %>

diff --git a/app/views/layouts/_settings_item.erb b/app/views/layouts/_settings_item.erb index d58e107c4..3e5e68ba3 100644 --- a/app/views/layouts/_settings_item.erb +++ b/app/views/layouts/_settings_item.erb @@ -1,7 +1,6 @@ <% item, link = settings_item %> <% selected = (controller.to_s == controller_name.to_s) %> -<% selected = selected && params[:id] == item.id.to_s if params[:id] %> - +<% selected = selected && params[:id] == item.try(:id)&.to_s %>

+

<%= @assistant.name %> has been deleted and cannot assist any longer.

+
+ +
<%= button_tag type: "button", @@ -407,7 +411,7 @@ data-controller="modal" data-action="keydown@document->modal#keydownQuestionOpen" > -
+
+
diff --git a/app/views/messages/_message.html.erb b/app/views/messages/_message.html.erb index aba6ef851..25d0e8151 100644 --- a/app/views/messages/_message.html.erb +++ b/app/views/messages/_message.html.erb @@ -168,7 +168,7 @@ end <% end %> <% end %> - <% if message.user? && !message.has_document_image? %> + <% if message.user? && message.assistant.not_deleted? && !message.has_document_image? %> <%= link_to edit_assistant_message_path(message.assistant, message), class: "cursor-pointer hover:text-gray-900 dark:hover:text-white flex items-center", data: { @@ -177,7 +177,7 @@ end } do %> <%= icon "pencil", variant: :outline, size: 18, title: "Edit" %> <% end %> - <% elsif message.assistant? %> + <% elsif message.assistant? && message.assistant.not_deleted? %> <%= button_tag type: "button", class: "cursor-pointer hover:text-gray-900 dark:hover:text-white", data: { diff --git a/app/views/messages/_nav_column.html.erb b/app/views/messages/_nav_column.html.erb index f7058451f..8b359a07e 100644 --- a/app/views/messages/_nav_column.html.erb +++ b/app/views/messages/_nav_column.html.erb @@ -1,6 +1,28 @@ -
-
+
<%= render @nav_assistants %> + + <%= button_tag type: "button", + class: %| + flex + text-sm + rounded-lg + mb-1 p-1 pl-2 pr-2 mr-5 + hover:bg-gray-100 dark:hover:bg-gray-700 + pointer + #{@nav_assistants.length <= Assistant::MAX_LIST_DISPLAY && 'hidden'} + |, + data: { + transition_target: "transitionable", + action: "transition#toggleClass", + } do %> + <%= icon "chevron-down", variant: :mini, class: 'text-gray-500 ml-[2px] mr-[14px]' %> Show All + <% end %>
diff --git a/app/views/messages/new.html.erb b/app/views/messages/new.html.erb index 8c4d285ce..69b6dc37e 100644 --- a/app/views/messages/new.html.erb +++ b/app/views/messages/new.html.erb @@ -3,6 +3,7 @@ <% end %> <%= content_for :messages do %> +
diff --git a/app/views/settings/assistants/_form.html.erb b/app/views/settings/assistants/_form.html.erb index 9455e26d6..1f5c977fe 100644 --- a/app/views/settings/assistants/_form.html.erb +++ b/app/views/settings/assistants/_form.html.erb @@ -13,7 +13,31 @@
<%= form.label :name %> - <%= form.text_field :name, class: "block shadow rounded-md border border-gray-200 outline-none px-3 py-2 mt-2 w-full dark:text-black" %> + <%= form.text_field :name, + autofocus: assistant.new_record?, + class: %| + block w-full + border border-gray-200 outline-none + shadow rounded-md + px-3 py-2 mt-2 + dark:text-black + | %> +
+ +
+ <%= form.label :language_model_id %>
+ <%= form.select :language_model_id, + LanguageModel.ordered.pluck(:description, :id), + {}, + class: %| + block + border border-gray-200 outline-none + shadow rounded-md + px-3 py-2 mt-2 + w-full + dark:text-black + | + %>
@@ -22,9 +46,17 @@
- <%= form.label :instructions %> + <%= form.label :instructions, "Custom Instructions" %> <%= form.text_area :instructions, - class: "block shadow rounded-md border border-gray-200 outline-none px-3 py-2 mt-2 w-full dark:text-black h-18", + autofocus: assistant.persisted?, + class: %| + block w-full + border border-gray-200 outline-none + shadow rounded-md + px-3 py-2 mt-2 + dark:text-black + h-18 + |, data: { controller: "textarea-autogrow", action: %| diff --git a/app/views/settings/assistants/edit.html.erb b/app/views/settings/assistants/edit.html.erb index 3ee8e807e..ba76627b8 100644 --- a/app/views/settings/assistants/edit.html.erb +++ b/app/views/settings/assistants/edit.html.erb @@ -3,21 +3,20 @@ <%= render "form", assistant: @assistant %> - <%# TODO: Messages are connected to assistants. When an assistant is deleted, - what should happen to the messages? - - button_to "Delete", - settings_assistant_path(@assistant), - method: :delete, - data: { turbo_confirm: "Are you sure?" }, - form: { class: "inline-block" }, - class: %| - ml-5 mt-2 py-3 px-5 - bg-white dark:bg-gray-500 - border border-gray-300 dark:border-gray-500 - rounded-lg - font-medium - | + <%= button_to "Delete", + settings_assistant_path(@assistant), + method: :delete, + data: { turbo_confirm: "Are you sure?" }, + form: { class: "inline-block" }, + class: %| + ml-5 py-3 px-5 + bg-white dark:bg-gray-500 + border border-gray-300 dark:border-gray-500 + rounded-lg + font-medium + #{@last_assistant && 'hidden'} + | %> + <%= link_to "Cancel", root_path, class: "float-right inline-block ml-5 py-3" %>
diff --git a/app/views/settings/assistants/new.html.erb b/app/views/settings/assistants/new.html.erb index 4122ed1f9..7a49677b0 100644 --- a/app/views/settings/assistants/new.html.erb +++ b/app/views/settings/assistants/new.html.erb @@ -1,5 +1,5 @@
-

New assistant

+

New Assistant

<%= render "form", assistant: @assistant %>
diff --git a/config/database.yml b/config/database.yml index 03998056f..4abe0cd3f 100644 --- a/config/database.yml +++ b/config/database.yml @@ -1,17 +1,3 @@ -# PostgreSQL. Versions 9.3 and up are supported. -# -# Install the pg driver: -# gem install pg -# On macOS with Homebrew: -# gem install pg -- --with-pg-config=/usr/local/bin/pg_config -# On Windows: -# gem install pg -# Choose the win32 build. -# Install PostgreSQL and put its /bin directory on your path. -# -# Configure Using Gemfile -# gem "pg" -# default: &default adapter: postgresql encoding: unicode @@ -23,64 +9,14 @@ default: &default development: <<: *default - database: hostedgpt_development + database: <%= ENV['HOSTEDGPT_DEV_DB'] || "hostedgpt_development" %> - # The specified database role being used to connect to PostgreSQL. - # To create additional roles in PostgreSQL see `$ createuser --help`. - # When left blank, PostgreSQL will use the default role. This is - # the same name as the operating system user running Rails. - #username: hostedgpt - - # The password associated with the PostgreSQL role (username). - #password: - - # Connect on a TCP socket. Omitted by default since the client uses a - # domain socket that doesn't need configuration. Windows does not have - # domain sockets, so uncomment these lines. - #host: localhost - - # The TCP port the server listens on. Defaults to 5432. - # If your server runs on a different port number, change accordingly. - #port: 5432 - - # Schema search path. The server defaults to $user,public - #schema_search_path: myapp,sharedapp,public - - # Minimum log levels, in increasing order: - # debug5, debug4, debug3, debug2, debug1, - # log, notice, warning, error, fatal, and panic - # Defaults to warning. - #min_messages: notice - -# Warning: The database defined as "test" will be erased and -# re-generated from your development database when you run "rake". -# Do not set this db to the same as development or production. test: <<: *default - database: hostedgpt_test + database: <%= ENV['HOSTEDGPT_TEST_DB'] || "hostedgpt_test" %> -# As with config/credentials.yml, you never want to store sensitive information, -# like your database password, in your source code. If your source code is -# ever seen by anyone, they now have access to your database. -# -# Instead, provide the password or a full connection URL as an environment -# variable when you boot the app. For example: -# -# DATABASE_URL="postgres://myuser:mypass@localhost/somedatabase" -# -# If the connection URL is provided in the special DATABASE_URL environment -# variable, Rails will automatically merge its configuration values on top of -# the values provided in this file. Alternatively, you can specify a connection -# URL environment variable explicitly: -# -# production: -# url: <%= ENV["MY_APP_DATABASE_URL"] %> -# -# Read https://guides.rubyonrails.org/configuring.html#configuring-a-database -# for a full overview on how database connection configuration can be specified. -# production: <<: *default database: hostedgpt_production username: hostedgpt - password: <%= ENV["HOSTEDGPT_DATABASE_PASSWORD"] %> + password: <%= ENV["HOSTEDGPT_DATABASE_PASSWORD"] %>= \ No newline at end of file diff --git a/db/migrate/20240523080700_create_language_models.rb b/db/migrate/20240523080700_create_language_models.rb new file mode 100644 index 000000000..59f1c899c --- /dev/null +++ b/db/migrate/20240523080700_create_language_models.rb @@ -0,0 +1,76 @@ +class CreateLanguageModels < ActiveRecord::Migration[7.1] + def up + create_table :language_models do |t| + t.integer :position, null: false + t.string :name, null: false + t.text :description, null: false + t.boolean :supports_images, null: false + + t.timestamps + end + + # Initially supported models + [ + [1, 'gpt-best', 'Best OpenAI Model', true], + [2, 'claude-best', 'Best Anthropic Model', true], + + [3, 'gpt-4o', 'GPT-4o (latest)', true], + [4, 'gpt-4o-2024-05-13', 'GPT-4o Omni Multimodal (2024-05-13)', true], + + [5, 'gpt-4-turbo', 'GPT-4 Turbo with Vision (latest)', true], + [6, 'gpt-4-turbo-2024-04-09', 'GPT-4 Turbo with Vision (2024-04-09)', true], + [7, 'gpt-4-turbo-preview', 'GPT-4 Turbo Preview', false], + [8, 'gpt-4-0125-preview', 'GPT-4 Turbo Preview (2024-01-25)', false], + [9, 'gpt-4-1106-preview', 'GPT-4 Turbo Preview (2023-11-06)', false], + [10, 'gpt-4-vision-preview', 'GPT-4 Turbo with Vision Preview (2023-11-06)', true], + [11, 'gpt-4-1106-vision-preview', 'GPT-4 Turbo with Vision Preview (2023-11-06)', true], + + [12, 'gpt-4', 'GPT-4 (latest)', false], + [13, 'gpt-4-0613', 'GPT-4 Snapshot improved function calling (2023-06-13)', false], + + [14, 'gpt-3.5-turbo', 'GPT-3.5 Turbo (latest)', false], + [15, 'gpt-3.5-turbo-16k-0613', 'GPT-3.5 Turbo (2022-06-13)', false], + [16, 'gpt-3.5-turbo-0125', 'GPT-3.5 Turbo (2022-01-25)', false], + [17, 'gpt-3.5-turbo-1106', 'GPT-3.5 Turbo (2022-11-06)', false], + [18, 'gpt-3.5-turbo-instruct', 'GPT-3.5 Turbo Instruct', false], + + [19, 'claude-3-opus-20240229', 'Claude 3 Opus (2024-02-29)', true], + [20, 'claude-3-sonnet-20240229', 'Claude 3 Sonnet (2024-02-29)', true], + [21, 'claude-3-haiku-20240307', 'Claude 3 Haiku (2024-03-07)', true], + [22, 'claude-2.1', 'Claude 2.1', false], + [23, 'claude-2.0', 'Claude 2.0', false], + [24, 'claude-instant-1.2', 'Claude Instant 1.2', false] + ].each do |position, name, description, supports_images| + LanguageModel.create!(position: position, name: name, description: description, supports_images: supports_images) + end + + max_position = 24 + + # Respect some users who may have added their own model values in the assistants table + (Assistant.all.pluck(:model).uniq - LanguageModel.all.pluck(:name)).each do |model_name| + Rails.logger.info "Create language_models record from assistants column value: #{model_name.inspect}. Setting supports_images to false, update manually if it has support" + LanguageModel.create!(name: model_name, description: model_name, position: max_position += 1, supports_images: false) + end + + add_reference :assistants, :language_model, null: true, foreign_key: { to_table: :language_models} + + Assistant.all.each do |a| + Rails.logger.info "Have assistant #{a.id} with model #{a.model}" + end + ActiveRecord::Base.connection.execute "update assistants a set language_model_id = (select id from language_models lm where lm.name = a.model)" + + remove_column :assistants, :model + remove_column :assistants, :images + end + + def down + add_column :assistants, :images, :boolean + add_column :assistants, :model, :string + + ActiveRecord::Base.connection.execute "update assistants a set images = (select supports_images from language_models lm where lm.id=a.language_model_id)" + ActiveRecord::Base.connection.execute "update assistants a set model = (select name from language_models lm where lm.id=a.language_model_id)" + + remove_column :assistants, :language_model_id + drop_table :language_models + end +end diff --git a/db/migrate/20240524144314_add_deleted_at_to_assistants.rb b/db/migrate/20240524144314_add_deleted_at_to_assistants.rb new file mode 100644 index 000000000..b267c0eaa --- /dev/null +++ b/db/migrate/20240524144314_add_deleted_at_to_assistants.rb @@ -0,0 +1,6 @@ +class AddDeletedAtToAssistants < ActiveRecord::Migration[7.1] + def change + add_column :assistants, :deleted_at, :timestamp, default: nil + add_index :assistants, [:user_id, :deleted_at] + end +end diff --git a/db/schema.rb b/db/schema.rb index c0ec3c009..8898daf97 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2024_05_22_231839) do +ActiveRecord::Schema[7.1].define(version: 2024_05_24_144314) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -50,14 +50,16 @@ create_table "assistants", force: :cascade do |t| t.bigint "user_id", null: false - t.string "model" t.string "name" t.string "description" t.string "instructions" t.jsonb "tools", default: [], null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.boolean "images", default: false, null: false + t.datetime "deleted_at", precision: nil + t.bigint "language_model_id" + t.index ["language_model_id"], name: "index_assistants_on_language_model_id" + t.index ["user_id", "deleted_at"], name: "index_assistants_on_user_id_and_deleted_at" t.index ["user_id"], name: "index_assistants_on_user_id" end @@ -96,6 +98,15 @@ t.index ["user_id"], name: "index_documents_on_user_id" end + create_table "language_models", force: :cascade do |t| + t.integer "position", null: false + t.string "name", null: false + t.text "description", null: false + t.boolean "supports_images", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + create_table "messages", force: :cascade do |t| t.bigint "conversation_id", null: false t.string "role", null: false @@ -105,10 +116,10 @@ t.bigint "content_document_id" t.bigint "run_id" t.bigint "assistant_id", null: false + t.datetime "cancelled_at" t.datetime "processed_at", precision: nil t.integer "index", null: false t.integer "version", null: false - t.datetime "cancelled_at" t.boolean "branched", default: false, null: false t.integer "branched_from_version" t.jsonb "content_tool_calls" @@ -304,6 +315,7 @@ add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" + add_foreign_key "assistants", "language_models" add_foreign_key "assistants", "users" add_foreign_key "chats", "users" add_foreign_key "conversations", "assistants" diff --git a/test/controllers/assistants_controller_test.rb b/test/controllers/assistants_controller_test.rb index 2224e98f6..806461069 100644 --- a/test/controllers/assistants_controller_test.rb +++ b/test/controllers/assistants_controller_test.rb @@ -12,6 +12,8 @@ class AssistantsControllerTest < ActionDispatch::IntegrationTest assert_redirected_to new_assistant_message_path(@assistant) end + # TODO: Delete this duplicate functionality since it's been moved to settings. + test "should get new" do get new_assistant_url assert_response :success @@ -19,7 +21,7 @@ class AssistantsControllerTest < ActionDispatch::IntegrationTest test "should create assistant" do assert_difference("Assistant.count") do - post assistants_url, params: {assistant: {description: @assistant.description, instructions: @assistant.instructions, model: @assistant.model, name: @assistant.name, tools: @assistant.tools, user_id: @assistant.user_id}} + post assistants_url, params: {assistant: {description: @assistant.description, instructions: @assistant.instructions, language_model_id: @assistant.language_model_id, name: @assistant.name, tools: @assistant.tools, user_id: @assistant.user_id}} end assert_redirected_to assistant_url(Assistant.last) @@ -36,18 +38,13 @@ class AssistantsControllerTest < ActionDispatch::IntegrationTest end test "should update assistant" do - patch assistant_url(@assistant), params: {assistant: {description: @assistant.description, instructions: @assistant.instructions, model: @assistant.model, name: @assistant.name, tools: @assistant.tools, user_id: @assistant.user_id}} + patch assistant_url(@assistant), params: {assistant: {description: "new description", instructions: "new instructions", language_model_id: language_models(:claude_3_opus).id, name: 'new name', tools: @assistant.tools, user_id: @assistant.user_id}} assert_redirected_to assistant_url(@assistant) + @assistant.reload + assert_equal "new description", @assistant.description + assert_equal "new instructions", @assistant.instructions + assert_equal "claude-3-opus-20240229", @assistant.language_model.name + assert_equal "new name", @assistant.name end - # TODO: Messages are connected to assistants. When an assistant is deleted, - # what should happen to the messages? - # - # test "should destroy assistant" do - # assert_difference("Assistant.count", -1) do - # delete assistant_url(@assistant) - # end - - # assert_redirected_to assistants_url - # end end diff --git a/test/controllers/messages_controller_test.rb b/test/controllers/messages_controller_test.rb index 37c0f7c22..6047d1900 100644 --- a/test/controllers/messages_controller_test.rb +++ b/test/controllers/messages_controller_test.rb @@ -1,6 +1,8 @@ require "test_helper" class MessagesControllerTest < ActionDispatch::IntegrationTest + include ActionDispatch::TestProcess::FixtureFile + setup do @message = messages(:hear_me) @conversation = @message.conversation @@ -79,6 +81,20 @@ class MessagesControllerTest < ActionDispatch::IntegrationTest assert_redirected_to conversation_messages_url(@conversation, version: 2) end + test "should create message with image attachment" do + test_file = fixture_file_upload("cat.png", "image/png") + assert_difference "Conversation.count", 1 do + assert_difference "Document.count", 1 do + assert_difference "Message.count", 2 do + post assistant_messages_url(@assistant), params: { message: { documents_attributes: {"0": {file: test_file}}, content_text: @message.content_text } } + end + end + end + + (user_msg, asst_msg) = Message.last(2) + assert_equal Document.last, user_msg.documents.first + end + test "should fail to create message when there is no content_text" do post assistant_messages_url(@assistant), params: { message: { content_text: nil } } assert_response :unprocessable_entity @@ -112,4 +128,69 @@ class MessagesControllerTest < ActionDispatch::IntegrationTest patch message_url(message, version: 2), params: { message: { id: message.id } } assert_redirected_to conversation_messages_url(message.conversation, version: 2) end + + test "messages can still be viewed when attached to a soft-deleted assistant" do + assert @assistant.soft_delete + get conversation_messages_url(@conversation, version: 1) + assert @conversation.messages.count > 0 + assert_select 'div[data-role="message"]', count: @conversation.messages.count + end + + test "when assistant is not deleted the deleted-blurb is hidden but the composer is visible" do + get conversation_messages_url(@conversation, version: 1) + assert_response :success + assert_contains_text "main footer", "Samantha has been deleted and cannot assist any longer." + assert_select "div#composer" + assert_select "div#composer.hidden", false + end + + test "when assistant supports images the image upload function is available" do + get conversation_messages_url(@conversation, version: 1) + assert_select "div#composer" + assert_select "div#composer.relationship", false + end + + test "when assistant doesn't support images the image upload function is not available" do + get conversation_messages_url(conversations(:trees), version: 1) + assert_select "div#composer.relationship" + end + + test "the composer is hidden when viewing a list of messages attached to an assistant that has been soft-deleted" do + @assistant.soft_delete + get conversation_messages_url(@conversation, version: 1) + assert_response :success + assert_contains_text "main footer", "Samantha has been deleted and cannot assist any longer." + assert_select "footer div.hidden p.text-center", false + assert_select "div#composer.hidden" + end + + test "viewing messages in a conversation which has history but the assistant has been soft deleted, the conversation history can still be viewed" do + @assistant.soft_delete + message = messages(:message2_v1) + + patch message_url(message, version: 2), params: { message: { id: message.id } } + assert_redirected_to conversation_messages_url(message.conversation, version: 2) + + get conversation_messages_url(message.conversation, version: 2) + assert_response :success + assert_contains_text "main", "Where were you born" + end + + test "when there are many assistants only a few are shown in the nav bar" do + 5.times do |x| + @user.assistants.create! name: "New assistant #{x+1}", language_model: LanguageModel.find_by(name: 'gpt-3.5-turbo') + end + get conversation_messages_url(@conversation, version: 1) + @user.assistants.each do |assistant| + assert_select %{div[data-radio-behavior-id-param="#{assistant.id}"] a[data-role="name"]} + end + @user.assistants.each_with_index do |assistant, index| + if index>5 + assert_select %{div.hidden[data-role="assistant"][data-radio-behavior-id-param="#{assistant.id}"] a[data-role="name"]} + else + assert_select %{div[data-role="assistant"][data-radio-behavior-id-param="#{assistant.id}"] a[data-role="name"]} + assert_select %{div.hiden[data-role="assistant"][data-radio-behavior-id-param="#{assistant.id}"] a[data-role="name"]}, false + end + end + end end diff --git a/test/controllers/settings/assistants_controller_test.rb b/test/controllers/settings/assistants_controller_test.rb index f5eb13e02..62b415399 100644 --- a/test/controllers/settings/assistants_controller_test.rb +++ b/test/controllers/settings/assistants_controller_test.rb @@ -3,7 +3,8 @@ class Settings::AssistantsControllerTest < ActionDispatch::IntegrationTest setup do @assistant = assistants(:samantha) - login_as @assistant.user + @user = @assistant.user + login_as @user end test "should get new" do @@ -12,7 +13,7 @@ class Settings::AssistantsControllerTest < ActionDispatch::IntegrationTest end test "should create assistant" do - params = assistants(:samantha).slice(:name, :description, :instructions) + params = assistants(:samantha).slice(:name, :description, :instructions, :language_model_id) assert_difference("Assistant.count") do post settings_assistants_url, params: { assistant: params } @@ -20,12 +21,41 @@ class Settings::AssistantsControllerTest < ActionDispatch::IntegrationTest assert_redirected_to edit_settings_assistant_url(Assistant.last) assert_nil flash[:error] - assert_equal params, Assistant.last.slice(:name, :description, :instructions) + assert_equal params, Assistant.last.slice(:name, :description, :instructions, :language_model_id) end test "should get edit" do get edit_settings_assistant_url(@assistant) assert_response :success + assert_contains_text "div#nav-container", "Your Account" + assert_contains_text "div#nav-container", "New Assistant" + end + + test "form should display a DELETE button if this is not your last assistant" do + assert @user.assistants.length > 1, "User needs to have more than one assistant" + get edit_settings_assistant_url(@assistant) + assert_response :success + assert_contains_text "main", "Delete" + end + + test "form should NOT display a DELETE button if this is your last assistant" do + @user.assistants.where.not(id: @assistant.id).map(&:destroy) + assert @user.assistants.length == 1, "User needs to have more than one assistant" + get edit_settings_assistant_url(@user.assistants.first) + assert_response :success + assert_contains_text "main", "Delete" + end + + test "should allow language_model selection" do + get edit_settings_assistant_url(@assistant) + assert_select 'label', 'Language model' + assert_select 'select#assistant_language_model_id' + end + + test "should update language_model" do + params = {language_model_id: language_models(:claude_best).id} + patch settings_assistant_url(@assistant), params: { assistant: params } + assert_equal language_models(:claude_best), @assistant.reload.language_model end test "should update assistant" do @@ -37,11 +67,28 @@ class Settings::AssistantsControllerTest < ActionDispatch::IntegrationTest assert_equal params, @assistant.reload.slice(:name, :description, :instructions) end - test "should destroy assistant" do - assert_difference("Assistant.count", -1) do + test "destroy should soft-delete assistant" do + assert_difference "Assistant.count", 0 do + delete settings_assistant_url(@assistant) + end + + assert @assistant.reload.deleted? + assert_redirected_to new_settings_assistant_url + assert flash[:notice].present?, "There should have been a success message" + refute flash[:alert].present?, "There should NOT have been an error message" + end + + test "destroy on the last assistant should not delete it" do + user = @assistant.user + user.assistants.where.not(id: @assistant.id).map(&:destroy) + + assert_no_difference "Assistant.count" do delete settings_assistant_url(@assistant) end + refute @assistant.reload.deleted? assert_redirected_to new_settings_assistant_url + refute flash[:notice].present?, "There should NOT have been a success message" + assert flash[:alert].present?, "There should have been an error message" end end diff --git a/test/fixtures/assistants.yml b/test/fixtures/assistants.yml index 3a0fc916c..c60ee050f 100644 --- a/test/fixtures/assistants.yml +++ b/test/fixtures/assistants.yml @@ -1,35 +1,39 @@ samantha: user: keith - model: gpt-4 + language_model: gpt_4 name: Samantha description: My personal assistant instructions: You are a helpful assistant tools: [{"type": "code_interpreter"}, {"type": "retrieval"}, {"type": "function"}] - images: true keith_gpt4: user: keith - model: gpt-4 + language_model: gpt_4 name: OpenAI's GPT-4 description: OpenAI's GPT-4 instructions: tools: [{"type": "code_interpreter"}, {"type": "retrieval"}, {"type": "function"}] - images: true keith_claude3: user: keith - model: claude-3-opus-20240229 + language_model: claude_3_opus name: Claude 3 Opus description: Claude 3, Opus version instructions: tools: [] - images: true + +keith_gpt3: + user: keith + language_model: gpt_3_5_turbo + name: GPT 3.5 + description: OpenAI's GPT-3 but with Zen + instructions: Include a Zen perspective when possilbe + tools: [{"type": "code_interpreter"}, {"type": "retrieval"}, {"type": "function"}] rob_gpt4: user: rob - model: gpt-4 + language_model: gpt_4 name: GPT-4 description: OpenAI's GPT-4 instructions: tools: [{"type": "code_interpreter"}, {"type": "retrieval"}, {"type": "function"}] - images: true diff --git a/test/fixtures/conversations.yml b/test/fixtures/conversations.yml index 65b7d2e7c..b0b77859c 100644 --- a/test/fixtures/conversations.yml +++ b/test/fixtures/conversations.yml @@ -75,3 +75,9 @@ weather: assistant: samantha title: Weather last_assistant_message: weather_explained + +trees: + user: keith + assistant: keith_gpt3 + title: Trees + last_assistant_message: trees_explained diff --git a/test/fixtures/language_models.yml b/test/fixtures/language_models.yml new file mode 100644 index 000000000..839d9f206 --- /dev/null +++ b/test/fixtures/language_models.yml @@ -0,0 +1,47 @@ +gpt_best: + position: 1 + supports_images: true + name: gpt-best + description: gpt-best from fixtures + +claude_best: + position: 2 + name: best-claude + supports_images: true + description: claude-best from fixtures + +gpt_4o: + position: 3 + name: gpt-4o + supports_images: true + description: gpt-4ot from fixtures + +gpt_3_5_turbo: + position: 4 + name: gpt-3.5-turbo + supports_images: false + description: gpt-3.5-turbo from fixtures + +claude_3_sonnet: + position: 18 + name: claude-3-sonnet-20240229 + supports_images: true + description: claude-3-sonnet-20240229 from fixtures + +claude_3_opus: + position: 17 + supports_images: true + name: claude-3-opus-20240229 + description: claude-3-opus-20240229 from fixtures + +gpt_4: + position: 3 + supports_images: true + name: gpt-4 + description: gpt-4 from fixtures + +gpt_3_5_turbo_0125: + position: 12 + name: gpt-3.5-turbo + description: gpt-3.5-turbo from fixtures + supports_images: false diff --git a/test/fixtures/messages.yml b/test/fixtures/messages.yml index 97b850d58..3dda98f7c 100644 --- a/test/fixtures/messages.yml +++ b/test/fixtures/messages.yml @@ -658,3 +658,19 @@ weather_explained: processed_at: 2023-12-30 1:03:00 index: 3 version: 1 + + +# Next conversation + +trees_explained: + assistant: keith_gpt3 + conversation: trees + role: assistant + tool_call_id: + content_text: The trees in Australia are + content_tool_calls: + content_document: + created_at: 2024-05-25 1:03:00 + processed_at: 2024-05-25 1:03:00 + index: 0 + version: 1 diff --git a/test/jobs/get_next_ai_message_job_anthropic_test.rb b/test/jobs/get_next_ai_message_job_anthropic_test.rb index e1212ff35..469827728 100644 --- a/test/jobs/get_next_ai_message_job_anthropic_test.rb +++ b/test/jobs/get_next_ai_message_job_anthropic_test.rb @@ -14,7 +14,7 @@ class GetNextAIMessageJobAnthropicTest < ActiveJob::TestCase assert GetNextAIMessageJob.perform_now(@user.id, @message.id, @conversation.assistant.id) end - message_text = @test_client.messages + message_text = @test_client.messages(model: "claude-3-opus-20240229") assert_equal message_text, @conversation.latest_message_for_version(:latest).content_text end diff --git a/test/jobs/get_next_ai_message_job_openai_test.rb b/test/jobs/get_next_ai_message_job_openai_test.rb index 7ea2c5b69..9bd2d205a 100644 --- a/test/jobs/get_next_ai_message_job_openai_test.rb +++ b/test/jobs/get_next_ai_message_job_openai_test.rb @@ -12,7 +12,7 @@ class GetNextAIMessageJobOpenaiTest < ActiveJob::TestCase test "populates the latest message from the assistant" do assert_no_difference "@conversation.messages.reload.length" do TestClient::OpenAI.stub :text, "Hello" do - TestClient::OpenAI.stub :api_response, TestClient::OpenAI.api_text_response do + TestClient::OpenAI.stub :api_response, -> { TestClient::OpenAI.api_text_response } do assert GetNextAIMessageJob.perform_now(@user.id, @message.id, @conversation.assistant.id) end end @@ -84,7 +84,7 @@ class GetNextAIMessageJobOpenaiTest < ActiveJob::TestCase test "when API response key is missing, a nice error message is displayed" do TestClient::OpenAI.stub :text, "" do - TestClient::OpenAI.stub :api_response, TestClient::OpenAI.api_text_response do + TestClient::OpenAI.stub :api_response, -> { TestClient::OpenAI.api_text_response } do assert GetNextAIMessageJob.perform_now(@user.id, @message.id, @conversation.assistant.id) assert_includes @conversation.latest_message_for_version(:latest).content_text, "a blank response" end diff --git a/test/jobs/get_next_ai_message_job_test.rb b/test/jobs/get_next_ai_message_job_test.rb index 6048edc4f..117c3306d 100644 --- a/test/jobs/get_next_ai_message_job_test.rb +++ b/test/jobs/get_next_ai_message_job_test.rb @@ -34,7 +34,7 @@ class GetNextAIMessageJobOpenaiTest < ActiveJob::TestCase job = GetNextAIMessageJob.new job.stub(:message_cancelled?, -> { false_on_first_run += 1; false_on_first_run != 1 }) do TestClient::OpenAI.stub :text, "Hello" do - TestClient::OpenAI.stub :api_response, TestClient::OpenAI.api_text_response do + TestClient::OpenAI.stub :api_response, -> { TestClient::OpenAI.api_text_response }do assert_changes "@message.content_text", from: nil, to: "Hello" do assert_changes "@message.reload.cancelled_at", from: nil do diff --git a/test/models/assistant_test.rb b/test/models/assistant_test.rb index 7bc113641..b018c4f3d 100644 --- a/test/models/assistant_test.rb +++ b/test/models/assistant_test.rb @@ -9,8 +9,9 @@ class AssistantTest < ActiveSupport::TestCase assert_instance_of Conversation, assistants(:samantha).conversations.first end - test "has associated messages (through conversations)" do - assert_instance_of Message, assistants(:samantha).messages.first + test "has supports_images?" do + assert assistants(:samantha).supports_images? + refute assistants(:keith_gpt3).supports_images? end test "has associated documents" do @@ -25,33 +26,59 @@ class AssistantTest < ActiveSupport::TestCase assert_instance_of Step, assistants(:samantha).steps.first end + test "has associated messages (through conversations)" do + assert_instance_of Message, assistants(:samantha).messages.first + end + + test "has associated language_model" do + assert_instance_of LanguageModel, assistants(:samantha).language_model + end + test "tools is an array of objects" do assert_instance_of Array, assistants(:samantha).tools end - test "simple create works" do + test "simple create works and tool defaults to empty array" do + a = nil assert_nothing_raised do - Assistant.create!(user: users(:keith)) + a = Assistant.create!( + user: users(:keith), + language_model: language_models(:gpt_4), + name: 'abc' + ) end - end - - test "tools defaults to empty array on create" do - a = Assistant.create!(user: users(:keith)) assert_equal [], a.tools end test "associations are deleted upon destroy" do assistant = assistants(:samantha) conversation_count = assistant.conversations.count * -1 + message_count = assistant.conversations.map { |c| c.messages.length }.sum * -1 document_count = (assistant.documents.count+assistant.conversations.sum { |c| c.messages.sum { |m| m.documents.count }}) * -1 run_count = assistant.runs.count * -1 step_count = assistant.steps.count * -1 - assert_difference "Conversation.count", conversation_count do - assert_difference "Document.count", document_count do - assert_difference "Run.count", run_count do - assert_difference "Step.count", step_count do - assistant.destroy + assert_difference "Message.count", message_count do + assert_difference "Conversation.count", conversation_count do + assert_difference "Document.count", document_count do + assert_difference "Run.count", run_count do + assert_difference "Step.count", step_count do + assistant.destroy + end + end + end + end + end + end + + test "associations are not deleted upon soft delete" do + assert_no_difference "Message.count" do + assert_no_difference "Conversation.count" do + assert_no_difference "Document.count" do + assert_no_difference "Run.count" do + assert_no_difference "Step.count" do + assistants(:samantha).soft_delete + end end end end @@ -70,4 +97,28 @@ class AssistantTest < ActiveSupport::TestCase test "initials will split on - and return two characters" do assert_equal "G4", assistants(:rob_gpt4).initials end + + test "language model validated" do + record = Assistant.new + refute record.valid? + assert record.errors[:language_model].present? + end + + test "name validated" do + record = Assistant.new + refute record.valid? + assert record.errors[:name].present? + end + + test "cannot soft_delete last assistant of a user" do + assert_raise do + users(:rob).assistants.first.soft_delete! + end + end + + test "can soft_delete assistant of a user if they have more than one" do + assert_nothing_raised do + users(:keith).assistants.first.destroy + end + end end diff --git a/test/models/conversation_test.rb b/test/models/conversation_test.rb index ce3549c66..71366312c 100644 --- a/test/models/conversation_test.rb +++ b/test/models/conversation_test.rb @@ -63,7 +63,7 @@ class ConversationTest < ActiveSupport::TestCase perform_enqueued_jobs do ChatCompletionAPI.stub :get_next_response, {"topic" => "Hear me"} do TestClient::OpenAI.stub :text, "Hello" do - TestClient::OpenAI.stub :api_response, TestClient::OpenAI.api_text_response do + TestClient::OpenAI.stub :api_response, -> { TestClient::OpenAI.api_text_response } do conversation = users(:keith).conversations.create!(assistant: assistants(:samantha)) assert_nil conversation.title diff --git a/test/models/language_model_test.rb b/test/models/language_model_test.rb new file mode 100644 index 000000000..5dd5bb519 --- /dev/null +++ b/test/models/language_model_test.rb @@ -0,0 +1,42 @@ +require "test_helper" + +class LanguageModelTest < ActiveSupport::TestCase + test "has associated assistant" do + assert_instance_of Assistant, language_models(:gpt_4).assistants.first + end + + test "is readonly?" do + assert language_models(:gpt_best).readonly? + assert language_models(:claude_3_sonnet).readonly? + end + + test "supports_images?" do + assert language_models(:gpt_best).supports_images? + refute language_models(:gpt_3_5_turbo).supports_images? + end + + test "ai_backend for best models" do + assert_equal AIBackend::OpenAI, language_models(:gpt_best).ai_backend + assert_equal AIBackend::Anthropic, language_models(:claude_best).ai_backend + end + + test "ai_backend for Anthropic models" do + assert_equal AIBackend::Anthropic, language_models(:claude_3_sonnet).ai_backend + assert_equal AIBackend::Anthropic, language_models(:claude_3_opus).ai_backend + end + + test "provider_name for Anthropic models" do + assert_equal "claude-3-sonnet-20240229", language_models(:claude_3_sonnet).provider_name + assert_equal "claude-3-opus-20240229", language_models(:claude_3_opus).provider_name + end + + test "provider_name for OpenAI models" do + assert_equal "gpt-3.5-turbo", language_models(:gpt_3_5_turbo_0125).provider_name + assert_equal "gpt-4o", language_models(:gpt_4o).provider_name + end + + test "ai_backend for OpenAI models" do + assert_equal AIBackend::OpenAI, language_models(:gpt_4o).ai_backend + assert_equal AIBackend::OpenAI, language_models(:gpt_3_5_turbo).ai_backend + end +end diff --git a/test/models/message_test.rb b/test/models/message_test.rb index 1699f4394..a1780f1c0 100644 --- a/test/models/message_test.rb +++ b/test/models/message_test.rb @@ -1,6 +1,8 @@ require "test_helper" class MessageTest < ActiveSupport::TestCase + include ActionDispatch::TestProcess::FixtureFile + test "has an associated assistant (it's through conversation)" do assert_instance_of Assistant, messages(:hear_me).assistant end @@ -17,6 +19,15 @@ class MessageTest < ActiveSupport::TestCase # assert_instance_of Document, messages(:examine_this).content_document # end + test "handles documents_attributes" do + Current.user = users(:keith) + test_file = fixture_file_upload("cat.png", "image/png") + message = Message.create(assistant: assistants(:samantha), documents_attributes: {"0": {file: test_file}}, content_text: "Nice file") + assert_equal 1, message.documents.length + document = message.documents.first + assert_equal 'cat.png', document.filename + end + test "has an associated run" do assert_instance_of Run, messages(:yes_i_do).run end diff --git a/test/models/run_test.rb b/test/models/run_test.rb index 82486c226..96ba7a59c 100644 --- a/test/models/run_test.rb +++ b/test/models/run_test.rb @@ -22,7 +22,6 @@ class RunTest < ActiveSupport::TestCase Run.create!( assistant: assistants(:samantha), conversation: conversations(:greeting), - model: assistants(:samantha).model, instructions: assistants(:samantha).instructions, status: "queued", expired_at: 1.minute.from_now @@ -30,6 +29,17 @@ class RunTest < ActiveSupport::TestCase end end + test "model populated from assistant" do + r = Run.create!( + assistant: assistants(:keith_claude3), + conversation: conversations(:greeting), + instructions: "Some instructions", + status: "queued", + expired_at: 1.minute.from_now + ) + assert_equal 'claude-3-opus-20240229', r.model + end + test "associations are deleted upon destroy" do assert_difference "Step.count", -1 do runs(:hear_me_response).destroy diff --git a/test/models/user_test.rb b/test/models/user_test.rb index 8e5a9e9d6..d490bf0ce 100644 --- a/test/models/user_test.rb +++ b/test/models/user_test.rb @@ -149,4 +149,13 @@ class UserTest < ActiveSupport::TestCase new_user.update!(preferences: { dark_mode: "system" }) assert_equal "system", new_user.preferences[:dark_mode] end + + test "assistants scope filters out deleted vs assistants_including_deleted" do + assert_difference "users(:keith).assistants.length", -1 do + assert_no_difference "users(:keith).assistants_including_deleted.length" do + users(:keith).assistants.first.soft_delete + users(:keith).reload + end + end + end end diff --git a/test/services/ai_backend/anthropic_test.rb b/test/services/ai_backend/anthropic_test.rb index d5a5440bf..21fe401b8 100644 --- a/test/services/ai_backend/anthropic_test.rb +++ b/test/services/ai_backend/anthropic_test.rb @@ -16,7 +16,7 @@ class AIBackend::AnthropicTest < ActiveSupport::TestCase end test "get_next_chat_message works" do - assert_equal @test_client.messages, @anthropic.get_next_chat_message + assert_equal @test_client.messages(model: "gpt-4", system: "You are a helpful assistant"), @anthropic.get_next_chat_message end test "preceding_messages constructs a proper response and pivots on images" do diff --git a/test/services/ai_backend/open_ai_test.rb b/test/services/ai_backend/open_ai_test.rb index 3867fe9ab..c97e86f74 100644 --- a/test/services/ai_backend/open_ai_test.rb +++ b/test/services/ai_backend/open_ai_test.rb @@ -3,9 +3,10 @@ class AIBackend::OpenAITest < ActiveSupport::TestCase setup do @conversation = conversations(:attachments) + @assistant = assistants(:keith_claude3) @openai = AIBackend::OpenAI.new( users(:keith), - assistants(:samantha), + @assistant, @conversation, @conversation.latest_message_for_version(:latest) ) @@ -16,6 +17,19 @@ class AIBackend::OpenAITest < ActiveSupport::TestCase assert @openai.client.present? end + test "get_next_chat_message works to stream text and uses model from assistant" do + assert_not_equal @assistant, @conversation.assistant, + "We want to force this next message to use a different assistant so these should not match" + + TestClient::OpenAI.stub :text, nil do # this forces it to fall back to default text + TestClient::OpenAI.stub :api_response, -> { TestClient::OpenAI.api_text_response }do + streamed_text = "" + @openai.get_next_chat_message { |chunk| streamed_text += chunk } + assert_equal "Hello this is model claude-3-opus-20240229 with instruction nil! How can I assist you today?", streamed_text + end + end + end + test "get_tool_messages_by_calling properly executes tools" do tool_message = { role: "tool", @@ -47,18 +61,6 @@ class AIBackend::OpenAITest < ActiveSupport::TestCase assert msg[:content].starts_with?('"An unexpected error occurred. You') end - test "get_next_chat_message works to stream text" do - text = "Hello! How can I assist you today?" - - TestClient::OpenAI.stub :text, text do - TestClient::OpenAI.stub :api_response, TestClient::OpenAI.api_text_response do - streamed_text = "" - @openai.get_next_chat_message { |chunk| streamed_text += chunk } - assert_equal text, streamed_text - end - end - end - test "get_next_chat_message works to get a function call" do function = "openmeteo_get_current_and_todays_weather" diff --git a/test/support/view_helpers.rb b/test/support/view_helpers.rb new file mode 100644 index 000000000..f9ba24570 --- /dev/null +++ b/test/support/view_helpers.rb @@ -0,0 +1,10 @@ +module ViewHelpers + + private + + def assert_contains_text(selector, text) + assert_select selector, 1, "#{selector} was not found" do |element| + assert element.text.include?(text), "Element #{selector} did not contain '#{text}' (#{element.text.remove("\n")})" + end + end +end diff --git a/test/system/assistants_test.rb b/test/system/assistants_test.rb index 2f4de479b..0639dc46f 100644 --- a/test/system/assistants_test.rb +++ b/test/system/assistants_test.rb @@ -11,32 +11,33 @@ class AssistantsTest < ApplicationSystemTestCase # assert_selector "h1", text: "Assistants" # end - test "should create assistant" do + test "should create Assistant" do visit new_assistant_url fill_in "Description", with: @assistant.description fill_in "Instructions", with: @assistant.instructions - fill_in "Model", with: @assistant.model + find('#assistant_language_model_id').find(:xpath, 'option[1]').select_option fill_in "Name", with: @assistant.name fill_in "User", with: @assistant.user_id click_text "Create Assistant" assert_text "Assistant was successfully created" + assert_text "claude-3-opus-20240229 from fixtures" click_text "Back" end test "should update Assistant" do visit assistant_url(@assistant) click_text "Edit this assistant", match: :first - fill_in "Description", with: @assistant.description fill_in "Instructions", with: @assistant.instructions - fill_in "Model", with: @assistant.model + find('#assistant_language_model_id').find(:xpath, 'option[2]').select_option fill_in "Name", with: @assistant.name fill_in "User", with: @assistant.user_id click_text "Update Assistant" assert_text "Assistant was successfully updated" + assert_text "claude-3-sonnet-20240229 from fixtures" click_text "Back" end diff --git a/test/test_helper.rb b/test/test_helper.rb index d5366843b..965052ae8 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -48,6 +48,7 @@ class TestCase include ActiveJob::TestHelper include FeatureHelpers include PostgresqlHelper + include ViewHelpers parallelize(workers: :number_of_processors) fixtures :all