feat: add global config for captain settings (#13141)
Co-authored-by: aakashb95 <aakashbakhle@gmail.com> Co-authored-by: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
This commit is contained in:
parent
ab83a663f0
commit
34b42a1ce1
@ -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
|
||||||
18
app/javascript/dashboard/api/captain/preferences.js
Normal file
18
app/javascript/dashboard/api/captain/preferences.js
Normal file
@ -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();
|
||||||
@ -485,6 +485,12 @@ const menuItems = computed(() => {
|
|||||||
icon: 'i-lucide-briefcase',
|
icon: 'i-lucide-briefcase',
|
||||||
to: accountScopedRoute('general_settings_index'),
|
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',
|
name: 'Settings Agents',
|
||||||
label: t('SIDEBAR.AGENTS'),
|
label: t('SIDEBAR.AGENTS'),
|
||||||
|
|||||||
@ -20,6 +20,7 @@ const FEATURE_HELP_URLS = {
|
|||||||
webhook: 'https://chwt.app/hc/webhooks',
|
webhook: 'https://chwt.app/hc/webhooks',
|
||||||
billing: 'https://chwt.app/pricing',
|
billing: 'https://chwt.app/pricing',
|
||||||
saml: 'https://chwt.app/hc/saml',
|
saml: 'https://chwt.app/hc/saml',
|
||||||
|
captain_billing: 'https://chwt.app/hc/captain_billing',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getHelpUrlForFeature(featureName) {
|
export function getHelpUrlForFeature(featureName) {
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
},
|
},
|
||||||
"CLOSE": "Close",
|
"CLOSE": "Close",
|
||||||
"BETA": "Beta",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -403,6 +403,7 @@
|
|||||||
"CANCEL_ANYTIME": "You can change or cancel your plan anytime"
|
"CANCEL_ANYTIME": "You can change or cancel your plan anytime"
|
||||||
},
|
},
|
||||||
"ENTERPRISE_PAYWALL": {
|
"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.",
|
"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."
|
"ASK_ADMIN": "Please reach out to your administrator for the upgrade."
|
||||||
},
|
},
|
||||||
|
|||||||
@ -378,7 +378,56 @@
|
|||||||
"INFO_SHORT": "Automatically mark offline when you aren't using the app."
|
"INFO_SHORT": "Automatically mark offline when you aren't using the app."
|
||||||
},
|
},
|
||||||
"DOCS": "Read docs",
|
"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": {
|
"BILLING_SETTINGS": {
|
||||||
"TITLE": "Billing",
|
"TITLE": "Billing",
|
||||||
|
|||||||
@ -0,0 +1,182 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, onMounted } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
import { useAccount } from 'dashboard/composables/useAccount';
|
||||||
|
import { useCaptain } from 'dashboard/composables/useCaptain';
|
||||||
|
import { useConfig } from 'dashboard/composables/useConfig';
|
||||||
|
import { useCaptainConfigStore } from 'dashboard/store/captain/preferences';
|
||||||
|
|
||||||
|
import SettingsLayout from '../SettingsLayout.vue';
|
||||||
|
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
|
||||||
|
import SectionLayout from '../account/components/SectionLayout.vue';
|
||||||
|
import ModelSelector from './components/ModelSelector.vue';
|
||||||
|
import FeatureToggle from './components/FeatureToggle.vue';
|
||||||
|
import CaptainPaywall from 'next/captain/pageComponents/Paywall.vue';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { captainEnabled } = useCaptain();
|
||||||
|
const { isEnterprise, enterprisePlanName } = useConfig();
|
||||||
|
const { isOnChatwootCloud } = useAccount();
|
||||||
|
|
||||||
|
const captainConfigStore = useCaptainConfigStore();
|
||||||
|
const { uiFlags } = storeToRefs(captainConfigStore);
|
||||||
|
|
||||||
|
const isLoading = computed(() => uiFlags.value.isFetching);
|
||||||
|
|
||||||
|
const modelFeatures = computed(() => [
|
||||||
|
{
|
||||||
|
key: 'editor',
|
||||||
|
title: t('CAPTAIN_SETTINGS.MODEL_CONFIG.EDITOR.TITLE'),
|
||||||
|
description: t('CAPTAIN_SETTINGS.MODEL_CONFIG.EDITOR.DESCRIPTION'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'assistant',
|
||||||
|
title: t('CAPTAIN_SETTINGS.MODEL_CONFIG.ASSISTANT.TITLE'),
|
||||||
|
description: t('CAPTAIN_SETTINGS.MODEL_CONFIG.ASSISTANT.DESCRIPTION'),
|
||||||
|
enterprise: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'copilot',
|
||||||
|
title: t('CAPTAIN_SETTINGS.MODEL_CONFIG.COPILOT.TITLE'),
|
||||||
|
description: t('CAPTAIN_SETTINGS.MODEL_CONFIG.COPILOT.DESCRIPTION'),
|
||||||
|
enterprise: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const featureToggles = computed(() => [
|
||||||
|
{
|
||||||
|
key: 'label_suggestion',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'help_center_search',
|
||||||
|
enterprise: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'audio_transcription',
|
||||||
|
enterprise: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const shouldShowFeature = feature => {
|
||||||
|
// Cloud will always see these features as long as captain is enabled
|
||||||
|
if (isOnChatwootCloud.value && captainEnabled) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (feature.enterprise) {
|
||||||
|
// if the app is in enterprise mode, then we can show the feature
|
||||||
|
// this is not the installation plan, but when the enterprise folder is missing
|
||||||
|
return isEnterprise;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isFeatureAccessible = feature => {
|
||||||
|
// Cloud will always see these features as long as captain is enabled
|
||||||
|
if (isOnChatwootCloud.value && captainEnabled) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (feature.enterprise) {
|
||||||
|
// plan is shown, but is it accessible?
|
||||||
|
// This ensures that the instance has purchased the enterprise license, and only then we allow
|
||||||
|
// access
|
||||||
|
return isEnterprise && enterprisePlanName === 'enterprise';
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function handleFeatureToggle({ feature, enabled }) {
|
||||||
|
try {
|
||||||
|
await captainConfigStore.updatePreferences({
|
||||||
|
captain_features: { [feature]: enabled },
|
||||||
|
});
|
||||||
|
useAlert(t('CAPTAIN_SETTINGS.API.SUCCESS'));
|
||||||
|
} catch (error) {
|
||||||
|
useAlert(t('CAPTAIN_SETTINGS.API.ERROR'));
|
||||||
|
captainConfigStore.fetch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleModelChange({ feature, model }) {
|
||||||
|
try {
|
||||||
|
await captainConfigStore.updatePreferences({
|
||||||
|
captain_models: { [feature]: model },
|
||||||
|
});
|
||||||
|
useAlert(t('CAPTAIN_SETTINGS.API.SUCCESS'));
|
||||||
|
} catch (error) {
|
||||||
|
useAlert(t('CAPTAIN_SETTINGS.API.ERROR'));
|
||||||
|
captainConfigStore.fetch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
captainConfigStore.fetch();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SettingsLayout
|
||||||
|
:is-loading="isLoading"
|
||||||
|
:no-records-message="t('CAPTAIN_SETTINGS.NOT_ENABLED')"
|
||||||
|
:loading-message="t('CAPTAIN_SETTINGS.LOADING')"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<BaseSettingsHeader
|
||||||
|
:title="t('CAPTAIN_SETTINGS.TITLE')"
|
||||||
|
:description="t('CAPTAIN_SETTINGS.DESCRIPTION')"
|
||||||
|
:link-text="t('CAPTAIN_SETTINGS.LINK_TEXT')"
|
||||||
|
icon-name="captain"
|
||||||
|
feature-name="captain_billing"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #body>
|
||||||
|
<div v-if="captainEnabled" class="flex flex-col gap-1">
|
||||||
|
<!-- Model Configuration Section -->
|
||||||
|
<SectionLayout
|
||||||
|
:title="t('CAPTAIN_SETTINGS.MODEL_CONFIG.TITLE')"
|
||||||
|
:description="t('CAPTAIN_SETTINGS.MODEL_CONFIG.DESCRIPTION')"
|
||||||
|
>
|
||||||
|
<div class="grid gap-4">
|
||||||
|
<ModelSelector
|
||||||
|
v-for="feature in modelFeatures"
|
||||||
|
v-show="shouldShowFeature(feature)"
|
||||||
|
:key="feature.key"
|
||||||
|
:is-allowed="isFeatureAccessible(feature)"
|
||||||
|
:feature-key="feature.key"
|
||||||
|
:title="feature.title"
|
||||||
|
:description="feature.description"
|
||||||
|
@change="handleModelChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SectionLayout>
|
||||||
|
|
||||||
|
<!-- Features Section -->
|
||||||
|
<SectionLayout
|
||||||
|
:title="t('CAPTAIN_SETTINGS.FEATURES.TITLE')"
|
||||||
|
:description="t('CAPTAIN_SETTINGS.FEATURES.DESCRIPTION')"
|
||||||
|
with-border
|
||||||
|
>
|
||||||
|
<div class="grid gap-4">
|
||||||
|
<FeatureToggle
|
||||||
|
v-for="feature in featureToggles"
|
||||||
|
v-show="shouldShowFeature(feature)"
|
||||||
|
:key="feature.key"
|
||||||
|
:is-allowed="isFeatureAccessible(feature)"
|
||||||
|
:feature-key="feature.key"
|
||||||
|
@change="handleFeatureToggle"
|
||||||
|
@model-change="handleModelChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SectionLayout>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<CaptainPaywall />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</SettingsLayout>
|
||||||
|
</template>
|
||||||
@ -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,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@ -0,0 +1,144 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
import { useCaptainConfigStore } from 'dashboard/store/captain/preferences';
|
||||||
|
import Switch from 'dashboard/components-next/switch/Switch.vue';
|
||||||
|
import ModelDropdown from './ModelDropdown.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
featureKey: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
isAllowed: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['change', 'modelChange']);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const captainConfigStore = useCaptainConfigStore();
|
||||||
|
const { features } = storeToRefs(captainConfigStore);
|
||||||
|
|
||||||
|
const availableModels = computed(() =>
|
||||||
|
captainConfigStore.getModelsForFeature(props.featureKey)
|
||||||
|
);
|
||||||
|
|
||||||
|
const isEnabled = ref(false);
|
||||||
|
|
||||||
|
const featureConfig = computed(() => features.value[props.featureKey]);
|
||||||
|
|
||||||
|
const hasMultipleModels = computed(() => {
|
||||||
|
return availableModels.value && availableModels.value.length > 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
const showModelSelector = computed(() => {
|
||||||
|
return isEnabled.value && hasMultipleModels.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const title = computed(() => {
|
||||||
|
if (props.featureKey.toUpperCase() === 'AUDIO_TRANSCRIPTION') {
|
||||||
|
return t('CAPTAIN_SETTINGS.FEATURES.AUDIO_TRANSCRIPTION.TITLE');
|
||||||
|
}
|
||||||
|
if (props.featureKey.toUpperCase() === 'HELP_CENTER_SEARCH') {
|
||||||
|
return t('CAPTAIN_SETTINGS.FEATURES.HELP_CENTER_SEARCH.TITLE');
|
||||||
|
}
|
||||||
|
if (props.featureKey.toUpperCase() === 'LABEL_SUGGESTION') {
|
||||||
|
return t('CAPTAIN_SETTINGS.FEATURES.LABEL_SUGGESTION.TITLE');
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const description = computed(() => {
|
||||||
|
if (props.featureKey.toUpperCase() === 'AUDIO_TRANSCRIPTION') {
|
||||||
|
return t('CAPTAIN_SETTINGS.FEATURES.AUDIO_TRANSCRIPTION.DESCRIPTION');
|
||||||
|
}
|
||||||
|
if (props.featureKey.toUpperCase() === 'HELP_CENTER_SEARCH') {
|
||||||
|
return t('CAPTAIN_SETTINGS.FEATURES.HELP_CENTER_SEARCH.DESCRIPTION');
|
||||||
|
}
|
||||||
|
if (props.featureKey.toUpperCase() === 'LABEL_SUGGESTION') {
|
||||||
|
return t('CAPTAIN_SETTINGS.FEATURES.LABEL_SUGGESTION.DESCRIPTION');
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const modelTitle = computed(() => {
|
||||||
|
if (props.featureKey.toUpperCase() === 'AUDIO_TRANSCRIPTION') {
|
||||||
|
return t('CAPTAIN_SETTINGS.FEATURES.AUDIO_TRANSCRIPTION.MODEL_TITLE');
|
||||||
|
}
|
||||||
|
if (props.featureKey.toUpperCase() === 'HELP_CENTER_SEARCH') {
|
||||||
|
return t('CAPTAIN_SETTINGS.FEATURES.HELP_CENTER_SEARCH.MODEL_TITLE');
|
||||||
|
}
|
||||||
|
if (props.featureKey.toUpperCase() === 'LABEL_SUGGESTION') {
|
||||||
|
return t('CAPTAIN_SETTINGS.FEATURES.LABEL_SUGGESTION.MODEL_TITLE');
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const modelDescription = computed(() => {
|
||||||
|
if (props.featureKey.toUpperCase() === 'AUDIO_TRANSCRIPTION') {
|
||||||
|
return t('CAPTAIN_SETTINGS.FEATURES.AUDIO_TRANSCRIPTION.MODEL_DESCRIPTION');
|
||||||
|
}
|
||||||
|
if (props.featureKey.toUpperCase() === 'HELP_CENTER_SEARCH') {
|
||||||
|
return t('CAPTAIN_SETTINGS.FEATURES.HELP_CENTER_SEARCH.MODEL_DESCRIPTION');
|
||||||
|
}
|
||||||
|
if (props.featureKey.toUpperCase() === 'LABEL_SUGGESTION') {
|
||||||
|
return t('CAPTAIN_SETTINGS.FEATURES.LABEL_SUGGESTION.MODEL_DESCRIPTION');
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
featureConfig,
|
||||||
|
newConfig => {
|
||||||
|
if (newConfig !== undefined) {
|
||||||
|
isEnabled.value = !!newConfig.enabled;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleFeature = () => {
|
||||||
|
emit('change', { feature: props.featureKey, enabled: isEnabled.value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleModelChange = ({ feature, model }) => {
|
||||||
|
emit('modelChange', { feature, model });
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="p-4 rounded-xl border border-n-weak bg-n-solid-1 flex"
|
||||||
|
:class="{
|
||||||
|
'flex-col gap-3': showModelSelector,
|
||||||
|
'items-center justify-between gap-4': !showModelSelector,
|
||||||
|
'opacity-60 pointer-events-none': !isAllowed,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-4 flex-1">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h4 class="text-sm font-medium text-n-slate-12">{{ title }}</h4>
|
||||||
|
<p class="text-sm text-n-slate-11 mt-0.5">{{ description }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="isAllowed" class="flex-shrink-0">
|
||||||
|
<Switch v-model="isEnabled" @change="toggleFeature" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="showModelSelector && isAllowed"
|
||||||
|
class="flex gap-2 ps-8 relative before:content-[''] before:absolute before:w-0.5 before:h-1/2 before:top-0 before:start-3 before:bg-n-weak after:content-[''] after:absolute after:w-2.5 after:h-3 after:top-[calc(50%-6px)] after:start-3 after:border-b-[0.125rem] after:border-s-[0.125rem] after:rounded-es after:border-n-weak"
|
||||||
|
>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h4 class="text-sm font-medium text-n-slate-12">{{ modelTitle }}</h4>
|
||||||
|
<p class="text-sm text-n-slate-11 mt-0.5">{{ modelDescription }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<ModelDropdown :feature-key="featureKey" @change="handleModelChange" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@ -0,0 +1,153 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useAccount } from 'dashboard/composables/useAccount';
|
||||||
|
import { useCaptainConfigStore } from 'dashboard/store/captain/preferences';
|
||||||
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
|
import DropdownBody from 'dashboard/components-next/dropdown-menu/base/DropdownBody.vue';
|
||||||
|
import DropdownItem from 'dashboard/components-next/dropdown-menu/base/DropdownItem.vue';
|
||||||
|
import { provideDropdownContext } from 'dashboard/components-next/dropdown-menu/base/provider.js';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
featureKey: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['change']);
|
||||||
|
const { isOnChatwootCloud } = useAccount();
|
||||||
|
|
||||||
|
const PROVIDER_ICONS = {
|
||||||
|
openai: 'i-ri-openai-fill',
|
||||||
|
anthropic: 'i-ri-anthropic-line',
|
||||||
|
mistral: 'i-logos-mistral-icon',
|
||||||
|
gemini: 'i-woot-gemini',
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconForModel = model => {
|
||||||
|
return PROVIDER_ICONS[model.provider];
|
||||||
|
};
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const captainConfigStore = useCaptainConfigStore();
|
||||||
|
const isOpen = ref(false);
|
||||||
|
|
||||||
|
const availableModels = computed(() =>
|
||||||
|
captainConfigStore.getModelsForFeature(props.featureKey)
|
||||||
|
);
|
||||||
|
|
||||||
|
const recommendedModelId = computed(() =>
|
||||||
|
captainConfigStore.getDefaultModelForFeature(props.featureKey)
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedModel = computed(() =>
|
||||||
|
captainConfigStore.getSelectedModelForFeature(props.featureKey)
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedModelId = ref(null);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
selectedModel,
|
||||||
|
newSelected => {
|
||||||
|
if (newSelected) {
|
||||||
|
selectedModelId.value = newSelected;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedModelDetails = computed(() => {
|
||||||
|
if (!selectedModelId.value) return null;
|
||||||
|
return (
|
||||||
|
availableModels.value.find(m => m.id === selectedModelId.value) || null
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const getCreditLabel = model => {
|
||||||
|
const multiplier = model.credit_multiplier || 1;
|
||||||
|
return t('CAPTAIN_SETTINGS.MODEL_CONFIG.CREDITS_PER_MESSAGE', {
|
||||||
|
credits: multiplier,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleDropdown = () => {
|
||||||
|
isOpen.value = !isOpen.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDropdown = () => {
|
||||||
|
isOpen.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
provideDropdownContext({
|
||||||
|
isOpen,
|
||||||
|
toggle: () => toggleDropdown(),
|
||||||
|
closeMenu: closeDropdown,
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectModel = model => {
|
||||||
|
selectedModelId.value = model.id;
|
||||||
|
emit('change', { feature: props.featureKey, model: model.id });
|
||||||
|
closeDropdown();
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-on-clickaway="closeDropdown" class="relative flex-shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center gap-2 px-3 py-2 text-sm border rounded-lg border-n-weak dark:bg-n-solid-2 dark:hover:bg-n-solid-3 bg-n-alpha-2 hover:bg-n-alpha-1 min-w-[180px] justify-between"
|
||||||
|
@click="toggleDropdown"
|
||||||
|
>
|
||||||
|
<span v-if="selectedModelDetails" class="text-n-slate-12">
|
||||||
|
{{ selectedModelDetails.display_name }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="text-n-slate-10">
|
||||||
|
{{ t('CAPTAIN_SETTINGS.MODEL_CONFIG.SELECT_MODEL') }}
|
||||||
|
</span>
|
||||||
|
<Icon
|
||||||
|
icon="i-lucide-chevron-down"
|
||||||
|
class="size-4 text-n-slate-11 transition-transform"
|
||||||
|
:class="{ 'rotate-180': isOpen }"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<DropdownBody
|
||||||
|
v-if="isOpen"
|
||||||
|
class="absolute right-0 top-full mt-1 min-w-64 z-50 max-h-96 [&>ul]:max-h-96 [&>ul]:overflow-y-scroll"
|
||||||
|
>
|
||||||
|
<DropdownItem
|
||||||
|
v-for="model in availableModels"
|
||||||
|
:key="model.id"
|
||||||
|
:click="() => selectModel(model)"
|
||||||
|
class="rounded-lg dark:hover:bg-n-solid-3 hover:bg-n-alpha-1"
|
||||||
|
:class="{
|
||||||
|
'dark:bg-n-solid-3 bg-n-alpha-1': selectedModelId === model.id,
|
||||||
|
'pointer-events-none opacity-60': model.coming_soon,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="flex gap-2 w-full">
|
||||||
|
<Icon :icon="iconForModel(model)" class="size-4 flex-shrink-0" />
|
||||||
|
<div class="flex flex-col w-full text-left gap-1">
|
||||||
|
<div
|
||||||
|
class="text-sm w-full font-medium leading-none text-n-slate-12 flex items-baseline justify-between"
|
||||||
|
>
|
||||||
|
{{ model.display_name }}
|
||||||
|
<span
|
||||||
|
v-if="model.id === recommendedModelId"
|
||||||
|
class="text-[10px] uppercase text-n-iris-11 border border-1 border-n-iris-10 leading-none rounded-lg px-1 py-0.5"
|
||||||
|
>
|
||||||
|
{{ t('GENERAL.PREFERRED') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span v-if="model.coming_soon" class="text-xs text-n-slate-11">
|
||||||
|
{{ t('CAPTAIN_SETTINGS.MODEL_CONFIG.COMING_SOON') }}
|
||||||
|
</span>
|
||||||
|
<span v-else-if="isOnChatwootCloud" class="text-xs text-n-slate-11">
|
||||||
|
{{ getCreditLabel(model) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DropdownItem>
|
||||||
|
</DropdownBody>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@ -0,0 +1,47 @@
|
|||||||
|
<script setup>
|
||||||
|
import ModelDropdown from './ModelDropdown.vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
featureKey: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
isAllowed: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['change']);
|
||||||
|
|
||||||
|
const handleModelChange = ({ feature, model }) => {
|
||||||
|
emit('change', { feature, model });
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between gap-4 p-4 rounded-xl border border-n-weak bg-n-solid-1"
|
||||||
|
:class="{ 'opacity-60 pointer-events-none relative': !isAllowed }"
|
||||||
|
>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h4 class="text-sm font-medium text-n-slate-12">
|
||||||
|
{{ title }}
|
||||||
|
</h4>
|
||||||
|
<p class="text-sm text-n-slate-11 mt-0.5">{{ description }}</p>
|
||||||
|
</div>
|
||||||
|
<ModelDropdown
|
||||||
|
v-if="isAllowed"
|
||||||
|
:feature-key="featureKey"
|
||||||
|
@change="handleModelChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@ -24,6 +24,7 @@ import teams from './teams/teams.routes';
|
|||||||
import customRoles from './customRoles/customRole.routes';
|
import customRoles from './customRoles/customRole.routes';
|
||||||
import profile from './profile/profile.routes';
|
import profile from './profile/profile.routes';
|
||||||
import security from './security/security.routes';
|
import security from './security/security.routes';
|
||||||
|
import captain from './captain/captain.routes';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
routes: [
|
routes: [
|
||||||
@ -63,5 +64,6 @@ export default {
|
|||||||
...customRoles.routes,
|
...customRoles.routes,
|
||||||
...profile.routes,
|
...profile.routes,
|
||||||
...security.routes,
|
...security.routes,
|
||||||
|
...captain.routes,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
71
app/javascript/dashboard/store/captain/preferences.js
Normal file
71
app/javascript/dashboard/store/captain/preferences.js
Normal file
@ -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 || {};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -28,6 +28,7 @@ class Account < ApplicationRecord
|
|||||||
include Reportable
|
include Reportable
|
||||||
include Featurable
|
include Featurable
|
||||||
include CacheKeys
|
include CacheKeys
|
||||||
|
include CaptainFeaturable
|
||||||
|
|
||||||
SETTINGS_PARAMS_SCHEMA = {
|
SETTINGS_PARAMS_SCHEMA = {
|
||||||
'type': 'object',
|
'type': 'object',
|
||||||
@ -41,6 +42,30 @@ class Account < ApplicationRecord
|
|||||||
'conversation_required_attributes': {
|
'conversation_required_attributes': {
|
||||||
'type': %w[array null],
|
'type': %w[array null],
|
||||||
'items': { 'type': 'string' }
|
'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': [],
|
'required': [],
|
||||||
@ -59,7 +84,9 @@ class Account < ApplicationRecord
|
|||||||
attribute_resolver: ->(record) { record.settings }
|
attribute_resolver: ->(record) { record.settings }
|
||||||
|
|
||||||
store_accessor :settings, :auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting
|
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, :audio_transcriptions, :auto_resolve_label, :conversation_required_attributes
|
||||||
|
store_accessor :settings, :captain_models, :captain_features
|
||||||
|
|
||||||
has_many :account_users, dependent: :destroy_async
|
has_many :account_users, dependent: :destroy_async
|
||||||
has_many :agent_bot_inboxes, dependent: :destroy_async
|
has_many :agent_bot_inboxes, dependent: :destroy_async
|
||||||
|
|||||||
62
app/models/concerns/captain_featurable.rb
Normal file
62
app/models/concerns/captain_featurable.rb
Normal file
@ -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
|
||||||
@ -179,7 +179,7 @@
|
|||||||
type: secret
|
type: secret
|
||||||
- name: CAPTAIN_OPEN_AI_MODEL
|
- name: CAPTAIN_OPEN_AI_MODEL
|
||||||
display_title: 'OpenAI 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
|
locked: false
|
||||||
- name: CAPTAIN_OPEN_AI_ENDPOINT
|
- name: CAPTAIN_OPEN_AI_ENDPOINT
|
||||||
display_title: 'OpenAI API Endpoint (optional)'
|
display_title: 'OpenAI API Endpoint (optional)'
|
||||||
|
|||||||
117
config/llm.yml
Normal file
117
config/llm.yml
Normal file
@ -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
|
||||||
@ -55,6 +55,7 @@ Rails.application.routes.draw do
|
|||||||
post :bulk_create, on: :collection
|
post :bulk_create, on: :collection
|
||||||
end
|
end
|
||||||
namespace :captain do
|
namespace :captain do
|
||||||
|
resource :preferences, only: [:show, :update]
|
||||||
resources :assistants do
|
resources :assistants do
|
||||||
member do
|
member do
|
||||||
post :playground
|
post :playground
|
||||||
|
|||||||
@ -7,7 +7,7 @@
|
|||||||
#
|
#
|
||||||
# For all other LLM operations, use Llm::BaseAiService with RubyLLM instead.
|
# For all other LLM operations, use Llm::BaseAiService with RubyLLM instead.
|
||||||
class Llm::LegacyBaseOpenAiService
|
class Llm::LegacyBaseOpenAiService
|
||||||
DEFAULT_MODEL = 'gpt-4o-mini'
|
DEFAULT_MODEL = 'gpt-4.1-mini'
|
||||||
|
|
||||||
attr_reader :client, :model
|
attr_reader :client, :model
|
||||||
|
|
||||||
|
|||||||
41
lib/llm/models.rb
Normal file
41
lib/llm/models.rb
Normal file
@ -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
|
||||||
12
package.json
12
package.json
@ -111,13 +111,13 @@
|
|||||||
"wavesurfer.js": "7.8.6"
|
"wavesurfer.js": "7.8.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@egoist/tailwindcss-icons": "^1.8.1",
|
"@egoist/tailwindcss-icons": "^1.9.0",
|
||||||
"@histoire/plugin-vue": "0.17.15",
|
"@histoire/plugin-vue": "0.17.15",
|
||||||
"@iconify-json/logos": "^1.2.3",
|
"@iconify-json/logos": "^1.2.10",
|
||||||
"@iconify-json/lucide": "^1.2.68",
|
"@iconify-json/lucide": "^1.2.82",
|
||||||
"@iconify-json/ph": "^1.2.1",
|
"@iconify-json/ph": "^1.2.2",
|
||||||
"@iconify-json/ri": "^1.2.3",
|
"@iconify-json/ri": "^1.2.6",
|
||||||
"@iconify-json/teenyicons": "^1.2.1",
|
"@iconify-json/teenyicons": "^1.2.2",
|
||||||
"@intlify/eslint-plugin-vue-i18n": "^3.2.0",
|
"@intlify/eslint-plugin-vue-i18n": "^3.2.0",
|
||||||
"@size-limit/file": "^8.2.4",
|
"@size-limit/file": "^8.2.4",
|
||||||
"@vitest/coverage-v8": "3.0.5",
|
"@vitest/coverage-v8": "3.0.5",
|
||||||
|
|||||||
194
pnpm-lock.yaml
generated
194
pnpm-lock.yaml
generated
@ -249,26 +249,26 @@ importers:
|
|||||||
version: 7.8.6
|
version: 7.8.6
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@egoist/tailwindcss-icons':
|
'@egoist/tailwindcss-icons':
|
||||||
specifier: ^1.8.1
|
specifier: ^1.9.0
|
||||||
version: 1.8.1(tailwindcss@3.4.13)
|
version: 1.9.0(tailwindcss@3.4.13)
|
||||||
'@histoire/plugin-vue':
|
'@histoire/plugin-vue':
|
||||||
specifier: 0.17.15
|
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))
|
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':
|
'@iconify-json/logos':
|
||||||
specifier: ^1.2.3
|
specifier: ^1.2.10
|
||||||
version: 1.2.3
|
version: 1.2.10
|
||||||
'@iconify-json/lucide':
|
'@iconify-json/lucide':
|
||||||
specifier: ^1.2.68
|
specifier: ^1.2.82
|
||||||
version: 1.2.68
|
version: 1.2.82
|
||||||
'@iconify-json/ph':
|
'@iconify-json/ph':
|
||||||
specifier: ^1.2.1
|
specifier: ^1.2.2
|
||||||
version: 1.2.1
|
version: 1.2.2
|
||||||
'@iconify-json/ri':
|
'@iconify-json/ri':
|
||||||
specifier: ^1.2.3
|
specifier: ^1.2.6
|
||||||
version: 1.2.3
|
version: 1.2.6
|
||||||
'@iconify-json/teenyicons':
|
'@iconify-json/teenyicons':
|
||||||
specifier: ^1.2.1
|
specifier: ^1.2.2
|
||||||
version: 1.2.1
|
version: 1.2.2
|
||||||
'@intlify/eslint-plugin-vue-i18n':
|
'@intlify/eslint-plugin-vue-i18n':
|
||||||
specifier: ^3.2.0
|
specifier: ^3.2.0
|
||||||
version: 3.2.0(eslint@8.57.0)
|
version: 3.2.0(eslint@8.57.0)
|
||||||
@ -399,11 +399,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
|
resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
|
||||||
engines: {node: '>=6.0.0'}
|
engines: {node: '>=6.0.0'}
|
||||||
|
|
||||||
'@antfu/install-pkg@0.4.1':
|
'@antfu/install-pkg@1.1.0':
|
||||||
resolution: {integrity: sha512-T7yB5QNG29afhWVkVq7XeIMBa5U/vs9mX69YqayXypPRmYzUmzwnYltplHmPtZ4HPCn+sQKeXW8I47wCbuBOjw==}
|
resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==}
|
||||||
|
|
||||||
'@antfu/utils@0.7.10':
|
'@antfu/utils@8.1.1':
|
||||||
resolution: {integrity: sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==}
|
resolution: {integrity: sha512-Mex9nXf9vR6AhcXmMrlz/HVgYYZpVGJ6YlPgwl7UnaFpnshXs6EK/oa5Gpf3CzENMjkvEx2tQtntGnb7UtSTOQ==}
|
||||||
|
|
||||||
'@asamuzakjp/css-color@4.1.0':
|
'@asamuzakjp/css-color@4.1.0':
|
||||||
resolution: {integrity: sha512-9xiBAtLn4aNsa4mDnpovJvBn72tNEIACyvlqaNJ+ADemR+yeMJWnBudOi2qGDviJa7SwcDOU/TRh5dnET7qk0w==}
|
resolution: {integrity: sha512-9xiBAtLn4aNsa4mDnpovJvBn72tNEIACyvlqaNJ+ADemR+yeMJWnBudOi2qGDviJa7SwcDOU/TRh5dnET7qk0w==}
|
||||||
@ -700,8 +700,8 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
postcss-selector-parser: ^6.0.10
|
postcss-selector-parser: ^6.0.10
|
||||||
|
|
||||||
'@egoist/tailwindcss-icons@1.8.1':
|
'@egoist/tailwindcss-icons@1.9.0':
|
||||||
resolution: {integrity: sha512-hqZeASrhT6BOeaBHYDPB0yBH/zgMKqmm7y2Rsq0c4iRnNVv1RWEiXMBMJB38JsDMTHME450FKa/wvdaIhED+Iw==}
|
resolution: {integrity: sha512-xWA9cUy6hzlK7Y6TaoRIcwmilSXiTJ8rbXcEdf9uht7yzDgw/yIgF4rThIQMrpD2Y2v4od51+r2y6Z7GStanDQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
tailwindcss: '*'
|
tailwindcss: '*'
|
||||||
|
|
||||||
@ -961,29 +961,29 @@ packages:
|
|||||||
resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==}
|
resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==}
|
||||||
deprecated: Use @eslint/object-schema instead
|
deprecated: Use @eslint/object-schema instead
|
||||||
|
|
||||||
'@iconify-json/logos@1.2.3':
|
'@iconify-json/logos@1.2.10':
|
||||||
resolution: {integrity: sha512-JLHS5hgZP1b55EONAWNeqBUuriRfRNKWXK4cqYx0PpVaJfIIMiiMxFfvoQiX/bkE9XgkLhcKmDUqL3LXPdXPwQ==}
|
resolution: {integrity: sha512-qxaXKJ6fu8jzTMPQdHtNxlfx6tBQ0jXRbHZIYy5Ilh8Lx9US9FsAdzZWUR8MXV8PnWTKGDFO4ZZee9VwerCyMA==}
|
||||||
|
|
||||||
'@iconify-json/lucide@1.2.68':
|
'@iconify-json/lucide@1.2.82':
|
||||||
resolution: {integrity: sha512-lR5xNJdn2CT0iR7lM25G4SewBO4G2hbr3fTWOc3AE9BspflEcneh02E3l9TBaCU/JOHozTJevWLrxBGypD7Tng==}
|
resolution: {integrity: sha512-fHZWegspOZonl5GNTvOkHsjnTMdSslFh3EzpzUtRyLxO8bOonqk2OTU3hCl0k4VXzViMjqpRK3X1sotnuBXkFA==}
|
||||||
|
|
||||||
'@iconify-json/material-symbols@1.2.10':
|
'@iconify-json/material-symbols@1.2.10':
|
||||||
resolution: {integrity: sha512-GcZxhOFStM7Dk/oZvJSaW0tR/k6NwTq+KDzYgCNBDg52ktZuRa/gkjRiYooJm/8PAe9NBYxIx8XjS/wi4sasdQ==}
|
resolution: {integrity: sha512-GcZxhOFStM7Dk/oZvJSaW0tR/k6NwTq+KDzYgCNBDg52ktZuRa/gkjRiYooJm/8PAe9NBYxIx8XjS/wi4sasdQ==}
|
||||||
|
|
||||||
'@iconify-json/ph@1.2.1':
|
'@iconify-json/ph@1.2.2':
|
||||||
resolution: {integrity: sha512-x0DNfwWrS18dbsBYOq3XGiZnGz4CgRyC+YSl/TZvMQiKhIUl1woWqUbMYqqfMNUBzjyk7ulvaRovpRsIlqIf8g==}
|
resolution: {integrity: sha512-PgkEZNtqa8hBGjHXQa4pMwZa93hmfu8FUSjs/nv4oUU6yLsgv+gh9nu28Kqi8Fz9CCVu4hj1MZs9/60J57IzFw==}
|
||||||
|
|
||||||
'@iconify-json/ri@1.2.3':
|
'@iconify-json/ri@1.2.6':
|
||||||
resolution: {integrity: sha512-UVKofd5xkSevGd5K01pvO4NWsu+2C9spu+GxnMZUYymUiaWmpCAxtd22MFSpm6MGf0MP4GCwhDCo1Q8L8oZ9wg==}
|
resolution: {integrity: sha512-tGXRmXtb8oFu8DNg9MsS1pywKFgs9QZ4U6LBzUamBHaw3ePSiPd7ouE64gzHzfEcR16hgVaXoUa+XxD3BB0XOg==}
|
||||||
|
|
||||||
'@iconify-json/teenyicons@1.2.1':
|
'@iconify-json/teenyicons@1.2.2':
|
||||||
resolution: {integrity: sha512-PaVv+zrQEO6I/9YfEwxkJfYSrCIWyOoSv/ZOVgETsr0MOqN9k7ecnHF/lPrgpyCLkwLzPX7MyFm3/gmziwjSiw==}
|
resolution: {integrity: sha512-Do08DrvNpT+pKVeyFqn7nZiIviAAY8KbduSfpNKzE1bgVekAIJ/AAJtOBSUFpV4vTk+hXw195+jmCv8/0cJSKA==}
|
||||||
|
|
||||||
'@iconify/types@2.0.0':
|
'@iconify/types@2.0.0':
|
||||||
resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
|
resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
|
||||||
|
|
||||||
'@iconify/utils@2.1.32':
|
'@iconify/utils@2.3.0':
|
||||||
resolution: {integrity: sha512-LeifFZPPKu28O3AEDpYJNdEbvS4/ojAPyIW+pF/vUpJTYnbTiXUHkCh0bwgFRzKvdpb8H4Fbfd/742++MF4fPQ==}
|
resolution: {integrity: sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA==}
|
||||||
|
|
||||||
'@intlify/core-base@9.14.2':
|
'@intlify/core-base@9.14.2':
|
||||||
resolution: {integrity: sha512-DZyQ4Hk22sC81MP4qiCDuU+LdaYW91A6lCjq8AWPvY3+mGMzhGDfOCzvyR6YBQxtlPjFqMoFk9ylnNYRAQwXtQ==}
|
resolution: {integrity: sha512-DZyQ4Hk22sC81MP4qiCDuU+LdaYW91A6lCjq8AWPvY3+mGMzhGDfOCzvyR6YBQxtlPjFqMoFk9ylnNYRAQwXtQ==}
|
||||||
@ -1916,8 +1916,11 @@ packages:
|
|||||||
concat-map@0.0.1:
|
concat-map@0.0.1:
|
||||||
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
|
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
|
||||||
|
|
||||||
confbox@0.1.7:
|
confbox@0.1.8:
|
||||||
resolution: {integrity: sha512-uJcB/FKZtBMCJpK8MQji6bJHgu1tixKPxRLeGkNzBoOZzpnZUJm0jm2/sBDWcuBx1dYgxV4JU+g5hmNxCyAmdA==}
|
resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
|
||||||
|
|
||||||
|
confbox@0.2.2:
|
||||||
|
resolution: {integrity: sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==}
|
||||||
|
|
||||||
config-chain@1.1.13:
|
config-chain@1.1.13:
|
||||||
resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==}
|
resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==}
|
||||||
@ -2444,6 +2447,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-bFi65yM+xZgk+u/KRIpekdSYkTB5W1pEf0Lt8Q8Msh7b+eQ7LXVtIB1Bkm4fvclDEL1b2CZkMhv2mOeF8tMdkA==}
|
resolution: {integrity: sha512-bFi65yM+xZgk+u/KRIpekdSYkTB5W1pEf0Lt8Q8Msh7b+eQ7LXVtIB1Bkm4fvclDEL1b2CZkMhv2mOeF8tMdkA==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
||||||
|
exsolve@1.0.8:
|
||||||
|
resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==}
|
||||||
|
|
||||||
extend-shallow@2.0.1:
|
extend-shallow@2.0.1:
|
||||||
resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==}
|
resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@ -2636,6 +2642,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-OkToC372DtlQeje9/zHIo5CT8lRP/FUgEOKBEhU4e0abL7J7CD24fD9ohiLN5hagG/kWCYj4K5oaxxtj2Z0Dig==}
|
resolution: {integrity: sha512-OkToC372DtlQeje9/zHIo5CT8lRP/FUgEOKBEhU4e0abL7J7CD24fD9ohiLN5hagG/kWCYj4K5oaxxtj2Z0Dig==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
globals@15.15.0:
|
||||||
|
resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
globalthis@1.0.3:
|
globalthis@1.0.3:
|
||||||
resolution: {integrity: sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==}
|
resolution: {integrity: sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@ -3118,8 +3128,8 @@ packages:
|
|||||||
lit@2.2.6:
|
lit@2.2.6:
|
||||||
resolution: {integrity: sha512-K2vkeGABfSJSfkhqHy86ujchJs3NR9nW1bEEiV+bXDkbiQ60Tv5GUausYN2mXigZn8lC1qXuc46ArQRKYmumZw==}
|
resolution: {integrity: sha512-K2vkeGABfSJSfkhqHy86ujchJs3NR9nW1bEEiV+bXDkbiQ60Tv5GUausYN2mXigZn8lC1qXuc46ArQRKYmumZw==}
|
||||||
|
|
||||||
local-pkg@0.5.0:
|
local-pkg@1.1.2:
|
||||||
resolution: {integrity: sha512-ok6z3qlYyCDS4ZEU27HaU6x/xZa9Whf8jD4ptH5UZTQYZVYeb9bnZ3ojVhiJNLiXK1Hfc0GNbLXcmZ5plLDDBg==}
|
resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
|
|
||||||
locate-path@5.0.0:
|
locate-path@5.0.0:
|
||||||
@ -3298,8 +3308,8 @@ packages:
|
|||||||
mitt@3.0.1:
|
mitt@3.0.1:
|
||||||
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
|
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
|
||||||
|
|
||||||
mlly@1.7.1:
|
mlly@1.8.0:
|
||||||
resolution: {integrity: sha512-rrVRZRELyQzrIUAVMHxP97kv+G786pHmOKzuFII8zDYahFBS7qnHh2AlYSl1GAHhaMPCz6/oHjVMcfFYgFYHgA==}
|
resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==}
|
||||||
|
|
||||||
mpd-parser@0.21.0:
|
mpd-parser@0.21.0:
|
||||||
resolution: {integrity: sha512-NbpMJ57qQzFmfCiP1pbL7cGMbVTD0X1hqNgL0VYP1wLlZXLf/HtmvQpNkOA1AHkPVeGQng+7/jEtSvNUzV7Gdg==}
|
resolution: {integrity: sha512-NbpMJ57qQzFmfCiP1pbL7cGMbVTD0X1hqNgL0VYP1wLlZXLf/HtmvQpNkOA1AHkPVeGQng+7/jEtSvNUzV7Gdg==}
|
||||||
@ -3481,8 +3491,8 @@ packages:
|
|||||||
package-json-from-dist@1.0.0:
|
package-json-from-dist@1.0.0:
|
||||||
resolution: {integrity: sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==}
|
resolution: {integrity: sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==}
|
||||||
|
|
||||||
package-manager-detector@0.2.0:
|
package-manager-detector@1.6.0:
|
||||||
resolution: {integrity: sha512-E385OSk9qDcXhcM9LNSe4sdhx8a9mAPrZ4sMLW+tmxl5ZuGtPUcdFu+MPP2jbgiWAZ6Pfe5soGFMd+0Db5Vrog==}
|
resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==}
|
||||||
|
|
||||||
param-case@3.0.4:
|
param-case@3.0.4:
|
||||||
resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==}
|
resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==}
|
||||||
@ -3540,6 +3550,9 @@ packages:
|
|||||||
pathe@2.0.2:
|
pathe@2.0.2:
|
||||||
resolution: {integrity: sha512-15Ztpk+nov8DR524R4BF7uEuzESgzUEAV4Ah7CUMNGXdE5ELuvxElxGXndBl32vMSsWa1jpNf22Z+Er3sKwq+w==}
|
resolution: {integrity: sha512-15Ztpk+nov8DR524R4BF7uEuzESgzUEAV4Ah7CUMNGXdE5ELuvxElxGXndBl32vMSsWa1jpNf22Z+Er3sKwq+w==}
|
||||||
|
|
||||||
|
pathe@2.0.3:
|
||||||
|
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||||
|
|
||||||
pathval@2.0.0:
|
pathval@2.0.0:
|
||||||
resolution: {integrity: sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==}
|
resolution: {integrity: sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==}
|
||||||
engines: {node: '>= 14.16'}
|
engines: {node: '>= 14.16'}
|
||||||
@ -3590,8 +3603,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-Ie9z/WINcxxLp27BKOCHGde4ITq9UklYKDzVo1nhk5sqGEXU3FpkwP5GM2voTGJkGd9B3Otl+Q4uwSOeSUtOBA==}
|
resolution: {integrity: sha512-Ie9z/WINcxxLp27BKOCHGde4ITq9UklYKDzVo1nhk5sqGEXU3FpkwP5GM2voTGJkGd9B3Otl+Q4uwSOeSUtOBA==}
|
||||||
engines: {node: '>=14.16'}
|
engines: {node: '>=14.16'}
|
||||||
|
|
||||||
pkg-types@1.2.0:
|
pkg-types@1.3.1:
|
||||||
resolution: {integrity: sha512-+ifYuSSqOQ8CqP4MbZA5hDpb97n3E8SVWdJe+Wms9kj745lmd3b7EZJiqvmLwAlmRfjrI7Hi5z3kdBJ93lFNPA==}
|
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
|
||||||
|
|
||||||
|
pkg-types@2.3.0:
|
||||||
|
resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==}
|
||||||
|
|
||||||
pngjs@5.0.0:
|
pngjs@5.0.0:
|
||||||
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
|
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
|
||||||
@ -3899,6 +3915,9 @@ packages:
|
|||||||
engines: {node: '>=10.13.0'}
|
engines: {node: '>=10.13.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
quansync@0.2.11:
|
||||||
|
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
|
||||||
|
|
||||||
querystringify@2.2.0:
|
querystringify@2.2.0:
|
||||||
resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==}
|
resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==}
|
||||||
|
|
||||||
@ -4290,6 +4309,10 @@ packages:
|
|||||||
tinyexec@0.3.2:
|
tinyexec@0.3.2:
|
||||||
resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
|
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:
|
tinykeys@3.0.0:
|
||||||
resolution: {integrity: sha512-nazawuGv5zx6MuDfDY0rmfXjuOGhD5XU2z0GLURQ1nzl0RUe9OuCJq+0u8xxJZINHe+mr7nw8PWYYZ9WhMFujw==}
|
resolution: {integrity: sha512-nazawuGv5zx6MuDfDY0rmfXjuOGhD5XU2z0GLURQ1nzl0RUe9OuCJq+0u8xxJZINHe+mr7nw8PWYYZ9WhMFujw==}
|
||||||
|
|
||||||
@ -4409,8 +4432,8 @@ packages:
|
|||||||
uc.micro@2.1.0:
|
uc.micro@2.1.0:
|
||||||
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
|
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
|
||||||
|
|
||||||
ufo@1.5.4:
|
ufo@1.6.1:
|
||||||
resolution: {integrity: sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==}
|
resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==}
|
||||||
|
|
||||||
unbox-primitive@1.0.2:
|
unbox-primitive@1.0.2:
|
||||||
resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==}
|
resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==}
|
||||||
@ -4910,12 +4933,12 @@ snapshots:
|
|||||||
'@jridgewell/gen-mapping': 0.3.8
|
'@jridgewell/gen-mapping': 0.3.8
|
||||||
'@jridgewell/trace-mapping': 0.3.25
|
'@jridgewell/trace-mapping': 0.3.25
|
||||||
|
|
||||||
'@antfu/install-pkg@0.4.1':
|
'@antfu/install-pkg@1.1.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
package-manager-detector: 0.2.0
|
package-manager-detector: 1.6.0
|
||||||
tinyexec: 0.3.2
|
tinyexec: 1.0.2
|
||||||
|
|
||||||
'@antfu/utils@0.7.10': {}
|
'@antfu/utils@8.1.1': {}
|
||||||
|
|
||||||
'@asamuzakjp/css-color@4.1.0':
|
'@asamuzakjp/css-color@4.1.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
@ -5233,9 +5256,9 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
postcss-selector-parser: 6.1.1
|
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:
|
dependencies:
|
||||||
'@iconify/utils': 2.1.32
|
'@iconify/utils': 2.3.0
|
||||||
tailwindcss: 3.4.13
|
tailwindcss: 3.4.13
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
@ -5490,11 +5513,11 @@ snapshots:
|
|||||||
|
|
||||||
'@humanwhocodes/object-schema@2.0.3': {}
|
'@humanwhocodes/object-schema@2.0.3': {}
|
||||||
|
|
||||||
'@iconify-json/logos@1.2.3':
|
'@iconify-json/logos@1.2.10':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@iconify/types': 2.0.0
|
'@iconify/types': 2.0.0
|
||||||
|
|
||||||
'@iconify-json/lucide@1.2.68':
|
'@iconify-json/lucide@1.2.82':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@iconify/types': 2.0.0
|
'@iconify/types': 2.0.0
|
||||||
|
|
||||||
@ -5502,29 +5525,30 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@iconify/types': 2.0.0
|
'@iconify/types': 2.0.0
|
||||||
|
|
||||||
'@iconify-json/ph@1.2.1':
|
'@iconify-json/ph@1.2.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@iconify/types': 2.0.0
|
'@iconify/types': 2.0.0
|
||||||
|
|
||||||
'@iconify-json/ri@1.2.3':
|
'@iconify-json/ri@1.2.6':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@iconify/types': 2.0.0
|
'@iconify/types': 2.0.0
|
||||||
|
|
||||||
'@iconify-json/teenyicons@1.2.1':
|
'@iconify-json/teenyicons@1.2.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@iconify/types': 2.0.0
|
'@iconify/types': 2.0.0
|
||||||
|
|
||||||
'@iconify/types@2.0.0': {}
|
'@iconify/types@2.0.0': {}
|
||||||
|
|
||||||
'@iconify/utils@2.1.32':
|
'@iconify/utils@2.3.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@antfu/install-pkg': 0.4.1
|
'@antfu/install-pkg': 1.1.0
|
||||||
'@antfu/utils': 0.7.10
|
'@antfu/utils': 8.1.1
|
||||||
'@iconify/types': 2.0.0
|
'@iconify/types': 2.0.0
|
||||||
debug: 4.4.0
|
debug: 4.4.3
|
||||||
|
globals: 15.15.0
|
||||||
kolorist: 1.8.0
|
kolorist: 1.8.0
|
||||||
local-pkg: 0.5.0
|
local-pkg: 1.1.2
|
||||||
mlly: 1.7.1
|
mlly: 1.8.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
@ -6196,8 +6220,7 @@ snapshots:
|
|||||||
|
|
||||||
acorn@8.14.0: {}
|
acorn@8.14.0: {}
|
||||||
|
|
||||||
acorn@8.15.0:
|
acorn@8.15.0: {}
|
||||||
optional: true
|
|
||||||
|
|
||||||
activestorage@5.2.8:
|
activestorage@5.2.8:
|
||||||
dependencies:
|
dependencies:
|
||||||
@ -6598,7 +6621,9 @@ snapshots:
|
|||||||
|
|
||||||
concat-map@0.0.1: {}
|
concat-map@0.0.1: {}
|
||||||
|
|
||||||
confbox@0.1.7: {}
|
confbox@0.1.8: {}
|
||||||
|
|
||||||
|
confbox@0.2.2: {}
|
||||||
|
|
||||||
config-chain@1.1.13:
|
config-chain@1.1.13:
|
||||||
dependencies:
|
dependencies:
|
||||||
@ -7235,6 +7260,8 @@ snapshots:
|
|||||||
|
|
||||||
expect-type@1.1.0: {}
|
expect-type@1.1.0: {}
|
||||||
|
|
||||||
|
exsolve@1.0.8: {}
|
||||||
|
|
||||||
extend-shallow@2.0.1:
|
extend-shallow@2.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-extendable: 0.1.1
|
is-extendable: 0.1.1
|
||||||
@ -7450,6 +7477,8 @@ snapshots:
|
|||||||
|
|
||||||
globals@15.14.0: {}
|
globals@15.14.0: {}
|
||||||
|
|
||||||
|
globals@15.15.0: {}
|
||||||
|
|
||||||
globalthis@1.0.3:
|
globalthis@1.0.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
define-properties: 1.2.0
|
define-properties: 1.2.0
|
||||||
@ -8020,10 +8049,11 @@ snapshots:
|
|||||||
lit-element: 3.3.3
|
lit-element: 3.3.3
|
||||||
lit-html: 2.8.0
|
lit-html: 2.8.0
|
||||||
|
|
||||||
local-pkg@0.5.0:
|
local-pkg@1.1.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
mlly: 1.7.1
|
mlly: 1.8.0
|
||||||
pkg-types: 1.2.0
|
pkg-types: 2.3.0
|
||||||
|
quansync: 0.2.11
|
||||||
|
|
||||||
locate-path@5.0.0:
|
locate-path@5.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
@ -8197,12 +8227,12 @@ snapshots:
|
|||||||
|
|
||||||
mitt@3.0.1: {}
|
mitt@3.0.1: {}
|
||||||
|
|
||||||
mlly@1.7.1:
|
mlly@1.8.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
acorn: 8.12.1
|
acorn: 8.15.0
|
||||||
pathe: 1.1.2
|
pathe: 2.0.3
|
||||||
pkg-types: 1.2.0
|
pkg-types: 1.3.1
|
||||||
ufo: 1.5.4
|
ufo: 1.6.1
|
||||||
|
|
||||||
mpd-parser@0.21.0:
|
mpd-parser@0.21.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
@ -8383,7 +8413,7 @@ snapshots:
|
|||||||
|
|
||||||
package-json-from-dist@1.0.0: {}
|
package-json-from-dist@1.0.0: {}
|
||||||
|
|
||||||
package-manager-detector@0.2.0: {}
|
package-manager-detector@1.6.0: {}
|
||||||
|
|
||||||
param-case@3.0.4:
|
param-case@3.0.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
@ -8435,6 +8465,8 @@ snapshots:
|
|||||||
|
|
||||||
pathe@2.0.2: {}
|
pathe@2.0.2: {}
|
||||||
|
|
||||||
|
pathe@2.0.3: {}
|
||||||
|
|
||||||
pathval@2.0.0: {}
|
pathval@2.0.0: {}
|
||||||
|
|
||||||
perfect-debounce@1.0.0: {}
|
perfect-debounce@1.0.0: {}
|
||||||
@ -8468,11 +8500,17 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
find-up: 6.3.0
|
find-up: 6.3.0
|
||||||
|
|
||||||
pkg-types@1.2.0:
|
pkg-types@1.3.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
confbox: 0.1.7
|
confbox: 0.1.8
|
||||||
mlly: 1.7.1
|
mlly: 1.8.0
|
||||||
pathe: 1.1.2
|
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: {}
|
pngjs@5.0.0: {}
|
||||||
|
|
||||||
@ -8843,6 +8881,8 @@ snapshots:
|
|||||||
pngjs: 5.0.0
|
pngjs: 5.0.0
|
||||||
yargs: 15.4.1
|
yargs: 15.4.1
|
||||||
|
|
||||||
|
quansync@0.2.11: {}
|
||||||
|
|
||||||
querystringify@2.2.0: {}
|
querystringify@2.2.0: {}
|
||||||
|
|
||||||
queue-microtask@1.2.3: {}
|
queue-microtask@1.2.3: {}
|
||||||
@ -9307,6 +9347,8 @@ snapshots:
|
|||||||
|
|
||||||
tinyexec@0.3.2: {}
|
tinyexec@0.3.2: {}
|
||||||
|
|
||||||
|
tinyexec@1.0.2: {}
|
||||||
|
|
||||||
tinykeys@3.0.0: {}
|
tinykeys@3.0.0: {}
|
||||||
|
|
||||||
tinypool@1.0.2: {}
|
tinypool@1.0.2: {}
|
||||||
@ -9437,7 +9479,7 @@ snapshots:
|
|||||||
|
|
||||||
uc.micro@2.1.0: {}
|
uc.micro@2.1.0: {}
|
||||||
|
|
||||||
ufo@1.5.4: {}
|
ufo@1.6.1: {}
|
||||||
|
|
||||||
unbox-primitive@1.0.2:
|
unbox-primitive@1.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
@ -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
|
||||||
@ -218,4 +218,59 @@ RSpec.describe Account do
|
|||||||
end
|
end
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
134
spec/models/concerns/captain_featurable_spec.rb
Normal file
134
spec/models/concerns/captain_featurable_spec.rb
Normal file
@ -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
|
||||||
101
theme/icons.js
101
theme/icons.js
@ -184,5 +184,106 @@ export const icons = {
|
|||||||
width: 14,
|
width: 14,
|
||||||
height: 14,
|
height: 14,
|
||||||
},
|
},
|
||||||
|
gemini: {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
body: `<defs>
|
||||||
|
<filter id="SVGqoIxVV2h" width="39.274" height="43.217" x="-19.824" y="13.152" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse">
|
||||||
|
<feFlood flood-opacity="0" result="BackgroundImageFix" />
|
||||||
|
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||||
|
<feGaussianBlur result="effect1_foregroundBlur_10859_4895" stdDeviation="2.46" />
|
||||||
|
</filter>
|
||||||
|
<filter id="SVGOahAkcjC" width="84.868" height="85.688" x="-15.001" y="-40.257" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse">
|
||||||
|
<feFlood flood-opacity="0" result="BackgroundImageFix" />
|
||||||
|
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||||
|
<feGaussianBlur result="effect1_foregroundBlur_10859_4895" stdDeviation="11.891" />
|
||||||
|
</filter>
|
||||||
|
<filter id="SVGyT4fLePl" width="79.454" height="90.917" x="-20.776" y="11.927" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse">
|
||||||
|
<feFlood flood-opacity="0" result="BackgroundImageFix" />
|
||||||
|
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||||
|
<feGaussianBlur result="effect1_foregroundBlur_10859_4895" stdDeviation="10.109" />
|
||||||
|
</filter>
|
||||||
|
<filter id="SVGonSETbRF" width="79.731" height="81.505" x="-19.845" y="15.459" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse">
|
||||||
|
<feFlood flood-opacity="0" result="BackgroundImageFix" />
|
||||||
|
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||||
|
<feGaussianBlur result="effect1_foregroundBlur_10859_4895" stdDeviation="10.109" />
|
||||||
|
</filter>
|
||||||
|
<filter id="SVGSN7ofz6B" width="75.117" height="73.758" x="29.832" y="-11.552" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse">
|
||||||
|
<feFlood flood-opacity="0" result="BackgroundImageFix" />
|
||||||
|
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||||
|
<feGaussianBlur result="effect1_foregroundBlur_10859_4895" stdDeviation="9.606" />
|
||||||
|
</filter>
|
||||||
|
<filter id="SVGHvbpPvOn" width="78.135" height="78.758" x="-38.583" y="-16.253" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse">
|
||||||
|
<feFlood flood-opacity="0" result="BackgroundImageFix" />
|
||||||
|
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||||
|
<feGaussianBlur result="effect1_foregroundBlur_10859_4895" stdDeviation="8.706" />
|
||||||
|
</filter>
|
||||||
|
<filter id="SVG7JmfweRd" width="78.877" height="77.539" x="8.107" y="-5.966" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse">
|
||||||
|
<feFlood flood-opacity="0" result="BackgroundImageFix" />
|
||||||
|
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||||
|
<feGaussianBlur result="effect1_foregroundBlur_10859_4895" stdDeviation="7.775" />
|
||||||
|
</filter>
|
||||||
|
<filter id="SVGgGkiybCN" width="56.272" height="51.81" x="13.587" y="-18.488" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse">
|
||||||
|
<feFlood flood-opacity="0" result="BackgroundImageFix" />
|
||||||
|
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||||
|
<feGaussianBlur result="effect1_foregroundBlur_10859_4895" stdDeviation="6.957" />
|
||||||
|
</filter>
|
||||||
|
<filter id="SVGxEY6lcrm" width="70.856" height="69.306" x="-15.526" y="-31.297" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse">
|
||||||
|
<feFlood flood-opacity="0" result="BackgroundImageFix" />
|
||||||
|
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||||
|
<feGaussianBlur result="effect1_foregroundBlur_10859_4895" stdDeviation="5.876" />
|
||||||
|
</filter>
|
||||||
|
<filter id="SVGg29FyG4g" width="55.501" height="51.571" x="-14.168" y="20.964" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse">
|
||||||
|
<feFlood flood-opacity="0" result="BackgroundImageFix" />
|
||||||
|
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||||
|
<feGaussianBlur result="effect1_foregroundBlur_10859_4895" stdDeviation="7.273" />
|
||||||
|
</filter>
|
||||||
|
<linearGradient id="SVGlOwgwsgJ" x1="18.447" x2="52.153" y1="43.42" y2="15.004" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#4893fc" />
|
||||||
|
<stop offset=".27" stop-color="#4893fc" />
|
||||||
|
<stop offset=".777" stop-color="#969dff" />
|
||||||
|
<stop offset="1" stop-color="#bd99fe" />
|
||||||
|
</linearGradient>
|
||||||
|
<mask id="SVGbqsmycuA" width="65" height="65" x="0" y="0" maskUnits="userSpaceOnUse">
|
||||||
|
<path d="M32.447 0c.68 0 1.272.465 1.438 1.125a39 39 0 0 0 2 5.905q3.23 7.5 8.854 13.125q5.626 5.626 13.125 8.855a39 39 0 0 0 5.905 1.999c.66.166 1.125.758 1.125 1.438s-.464 1.273-1.125 1.439a39 39 0 0 0-5.905 1.999q-7.5 3.23-13.125 8.854q-5.625 5.627-8.854 13.125a39 39 0 0 0-2 5.906a1.485 1.485 0 0 1-1.438 1.124c-.68 0-1.272-.464-1.438-1.125a39 39 0 0 0-2-5.905q-3.228-7.5-8.854-13.125T7.03 35.885a39 39 0 0 0-5.905-2A1.485 1.485 0 0 1 0 32.448c0-.68.465-1.272 1.125-1.438a39 39 0 0 0 5.905-2q7.5-3.229 13.125-8.854C25.78 14.53 26.857 12.03 29.01 7.03a39 39 0 0 0 1.999-5.905A1.485 1.485 0 0 1 32.447 0" />
|
||||||
|
<path fill="url(#SVGlOwgwsgJ)" d="M32.447 0c.68 0 1.272.465 1.438 1.125a39 39 0 0 0 2 5.905q3.23 7.5 8.854 13.125q5.626 5.626 13.125 8.855a39 39 0 0 0 5.905 1.999c.66.166 1.125.758 1.125 1.438s-.464 1.273-1.125 1.439a39 39 0 0 0-5.905 1.999q-7.5 3.23-13.125 8.854q-5.625 5.627-8.854 13.125a39 39 0 0 0-2 5.906a1.485 1.485 0 0 1-1.438 1.124c-.68 0-1.272-.464-1.438-1.125a39 39 0 0 0-2-5.905q-3.228-7.5-8.854-13.125T7.03 35.885a39 39 0 0 0-5.905-2A1.485 1.485 0 0 1 0 32.448c0-.68.465-1.272 1.125-1.438a39 39 0 0 0 5.905-2q7.5-3.229 13.125-8.854C25.78 14.53 26.857 12.03 29.01 7.03a39 39 0 0 0 1.999-5.905A1.485 1.485 0 0 1 32.447 0" />
|
||||||
|
</mask>
|
||||||
|
</defs>
|
||||||
|
<g mask="url(#SVGbqsmycuA)" transform="translate(2.15 2.15)scale(.42687)">
|
||||||
|
<g filter="url(#SVGqoIxVV2h)">
|
||||||
|
<ellipse cx="14.407" cy="16.95" fill="#ffe432" rx="14.407" ry="16.95" transform="rotate(19.551 -44.575 -16.496)" />
|
||||||
|
</g>
|
||||||
|
<g filter="url(#SVGOahAkcjC)">
|
||||||
|
<ellipse cx="27.433" cy="2.587" fill="#fc413d" rx="18.652" ry="19.062" />
|
||||||
|
</g>
|
||||||
|
<g filter="url(#SVGyT4fLePl)">
|
||||||
|
<ellipse cx="18.951" cy="57.386" fill="#00b95c" rx="19.493" ry="25.253" transform="rotate(-2.799 18.951 57.386)" />
|
||||||
|
</g>
|
||||||
|
<g filter="url(#SVGyT4fLePl)">
|
||||||
|
<ellipse cx="18.951" cy="57.386" fill="#00b95c" rx="19.493" ry="25.253" transform="rotate(-2.799 18.951 57.386)" />
|
||||||
|
</g>
|
||||||
|
<g filter="url(#SVGonSETbRF)">
|
||||||
|
<ellipse cx="20.02" cy="56.211" fill="#00b95c" rx="19.107" ry="21.034" transform="rotate(-31.318 20.02 56.211)" />
|
||||||
|
</g>
|
||||||
|
<g filter="url(#SVGSN7ofz6B)">
|
||||||
|
<ellipse cx="67.391" cy="25.327" fill="#3186ff" rx="18.346" ry="17.667" />
|
||||||
|
</g>
|
||||||
|
<g filter="url(#SVGHvbpPvOn)">
|
||||||
|
<ellipse cx="21.222" cy="22.384" fill="#fbbc04" rx="21.222" ry="22.384" transform="rotate(37.252 9.752 -8.009)" />
|
||||||
|
</g>
|
||||||
|
<g filter="url(#SVG7JmfweRd)">
|
||||||
|
<ellipse cx="24.469" cy="22.604" fill="#3186ff" rx="24.469" ry="22.604" transform="rotate(34.51 19.587 64.852)" />
|
||||||
|
</g>
|
||||||
|
<g filter="url(#SVGgGkiybCN)">
|
||||||
|
<path fill="#749bff" d="M54.984-2.336c2.833 3.852-.807 11.34-8.13 16.728c-7.325 5.386-15.558 6.63-18.39 2.779c-2.834-3.852.806-11.341 8.13-16.728c7.323-5.387 15.557-6.631 18.39-2.78z" />
|
||||||
|
</g>
|
||||||
|
<g filter="url(#SVGxEY6lcrm)">
|
||||||
|
<ellipse cx="19.902" cy="3.356" fill="#fc413d" rx="27.971" ry="17.388" transform="rotate(-42.848 19.902 3.356)" />
|
||||||
|
</g>
|
||||||
|
<g filter="url(#SVGg29FyG4g)">
|
||||||
|
<ellipse cx="13.583" cy="46.75" fill="#ffee48" rx="14.989" ry="8.717" transform="rotate(35.592 13.583 46.75)" />
|
||||||
|
</g>
|
||||||
|
</g>`,
|
||||||
|
},
|
||||||
/** Ends */
|
/** Ends */
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user