diff --git a/.gitignore b/.gitignore index bab408d8f..ed9ff7770 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,9 @@ /.tool-versions /node_modules +# Docker Compose override file +*compose.override.y*ml + # Ignore all environment files (except templates). /.env* !/.env*.erb diff --git a/.rubocop.yml b/.rubocop.yml index 6fad3fd68..c60afbe88 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -9,7 +9,7 @@ require: - rubocop-performance AllCops: - TargetRubyVersion: 3.0 + TargetRubyVersion: 3.3 # RuboCop has a bunch of cops enabled by default. This setting tells RuboCop # to ignore them, so only the ones explicitly set in this file are enabled. DisabledByDefault: true diff --git a/.ruby-version b/.ruby-version index fa7adc7ac..9c25013db 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.3.5 +3.3.6 diff --git a/.tool-versions b/.tool-versions index 1dd199803..5aa8e0c30 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1 @@ -ruby 3.3.5 +ruby 3.3.6 diff --git a/Dockerfile b/Dockerfile index 8a3c68ba2..93ec35227 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ ### START of FLY #### # Make sure RUBY_VERSION matches the Ruby version in .ruby-version and Gemfile -ARG RUBY_VERSION=3.3.5 +ARG RUBY_VERSION=3.3.6 FROM quay.io/evl.ms/fullstaq-ruby:${RUBY_VERSION}-jemalloc-slim as base-for-fly LABEL fly_launch_runtime="rails" @@ -53,7 +53,7 @@ RUN bundle exec bootsnap precompile app/ lib/ RUN grep -l '#!/usr/bin/env ruby' /rails/bin/* | xargs sed -i '/^#!/aDir.chdir File.expand_path("..", __dir__)' # Precompiling assets for production without requiring secret RAILS_MASTER_KEY -RUN SECRET_KEY_BASE_DUMMY=1 VALIDATE_ENV_VARS=0 ./bin/rails assets:precompile +RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile # Final stage for app image @@ -89,7 +89,8 @@ EXPOSE 3000 #### START of DEV #### # RUBY_VERSION is the only thing used from anything above -FROM ruby:${RUBY_VERSION}-alpine AS development +FROM ruby:${RUBY_VERSION}-alpine@sha256:caeab43b356463e63f87af54a03de1ae4687b36da708e6d37025c557ade450f8 AS development +# TODO: When we bump to a new version of ruby we'll ahve to unpin from this specific sha RUN apk add --no-cache bash git build-base postgresql-dev curl-dev gcompat tzdata vips-dev imagemagick @@ -134,7 +135,7 @@ COPY . . # Precompiling assets for production without requiring secret RAILS_MASTER_KEY RUN bundle exec bootsnap precompile --gemfile app/ lib/ -RUN SECRET_KEY_BASE_DUMMY=1 VALIDATE_ENV_VARS=0 ./bin/rails assets:precompile +RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile RUN mkdir -p log tmp bin diff --git a/Gemfile b/Gemfile index 7879c5ded..6bbf7418a 100644 --- a/Gemfile +++ b/Gemfile @@ -13,7 +13,6 @@ gem "turbo-rails", "~> 2.0.5" gem "stimulus-rails", "~> 1.3.3" gem "tailwindcss-rails", "~> 2.7.2" gem "rack-cors" -gem "ostruct" # Build JSON APIs with ease [https://github.com/rails/jbuilder] # gem "jbuilder" @@ -42,6 +41,7 @@ gem "amatch", "~> 0.4.1" # enables fuzzy comparison of strings, a tool uses this gem "rails_heroicon", "~> 2.2.0" gem "ruby-openai", "~> 7.0.1" gem "anthropic", "~> 0.1.0" # TODO update to the latest version +gem "gemini-ai", "~> 4.2.0" gem "tiktoken_ruby", "~> 0.0.9" gem "solid_queue", "~> 1.0.0" gem "name_of_person" @@ -52,6 +52,7 @@ gem "ostruct" gem "omniauth", "~> 2.1" gem "omniauth-google-oauth2", "~> 1.1" +gem "omniauth-microsoft_graph", "~> 2.0" gem "omniauth-rails_csrf_protection", "~> 1.0.2" group :development, :test do @@ -86,4 +87,6 @@ group :test do gem "selenium-webdriver" gem "minitest-stub_any_instance" gem "rails-controller-testing" + gem "minitest-retry" + gem "webmock" end diff --git a/Gemfile.lock b/Gemfile.lock index 83d95e829..42848495a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -122,6 +122,9 @@ GEM coderay (1.1.3) concurrent-ruby (1.3.4) connection_pool (2.4.1) + crack (1.0.0) + bigdecimal + rexml crass (1.0.6) date (3.3.4) debug (1.8.0) @@ -133,20 +136,41 @@ GEM erubi (1.13.0) et-orbi (1.2.11) tzinfo + ethon (0.16.0) + ffi (>= 1.15.0) event_stream_parser (1.0.0) - faraday (2.8.1) - base64 - faraday-net_http (>= 2.0, < 3.1) - ruby2_keywords (>= 0.0.4) + faraday (2.12.1) + faraday-net_http (>= 2.0, < 3.5) + json + logger faraday-multipart (1.0.4) multipart-post (~> 2) faraday-net_http (3.0.2) + faraday-typhoeus (1.1.0) + faraday (~> 2.0) + typhoeus (~> 1.4) ffi (1.15.5) fugit (1.11.1) et-orbi (~> 1, >= 1.2.11) raabro (~> 1.4) + gemini-ai (4.2.0) + event_stream_parser (~> 1.0) + faraday (~> 2.10) + faraday-typhoeus (~> 1.1) + googleauth (~> 1.8) + typhoeus (~> 1.4, >= 1.4.1) globalid (1.2.1) activesupport (>= 6.1) + google-cloud-env (2.2.1) + faraday (>= 1.0, < 3.a) + googleauth (1.11.2) + faraday (>= 1.0, < 3.a) + google-cloud-env (~> 2.1) + jwt (>= 1.4, < 3.0) + multi_json (~> 1.11) + os (>= 0.9, < 2.0) + signet (>= 0.16, < 2.a) + hashdiff (1.1.2) hashie (5.0.0) i18n (1.14.6) concurrent-ruby (~> 1.0) @@ -182,10 +206,13 @@ GEM mini_magick (4.13.2) mini_mime (1.1.5) minitest (5.25.1) + minitest-retry (0.2.3) + minitest (>= 5.0) minitest-stub_any_instance (1.0.3) mize (0.4.1) protocol (~> 2.0) msgpack (1.7.2) + multi_json (1.15.0) multi_xml (0.7.1) bigdecimal (~> 3.1) multipart-post (2.3.0) @@ -201,6 +228,12 @@ GEM net-smtp (0.5.0) net-protocol nio4r (2.7.3) + nokogiri (1.16.7-aarch64-linux) + racc (~> 1.4) + nokogiri (1.16.7-arm64-darwin) + racc (~> 1.4) + nokogiri (1.16.7-x86_64-darwin) + racc (~> 1.4) nokogiri (1.16.7-x86_64-linux) racc (~> 1.4) oauth2 (2.0.9) @@ -219,12 +252,17 @@ GEM oauth2 (~> 2.0) omniauth (~> 2.0) omniauth-oauth2 (~> 1.8) + omniauth-microsoft_graph (2.0.1) + jwt (~> 2.0) + omniauth (~> 2.0) + omniauth-oauth2 (~> 1.8.0) omniauth-oauth2 (1.8.0) oauth2 (>= 1.4, < 3) omniauth (~> 2.0) omniauth-rails_csrf_protection (1.0.2) actionpack (>= 4.2) omniauth (~> 2.0) + os (1.1.4) ostruct (0.6.0) parallel (1.24.0) parser (3.2.2.4) @@ -351,7 +389,6 @@ GEM ruby-vips (2.2.2) ffi (~> 1.12) logger - ruby2_keywords (0.0.5) ruby_parser (3.21.0) racc (~> 1.5) sexp_processor (~> 4.16) @@ -364,6 +401,11 @@ GEM rubyzip (>= 1.2.2, < 3.0) websocket (~> 1.0) sexp_processor (4.17.1) + signet (0.19.0) + addressable (~> 2.8) + faraday (>= 0.17.5, < 3.a) + jwt (>= 1.5, < 3.0) + multi_json (~> 1.10) snaky_hash (2.0.1) hashie version_gem (~> 1.1, >= 1.1.1) @@ -398,9 +440,18 @@ GEM railties (>= 6.0.0) stringio (3.1.1) sync (0.5.0) + tailwindcss-rails (2.7.2-aarch64-linux) + railties (>= 7.0.0) + tailwindcss-rails (2.7.2-arm64-darwin) + railties (>= 7.0.0) + tailwindcss-rails (2.7.2-x86_64-darwin) + railties (>= 7.0.0) tailwindcss-rails (2.7.2-x86_64-linux) railties (>= 7.0.0) thor (1.3.2) + tiktoken_ruby (0.0.9-aarch64-linux) + tiktoken_ruby (0.0.9-arm64-darwin) + tiktoken_ruby (0.0.9-x86_64-darwin) tiktoken_ruby (0.0.9-x86_64-linux) timecop (0.9.8) timeout (0.4.1) @@ -411,6 +462,8 @@ GEM actionpack (>= 6.0.0) activejob (>= 6.0.0) railties (>= 6.0.0) + typhoeus (1.4.1) + ethon (>= 0.9.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) unicode-display_width (2.5.0) @@ -421,6 +474,10 @@ GEM activemodel (>= 6.0.0) bindex (>= 0.4.0) railties (>= 6.0.0) + webmock (3.24.0) + addressable (>= 2.8.0) + crack (>= 0.3.2) + hashdiff (>= 0.4.0, < 2.0.0) webrick (1.8.2) websocket (1.2.11) websocket-driver (0.7.6) @@ -431,6 +488,11 @@ GEM zeitwerk (2.7.1) PLATFORMS + aarch64-linux-musl + arm64-darwin-23 + arm64-darwin-24 + x86_64-darwin-22 + x86_64-darwin-23 x86_64-linux DEPENDENCIES @@ -445,12 +507,15 @@ DEPENDENCIES debug dockerfile-rails (>= 1.6) ffi (~> 1.15.5) + gemini-ai (~> 4.2.0) image_processing (~> 1.13.0) importmap-rails + minitest-retry minitest-stub_any_instance name_of_person omniauth (~> 2.1) omniauth-google-oauth2 (~> 1.1) + omniauth-microsoft_graph (~> 2.0) omniauth-rails_csrf_protection (~> 1.0.2) ostruct pg (~> 1.1) @@ -479,9 +544,10 @@ DEPENDENCIES turbo-rails (~> 2.0.5) tzinfo-data web-console + webmock RUBY VERSION - ruby 3.3.5p100 + ruby 3.3.6p108 BUNDLED WITH - 2.5.1 + 2.5.22 diff --git a/README.md b/README.md index 78c3a5fbc..164d14022 100644 --- a/README.md +++ b/README.md @@ -22,23 +22,23 @@ This project is led by an experienced rails developer, but I'm actively looking - [Top features of HostedGPT](#top-features-of-hostedgpt) - [Watch a short demo](#watch-a-short-demo) -- [Table of Contents](#table-of-contents) - [Deploy the app on Render](#deploy-the-app-on-render) - [Troubleshooting Render](#troubleshooting-render) - [Deploy the app on Fly.io](#deploy-the-app-on-flyio) - [Deploy the app on Heroku](#deploy-the-app-on-heroku) - [Deploy on your own server](#deploy-on-your-own-server) +- [Running locally on your computer](#running-locally-on-your-computer) + - [Alternatively, you can run outside of Docker](#alternatively-you-can-run-outside-of-docker)] - [Configure optional features](#configure-optional-features) - [Give assistant access to your Google apps](#configuring-google-tools) - [Authentication](#authentication) - [Password authentication](#password-authentication) - [Google OAuth authentication](#google-oauth-authentication) + - [Microsoft Graph OAuth authentication](#microsoft-graph-oauth-authentication) - [HTTP header authentication](#http-header-authentication) - [Contribute as a developer](#contribute-as-a-developer) - - [Running locally](#Running-locally) - - [Alternatively, you can skip Docker:](#alternatively-you-can-set-skip-docker) - - [Running tests](#running-tests) -- [Understanding the Docker configuration](#understanding-the-docker-configuration) + - [Running the test suite](#running-the-test-suite) + - [Understanding the Docker configuration](#understanding-the-docker-configuration) - [Changelog](#changelog) ## Deploy the app on Render @@ -81,18 +81,19 @@ If you encountered an error while waiting for the services to be deployed on Ren 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. Click Fork > Create New Fork at the top of this repository. **Pull your forked repository down to your computer (the usual git clone ...)**. +1. Go into the directory you just created with your git clone and run `bundle` 1. Install the Fly command-line tool on Mac with `brew install flyctl` otherwise `curl -L https://fly.io/install.sh | sh` ([view instructions](https://fly.io/docs/hands-on/install-flyctl/)) -1. Think of an internal Fly name for your app, it has to be unique to all of Fly, and then in the root directory of the repository you pulled down, run `fly launch --build-only --copy-config --name=APP_NAME_YOU_CHOSE` +1. Think of an internal Fly name for your app, it has to be unique to all of Fly. You'll use this **APP_NAME** three times in the steps below. First, in the root directory of the repository you pulled down, run `fly launch --build-only --copy-config --name=APP_NAME` - Say "Yes" when it asks if you want to tweak these settings -1. When it opens your browser, change the Database to `Fly Postgres` with a unique name such as `[APP_NAME]-db` and you can set the configuration to `Development`. +1. When it opens your browser, (i) change the Database to `Fly Automated Postgres`, (ii) set the name to be `[APP_NAME]-db`, (iii) and you can set the configuration to `Development`. 1. Click `Confirm Settings` at the bottom of the page and close the browser. -1. The app will do a bunch of build steps and then return to the command line. Scroll through the output and save the Postgres username & password somewhere as you'll never be able to see those again. -1. Next run `bin/rails db:setup_encryption[true]`. This will initialize some private keys for your app and send them to Fly. +1. The app will do a bunch of build steps and then return to the command line. Scroll through the output and **save the Postgres username & password somewhere as you'll never be able to see those again**. +1. Next run `bin/rails db:setup_encryption[true]`. This will initialize some private keys for your app and send them to Fly. (If you get an error you may have forgotten to run `bundle`). 1. Run `fly deploy --ha=false` -1. Assuming you chose `Development` as the DB size in the step above, now you should run `bin/rails db:fly[APP_NAME_FROM_EARLIER,swap,512]` This will increase the swap on your database machine so that it doesn't crash. +1. Assuming you chose `Development` as the DB size in the step above, now you should run `bin/rails db:fly[APP_NAME,swap,512]` This will increase the swap on your database machine so that it doesn't crash since the Development database has less ram. You may want to read about [configuring optional features](#configure-optional-features). @@ -152,16 +153,65 @@ If you are running a proxy such as nginx, be aware that the app is running http ``` +## Running locally on your computer + +The easiest way to get up and running is to use the provided Docker compose workflow. The only things you need installed on your computer are Docker and Git. + +1. Make sure you have [Docker Desktop](https://docs.docker.com/desktop/) installed and running +1. Clone your fork `git clone [repository url]` +1. `cd` into your clone +1. Run `docker compose up --build` to start the app +1. Open [http://localhost:3000](http://localhost:3000) and register as a new user +1. Run tests: `docker compose run base rails test` The app has comprehensive test coverage but note that system tests currently do not work in docker. +1. Open the rails console: `docker compose run base rails console` +1. Run a psql console: `docker compose run base psql` +1. If you want a few fake users and a bunch of conversations and other data pre-populated in the database, you can load fixtures into the development database. This can be helpful, for example, if you want to test a migration and save yourself the time manually creating a bunch of data: `docker compose run base rails db:fixtures:load` +1. The project root has an `.editorconfig` file to help eliminate whitespace differences in pull requests. It's nice if you install an extension in your IDE to utilize this (e.g. VS Code has "EditorConfig for VS Code"). + +Every time you pull new changes down, kill docker (if it's running) and re-run: +`docker compose up --build` This will ensure your local app picks up changes to Gemfile, migrations, and docker config. + +If you are doing development see [Running the test suite](#running-the-test-suite). + +If you need to make changes to the Docker configuration, see the [Understanding the Docker configuration](#understanding-the-docker-configuration). + +If you want to run your LLM locally so the app has no online dependencies, see [Running an LLM on your computer](https://github.com/AllYourBot/hostedgpt/discussions/471). + +### Alternatively, you can run outside of Docker + +HostedGPT requires these services to be running: + +- Postgres (`brew install postgresql@16` or other [install instructions](https://www.postgresql.org/download/)) +- rbenv ([installation instructions](https://github.com/rbenv/rbenv)) +- ImageMagick (`brew install imagemagick` should work on Mac ) + +1. `cd` into your local repository clone +1. `rbenv install` to install the correct ruby version (it reads the .ruby-version in the repo) +1. Do NOT run db:setup as it will not configure encryption properly. Proceed to the next step and it will automatically configure the database. +1. `bin/dev` starts up all the services, installs gems, and handles db. The app will automatically configure a database, but check [Configure optional features](#configure-optional-features) if you need to change the default configuration. +1. Open [http://localhost:3000](http://localhost:3000) and register as a new user. +1. `bin/rails test` and `bin/rails test:system` to run the comprehensive tests +1. The project root has an `.editorconfig` file to help eliminate whitespace differences in pull requests. It's nice if you install an extension in your IDE to utilize this (e.g. VS Code has "EditorConfig for VS Code"). +1. If you want a few fake users and a bunch of conversations and other data pre-populated in the database, you can load fixtures into the development database. This can be helpful, for example, if you want to test a migration and save yourself the time manually creating a bunch of data: `bin/rails db:fixtures:load` + +Every time you pull new changes down, kill `bin/dev` and then re-run it. This will ensure your local app picks up changes to Gemfile and migrations. + +If you are doing development see [Running the test suite](#running-the-test-suite). + +If you want to run your LLM locally so the app has no online dependencies, see [Running an LLM on your computer](https://github.com/AllYourBot/hostedgpt/discussions/471). + ## Configure optional features There are a number of optional feature flags that can be set and settings that can be configured. All of these can be seen in the file `options.yml`, however each is explained below and can be activated by setting environment variables. - `PRODUCTION_HOST` is blank but you should ideally set this if you are deploying the app with a public domain to protect against host header attacks. For example, set it to `example.com` (leave off https). You may add multiple host names separated by comma, `example.fly.dev, example.com` (whitespace is ignored). +- Database defaults can be changed with `HOSTED_DB_USERNAME`, `HOSTED_DB_PASSWORD`, `HOSTED_DB_HOST`, `HOSTED_DB_PORT`, and `HOSTED_DB_NAME` (note: _development, _test, and/or _production will be appended after DB_NAME based on the environment). - `REGISTRATON_FEATURE` is `true` by default, but you can set to `false` to prevent any new people from creating an account. - `DEFAULT_LLM_KEYS` is `false` by default so each user is expected to add LLM API keys to their user settings. Set this to `true` if you want to configure LLM API keys that will be shared by all users. Set one or more of the additional variables in order to use this feature. The app will still check if the user has added their own API keys for any services and will use those instead of the default ones. - `DEFAULT_OPENAI_KEY` will be used by the pre-configured OpenAI API Service - `DEFAULT_ANTHROPIC_KEY` will be used by the pre-configured Anthropic API Service - `DEFAULT_GROQ_KEY` will be used by the pre-configured Groq API Service +- Edit `models.yml` to modify which Language Models are automatically created for new users upon signing up. Any changes to this file will be applied to existing users when `rails models:import` is run, or when `rails db:prepare` is run, or when the server is restarted. If you ever need to export your list of models you can do `rails models:export[tmp/models.json]` - `CLOUDFLARE_STORAGE_FEATURE` is `false` by default so any files that are uploaded while chatting with your assistant will be stored in postgres. This is recommended for small deployments. Set this to `true` if you would like to store message attachments in Cloudflare's R2 storage (this mimics AWS S3). You must also sign up for Cloudflare. The free tier allows 10 GB of storage. After you sign up, you need to create a new bucket and an API token. The API token should have "Object Read and Write" access to your bucket. Take note of your Access Key ID and your Secret Access Key along with your Account ID. Set the following environment variables: - `CLOUDFLARE_ACCOUNT_ID` - Your Cloudflare Account ID - `CLOUDFLARE_ACCESS_KEY_ID` - Your Cloudflare Access Key ID @@ -195,6 +245,7 @@ HostedGPT supports multiple authentication methods: - [Password authentication](#password-authentication) - [Google OAuth authentication](#google-oauth-authentication) +- [Microsoft Graph OAuth authentication](#microsoft-graph-oauth-authentication) #### Password authentication @@ -209,6 +260,14 @@ To enable Google OAuth authentication, you need to set up Google OAuth in the Go - `GOOGLE_AUTH_CLIENT_ID` - Google OAuth client ID - `GOOGLE_AUTH_CLIENT_SECRET` - Google OAuth client secret +Alternately, add the following to your encrypted credentials file: + +```yaml +google: + auth_client_id: + auth_client_secret: +``` + **Steps to set up:** 1. **Go to the Google Cloud Console and Create a New Project:** @@ -247,6 +306,52 @@ To enable Google OAuth authentication, you need to set up Google OAuth in the Go - `GOOGLE_AUTH_CLIENT_ID`: Your Client ID - `GOOGLE_AUTH_CLIENT_SECRET`: Your Client Secret +#### Microsoft Graph OAuth authentication + +Microsoft Graph OAuth authentication is disabled by default. You can enable it by setting `MICROSOFT_GRAPH_AUTHENTICATION_FEATURE` to `true`. + +To enable Microsoft Graph OAuth authentication, you need to set up Microsoft Graph OAuth in the Microsoft Azure portal. It's a bit involved but we've outlined the steps below. After you follow these steps you will set the following environment variables: + +- `MICROSOFT_GRAPH_AUTH_CLIENT_ID` - Microsoft Graph OAuth client ID +- `MICROSOFT_GRAPH_AUTH_CLIENT_SECRET` - Microsoft Graph OAuth client secret +- `MICROSOFT_GRAPH_SCOPE` - Space separated list of scopes to request. This defaults to `openid profile email offline_access user.read mailboxsettings.read`. + +Alternately, add the following to your encrypted credentials file: + +```yaml +microsoft_graph: + auth_client_id: + auth_client_secret: + scope: openid profile email offline_access user.read mailboxsettings.read +``` + +Users will need to have setup their full name in their Microsoft account before they can use this authentication method, via , otherwise they will see a login/registration error like "First name can't be blank and last name can't be blank". + +Users can remotely remove the connection between their Microsoft account and HostedGPT by going to and clicking "Don't Allow" on the corresponding application. However, this will not sign out the user from HostedGPT until the session expires. + +**Steps to set up:** + +1. **Go to the Microsoft Azure portal and create a new application:** + + - Navigate to [Register an application](https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/CreateApplicationBlade/quickStartType) + - Give it a name + - Select the Supported account types + - Select the Redirect URI for "Web" (e.g., `https://example.com/auth/microsoft/callback` or `http://localhost:3000/auth/microsoft/callback`) + - Click Register + +2. **Create OAuth Credentials:** + + - The client ID ("Application (client) ID") is displayed on the Overview page + - To generate a client secret, click on "Add a certificate or secret" > "New client secret" + - Give it a name and pick an expiration date + - Back on the "Certificates & secrets" page, the new client secret will be listed under "Value" + +3. **Set Environment Variables:** + - Set the Client ID and Client Secret as environment variables in your application: + - `MICROSOFT_GRAPH_AUTH_CLIENT_ID`: Your Client ID + - `MICROSOFT_GRAPH_AUTH_CLIENT_SECRET`: Your Client Secret + - `MICROSOFT_GRAPH_SCOPE` - Space separated list of scopes to request. This defaults to `openid profile email offline_access user.read mailboxsettings.read`. + #### HTTP header authentication Note: Enabling this automatically disables Password-based and Google-auth based authentication. @@ -274,49 +379,15 @@ HTTP header authentication is an alternative method to authenticate users based We welcome contributors! After you get your development environment setup, review the list of Issues. We organize the issues into Milestones and are currently wrapping up v0.7 and starting 0.8 [View 0.8 Milestone](https://github.com/allyourbot/hostedgpt/milestone/8). Look for any issues tagged with **Good first issue** and add a comment so we know you're working on it. -### Running locally - -The easiest way to get up and running is to use the provided Docker compose workflow. The only things you need installed on your computer are Docker and Git. - -1. Make sure you have [Docker Desktop](https://docs.docker.com/desktop/) installed and running -1. Clone your fork `git clone [repository url]` -1. `cd` into your clone -1. Run `docker compose up --build` to start the app -1. Open [http://localhost:3000](http://localhost:3000) and register as a new user -1. Run tests: `docker compose run base rails test` The app has comprehensive test coverage but note that system tests currently do not work in docker. -1. Open the rails console: `docker compose run base rails console` -1. Run a psql console: `docker compose run base psql` -1. If you want a few fake users and a bunch of conversations and other data pre-populated in the database, you can load fixtures into the development database. This can be helpful, for example, if you want to test a migration and save yourself the time manually creating a bunch of data: `docker compose run base rails db:fixtures:load` -1. The project root has an `.editorconfig` file to help eliminate whitespace differences in pull requests. It's nice if you install an extension in your IDE to utilize this (e.g. VS Code has "EditorConfig for VS Code"). - -Every time you pull new changes down, kill docker (if it's running) and re-run: -`docker compose up --build` This will ensure your local app picks up changes to Gemfile, migrations, and docker config. - -#### Alternatively, you can skip Docker - -HostedGPT requires these services to be running: - -- Postgres ([installation instructions](https://www.postgresql.org/download/)) -- rbenv ([installation instructions](https://github.com/rbenv/rbenv)) -- ImageMagick (`brew install imagemagick` should work on Mac ) - -1. `cd` into your local repository clone -1. `rbenv install` to install the correct ruby version (it reads the .ruby-version in the repo) -1. `bin/dev` starts up all the services, installs gems, and inits database (don't run **db:setup** as it will not configure encryption properly) -1. Open [http://localhost:3000](http://localhost:3000) and register as a new user -1. `bin/rails test` and `bin/rails test:system` to run the comprehensive tests -1. The project root has an `.editorconfig` file to help eliminate whitespace differences in pull requests. It's nice if you install an extension in your IDE to utilize this (e.g. VS Code has "EditorConfig for VS Code"). -1. If you want a few fake users and a bunch of conversations and other data pre-populated in the database, you can load fixtures into the development database. This can be helpful, for example, if you want to test a migration and save yourself the time manually creating a bunch of data: `bin/rails db:fixtures:load` - -Every time you pull new changes down, kill `bin/dev` and then re-run it. This will ensure your local app picks up changes to Gemfile and migrations. +Get your development environment set up by running the Rails app directly on your machine either with Docker or outside of Docker. See [Running locally on your computer](#running-locally-on-your-computer) for more details. -### Running tests +### Running the test suite If you're set up with Docker you run `docker compose run base rails test`. Note that the system tests, which use a headless browser, are not able to run in Docker. They will be run automatically for you if you create a Pull Request against the project. If you set up the app outside of Docker, then run the usual `bin/rails test` and `bin/rails test:system`. -## Understanding the Docker configuration +### Understanding the Docker configuration The `Dockerfile` is set up to support three distinct situations: development, deploying to Render, and deploying to Fly. Each of these are completely separate targets which don't share any steps, they are simply in the same Dockerfile. diff --git a/app/assets/images/google_gemini_logo.svg b/app/assets/images/google_gemini_logo.svg new file mode 100644 index 000000000..787c83710 --- /dev/null +++ b/app/assets/images/google_gemini_logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/controllers/authentications/google_oauth_controller.rb b/app/controllers/authentications/google_oauth_controller.rb index af070aa1e..3d8a83fd3 100644 --- a/app/controllers/authentications/google_oauth_controller.rb +++ b/app/controllers/authentications/google_oauth_controller.rb @@ -97,7 +97,7 @@ def initialize_google_person def add_person_credentials(type) p = Current.person || @person c = p.user.credentials.build( - type: type, + type:, oauth_id: auth[:uid], oauth_email: auth[:info][:email], oauth_token: auth[:credentials][:token], diff --git a/app/controllers/authentications/microsoft_graph_oauth_controller.rb b/app/controllers/authentications/microsoft_graph_oauth_controller.rb new file mode 100644 index 000000000..49cce2d28 --- /dev/null +++ b/app/controllers/authentications/microsoft_graph_oauth_controller.rb @@ -0,0 +1,106 @@ +class Authentications::MicrosoftGraphOauthController < ApplicationController + allow_unauthenticated_access + + # GET /auth/microsoft_graph/callback + def create + if params[:error] + if Current.user + redirect_to(edit_settings_person_path, alert: params[:error_description]) + else + redirect_to(login_path, alert: params[:error_description]) + end + return + end + + if Current.user + Current.user.microsoft_graph_credential&.destroy + _, cred = add_person_credentials("MicrosoftGraphCredential") + cred.save! && redirect_to(edit_settings_person_path, notice: "Saved") && return + + elsif (credential = MicrosoftGraphCredential.find_by(oauth_id: auth[:uid])) + @person = credential.user.person + + elsif Feature.disabled?(:registration) + redirect_to(root_path, alert: "Registration is disabled") && return + + elsif auth_email && (user = Person.find_by(email: auth_email)&.user) + @person = init_for_user(user) + + elsif auth_email && (@person = Person.find_by(email: auth_email)) + @person = init_for_person(@person) + + else + @person = initialize_microsoft_person + end + + if @person&.save + login_as(@person, credential: @person.user.reload.microsoft_graph_credential) + redirect_to root_path + else + @person&.errors&.delete :personable + msg = @person.errors.full_messages.map { |m| m.gsub(/Personable |credentials /, "") }.to_sentence.capitalize + if msg.downcase.include?("oauth refresh token can't be blank") + msg += " " + helpers.link_to("Microsoft third-party connections", "https://account.microsoft.com/privacy/app-access", class: "underline") + " search for website, and delete all it's connections. Then try again." + end + + redirect_to new_user_path, alert: msg + end + rescue => e + warn e.message + warn e.backtrace.join("\n") + redirect_to edit_settings_person_path, alert: "Error. #{e.message}", status: :see_other + end + + private + + def auth + request.env["omniauth.auth"]&.deep_symbolize_keys || {} + end + + def auth_email + auth.dig(:info, :email) + end + + def init_for_user(user) + user.microsoft_graph_credential&.destroy + + user.first_name = auth[:info][:first_name] + user.last_name = auth[:info][:last_name] + @person = user.person + add_person_credentials("MicrosoftGraphCredential").first + end + + def init_for_person(person) + @person.personable_type = "User" + @person.personable_attributes = { + first_name: auth[:info][:first_name], + last_name: auth[:info][:last_name] + } + add_person_credentials("MicrosoftGraphCredential").first + end + + def initialize_microsoft_person + @person = Person.new({ + personable_type: "User", + email: auth_email, + personable_attributes: { + first_name: auth[:info][:first_name], + last_name: auth[:info][:last_name], + } + }) + add_person_credentials("MicrosoftGraphCredential").first + end + + def add_person_credentials(type) + p = Current.person || @person + c = p.user.credentials.build( + type: type, + oauth_id: auth[:uid], + oauth_email: auth[:info][:email], + oauth_token: auth[:credentials][:token], + oauth_refresh_token: auth[:credentials][:refresh_token], + properties: auth[:credentials].except(:token, :refresh_token) + ) + [p, c] + end +end diff --git a/app/controllers/concerns/authenticate/login_logout.rb b/app/controllers/concerns/authenticate/login_logout.rb index 3d54a5366..b589cb3ed 100644 --- a/app/controllers/concerns/authenticate/login_logout.rb +++ b/app/controllers/concerns/authenticate/login_logout.rb @@ -35,6 +35,6 @@ def reset_authentication end def manual_login_allowed? - Feature.password_authentication? || Feature.google_authentication? + Feature.password_authentication? || Feature.google_authentication? || Feature.microsoft_graph_authentication? end end diff --git a/app/controllers/conversations_controller.rb b/app/controllers/conversations_controller.rb index aeef68680..5c0b3a084 100644 --- a/app/controllers/conversations_controller.rb +++ b/app/controllers/conversations_controller.rb @@ -1,8 +1,12 @@ class ConversationsController < ApplicationController - before_action :set_conversation, only: [:show, :edit, :update, :destroy] - before_action :set_nav_conversations + before_action :set_conversation before_action :set_nav_assistants + def index + @query = params[:query] + @nav_conversations = Conversation.grouped_by_increasing_time_interval_for_user(Current.user, @query) + end + def show end @@ -28,16 +32,12 @@ def destroy private - def set_nav_conversations - @nav_conversations = Conversation.grouped_by_increasing_time_interval_for_user(Current.user) - end - def set_nav_assistants @nav_assistants = Current.user.assistants.ordered end def set_conversation - @conversation = Current.user.conversations.find(params[:id]) + @conversation = Current.user.conversations.find_by(id: params[:id] || params[:conversation_id]) end def conversation_params diff --git a/app/controllers/messages_controller.rb b/app/controllers/messages_controller.rb index a88720cbb..8aff33231 100644 --- a/app/controllers/messages_controller.rb +++ b/app/controllers/messages_controller.rb @@ -6,7 +6,6 @@ class MessagesController < ApplicationController before_action :set_conversation, only: [:index] before_action :set_assistant, only: [:index, :new, :edit, :create] before_action :set_message, only: [:show, :edit, :update] - before_action :set_nav_conversations, only: [:index, :new] before_action :set_nav_assistants, only: [:index, :new] before_action :set_conversation_starters, only: [:new] @@ -46,7 +45,6 @@ def create redirect_to conversation_messages_path(@message.conversation, version: @message.version), status: :see_other else # what's the right flow for a failed message create? it's not this, but hacking it so tests pass until we have a plan - set_nav_conversations set_nav_assistants @new_message = @assistant.messages.new @@ -73,7 +71,7 @@ def set_conversation end def set_assistant - @assistant = Current.user.assistants_including_deleted.find_by(id: params[:assistant_id]) + @assistant = Current.user.assistants_including_deleted.find_by(slug: params[:assistant_id]) @assistant ||= @conversation.latest_message_for_version(@version).assistant end @@ -82,10 +80,6 @@ def set_message redirect_to root_url, status: :unauthorized if @message.conversation.user != Current.user end - def set_nav_conversations - @nav_conversations = Conversation.grouped_by_increasing_time_interval_for_user(Current.user) - end - def set_nav_assistants @nav_assistants = Current.user.assistants.ordered end diff --git a/app/controllers/settings/assistants_controller.rb b/app/controllers/settings/assistants_controller.rb index 37ae5317f..c823d79e8 100644 --- a/app/controllers/settings/assistants_controller.rb +++ b/app/controllers/settings/assistants_controller.rb @@ -38,7 +38,7 @@ def destroy private def set_assistant - @assistant = Current.user.assistants.find_by(id: params[:id]) + @assistant = Current.user.assistants.find_by(slug: params[:id]) if @assistant.nil? redirect_to new_settings_assistant_url, notice: "The assistant was deleted", status: :see_other end @@ -49,6 +49,6 @@ def set_last_assistant end def assistant_params - params.require(:assistant).permit(:name, :description, :instructions, :language_model_id) + params.require(:assistant).permit(:name, :slug, :description, :instructions, :language_model_id) end end diff --git a/app/controllers/settings/language_models_controller.rb b/app/controllers/settings/language_models_controller.rb index 5ff7f919d..5c99396f5 100644 --- a/app/controllers/settings/language_models_controller.rb +++ b/app/controllers/settings/language_models_controller.rb @@ -3,7 +3,7 @@ class Settings::LanguageModelsController < Settings::ApplicationController before_action :set_system_language_model, only: [:show] def index - @language_models = LanguageModel.for_user(Current.user).order(updated_at: :desc) + @language_models = LanguageModel.for_user(Current.user).ordered end def edit @@ -53,6 +53,6 @@ def set_system_language_model end def language_model_params - params.require(:language_model).permit(:api_name, :name, :supports_images, :supports_tools, :api_service_id) + params.require(:language_model).permit(:api_name, :name, :best, :supports_images, :supports_tools, :api_service_id, :supports_system_message) end end diff --git a/app/controllers/settings/memories_controller.rb b/app/controllers/settings/memories_controller.rb index cfc0b43f6..951b32474 100644 --- a/app/controllers/settings/memories_controller.rb +++ b/app/controllers/settings/memories_controller.rb @@ -1,10 +1,23 @@ class Settings::MemoriesController < Settings::ApplicationController + before_action :set_memory, only: :destroy + def index @memories = Current.user.memories.includes(:message) end def destroy + @memory.destroy! + redirect_to settings_memories_url, notice: "Forgotten", status: :see_other + end + + def destroy_all Current.user.memories.delete_all redirect_to settings_memories_url, notice: "Cleared memory", status: :see_other end + + private + + def set_memory + @memory = Current.user.memories.find_by(id: params[:id]) + end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 1e2f0d02e..a088b0899 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,4 +1,10 @@ module ApplicationHelper + + def at_most_two_initials(initials) + return initials if initials.nil? || initials.length <= 2 + initials[0] + initials[-1] + end + def spinner(opts = {}) html = <<~HTML @@ -70,15 +76,15 @@ def div_tag(content_or_options_with_block = nil, options = nil, &block) end def meta_tag(name, content) - tag.meta(name: name, content: content) + tag.meta(name:, content:) end def charset_tag(charset) - tag.meta(charset: charset) + tag.meta(charset:) end def viewport_tag(content) - tag.meta(name: "viewport", content: content) + tag.meta(name: "viewport", content:) end def n_a_if_blank(value, n_a = "Not Available") @@ -86,6 +92,6 @@ def n_a_if_blank(value, n_a = "Not Available") end def to_dollars(cents, precision: 2) - number_to_currency(cents / 100.0, precision: precision) + number_to_currency(cents / 100.0, precision:) end end diff --git a/app/helpers/messages_helper.rb b/app/helpers/messages_helper.rb index 9b26aaa7b..7c758bf68 100644 --- a/app/helpers/messages_helper.rb +++ b/app/helpers/messages_helper.rb @@ -4,6 +4,8 @@ module MessagesHelper def render_avatar_for(message) if message.user? render partial: "layouts/user_avatar", locals: { user: Current.user, size: 7, classes: "mt-1" } + elsif message.tool? + render partial: "layouts/tool_avatar" else render partial: "layouts/assistant_avatar", locals: { assistant: message.assistant, size: 7, classes: "mt-1" } end @@ -13,8 +15,7 @@ def from_name_for(message) case message.role when "user" then "You" when "assistant" then message.assistant.name - when "tool" then "Tool" - end + end end def format_for_copying(text) @@ -25,17 +26,36 @@ def format_for_speaking(text) ::MarkdownRenderer.render_for_speaking(text) end - def format_for_display(text, append_inside_tag: nil) - escaped_text = html_escape(text) + def format_for_display(message, append_inside_tag: nil) + if message_to_user_from_tool_call?(message) + function_name = message.content_tool_calls.dig(:function, :name) + message_to_user = JSON.parse(message.content_text)["message_to_user"] - html = ::MarkdownRenderer.render_for_display( - escaped_text, - block_code: block_code - ) + case function_name + when "memory_remember_detail_about_user" + return link_to message_to_user, + settings_memories_path, + { data: { turbo_frame: "_top" }, class: "text-gray-400 dark:!text-gray-500 font-normal no-underline" } + when "googlesearch_google_search" + query = message_to_user.partition(":").last + return link_to message_to_user, + "https://www.google.com/search?q=#{URI.encode_www_form_component(query)}", + { target: :_blank, data: { turbo_frame: "_top" }, class: "text-gray-400 dark:!text-gray-500 font-normal no-underline" } + else + return content_tag(:span, message_to_user, class: "text-gray-400 dark:!text-gray-500") + end + else + escaped_text = html_escape(message.content_text) - html = "

