Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion app/controllers/settings/people_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/users_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
28 changes: 27 additions & 1 deletion app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
47 changes: 47 additions & 0 deletions app/models/user/features.rb
Original file line number Diff line number Diff line change
@@ -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 (<driver>_ai_backend) and use_ruby_llm are accessible here. Did you typo a feature name?"
end

key
end
end
4 changes: 2 additions & 2 deletions app/views/layouts/application.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<body
class="
nav-closed
<%= Current.user.preferences[:dark_mode] %>
<%= Current.user.dark_mode %>
bg-white dark:bg-gray-800
"
data-controller="transition focus-restore"
Expand All @@ -17,7 +17,7 @@
min-w-[260px]
bg-gray-50 dark:bg-gray-900
h-full
flex nav-closed:hidden <%= !Current.user.preferences[:nav_closed] && "md:!flex" %>
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
Expand Down
6 changes: 3 additions & 3 deletions app/views/messages/_main_column.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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') %>"
>
Expand All @@ -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') %>"
>
Expand Down
49 changes: 42 additions & 7 deletions app/views/settings/people/_form.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -104,13 +104,48 @@
<div class="my-5" data-controller="transition" data-transition-toggle-class="hidden">
<%= user_fields.label :dark_mode, t('app.settings.people.form.color_theme') %>
<div class="flex items-center justify-between outline-none pl-3 py-2 mt-2 w-full dark:text-black gap-1">
<%= user_fields.fields_for :preferences do |preferences_fields| %>
<% %w(dark light system).each do |value| %>
<div class="flex items-center gap-2">
<%= 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}"%>
</div>
<% end %>
<% %w(dark light system).each do |value| %>
<div class="flex items-center gap-2">
<%= 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}"%>
</div>
<% end %>
</div>
</div>

<div class="my-5">
<span class="block mb-2"><%= t('app.settings.people.form.ai_backends') %></span>
<p class="text-sm text-gray-500 dark:text-gray-400">
<%= 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')) %>
</p>
<div class="pl-3 mt-2">
<div class="flex items-center gap-8 mb-1 text-sm text-gray-500 dark:text-gray-400">
<span class="w-28"></span>
<span class="w-32"><%= t('app.settings.people.form.backend_choice_default') %></span>
<span class="w-32"><%= t('app.settings.people.form.backend_choice_ruby_llm') %></span>
<span class="w-32"><%= t('app.settings.people.form.backend_choice_sdk') %></span>
</div>
<% 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] %>
<div class="flex items-center gap-8 py-1">
<span class="w-28 dark:text-gray-400"><%= display_name %></span>
<span class="w-32">
<%= radio_button_tag "person[backend_choices][#{choice}]", "", current_choice.blank?,
aria: { label: t('app.settings.people.form.backend_choice_aria_default', driver: display_name) } %>
</span>
<span class="w-32">
<%= 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) } %>
</span>
<span class="w-32">
<%= 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) } %>
</span>
</div>
<% end %>
</div>
</div>
Expand Down
8 changes: 8 additions & 0 deletions config/locales/de.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:"
Expand Down
8 changes: 8 additions & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:"
Expand Down
1 change: 1 addition & 0 deletions config/options.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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) %>
Expand Down
34 changes: 34 additions & 0 deletions test/controllers/settings/people_controller_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
14 changes: 7 additions & 7 deletions test/controllers/users_controller_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading