feat(copilot): finish configuration center
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { computed, onMounted, onUnmounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useCaptainConfigStore } from 'dashboard/store/captain/preferences';
|
||||
|
||||
import SettingsLayout from '../SettingsLayout.vue';
|
||||
@@ -11,147 +12,352 @@ import SectionLayout from '../account/components/SectionLayout.vue';
|
||||
import ModelSelector from './components/ModelSelector.vue';
|
||||
import FeatureToggle from './components/FeatureToggle.vue';
|
||||
import ProviderConfiguration from './components/ProviderConfiguration.vue';
|
||||
import BehaviorConfiguration from './components/BehaviorConfiguration.vue';
|
||||
import { isSuperAdminUser } from './utils';
|
||||
|
||||
const { t } = useI18n();
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
const captainConfigStore = useCaptainConfigStore();
|
||||
const { uiFlags, providerConfig } = storeToRefs(captainConfigStore);
|
||||
const isSavingProvider = ref(false);
|
||||
const {
|
||||
uiFlags,
|
||||
providerConfig,
|
||||
providerTestResult,
|
||||
embeddingReindexStatus,
|
||||
behavior,
|
||||
} = storeToRefs(captainConfigStore);
|
||||
let reindexTimer;
|
||||
|
||||
const isSuperAdmin = computed(() => {
|
||||
return isSuperAdminUser(currentUser.value);
|
||||
});
|
||||
const isLoading = computed(() => uiFlags.value.isFetching);
|
||||
const providerConfigured = computed(() =>
|
||||
Boolean(providerConfig.value.configured)
|
||||
);
|
||||
const providerHealth = computed(
|
||||
() => providerTestResult.value || providerConfig.value.health
|
||||
);
|
||||
const providerHealthy = computed(() => {
|
||||
const health = providerHealth.value;
|
||||
if (!health) return null;
|
||||
return Boolean(health.chat?.ok && health.embedding?.ok);
|
||||
});
|
||||
|
||||
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'),
|
||||
},
|
||||
{
|
||||
key: 'copilot',
|
||||
title: t('CAPTAIN_SETTINGS.MODEL_CONFIG.COPILOT.TITLE'),
|
||||
description: t('CAPTAIN_SETTINGS.MODEL_CONFIG.COPILOT.DESCRIPTION'),
|
||||
},
|
||||
{
|
||||
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'),
|
||||
},
|
||||
{
|
||||
key: 'copilot',
|
||||
title: t('CAPTAIN_SETTINGS.MODEL_CONFIG.COPILOT.TITLE'),
|
||||
description: t('CAPTAIN_SETTINGS.MODEL_CONFIG.COPILOT.DESCRIPTION'),
|
||||
},
|
||||
]);
|
||||
|
||||
const featureToggles = computed(() => [
|
||||
{
|
||||
key: 'label_suggestion',
|
||||
},
|
||||
{
|
||||
key: 'help_center_search',
|
||||
},
|
||||
{
|
||||
key: 'audio_transcription',
|
||||
},
|
||||
]);
|
||||
const featureToggles = [
|
||||
{ key: 'label_suggestion' },
|
||||
{ key: 'help_center_search' },
|
||||
];
|
||||
|
||||
const errorMessage = error => {
|
||||
const payload = error.response?.data?.error;
|
||||
return payload?.message || payload || t('CAPTAIN_SETTINGS.API.ERROR');
|
||||
};
|
||||
|
||||
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();
|
||||
}
|
||||
try {
|
||||
await captainConfigStore.updateAccountConfig({
|
||||
captain_features: { [feature]: enabled },
|
||||
});
|
||||
useAlert(t('CAPTAIN_SETTINGS.API.ACCOUNT_SUCCESS'));
|
||||
} catch (error) {
|
||||
useAlert(errorMessage(error));
|
||||
await captainConfigStore.fetch({ includePlatform: isSuperAdmin.value });
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
try {
|
||||
await captainConfigStore.updateAccountConfig({
|
||||
captain_models: { [feature]: model },
|
||||
});
|
||||
useAlert(t('CAPTAIN_SETTINGS.API.ACCOUNT_SUCCESS'));
|
||||
} catch (error) {
|
||||
useAlert(errorMessage(error));
|
||||
await captainConfigStore.fetch({ includePlatform: isSuperAdmin.value });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBehaviorSave(nextBehavior) {
|
||||
try {
|
||||
await captainConfigStore.updateAccountConfig({
|
||||
behavior: nextBehavior,
|
||||
});
|
||||
useAlert(t('CAPTAIN_SETTINGS.API.ACCOUNT_SUCCESS'));
|
||||
} catch (error) {
|
||||
useAlert(errorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleProviderSave(config) {
|
||||
isSavingProvider.value = true;
|
||||
try {
|
||||
await captainConfigStore.updatePreferences({ provider_config: config });
|
||||
useAlert(t('CAPTAIN_SETTINGS.API.SUCCESS'));
|
||||
} catch (error) {
|
||||
useAlert(error.response?.data?.error || t('CAPTAIN_SETTINGS.API.ERROR'));
|
||||
} finally {
|
||||
isSavingProvider.value = false;
|
||||
}
|
||||
try {
|
||||
await captainConfigStore.updatePlatformConfig(config);
|
||||
useAlert(t('CAPTAIN_SETTINGS.API.PROVIDER_SUCCESS'));
|
||||
} catch (error) {
|
||||
useAlert(errorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
captainConfigStore.fetch();
|
||||
async function handleProviderTest(config) {
|
||||
try {
|
||||
const result = await captainConfigStore.testPlatformConfig(config);
|
||||
const ok = result?.chat?.ok && result?.embedding?.ok;
|
||||
useAlert(
|
||||
ok
|
||||
? t('CAPTAIN_SETTINGS.API.TEST_SUCCESS')
|
||||
: t('CAPTAIN_SETTINGS.API.TEST_ERROR')
|
||||
);
|
||||
} catch (error) {
|
||||
useAlert(errorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
function stopReindexPolling() {
|
||||
if (!reindexTimer) return;
|
||||
window.clearInterval(reindexTimer);
|
||||
reindexTimer = undefined;
|
||||
}
|
||||
|
||||
function startReindexPolling() {
|
||||
stopReindexPolling();
|
||||
reindexTimer = window.setInterval(async () => {
|
||||
try {
|
||||
const status =
|
||||
await captainConfigStore.fetchEmbeddingReindexStatus();
|
||||
if (!status?.running) {
|
||||
stopReindexPolling();
|
||||
useAlert(t('CAPTAIN_SETTINGS.API.REINDEX_COMPLETE'));
|
||||
}
|
||||
} catch (error) {
|
||||
stopReindexPolling();
|
||||
}
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
async function handleEmbeddingReindex() {
|
||||
if (!window.confirm(t('CAPTAIN_SETTINGS.PROVIDER.REINDEX_CONFIRM'))) return;
|
||||
try {
|
||||
await captainConfigStore.startEmbeddingReindex();
|
||||
useAlert(t('CAPTAIN_SETTINGS.API.REINDEX_STARTED'));
|
||||
startReindexPolling();
|
||||
} catch (error) {
|
||||
useAlert(errorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await captainConfigStore.fetch({ includePlatform: isSuperAdmin.value });
|
||||
if (embeddingReindexStatus.value?.running) startReindexPolling();
|
||||
} catch (error) {
|
||||
useAlert(errorMessage(error));
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(stopReindexPolling);
|
||||
</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')"
|
||||
icon-name="captain"
|
||||
/>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="flex flex-col gap-1">
|
||||
<SectionLayout
|
||||
:title="t('CAPTAIN_SETTINGS.PROVIDER.TITLE')"
|
||||
:description="t('CAPTAIN_SETTINGS.PROVIDER.DESCRIPTION')"
|
||||
>
|
||||
<ProviderConfiguration
|
||||
:config="providerConfig"
|
||||
:is-saving="isSavingProvider"
|
||||
@save="handleProviderSave"
|
||||
/>
|
||||
</SectionLayout>
|
||||
<SettingsLayout
|
||||
:is-loading="isLoading"
|
||||
:no-records-message="t('CAPTAIN_SETTINGS.NOT_AVAILABLE')"
|
||||
:loading-message="t('CAPTAIN_SETTINGS.LOADING')"
|
||||
>
|
||||
<template #header>
|
||||
<BaseSettingsHeader
|
||||
:title="t('CAPTAIN_SETTINGS.TITLE')"
|
||||
:description="t('CAPTAIN_SETTINGS.DESCRIPTION')"
|
||||
/>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="flex flex-col gap-1">
|
||||
<SectionLayout
|
||||
:title="t('CAPTAIN_SETTINGS.STATUS.TITLE')"
|
||||
:description="t('CAPTAIN_SETTINGS.STATUS.DESCRIPTION')"
|
||||
>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div
|
||||
class="flex items-center justify-between gap-4 rounded-xl border border-n-weak bg-n-solid-1 p-4"
|
||||
>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-n-slate-12">
|
||||
{{
|
||||
t(
|
||||
'CAPTAIN_SETTINGS.STATUS.CONFIGURATION'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<p class="mt-0.5 text-xs text-n-slate-10">
|
||||
{{
|
||||
t(
|
||||
'CAPTAIN_SETTINGS.STATUS.ALWAYS_ENABLED'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<p
|
||||
v-if="providerConfigured"
|
||||
class="mt-1 text-xs text-n-slate-10"
|
||||
>
|
||||
{{
|
||||
t('CAPTAIN_SETTINGS.STATUS.APPLIED', {
|
||||
provider:
|
||||
providerConfig.chat?.provider,
|
||||
model: providerConfig.chat?.model,
|
||||
time: providerConfig.applied_at
|
||||
? new Date(
|
||||
providerConfig.applied_at
|
||||
).toLocaleString()
|
||||
: '-',
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
class="rounded-full px-2.5 py-1 text-xs font-medium"
|
||||
:class="
|
||||
providerConfigured
|
||||
? 'bg-n-teal-3 text-n-teal-11'
|
||||
: 'bg-n-amber-3 text-n-amber-11'
|
||||
"
|
||||
>
|
||||
{{
|
||||
providerConfigured
|
||||
? t(
|
||||
'CAPTAIN_SETTINGS.STATUS.CONFIGURED'
|
||||
)
|
||||
: t(
|
||||
'CAPTAIN_SETTINGS.STATUS.INCOMPLETE'
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center justify-between gap-4 rounded-xl border border-n-weak bg-n-solid-1 p-4"
|
||||
>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-n-slate-12">
|
||||
{{
|
||||
t('CAPTAIN_SETTINGS.STATUS.CONNECTION')
|
||||
}}
|
||||
</p>
|
||||
<p class="mt-0.5 text-xs text-n-slate-10">
|
||||
{{
|
||||
providerHealth?.tested_at
|
||||
? new Date(
|
||||
providerHealth.tested_at
|
||||
).toLocaleString()
|
||||
: t(
|
||||
'CAPTAIN_SETTINGS.STATUS.NOT_TESTED'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
class="rounded-full px-2.5 py-1 text-xs font-medium"
|
||||
:class="
|
||||
providerHealthy === true
|
||||
? 'bg-n-teal-3 text-n-teal-11'
|
||||
: providerHealthy === false
|
||||
? 'bg-n-ruby-3 text-n-ruby-11'
|
||||
: 'bg-n-alpha-2 text-n-slate-10'
|
||||
"
|
||||
>
|
||||
{{
|
||||
providerHealthy === true
|
||||
? t('CAPTAIN_SETTINGS.STATUS.CONNECTED')
|
||||
: providerHealthy === false
|
||||
? t('CAPTAIN_SETTINGS.STATUS.ERROR')
|
||||
: t(
|
||||
'CAPTAIN_SETTINGS.STATUS.UNKNOWN'
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</SectionLayout>
|
||||
|
||||
<!-- 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"
|
||||
:key="feature.key"
|
||||
is-allowed
|
||||
:feature-key="feature.key"
|
||||
:title="feature.title"
|
||||
:description="feature.description"
|
||||
@change="handleModelChange"
|
||||
/>
|
||||
</div>
|
||||
</SectionLayout>
|
||||
<SectionLayout
|
||||
:title="t('CAPTAIN_SETTINGS.PROVIDER.TITLE')"
|
||||
:description="t('CAPTAIN_SETTINGS.PROVIDER.DESCRIPTION')"
|
||||
>
|
||||
<ProviderConfiguration
|
||||
:config="providerConfig"
|
||||
:test-result="providerTestResult"
|
||||
:reindex-status="embeddingReindexStatus"
|
||||
:read-only="!isSuperAdmin"
|
||||
:is-saving="uiFlags.isSavingProvider"
|
||||
:is-testing="uiFlags.isTestingProvider"
|
||||
:is-starting-reindex="uiFlags.isStartingReindex"
|
||||
@save="handleProviderSave"
|
||||
@test="handleProviderTest"
|
||||
@reindex="handleEmbeddingReindex"
|
||||
/>
|
||||
</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"
|
||||
:key="feature.key"
|
||||
is-allowed
|
||||
:feature-key="feature.key"
|
||||
@change="handleFeatureToggle"
|
||||
@model-change="handleModelChange"
|
||||
/>
|
||||
</div>
|
||||
</SectionLayout>
|
||||
</div>
|
||||
</template>
|
||||
</SettingsLayout>
|
||||
<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"
|
||||
:key="feature.key"
|
||||
is-allowed
|
||||
:feature-key="feature.key"
|
||||
:title="feature.title"
|
||||
:description="feature.description"
|
||||
@change="handleModelChange"
|
||||
/>
|
||||
</div>
|
||||
</SectionLayout>
|
||||
|
||||
<SectionLayout
|
||||
:title="t('CAPTAIN_SETTINGS.FEATURES.TITLE')"
|
||||
:description="t('CAPTAIN_SETTINGS.FEATURES.DESCRIPTION')"
|
||||
>
|
||||
<div class="grid gap-4">
|
||||
<FeatureToggle
|
||||
v-for="feature in featureToggles"
|
||||
:key="feature.key"
|
||||
is-allowed
|
||||
:feature-key="feature.key"
|
||||
@change="handleFeatureToggle"
|
||||
@model-change="handleModelChange"
|
||||
/>
|
||||
</div>
|
||||
</SectionLayout>
|
||||
|
||||
<SectionLayout
|
||||
:title="t('CAPTAIN_SETTINGS.BEHAVIOR.TITLE')"
|
||||
:description="t('CAPTAIN_SETTINGS.BEHAVIOR.DESCRIPTION')"
|
||||
with-border
|
||||
>
|
||||
<BehaviorConfiguration
|
||||
:behavior="behavior"
|
||||
:is-saving="uiFlags.isSavingAccount"
|
||||
@save="handleBehaviorSave"
|
||||
/>
|
||||
</SectionLayout>
|
||||
</div>
|
||||
</template>
|
||||
</SettingsLayout>
|
||||
</template>
|
||||
|
||||
+24
-24
@@ -3,28 +3,28 @@ import SettingsWrapper from '../SettingsWrapper.vue';
|
||||
import Index from './Index.vue';
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/settings/captain'),
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
component: SettingsWrapper,
|
||||
props: {
|
||||
headerTitle: 'CAPTAIN_SETTINGS.TITLE',
|
||||
icon: 'i-lucide-bot',
|
||||
showNewButton: false,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'captain_settings_index',
|
||||
component: Index,
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
routes: [
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/settings/copilot'),
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
component: SettingsWrapper,
|
||||
props: {
|
||||
headerTitle: 'CAPTAIN_SETTINGS.TITLE',
|
||||
icon: 'i-lucide-bot',
|
||||
showNewButton: false,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'copilot_settings_index',
|
||||
component: Index,
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
describe('Copilot settings route', () => {
|
||||
it('uses the Copilot path and administrator permission without install gating', () => {
|
||||
const source = readFileSync(
|
||||
'app/javascript/dashboard/routes/dashboard/settings/captain/captain.routes.js',
|
||||
'utf8'
|
||||
);
|
||||
|
||||
expect(source).toContain('settings/copilot');
|
||||
expect(source).toContain("name: 'copilot_settings_index'");
|
||||
expect(source).toContain("permissions: ['administrator']");
|
||||
expect(source).not.toContain('featureFlag');
|
||||
expect(source).not.toContain('installationTypes');
|
||||
});
|
||||
});
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import BehaviorConfiguration from './BehaviorConfiguration.vue';
|
||||
|
||||
describe('BehaviorConfiguration', () => {
|
||||
it('requires confirmation before enabling automatic replies', async () => {
|
||||
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
const wrapper = mount(BehaviorConfiguration, {
|
||||
props: {
|
||||
behavior: {
|
||||
tone: 'professional',
|
||||
language: 'auto',
|
||||
max_response_length: 500,
|
||||
auto_reply_enabled: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await wrapper.get('[data-test-id="auto-reply"]').setValue(true);
|
||||
await wrapper.get('[data-test-id="save-behavior"]').trigger('click');
|
||||
|
||||
expect(confirm).toHaveBeenCalledOnce();
|
||||
expect(wrapper.emitted('save')).toBeUndefined();
|
||||
confirm.mockRestore();
|
||||
});
|
||||
|
||||
it('emits the complete behavior payload after confirmation', async () => {
|
||||
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
const wrapper = mount(BehaviorConfiguration, {
|
||||
props: { behavior: { max_response_length: 500 } },
|
||||
});
|
||||
|
||||
await wrapper.get('[data-test-id="auto-reply"]').setValue(true);
|
||||
await wrapper.get('[data-test-id="save-behavior"]').trigger('click');
|
||||
|
||||
expect(wrapper.emitted('save')[0][0]).toEqual(
|
||||
expect.objectContaining({
|
||||
tone: 'professional',
|
||||
language: 'auto',
|
||||
max_response_length: 500,
|
||||
auto_reply_enabled: true,
|
||||
})
|
||||
);
|
||||
confirm.mockRestore();
|
||||
});
|
||||
});
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
<script setup>
|
||||
import { reactive, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const props = defineProps({
|
||||
behavior: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
isSaving: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['save']);
|
||||
const { t } = useI18n();
|
||||
|
||||
const form = reactive({
|
||||
tone: 'professional',
|
||||
language: 'auto',
|
||||
maxResponseLength: 500,
|
||||
customPromptSuffix: '',
|
||||
autoLabelEnabled: false,
|
||||
autoFollowUpEnabled: false,
|
||||
autoReplyEnabled: false,
|
||||
});
|
||||
let originalAutoReplyEnabled = false;
|
||||
|
||||
watch(
|
||||
() => props.behavior,
|
||||
behavior => {
|
||||
form.tone = behavior?.tone || 'professional';
|
||||
form.language = behavior?.language || 'auto';
|
||||
form.maxResponseLength = behavior?.max_response_length || 500;
|
||||
form.customPromptSuffix = behavior?.custom_prompt_suffix || '';
|
||||
form.autoLabelEnabled = Boolean(behavior?.auto_label_enabled);
|
||||
form.autoFollowUpEnabled = Boolean(behavior?.auto_follow_up_enabled);
|
||||
form.autoReplyEnabled = Boolean(behavior?.auto_reply_enabled);
|
||||
originalAutoReplyEnabled = form.autoReplyEnabled;
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
);
|
||||
|
||||
const save = () => {
|
||||
if (form.autoReplyEnabled && !originalAutoReplyEnabled) {
|
||||
const confirmed = window.confirm(
|
||||
t('CAPTAIN_SETTINGS.BEHAVIOR.AUTO_REPLY_CONFIRM')
|
||||
);
|
||||
if (!confirmed) return;
|
||||
}
|
||||
emit('save', {
|
||||
tone: form.tone,
|
||||
language: form.language.trim() || 'auto',
|
||||
max_response_length: Number(form.maxResponseLength),
|
||||
custom_prompt_suffix: form.customPromptSuffix,
|
||||
auto_label_enabled: form.autoLabelEnabled,
|
||||
auto_follow_up_enabled: form.autoFollowUpEnabled,
|
||||
auto_reply_enabled: form.autoReplyEnabled,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid gap-4 rounded-xl border border-n-weak bg-n-solid-1 p-4">
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<label class="grid gap-1.5 text-sm text-n-slate-12">
|
||||
<span class="font-medium">{{
|
||||
t('CAPTAIN_SETTINGS.BEHAVIOR.TONE')
|
||||
}}</span>
|
||||
<select
|
||||
v-model="form.tone"
|
||||
class="w-full rounded-lg border border-n-weak bg-n-solid-2 px-3 py-2"
|
||||
>
|
||||
<option value="professional">
|
||||
{{ t('CAPTAIN_SETTINGS.BEHAVIOR.TONES.PROFESSIONAL') }}
|
||||
</option>
|
||||
<option value="friendly">
|
||||
{{ t('CAPTAIN_SETTINGS.BEHAVIOR.TONES.FRIENDLY') }}
|
||||
</option>
|
||||
<option value="casual">
|
||||
{{ t('CAPTAIN_SETTINGS.BEHAVIOR.TONES.CASUAL') }}
|
||||
</option>
|
||||
<option value="formal">
|
||||
{{ t('CAPTAIN_SETTINGS.BEHAVIOR.TONES.FORMAL') }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="grid gap-1.5 text-sm text-n-slate-12">
|
||||
<span class="font-medium">{{
|
||||
t('CAPTAIN_SETTINGS.BEHAVIOR.LANGUAGE')
|
||||
}}</span>
|
||||
<input
|
||||
v-model.trim="form.language"
|
||||
type="text"
|
||||
maxlength="10"
|
||||
class="w-full rounded-lg border border-n-weak bg-n-solid-2 px-3 py-2"
|
||||
:placeholder="
|
||||
t('CAPTAIN_SETTINGS.BEHAVIOR.LANGUAGE_PLACEHOLDER')
|
||||
"
|
||||
/>
|
||||
</label>
|
||||
<label class="grid gap-1.5 text-sm text-n-slate-12">
|
||||
<span class="font-medium">{{
|
||||
t('CAPTAIN_SETTINGS.BEHAVIOR.MAX_LENGTH')
|
||||
}}</span>
|
||||
<input
|
||||
v-model.number="form.maxResponseLength"
|
||||
type="number"
|
||||
min="50"
|
||||
max="5000"
|
||||
class="w-full rounded-lg border border-n-weak bg-n-solid-2 px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="grid gap-1.5 text-sm text-n-slate-12">
|
||||
<span class="font-medium">{{
|
||||
t('CAPTAIN_SETTINGS.BEHAVIOR.CUSTOM_PROMPT')
|
||||
}}</span>
|
||||
<textarea
|
||||
v-model="form.customPromptSuffix"
|
||||
rows="4"
|
||||
maxlength="1000"
|
||||
class="w-full resize-y rounded-lg border border-n-weak bg-n-solid-2 px-3 py-2"
|
||||
:placeholder="
|
||||
t('CAPTAIN_SETTINGS.BEHAVIOR.CUSTOM_PROMPT_PLACEHOLDER')
|
||||
"
|
||||
/>
|
||||
<span class="text-xs text-n-slate-10">
|
||||
{{ t('CAPTAIN_SETTINGS.BEHAVIOR.CUSTOM_PROMPT_HELP') }}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div class="grid gap-3 md:grid-cols-3">
|
||||
<label
|
||||
class="flex items-start gap-3 rounded-lg border border-n-weak p-3 text-sm text-n-slate-12"
|
||||
>
|
||||
<input
|
||||
v-model="form.autoLabelEnabled"
|
||||
type="checkbox"
|
||||
class="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
<strong class="block font-medium">{{
|
||||
t('CAPTAIN_SETTINGS.BEHAVIOR.AUTO_LABEL')
|
||||
}}</strong>
|
||||
<span class="text-xs text-n-slate-10">{{
|
||||
t('CAPTAIN_SETTINGS.BEHAVIOR.AUTO_LABEL_HELP')
|
||||
}}</span>
|
||||
</span>
|
||||
</label>
|
||||
<label
|
||||
class="flex items-start gap-3 rounded-lg border border-n-weak p-3 text-sm text-n-slate-12"
|
||||
>
|
||||
<input
|
||||
v-model="form.autoFollowUpEnabled"
|
||||
type="checkbox"
|
||||
class="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
<strong class="block font-medium">{{
|
||||
t('CAPTAIN_SETTINGS.BEHAVIOR.AUTO_FOLLOW_UP')
|
||||
}}</strong>
|
||||
<span class="text-xs text-n-slate-10">{{
|
||||
t('CAPTAIN_SETTINGS.BEHAVIOR.AUTO_FOLLOW_UP_HELP')
|
||||
}}</span>
|
||||
</span>
|
||||
</label>
|
||||
<label
|
||||
class="flex items-start gap-3 rounded-lg border border-n-ruby-5 p-3 text-sm text-n-slate-12"
|
||||
>
|
||||
<input
|
||||
v-model="form.autoReplyEnabled"
|
||||
data-test-id="auto-reply"
|
||||
type="checkbox"
|
||||
class="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
<strong class="block font-medium">{{
|
||||
t('CAPTAIN_SETTINGS.BEHAVIOR.AUTO_REPLY')
|
||||
}}</strong>
|
||||
<span class="text-xs text-n-ruby-11">{{
|
||||
t('CAPTAIN_SETTINGS.BEHAVIOR.AUTO_REPLY_HELP')
|
||||
}}</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
data-test-id="save-behavior"
|
||||
class="rounded-lg bg-n-brand px-4 py-2 text-sm font-medium text-white hover:bg-n-brand/90 disabled:opacity-60"
|
||||
:disabled="isSaving"
|
||||
@click="save"
|
||||
>
|
||||
{{
|
||||
isSaving
|
||||
? t('CAPTAIN_SETTINGS.BEHAVIOR.SAVING')
|
||||
: t('CAPTAIN_SETTINGS.BEHAVIOR.SAVE')
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+87
-96
@@ -7,14 +7,14 @@ 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,
|
||||
},
|
||||
featureKey: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
isAllowed: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['change', 'modelChange']);
|
||||
@@ -23,122 +23,113 @@ 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;
|
||||
return isEnabled.value && props.featureKey === 'label_suggestion';
|
||||
});
|
||||
|
||||
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 '';
|
||||
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 '';
|
||||
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 '';
|
||||
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 '';
|
||||
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 }
|
||||
featureConfig,
|
||||
newConfig => {
|
||||
if (newConfig !== undefined) {
|
||||
isEnabled.value = !!newConfig.enabled;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const toggleFeature = () => {
|
||||
emit('change', { feature: props.featureKey, enabled: isEnabled.value });
|
||||
emit('change', { feature: props.featureKey, enabled: isEnabled.value });
|
||||
};
|
||||
|
||||
const handleModelChange = ({ feature, model }) => {
|
||||
emit('modelChange', { 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>
|
||||
<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>
|
||||
|
||||
+49
-129
@@ -1,153 +1,73 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { computed, ref, 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,
|
||||
},
|
||||
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)
|
||||
captainConfigStore.getModelsForFeature(props.featureKey)
|
||||
);
|
||||
|
||||
const recommendedModelId = computed(() =>
|
||||
captainConfigStore.getDefaultModelForFeature(props.featureKey)
|
||||
);
|
||||
|
||||
const selectedModel = computed(() =>
|
||||
captainConfigStore.getSelectedModelForFeature(props.featureKey)
|
||||
captainConfigStore.getSelectedModelForFeature(props.featureKey)
|
||||
);
|
||||
|
||||
const selectedModelId = ref(null);
|
||||
const modelId = ref('');
|
||||
const datalistId = computed(() => `copilot-models-${props.featureKey}`);
|
||||
|
||||
watch(
|
||||
selectedModel,
|
||||
newSelected => {
|
||||
if (newSelected) {
|
||||
selectedModelId.value = newSelected;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
selectedModel,
|
||||
value => {
|
||||
modelId.value = value || '';
|
||||
},
|
||||
{ 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 save = () => {
|
||||
const value = modelId.value.trim();
|
||||
if (!value || value === selectedModel.value) return;
|
||||
emit('change', { feature: props.featureKey, model: value });
|
||||
};
|
||||
</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>
|
||||
<div class="flex min-w-[240px] flex-shrink-0 gap-2">
|
||||
<div class="flex-1">
|
||||
<input
|
||||
v-model="modelId"
|
||||
type="text"
|
||||
:list="datalistId"
|
||||
class="w-full rounded-lg border border-n-weak bg-n-solid-2 px-3 py-2 text-sm text-n-slate-12"
|
||||
:placeholder="
|
||||
t('CAPTAIN_SETTINGS.MODEL_CONFIG.MODEL_ID_PLACEHOLDER')
|
||||
"
|
||||
@keyup.enter="save"
|
||||
/>
|
||||
<datalist :id="datalistId">
|
||||
<option
|
||||
v-for="model in availableModels"
|
||||
:key="model.id"
|
||||
:value="model.id"
|
||||
>
|
||||
{{ model.display_name }}
|
||||
</option>
|
||||
</datalist>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border border-n-weak bg-n-solid-2 px-3 py-2 text-sm font-medium text-n-slate-12 hover:bg-n-alpha-2 disabled:opacity-50"
|
||||
:disabled="!modelId.trim() || modelId.trim() === selectedModel"
|
||||
@click="save"
|
||||
>
|
||||
{{ t('CAPTAIN_SETTINGS.MODEL_CONFIG.APPLY') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import ProviderConfiguration from './ProviderConfiguration.vue';
|
||||
|
||||
const configuredProvider = {
|
||||
configured: true,
|
||||
chat: {
|
||||
provider: 'openai',
|
||||
base_url: 'https://api.openai.com/v1',
|
||||
model: 'gpt-4o-mini',
|
||||
api_key: { configured: true, masked: 'sec****-key' },
|
||||
},
|
||||
embedding: {
|
||||
mode: 'reuse_chat_credentials',
|
||||
provider: 'openai',
|
||||
base_url: 'https://api.openai.com/v1',
|
||||
model: 'text-embedding-3-small',
|
||||
dimensions: 1536,
|
||||
api_key: { configured: false },
|
||||
},
|
||||
generation: { temperature: 0.7, max_tokens: 1024 },
|
||||
request: { timeout_seconds: 60, max_retries: 3 },
|
||||
};
|
||||
|
||||
describe('ProviderConfiguration', () => {
|
||||
it('never fills the saved API key into the input', () => {
|
||||
const wrapper = mount(ProviderConfiguration, {
|
||||
props: { config: configuredProvider },
|
||||
});
|
||||
|
||||
expect(wrapper.get('[data-test-id="chat-api-key"]').element.value).toBe(
|
||||
''
|
||||
);
|
||||
expect(
|
||||
wrapper
|
||||
.get('[data-test-id="chat-api-key"]')
|
||||
.attributes('placeholder')
|
||||
).toContain('sec****-key');
|
||||
});
|
||||
|
||||
it('is read-only for account administrators', () => {
|
||||
const wrapper = mount(ProviderConfiguration, {
|
||||
props: { config: configuredProvider, readOnly: true },
|
||||
});
|
||||
|
||||
expect(wrapper.find('[data-test-id="save-provider"]').exists()).toBe(
|
||||
false
|
||||
);
|
||||
expect(wrapper.find('[data-test-id="test-provider"]').exists()).toBe(
|
||||
false
|
||||
);
|
||||
expect(wrapper.get('fieldset').attributes('disabled')).toBeDefined();
|
||||
});
|
||||
|
||||
it('rejects Anthropic with reused embedding credentials', async () => {
|
||||
const wrapper = mount(ProviderConfiguration, {
|
||||
props: { config: configuredProvider },
|
||||
});
|
||||
|
||||
await wrapper
|
||||
.get('[data-test-id="chat-provider"]')
|
||||
.setValue('anthropic');
|
||||
|
||||
expect(
|
||||
wrapper.find('[data-test-id="anthropic-embedding-error"]').exists()
|
||||
).toBe(true);
|
||||
expect(
|
||||
wrapper.get('[data-test-id="save-provider"]').attributes('disabled')
|
||||
).toBeDefined();
|
||||
expect(
|
||||
wrapper.get('[data-test-id="test-provider"]').attributes('disabled')
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it('requires confirmation before saving changed embedding dimensions', async () => {
|
||||
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
const wrapper = mount(ProviderConfiguration, {
|
||||
props: { config: configuredProvider },
|
||||
});
|
||||
|
||||
await wrapper
|
||||
.get('[data-test-id="embedding-dimensions"]')
|
||||
.setValue(3072);
|
||||
await wrapper.get('[data-test-id="save-provider"]').trigger('click');
|
||||
|
||||
expect(confirm).toHaveBeenCalledOnce();
|
||||
expect(wrapper.emitted('save')).toBeUndefined();
|
||||
confirm.mockRestore();
|
||||
});
|
||||
});
|
||||
+593
-88
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { reactive, watch } from 'vue';
|
||||
import { computed, reactive, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -7,126 +7,631 @@ const props = defineProps({
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
testResult: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
reindexStatus: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
readOnly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isSaving: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isTesting: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isStartingReindex: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['save']);
|
||||
const emit = defineEmits(['save', 'test', 'reindex']);
|
||||
const { t } = useI18n();
|
||||
|
||||
const form = reactive({
|
||||
provider: 'openai',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
model: 'gpt-4o-mini',
|
||||
apiKey: '',
|
||||
});
|
||||
|
||||
const providerDefaults = {
|
||||
openai: 'https://api.openai.com/v1',
|
||||
anthropic: 'https://api.anthropic.com',
|
||||
openai_compatible: '',
|
||||
};
|
||||
|
||||
const defaultForm = () => ({
|
||||
chat: {
|
||||
provider: 'openai',
|
||||
baseUrl: providerDefaults.openai,
|
||||
model: 'gpt-4o-mini',
|
||||
apiKey: '',
|
||||
clearApiKey: false,
|
||||
},
|
||||
embedding: {
|
||||
mode: 'reuse_chat_credentials',
|
||||
provider: 'openai',
|
||||
baseUrl: providerDefaults.openai,
|
||||
model: 'text-embedding-3-small',
|
||||
dimensions: 1536,
|
||||
apiKey: '',
|
||||
clearApiKey: false,
|
||||
},
|
||||
generation: {
|
||||
temperature: 0.7,
|
||||
maxTokens: 1024,
|
||||
},
|
||||
request: {
|
||||
timeoutSeconds: 60,
|
||||
maxRetries: 3,
|
||||
},
|
||||
});
|
||||
|
||||
const form = reactive(defaultForm());
|
||||
let originalDimensions = 1536;
|
||||
|
||||
watch(
|
||||
() => props.config,
|
||||
config => {
|
||||
form.provider = config?.provider || 'openai';
|
||||
form.baseUrl =
|
||||
config?.base_url || providerDefaults[form.provider] || '';
|
||||
form.model = config?.model || 'gpt-4o-mini';
|
||||
form.apiKey = '';
|
||||
const defaults = defaultForm();
|
||||
const chat = config?.chat || {};
|
||||
const embedding = config?.embedding || {};
|
||||
const generation = config?.generation || {};
|
||||
const request = config?.request || {};
|
||||
|
||||
form.chat.provider = chat.provider || defaults.chat.provider;
|
||||
form.chat.baseUrl = chat.base_url ?? defaults.chat.baseUrl;
|
||||
form.chat.model = chat.model || defaults.chat.model;
|
||||
form.chat.apiKey = '';
|
||||
form.chat.clearApiKey = false;
|
||||
|
||||
form.embedding.mode = embedding.mode || defaults.embedding.mode;
|
||||
form.embedding.provider =
|
||||
embedding.provider || defaults.embedding.provider;
|
||||
form.embedding.baseUrl =
|
||||
embedding.base_url ?? defaults.embedding.baseUrl;
|
||||
form.embedding.model = embedding.model || defaults.embedding.model;
|
||||
form.embedding.dimensions =
|
||||
embedding.dimensions || defaults.embedding.dimensions;
|
||||
form.embedding.apiKey = '';
|
||||
form.embedding.clearApiKey = false;
|
||||
|
||||
form.generation.temperature =
|
||||
generation.temperature ?? defaults.generation.temperature;
|
||||
form.generation.maxTokens =
|
||||
generation.max_tokens || defaults.generation.maxTokens;
|
||||
form.request.timeoutSeconds =
|
||||
request.timeout_seconds || defaults.request.timeoutSeconds;
|
||||
form.request.maxRetries =
|
||||
request.max_retries ?? defaults.request.maxRetries;
|
||||
originalDimensions = form.embedding.dimensions;
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
);
|
||||
|
||||
const handleProviderChange = () => {
|
||||
form.baseUrl = providerDefaults[form.provider] || '';
|
||||
const isSeparateEmbedding = computed(() => form.embedding.mode === 'separate');
|
||||
const invalidAnthropicReuse = computed(
|
||||
() =>
|
||||
form.chat.provider === 'anthropic' &&
|
||||
form.embedding.mode === 'reuse_chat_credentials'
|
||||
);
|
||||
const hasChatCredential = computed(
|
||||
() =>
|
||||
Boolean(form.chat.apiKey.trim()) ||
|
||||
(Boolean(props.config?.chat?.api_key?.configured) &&
|
||||
!form.chat.clearApiKey)
|
||||
);
|
||||
const hasEmbeddingCredential = computed(
|
||||
() =>
|
||||
!isSeparateEmbedding.value ||
|
||||
Boolean(form.embedding.apiKey.trim()) ||
|
||||
(Boolean(props.config?.embedding?.api_key?.configured) &&
|
||||
!form.embedding.clearApiKey)
|
||||
);
|
||||
const isFormComplete = computed(
|
||||
() =>
|
||||
Boolean(
|
||||
form.chat.provider &&
|
||||
form.chat.baseUrl &&
|
||||
form.chat.model &&
|
||||
form.embedding.model &&
|
||||
form.embedding.dimensions >= 1 &&
|
||||
hasChatCredential.value &&
|
||||
hasEmbeddingCredential.value
|
||||
) &&
|
||||
(!isSeparateEmbedding.value ||
|
||||
Boolean(form.embedding.provider && form.embedding.baseUrl)) &&
|
||||
!invalidAnthropicReuse.value
|
||||
);
|
||||
const activeHealth = computed(() => props.testResult || props.config?.health);
|
||||
const reindexProgress = computed(() => {
|
||||
if (!props.reindexStatus?.total) return 0;
|
||||
return Math.min(
|
||||
100,
|
||||
Math.round(
|
||||
(props.reindexStatus.processed / props.reindexStatus.total) * 100
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
const changeChatProvider = event => {
|
||||
const nextProvider = event.target.value;
|
||||
const currentDefault = providerDefaults[form.chat.provider] || '';
|
||||
if (!form.chat.baseUrl || form.chat.baseUrl === currentDefault) {
|
||||
form.chat.baseUrl = providerDefaults[nextProvider] || '';
|
||||
}
|
||||
form.chat.provider = nextProvider;
|
||||
};
|
||||
|
||||
const save = () => {
|
||||
emit('save', {
|
||||
provider: form.provider,
|
||||
base_url: form.baseUrl,
|
||||
model: form.model,
|
||||
api_key: form.apiKey,
|
||||
});
|
||||
const changeEmbeddingProvider = event => {
|
||||
const nextProvider = event.target.value;
|
||||
const currentDefault = providerDefaults[form.embedding.provider] || '';
|
||||
if (!form.embedding.baseUrl || form.embedding.baseUrl === currentDefault) {
|
||||
form.embedding.baseUrl = providerDefaults[nextProvider] || '';
|
||||
}
|
||||
form.embedding.provider = nextProvider;
|
||||
};
|
||||
|
||||
const payload = () => ({
|
||||
chat: {
|
||||
provider: form.chat.provider,
|
||||
base_url: form.chat.baseUrl.trim(),
|
||||
model: form.chat.model.trim(),
|
||||
api_key: form.chat.apiKey.trim(),
|
||||
clear_api_key: form.chat.clearApiKey,
|
||||
},
|
||||
embedding: {
|
||||
mode: form.embedding.mode,
|
||||
provider: form.embedding.provider,
|
||||
base_url: form.embedding.baseUrl.trim(),
|
||||
model: form.embedding.model.trim(),
|
||||
dimensions: Number(form.embedding.dimensions),
|
||||
api_key: form.embedding.apiKey.trim(),
|
||||
clear_api_key: form.embedding.clearApiKey,
|
||||
},
|
||||
generation: {
|
||||
temperature: Number(form.generation.temperature),
|
||||
max_tokens: Number(form.generation.maxTokens),
|
||||
},
|
||||
request: {
|
||||
timeout_seconds: Number(form.request.timeoutSeconds),
|
||||
max_retries: Number(form.request.maxRetries),
|
||||
},
|
||||
});
|
||||
|
||||
const save = () => {
|
||||
if (form.embedding.dimensions !== originalDimensions) {
|
||||
const confirmed = window.confirm(
|
||||
t('CAPTAIN_SETTINGS.PROVIDER.DIMENSIONS_CONFIRM')
|
||||
);
|
||||
if (!confirmed) return;
|
||||
}
|
||||
if (form.chat.clearApiKey || form.embedding.clearApiKey) {
|
||||
const confirmed = window.confirm(
|
||||
t('CAPTAIN_SETTINGS.PROVIDER.CLEAR_KEY_CONFIRM')
|
||||
);
|
||||
if (!confirmed) return;
|
||||
}
|
||||
emit('save', payload());
|
||||
};
|
||||
|
||||
const test = () => emit('test', payload());
|
||||
|
||||
const resultClass = ok =>
|
||||
ok ? 'text-n-teal-11 bg-n-teal-3' : 'text-n-ruby-11 bg-n-ruby-3';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid gap-4 p-4 rounded-xl border border-n-weak bg-n-solid-1">
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<label class="grid gap-1.5 text-sm text-n-slate-12">
|
||||
<span class="font-medium">{{
|
||||
t('CAPTAIN_SETTINGS.PROVIDER.PROVIDER')
|
||||
}}</span>
|
||||
<select
|
||||
v-model="form.provider"
|
||||
class="w-full px-3 py-2 border rounded-lg border-n-weak bg-n-solid-2"
|
||||
@change="handleProviderChange"
|
||||
>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="openai_compatible">OpenAI Compatible</option>
|
||||
<option value="anthropic">Anthropic</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="grid gap-1.5 text-sm text-n-slate-12">
|
||||
<span class="font-medium">{{
|
||||
t('CAPTAIN_SETTINGS.PROVIDER.MODEL')
|
||||
}}</span>
|
||||
<input
|
||||
v-model.trim="form.model"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 border rounded-lg border-n-weak bg-n-solid-2"
|
||||
placeholder="gpt-4o-mini"
|
||||
/>
|
||||
</label>
|
||||
<div class="grid gap-5">
|
||||
<div
|
||||
v-if="readOnly"
|
||||
class="rounded-lg border border-n-weak bg-n-alpha-2 px-4 py-3 text-sm text-n-slate-11"
|
||||
>
|
||||
{{ t('CAPTAIN_SETTINGS.PROVIDER.READ_ONLY') }}
|
||||
</div>
|
||||
|
||||
<label class="grid gap-1.5 text-sm text-n-slate-12">
|
||||
<span class="font-medium">{{
|
||||
t('CAPTAIN_SETTINGS.PROVIDER.BASE_URL')
|
||||
}}</span>
|
||||
<input
|
||||
v-model.trim="form.baseUrl"
|
||||
type="url"
|
||||
class="w-full px-3 py-2 border rounded-lg border-n-weak bg-n-solid-2"
|
||||
placeholder="https://api.openai.com/v1"
|
||||
/>
|
||||
</label>
|
||||
<fieldset
|
||||
class="grid gap-4 rounded-xl border border-n-weak bg-n-solid-1 p-4"
|
||||
:disabled="readOnly"
|
||||
>
|
||||
<legend class="px-1 text-sm font-semibold text-n-slate-12">
|
||||
{{ t('CAPTAIN_SETTINGS.PROVIDER.CHAT_TITLE') }}
|
||||
</legend>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<label class="grid gap-1.5 text-sm text-n-slate-12">
|
||||
<span class="font-medium">{{
|
||||
t('CAPTAIN_SETTINGS.PROVIDER.PROVIDER')
|
||||
}}</span>
|
||||
<select
|
||||
:value="form.chat.provider"
|
||||
data-test-id="chat-provider"
|
||||
class="w-full rounded-lg border border-n-weak bg-n-solid-2 px-3 py-2"
|
||||
@change="changeChatProvider"
|
||||
>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="openai_compatible">
|
||||
OpenAI Compatible
|
||||
</option>
|
||||
<option value="anthropic">Anthropic</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="grid gap-1.5 text-sm text-n-slate-12">
|
||||
<span class="font-medium">{{
|
||||
t('CAPTAIN_SETTINGS.PROVIDER.MODEL')
|
||||
}}</span>
|
||||
<input
|
||||
v-model.trim="form.chat.model"
|
||||
type="text"
|
||||
class="w-full rounded-lg border border-n-weak bg-n-solid-2 px-3 py-2"
|
||||
placeholder="gpt-4o-mini"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label class="grid gap-1.5 text-sm text-n-slate-12">
|
||||
<span class="font-medium">{{
|
||||
t('CAPTAIN_SETTINGS.PROVIDER.BASE_URL')
|
||||
}}</span>
|
||||
<input
|
||||
v-model.trim="form.chat.baseUrl"
|
||||
type="url"
|
||||
class="w-full rounded-lg border border-n-weak bg-n-solid-2 px-3 py-2"
|
||||
placeholder="https://api.openai.com/v1"
|
||||
/>
|
||||
</label>
|
||||
<label class="grid gap-1.5 text-sm text-n-slate-12">
|
||||
<span class="font-medium">{{
|
||||
t('CAPTAIN_SETTINGS.PROVIDER.API_KEY')
|
||||
}}</span>
|
||||
<input
|
||||
v-model="form.chat.apiKey"
|
||||
data-test-id="chat-api-key"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
class="w-full rounded-lg border border-n-weak bg-n-solid-2 px-3 py-2"
|
||||
:placeholder="
|
||||
config?.chat?.api_key?.configured
|
||||
? t(
|
||||
'CAPTAIN_SETTINGS.PROVIDER.API_KEY_CONFIGURED',
|
||||
{
|
||||
masked:
|
||||
config.chat.api_key.masked ||
|
||||
t(
|
||||
'CAPTAIN_SETTINGS.PROVIDER.API_KEY_MASKED'
|
||||
),
|
||||
}
|
||||
)
|
||||
: t('CAPTAIN_SETTINGS.PROVIDER.API_KEY_PLACEHOLDER')
|
||||
"
|
||||
/>
|
||||
<span class="text-xs text-n-slate-10">{{
|
||||
t('CAPTAIN_SETTINGS.PROVIDER.API_KEY_HELP')
|
||||
}}</span>
|
||||
<label
|
||||
v-if="config?.chat?.api_key?.configured && !readOnly"
|
||||
class="flex items-center gap-2 text-xs text-n-ruby-11"
|
||||
>
|
||||
<input v-model="form.chat.clearApiKey" type="checkbox" />
|
||||
{{ t('CAPTAIN_SETTINGS.PROVIDER.CLEAR_CHAT_KEY') }}
|
||||
</label>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<label class="grid gap-1.5 text-sm text-n-slate-12">
|
||||
<span class="font-medium">{{
|
||||
t('CAPTAIN_SETTINGS.PROVIDER.API_KEY')
|
||||
}}</span>
|
||||
<input
|
||||
v-model="form.apiKey"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
class="w-full px-3 py-2 border rounded-lg border-n-weak bg-n-solid-2"
|
||||
:placeholder="
|
||||
config.api_key_configured
|
||||
? t('CAPTAIN_SETTINGS.PROVIDER.API_KEY_CONFIGURED', {
|
||||
masked: config.api_key_masked,
|
||||
})
|
||||
: t('CAPTAIN_SETTINGS.PROVIDER.API_KEY_PLACEHOLDER')
|
||||
"
|
||||
/>
|
||||
<span class="text-xs text-n-slate-10">
|
||||
{{ t('CAPTAIN_SETTINGS.PROVIDER.API_KEY_HELP') }}
|
||||
</span>
|
||||
</label>
|
||||
<fieldset
|
||||
class="grid gap-4 rounded-xl border border-n-weak bg-n-solid-1 p-4"
|
||||
:disabled="readOnly"
|
||||
>
|
||||
<legend class="px-1 text-sm font-semibold text-n-slate-12">
|
||||
{{ t('CAPTAIN_SETTINGS.PROVIDER.EMBEDDING_TITLE') }}
|
||||
</legend>
|
||||
<label class="grid gap-1.5 text-sm text-n-slate-12">
|
||||
<span class="font-medium">{{
|
||||
t('CAPTAIN_SETTINGS.PROVIDER.EMBEDDING_MODE')
|
||||
}}</span>
|
||||
<select
|
||||
v-model="form.embedding.mode"
|
||||
class="w-full rounded-lg border border-n-weak bg-n-solid-2 px-3 py-2"
|
||||
>
|
||||
<option value="reuse_chat_credentials">
|
||||
{{ t('CAPTAIN_SETTINGS.PROVIDER.REUSE_CHAT') }}
|
||||
</option>
|
||||
<option value="separate">
|
||||
{{ t('CAPTAIN_SETTINGS.PROVIDER.SEPARATE_EMBEDDING') }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<p
|
||||
v-if="invalidAnthropicReuse"
|
||||
data-test-id="anthropic-embedding-error"
|
||||
class="rounded-lg bg-n-ruby-3 px-3 py-2 text-sm text-n-ruby-11"
|
||||
>
|
||||
{{ t('CAPTAIN_SETTINGS.PROVIDER.ANTHROPIC_EMBEDDING_ERROR') }}
|
||||
</p>
|
||||
<div v-if="isSeparateEmbedding" class="grid gap-4 md:grid-cols-2">
|
||||
<label class="grid gap-1.5 text-sm text-n-slate-12">
|
||||
<span class="font-medium">{{
|
||||
t('CAPTAIN_SETTINGS.PROVIDER.EMBEDDING_PROVIDER')
|
||||
}}</span>
|
||||
<select
|
||||
:value="form.embedding.provider"
|
||||
class="w-full rounded-lg border border-n-weak bg-n-solid-2 px-3 py-2"
|
||||
@change="changeEmbeddingProvider"
|
||||
>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="openai_compatible">
|
||||
OpenAI Compatible
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="grid gap-1.5 text-sm text-n-slate-12">
|
||||
<span class="font-medium">{{
|
||||
t('CAPTAIN_SETTINGS.PROVIDER.EMBEDDING_BASE_URL')
|
||||
}}</span>
|
||||
<input
|
||||
v-model.trim="form.embedding.baseUrl"
|
||||
type="url"
|
||||
class="w-full rounded-lg border border-n-weak bg-n-solid-2 px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label
|
||||
v-if="isSeparateEmbedding"
|
||||
class="grid gap-1.5 text-sm text-n-slate-12"
|
||||
>
|
||||
<span class="font-medium">{{
|
||||
t('CAPTAIN_SETTINGS.PROVIDER.EMBEDDING_API_KEY')
|
||||
}}</span>
|
||||
<input
|
||||
v-model="form.embedding.apiKey"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
class="w-full rounded-lg border border-n-weak bg-n-solid-2 px-3 py-2"
|
||||
:placeholder="
|
||||
config?.embedding?.api_key?.configured
|
||||
? t(
|
||||
'CAPTAIN_SETTINGS.PROVIDER.API_KEY_CONFIGURED',
|
||||
{
|
||||
masked:
|
||||
config.embedding.api_key.masked ||
|
||||
t(
|
||||
'CAPTAIN_SETTINGS.PROVIDER.API_KEY_MASKED'
|
||||
),
|
||||
}
|
||||
)
|
||||
: t('CAPTAIN_SETTINGS.PROVIDER.API_KEY_PLACEHOLDER')
|
||||
"
|
||||
/>
|
||||
<label
|
||||
v-if="config?.embedding?.api_key?.configured && !readOnly"
|
||||
class="flex items-center gap-2 text-xs text-n-ruby-11"
|
||||
>
|
||||
<input
|
||||
v-model="form.embedding.clearApiKey"
|
||||
type="checkbox"
|
||||
/>
|
||||
{{ t('CAPTAIN_SETTINGS.PROVIDER.CLEAR_EMBEDDING_KEY') }}
|
||||
</label>
|
||||
</label>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<label class="grid gap-1.5 text-sm text-n-slate-12">
|
||||
<span class="font-medium">{{
|
||||
t('CAPTAIN_SETTINGS.PROVIDER.EMBEDDING_MODEL')
|
||||
}}</span>
|
||||
<input
|
||||
v-model.trim="form.embedding.model"
|
||||
type="text"
|
||||
class="w-full rounded-lg border border-n-weak bg-n-solid-2 px-3 py-2"
|
||||
placeholder="text-embedding-3-small"
|
||||
/>
|
||||
</label>
|
||||
<label class="grid gap-1.5 text-sm text-n-slate-12">
|
||||
<span class="font-medium">{{
|
||||
t('CAPTAIN_SETTINGS.PROVIDER.DIMENSIONS')
|
||||
}}</span>
|
||||
<input
|
||||
v-model.number="form.embedding.dimensions"
|
||||
data-test-id="embedding-dimensions"
|
||||
type="number"
|
||||
min="1"
|
||||
max="4096"
|
||||
class="w-full rounded-lg border border-n-weak bg-n-solid-2 px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<fieldset
|
||||
class="grid gap-4 rounded-xl border border-n-weak bg-n-solid-1 p-4"
|
||||
:disabled="readOnly"
|
||||
>
|
||||
<legend class="px-1 text-sm font-semibold text-n-slate-12">
|
||||
{{ t('CAPTAIN_SETTINGS.PROVIDER.RELIABILITY_TITLE') }}
|
||||
</legend>
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<label class="grid gap-1.5 text-sm text-n-slate-12">
|
||||
<span class="font-medium">Temperature</span>
|
||||
<input
|
||||
v-model.number="form.generation.temperature"
|
||||
type="number"
|
||||
min="0"
|
||||
max="2"
|
||||
step="0.1"
|
||||
class="w-full rounded-lg border border-n-weak bg-n-solid-2 px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
<label class="grid gap-1.5 text-sm text-n-slate-12">
|
||||
<span class="font-medium">{{
|
||||
t('CAPTAIN_SETTINGS.PROVIDER.MAX_TOKENS')
|
||||
}}</span>
|
||||
<input
|
||||
v-model.number="form.generation.maxTokens"
|
||||
type="number"
|
||||
min="64"
|
||||
max="32768"
|
||||
class="w-full rounded-lg border border-n-weak bg-n-solid-2 px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
<label class="grid gap-1.5 text-sm text-n-slate-12">
|
||||
<span class="font-medium">{{
|
||||
t('CAPTAIN_SETTINGS.PROVIDER.TIMEOUT')
|
||||
}}</span>
|
||||
<input
|
||||
v-model.number="form.request.timeoutSeconds"
|
||||
type="number"
|
||||
min="5"
|
||||
max="300"
|
||||
class="w-full rounded-lg border border-n-weak bg-n-solid-2 px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
<label class="grid gap-1.5 text-sm text-n-slate-12">
|
||||
<span class="font-medium">{{
|
||||
t('CAPTAIN_SETTINGS.PROVIDER.RETRIES')
|
||||
}}</span>
|
||||
<input
|
||||
v-model.number="form.request.maxRetries"
|
||||
type="number"
|
||||
min="0"
|
||||
max="5"
|
||||
class="w-full rounded-lg border border-n-weak bg-n-solid-2 px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div
|
||||
v-if="activeHealth"
|
||||
class="grid gap-3 rounded-xl border border-n-weak bg-n-solid-1 p-4 md:grid-cols-2"
|
||||
>
|
||||
<div class="grid gap-2">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="text-sm font-medium text-n-slate-12"
|
||||
>Chat</span
|
||||
>
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs font-medium"
|
||||
:class="resultClass(activeHealth.chat?.ok)"
|
||||
>
|
||||
{{
|
||||
activeHealth.chat?.ok
|
||||
? t('CAPTAIN_SETTINGS.STATUS.CONNECTED')
|
||||
: t('CAPTAIN_SETTINGS.STATUS.ERROR')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-n-slate-10">
|
||||
{{ activeHealth.chat?.model }} ·
|
||||
{{ activeHealth.chat?.latency_ms || 0 }}ms
|
||||
</p>
|
||||
<p
|
||||
v-if="activeHealth.chat?.error"
|
||||
class="text-xs text-n-ruby-11"
|
||||
>
|
||||
{{ activeHealth.chat.error }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="text-sm font-medium text-n-slate-12"
|
||||
>Embedding</span
|
||||
>
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs font-medium"
|
||||
:class="resultClass(activeHealth.embedding?.ok)"
|
||||
>
|
||||
{{
|
||||
activeHealth.embedding?.ok
|
||||
? t('CAPTAIN_SETTINGS.STATUS.CONNECTED')
|
||||
: t('CAPTAIN_SETTINGS.STATUS.ERROR')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-n-slate-10">
|
||||
{{ activeHealth.embedding?.model }} ·
|
||||
{{
|
||||
activeHealth.embedding?.dimensions ||
|
||||
form.embedding.dimensions
|
||||
}}D · {{ activeHealth.embedding?.latency_ms || 0 }}ms
|
||||
</p>
|
||||
<p
|
||||
v-if="activeHealth.embedding?.error"
|
||||
class="text-xs text-n-ruby-11"
|
||||
>
|
||||
{{ activeHealth.embedding.error }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!readOnly && config.configured"
|
||||
class="grid gap-3 rounded-xl border border-n-weak bg-n-solid-1 p-4"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('CAPTAIN_SETTINGS.PROVIDER.REINDEX_TITLE') }}
|
||||
</p>
|
||||
<p class="mt-0.5 text-xs text-n-slate-10">
|
||||
{{ t('CAPTAIN_SETTINGS.PROVIDER.REINDEX_DESCRIPTION') }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-test-id="reindex-embeddings"
|
||||
class="rounded-lg border border-n-weak bg-n-solid-2 px-3 py-2 text-sm font-medium text-n-slate-12 hover:bg-n-alpha-2 disabled:opacity-60"
|
||||
:disabled="
|
||||
isStartingReindex || Boolean(reindexStatus?.running)
|
||||
"
|
||||
@click="emit('reindex')"
|
||||
>
|
||||
{{
|
||||
reindexStatus?.running || isStartingReindex
|
||||
? t('CAPTAIN_SETTINGS.PROVIDER.REINDEXING')
|
||||
: t('CAPTAIN_SETTINGS.PROVIDER.REINDEX')
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="reindexStatus?.total" class="grid gap-1.5">
|
||||
<div class="h-2 overflow-hidden rounded-full bg-n-alpha-2">
|
||||
<div
|
||||
class="h-full rounded-full bg-n-brand transition-all"
|
||||
:style="{ width: `${reindexProgress}%` }"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-xs text-n-slate-10">
|
||||
{{
|
||||
t('CAPTAIN_SETTINGS.PROVIDER.REINDEX_PROGRESS', {
|
||||
processed: reindexStatus.processed || 0,
|
||||
total: reindexStatus.total,
|
||||
failed: reindexStatus.failed || 0,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<p v-if="reindexStatus?.error" class="text-xs text-n-ruby-11">
|
||||
{{ reindexStatus.error }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="!readOnly" class="flex flex-wrap justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2 text-sm font-medium text-white rounded-lg bg-n-brand hover:bg-n-brand/90 disabled:opacity-60"
|
||||
:disabled="
|
||||
isSaving || !form.provider || !form.baseUrl || !form.model
|
||||
"
|
||||
data-test-id="test-provider"
|
||||
class="rounded-lg border border-n-weak bg-n-solid-2 px-4 py-2 text-sm font-medium text-n-slate-12 hover:bg-n-alpha-2 disabled:opacity-60"
|
||||
:disabled="isTesting || isSaving || !isFormComplete"
|
||||
@click="test"
|
||||
>
|
||||
{{
|
||||
isTesting
|
||||
? t('CAPTAIN_SETTINGS.PROVIDER.TESTING')
|
||||
: t('CAPTAIN_SETTINGS.PROVIDER.TEST')
|
||||
}}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-test-id="save-provider"
|
||||
class="rounded-lg bg-n-brand px-4 py-2 text-sm font-medium text-white hover:bg-n-brand/90 disabled:opacity-60"
|
||||
:disabled="isSaving || isTesting || !isFormComplete"
|
||||
@click="save"
|
||||
>
|
||||
{{
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export const isSuperAdminUser = user => {
|
||||
const type = String(user?.type || '').toLowerCase();
|
||||
const role = String(user?.role || '').toLowerCase();
|
||||
return (
|
||||
type === 'superadmin' ||
|
||||
type === 'super_admin' ||
|
||||
role === 'superadmin' ||
|
||||
role === 'super_admin'
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import { isSuperAdminUser } from './utils';
|
||||
|
||||
describe('isSuperAdminUser', () => {
|
||||
it.each([
|
||||
{ type: 'SuperAdmin' },
|
||||
{ type: 'super_admin' },
|
||||
{ type: 'User', role: 'super_admin' },
|
||||
])('accepts supported SuperAdmin payloads: %o', user => {
|
||||
expect(isSuperAdminUser(user)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects an account administrator', () => {
|
||||
expect(isSuperAdminUser({ type: 'User', role: 'administrator' })).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user