diff --git a/app/controllers/settings/people_controller.rb b/app/controllers/settings/people_controller.rb index e70d102f3..b21f27c1d 100644 --- a/app/controllers/settings/people_controller.rb +++ b/app/controllers/settings/people_controller.rb @@ -7,6 +7,7 @@ def edit def update Current.person.update!(person_params) + apply_backend_choices! redirect_to edit_settings_person_path, notice: I18n.t("app.flashes.assistants.saved"), status: :see_other rescue @person = Current.person @@ -17,12 +18,22 @@ def update def person_params h = params.require(:person).permit(:email, personable_attributes: [ - :id, :first_name, :last_name, :password, :profile_picture, :remove_profile_picture, preferences: [:dark_mode], + :id, :first_name, :last_name, :password, :profile_picture, :remove_profile_picture, + :dark_mode, credentials_attributes: [ :id, :type, :password ] ]).to_h format_and_strip_all_but_first_valid_credential(h) end + def apply_backend_choices! + choices = params.permit(person: { backend_choices: User::Features.derived_backend_names }) + .dig(:person, :backend_choices) + return if choices.blank? + + user = Current.person.reload.user # merge against fresh state, not a stale session copy + choices.each { |name, value| user.features[name.to_sym] = value.presence } + end + def check_personable_id personable_id = params[:person].try(:[], :personable_attributes).try(:[], :id) if personable_id.present? && personable_id.to_i != Current.person.personable_id diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index aa94c1d96..5111419b1 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -59,6 +59,6 @@ def person_params end def user_params - params.require(:user).permit(preferences: [:nav_closed, :dark_mode]) + params.require(:user).permit(:nav_closed) end end diff --git a/app/models/user.rb b/app/models/user.rb index a6f3c3ee9..a0673b3d0 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -38,9 +38,31 @@ class User < ApplicationRecord accepts_nested_attributes_for :credentials serialize :preferences, coder: JsonSerializer + store_accessor :preferences, :dark_mode, :nav_closed def preferences - attributes["preferences"].with_defaults(dark_mode: "system") + attributes["preferences"] || {} + end + + def dark_mode + super.presence || "system" + end + + def nav_closed=(value) + super(value.nil? ? nil : value.to_b) + end + + def features + User::Features.new(self) + end + + def ai_backend(driver) + features[:"#{driver}_ai_backend"] + end + + # @todo: generalize into a per-flag opinion API if other features ever need per-user values (#740) + def ruby_llm?(driver) + ai_backend(driver).presence || use_ruby_llm? end # Profile picture helper methods @@ -93,4 +115,8 @@ def creating_google_credential? !credential.persisted? && credential.type == "GoogleCredential" end + + def use_ruby_llm? + Feature.raw_features.key?(:use_ruby_llm) ? Feature.use_ruby_llm? : false + end end diff --git a/app/models/user/features.rb b/app/models/user/features.rb new file mode 100644 index 000000000..5b2f04b64 --- /dev/null +++ b/app/models/user/features.rb @@ -0,0 +1,47 @@ +class User::Features + def self.derived_backend_names + APIService.drivers.keys + .select { |driver| APIService.new(driver: driver).ai_backend } + .map { |driver| :"#{driver}_ai_backend" } + end + + def self.valid_names + derived_backend_names + [:use_ruby_llm] + end + + def self.ruby_llm_available?(driver) + defined?(AIBackend::RubyLLM) && + AIBackend::RubyLLM.respond_to?(:supports_driver?) && + AIBackend::RubyLLM.supports_driver?(driver) + end + + def initialize(user) + @user = user + end + + def [](name) + key = guard_name!(name) + feature_preferences[key] + end + + def []=(name, value) + key = guard_name!(name) + @user.update!(preferences: @user.preferences.deep_merge(feature: { key => value })) + end + + private + + def feature_preferences + sub_hash = @user.preferences[:feature] || @user.preferences["feature"] || {} + sub_hash.transform_keys(&:to_sym) + end + + def guard_name!(name) + key = name.to_s.chomp("=").to_sym + unless self.class.valid_names.include?(key) + raise KeyError, "You attempted to reference '#{key}' but only AI-backend choices (_ai_backend) and use_ruby_llm are accessible here. Did you typo a feature name?" + end + + key + end +end diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 2a6254411..24f8388af 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -5,7 +5,7 @@ + flex nav-closed:hidden <%= !Current.user.nav_closed && "md:!flex" %> z-30 md:z-auto nav-closed:z-auto absolute md:relative nav-closed:relative overflow-x-clip diff --git a/app/views/messages/_main_column.html.erb b/app/views/messages/_main_column.html.erb index 1d716a093..e02f47ea5 100644 --- a/app/views/messages/_main_column.html.erb +++ b/app/views/messages/_main_column.html.erb @@ -13,7 +13,7 @@ group |, method: :patch, - params: { user: { preferences: { nav_closed: !Current.user.preferences[:nav_closed] } } }, + params: { user: { nav_closed: !Current.user.nav_closed } }, class: %| flex-1 flex w-8 h-20 |, data: { @@ -33,7 +33,7 @@ flex-1 flex h-full items-center justify-center tooltip tooltip-right hover:tooltip-open - <%= Current.user.preferences[:nav_closed] && "!hidden" %> + <%= Current.user.nav_closed && "!hidden" %> " data-tip="<%= t('app.conversations.ui.close_sidebar') %>" > @@ -49,7 +49,7 @@ flex-1 flex h-full items-center justify-center tooltip tooltip-right hover:tooltip-open - <%= !Current.user.preferences[:nav_closed] && "!hidden" %> + <%= !Current.user.nav_closed && "!hidden" %> " data-tip="<%= t('app.conversations.ui.open_sidebar') %>" > diff --git a/app/views/settings/people/_form.html.erb b/app/views/settings/people/_form.html.erb index 110ca710a..3f4a719ad 100644 --- a/app/views/settings/people/_form.html.erb +++ b/app/views/settings/people/_form.html.erb @@ -104,13 +104,48 @@
<%= user_fields.label :dark_mode, t('app.settings.people.form.color_theme') %>
- <%= user_fields.fields_for :preferences do |preferences_fields| %> - <% %w(dark light system).each do |value| %> -
- <%= preferences_fields.radio_button :dark_mode, value, checked: Current.user.preferences[:dark_mode] == value %> - <%= user_fields.label :dark_mode, t("app.settings.people.form.theme_options.#{value}"), class: "dark:text-gray-100", for: "person_personable_attributes_preferences_dark_mode_#{value}"%> -
- <% end %> + <% %w(dark light system).each do |value| %> +
+ <%= user_fields.radio_button :dark_mode, value, checked: Current.user.dark_mode == value %> + <%= user_fields.label :dark_mode, t("app.settings.people.form.theme_options.#{value}"), class: "dark:text-gray-100", for: "person_personable_attributes_dark_mode_#{value}"%> +
+ <% end %> +
+
+ +
+ <%= t('app.settings.people.form.ai_backends') %> +

