feat: add model selector for label suggestion

This commit is contained in:
Shivam Mishra
2025-12-23 14:58:25 +05:30
parent 529f22954e
commit ac64840ad7
5 changed files with 277 additions and 166 deletions
@@ -419,7 +419,9 @@
},
"LABEL_SUGGESTION": {
"TITLE": "Label Suggestion",
"DESCRIPTION": "Automatically suggest relevant labels and tags for conversations based on content analysis and context."
"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": {
@@ -44,20 +44,14 @@ const modelFeatures = computed(() => [
const featureToggles = computed(() => [
{
key: 'audio_transcription',
title: t('CAPTAIN_SETTINGS.FEATURES.AUDIO_TRANSCRIPTION.TITLE'),
description: t('CAPTAIN_SETTINGS.FEATURES.AUDIO_TRANSCRIPTION.DESCRIPTION'),
enterprise: true,
},
{
key: 'help_center_search',
title: t('CAPTAIN_SETTINGS.FEATURES.HELP_CENTER_SEARCH.TITLE'),
description: t('CAPTAIN_SETTINGS.FEATURES.HELP_CENTER_SEARCH.DESCRIPTION'),
enterprise: true,
},
{
key: 'label_suggestion',
title: t('CAPTAIN_SETTINGS.FEATURES.LABEL_SUGGESTION.TITLE'),
description: t('CAPTAIN_SETTINGS.FEATURES.LABEL_SUGGESTION.DESCRIPTION'),
enterprise: true,
},
]);
@@ -145,9 +139,8 @@ onMounted(() => {
v-show="hasFeatureToggle(feature)"
:key="feature.key"
:feature-key="feature.key"
:title="feature.title"
:description="feature.description"
@change="handleFeatureToggle"
@model-change="handleModelChange"
/>
</div>
</SectionLayout>
@@ -1,31 +1,63 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useFunctionGetter, useMapGetter } from 'dashboard/composables/store';
import Switch from 'dashboard/components-next/switch/Switch.vue';
import ModelDropdown from './ModelDropdown.vue';
const props = defineProps({
featureKey: {
type: String,
required: true,
},
title: {
type: String,
required: true,
},
description: {
type: String,
required: true,
},
});
const emit = defineEmits(['change']);
const emit = defineEmits(['change', 'modelChange']);
const { t } = useI18n();
const features = useMapGetter('captainConfig/getFeatures');
const availableModels = useFunctionGetter(
'captainConfig/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(() => {
const key = `CAPTAIN_SETTINGS.FEATURES.${props.featureKey.toUpperCase()}.TITLE`;
// eslint-disable-next-line @intlify/vue-i18n/no-dynamic-keys
return t(key);
});
const description = computed(() => {
const key = `CAPTAIN_SETTINGS.FEATURES.${props.featureKey.toUpperCase()}.DESCRIPTION`;
// eslint-disable-next-line @intlify/vue-i18n/no-dynamic-keys
return t(key);
});
const modelTitle = computed(() => {
const key = `CAPTAIN_SETTINGS.FEATURES.${props.featureKey.toUpperCase()}.MODEL_TITLE`;
// eslint-disable-next-line @intlify/vue-i18n/no-dynamic-keys
return t(key);
});
const modelDescription = computed(() => {
const key = `CAPTAIN_SETTINGS.FEATURES.${props.featureKey.toUpperCase()}.MODEL_DESCRIPTION`;
// eslint-disable-next-line @intlify/vue-i18n/no-dynamic-keys
return t(key);
});
watch(
featureConfig,
newConfig => {
@@ -39,18 +71,82 @@ watch(
const toggleFeature = () => {
emit('change', { feature: props.featureKey, enabled: isEnabled.value });
};
const handleModelChange = ({ feature, model }) => {
emit('modelChange', { 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="p-4 rounded-xl border border-n-weak bg-n-solid-1"
:class="
showModelSelector
? 'flex flex-col gap-3'
: 'flex items-center justify-between gap-4'
"
>
<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 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 class="flex-shrink-0">
<Switch v-model="isEnabled" @change="toggleFeature" />
</div>
</div>
<div class="flex-shrink-0">
<Switch v-model="isEnabled" @change="toggleFeature" />
<div v-if="showModelSelector" class="flex gap-2 ps-8 model-selector-item">
<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>
<style scoped>
.model-selector-item {
position: relative;
}
.model-selector-item::before {
content: '';
position: absolute;
width: 0.125rem;
height: 50%;
top: 0;
left: 0.75rem;
@apply border-n-weak bg-n-weak;
}
.model-selector-item::after {
content: '';
position: absolute;
width: 10px;
height: 12px;
top: calc(50% - 6px);
left: 0.75rem;
border-bottom-width: 0.125rem;
border-left-width: 0.125rem;
border-right-width: 0px;
border-top-width: 0px;
border-radius: 0 0 0 4px;
@apply border-n-weak;
}
#app[dir='rtl'] .model-selector-item::before {
left: auto;
right: 0.75rem;
}
#app[dir='rtl'] .model-selector-item::after {
left: auto;
right: 0.75rem;
border-left-width: 0px;
border-right-width: 0.125rem;
border-radius: 0 0 4px 0;
}
</style>
@@ -0,0 +1,156 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useFunctionGetter } from 'dashboard/composables/store';
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 PROVIDER_ICONS = {
openai: 'i-logos-openai-icon',
anthropic: 'i-logos-anthropic-icon',
mistral: 'i-logos-mistral-icon',
gemini: 'i-woot-gemini',
};
const iconForModel = model => {
return PROVIDER_ICONS[model.provider];
};
const { t } = useI18n();
const isOpen = ref(false);
const availableModels = useFunctionGetter(
'captainConfig/getModelsForFeature',
props.featureKey
);
const recommendedModelId = useFunctionGetter(
'captainConfig/getDefaultModelForFeature',
props.featureKey
);
const selectedModel = useFunctionGetter(
'captainConfig/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 bg-n-solid-2 hover:bg-n-solid-3 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 hover:bg-n-alpha-1"
:class="{
'bg-n-alpha-2': 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 text-n-slate-11"
/>
<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 class="text-xs text-n-slate-11">
{{ getCreditLabel(model) }}
</span>
</div>
</div>
</DropdownItem>
</DropdownBody>
</div>
</template>
@@ -1,13 +1,7 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useFunctionGetter } from 'dashboard/composables/store';
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';
import ModelDropdown from './ModelDropdown.vue';
const props = defineProps({
defineProps({
featureKey: {
type: String,
required: true,
@@ -24,79 +18,8 @@ const props = defineProps({
const emit = defineEmits(['change']);
const PROVIDER_ICONS = {
openai: 'i-logos-openai-icon',
anthropic: 'i-logos-anthropic-icon',
mistral: 'i-logos-mistral-icon',
gemini: 'i-woot-gemini',
};
const iconForModel = model => {
return PROVIDER_ICONS[model.provider];
};
const { t } = useI18n();
const isOpen = ref(false);
const availableModels = useFunctionGetter(
'captainConfig/getModelsForFeature',
props.featureKey
);
const recommendedModelId = useFunctionGetter(
'captainConfig/getDefaultModelForFeature',
props.featureKey
);
const selectedModel = useFunctionGetter(
'captainConfig/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();
const handleModelChange = ({ feature, model }) => {
emit('change', { feature, model });
};
</script>
@@ -108,65 +31,6 @@ const selectModel = model => {
<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-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 bg-n-solid-2 hover:bg-n-solid-3 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 hover:bg-n-alpha-1"
:class="{
'bg-n-alpha-2': 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 text-n-slate-11"
/>
<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 class="text-xs text-n-slate-11">
{{ getCreditLabel(model) }}
</span>
</div>
</div>
</DropdownItem>
</DropdownBody>
</div>
<ModelDropdown :feature-key="featureKey" @change="handleModelChange" />
</div>
</template>