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:
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',
|
||||
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'),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 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,
|
||||
],
|
||||
};
|
||||
|
||||
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 || {};
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user