" if html.blank? - html = append(html, append_inside_tag) if append_inside_tag - return html.html_safe + html = ::MarkdownRenderer.render_for_display( + escaped_text, + block_code: block_code + ) + + html = "

" if html.blank? + html = append(html, append_inside_tag) if append_inside_tag + return html.html_safe + end end def thinking_html(message, thinking) @@ -59,6 +79,12 @@ def thinking_html(message, thinking) ) end + def message_to_user_from_tool_call?(message) + JSON.parse(message.content_text)["message_to_user"].present? if message.content_text.present? + rescue JSON::ParserError + false + end + private def block_code diff --git a/app/helpers/settings/api_services_helper.rb b/app/helpers/settings/api_services_helper.rb index 0168d4007..c8a1f74c8 100644 --- a/app/helpers/settings/api_services_helper.rb +++ b/app/helpers/settings/api_services_helper.rb @@ -1,7 +1,7 @@ module Settings module APIServicesHelper def official?(model) - openai?(model) || anthropic?(model) || groq?(model) + openai?(model) || anthropic?(model) || groq?(model) || gemini?(model) end def openai?(api_service) @@ -15,5 +15,9 @@ def anthropic?(api_service) def groq?(api_service) api_service.url == APIService::URL_GROQ end + + def gemini?(api_service) + api_service.url == APIService::URL_GEMINI + end end end diff --git a/app/javascript/stimulus/search_controller.js b/app/javascript/stimulus/search_controller.js new file mode 100644 index 000000000..65b58720d --- /dev/null +++ b/app/javascript/stimulus/search_controller.js @@ -0,0 +1,37 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = [ "input", "clear" ] + + inputTargetConnected() { + this.setSearchClearIcon() + } + + disconnect() { + clearTimeout(this.timeout) + } + + clear() { + this.inputTarget.value = "" + this.element.requestSubmit() + } + + unfocus() { + this.inputTarget.autofocus = false + } + + search() { + clearTimeout(this.timeout) + this.setSearchClearIcon() + this.timeout = setTimeout(() => { + this.element.requestSubmit() + }, 500) + } + + setSearchClearIcon() { + if (this.inputTarget.value.length > 0) + this.clearTarget.classList.remove("hidden") + else + this.clearTarget.classList.add("hidden") + } +} diff --git a/app/jobs/autotitle_conversation_job.rb b/app/jobs/autotitle_conversation_job.rb index 8f80c011c..4966861a6 100644 --- a/app/jobs/autotitle_conversation_job.rb +++ b/app/jobs/autotitle_conversation_job.rb @@ -7,7 +7,7 @@ class ConversationNotReady < StandardError; end def perform(conversation_id) @conversation = Conversation.find(conversation_id) - return false if @conversation.assistant.api_service.effective_token.blank? # should we use anthropic key if that's all the user has? + return false if @conversation.assistant.api_service.requires_token? && @conversation.assistant.api_service.effective_token.blank? messages = @conversation.messages.ordered.limit(4) raise ConversationNotReady if messages.empty? @@ -31,6 +31,13 @@ def generate_title_for(text) response_format: { type: "json_object" } # this causes problems for Groq even though it's supported: https://console.groq.com/docs/api-reference#chat-create ) return JSON.parse(response)["topic"] + elsif ai_backend.class == AIBackend::Gemini + response = ai_backend.get_oneoff_message( + system_message, + [text], + generation_config: { response_mime_type: "application/json" } + ) + return JSON.parse(response)["topic"] else response = ai_backend.get_oneoff_message( system_message, diff --git a/app/jobs/get_next_ai_message_job.rb b/app/jobs/get_next_ai_message_job.rb index 0ad348334..8ad4aeb23 100644 --- a/app/jobs/get_next_ai_message_job.rb +++ b/app/jobs/get_next_ai_message_job.rb @@ -1,6 +1,7 @@ require "open-uri" include ActionView::RecordIdentifier require "nokogiri/xml/node" +class ::Gemini::Errors::ConfigurationError < ::Gemini::Errors::GeminiError; end class GetNextAIMessageJob < ApplicationJob include ActionView::Helpers::RenderingHelper @@ -14,7 +15,7 @@ def ai_backend end def perform(user_id, message_id, assistant_id, attempt = 1) - puts "\n### GetNextAIMessageJob.perform(#{user_id}, #{message_id}, #{assistant_id}, #{attempt})" unless Rails.env.test? + Rails.logger.info "### GetNextAIMessageJob.perform(#{user_id}, #{message_id}, #{assistant_id}, #{attempt})" unless Rails.env.test? @user = User.find(user_id) @message = Message.find(message_id) @@ -30,7 +31,7 @@ def perform(user_id, message_id, assistant_id, attempt = 1) @message.update!(processed_at: Time.current, content_text: "") GetNextAIMessageJob.broadcast_updated_message(@message, thinking: true) # thinking shows dot, signaling to user that we're waiting now on ai_backend - puts "\n### Wait for reply" unless Rails.env.test? + Rails.logger.info "\n### Wait for reply" unless Rails.env.test? response = Current.set(user: @user, message: @message) do ai_backend.new(@conversation.user, @assistant, @conversation, @message) @@ -60,7 +61,7 @@ def perform(user_id, message_id, assistant_id, attempt = 1) return true rescue ResponseCancelled => e - puts "\n### Response cancelled in GetNextAIMessageJob(#{message_id})" unless Rails.env.test? + Rails.logger.info "\n### Response cancelled in GetNextAIMessageJob(#{message_id})" unless Rails.env.test? wrap_up_the_message return true rescue OpenAI::ConfigurationError => e @@ -78,6 +79,10 @@ def perform(user_id, message_id, assistant_id, attempt = 1) set_anthropic_error wrap_up_the_message return true + rescue Gemini::Errors::ConfigurationError => e + set_generic_error("Gemini") + wrap_up_the_message + return true rescue Faraday::ParsingError => e set_response_error wrap_up_the_message @@ -91,22 +96,23 @@ def perform(user_id, message_id, assistant_id, attempt = 1) wrap_up_the_message return true rescue WaitForPrevious - puts "\n### WaitForPrevious in GetNextAIMessageJob(#{message_id})" unless Rails.env.test? + Rails.logger.info "\n### WaitForPrevious in GetNextAIMessageJob(#{message_id})" unless Rails.env.test? raise WaitForPrevious rescue => e msg = e.inspect.gsub(/(sk-)[\w\-]{40}/, '\1' + "*" * 40) unless Rails.env.test? - puts "\n### Finished GetNextAIMessageJob attempt ##{attempt} with ERROR: #{msg}" unless Rails.env.test? - puts e.backtrace.join("\n") if Rails.env.development? + Rails.logger.info "\n### Finished GetNextAIMessageJob attempt ##{attempt} with ERROR: #{msg}" unless Rails.env.test? + Rails.logger.info e.backtrace.join("\n") if Rails.env.development? if attempt < 3 GetNextAIMessageJob.broadcast_updated_message(@message, thinking: false) GetNextAIMessageJob.set(wait: (attempt+1).seconds).perform_later(user_id, message_id, assistant_id, attempt+1) else - error_text = nil - begin - error_text = e&.response&.dig(:body, "error", "message") + error_text = if e.try(:response) + e&.response&.dig(:body, "error", "message") rescue e&.response&.dig(:body) + else + e.message end set_unexpected_error(msg&.slice(0...1500), error_text) wrap_up_the_message @@ -119,7 +125,7 @@ def self.broadcast_updated_message(message, locals = {}) html = ApplicationController.render( partial: "messages/message", locals: { - message: message, + message:, only_scroll_down_if_was_bottom: true, streamed: true, message_counter: message.index @@ -166,8 +172,16 @@ def set_unexpected_error(msg, text) def set_billing_error service = ai_backend.to_s.split("::").second - url = service == "OpenAI" ? "https://platform.openai.com/account/billing/overview" : "https://console.anthropic.com/settings/plans" - + url = case service + when "OpenAI" + "https://platform.openai.com/account/billing/overview" + when "Anthropic" + "https://console.anthropic.com/settings/plans" + when "Gemini" + "https://aistudio.google.com/app/apikey" + else + "https://platform.openai.com/account/billing/overview" + end @message.content_text = "(I received a quota error. Try again and if you still get this error then your API key is probably valid, but you may need to adding billing details. You are using " + "#{service} so go here #{url} and add a credit card, or if you already have one review your billing plan.)" end @@ -179,11 +193,11 @@ def wrap_up_the_message @message.save! @message.conversation.touch # updated_at change will bump it up your list + ensures it will be auto-titled - puts "\n### Finished GetNextAIMessageJob.perform(#{@user.id}, #{@message.id}, #{@message.assistant_id}, #{@attempt})" unless Rails.env.test? + Rails.logger.info "\n### Finished GetNextAIMessageJob.perform(#{@user.id}, #{@message.id}, #{@message.assistant_id}, #{@attempt})" unless Rails.env.test? end def call_tools_before_wrapping_up - puts "\n### Calling tools" unless Rails.env.test? + Rails.logger.info "\n### Calling tools" unless Rails.env.test? msgs = [] Current.set(user: @user, message: @message) do @@ -198,6 +212,7 @@ def call_tools_before_wrapping_up role: tool_message[:role], content_text: tool_message[:content], tool_call_id: tool_message[:tool_call_id], + content_tool_calls: tool_message[:content_tool_calls], version: @message.version, index: index += 1, processed_at: Time.current, diff --git a/app/jobs/send_reset_password_email_job.rb b/app/jobs/send_reset_password_email_job.rb index 371de716b..e7fc1f5bd 100644 --- a/app/jobs/send_reset_password_email_job.rb +++ b/app/jobs/send_reset_password_email_job.rb @@ -7,7 +7,7 @@ def perform(email, os, browser) Rails.logger.info "Sending reset password email to #{email} from #{os} with #{browser}" if person&.user # make sure the user exists (i.e. user has not become a tombstone) - PasswordMailer.with(person: person, os: os, browser: browser).reset.deliver_now + PasswordMailer.with(person:, os:, browser:).reset.deliver_now end end end diff --git a/app/mailers/password_mailer.rb b/app/mailers/password_mailer.rb index 0360a5352..5b729f8b8 100644 --- a/app/mailers/password_mailer.rb +++ b/app/mailers/password_mailer.rb @@ -11,7 +11,7 @@ def reset purpose: Email::PasswordReset::TOKEN_PURPOSE, expires_in: @token_ttl ) - @edit_url = edit_password_credential_url(token: token) + @edit_url = edit_password_credential_url(token:) mail( to: person.email, diff --git a/app/models/api_service.rb b/app/models/api_service.rb index 303f501a0..348fc8f32 100644 --- a/app/models/api_service.rb +++ b/app/models/api_service.rb @@ -1,13 +1,14 @@ class APIService < ApplicationRecord - URL_OPEN_AI = "https://api.openai.com/" + URL_OPEN_AI = "https://api.openai.com/v1/" URL_ANTHROPIC = "https://api.anthropic.com/" URL_GROQ = "https://api.groq.com/openai/v1/" + URL_GEMINI = "https://generativelanguage.googleapis.com/v1beta/" belongs_to :user has_many :language_models, -> { not_deleted } - enum :driver, %w[ openai anthropic ].index_by(&:to_sym) + enum :driver, %w[openai anthropic gemini].index_by(&:to_sym) validates :url, format: URI::DEFAULT_PARSER.make_regexp(%w[http https]), if: -> { url.present? } validates :name, :url, presence: true @@ -20,11 +21,18 @@ class APIService < ApplicationRecord scope :ordered, -> { order(:name) } def ai_backend - openai? ? AIBackend::OpenAI : AIBackend::Anthropic + case driver + when "openai" + AIBackend::OpenAI + when "anthropic" + AIBackend::Anthropic + when "gemini" + AIBackend::Gemini + end end def requires_token? - [URL_OPEN_AI, URL_ANTHROPIC].include?(url) # other services may require it but we don't always know + [URL_OPEN_AI, URL_ANTHROPIC, URL_GEMINI].include?(url) # other services may require it but we don't always know end def effective_token diff --git a/app/models/assistant.rb b/app/models/assistant.rb index 4832796d7..dd7a06065 100644 --- a/app/models/assistant.rb +++ b/app/models/assistant.rb @@ -1,4 +1,6 @@ class Assistant < ApplicationRecord + include Export + MAX_LIST_DISPLAY = 5 belongs_to :user @@ -19,6 +21,10 @@ class Assistant < ApplicationRecord scope :ordered, -> { order(:id) } + delegate :api_name, to: :language_model, prefix: true, allow_nil: true + + before_validation :set_default_slug + def initials return nil if name.blank? @@ -31,4 +37,34 @@ def initials def to_s name end + + def to_param + slug + end + + def language_model_api_name=(api_name) + self.language_model = LanguageModel.for_user(user).find_by(api_name:) + end + + private + + # Set the slug to the name, downcased, with non-word characters replaced with "-" + # and trailing "-" removed. + # If the slug is not unique for the user, append "-2", "-3", etc. + def set_default_slug + return if slug.present? + return if name.blank? + + base_slug = name.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/-$/, "") + + existing_base_slugs = user.assistants.where("slug LIKE ?", "#{base_slug}%").pluck(:slug) + largest_slug_number = existing_base_slugs.map { |slug| slug.split("--").last.to_i }.max + self.slug = if largest_slug_number.present? + "#{base_slug}--#{largest_slug_number + 1}" + elsif existing_base_slugs.any? + "#{base_slug}--1" + else + base_slug + end + end end diff --git a/app/models/assistant/export.rb b/app/models/assistant/export.rb new file mode 100644 index 000000000..f0290cc1a --- /dev/null +++ b/app/models/assistant/export.rb @@ -0,0 +1,52 @@ +module Assistant::Export + extend ActiveSupport::Concern + + DEFAULT_EXPORT_ONLY = %i[ + name + slug + description + instructions + language_model_api_name + ] + + DEFAULT_ASSISTANT_FILE = "assistants.yml" + + def attributes + super.merge("language_model_api_name" => language_model_api_name) + end + + # Unsure why this needs to re-defined, but the original ActiveModel::Serialization + # implementation is ignoring the #attributes method above. + def attribute_names_for_serialization + attributes.keys + end + + class_methods do + def export_to_file(path: Rails.root.join(DEFAULT_ASSISTANT_FILE), assistants:, only: DEFAULT_EXPORT_ONLY) + path = path.to_s + storage = { + "assistants" => assistants.as_json(only:).map(&:compact) + } + if path.ends_with?(".json") + File.write(path, storage.to_json) + else + File.write(path, storage.to_yaml) + end + end + + def import_from_file(path: Rails.root.join(DEFAULT_ASSISTANT_FILE), users: User.all) + users = Array.wrap(users) + + storage = YAML.load_file(path) + assistants = storage["assistants"] + assistants.each do |assistant| + assistant = assistant.with_indifferent_access + users.each do |user| + asst = user.assistants.find_or_create_by(slug: assistant["slug"]) + asst.assign_attributes(assistant.except("slug")) + asst.save! + end + end + end + end +end diff --git a/app/models/conversation.rb b/app/models/conversation.rb index 61106f911..af2245e3b 100644 --- a/app/models/conversation.rb +++ b/app/models/conversation.rb @@ -27,8 +27,14 @@ class Conversation < ApplicationRecord # "Last Month" => relation, # "Older" => relation # } - def self.grouped_by_increasing_time_interval_for_user(user) - nav_conversations = user.conversations.ordered + def self.grouped_by_increasing_time_interval_for_user(user, query = nil) + if query.blank? + nav_conversations = user.conversations.ordered + else + nav_conversations = user.conversations.joins(:messages).ordered.where("messages.content_text ILIKE ?", "%#{query}%"). + or(user.conversations.ordered.where("title ILIKE ?", "%#{query}%")). + select("DISTINCT conversations.*") + end keys = ["Today", "Yesterday", "This Week", "This Month", "Last Month", "Older"] values = [ diff --git a/app/models/document.rb b/app/models/document.rb index 68ab2f5b9..df20f38e1 100644 --- a/app/models/document.rb +++ b/app/models/document.rb @@ -30,7 +30,9 @@ def has_image?(variant = nil) def image_url(variant, fallback: nil) return nil unless has_image? - if has_file_variant_processed?(variant) + if Rails.application.config.x.app_url.blank? + file_data_url(variant) + elsif has_file_variant_processed?(variant) fully_processed_url(variant) elsif fallback.nil? redirect_to_processed_path(variant) @@ -39,20 +41,6 @@ def image_url(variant, fallback: nil) end end - def file_data_url(variant = :large) - return nil if !file.attached? - - "data:#{file.blob.content_type};base64,#{file_base64(variant)}" - end - - def file_base64(variant = :large) - return nil if !file.attached? - wait_for_file_variant_to_process!(variant.to_sym) - - file_contents = file.variant(variant.to_sym).processed.download - base64 = Base64.strict_encode64(file_contents) - end - def has_file_variant_processed?(variant) r = file.attached? && variant.present? && @@ -75,8 +63,19 @@ def redirect_to_processed_path(variant) ) end + def file_base64(variant = :large) + return nil if !file.attached? + wait_for_file_variant_to_process!(variant.to_sym) + file_contents = file.variant(variant.to_sym).processed.download + base64 = Base64.strict_encode64(file_contents) + end + private + def file_data_url(variant = :large) + "data:#{file.blob.content_type};base64,#{file_base64(variant)}" + end + def set_default_user self.user ||= message.conversation.user end diff --git a/app/models/feature.rb b/app/models/feature.rb index 2841773f6..89125c8f9 100644 --- a/app/models/feature.rb +++ b/app/models/feature.rb @@ -21,6 +21,7 @@ def features if @@features_hash[:http_header_authentication] @@features_hash[:password_authentication] = false @@features_hash[:google_authentication] = false + @@features_hash[:microsoft_graph_authentication] = false end @@features_hash diff --git a/app/models/language_model.rb b/app/models/language_model.rb index 72e26c348..a0e48e8dc 100644 --- a/app/models/language_model.rb +++ b/app/models/language_model.rb @@ -1,26 +1,6 @@ # We don"t care about large or not class LanguageModel < ApplicationRecord - BEST_GPT = "gpt-best" - BEST_CLAUDE = "claude-best" - BEST_GROQ = "groq-best" - - BEST_MODELS = { - BEST_GPT => "gpt-4o-2024-08-06", - BEST_CLAUDE => "claude-3-5-sonnet-20240620", - BEST_GROQ => "llama3-70b-8192", - } - - BEST_MODEL_INPUT_PRICES = { - BEST_GPT => 250, - BEST_CLAUDE => 300, - BEST_GROQ => 59, - } - - BEST_MODEL_OUTPUT_PRICES = { - BEST_GPT => 1000, - BEST_CLAUDE => 1500, - BEST_GROQ => 79, - } + include Export belongs_to :user belongs_to :api_service @@ -33,15 +13,14 @@ class LanguageModel < ApplicationRecord validates :api_name, :name, :position, presence: true before_save :soft_delete_assistants, if: -> { has_attribute?(:deleted_at) && deleted_at && deleted_at_changed? && deleted_at_was.nil? } + after_save :update_best_language_model_for_api_service - scope :ordered, -> { order(:position) } + scope :ordered, -> { order(Arel.sql("CASE WHEN best THEN 0 ELSE position END")).order(:position) } scope :for_user, ->(user) { where(user_id: user.id).not_deleted } + scope :best_for_api_service, ->(api_service) { where(best: true, api_service: api_service) } delegate :ai_backend, to: :api_service - - def provider_name - BEST_MODELS[api_name] || api_name - end + delegate :name, to: :api_service, prefix: true, allow_nil: true def created_by_current_user? user == Current.user @@ -61,4 +40,12 @@ def populate_position def soft_delete_assistants assistants.update_all(deleted_at: Time.current) end + + # Only one best language model per API service + def update_best_language_model_for_api_service + if best? + api_service.language_models.update_all(best: false) + update_column(:best, true) + end + end end diff --git a/app/models/language_model/export.rb b/app/models/language_model/export.rb new file mode 100644 index 000000000..261f62b14 --- /dev/null +++ b/app/models/language_model/export.rb @@ -0,0 +1,58 @@ +module LanguageModel::Export + extend ActiveSupport::Concern + + DEFAULT_EXPORT_ONLY = %i[ + api_name + name + best + api_service_name + supports_images + supports_tools + supports_system_message + input_token_cost_cents + output_token_cost_cents + ] + + DEFAULT_MODEL_FILE = "models.yml" + + def attributes + super.merge("api_service_name" => api_service_name) + end + + # Unsure why this needs to re-defined, but the original ActiveModel::Serialization + # implementation is ignoring the #attributes method above. + def attribute_names_for_serialization + attributes.keys + end + + class_methods do + def export_to_file(path: Rails.root.join(DEFAULT_MODEL_FILE), models:, only: DEFAULT_EXPORT_ONLY) + path = path.to_s + storage = { + "models" => models.as_json(only:).map(&:compact) + } + if path.ends_with?(".json") + File.write(path, storage.to_json) + else + File.write(path, storage.to_yaml) + end + end + + def import_from_file(path: Rails.root.join(DEFAULT_MODEL_FILE), users: User.all) + users = Array.wrap(users) + storage = YAML.load_file(path) + models = storage["models"] + models.each do |model| + model = model.with_indifferent_access + users.each do |user| + lm = user.language_models.find_or_initialize_by(api_name: model[:api_name]) + lm.api_service = user.api_services.find_by(name: model[:api_service_name]) if model[:api_service_name] + lm.assign_attributes(model.except(:api_service_name)) + lm.save! + rescue ActiveRecord::RecordInvalid => e + warn "Failed to import '#{model[:api_name]}': #{e.message} for #{model.inspect}" + end + end + end + end +end diff --git a/app/models/message.rb b/app/models/message.rb index 315b2b97e..f8a01ea71 100644 --- a/app/models/message.rb +++ b/app/models/message.rb @@ -48,7 +48,7 @@ def not_finished? = !finished? private def create_conversation - self.conversation = Conversation.create!(user: Current.user, assistant: assistant) + self.conversation = Conversation.create!(user: Current.user, assistant:) end def validate_conversation @@ -60,12 +60,12 @@ def validate_assistant end def start_assistant_reply - m = conversation.messages.create!( - assistant: assistant, + conversation.messages.create!( + assistant:, role: :assistant, content_text: nil, - version: version, - index: index+1 + version:, + index: index + 1 ) end @@ -75,7 +75,7 @@ def set_last_assistant_message def update_assistant_on_conversation return if conversation.assistant == assistant - conversation.update!(assistant: assistant) + conversation.update!(assistant:) end def update_input_token_cost diff --git a/app/models/message/toolable.rb b/app/models/message/toolable.rb index d0b44dff4..2dd7bd2a4 100644 --- a/app/models/message/toolable.rb +++ b/app/models/message/toolable.rb @@ -7,6 +7,7 @@ module Message::Toolable serialize :content_tool_calls, coder: JsonSerializer validates :tool_call_id, presence: true, if: :tool? + validates :content_tool_calls, presence: true, if: :tool? normalizes :tool_call_id, with: -> tool_call_id { tool_call_id[0...40] } end diff --git a/app/models/message/version.rb b/app/models/message/version.rb index 5baa5fa9e..8c1659ab9 100644 --- a/app/models/message/version.rb +++ b/app/models/message/version.rb @@ -81,9 +81,9 @@ def set_next_conversation_index_and_version else if version.negative? || version > max_version errors.add(:version, "#{version} is invalid for this index") - elsif conversation.messages.exists?(index: index, version: version) + elsif conversation.messages.exists?(index:, version:) errors.add(:version, "#{version} already exists for this index") - elsif versions.present? && version < versions.max && !conversation.messages.exists?(index: index-1, version: version) + elsif versions.present? && version < versions.max && !conversation.messages.exists?(index: index-1, version:) errors.add(:version, "#{version} is invalid for this index") end end diff --git a/app/models/microsoft_graph_credential.rb b/app/models/microsoft_graph_credential.rb new file mode 100644 index 000000000..a109f7992 --- /dev/null +++ b/app/models/microsoft_graph_credential.rb @@ -0,0 +1,52 @@ +class MicrosoftGraphCredential < Credential + alias_attribute :oauth_id, :external_id + + validates :oauth_token, presence: true + validates :oauth_refresh_token, presence: true + validates :oauth_id, presence: true, uniqueness: true + validates :oauth_email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP } + + normalizes :oauth_email, with: -> email { email.downcase.strip } + + # Use this method to retrieve the latest oauth_token. + # Token will be automatically renewed as necessary + def token + renew_token! if expired? + oauth_token + end + + def expires_at + Time.at(properties[:expires_at]) if properties[:expires_at] + rescue + nil + end + + def expired? + expires_at && expires_at < Time.current + end + + def renew_token! + new_token = current_token.refresh! + update( + oauth_token: new_token.token, + oauth_refresh_token: new_token.refresh_token, + properties: { expires_at: new_token.expires_at } + ) + end + + private + + def current_token + OAuth2::AccessToken.new( + strategy.client, + oauth_token, + refresh_token: oauth_refresh_token + ) + end + + def strategy + client_id = Setting.microsoft_graph_auth_client_id + client_secret = Setting.microsoft_graph_auth_client_secret + OmniAuth::Strategies::MicrosoftGraph.new(nil, client_id, client_secret) + end +end diff --git a/app/models/run.rb b/app/models/run.rb index 9e6976a04..438ba938e 100644 --- a/app/models/run.rb +++ b/app/models/run.rb @@ -14,6 +14,6 @@ class Run < ApplicationRecord private def set_model - self.model = assistant&.language_model&.provider_name + self.model = assistant&.language_model&.api_name end end diff --git a/app/models/setting.rb b/app/models/setting.rb index 79f9bb581..f57f842c4 100644 --- a/app/models/setting.rb +++ b/app/models/setting.rb @@ -27,5 +27,9 @@ def require_keys!(*keys) end end end + + def key_set?(key) + send(key).present? + end end end diff --git a/app/models/user.rb b/app/models/user.rb index 5f8b2df0a..c822d839b 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -18,6 +18,7 @@ class User < ApplicationRecord has_one :google_credential, -> { type_is("GoogleCredential") }, class_name: "Credential", inverse_of: :user has_one :gmail_credential, -> { type_is("GmailCredential") }, class_name: "Credential", inverse_of: :user has_one :google_tasks_credential, -> { type_is("GoogleTasksCredential") }, class_name: "Credential", inverse_of: :user + has_one :microsoft_graph_credential, -> { type_is("MicrosoftGraphCredential") }, class_name: "Credential", inverse_of: :user has_one :http_header_credential, -> { type_is("HttpHeaderCredential") }, class_name: "Credential", inverse_of: :user belongs_to :last_cancelled_message, class_name: "Message", optional: true diff --git a/app/models/user/registerable.rb b/app/models/user/registerable.rb index 0ddfcb322..72691389f 100644 --- a/app/models/user/registerable.rb +++ b/app/models/user/registerable.rb @@ -8,76 +8,12 @@ module User::Registerable private def create_initial_assistants_etc - open_ai_api_service = api_services.create!(url: APIService::URL_OPEN_AI, driver: :openai, name: "OpenAI") - anthropic_api_service = api_services.create!(url: APIService::URL_ANTHROPIC, driver: :anthropic, name: "Anthropic") - groq_api_service = api_services.create!(url: APIService::URL_GROQ, driver: :openai, name: "Groq") + api_services.create!(url: APIService::URL_OPEN_AI, driver: :openai, name: "OpenAI") + api_services.create!(url: APIService::URL_ANTHROPIC, driver: :anthropic, name: "Anthropic") + api_services.create!(url: APIService::URL_GROQ, driver: :openai, name: "Groq") + api_services.create!(url: APIService::URL_GEMINI, driver: :gemini, name: "Google Gemini") - [ - [LanguageModel::BEST_GPT, "Best OpenAI Model", true, open_ai_api_service, LanguageModel::BEST_MODEL_INPUT_PRICES[LanguageModel::BEST_GPT], LanguageModel::BEST_MODEL_OUTPUT_PRICES[LanguageModel::BEST_GPT]], - [LanguageModel::BEST_CLAUDE, "Best Anthropic Model", true, anthropic_api_service, LanguageModel::BEST_MODEL_INPUT_PRICES[LanguageModel::BEST_CLAUDE], LanguageModel::BEST_MODEL_OUTPUT_PRICES[LanguageModel::BEST_CLAUDE]], - [LanguageModel::BEST_GROQ, "Best Open-Source Model", true, groq_api_service, LanguageModel::BEST_MODEL_INPUT_PRICES[LanguageModel::BEST_GROQ], LanguageModel::BEST_MODEL_OUTPUT_PRICES[LanguageModel::BEST_GROQ]], - - ["gpt-4o", "GPT-4o (latest)", true, open_ai_api_service, 250, 1000], - ["gpt-4o-2024-08-06", "GPT-4o Omni Multimodal (2024-08-06)", true, open_ai_api_service, 250, 1000], - ["gpt-4o-2024-05-13", "GPT-4o Omni Multimodal (2024-05-13)", true, open_ai_api_service, 500, 1500], - - ["gpt-4-turbo", "GPT-4 Turbo with Vision (latest)", true, open_ai_api_service, 1000, 3000], - ["gpt-4-turbo-2024-04-09", "GPT-4 Turbo with Vision (2024-04-09)", true, open_ai_api_service, 1000, 3000], - ["gpt-4-turbo-preview", "GPT-4 Turbo Preview", false, open_ai_api_service, 1000, 3000], - ["gpt-4-0125-preview", "GPT-4 Turbo Preview (2024-01-25)", false, open_ai_api_service, 1000, 3000], - ["gpt-4-1106-preview", "GPT-4 Turbo Preview (2023-11-06)", false, open_ai_api_service, 1000, 3000], - ["gpt-4-vision-preview", "GPT-4 Turbo with Vision Preview (2023-11-06)", true, open_ai_api_service, 1000, 3000], - ["gpt-4-1106-vision-preview", "GPT-4 Turbo with Vision Preview (2023-11-06)", true, open_ai_api_service, 1000, 3000], - - ["gpt-4", "GPT-4 (latest)", false, open_ai_api_service, 3000, 6000], - ["gpt-4-0613", "GPT-4 Snapshot improved function calling (2023-06-13)", false, open_ai_api_service, 1000, 3000], - - ["gpt-3.5-turbo", "GPT-3.5 Turbo (latest)", false, open_ai_api_service, 300, 600], - ["gpt-3.5-turbo-0125", "GPT-3.5 Turbo (2022-01-25)", false, open_ai_api_service, 50, 150], - ["gpt-3.5-turbo-1106", "GPT-3.5 Turbo (2022-11-06)", false, open_ai_api_service, 100, 200], - - ["claude-3-5-sonnet-20240620", "Claude 3.5 Sonnet (2024-06-20)", true, anthropic_api_service, 300, 1500], - ["claude-3-opus-20240229", "Claude 3 Opus (2024-02-29)", true, anthropic_api_service, 1500, 7500], - ["claude-3-sonnet-20240229", "Claude 3 Sonnet (2024-02-29)", true, anthropic_api_service, 300, 1500], - ["claude-3-haiku-20240307", "Claude 3 Haiku (2024-03-07)", true, anthropic_api_service, 25, 125], - ["claude-2.1", "Claude 2.1", false, anthropic_api_service, 800, 2400], - ["claude-2.0", "Claude 2.0", false, anthropic_api_service, 800, 2400], - ["claude-instant-1.2", "Claude Instant 1.2", false, anthropic_api_service, 80, 240], - - ["llama3-70b-8192", "Meta Llama 3 70b", false, groq_api_service, 59, 79], - ["llama3-8b-8192", "Meta Llama 3 8b", false, groq_api_service, 5, 8], - ["mixtral-8x7b-32768", "Mistral 8 7b", false, groq_api_service, 24, 24], - ["gemma-7b-it", "Google Gemma 7b", false, groq_api_service, 7, 7], - ].each do |api_name, name, supports_images, api_service, input_token_cost_per_million, output_token_cost_per_million| - million = BigDecimal(1_000_000) - input_token_cost_cents = input_token_cost_per_million/million - output_token_cost_cents = output_token_cost_per_million/million - - language_models.create!( - api_name: api_name, - api_service: api_service, - name: name, - supports_tools: true, - supports_images: supports_images, - input_token_cost_cents: input_token_cost_cents, - output_token_cost_cents: output_token_cost_cents, - ) - end - - # Only these don't support tools: - [ - ["gpt-3.5-turbo-instruct", "GPT-3.5 Turbo Instruct", false, open_ai_api_service, 150, 200], - ["gpt-3.5-turbo-16k-0613", "GPT-3.5 Turbo (2022-06-13)", false, open_ai_api_service, 300, 400], - ].each do |api_name, name, supports_images, api_service, input_token_cost_per_million, output_token_cost_per_million| - million = BigDecimal(1_000_000) - input_token_cost_cents = input_token_cost_per_million/million - output_token_cost_cents = output_token_cost_per_million/million - - language_models.create!(api_name: api_name, api_service: api_service, name: name, supports_tools: false, supports_images: supports_images, input_token_cost_cents: input_token_cost_cents, output_token_cost_cents: output_token_cost_cents) - end - - assistants.create! name: "GPT-4o", language_model: language_models.find_by(api_name: LanguageModel::BEST_GPT) - assistants.create! name: "Claude 3.5 Sonnet", language_model: language_models.find_by(api_name: LanguageModel::BEST_CLAUDE) - assistants.create! name: "Meta Llama 3 70b", language_model: language_models.find_by(api_name: LanguageModel::BEST_GROQ) + LanguageModel.import_from_file(users: [self]) + Assistant.import_from_file(users: [self]) end end diff --git a/app/serializers/JsonSerializer.rb b/app/serializers/JsonSerializer.rb index cfcd3f99c..b93276323 100644 --- a/app/serializers/JsonSerializer.rb +++ b/app/serializers/JsonSerializer.rb @@ -36,4 +36,4 @@ def self.convert_value(value) value end end -end \ No newline at end of file +end diff --git a/app/services/ai_backend.rb b/app/services/ai_backend.rb index 42e4fd7d8..d4204f084 100644 --- a/app/services/ai_backend.rb +++ b/app/services/ai_backend.rb @@ -14,9 +14,9 @@ def initialize(user, assistant, conversation = nil, message = nil) def get_oneoff_message(instructions, messages, params = {}) set_client_config( - instructions: instructions, + instructions:, messages: preceding_messages(messages), - params: params, + params:, ) response = @client.send(client_method_name, ** @client_config) @@ -77,7 +77,7 @@ def preceding_messages(messages = []) role = (i % 2).zero? ? "user" : "assistant" { - role: role, + role:, content: msg } end diff --git a/app/services/ai_backend/anthropic.rb b/app/services/ai_backend/anthropic.rb index 18f004189..f8a4d5d9d 100644 --- a/app/services/ai_backend/anthropic.rb +++ b/app/services/ai_backend/anthropic.rb @@ -39,7 +39,7 @@ def set_client_config(config) super(config) @client_config = { - model: @assistant.language_model.provider_name, + model: @assistant.language_model.api_name, system: config[:instructions], messages: config[:messages], parameters: { @@ -71,8 +71,8 @@ def stream_handler(&chunk_handler) rescue ::Faraday::UnauthorizedError => e raise ::Anthropic::ConfigurationError rescue => e - puts "\nUnhandled error in AIBackend::Anthropic response handler: #{e.message}" - puts e.backtrace + Rails.logger.info "\nUnhandled error in AIBackend::Anthropic response handler: #{e.message}" + Rails.logger.info e.backtrace end end diff --git a/app/services/ai_backend/gemini.rb b/app/services/ai_backend/gemini.rb new file mode 100644 index 000000000..f89447cf6 --- /dev/null +++ b/app/services/ai_backend/gemini.rb @@ -0,0 +1,122 @@ +class AIBackend::Gemini < AIBackend + include Tools + class ::Gemini::Errors::ConfigurationError < ::Gemini::Errors::GeminiError; end + + # Rails system tests don't seem to allow mocking because the server and the + # test are in separate processes. + # + # In regular tests, mock this method or the TestClients::Gemini class to do + # what you want instead. + def self.client + if Rails.env.test? + ::TestClient::Gemini + else + ::Gemini + end + end + + def initialize(user, assistant, conversation = nil, message = nil) + super(user, assistant, conversation, message) + begin + raise configuration_error if assistant.api_service.requires_token? && assistant.api_service.effective_token.blank? + Rails.logger.info "Connecting to Gemini API server at #{assistant.api_service.url} with access token of length #{assistant.api_service.effective_token.to_s.length}" + @client = self.class.client.new( + credentials: { + service: "generative-language-api", + api_key: assistant.api_service.effective_token, + version: "v1beta" + }, + options: { + model: assistant.language_model.api_name, + server_sent_events: true + } + ) + rescue ::Faraday::UnauthorizedError, ::Faraday::BadRequestError => e + raise configuration_error + end + end + + def client_method_name + :stream_generate_content + end + + def configuration_error + ::Gemini::Errors::ConfigurationError + end + + def set_client_config(config) + super(config) + + @client_config = { + contents: config[:messages], + system_instruction: ( system_message(config[:instructions]) if @assistant.language_model.supports_system_message?) + }.compact + end + + def get_oneoff_message(instructions, messages, params = {}) + response = @client.generate_content({ + system_instruction: system_message(instructions), + contents: { role: "user", parts: { text: messages.first }}, # TODO: could implement preceding_conversation_messages and call it here + ** params + }) + response.dig("candidates", 0, "content", "parts", 0, "text") + end + + def stream_next_conversation_message(&chunk_handler) + set_client_config( + messages: preceding_conversation_messages, + instructions: full_instructions, + ) + + begin + if Rails.env.test? + @client.send(client_method_name, @client_config).each do |intermediate_response| + content_chunk = intermediate_response.dig("candidates",0,"content","parts",0,"text") + yield content_chunk if content_chunk != nil + end + else + response = @client.send(client_method_name, @client_config) do |intermediate_response, parsed, raw| + content_chunk = intermediate_response.dig("candidates",0,"content","parts",0,"text") + yield content_chunk if content_chunk != nil + end + end + rescue ::Faraday::UnauthorizedError, ::Faraday::BadRequestError => e + puts e.message + raise configuration_error + end + return nil + end + + private + + def system_message(content) + return [] if content.blank? + { + role: "user", parts: { text: content } + } + end + + def preceding_conversation_messages + @conversation.messages.for_conversation_version(@message.version).where("messages.index < ?", @message.index).collect do |message| + if @assistant.supports_images? && message.documents.present? + + content = [{ text: message.content_text }] + content += message.documents.collect do |document| + { inline_data: { + mime_type: document.file.blob.content_type, + data: document.file_base64(:large), + } + } + end + + { + role: message.role == "assistant" ? "model" : "user", parts: content + } + else + { + role: message.role == "assistant" ? "model" : "user", parts: { text: message.content_text || "" } + } + end + end + end +end diff --git a/app/services/ai_backend/open_ai.rb b/app/services/ai_backend/open_ai.rb index 3930a7d0e..259e51013 100644 --- a/app/services/ai_backend/open_ai.rb +++ b/app/services/ai_backend/open_ai.rb @@ -19,7 +19,7 @@ def initialize(user, assistant, conversation = nil, message = nil) begin raise ::OpenAI::ConfigurationError if assistant.api_service.requires_token? && assistant.api_service.effective_token.blank? Rails.logger.info "Connecting to OpenAI API server at #{assistant.api_service.url} with access token of length #{assistant.api_service.effective_token.to_s.length}" - @client = self.class.client.new(uri_base: assistant.api_service.url, access_token: assistant.api_service.effective_token) + @client = self.class.client.new(uri_base: assistant.api_service.url, access_token: assistant.api_service.effective_token, api_version: "") rescue ::Faraday::UnauthorizedError => e raise ::OpenAI::ConfigurationError end @@ -40,7 +40,7 @@ def set_client_config(config) @client_config = { parameters: { - model: @assistant.language_model.provider_name, + model: @assistant.language_model.api_name, messages: system_message(config[:instructions]) + config[:messages], stream: config[:streaming] && @response_handler || nil, max_tokens: 2000, # we should really set this dynamically, based on the model, to the max @@ -80,15 +80,15 @@ def stream_handler(&chunk_handler) rescue ::Faraday::UnauthorizedError => e raise OpenAI::ConfigurationError rescue => e - puts "\nUnhandled error in AIBackend::OpenAI response handler: #{e.message}" - puts e.backtrace.join("\n") + Rails.logger.info "\nUnhandled error in AIBackend::OpenAI response handler: #{e.message}" + Rails.logger.info e.backtrace.join("\n") end end def system_message(content) [{ role: "system", - content: content, + content:, }] end @@ -110,8 +110,8 @@ def preceding_conversation_messages { role: message.role, name: message.name_for_api, - content: message.content_text, - tool_calls: message.content_tool_calls, # only for some assistant messages + content: (JSON.parse(message.content_text).except("message_to_user").to_json rescue message.content_text), + tool_calls: message.assistant? ? message.content_tool_calls : nil, # only for some assistant messages tool_call_id: message.tool_call_id, # only for tool messages }.compact.except( message.content_tool_calls.blank? && :tool_calls ) end diff --git a/app/services/ai_backend/tools.rb b/app/services/ai_backend/tools.rb index c654249c5..fb2c97564 100644 --- a/app/services/ai_backend/tools.rb +++ b/app/services/ai_backend/tools.rb @@ -16,8 +16,8 @@ def get_tool_messages_by_calling(tool_calls_response) function_response = begin Toolbox.call(function_name, function_arguments) rescue => e - puts "## Handled error calling tools: #{e.message}" unless Rails.env.test? - puts e.backtrace.join("\n") unless Rails.env.test? + Rails.logger.info "## Handled error calling tools: #{e.message}" unless Rails.env.test? + Rails.logger.info e.backtrace.join("\n") unless Rails.env.test? <<~STR.gsub("\n", " ") An unexpected error occurred (#{e.message}). You were querying information to help you answer a users question. Because this information @@ -29,12 +29,13 @@ def get_tool_messages_by_calling(tool_calls_response) { role: "tool", content: function_response.to_json, + content_tool_calls: tool_call, tool_call_id: id, } end rescue => e - puts "## UNHANDLED error calling tools: #{e.message}" - puts e.backtrace.join("\n") + Rails.logger.info "## UNHANDLED error calling tools: #{e.message}" + Rails.logger.info e.backtrace.join("\n") raise ::Faraday::ParsingError end end diff --git a/app/services/fly.rb b/app/services/fly.rb index d82d2415e..b384d1e15 100644 --- a/app/services/fly.rb +++ b/app/services/fly.rb @@ -2,20 +2,22 @@ class Fly < SDK def change_db_swap(app:, swap:) app_name = app+"-db" unless swap.is_a?(Integer) || swap.to_i.to_s == swap.to_s - puts "Expected an integer for swap such as 512 but it was '#{swap}'. Aborting." + Rails.logger.info "Expected an integer for swap such as 512 but it was '#{swap}'. Aborting." return end swap = swap.to_i - app_id = get_apps.find { |m| m.name == app_name }&.id + apps = get_apps + app_id = apps.find { |m| m.name == app_name }&.id if app_id.nil? - puts "Could not find the app named #{app_name}. Aborting." + Rails.logger.info "Could not find the app named #{app_name}. Aborting." + Rails.logger.info "These are all the app names on your Fly account: #{apps.map(&:name).join(", ")}" return end machines = get_machines(app_name) if machines.length > 1 - puts "Expected only a single database machine under #{app_name} but found #{machines.length}. Aborting." + Rails.logger.info "Expected only a single database machine under #{app_name} but found #{machines.length}. Aborting." return end @@ -26,7 +28,7 @@ def change_db_swap(app:, swap:) updated_config = patch_machine(app_name, machine.id, config) - puts "Updated machine id #{machine.id} on #{app_name} to #{swap}mb. It make take a minute for the machine to finish booting." + Rails.logger.info "Updated machine id #{machine.id} on #{app_name} to #{swap}mb. It make take a minute for the machine to finish booting." end def get_apps @@ -38,13 +40,13 @@ def get_machines(app_name) end def patch_machine(app_name, id, config) - patch("https://api.machines.dev/v1/apps/#{app_name}/machines/#{id}").param(config: config) + post("https://api.machines.dev/v1/apps/#{app_name}/machines/#{id}").param(config: config) end private def bearer_token - `fly auth token`.chop + @bearer_token ||= `fly auth token`.chop.split.last end def header diff --git a/app/services/sdk.rb b/app/services/sdk.rb index d073c81bd..238a038c8 100644 --- a/app/services/sdk.rb +++ b/app/services/sdk.rb @@ -1,40 +1,40 @@ class SDK def get(url, token = nil) SDK::Get.new( - url: url, + url:, bearer_token: token || bearer_token, - expected_status: expected_status, - header: header, + expected_status:, + header:, calling_method: calling_method(__method__), ) end def post(url, token = nil) SDK::Post.new( - url: url, + url:, bearer_token: token || bearer_token, - expected_status: expected_status, - header: header, + expected_status:, + header:, calling_method: calling_method(__method__), ) end def patch(url, token = nil) SDK::Patch.new( - url: url, + url:, bearer_token: token || bearer_token, - expected_status: expected_status, - header: header, + expected_status:, + header:, calling_method: calling_method(__method__), ) end def delete(url, token = nil) SDK::Delete.new( - url: url, + url:, bearer_token: token || bearer_token, - expected_status: expected_status, - header: header, + expected_status:, + header:, calling_method: calling_method(__method__), ) end diff --git a/app/services/sdk/verb.rb b/app/services/sdk/verb.rb index cf13a9423..9adcc9bf7 100644 --- a/app/services/sdk/verb.rb +++ b/app/services/sdk/verb.rb @@ -31,7 +31,7 @@ def handle(response) raise ResponseError.new(response) if !response.status.in? @expected_statuses if response.status.between?(200, 299) - response.body.presence && OpenData.for(JSON.parse(response.body)) + response.body.presence && OpenData.for(JSON.parse(response.body)) rescue response else response end @@ -131,6 +131,6 @@ def possible_test_warning(verb) return if !Rails.env.test? return if self.class.send("allow_#{verb}_#{@calling_method}") rescue false - puts "WARNING: live API call in test. USE: stub_#{verb}_response(:#{@calling_method}, status: ___, response: _______) do; ...; end" + Rails.logger.info "WARNING: live API call in test. USE: stub_#{verb}_response(:#{@calling_method}, status: ___, response: _______) do; ...; end" end end diff --git a/app/services/toolbox.rb b/app/services/toolbox.rb index 54dab1358..35ea76ebe 100644 --- a/app/services/toolbox.rb +++ b/app/services/toolbox.rb @@ -8,6 +8,7 @@ def self.descendants Toolbox::OpenMeteo, Toolbox::Dalle, Toolbox::Memory, + Toolbox::GoogleSearch, gmail_active && Toolbox::Gmail, tasks_active && Toolbox::GoogleTasks, ].compact diff --git a/app/services/toolbox/gmail/message.rb b/app/services/toolbox/gmail/message.rb index 5a26a5040..20468e0a3 100644 --- a/app/services/toolbox/gmail/message.rb +++ b/app/services/toolbox/gmail/message.rb @@ -55,13 +55,13 @@ def body_html def to_h { - id: id, - thread_id: thread_id, - date: date, - from: from, - to: to, - subject: subject, - snippet: snippet, + id:, + thread_id:, + date:, + from:, + to:, + subject:, + snippet:, body: body || body_html, } end diff --git a/app/services/toolbox/google_search.rb b/app/services/toolbox/google_search.rb new file mode 100644 index 000000000..95f42a493 --- /dev/null +++ b/app/services/toolbox/google_search.rb @@ -0,0 +1,32 @@ +class Toolbox::GoogleSearch < Toolbox + + describe :google_search, <<~S + Search Google for the indicated query. + Use this to answer questions about current events, look up information, or find answers to questions. + Try to use this sparingly; prefer to use the user's memories and the tools you have available to answer questions. + When you do use this, try to use exact queries for which you expect to get a definitive answer. + When you respond to the user, try to include an answer to the question rather than just a link. + S + def google_search(query_s:) + encoded_query = URI.encode_www_form_component(query_s) + response_body = get("https://www.google.com/search").param(q: encoded_query).body + doc = Nokogiri::HTML(response_body) + + results = doc.css("div.BNeawe").map do |div| + div.children.map do |node| + if node.name == "a" + anchor_text = node.text.strip + href = node["href"] + "#{anchor_text} (#{href})" + else + node.text.strip + end + end.join(" ") + end.join("\n") + + { + message_to_user: "Web query: #{query_s}", + query_results: results + } + end +end \ No newline at end of file diff --git a/app/services/toolbox/google_tasks/api_helpers.rb b/app/services/toolbox/google_tasks/api_helpers.rb index 8a56bbf11..4e6bfb86a 100644 --- a/app/services/toolbox/google_tasks/api_helpers.rb +++ b/app/services/toolbox/google_tasks/api_helpers.rb @@ -41,8 +41,8 @@ def get_tasks_for_list(list, due_min: nil, due_max: nil) def create_task_for_list(list, title:, notes: nil, due: nil) refresh_token_if_needed do post("https://tasks.googleapis.com/tasks/v1/lists/#{list.id}/tasks").param( - title: title, - notes: notes, + title:, + notes:, due: format_time(due), status: "needsAction", ) @@ -58,7 +58,7 @@ def move_task_to_list(task, list, keep_due: true) delete_task(task) create_task_for_list(list, title: task.title, - notes: notes, + notes:, due: keep_due.presence && task.try(:due), ) end @@ -73,12 +73,12 @@ def update_task(task, title: nil, notes: nil, due: nil, completed: nil, deleted: refresh_token_if_needed do patch("https://tasks.googleapis.com/tasks/v1/lists/#{list_id_of(task)}/tasks/#{task.id}").param( { id: task.id, - title: title, - notes: notes, + title:, + notes:, due: format_time(due), status: !!completed ? "completed" : "needsAction", completed: completed.presence && format_time(completed), - deleted: deleted, + deleted:, }.compact) # w/o this it updates values to nil end end diff --git a/app/services/toolbox/memory.rb b/app/services/toolbox/memory.rb index 5419d706e..ffab5d78c 100644 --- a/app/services/toolbox/memory.rb +++ b/app/services/toolbox/memory.rb @@ -15,6 +15,8 @@ def remember_detail_about_user(detail_s:) conversation_messages = Current.message.conversation.messages.for_conversation_version(Current.message.version) related_message = conversation_messages.where("messages.id < ?", Current.message.id).last Current.user.memories.create!(detail: detail_s, message: related_message) - "This has been remembered" + { + message_to_user: "Memory updated" + } end end diff --git a/app/views/assistants/_assistant.html.erb b/app/views/assistants/_assistant.html.erb index d36f297f6..3e04f9fea 100644 --- a/app/views/assistants/_assistant.html.erb +++ b/app/views/assistants/_assistant.html.erb @@ -6,7 +6,7 @@ <%# This extra div ^ is needed because of the absolute positioning. It doesn't lay out properly if added to the div below. %>
" 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 %> + <%= link_to new_assistant_message_path(assistant), class: "flex-1 flex py-1 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" } %> <%= assistant.name %> <% end %> diff --git a/app/views/authentications/new.html.erb b/app/views/authentications/new.html.erb index f88b7a5ba..f5389ca9e 100644 --- a/app/views/authentications/new.html.erb +++ b/app/views/authentications/new.html.erb @@ -26,6 +26,27 @@ <% end %> +<% if Feature.microsoft_graph_authentication? %> + <%= button_to "Log In with Microsoft", "/auth/microsoft_graph", + method: "post", + class: %| + text-white font-medium + bg-brand-blue dark:bg-gray-900 + border border-white dark:border-gray-400 + rounded-lg p-4 text-center + cursor-pointer + hover:opacity-95 + |, + form_class: "flex flex-col space-y-4 w-80", + data: { + turbo: false, + } + %> + + - Or - + +<% end %> + <% if Feature.password_authentication? %> <%= form_with(url: login_path, method: :post, class: "flex flex-col space-y-4 w-80") do |f| %> <%= f.email_field :email, diff --git a/app/views/conversations/index.html.erb b/app/views/conversations/index.html.erb new file mode 100644 index 000000000..951113dc7 --- /dev/null +++ b/app/views/conversations/index.html.erb @@ -0,0 +1,57 @@ + + + +
+ + > + <% @nav_conversations.each do |named_time_span, conversations| %> +
+ <%= named_time_span %> +
+ + <%= render conversations %> + <% end %> +
+
+
diff --git a/app/views/layouts/_assistant_avatar.erb b/app/views/layouts/_assistant_avatar.erb index 52efb8bd6..ddd74fa63 100644 --- a/app/views/layouts/_assistant_avatar.erb +++ b/app/views/layouts/_assistant_avatar.erb @@ -11,6 +11,8 @@ elsif assistant.name.starts_with?("Claude") "claude_logo.svg" elsif assistant.name.starts_with?("Meta Llama") "meta_ai_logo.svg" +elsif assistant.name.starts_with?("Gemini") + "google_gemini_logo.svg" end %> <% if logo %> diff --git a/app/views/layouts/_settings_item.erb b/app/views/layouts/_settings_item.erb index 5ec2e5ecc..085de0b37 100644 --- a/app/views/layouts/_settings_item.erb +++ b/app/views/layouts/_settings_item.erb @@ -3,7 +3,7 @@ <% selected = selected && params[:id] == item.try(:id)&.to_s if controller.to_s == "assistants" %>