feat(copilot): finish configuration center

This commit is contained in:
2026-07-13 14:57:28 +08:00
parent 0a69d80f7c
commit 8b9eedc0e2
60 changed files with 3040 additions and 1042 deletions
@@ -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>
@@ -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'],
},
},
],
},
],
};
@@ -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');
});
});
@@ -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();
});
});
@@ -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>
@@ -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>
@@ -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>
@@ -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();
});
});
@@ -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
);
});
});