From 34b42a1ce150681599d75d56c1a99384503f87d5 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Mon, 12 Jan 2026 19:54:19 +0530 Subject: [PATCH] feat: add global config for captain settings (#13141) Co-authored-by: aakashb95 Co-authored-by: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> --- .../captain/preferences_controller.rb | 76 +++++++ .../dashboard/api/captain/preferences.js | 18 ++ .../components-next/sidebar/Sidebar.vue | 6 + .../dashboard/helper/featureHelper.js | 1 + .../dashboard/i18n/locale/en/general.json | 3 +- .../i18n/locale/en/integrations.json | 1 + .../dashboard/i18n/locale/en/settings.json | 51 ++++- .../dashboard/settings/captain/Index.vue | 182 ++++++++++++++++ .../settings/captain/captain.routes.js | 38 ++++ .../captain/components/FeatureToggle.vue | 144 +++++++++++++ .../captain/components/ModelDropdown.vue | 153 ++++++++++++++ .../captain/components/ModelSelector.vue | 47 +++++ .../dashboard/settings/settings.routes.js | 2 + .../dashboard/store/captain/preferences.js | 71 +++++++ app/models/account.rb | 27 +++ app/models/concerns/captain_featurable.rb | 62 ++++++ config/installation_config.yml | 2 +- config/llm.yml | 117 +++++++++++ config/routes.rb | 1 + .../llm/legacy_base_open_ai_service.rb | 2 +- lib/llm/models.rb | 41 ++++ package.json | 12 +- pnpm-lock.yaml | 194 +++++++++++------- .../captain/preferences_controller_spec.rb | 153 ++++++++++++++ spec/models/account_spec.rb | 55 +++++ .../concerns/captain_featurable_spec.rb | 134 ++++++++++++ theme/icons.js | 101 +++++++++ 27 files changed, 1608 insertions(+), 86 deletions(-) create mode 100644 app/controllers/api/v1/accounts/captain/preferences_controller.rb create mode 100644 app/javascript/dashboard/api/captain/preferences.js create mode 100644 app/javascript/dashboard/routes/dashboard/settings/captain/Index.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/captain/captain.routes.js create mode 100644 app/javascript/dashboard/routes/dashboard/settings/captain/components/FeatureToggle.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/captain/components/ModelDropdown.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/captain/components/ModelSelector.vue create mode 100644 app/javascript/dashboard/store/captain/preferences.js create mode 100644 app/models/concerns/captain_featurable.rb create mode 100644 config/llm.yml create mode 100644 lib/llm/models.rb create mode 100644 spec/controllers/api/v1/accounts/captain/preferences_controller_spec.rb create mode 100644 spec/models/concerns/captain_featurable_spec.rb diff --git a/app/controllers/api/v1/accounts/captain/preferences_controller.rb b/app/controllers/api/v1/accounts/captain/preferences_controller.rb new file mode 100644 index 000000000..156c031fa --- /dev/null +++ b/app/controllers/api/v1/accounts/captain/preferences_controller.rb @@ -0,0 +1,76 @@ +class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::BaseController + before_action :current_account + before_action :authorize_account_update, only: [:update] + + def show + render json: preferences_payload + end + + def update + params_to_update = captain_params + @current_account.captain_models = params_to_update[:captain_models] if params_to_update[:captain_models] + @current_account.captain_features = params_to_update[:captain_features] if params_to_update[:captain_features] + @current_account.save! + + render json: preferences_payload + end + + private + + def preferences_payload + { + providers: Llm::Models.providers, + models: Llm::Models.models, + features: features_with_account_preferences + } + end + + def authorize_account_update + authorize @current_account, :update? + end + + def captain_params + permitted = {} + permitted[:captain_models] = merged_captain_models if params[:captain_models].present? + permitted[:captain_features] = merged_captain_features if params[:captain_features].present? + permitted + end + + def merged_captain_models + existing_models = @current_account.captain_models || {} + existing_models.merge(permitted_captain_models) + end + + def merged_captain_features + existing_features = @current_account.captain_features || {} + existing_features.merge(permitted_captain_features) + end + + def permitted_captain_models + params.require(:captain_models).permit( + :editor, :assistant, :copilot, :label_suggestion, + :audio_transcription, :help_center_search + ).to_h.stringify_keys + end + + def permitted_captain_features + params.require(:captain_features).permit( + :editor, :assistant, :copilot, :label_suggestion, + :audio_transcription, :help_center_search + ).to_h.stringify_keys + end + + def features_with_account_preferences + preferences = Current.account.captain_preferences + account_features = preferences[:features] || {} + account_models = preferences[:models] || {} + + Llm::Models.feature_keys.index_with do |feature_key| + config = Llm::Models.feature_config(feature_key) + config.merge( + enabled: account_features[feature_key] == true, + selected: account_models[feature_key] || config[:default] + ) + end + end +end diff --git a/app/javascript/dashboard/api/captain/preferences.js b/app/javascript/dashboard/api/captain/preferences.js new file mode 100644 index 000000000..f1ce30582 --- /dev/null +++ b/app/javascript/dashboard/api/captain/preferences.js @@ -0,0 +1,18 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class CaptainPreferences extends ApiClient { + constructor() { + super('captain/preferences', { accountScoped: true }); + } + + get() { + return axios.get(this.url); + } + + updatePreferences(data) { + return axios.put(this.url, data); + } +} + +export default new CaptainPreferences(); diff --git a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue index 95baafcfd..a0e83973d 100644 --- a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue +++ b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue @@ -485,6 +485,12 @@ const menuItems = computed(() => { icon: 'i-lucide-briefcase', to: accountScopedRoute('general_settings_index'), }, + // { + // name: 'Settings Captain', + // label: t('SIDEBAR.CAPTAIN_AI'), + // icon: 'i-woot-captain', + // to: accountScopedRoute('captain_settings_index'), + // }, { name: 'Settings Agents', label: t('SIDEBAR.AGENTS'), diff --git a/app/javascript/dashboard/helper/featureHelper.js b/app/javascript/dashboard/helper/featureHelper.js index 910a6bed6..ae805ccf1 100644 --- a/app/javascript/dashboard/helper/featureHelper.js +++ b/app/javascript/dashboard/helper/featureHelper.js @@ -20,6 +20,7 @@ const FEATURE_HELP_URLS = { webhook: 'https://chwt.app/hc/webhooks', billing: 'https://chwt.app/pricing', saml: 'https://chwt.app/hc/saml', + captain_billing: 'https://chwt.app/hc/captain_billing', }; export function getHelpUrlForFeature(featureName) { diff --git a/app/javascript/dashboard/i18n/locale/en/general.json b/app/javascript/dashboard/i18n/locale/en/general.json index 283cf79c4..98f724223 100644 --- a/app/javascript/dashboard/i18n/locale/en/general.json +++ b/app/javascript/dashboard/i18n/locale/en/general.json @@ -7,6 +7,7 @@ }, "CLOSE": "Close", "BETA": "Beta", - "BETA_DESCRIPTION": "This feature is in beta and may change as we improve it." + "BETA_DESCRIPTION": "This feature is in beta and may change as we improve it.", + "PREFERRED": "Preferred" } } diff --git a/app/javascript/dashboard/i18n/locale/en/integrations.json b/app/javascript/dashboard/i18n/locale/en/integrations.json index c08cde4d3..21e9e72af 100644 --- a/app/javascript/dashboard/i18n/locale/en/integrations.json +++ b/app/javascript/dashboard/i18n/locale/en/integrations.json @@ -403,6 +403,7 @@ "CANCEL_ANYTIME": "You can change or cancel your plan anytime" }, "ENTERPRISE_PAYWALL": { + "AVAILABLE_ON": "Captain AI is only available in the Enterprise plans.", "UPGRADE_PROMPT": "Upgrade your plan to get access to our assistants, copilot and more.", "ASK_ADMIN": "Please reach out to your administrator for the upgrade." }, diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index 3017e9464..0c4647474 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -378,7 +378,56 @@ "INFO_SHORT": "Automatically mark offline when you aren't using the app." }, "DOCS": "Read docs", - "SECURITY": "Security" + "SECURITY": "Security", + "CAPTAIN_AI": "Captain" + }, + "CAPTAIN_SETTINGS": { + "TITLE": "Captain Settings", + "DESCRIPTION": "Configure your AI models and features for Captain. Captain follows a credit based billing, you will be charged credits for every action Captain takes based on the model selected.", + "LOADING": "Loading Captain configuration...", + "LINK_TEXT": "Learn more about Captain Credits", + "NOT_ENABLED": "Captain is not enabled for your account. Please upgrade your plan to access Captain features.", + "MODEL_CONFIG": { + "TITLE": "Model Configuration", + "DESCRIPTION": "Select AI models for different features.", + "SELECT_MODEL": "Select model", + "CREDITS_PER_MESSAGE": "{credits} credit/message", + "COMING_SOON": "Coming soon", + "EDITOR": { + "TITLE": "Editor Features", + "DESCRIPTION": "Powers smart compose, grammar corrections, tone adjustments, and content enhancement in your message editor." + }, + "ASSISTANT": { + "TITLE": "Assistant", + "DESCRIPTION": "Handles automated responses, conversation summaries, and intelligent reply suggestions for customer interactions." + }, + "COPILOT": { + "TITLE": "Co-pilot", + "DESCRIPTION": "Provides real-time contextual suggestions, knowledge base recommendations, and proactive insights during conversations." + } + }, + "FEATURES": { + "TITLE": "Features", + "DESCRIPTION": "Enable or disable AI-powered features.", + "AUDIO_TRANSCRIPTION": { + "TITLE": "Audio Transcription", + "DESCRIPTION": "Automatically convert voice messages and call recordings into searchable text transcripts." + }, + "HELP_CENTER_SEARCH": { + "TITLE": "Help Center Search Indexing", + "DESCRIPTION": "Use AI for context aware search inside your help center articles." + }, + "LABEL_SUGGESTION": { + "TITLE": "Label Suggestion", + "DESCRIPTION": "Automatically suggest relevant labels and tags for conversations based on content analysis and context.", + "MODEL_TITLE": "Label Suggestion Model", + "MODEL_DESCRIPTION": "Select the AI model to use for analyzing conversations and suggesting appropriate labels" + } + }, + "API": { + "SUCCESS": "Captain settings updated successfully.", + "ERROR": "Failed to update Captain settings. Please try again." + } }, "BILLING_SETTINGS": { "TITLE": "Billing", diff --git a/app/javascript/dashboard/routes/dashboard/settings/captain/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/captain/Index.vue new file mode 100644 index 000000000..55d69f180 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/captain/Index.vue @@ -0,0 +1,182 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/captain/captain.routes.js b/app/javascript/dashboard/routes/dashboard/settings/captain/captain.routes.js new file mode 100644 index 000000000..85e4cf9ab --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/captain/captain.routes.js @@ -0,0 +1,38 @@ +import { frontendURL } from '../../../../helper/URLHelper'; +import { FEATURE_FLAGS } from 'dashboard/featureFlags'; +import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes'; +import SettingsWrapper from '../SettingsWrapper.vue'; +import Index from './Index.vue'; + +export default { + routes: [ + { + path: frontendURL('accounts/:accountId/settings/captain'), + meta: { + permissions: ['administrator'], + featureFlag: FEATURE_FLAGS.CAPTAIN, + }, + component: SettingsWrapper, + props: { + headerTitle: 'CAPTAIN_SETTINGS.TITLE', + icon: 'i-lucide-bot', + showNewButton: false, + }, + children: [ + { + path: '', + name: 'captain_settings_index', + component: Index, + meta: { + permissions: ['administrator'], + featureFlag: FEATURE_FLAGS.CAPTAIN, + installationTypes: [ + INSTALLATION_TYPES.ENTERPRISE, + INSTALLATION_TYPES.CLOUD, + ], + }, + }, + ], + }, + ], +}; diff --git a/app/javascript/dashboard/routes/dashboard/settings/captain/components/FeatureToggle.vue b/app/javascript/dashboard/routes/dashboard/settings/captain/components/FeatureToggle.vue new file mode 100644 index 000000000..23a595649 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/captain/components/FeatureToggle.vue @@ -0,0 +1,144 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/captain/components/ModelDropdown.vue b/app/javascript/dashboard/routes/dashboard/settings/captain/components/ModelDropdown.vue new file mode 100644 index 000000000..05e59fa9d --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/captain/components/ModelDropdown.vue @@ -0,0 +1,153 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/captain/components/ModelSelector.vue b/app/javascript/dashboard/routes/dashboard/settings/captain/components/ModelSelector.vue new file mode 100644 index 000000000..58f14ea9b --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/captain/components/ModelSelector.vue @@ -0,0 +1,47 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/settings.routes.js b/app/javascript/dashboard/routes/dashboard/settings/settings.routes.js index 22173f66d..41c3498e1 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/settings.routes.js +++ b/app/javascript/dashboard/routes/dashboard/settings/settings.routes.js @@ -24,6 +24,7 @@ import teams from './teams/teams.routes'; import customRoles from './customRoles/customRole.routes'; import profile from './profile/profile.routes'; import security from './security/security.routes'; +import captain from './captain/captain.routes'; export default { routes: [ @@ -63,5 +64,6 @@ export default { ...customRoles.routes, ...profile.routes, ...security.routes, + ...captain.routes, ], }; diff --git a/app/javascript/dashboard/store/captain/preferences.js b/app/javascript/dashboard/store/captain/preferences.js new file mode 100644 index 000000000..12899195c --- /dev/null +++ b/app/javascript/dashboard/store/captain/preferences.js @@ -0,0 +1,71 @@ +import { defineStore } from 'pinia'; +import CaptainPreferencesAPI from 'dashboard/api/captain/preferences'; + +export const useCaptainConfigStore = defineStore('captainConfig', { + state: () => ({ + providers: {}, + models: {}, + features: {}, + uiFlags: { + isFetching: false, + }, + }), + + getters: { + getProviders: state => state.providers, + getModels: state => state.models, + getFeatures: state => state.features, + getUIFlags: state => state.uiFlags, + getModelsForFeature: state => featureKey => { + const feature = state.features[featureKey]; + const models = feature?.models || []; + + const providerOrder = { openai: 0, anthropic: 1, gemini: 2 }; + + return [...models].sort((a, b) => { + // Move coming_soon items to the end + if (a.coming_soon && !b.coming_soon) return 1; + if (!a.coming_soon && b.coming_soon) return -1; + + // Sort by provider + const providerA = providerOrder[a.provider] ?? 999; + const providerB = providerOrder[b.provider] ?? 999; + if (providerA !== providerB) return providerA - providerB; + + // Sort by credit_multiplier (highest first) + return (b.credit_multiplier || 0) - (a.credit_multiplier || 0); + }); + }, + getDefaultModelForFeature: state => featureKey => { + const feature = state.features[featureKey]; + return feature?.default || null; + }, + getSelectedModelForFeature: state => featureKey => { + const feature = state.features[featureKey]; + return feature?.selected || feature?.default || null; + }, + }, + + actions: { + async fetch() { + this.uiFlags.isFetching = true; + try { + const response = await CaptainPreferencesAPI.get(); + this.providers = response.data.providers || {}; + this.models = response.data.models || {}; + this.features = response.data.features || {}; + } catch (error) { + // Ignore error + } finally { + this.uiFlags.isFetching = false; + } + }, + + async updatePreferences(data) { + const response = await CaptainPreferencesAPI.updatePreferences(data); + this.providers = response.data.providers || {}; + this.models = response.data.models || {}; + this.features = response.data.features || {}; + }, + }, +}); diff --git a/app/models/account.rb b/app/models/account.rb index f52896496..dad5bc18d 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -28,6 +28,7 @@ class Account < ApplicationRecord include Reportable include Featurable include CacheKeys + include CaptainFeaturable SETTINGS_PARAMS_SCHEMA = { 'type': 'object', @@ -41,6 +42,30 @@ class Account < ApplicationRecord 'conversation_required_attributes': { 'type': %w[array null], 'items': { 'type': 'string' } + }, + 'captain_models': { + 'type': %w[object null], + 'properties': { + 'editor': { 'type': %w[string null] }, + 'assistant': { 'type': %w[string null] }, + 'copilot': { 'type': %w[string null] }, + 'label_suggestion': { 'type': %w[string null] }, + 'audio_transcription': { 'type': %w[string null] }, + 'help_center_search': { 'type': %w[string null] } + }, + 'additionalProperties': false + }, + 'captain_features': { + 'type': %w[object null], + 'properties': { + 'editor': { 'type': %w[boolean null] }, + 'assistant': { 'type': %w[boolean null] }, + 'copilot': { 'type': %w[boolean null] }, + 'label_suggestion': { 'type': %w[boolean null] }, + 'audio_transcription': { 'type': %w[boolean null] }, + 'help_center_search': { 'type': %w[boolean null] } + }, + 'additionalProperties': false } }, 'required': [], @@ -59,7 +84,9 @@ class Account < ApplicationRecord attribute_resolver: ->(record) { record.settings } store_accessor :settings, :auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting + store_accessor :settings, :audio_transcriptions, :auto_resolve_label, :conversation_required_attributes + store_accessor :settings, :captain_models, :captain_features has_many :account_users, dependent: :destroy_async has_many :agent_bot_inboxes, dependent: :destroy_async diff --git a/app/models/concerns/captain_featurable.rb b/app/models/concerns/captain_featurable.rb new file mode 100644 index 000000000..af73fded3 --- /dev/null +++ b/app/models/concerns/captain_featurable.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module CaptainFeaturable + extend ActiveSupport::Concern + + included do + validate :validate_captain_models + + # Dynamically define accessor methods for each captain feature + Llm::Models.feature_keys.each do |feature_key| + # Define enabled? methods (e.g., captain_editor_enabled?) + define_method("captain_#{feature_key}_enabled?") do + captain_features_with_defaults[feature_key] + end + + # Define model accessor methods (e.g., captain_editor_model) + define_method("captain_#{feature_key}_model") do + captain_models_with_defaults[feature_key] + end + end + end + + def captain_preferences + { + models: captain_models_with_defaults, + features: captain_features_with_defaults + }.with_indifferent_access + end + + private + + def captain_models_with_defaults + stored_models = captain_models || {} + Llm::Models.feature_keys.each_with_object({}) do |feature_key, result| + stored_value = stored_models[feature_key] + result[feature_key] = if stored_value.present? && Llm::Models.valid_model_for?(feature_key, stored_value) + stored_value + else + Llm::Models.default_model_for(feature_key) + end + end + end + + def captain_features_with_defaults + stored_features = captain_features || {} + Llm::Models.feature_keys.index_with do |feature_key| + stored_features[feature_key] == true + end + end + + def validate_captain_models + return if captain_models.blank? + + captain_models.each do |feature_key, model_name| + next if model_name.blank? + next if Llm::Models.valid_model_for?(feature_key, model_name) + + allowed_models = Llm::Models.models_for(feature_key) + errors.add(:captain_models, "'#{model_name}' is not a valid model for #{feature_key}. Allowed: #{allowed_models.join(', ')}") + end + end +end diff --git a/config/installation_config.yml b/config/installation_config.yml index 567da8a59..33070357d 100644 --- a/config/installation_config.yml +++ b/config/installation_config.yml @@ -179,7 +179,7 @@ type: secret - name: CAPTAIN_OPEN_AI_MODEL display_title: 'OpenAI Model' - description: 'The OpenAI model configured for use in Captain AI. Default: gpt-4o-mini' + description: 'The OpenAI model configured for use in Captain AI. Default: gpt-4.1-mini' locked: false - name: CAPTAIN_OPEN_AI_ENDPOINT display_title: 'OpenAI API Endpoint (optional)' diff --git a/config/llm.yml b/config/llm.yml new file mode 100644 index 000000000..1442c83f0 --- /dev/null +++ b/config/llm.yml @@ -0,0 +1,117 @@ +aproviders: + openai: + display_name: 'OpenAI' + anthropic: + display_name: 'Anthropic' + gemini: + display_name: 'Gemini' + +models: + gpt-4.1: + provider: openai + display_name: 'GPT-4.1' + credit_multiplier: 3 + gpt-4.1-mini: + provider: openai + display_name: 'GPT-4.1 Mini' + credit_multiplier: 1 + gpt-4.1-nano: + provider: openai + display_name: 'GPT-4.1 Nano' + credit_multiplier: 1 + gpt-5.1: + provider: openai + display_name: 'GPT-5.1' + credit_multiplier: 2 + gpt-5-mini: + provider: openai + display_name: 'GPT-5 Mini' + credit_multiplier: 1 + gpt-5-nano: + provider: openai + display_name: 'GPT-5 Nano' + credit_multiplier: 1 + gpt-5.2: + provider: openai + display_name: 'GPT-5.2' + credit_multiplier: 3 + claude-haiku-4.5: + provider: anthropic + display_name: 'Claude Haiku 4.5' + coming_soon: true + credit_multiplier: 2 + claude-sonnet-4.5: + provider: anthropic + display_name: 'Claude Sonnet 4.5' + coming_soon: true + credit_multiplier: 3 + gemini-3-flash: + provider: gemini + display_name: 'Gemini 3 Flash' + coming_soon: true + credit_multiplier: 1 + gemini-3-pro: + provider: gemini + display_name: 'Gemini 3 Pro' + coming_soon: true + credit_multiplier: 3 + whisper-1: + provider: openai + display_name: 'Whisper' + credit_multiplier: 1 + text-embedding-3-small: + provider: openai + display_name: 'Text Embedding 3 Small' + credit_multiplier: 1 + +features: + editor: + models: + [ + gpt-4.1-mini, + gpt-4.1-nano, + gpt-5-mini, + gpt-4.1, + gpt-5.1, + gpt-5.2, + claude-haiku-4.5, + gemini-3-flash, + gemini-3-pro, + ] + default: gpt-4.1-mini + assistant: + models: + [ + gpt-5-mini, + gpt-4.1, + gpt-5.1, + gpt-5.2, + claude-haiku-4.5, + claude-sonnet-4.5, + gemini-3-flash, + gemini-3-pro, + ] + default: gpt-5.1 + copilot: + models: + [ + gpt-5-mini, + gpt-4.1, + gpt-5.1, + gpt-5.2, + claude-haiku-4.5, + claude-sonnet-4.5, + gemini-3-flash, + gemini-3-pro, + ] + default: gpt-5.1 + label_suggestion: + models: + [gpt-4.1-nano, gpt-4.1-mini, gpt-5-mini, gemini-3-flash, claude-haiku-4.5] + default: gpt-4.1-nano + audio_transcription: + models: [whisper-1] + default: whisper-1 + help_center_search: + models: [text-embedding-3-small] + default: text-embedding-3-small diff --git a/config/routes.rb b/config/routes.rb index 4582a4495..ed1e5e690 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -55,6 +55,7 @@ Rails.application.routes.draw do post :bulk_create, on: :collection end namespace :captain do + resource :preferences, only: [:show, :update] resources :assistants do member do post :playground diff --git a/enterprise/app/services/llm/legacy_base_open_ai_service.rb b/enterprise/app/services/llm/legacy_base_open_ai_service.rb index f431830db..c13e1de1c 100644 --- a/enterprise/app/services/llm/legacy_base_open_ai_service.rb +++ b/enterprise/app/services/llm/legacy_base_open_ai_service.rb @@ -7,7 +7,7 @@ # # For all other LLM operations, use Llm::BaseAiService with RubyLLM instead. class Llm::LegacyBaseOpenAiService - DEFAULT_MODEL = 'gpt-4o-mini' + DEFAULT_MODEL = 'gpt-4.1-mini' attr_reader :client, :model diff --git a/lib/llm/models.rb b/lib/llm/models.rb new file mode 100644 index 000000000..010742ff4 --- /dev/null +++ b/lib/llm/models.rb @@ -0,0 +1,41 @@ +module Llm::Models + CONFIG = YAML.load_file(Rails.root.join('config/llm.yml')).freeze + + class << self + def providers = CONFIG['providers'] + def models = CONFIG['models'] + def features = CONFIG['features'] + def feature_keys = CONFIG['features'].keys + + def default_model_for(feature) + CONFIG.dig('features', feature.to_s, 'default') + end + + def models_for(feature) + CONFIG.dig('features', feature.to_s, 'models') || [] + end + + def valid_model_for?(feature, model_name) + models_for(feature).include?(model_name.to_s) + end + + def feature_config(feature_key) + feature = features[feature_key.to_s] + return nil unless feature + + { + models: feature['models'].map do |model_name| + model = models[model_name] + { + id: model_name, + display_name: model['display_name'], + provider: model['provider'], + coming_soon: model['coming_soon'], + credit_multiplier: model['credit_multiplier'] + } + end, + default: feature['default'] + } + end + end +end diff --git a/package.json b/package.json index 53d7ed87a..731c2eb48 100644 --- a/package.json +++ b/package.json @@ -111,13 +111,13 @@ "wavesurfer.js": "7.8.6" }, "devDependencies": { - "@egoist/tailwindcss-icons": "^1.8.1", + "@egoist/tailwindcss-icons": "^1.9.0", "@histoire/plugin-vue": "0.17.15", - "@iconify-json/logos": "^1.2.3", - "@iconify-json/lucide": "^1.2.68", - "@iconify-json/ph": "^1.2.1", - "@iconify-json/ri": "^1.2.3", - "@iconify-json/teenyicons": "^1.2.1", + "@iconify-json/logos": "^1.2.10", + "@iconify-json/lucide": "^1.2.82", + "@iconify-json/ph": "^1.2.2", + "@iconify-json/ri": "^1.2.6", + "@iconify-json/teenyicons": "^1.2.2", "@intlify/eslint-plugin-vue-i18n": "^3.2.0", "@size-limit/file": "^8.2.4", "@vitest/coverage-v8": "3.0.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f062848c5..27d0281a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -249,26 +249,26 @@ importers: version: 7.8.6 devDependencies: '@egoist/tailwindcss-icons': - specifier: ^1.8.1 - version: 1.8.1(tailwindcss@3.4.13) + specifier: ^1.9.0 + version: 1.9.0(tailwindcss@3.4.13) '@histoire/plugin-vue': specifier: 0.17.15 version: 0.17.15(histoire@0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)))(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2)) '@iconify-json/logos': - specifier: ^1.2.3 - version: 1.2.3 + specifier: ^1.2.10 + version: 1.2.10 '@iconify-json/lucide': - specifier: ^1.2.68 - version: 1.2.68 + specifier: ^1.2.82 + version: 1.2.82 '@iconify-json/ph': - specifier: ^1.2.1 - version: 1.2.1 + specifier: ^1.2.2 + version: 1.2.2 '@iconify-json/ri': - specifier: ^1.2.3 - version: 1.2.3 + specifier: ^1.2.6 + version: 1.2.6 '@iconify-json/teenyicons': - specifier: ^1.2.1 - version: 1.2.1 + specifier: ^1.2.2 + version: 1.2.2 '@intlify/eslint-plugin-vue-i18n': specifier: ^3.2.0 version: 3.2.0(eslint@8.57.0) @@ -399,11 +399,11 @@ packages: resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} - '@antfu/install-pkg@0.4.1': - resolution: {integrity: sha512-T7yB5QNG29afhWVkVq7XeIMBa5U/vs9mX69YqayXypPRmYzUmzwnYltplHmPtZ4HPCn+sQKeXW8I47wCbuBOjw==} + '@antfu/install-pkg@1.1.0': + resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} - '@antfu/utils@0.7.10': - resolution: {integrity: sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==} + '@antfu/utils@8.1.1': + resolution: {integrity: sha512-Mex9nXf9vR6AhcXmMrlz/HVgYYZpVGJ6YlPgwl7UnaFpnshXs6EK/oa5Gpf3CzENMjkvEx2tQtntGnb7UtSTOQ==} '@asamuzakjp/css-color@4.1.0': resolution: {integrity: sha512-9xiBAtLn4aNsa4mDnpovJvBn72tNEIACyvlqaNJ+ADemR+yeMJWnBudOi2qGDviJa7SwcDOU/TRh5dnET7qk0w==} @@ -700,8 +700,8 @@ packages: peerDependencies: postcss-selector-parser: ^6.0.10 - '@egoist/tailwindcss-icons@1.8.1': - resolution: {integrity: sha512-hqZeASrhT6BOeaBHYDPB0yBH/zgMKqmm7y2Rsq0c4iRnNVv1RWEiXMBMJB38JsDMTHME450FKa/wvdaIhED+Iw==} + '@egoist/tailwindcss-icons@1.9.0': + resolution: {integrity: sha512-xWA9cUy6hzlK7Y6TaoRIcwmilSXiTJ8rbXcEdf9uht7yzDgw/yIgF4rThIQMrpD2Y2v4od51+r2y6Z7GStanDQ==} peerDependencies: tailwindcss: '*' @@ -961,29 +961,29 @@ packages: resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} deprecated: Use @eslint/object-schema instead - '@iconify-json/logos@1.2.3': - resolution: {integrity: sha512-JLHS5hgZP1b55EONAWNeqBUuriRfRNKWXK4cqYx0PpVaJfIIMiiMxFfvoQiX/bkE9XgkLhcKmDUqL3LXPdXPwQ==} + '@iconify-json/logos@1.2.10': + resolution: {integrity: sha512-qxaXKJ6fu8jzTMPQdHtNxlfx6tBQ0jXRbHZIYy5Ilh8Lx9US9FsAdzZWUR8MXV8PnWTKGDFO4ZZee9VwerCyMA==} - '@iconify-json/lucide@1.2.68': - resolution: {integrity: sha512-lR5xNJdn2CT0iR7lM25G4SewBO4G2hbr3fTWOc3AE9BspflEcneh02E3l9TBaCU/JOHozTJevWLrxBGypD7Tng==} + '@iconify-json/lucide@1.2.82': + resolution: {integrity: sha512-fHZWegspOZonl5GNTvOkHsjnTMdSslFh3EzpzUtRyLxO8bOonqk2OTU3hCl0k4VXzViMjqpRK3X1sotnuBXkFA==} '@iconify-json/material-symbols@1.2.10': resolution: {integrity: sha512-GcZxhOFStM7Dk/oZvJSaW0tR/k6NwTq+KDzYgCNBDg52ktZuRa/gkjRiYooJm/8PAe9NBYxIx8XjS/wi4sasdQ==} - '@iconify-json/ph@1.2.1': - resolution: {integrity: sha512-x0DNfwWrS18dbsBYOq3XGiZnGz4CgRyC+YSl/TZvMQiKhIUl1woWqUbMYqqfMNUBzjyk7ulvaRovpRsIlqIf8g==} + '@iconify-json/ph@1.2.2': + resolution: {integrity: sha512-PgkEZNtqa8hBGjHXQa4pMwZa93hmfu8FUSjs/nv4oUU6yLsgv+gh9nu28Kqi8Fz9CCVu4hj1MZs9/60J57IzFw==} - '@iconify-json/ri@1.2.3': - resolution: {integrity: sha512-UVKofd5xkSevGd5K01pvO4NWsu+2C9spu+GxnMZUYymUiaWmpCAxtd22MFSpm6MGf0MP4GCwhDCo1Q8L8oZ9wg==} + '@iconify-json/ri@1.2.6': + resolution: {integrity: sha512-tGXRmXtb8oFu8DNg9MsS1pywKFgs9QZ4U6LBzUamBHaw3ePSiPd7ouE64gzHzfEcR16hgVaXoUa+XxD3BB0XOg==} - '@iconify-json/teenyicons@1.2.1': - resolution: {integrity: sha512-PaVv+zrQEO6I/9YfEwxkJfYSrCIWyOoSv/ZOVgETsr0MOqN9k7ecnHF/lPrgpyCLkwLzPX7MyFm3/gmziwjSiw==} + '@iconify-json/teenyicons@1.2.2': + resolution: {integrity: sha512-Do08DrvNpT+pKVeyFqn7nZiIviAAY8KbduSfpNKzE1bgVekAIJ/AAJtOBSUFpV4vTk+hXw195+jmCv8/0cJSKA==} '@iconify/types@2.0.0': resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} - '@iconify/utils@2.1.32': - resolution: {integrity: sha512-LeifFZPPKu28O3AEDpYJNdEbvS4/ojAPyIW+pF/vUpJTYnbTiXUHkCh0bwgFRzKvdpb8H4Fbfd/742++MF4fPQ==} + '@iconify/utils@2.3.0': + resolution: {integrity: sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA==} '@intlify/core-base@9.14.2': resolution: {integrity: sha512-DZyQ4Hk22sC81MP4qiCDuU+LdaYW91A6lCjq8AWPvY3+mGMzhGDfOCzvyR6YBQxtlPjFqMoFk9ylnNYRAQwXtQ==} @@ -1916,8 +1916,11 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - confbox@0.1.7: - resolution: {integrity: sha512-uJcB/FKZtBMCJpK8MQji6bJHgu1tixKPxRLeGkNzBoOZzpnZUJm0jm2/sBDWcuBx1dYgxV4JU+g5hmNxCyAmdA==} + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + confbox@0.2.2: + resolution: {integrity: sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==} config-chain@1.1.13: resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} @@ -2444,6 +2447,9 @@ packages: resolution: {integrity: sha512-bFi65yM+xZgk+u/KRIpekdSYkTB5W1pEf0Lt8Q8Msh7b+eQ7LXVtIB1Bkm4fvclDEL1b2CZkMhv2mOeF8tMdkA==} engines: {node: '>=12.0.0'} + exsolve@1.0.8: + resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + extend-shallow@2.0.1: resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==} engines: {node: '>=0.10.0'} @@ -2636,6 +2642,10 @@ packages: resolution: {integrity: sha512-OkToC372DtlQeje9/zHIo5CT8lRP/FUgEOKBEhU4e0abL7J7CD24fD9ohiLN5hagG/kWCYj4K5oaxxtj2Z0Dig==} engines: {node: '>=18'} + globals@15.15.0: + resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==} + engines: {node: '>=18'} + globalthis@1.0.3: resolution: {integrity: sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==} engines: {node: '>= 0.4'} @@ -3118,8 +3128,8 @@ packages: lit@2.2.6: resolution: {integrity: sha512-K2vkeGABfSJSfkhqHy86ujchJs3NR9nW1bEEiV+bXDkbiQ60Tv5GUausYN2mXigZn8lC1qXuc46ArQRKYmumZw==} - local-pkg@0.5.0: - resolution: {integrity: sha512-ok6z3qlYyCDS4ZEU27HaU6x/xZa9Whf8jD4ptH5UZTQYZVYeb9bnZ3ojVhiJNLiXK1Hfc0GNbLXcmZ5plLDDBg==} + local-pkg@1.1.2: + resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==} engines: {node: '>=14'} locate-path@5.0.0: @@ -3298,8 +3308,8 @@ packages: mitt@3.0.1: resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} - mlly@1.7.1: - resolution: {integrity: sha512-rrVRZRELyQzrIUAVMHxP97kv+G786pHmOKzuFII8zDYahFBS7qnHh2AlYSl1GAHhaMPCz6/oHjVMcfFYgFYHgA==} + mlly@1.8.0: + resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} mpd-parser@0.21.0: resolution: {integrity: sha512-NbpMJ57qQzFmfCiP1pbL7cGMbVTD0X1hqNgL0VYP1wLlZXLf/HtmvQpNkOA1AHkPVeGQng+7/jEtSvNUzV7Gdg==} @@ -3481,8 +3491,8 @@ packages: package-json-from-dist@1.0.0: resolution: {integrity: sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==} - package-manager-detector@0.2.0: - resolution: {integrity: sha512-E385OSk9qDcXhcM9LNSe4sdhx8a9mAPrZ4sMLW+tmxl5ZuGtPUcdFu+MPP2jbgiWAZ6Pfe5soGFMd+0Db5Vrog==} + package-manager-detector@1.6.0: + resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} param-case@3.0.4: resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==} @@ -3540,6 +3550,9 @@ packages: pathe@2.0.2: resolution: {integrity: sha512-15Ztpk+nov8DR524R4BF7uEuzESgzUEAV4Ah7CUMNGXdE5ELuvxElxGXndBl32vMSsWa1jpNf22Z+Er3sKwq+w==} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pathval@2.0.0: resolution: {integrity: sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==} engines: {node: '>= 14.16'} @@ -3590,8 +3603,11 @@ packages: resolution: {integrity: sha512-Ie9z/WINcxxLp27BKOCHGde4ITq9UklYKDzVo1nhk5sqGEXU3FpkwP5GM2voTGJkGd9B3Otl+Q4uwSOeSUtOBA==} engines: {node: '>=14.16'} - pkg-types@1.2.0: - resolution: {integrity: sha512-+ifYuSSqOQ8CqP4MbZA5hDpb97n3E8SVWdJe+Wms9kj745lmd3b7EZJiqvmLwAlmRfjrI7Hi5z3kdBJ93lFNPA==} + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + pkg-types@2.3.0: + resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} pngjs@5.0.0: resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} @@ -3899,6 +3915,9 @@ packages: engines: {node: '>=10.13.0'} hasBin: true + quansync@0.2.11: + resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} + querystringify@2.2.0: resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} @@ -4290,6 +4309,10 @@ packages: tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyexec@1.0.2: + resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + engines: {node: '>=18'} + tinykeys@3.0.0: resolution: {integrity: sha512-nazawuGv5zx6MuDfDY0rmfXjuOGhD5XU2z0GLURQ1nzl0RUe9OuCJq+0u8xxJZINHe+mr7nw8PWYYZ9WhMFujw==} @@ -4409,8 +4432,8 @@ packages: uc.micro@2.1.0: resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} - ufo@1.5.4: - resolution: {integrity: sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==} + ufo@1.6.1: + resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==} unbox-primitive@1.0.2: resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==} @@ -4910,12 +4933,12 @@ snapshots: '@jridgewell/gen-mapping': 0.3.8 '@jridgewell/trace-mapping': 0.3.25 - '@antfu/install-pkg@0.4.1': + '@antfu/install-pkg@1.1.0': dependencies: - package-manager-detector: 0.2.0 - tinyexec: 0.3.2 + package-manager-detector: 1.6.0 + tinyexec: 1.0.2 - '@antfu/utils@0.7.10': {} + '@antfu/utils@8.1.1': {} '@asamuzakjp/css-color@4.1.0': dependencies: @@ -5233,9 +5256,9 @@ snapshots: dependencies: postcss-selector-parser: 6.1.1 - '@egoist/tailwindcss-icons@1.8.1(tailwindcss@3.4.13)': + '@egoist/tailwindcss-icons@1.9.0(tailwindcss@3.4.13)': dependencies: - '@iconify/utils': 2.1.32 + '@iconify/utils': 2.3.0 tailwindcss: 3.4.13 transitivePeerDependencies: - supports-color @@ -5490,11 +5513,11 @@ snapshots: '@humanwhocodes/object-schema@2.0.3': {} - '@iconify-json/logos@1.2.3': + '@iconify-json/logos@1.2.10': dependencies: '@iconify/types': 2.0.0 - '@iconify-json/lucide@1.2.68': + '@iconify-json/lucide@1.2.82': dependencies: '@iconify/types': 2.0.0 @@ -5502,29 +5525,30 @@ snapshots: dependencies: '@iconify/types': 2.0.0 - '@iconify-json/ph@1.2.1': + '@iconify-json/ph@1.2.2': dependencies: '@iconify/types': 2.0.0 - '@iconify-json/ri@1.2.3': + '@iconify-json/ri@1.2.6': dependencies: '@iconify/types': 2.0.0 - '@iconify-json/teenyicons@1.2.1': + '@iconify-json/teenyicons@1.2.2': dependencies: '@iconify/types': 2.0.0 '@iconify/types@2.0.0': {} - '@iconify/utils@2.1.32': + '@iconify/utils@2.3.0': dependencies: - '@antfu/install-pkg': 0.4.1 - '@antfu/utils': 0.7.10 + '@antfu/install-pkg': 1.1.0 + '@antfu/utils': 8.1.1 '@iconify/types': 2.0.0 - debug: 4.4.0 + debug: 4.4.3 + globals: 15.15.0 kolorist: 1.8.0 - local-pkg: 0.5.0 - mlly: 1.7.1 + local-pkg: 1.1.2 + mlly: 1.8.0 transitivePeerDependencies: - supports-color @@ -6196,8 +6220,7 @@ snapshots: acorn@8.14.0: {} - acorn@8.15.0: - optional: true + acorn@8.15.0: {} activestorage@5.2.8: dependencies: @@ -6598,7 +6621,9 @@ snapshots: concat-map@0.0.1: {} - confbox@0.1.7: {} + confbox@0.1.8: {} + + confbox@0.2.2: {} config-chain@1.1.13: dependencies: @@ -7235,6 +7260,8 @@ snapshots: expect-type@1.1.0: {} + exsolve@1.0.8: {} + extend-shallow@2.0.1: dependencies: is-extendable: 0.1.1 @@ -7450,6 +7477,8 @@ snapshots: globals@15.14.0: {} + globals@15.15.0: {} + globalthis@1.0.3: dependencies: define-properties: 1.2.0 @@ -8020,10 +8049,11 @@ snapshots: lit-element: 3.3.3 lit-html: 2.8.0 - local-pkg@0.5.0: + local-pkg@1.1.2: dependencies: - mlly: 1.7.1 - pkg-types: 1.2.0 + mlly: 1.8.0 + pkg-types: 2.3.0 + quansync: 0.2.11 locate-path@5.0.0: dependencies: @@ -8197,12 +8227,12 @@ snapshots: mitt@3.0.1: {} - mlly@1.7.1: + mlly@1.8.0: dependencies: - acorn: 8.12.1 - pathe: 1.1.2 - pkg-types: 1.2.0 - ufo: 1.5.4 + acorn: 8.15.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.1 mpd-parser@0.21.0: dependencies: @@ -8383,7 +8413,7 @@ snapshots: package-json-from-dist@1.0.0: {} - package-manager-detector@0.2.0: {} + package-manager-detector@1.6.0: {} param-case@3.0.4: dependencies: @@ -8435,6 +8465,8 @@ snapshots: pathe@2.0.2: {} + pathe@2.0.3: {} + pathval@2.0.0: {} perfect-debounce@1.0.0: {} @@ -8468,11 +8500,17 @@ snapshots: dependencies: find-up: 6.3.0 - pkg-types@1.2.0: + pkg-types@1.3.1: dependencies: - confbox: 0.1.7 - mlly: 1.7.1 - pathe: 1.1.2 + confbox: 0.1.8 + mlly: 1.8.0 + pathe: 2.0.3 + + pkg-types@2.3.0: + dependencies: + confbox: 0.2.2 + exsolve: 1.0.8 + pathe: 2.0.3 pngjs@5.0.0: {} @@ -8843,6 +8881,8 @@ snapshots: pngjs: 5.0.0 yargs: 15.4.1 + quansync@0.2.11: {} + querystringify@2.2.0: {} queue-microtask@1.2.3: {} @@ -9307,6 +9347,8 @@ snapshots: tinyexec@0.3.2: {} + tinyexec@1.0.2: {} + tinykeys@3.0.0: {} tinypool@1.0.2: {} @@ -9437,7 +9479,7 @@ snapshots: uc.micro@2.1.0: {} - ufo@1.5.4: {} + ufo@1.6.1: {} unbox-primitive@1.0.2: dependencies: diff --git a/spec/controllers/api/v1/accounts/captain/preferences_controller_spec.rb b/spec/controllers/api/v1/accounts/captain/preferences_controller_spec.rb new file mode 100644 index 000000000..c06f3c836 --- /dev/null +++ b/spec/controllers/api/v1/accounts/captain/preferences_controller_spec.rb @@ -0,0 +1,153 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Api::V1::Accounts::Captain::Preferences', type: :request do + let(:account) { create(:account) } + let(:admin) { create(:user, account: account, role: :administrator) } + let(:agent) { create(:user, account: account, role: :agent) } + + def json_response + JSON.parse(response.body, symbolize_names: true) + end + + describe 'GET /api/v1/accounts/{account.id}/captain/preferences' do + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + get "/api/v1/accounts/#{account.id}/captain/preferences", + as: :json + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an agent' do + it 'returns captain config' do + get "/api/v1/accounts/#{account.id}/captain/preferences", + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(json_response).to have_key(:providers) + expect(json_response).to have_key(:models) + expect(json_response).to have_key(:features) + end + end + + context 'when it is an admin' do + it 'returns captain config' do + get "/api/v1/accounts/#{account.id}/captain/preferences", + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(json_response).to have_key(:providers) + expect(json_response).to have_key(:models) + expect(json_response).to have_key(:features) + end + end + end + + describe 'PUT /api/v1/accounts/{account.id}/captain/preferences' do + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + put "/api/v1/accounts/#{account.id}/captain/preferences", + params: { captain_models: { editor: 'gpt-4.1-mini' } }, + as: :json + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an agent' do + it 'returns forbidden' do + put "/api/v1/accounts/#{account.id}/captain/preferences", + headers: agent.create_new_auth_token, + params: { captain_models: { editor: 'gpt-4.1-mini' } }, + as: :json + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an admin' do + it 'updates captain_models' do + put "/api/v1/accounts/#{account.id}/captain/preferences", + headers: admin.create_new_auth_token, + params: { captain_models: { editor: 'gpt-4.1-mini' } }, + as: :json + + expect(response).to have_http_status(:success) + expect(json_response).to have_key(:providers) + expect(json_response).to have_key(:models) + expect(json_response).to have_key(:features) + expect(account.reload.captain_models['editor']).to eq('gpt-4.1-mini') + end + + it 'updates captain_features' do + put "/api/v1/accounts/#{account.id}/captain/preferences", + headers: admin.create_new_auth_token, + params: { captain_features: { editor: true } }, + as: :json + + expect(response).to have_http_status(:success) + expect(json_response).to have_key(:providers) + expect(json_response).to have_key(:models) + expect(json_response).to have_key(:features) + expect(account.reload.captain_features['editor']).to be true + end + + it 'merges with existing captain_models' do + account.update!(captain_models: { 'editor' => 'gpt-4.1-mini', 'assistant' => 'gpt-5.1' }) + + put "/api/v1/accounts/#{account.id}/captain/preferences", + headers: admin.create_new_auth_token, + params: { captain_models: { editor: 'gpt-4.1' } }, + as: :json + + expect(response).to have_http_status(:success) + expect(json_response).to have_key(:providers) + expect(json_response).to have_key(:models) + expect(json_response).to have_key(:features) + models = account.reload.captain_models + expect(models['editor']).to eq('gpt-4.1') + expect(models['assistant']).to eq('gpt-5.1') # Preserved + end + + it 'merges with existing captain_features' do + account.update!(captain_features: { 'editor' => true, 'assistant' => false }) + + put "/api/v1/accounts/#{account.id}/captain/preferences", + headers: admin.create_new_auth_token, + params: { captain_features: { editor: false } }, + as: :json + + expect(response).to have_http_status(:success) + expect(json_response).to have_key(:providers) + expect(json_response).to have_key(:models) + expect(json_response).to have_key(:features) + features = account.reload.captain_features + expect(features['editor']).to be false + expect(features['assistant']).to be false # Preserved + end + + it 'updates both models and features in single request' do + put "/api/v1/accounts/#{account.id}/captain/preferences", + headers: admin.create_new_auth_token, + params: { + captain_models: { editor: 'gpt-4.1-mini' }, + captain_features: { editor: true } + }, + as: :json + + expect(response).to have_http_status(:success) + expect(json_response).to have_key(:providers) + expect(json_response).to have_key(:models) + expect(json_response).to have_key(:features) + account.reload + expect(account.captain_models['editor']).to eq('gpt-4.1-mini') + expect(account.captain_features['editor']).to be true + end + end + end +end diff --git a/spec/models/account_spec.rb b/spec/models/account_spec.rb index 0656870c8..5ccff6517 100644 --- a/spec/models/account_spec.rb +++ b/spec/models/account_spec.rb @@ -218,4 +218,59 @@ RSpec.describe Account do end end end + + describe 'captain_preferences' do + let(:account) { create(:account) } + + describe 'with no saved preferences' do + it 'returns defaults from llm.yml' do + prefs = account.captain_preferences + + expect(prefs[:features].values).to all(be false) + + Llm::Models.feature_keys.each do |feature| + expect(prefs[:models][feature]).to eq(Llm::Models.default_model_for(feature)) + end + end + end + + describe 'with saved model preferences' do + it 'returns saved preferences merged with defaults' do + account.update!(captain_models: { 'editor' => 'gpt-4.1-mini', 'assistant' => 'gpt-5.2' }) + + prefs = account.captain_preferences + + expect(prefs[:models]['editor']).to eq('gpt-4.1-mini') + expect(prefs[:models]['assistant']).to eq('gpt-5.2') + expect(prefs[:models]['copilot']).to eq(Llm::Models.default_model_for('copilot')) + end + end + + describe 'with saved feature preferences' do + it 'returns saved feature states' do + account.update!(captain_features: { 'editor' => true, 'assistant' => true }) + + prefs = account.captain_preferences + + expect(prefs[:features]['editor']).to be true + expect(prefs[:features]['assistant']).to be true + expect(prefs[:features]['copilot']).to be false + end + end + + describe 'validation' do + it 'rejects invalid model for a feature' do + account.captain_models = { 'label_suggestion' => 'gpt-5.1' } + + expect(account).not_to be_valid + expect(account.errors[:captain_models].first).to include('not a valid model for label_suggestion') + end + + it 'accepts valid model for a feature' do + account.captain_models = { 'editor' => 'gpt-4.1-mini', 'label_suggestion' => 'gpt-4.1-nano' } + + expect(account).to be_valid + end + end + end end diff --git a/spec/models/concerns/captain_featurable_spec.rb b/spec/models/concerns/captain_featurable_spec.rb new file mode 100644 index 000000000..7221ad055 --- /dev/null +++ b/spec/models/concerns/captain_featurable_spec.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe CaptainFeaturable do + let(:account) { create(:account) } + + describe 'dynamic method generation' do + it 'generates enabled? methods for all features' do + Llm::Models.feature_keys.each do |feature_key| + expect(account).to respond_to("captain_#{feature_key}_enabled?") + end + end + + it 'generates model accessor methods for all features' do + Llm::Models.feature_keys.each do |feature_key| + expect(account).to respond_to("captain_#{feature_key}_model") + end + end + end + + describe 'feature enabled methods' do + context 'when no features are explicitly enabled' do + it 'returns false for all features' do + Llm::Models.feature_keys.each do |feature_key| + expect(account.send("captain_#{feature_key}_enabled?")).to be false + end + end + end + + context 'when features are explicitly enabled' do + before do + account.update!(captain_features: { 'editor' => true, 'assistant' => true }) + end + + it 'returns true for enabled features' do + expect(account.captain_editor_enabled?).to be true + expect(account.captain_assistant_enabled?).to be true + end + + it 'returns false for disabled features' do + expect(account.captain_copilot_enabled?).to be false + expect(account.captain_label_suggestion_enabled?).to be false + end + end + + context 'when captain_features is nil' do + before do + account.update!(captain_features: nil) + end + + it 'returns false for all features' do + Llm::Models.feature_keys.each do |feature_key| + expect(account.send("captain_#{feature_key}_enabled?")).to be false + end + end + end + end + + describe 'model accessor methods' do + context 'when no models are explicitly configured' do + it 'returns default models for all features' do + Llm::Models.feature_keys.each do |feature_key| + expected_default = Llm::Models.default_model_for(feature_key) + expect(account.send("captain_#{feature_key}_model")).to eq(expected_default) + end + end + end + + context 'when models are explicitly configured' do + before do + account.update!(captain_models: { + 'editor' => 'gpt-4.1-mini', + 'assistant' => 'gpt-5.1', + 'label_suggestion' => 'gpt-4.1-nano' + }) + end + + it 'returns configured models for configured features' do + expect(account.captain_editor_model).to eq('gpt-4.1-mini') + expect(account.captain_assistant_model).to eq('gpt-5.1') + expect(account.captain_label_suggestion_model).to eq('gpt-4.1-nano') + end + + it 'returns default models for unconfigured features' do + expect(account.captain_copilot_model).to eq(Llm::Models.default_model_for('copilot')) + expect(account.captain_audio_transcription_model).to eq(Llm::Models.default_model_for('audio_transcription')) + end + end + + context 'when configured with invalid model' do + before do + account.captain_models = { 'editor' => 'invalid-model' } + end + + it 'falls back to default model' do + expect(account.captain_editor_model).to eq(Llm::Models.default_model_for('editor')) + end + end + + context 'when captain_models is nil' do + before do + account.update!(captain_models: nil) + end + + it 'returns default models for all features' do + Llm::Models.feature_keys.each do |feature_key| + expected_default = Llm::Models.default_model_for(feature_key) + expect(account.send("captain_#{feature_key}_model")).to eq(expected_default) + end + end + end + end + + describe 'integration with existing captain_preferences' do + it 'enabled? methods use the same logic as captain_preferences[:features]' do + account.update!(captain_features: { 'editor' => true, 'copilot' => true }) + prefs = account.captain_preferences + + Llm::Models.feature_keys.each do |feature_key| + expect(account.send("captain_#{feature_key}_enabled?")).to eq(prefs[:features][feature_key]) + end + end + + it 'model methods use the same logic as captain_preferences[:models]' do + account.update!(captain_models: { 'editor' => 'gpt-4.1-mini', 'assistant' => 'gpt-5.2' }) + prefs = account.captain_preferences + + Llm::Models.feature_keys.each do |feature_key| + expect(account.send("captain_#{feature_key}_model")).to eq(prefs[:models][feature_key]) + end + end + end +end diff --git a/theme/icons.js b/theme/icons.js index 6e7df16a4..373421de8 100644 --- a/theme/icons.js +++ b/theme/icons.js @@ -184,5 +184,106 @@ export const icons = { width: 14, height: 14, }, + gemini: { + width: 32, + height: 32, + body: ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `, + }, /** Ends */ };