feat: add model selector for label suggestion
This commit is contained in:
@@ -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>
|
||||
|
||||
+112
-16
@@ -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>
|
||||
|
||||
+156
@@ -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>
|
||||
+5
-141
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user