+ <%= t('app.settings.people.form.ai_backends_description', + current: Feature.raw_features.fetch(:use_ruby_llm, false).to_b ? t('app.settings.people.form.backend_choice_ruby_llm') : t('app.settings.people.form.backend_choice_sdk')) %> +

+
+
+ + <%= t('app.settings.people.form.backend_choice_default') %> + <%= t('app.settings.people.form.backend_choice_ruby_llm') %> + <%= t('app.settings.people.form.backend_choice_sdk') %> +
+ <% User::Features.derived_backend_names.each do |choice| %> + <% driver = choice.to_s.delete_suffix("_ai_backend") %> + <% display_name = { "openai" => "OpenAI" }.fetch(driver) { driver.humanize } %> + <% current_choice = Current.person.user.features[choice] %> +
+ <%= display_name %> + + <%= radio_button_tag "person[backend_choices][#{choice}]", "", current_choice.blank?, + aria: { label: t('app.settings.people.form.backend_choice_aria_default', driver: display_name) } %> + + + <%= radio_button_tag "person[backend_choices][#{choice}]", "ruby_llm", current_choice == "ruby_llm", + disabled: !User::Features.ruby_llm_available?(driver), + aria: { label: t('app.settings.people.form.backend_choice_aria_ruby_llm', driver: display_name) } %> + + + <%= radio_button_tag "person[backend_choices][#{choice}]", "sdk", current_choice == "sdk", + aria: { label: t('app.settings.people.form.backend_choice_aria_sdk', driver: display_name) } %> + +
<% end %>
diff --git a/config/locales/de.yml b/config/locales/de.yml index 636159bf9..7136610b6 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -106,6 +106,14 @@ de: dark: "Dunkel" light: "Hell" system: "System" + ai_backends: "KI-Backends" + ai_backends_description: "Welche Implementierung jeden Anbieter bedient. Standard folgt der globalen Einstellung (aktuell %{current}). RubyLLM wird wählbar, sobald es ausgerollt ist." + backend_choice_default: "Standard" + backend_choice_ruby_llm: "RubyLLM" + backend_choice_sdk: "Integriertes SDK" + backend_choice_aria_default: "%{driver}: Standard" + backend_choice_aria_ruby_llm: "%{driver}: RubyLLM" + backend_choice_aria_sdk: "%{driver}: integriertes SDK" enable_tools: "Tools für Assistenten aktivieren" enable_tools_note: "Hinweis für Entwickler: Die entsprechende API (Gmail, Google Tasks) muss auch in der Google Cloud Console für das Projekt hinter dem OAuth-Client dieser App aktiviert sein, sonst schlägt die Verbindung fehl." gmail_section: "Gmail:" diff --git a/config/locales/en.yml b/config/locales/en.yml index cd627a628..cdd36b157 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -135,6 +135,14 @@ en: dark: "Dark" light: "Light" system: "System" + ai_backends: "AI backends" + ai_backends_description: "Which implementation handles each provider. Default follows the site-wide setting (currently %{current}). RubyLLM becomes selectable as its rollout lands." + backend_choice_default: "Default" + backend_choice_ruby_llm: "RubyLLM" + backend_choice_sdk: "Built-in SDK" + backend_choice_aria_default: "%{driver}: default" + backend_choice_aria_ruby_llm: "%{driver}: RubyLLM" + backend_choice_aria_sdk: "%{driver}: built-in SDK" enable_tools: "Enable Tools for Assistants" enable_tools_note: "Note for developers: the corresponding API (Gmail, Google Tasks) must also be enabled in the Google Cloud console for the project behind this app's OAuth client, or connecting here will fail." gmail_section: "Gmail:" diff --git a/config/options.yml b/config/options.yml index c2123bf3c..aaf012137 100644 --- a/config/options.yml +++ b/config/options.yml @@ -47,6 +47,7 @@ shared: email: <%= ENV["EMAIL_FEATURE"] || default_to(false, except_env_test: true) %> password_reset_email: <%= ENV["PASSWORD_RESET_EMAIL_FEATURE"] || default_to(false, except_env_test: true) %> assistants_page: <%= ENV["ASSISTANTS_PAGE_FEATURE"] || true %> + use_ruby_llm: <%= ENV["USE_RUBY_LLM_FEATURE"] || false %> settings: # Be sure to add these ENV to docker-compose.yml app_url_protocol: <%= ENV["APP_URL_PROTOCOL"] || default_to(app_url: :protocol) %> diff --git a/test/controllers/settings/people_controller_test.rb b/test/controllers/settings/people_controller_test.rb index 7d906288a..e7b5c1204 100644 --- a/test/controllers/settings/people_controller_test.rb +++ b/test/controllers/settings/people_controller_test.rb @@ -162,6 +162,40 @@ class Settings::PeopleControllerTest < ActionDispatch::IntegrationTest end end + test "updating dark_mode leaves nav_closed and feature opinions intact" do + @user.update!(nav_closed: true) + @user.update!(preferences: @user.preferences.merge(feature: { openai_backend: "sdk" })) + + params = person_params + params["personable_attributes"]["dark_mode"] = "dark" + + patch settings_person_url, params: { person: params } + assert_redirected_to edit_settings_person_url + assert_nil flash[:error] + + @user.reload + assert_equal "dark", @user.dark_mode + assert_equal true, @user.nav_closed + assert_equal "sdk", @user.preferences[:feature][:openai_backend] + end + + test "stores and inherits backend choices without touching other preferences" do + @user.update!(dark_mode: "dark", nav_closed: true) + + params = person_params + params["backend_choices"] = { "openai_ai_backend" => "ruby_llm", "gemini_ai_backend" => "" } + + patch settings_person_url, params: { person: params } + assert_redirected_to edit_settings_person_url + assert_nil flash[:error] + + @user.reload + assert_equal "ruby_llm", @user.features[:openai_ai_backend] + assert_nil @user.features[:gemini_ai_backend] + assert_equal true, @user.nav_closed + assert_equal "dark", @user.dark_mode + end + private def with_feature(name, enabled) diff --git a/test/controllers/users_controller_test.rb b/test/controllers/users_controller_test.rb index 8bfa53834..89b0b6489 100644 --- a/test/controllers/users_controller_test.rb +++ b/test/controllers/users_controller_test.rb @@ -86,17 +86,17 @@ class UsersControllerTest < ActionDispatch::IntegrationTest end end - test "updates user preferences" do + test "updates nav_closed preference without touching other preferences" do user = users(:keith) + user.update!(dark_mode: "dark", nav_closed: false) login_as user - assert_changes "user.preferences[:nav_closed]", to: true do - assert_changes "user.preferences[:dark_mode]", to: "dark" do - patch user_url(user), params: { user: { preferences: { nav_closed: true, dark_mode: "dark" } } } - user.reload - end - end + patch user_url(user), params: { user: { nav_closed: true } } assert_response :redirect + user.reload + + assert_equal true, user.nav_closed + assert_equal "dark", user.dark_mode, "sidebar toggle must not disturb dark_mode" end private diff --git a/test/models/user/features_test.rb b/test/models/user/features_test.rb new file mode 100644 index 000000000..e35b67a5a --- /dev/null +++ b/test/models/user/features_test.rb @@ -0,0 +1,99 @@ +require "test_helper" + +class User::FeaturesTest < ActiveSupport::TestCase + setup do + @user = users(:keith) + end + + test "unset choices read nil so they inherit the site default" do + assert_nil @user.features[:use_ruby_llm] + assert_nil @user.features[:openai_ai_backend] + end + + test "explicit choices persist distinctly through save and reload" do + @user.features[:use_ruby_llm] = false + @user.features[:openai_ai_backend] = "ruby_llm" + @user.reload + + assert_equal false, @user.features[:use_ruby_llm] + assert_equal "ruby_llm", @user.features[:openai_ai_backend] + end + + test "writing one choice preserves sibling choices and unrelated preferences" do + @user.update!(dark_mode: "dark", nav_closed: true) + @user.features[:use_ruby_llm] = true + @user.reload + + assert_equal "dark", @user.dark_mode + assert_equal true, @user.nav_closed + assert_equal true, @user.features[:use_ruby_llm] + + assert_raises(KeyError) { @user.features[:registration] = true } + end + + test "names outside the AI-backend domain raise the typo error on read and write" do + error = assert_raises(KeyError) { @user.features[:google_tools] } + assert_match "Did you typo a feature name?", error.message + + assert_raises(KeyError) { @user.features[:voic] } + assert_raises(KeyError) { @user.features[:voic] = true } + end + + test "RubyLLM is unavailable while the RubyLLM backend does not exist or does not support a driver" do + refute User::Features.ruby_llm_available?("openai") + end + + test "derived backend names are valid and non-chat drivers are not" do + @user.features[:openai_ai_backend] = "ruby_llm" + @user.reload + assert_equal "ruby_llm", @user.features[:openai_ai_backend] + + error = assert_raises(KeyError) { @user.features[:brave_ai_backend] = "sdk" } + assert_match "Did you typo a feature name?", error.message + end + + test "an explicit per-driver choice overrides an off site default, per driver" do + original = Feature.features_hash + Feature.features_hash = Feature.features.merge(use_ruby_llm: false) + Current.reset + @user.features[:openai_ai_backend] = "ruby_llm" + @user.reload + + assert_equal "ruby_llm", @user.ruby_llm?("openai") + assert_equal false, @user.ruby_llm?("anthropic") + ensure + Feature.features_hash = original + Current.reset + end + + test "an explicit per-driver choice overrides an on site default, per driver" do + original = Feature.features_hash + Feature.features_hash = Feature.features.merge(use_ruby_llm: true) + Current.reset + @user.features[:openai_ai_backend] = "sdk" + @user.reload + + assert_equal "sdk", @user.ruby_llm?("openai") + assert_equal true, @user.ruby_llm?("gemini") + ensure + Feature.features_hash = original + Current.reset + end + + test "choices written here are visible to Feature.enabled? after reload, and unset falls back to the site default" do + @user.features[:use_ruby_llm] = true + @user.reload + + Current.user = @user + assert_equal true, Feature.use_ruby_llm? + ensure + Current.reset + end + + test "without a current user, Feature.enabled? falls back to the site default" do + Current.reset + site_default = Feature.raw_features[:google_tools].to_b + + assert_equal site_default, Feature.google_tools? + end +end diff --git a/test/models/user/preference_test.rb b/test/models/user/preference_test.rb index a012eb129..a060c4689 100644 --- a/test/models/user/preference_test.rb +++ b/test/models/user/preference_test.rb @@ -27,15 +27,46 @@ class User::PreferenceTest < ActiveSupport::TestCase test "dark_mode preference defaults to system and it can update user dark_mode preference" do new_user = User.create!(first_name: "First", last_name: "Last") - assert_equal "system", new_user.preferences[:dark_mode] + assert_equal "system", new_user.dark_mode new_user.update!(preferences: { dark_mode: "light" }) - assert_equal "light", new_user.preferences[:dark_mode] + assert_equal "light", new_user.dark_mode new_user.update!(preferences: { dark_mode: "dark" }) - assert_equal "dark", new_user.preferences[:dark_mode] + assert_equal "dark", new_user.dark_mode new_user.update!(preferences: { dark_mode: "system" }) - assert_equal "system", new_user.preferences[:dark_mode] + assert_equal "system", new_user.dark_mode + end + + test "accessor writes persist through the reworked preferences getter" do + user = User.create!(first_name: "First", last_name: "Last") + assert_nil user.preferences[:dark_mode] + + user.dark_mode = "dark" + assert_predicate user, :will_save_change_to_preferences? + user.save! + user.reload + + assert_equal "dark", user.preferences[:dark_mode] + end + + test "accessors coerce form payloads and round-trip through save and reload" do + user = User.create!(first_name: "First", last_name: "Last") + + user.dark_mode = "dark" + user.nav_closed = "true" + user.save! + user.reload + + assert_equal "dark", user.dark_mode + assert_equal true, user.reload.preferences[:nav_closed] + assert_equal true, user.nav_closed + + user.nav_closed = "false" + assert_equal false, user.nav_closed + + keys = user.reload.preferences.keys.map(&:to_s) + assert_equal keys.count, keys.uniq.count end end