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:
Shivam Mishra
2026-01-12 19:54:19 +05:30
committed by GitHub
parent ab83a663f0
commit 34b42a1ce1
27 changed files with 1608 additions and 86 deletions

View 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();

View File

@@ -485,6 +485,12 @@ const menuItems = computed(() => {
icon: 'i-lucide-briefcase',
to: accountScopedRoute('general_settings_index'),
},
// {
// name: 'Settings Captain',
// label: t('SIDEBAR.CAPTAIN_AI'),
// icon: 'i-woot-captain',
// to: accountScopedRoute('captain_settings_index'),
// },
{
name: 'Settings Agents',
label: t('SIDEBAR.AGENTS'),

View File

@@ -20,6 +20,7 @@ const FEATURE_HELP_URLS = {
webhook: 'https://chwt.app/hc/webhooks',
billing: 'https://chwt.app/pricing',
saml: 'https://chwt.app/hc/saml',
captain_billing: 'https://chwt.app/hc/captain_billing',
};
export function getHelpUrlForFeature(featureName) {

View File

@@ -7,6 +7,7 @@
},
"CLOSE": "Close",
"BETA": "Beta",
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it."
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it.",
"PREFERRED": "Preferred"
}
}

View File

@@ -403,6 +403,7 @@
"CANCEL_ANYTIME": "You can change or cancel your plan anytime"
},
"ENTERPRISE_PAYWALL": {
"AVAILABLE_ON": "Captain AI is only available in the Enterprise plans.",
"UPGRADE_PROMPT": "Upgrade your plan to get access to our assistants, copilot and more.",
"ASK_ADMIN": "Please reach out to your administrator for the upgrade."
},

View File

@@ -378,7 +378,56 @@
"INFO_SHORT": "Automatically mark offline when you aren't using the app."
},
"DOCS": "Read docs",
"SECURITY": "Security"
"SECURITY": "Security",
"CAPTAIN_AI": "Captain"
},
"CAPTAIN_SETTINGS": {
"TITLE": "Captain Settings",
"DESCRIPTION": "Configure your AI models and features for Captain. Captain follows a credit based billing, you will be charged credits for every action Captain takes based on the model selected.",
"LOADING": "Loading Captain configuration...",
"LINK_TEXT": "Learn more about Captain Credits",
"NOT_ENABLED": "Captain is not enabled for your account. Please upgrade your plan to access Captain features.",
"MODEL_CONFIG": {
"TITLE": "Model Configuration",
"DESCRIPTION": "Select AI models for different features.",
"SELECT_MODEL": "Select model",
"CREDITS_PER_MESSAGE": "{credits} credit/message",
"COMING_SOON": "Coming soon",
"EDITOR": {
"TITLE": "Editor Features",
"DESCRIPTION": "Powers smart compose, grammar corrections, tone adjustments, and content enhancement in your message editor."
},
"ASSISTANT": {
"TITLE": "Assistant",
"DESCRIPTION": "Handles automated responses, conversation summaries, and intelligent reply suggestions for customer interactions."
},
"COPILOT": {
"TITLE": "Co-pilot",
"DESCRIPTION": "Provides real-time contextual suggestions, knowledge base recommendations, and proactive insights during conversations."
}
},
"FEATURES": {
"TITLE": "Features",
"DESCRIPTION": "Enable or disable AI-powered features.",
"AUDIO_TRANSCRIPTION": {
"TITLE": "Audio Transcription",
"DESCRIPTION": "Automatically convert voice messages and call recordings into searchable text transcripts."
},
"HELP_CENTER_SEARCH": {
"TITLE": "Help Center Search Indexing",
"DESCRIPTION": "Use AI for context aware search inside your help center articles."
},
"LABEL_SUGGESTION": {
"TITLE": "Label Suggestion",
"DESCRIPTION": "Automatically suggest relevant labels and tags for conversations based on content analysis and context.",
"MODEL_TITLE": "Label Suggestion Model",
"MODEL_DESCRIPTION": "Select the AI model to use for analyzing conversations and suggesting appropriate labels"
}
},
"API": {
"SUCCESS": "Captain settings updated successfully.",
"ERROR": "Failed to update Captain settings. Please try again."
}
},
"BILLING_SETTINGS": {
"TITLE": "Billing",

View File

@@ -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>

View File

@@ -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,
],
},
},
],
},
],
};

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -24,6 +24,7 @@ import teams from './teams/teams.routes';
import customRoles from './customRoles/customRole.routes';
import profile from './profile/profile.routes';
import security from './security/security.routes';
import captain from './captain/captain.routes';
export default {
routes: [
@@ -63,5 +64,6 @@ export default {
...customRoles.routes,
...profile.routes,
...security.routes,
...captain.routes,
],
};

View 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 || {};
},
},
});