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
@@ -0,0 +1,38 @@
/* global axios */
import ApiClient from './ApiClient';
class CopilotConfigAPI extends ApiClient {
constructor() {
super('copilot/config', { accountScoped: true });
}
getAccountConfig() {
return axios.get(this.url);
}
updateAccountConfig(data) {
return axios.put(this.url, data);
}
getPlatformConfig() {
return axios.get('/platform/api/v1/copilot/config');
}
updatePlatformConfig(data) {
return axios.put('/platform/api/v1/copilot/config', data);
}
testPlatformConfig(data) {
return axios.post('/platform/api/v1/copilot/config/test', data);
}
getEmbeddingReindexStatus() {
return axios.get('/platform/api/v1/copilot/embeddings/reindex');
}
startEmbeddingReindex() {
return axios.post('/platform/api/v1/copilot/embeddings/reindex');
}
}
export default new CopilotConfigAPI();
@@ -646,10 +646,10 @@ const menuItems = computed(() => {
to: accountScopedRoute('general_settings_index'),
},
{
name: 'Settings Captain',
name: 'Settings Copilot',
label: t('SIDEBAR.CAPTAIN_AI'),
icon: 'i-woot-captain',
to: accountScopedRoute('captain_settings_index'),
to: accountScopedRoute('copilot_settings_index'),
},
{
name: 'Settings Agents',
@@ -387,63 +387,131 @@
},
"CAPTAIN_SETTINGS": {
"TITLE": "Copilot Configuration",
"DESCRIPTION": "Configure the AI provider and models used by Copilot. Configuration is stored securely and takes effect without restarting the service.",
"LOADING": "Loading Captain configuration...",
"LINK_TEXT": "Learn more about Captain Credits",
"NOT_ENABLED": "Captain is not enabled for your account. Please upgrade your plan to access Captain features.",
"DESCRIPTION": "Configure the platform AI provider, account feature models, and response behavior. Copilot is always available, and saved provider changes take effect without a restart.",
"LOADING": "Loading Copilot configuration...",
"NOT_AVAILABLE": "Copilot configuration could not be loaded.",
"STATUS": {
"TITLE": "Runtime status",
"DESCRIPTION": "Review platform configuration completeness and the latest connection test.",
"CONFIGURATION": "Provider configuration",
"ALWAYS_ENABLED": "Copilot is globally enabled and cannot be disabled",
"APPLIED": "{provider} · {model}, applied at {time}",
"CONFIGURED": "Configured",
"INCOMPLETE": "Incomplete",
"CONNECTION": "Connection status",
"NOT_TESTED": "Connection has not been tested",
"CONNECTED": "Connected",
"ERROR": "Connection error",
"UNKNOWN": "Unknown"
},
"PROVIDER": {
"TITLE": "Provider Configuration",
"DESCRIPTION": "Configure the provider connection used by all Copilot and Captain AI requests.",
"TITLE": "Platform provider",
"DESCRIPTION": "Chat and embedding settings apply to the entire GoChat installation. Only a SuperAdmin can edit or test them.",
"READ_ONLY": "Account administrators can only view platform configuration status. Contact a SuperAdmin to edit the provider or run a connection test.",
"CHAT_TITLE": "Chat model",
"EMBEDDING_TITLE": "Embedding model",
"RELIABILITY_TITLE": "Generation and reliability",
"PROVIDER": "Provider",
"MODEL": "Default model",
"BASE_URL": "Base URL",
"API_KEY": "API key",
"API_KEY_PLACEHOLDER": "Enter API key",
"API_KEY_CONFIGURED": "Configured: {masked}. Leave blank to keep it unchanged.",
"API_KEY_HELP": "The API key is encrypted at rest and is never returned by the API.",
"SAVE": "Save provider configuration",
"API_KEY_MASKED": "saved",
"API_KEY_HELP": "The API key is stored as plaintext in the protected installation configuration table; the page and read APIs never return it.",
"CLEAR_CHAT_KEY": "Clear the saved Chat API key",
"CLEAR_EMBEDDING_KEY": "Clear the saved Embedding API key",
"CLEAR_KEY_CONFIRM": "Clearing an API key immediately makes the related provider configuration incomplete. Continue?",
"EMBEDDING_MODE": "Credential mode",
"REUSE_CHAT": "Reuse Chat credentials",
"SEPARATE_EMBEDDING": "Separate Embedding configuration",
"EMBEDDING_PROVIDER": "Embedding provider",
"EMBEDDING_BASE_URL": "Embedding Base URL",
"EMBEDDING_API_KEY": "Embedding API key",
"EMBEDDING_MODEL": "Embedding model",
"DIMENSIONS": "Vector dimensions",
"DIMENSIONS_CONFIRM": "Changing embedding dimensions requires rebuilding existing indexes or semantic search will stop working. Save the new dimensions?",
"ANTHROPIC_EMBEDDING_ERROR": "Anthropic does not provide an embeddings API. Select a separate OpenAI or OpenAI-compatible embedding provider.",
"MAX_TOKENS": "Maximum output tokens",
"TIMEOUT": "Timeout (seconds)",
"RETRIES": "Maximum retries",
"REINDEX_TITLE": "Knowledge base embedding rebuild",
"REINDEX_DESCRIPTION": "Rebuild all help center article vectors after changing the embedding model or dimensions.",
"REINDEX": "Start rebuild",
"REINDEXING": "Rebuilding...",
"REINDEX_CONFIRM": "Semantic search results may be incomplete during the rebuild. Rebuild embeddings for all articles now?",
"REINDEX_PROGRESS": "Processed {processed}/{total}, failed {failed}",
"TEST": "Test connection",
"TESTING": "Testing...",
"SAVE": "Save platform configuration",
"SAVING": "Saving..."
},
"MODEL_CONFIG": {
"TITLE": "Model Configuration",
"DESCRIPTION": "Select AI models for different features.",
"SELECT_MODEL": "Select model",
"CREDITS_PER_MESSAGE": "{credits} credit/message",
"COMING_SOON": "Coming soon",
"TITLE": "Current account models",
"DESCRIPTION": "Assign model IDs to individual features. Keep the platform default or enter any provider-compatible model ID.",
"MODEL_ID_PLACEHOLDER": "Enter a model ID",
"APPLY": "Apply",
"EDITOR": {
"TITLE": "Editor Features",
"DESCRIPTION": "Powers smart compose, grammar corrections, tone adjustments, and content enhancement in your message editor."
"TITLE": "Editor rewriting",
"DESCRIPTION": "Powers smart compose, grammar correction, tone adjustment, and content enhancement."
},
"ASSISTANT": {
"TITLE": "Assistant",
"DESCRIPTION": "Handles automated responses, conversation summaries, and intelligent reply suggestions for customer interactions."
"DESCRIPTION": "Used by Assistant Playground, automated assistance, and knowledge-grounded answers."
},
"COPILOT": {
"TITLE": "Co-pilot",
"DESCRIPTION": "Provides real-time contextual suggestions, knowledge base recommendations, and proactive insights during conversations."
"TITLE": "Copilot conversation",
"DESCRIPTION": "Used for sidebar conversations, reply suggestions, summaries, and translations."
}
},
"FEATURES": {
"TITLE": "Features",
"DESCRIPTION": "Enable or disable AI-powered features.",
"AUDIO_TRANSCRIPTION": {
"TITLE": "Audio Transcription",
"DESCRIPTION": "Automatically convert voice messages and call recordings into searchable text transcripts."
},
"TITLE": "Current account features",
"DESCRIPTION": "Control specific AI capabilities for this account. These switches do not disable system-wide Copilot.",
"HELP_CENTER_SEARCH": {
"TITLE": "Help Center Search Indexing",
"DESCRIPTION": "Use AI for context aware search inside your help center articles."
"TITLE": "Knowledge base semantic search",
"DESCRIPTION": "Use the platform embedding model for semantic search across help center articles."
},
"LABEL_SUGGESTION": {
"TITLE": "Label Suggestion",
"DESCRIPTION": "Automatically suggest relevant labels and tags for conversations based on content analysis and context.",
"MODEL_TITLE": "Label Suggestion Model",
"MODEL_DESCRIPTION": "Select the AI model to use for analyzing conversations and suggesting appropriate labels"
"TITLE": "Label suggestions",
"DESCRIPTION": "Suggest relevant labels from conversation context. Automatic application is controlled below.",
"MODEL_TITLE": "Label suggestion model",
"MODEL_DESCRIPTION": "Optionally use a compatible model ID different from the account default."
}
},
"BEHAVIOR": {
"TITLE": "Response behavior",
"DESCRIPTION": "Set account defaults for tone, language, length, and automation. Assistant-specific settings take priority.",
"TONE": "Response tone",
"TONES": {
"PROFESSIONAL": "Professional",
"FRIENDLY": "Friendly",
"CASUAL": "Casual",
"FORMAL": "Formal"
},
"LANGUAGE": "Response language",
"LANGUAGE_PLACEHOLDER": "auto or a language code",
"MAX_LENGTH": "Maximum response length",
"CUSTOM_PROMPT": "Additional instructions",
"CUSTOM_PROMPT_PLACEHOLDER": "For example: prioritize actionable next steps.",
"CUSTOM_PROMPT_HELP": "Appended to this account's default prompt, up to 1,000 characters.",
"AUTO_LABEL": "Automatically apply labels",
"AUTO_LABEL_HELP": "Apply label suggestion results automatically.",
"AUTO_FOLLOW_UP": "Automatically create follow-ups",
"AUTO_FOLLOW_UP_HELP": "Create follow-up tasks from AI suggestions.",
"AUTO_REPLY": "Automatically reply to customers",
"AUTO_REPLY_HELP": "High-risk capability; verify the Assistant and Inbox binding before enabling it.",
"AUTO_REPLY_CONFIRM": "Automatic replies send AI content directly to customers. Confirm that the Assistant, knowledge base, guardrails, and Inbox binding are configured. Enable it anyway?",
"SAVE": "Save response behavior",
"SAVING": "Saving..."
},
"API": {
"SUCCESS": "Captain settings updated successfully.",
"ERROR": "Failed to update Captain settings. Please try again."
"ACCOUNT_SUCCESS": "Account Copilot configuration updated.",
"PROVIDER_SUCCESS": "Platform provider configuration saved and applied immediately.",
"TEST_SUCCESS": "Chat and Embedding connection tests passed.",
"TEST_ERROR": "The connection test completed, but at least one check failed.",
"REINDEX_STARTED": "Knowledge base embedding rebuild started.",
"REINDEX_COMPLETE": "Knowledge base embedding rebuild completed.",
"ERROR": "Failed to update Copilot configuration. Please try again."
}
},
"BILLING_SETTINGS": {
@@ -387,63 +387,131 @@
},
"CAPTAIN_SETTINGS": {
"TITLE": "Copilot 配置",
"DESCRIPTION": "配置 Copilot 使用的 AI 服务商和模型。保存后立即生效,无需重启服务。",
"DESCRIPTION": "配置平台 AI 服务商、账户功能模型与回复行为。Copilot 在系统中始终可用,Provider 配置保存后无需重启即可生效。",
"LOADING": "正在加载 Copilot 配置...",
"LINK_TEXT": "了解 Copilot",
"NOT_ENABLED": "Copilot 配置暂不可用。",
"NOT_AVAILABLE": "无法加载 Copilot 配置。",
"STATUS": {
"TITLE": "运行状态",
"DESCRIPTION": "查看平台配置完整性和最近一次连接测试结果。",
"CONFIGURATION": "Provider 配置",
"ALWAYS_ENABLED": "Copilot 全局启用且不可关闭",
"APPLIED": "{provider} · {model},生效于 {time}",
"CONFIGURED": "配置完整",
"INCOMPLETE": "配置不完整",
"CONNECTION": "连接状态",
"NOT_TESTED": "尚未测试连接",
"CONNECTED": "连接正常",
"ERROR": "连接异常",
"UNKNOWN": "未知"
},
"PROVIDER": {
"TITLE": "服务商配置",
"DESCRIPTION": "配置所有 Copilot 与 Captain AI 请求使用的模型服务。",
"TITLE": "平台 Provider",
"DESCRIPTION": "Chat 与 Embedding 配置对整套 GoChat 安装生效。只有 SuperAdmin 可以修改和测试。",
"READ_ONLY": "当前账户管理员只能查看平台配置状态。请联系 SuperAdmin 修改 Provider 或执行连接测试。",
"CHAT_TITLE": "对话模型",
"EMBEDDING_TITLE": "Embedding 模型",
"RELIABILITY_TITLE": "生成与可靠性",
"PROVIDER": "服务商",
"MODEL": "默认模型",
"BASE_URL": "接口地址",
"API_KEY": "API Key",
"API_KEY_PLACEHOLDER": "请输入 API Key",
"API_KEY_CONFIGURED": "已配置:{masked}。留空表示保持不变。",
"API_KEY_HELP": "API Key 加密保存,接口不会返回明文。",
"SAVE": "保存服务商配置",
"API_KEY_MASKED": "已保存",
"API_KEY_HELP": "API Key 明文写入受保护的安装配置表;页面和读取接口永不返回明文。",
"CLEAR_CHAT_KEY": "清除已保存的 Chat API Key",
"CLEAR_EMBEDDING_KEY": "清除已保存的 Embedding API Key",
"CLEAR_KEY_CONFIRM": "清除 API Key 会让相关 Provider 立即变为配置不完整。确认继续吗?",
"EMBEDDING_MODE": "凭据模式",
"REUSE_CHAT": "复用 Chat 凭据",
"SEPARATE_EMBEDDING": "独立 Embedding 配置",
"EMBEDDING_PROVIDER": "Embedding 服务商",
"EMBEDDING_BASE_URL": "Embedding 接口地址",
"EMBEDDING_API_KEY": "Embedding API Key",
"EMBEDDING_MODEL": "Embedding 模型",
"DIMENSIONS": "向量维度",
"DIMENSIONS_CONFIRM": "修改 Embedding 向量维度后必须重建已有索引,否则语义搜索不可用。确认保存新维度吗?",
"ANTHROPIC_EMBEDDING_ERROR": "Anthropic 不提供 Embedding API。请选择独立 Embedding 配置并使用 OpenAI 或 OpenAI Compatible 服务商。",
"MAX_TOKENS": "最大输出 Tokens",
"TIMEOUT": "超时时间(秒)",
"RETRIES": "最大重试次数",
"REINDEX_TITLE": "知识库 Embedding 重建",
"REINDEX_DESCRIPTION": "在修改 Embedding 模型或向量维度后,重建所有帮助中心文章向量。",
"REINDEX": "开始重建",
"REINDEXING": "重建中...",
"REINDEX_CONFIRM": "重建期间知识库语义搜索结果可能暂时不完整。确认开始重建全部文章 Embedding 吗?",
"REINDEX_PROGRESS": "已处理 {processed}/{total},失败 {failed}",
"TEST": "测试连接",
"TESTING": "测试中...",
"SAVE": "保存平台配置",
"SAVING": "保存中..."
},
"MODEL_CONFIG": {
"TITLE": "Model Configuration",
"DESCRIPTION": "Select AI models for different features.",
"SELECT_MODEL": "Select model",
"CREDITS_PER_MESSAGE": "{credits} credit/message",
"COMING_SOON": "Coming soon",
"TITLE": "当前账户模型",
"DESCRIPTION": "为各项功能指定模型 ID;留在平台默认值即可跟随 Provider 默认模型。支持输入任意兼容模型 ID。",
"MODEL_ID_PLACEHOLDER": "输入模型 ID",
"APPLY": "应用",
"EDITOR": {
"TITLE": "Editor Features",
"DESCRIPTION": "Powers smart compose, grammar corrections, tone adjustments, and content enhancement in your message editor."
"TITLE": "编辑器改写",
"DESCRIPTION": "用于智能撰写、语法修正、语气调整和内容润色。"
},
"ASSISTANT": {
"TITLE": "助手",
"DESCRIPTION": "Handles automated responses, conversation summaries, and intelligent reply suggestions for customer interactions."
"DESCRIPTION": "用于 Assistant Playground、自动辅助和知识库回答。"
},
"COPILOT": {
"TITLE": "Co-pilot",
"DESCRIPTION": "Provides real-time contextual suggestions, knowledge base recommendations, and proactive insights during conversations."
"TITLE": "Copilot 对话",
"DESCRIPTION": "用于客服侧边栏问答、回复建议、摘要和翻译。"
}
},
"FEATURES": {
"TITLE": "特性",
"DESCRIPTION": "Enable or disable AI-powered features.",
"AUDIO_TRANSCRIPTION": {
"TITLE": "Audio Transcription",
"DESCRIPTION": "Automatically convert voice messages and call recordings into searchable text transcripts."
},
"TITLE": "当前账户功能",
"DESCRIPTION": "控制当前账户的具体 AI 能力;这些开关不会关闭系统级 Copilot。",
"HELP_CENTER_SEARCH": {
"TITLE": "Help Center Search Indexing",
"DESCRIPTION": "Use AI for context aware search inside your help center articles."
"TITLE": "知识库语义搜索",
"DESCRIPTION": "使用平台 Embedding 模型对帮助中心文章执行语义搜索。"
},
"LABEL_SUGGESTION": {
"TITLE": "Label Suggestion",
"DESCRIPTION": "Automatically suggest relevant labels and tags for conversations based on content analysis and context.",
"MODEL_TITLE": "Label Suggestion Model",
"MODEL_DESCRIPTION": "Select the AI model to use for analyzing conversations and suggesting appropriate labels"
"TITLE": "标签建议",
"DESCRIPTION": "根据会话内容建议相关标签;是否自动应用由下方回复行为控制。",
"MODEL_TITLE": "标签建议模型",
"MODEL_DESCRIPTION": "可指定不同于账户默认值的兼容模型 ID。"
}
},
"BEHAVIOR": {
"TITLE": "回复行为",
"DESCRIPTION": "设置当前账户的默认语气、语言、长度和自动化策略。Assistant 自身配置优先于这些默认值。",
"TONE": "回复语气",
"TONES": {
"PROFESSIONAL": "专业",
"FRIENDLY": "友好",
"CASUAL": "轻松",
"FORMAL": "正式"
},
"LANGUAGE": "回复语言",
"LANGUAGE_PLACEHOLDER": "auto 或语言代码",
"MAX_LENGTH": "最大回复长度",
"CUSTOM_PROMPT": "附加指令",
"CUSTOM_PROMPT_PLACEHOLDER": "例如:回复中优先给出可执行步骤。",
"CUSTOM_PROMPT_HELP": "附加到当前账户的默认提示词,最多 1000 字。",
"AUTO_LABEL": "自动应用标签",
"AUTO_LABEL_HELP": "自动应用标签建议结果。",
"AUTO_FOLLOW_UP": "自动创建跟进",
"AUTO_FOLLOW_UP_HELP": "根据 AI 建议创建跟进任务。",
"AUTO_REPLY": "自动回复客户",
"AUTO_REPLY_HELP": "高风险能力;启用前请确认 Assistant 与 Inbox 已正确绑定。",
"AUTO_REPLY_CONFIRM": "自动回复会直接向客户发送 AI 内容。请确认 Assistant、知识库、Guardrails 和 Inbox 绑定均已配置,仍要启用吗?",
"SAVE": "保存回复行为",
"SAVING": "保存中..."
},
"API": {
"SUCCESS": "Captain settings updated successfully.",
"ERROR": "Failed to update Captain settings. Please try again."
"ACCOUNT_SUCCESS": "账户 Copilot 配置已更新。",
"PROVIDER_SUCCESS": "平台 Provider 配置已保存并立即生效。",
"TEST_SUCCESS": "Chat 与 Embedding 连接测试通过。",
"TEST_ERROR": "连接测试已完成,但至少有一项失败。",
"REINDEX_STARTED": "知识库 Embedding 重建已开始。",
"REINDEX_COMPLETE": "知识库 Embedding 重建已完成。",
"ERROR": "Copilot 配置更新失败,请重试。"
}
},
"BILLING_SETTINGS": {
@@ -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
);
});
});
@@ -1,74 +1,144 @@
import { defineStore } from 'pinia';
import CaptainPreferencesAPI from 'dashboard/api/captain/preferences';
import CopilotConfigAPI from 'dashboard/api/copilotConfig';
const applyAccountPayload = (store, payload = {}) => {
store.providers = payload.providers || {};
store.models = payload.models || {};
store.features = payload.features || {};
store.behavior = payload.behavior || {};
store.providerConfig = payload.provider_config || {};
};
export const useCaptainConfigStore = defineStore('captainConfig', {
state: () => ({
providers: {},
models: {},
features: {},
providerConfig: {},
uiFlags: {
isFetching: false,
},
}),
state: () => ({
providers: {},
models: {},
features: {},
behavior: {},
providerConfig: {},
providerTestResult: null,
embeddingReindexStatus: {},
uiFlags: {
isFetching: false,
isSavingAccount: false,
isSavingProvider: false,
isTestingProvider: false,
isStartingReindex: false,
},
}),
getters: {
getProviders: state => state.providers,
getModels: state => state.models,
getFeatures: state => state.features,
getUIFlags: state => state.uiFlags,
getModelsForFeature: state => featureKey => {
const feature = state.features[featureKey];
const models = feature?.models || [];
getters: {
getProviders: state => state.providers,
getModels: state => state.models,
getFeatures: state => state.features,
getUIFlags: state => state.uiFlags,
getModelsForFeature: state => featureKey => {
const feature = state.features[featureKey];
const models = feature?.models || [];
const providerOrder = { openai: 0, anthropic: 1, gemini: 2 };
const providerOrder = { openai: 0, anthropic: 1, gemini: 2 };
return [...models].sort((a, b) => {
// Move coming_soon items to the end
if (a.coming_soon && !b.coming_soon) return 1;
if (!a.coming_soon && b.coming_soon) return -1;
return [...models].sort((a, b) => {
// Move coming_soon items to the end
if (a.coming_soon && !b.coming_soon) return 1;
if (!a.coming_soon && b.coming_soon) return -1;
// Sort by provider
const providerA = providerOrder[a.provider] ?? 999;
const providerB = providerOrder[b.provider] ?? 999;
if (providerA !== providerB) return providerA - providerB;
// Sort by provider
const providerA = providerOrder[a.provider] ?? 999;
const providerB = providerOrder[b.provider] ?? 999;
if (providerA !== providerB) return providerA - providerB;
// Sort by credit_multiplier (highest first)
return (b.credit_multiplier || 0) - (a.credit_multiplier || 0);
});
},
getDefaultModelForFeature: state => featureKey => {
const feature = state.features[featureKey];
return feature?.default || null;
},
getSelectedModelForFeature: state => featureKey => {
const feature = state.features[featureKey];
return feature?.selected || feature?.default || null;
},
},
// Sort by credit_multiplier (highest first)
return (b.credit_multiplier || 0) - (a.credit_multiplier || 0);
});
},
getDefaultModelForFeature: state => featureKey => {
const feature = state.features[featureKey];
return feature?.default || null;
},
getSelectedModelForFeature: state => featureKey => {
const feature = state.features[featureKey];
return feature?.selected || feature?.default || null;
},
},
actions: {
async fetch() {
this.uiFlags.isFetching = true;
try {
const response = await CaptainPreferencesAPI.get();
this.providers = response.data.providers || {};
this.models = response.data.models || {};
this.features = response.data.features || {};
this.providerConfig = response.data.provider_config || {};
} catch (error) {
// Ignore error
} finally {
this.uiFlags.isFetching = false;
}
},
actions: {
async fetch({ includePlatform = false } = {}) {
this.uiFlags.isFetching = true;
try {
const accountResponse =
await CopilotConfigAPI.getAccountConfig();
applyAccountPayload(this, accountResponse.data);
if (includePlatform) {
const [platformResponse, reindexResponse] =
await Promise.all([
CopilotConfigAPI.getPlatformConfig(),
CopilotConfigAPI.getEmbeddingReindexStatus(),
]);
this.providerConfig = platformResponse.data || {};
this.embeddingReindexStatus = reindexResponse.data || {};
}
} finally {
this.uiFlags.isFetching = false;
}
},
async updatePreferences(data) {
const response = await CaptainPreferencesAPI.updatePreferences(data);
this.providers = response.data.providers || {};
this.models = response.data.models || {};
this.features = response.data.features || {};
this.providerConfig = response.data.provider_config || {};
},
},
async updateAccountConfig(data) {
this.uiFlags.isSavingAccount = true;
try {
const response =
await CopilotConfigAPI.updateAccountConfig(data);
applyAccountPayload(this, response.data);
return response.data;
} finally {
this.uiFlags.isSavingAccount = false;
}
},
async updatePreferences(data) {
return this.updateAccountConfig(data);
},
async updatePlatformConfig(data) {
this.uiFlags.isSavingProvider = true;
try {
const response =
await CopilotConfigAPI.updatePlatformConfig(data);
this.providerConfig = response.data || {};
this.providerTestResult = null;
return response.data;
} finally {
this.uiFlags.isSavingProvider = false;
}
},
async testPlatformConfig(data) {
this.uiFlags.isTestingProvider = true;
try {
const response =
await CopilotConfigAPI.testPlatformConfig(data);
this.providerTestResult = response.data || null;
return response.data;
} finally {
this.uiFlags.isTestingProvider = false;
}
},
async fetchEmbeddingReindexStatus() {
const response = await CopilotConfigAPI.getEmbeddingReindexStatus();
this.embeddingReindexStatus = response.data || {};
return response.data;
},
async startEmbeddingReindex() {
this.uiFlags.isStartingReindex = true;
try {
const response = await CopilotConfigAPI.startEmbeddingReindex();
this.embeddingReindexStatus = response.data || {};
return response.data;
} finally {
this.uiFlags.isStartingReindex = false;
}
},
},
});
@@ -0,0 +1,100 @@
import { createPinia, setActivePinia } from 'pinia';
import CopilotConfigAPI from 'dashboard/api/copilotConfig';
import { useCaptainConfigStore } from './preferences';
vi.mock('dashboard/api/copilotConfig', () => ({
default: {
getAccountConfig: vi.fn(),
updateAccountConfig: vi.fn(),
getPlatformConfig: vi.fn(),
updatePlatformConfig: vi.fn(),
testPlatformConfig: vi.fn(),
getEmbeddingReindexStatus: vi.fn(),
startEmbeddingReindex: vi.fn(),
},
}));
const accountPayload = {
providers: { openai: { display_name: 'OpenAI' } },
models: { 'account-model': { provider: 'openai' } },
features: {
editor: {
default: 'platform-model',
selected: 'account-model',
models: [],
},
},
behavior: { tone: 'friendly' },
provider_config: { configured: true, chat: { model: 'account-view' } },
};
describe('Copilot configuration store', () => {
beforeEach(() => {
setActivePinia(createPinia());
vi.clearAllMocks();
});
it('loads account config and replaces provider details for SuperAdmin', async () => {
CopilotConfigAPI.getAccountConfig.mockResolvedValue({
data: accountPayload,
});
CopilotConfigAPI.getPlatformConfig.mockResolvedValue({
data: { configured: true, chat: { model: 'platform-model' } },
});
CopilotConfigAPI.getEmbeddingReindexStatus.mockResolvedValue({
data: { running: false, processed: 3, total: 3 },
});
const store = useCaptainConfigStore();
await store.fetch({ includePlatform: true });
expect(store.behavior.tone).toBe('friendly');
expect(store.features.editor.selected).toBe('account-model');
expect(store.providerConfig.chat.model).toBe('platform-model');
expect(CopilotConfigAPI.getPlatformConfig).toHaveBeenCalledOnce();
expect(store.embeddingReindexStatus.processed).toBe(3);
expect(store.uiFlags.isFetching).toBe(false);
});
it('does not request platform secrets for an account administrator', async () => {
CopilotConfigAPI.getAccountConfig.mockResolvedValue({
data: accountPayload,
});
const store = useCaptainConfigStore();
await store.fetch();
expect(CopilotConfigAPI.getPlatformConfig).not.toHaveBeenCalled();
expect(store.providerConfig.chat.model).toBe('account-view');
});
it('uses separate account, platform save, and provider test endpoints', async () => {
CopilotConfigAPI.updateAccountConfig.mockResolvedValue({
data: { ...accountPayload, behavior: { tone: 'formal' } },
});
CopilotConfigAPI.updatePlatformConfig.mockResolvedValue({
data: { configured: true, chat: { model: 'saved-model' } },
});
CopilotConfigAPI.testPlatformConfig.mockResolvedValue({
data: { chat: { ok: true }, embedding: { ok: true } },
});
CopilotConfigAPI.startEmbeddingReindex.mockResolvedValue({
data: { running: true, processed: 0, total: 2 },
});
const store = useCaptainConfigStore();
await store.updateAccountConfig({ behavior: { tone: 'formal' } });
await store.updatePlatformConfig({ chat: { model: 'saved-model' } });
await store.testPlatformConfig({ chat: { model: 'candidate-model' } });
await store.startEmbeddingReindex();
expect(store.behavior.tone).toBe('formal');
expect(store.providerConfig.chat.model).toBe('saved-model');
expect(store.providerTestResult.embedding.ok).toBe(true);
expect(CopilotConfigAPI.updateAccountConfig).toHaveBeenCalledOnce();
expect(CopilotConfigAPI.updatePlatformConfig).toHaveBeenCalledOnce();
expect(CopilotConfigAPI.testPlatformConfig).toHaveBeenCalledOnce();
expect(CopilotConfigAPI.startEmbeddingReindex).toHaveBeenCalledOnce();
expect(store.embeddingReindexStatus.running).toBe(true);
});
});