- Implemented `cron` store to manage scheduled tasks with CRUD operations. - Created `locale` store for language settings with persistence and system language detection. - Added `providers` store to handle provider accounts and configurations with API interactions. - Developed `script` store for managing automation scripts, including recording and execution. - Introduced `sharedStore` for managing shared data across components. - Established `skills` store for fetching, installing, and managing skills from a marketplace. - Created `userinfo` store for user authentication and session management. chore: update path aliases from `@store` to `@stores` in TypeScript configuration and Vite config
420 lines
17 KiB
Vue
420 lines
17 KiB
Vue
<template>
|
|
<div class="space-y-6">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<h3 class="text-lg font-medium text-[#171717] dark:text-[#f3f4f6]">{{ t('models.aiProviders.title', 'AI Providers') }}</h3>
|
|
<p class="text-sm text-[#99A0AE] dark:text-gray-500 mt-1">{{ t('models.aiProviders.subtitle', 'Manage your AI models and API keys.') }}</p>
|
|
</div>
|
|
<el-button type="primary" @click="showAddDialog = true">
|
|
<el-icon class="mr-1"><Plus /></el-icon>
|
|
{{ t('models.aiProviders.addProvider', 'Add Provider') }}
|
|
</el-button>
|
|
</div>
|
|
|
|
<!-- Provider List -->
|
|
<div v-if="loading" class="py-8 text-center text-[#99A0AE] dark:text-gray-500">
|
|
<el-icon class="is-loading text-2xl"><Loading /></el-icon>
|
|
</div>
|
|
|
|
<div v-else class="space-y-4">
|
|
<el-card v-for="item in displayProviders" :key="item.account.id" shadow="hover" class="provider-card">
|
|
<div class="flex items-start justify-between">
|
|
<div class="flex items-center gap-4">
|
|
<div class="w-10 h-10 rounded-full bg-gray-100 dark:bg-[#222225] flex items-center justify-center text-xl overflow-hidden border border-gray-200 dark:border-[#2a2a2d]">
|
|
{{ item.vendor?.icon || '🤖' }}
|
|
</div>
|
|
<div>
|
|
<div class="flex items-center gap-2">
|
|
<span class="font-medium text-[16px] text-[#171717] dark:text-[#f3f4f6]">{{ item.account.label || item.vendor?.name }}</span>
|
|
<el-tag size="small" :type="item.account.isDefault ? 'success' : 'info'" v-if="item.account.isDefault">
|
|
{{ t('models.aiProviders.default', 'Default') }}
|
|
</el-tag>
|
|
</div>
|
|
<div class="text-sm text-[#99A0AE] dark:text-gray-500 mt-1 flex items-center gap-2">
|
|
<span v-if="item.account.baseUrl" class="truncate max-w-[200px]">{{ item.account.baseUrl }}</span>
|
|
<span v-if="item.account.model" class="truncate max-w-[150px] bg-gray-100 dark:bg-[#222225] px-2 py-0.5 rounded text-xs">{{ item.account.model }}</span>
|
|
<span v-if="item.status?.hasKey" class="text-green-600 text-xs flex items-center">
|
|
<el-icon class="mr-1"><Check /></el-icon> {{ t('models.aiProviders.configured', 'Configured') }}
|
|
</span>
|
|
<span v-else class="text-orange-500 text-xs flex items-center">
|
|
<el-icon class="mr-1"><Warning /></el-icon> {{ t('models.aiProviders.notConfigured', 'Not Configured') }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-2">
|
|
<el-button v-if="!item.account.isDefault && item.status?.hasKey" size="small" @click="handleSetDefault(item.account.id)">
|
|
{{ t('models.aiProviders.setDefault', 'Set Default') }}
|
|
</el-button>
|
|
<el-button size="small" @click="handleEdit(item)">
|
|
{{ t('models.aiProviders.edit', 'Edit') }}
|
|
</el-button>
|
|
<el-popconfirm :title="t('models.aiProviders.deleteConfirm', 'Are you sure you want to delete this provider?')" @confirm="handleDelete(item.account.id)">
|
|
<template #reference>
|
|
<el-button size="small" type="danger" plain>
|
|
{{ t('models.aiProviders.delete', 'Delete') }}
|
|
</el-button>
|
|
</template>
|
|
</el-popconfirm>
|
|
</div>
|
|
</div>
|
|
</el-card>
|
|
|
|
<div v-if="displayProviders.length === 0" class="text-center py-12 border border-dashed border-gray-300 dark:border-[#2a2a2d] rounded-lg text-gray-500 dark:text-gray-400">
|
|
{{ t('models.aiProviders.empty', 'No providers configured yet. Click "Add Provider" to start.') }}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Edit Dialog -->
|
|
<el-dialog
|
|
v-model="editDialogVisible"
|
|
width="640px"
|
|
:show-close="false"
|
|
class="custom-provider-dialog"
|
|
>
|
|
<div class="p-[30px] pb-[20px]">
|
|
<div class="flex justify-between items-start mb-[24px]">
|
|
<div>
|
|
<h2 class="text-[24px] font-serif text-[#171717] dark:text-[#f3f4f6] mb-[8px] font-normal tracking-tight">
|
|
{{ editingItem?.account.id.includes('-') ? t('models.aiProviders.addProvider', '添加 AI 提供商') : t('models.aiProviders.editProvider', '编辑提供商') }}
|
|
</h2>
|
|
<p class="text-[14px] text-[#99A0AE] dark:text-gray-500">
|
|
配置新的 AI 模型提供商
|
|
</p>
|
|
</div>
|
|
<button @click="editDialogVisible = false" class="text-[#99A0AE] dark:text-gray-500 hover:text-[#171717] dark:hover:text-[#f3f4f6] transition-colors mt-[4px]">
|
|
<el-icon class="text-[20px]"><Close /></el-icon>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="bg-white dark:bg-[#1f1f22] rounded-[16px] p-[20px] flex items-center gap-[16px] mb-[24px] shadow-sm">
|
|
<div class="w-[48px] h-[48px] rounded-[14px] bg-[#F4F3EB] dark:bg-[#222225] flex items-center justify-center text-[24px] overflow-hidden">
|
|
{{ editingItem?.vendor?.icon || '🤖' }}
|
|
</div>
|
|
<div class="flex flex-col">
|
|
<span class="text-[16px] font-medium text-[#171717] dark:text-[#f3f4f6] mb-[4px]">{{ editingItem?.vendor?.name }}</span>
|
|
<div class="flex items-center text-[13px] text-[#007AFF] dark:text-blue-400">
|
|
<a href="javascript:void(0)" @click="showAddDialog = true; editDialogVisible = false" class="hover:underline">更换提供商</a>
|
|
<span class="mx-[8px] text-[#E5E8EE] dark:text-[#2a2a2d]">|</span>
|
|
<a v-if="editingItem?.vendor?.docsUrl" :href="editingItem.vendor.docsUrl" target="_blank" class="hover:underline flex items-center gap-[4px]">
|
|
查看文档
|
|
<el-icon class="text-[12px]"><TopRight /></el-icon>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<el-form :model="editForm" label-position="top" @submit.prevent class="custom-form">
|
|
<el-form-item label="显示名称" class="mb-[24px]">
|
|
<el-input v-if="editingItem" v-model="editingItem.account.label" :placeholder="editingItem?.vendor?.name" class="custom-input font-mono" />
|
|
</el-form-item>
|
|
|
|
<el-form-item label="API 密钥" class="mb-[8px]">
|
|
<el-input
|
|
v-model="editForm.apiKey"
|
|
:placeholder="editingItem?.vendor?.placeholder || t('models.aiProviders.apiKeyPlaceholder', 'Enter API Key')"
|
|
show-password
|
|
clearable
|
|
class="custom-input font-mono"
|
|
>
|
|
</el-input>
|
|
<div class="text-[12px] text-[#99A0AE] mt-[8px]">
|
|
您的 API 密钥存储在本地机器上。
|
|
</div>
|
|
</el-form-item>
|
|
|
|
<template v-if="editingItem?.vendor?.showBaseUrl">
|
|
<el-form-item :label="t('models.aiProviders.baseUrl', 'Base URL (Optional)')" class="mt-[24px] mb-[24px]">
|
|
<el-input v-model="editForm.baseUrl" :placeholder="editingItem?.vendor?.defaultBaseUrl || 'https://api.example.com/v1'" clearable class="custom-input font-mono" />
|
|
</el-form-item>
|
|
</template>
|
|
|
|
<template v-if="editingItem?.vendor?.showModelId">
|
|
<el-form-item :label="t('models.aiProviders.defaultModel', 'Default Model (Optional)')" class="mb-[24px]">
|
|
<el-input v-model="editForm.model" :placeholder="editingItem?.vendor?.modelIdPlaceholder || editingItem?.vendor?.defaultModelId || 'e.g. gpt-4'" clearable class="custom-input font-mono" />
|
|
</el-form-item>
|
|
</template>
|
|
</el-form>
|
|
|
|
<div class="h-[1px] bg-[rgba(0,0,0,0.05)] dark:bg-[rgba(255,255,255,0.08)] w-full my-[24px]"></div>
|
|
|
|
<div class="flex justify-end">
|
|
<el-button type="primary" @click="saveEdit" :loading="saving" class="h-[40px] px-[24px] rounded-[20px] bg-[#007AFF] hover:bg-[#0066FF] border-none text-[14px] font-medium">
|
|
{{ editingItem?.account.id.includes('-') ? '添加提供商' : t('common.save', 'Save') }}
|
|
</el-button>
|
|
</div>
|
|
</div>
|
|
</el-dialog>
|
|
|
|
<!-- Add Dialog -->
|
|
<el-dialog
|
|
v-model="showAddDialog"
|
|
width="800px"
|
|
:show-close="false"
|
|
class="custom-provider-dialog"
|
|
>
|
|
<div class="p-[30px]">
|
|
<div class="flex justify-between items-start mb-[30px]">
|
|
<div>
|
|
<h2 class="text-[24px] font-serif text-[#171717] dark:text-[#f3f4f6] mb-[8px] font-normal tracking-tight">
|
|
{{ t('models.aiProviders.addProvider', 'Add AI Provider') }}
|
|
</h2>
|
|
<p class="text-[14px] text-[#99A0AE] dark:text-gray-500">
|
|
配置新的 AI 模型提供商
|
|
</p>
|
|
</div>
|
|
<button @click="showAddDialog = false" class="text-[#99A0AE] dark:text-gray-500 hover:text-[#171717] dark:hover:text-[#f3f4f6] transition-colors mt-[4px]">
|
|
<el-icon class="text-[20px]"><Close /></el-icon>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-2 md:grid-cols-3 gap-[16px]">
|
|
<div
|
|
v-for="provider in availableProviders"
|
|
:key="provider.id"
|
|
@click="selectNewProvider(provider)"
|
|
class="cursor-pointer border border-[rgba(0,0,0,0.05)] dark:border-[rgba(255,255,255,0.08)] rounded-[16px] p-[20px] flex flex-col items-center justify-center gap-[12px] bg-[rgba(255,255,255,0.4)] dark:bg-[#1f1f22] hover:bg-[rgba(255,255,255,0.8)] dark:hover:bg-[#2a2a2d] transition-all duration-200"
|
|
>
|
|
<div class="w-[48px] h-[48px] rounded-[14px] bg-[rgba(0,0,0,0.05)] dark:bg-[#222225] flex items-center justify-center text-[24px] overflow-hidden">
|
|
{{ provider.icon || '🤖' }}
|
|
</div>
|
|
<span class="text-[14px] font-medium text-[#171717] dark:text-[#f3f4f6] text-center">{{ provider.name }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</el-dialog>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, onMounted } from 'vue';
|
|
import { useI18n } from 'vue-i18n';
|
|
import { Plus, Loading, Check, Warning, Close, TopRight } from '@element-plus/icons-vue';
|
|
import { ElMessage } from 'element-plus';
|
|
import { useProviderStore } from '@src/stores/providers';
|
|
import { PROVIDER_TYPE_INFO, type ProviderTypeInfo } from '@lib/providers';
|
|
import type { ProviderListItem } from '@lib/provider-accounts';
|
|
|
|
const { t } = useI18n();
|
|
const providerStore = useProviderStore();
|
|
|
|
const showAddDialog = ref(false);
|
|
const editDialogVisible = ref(false);
|
|
const validating = ref(false);
|
|
const saving = ref(false);
|
|
const editingItem = ref<ProviderListItem | null>(null);
|
|
|
|
const editForm = ref({
|
|
apiKey: '',
|
|
baseUrl: '',
|
|
model: ''
|
|
});
|
|
|
|
const loading = computed(() => providerStore.loading);
|
|
const displayProviders = computed(() => {
|
|
const { accounts, statuses, vendors, defaultAccountId } = providerStore;
|
|
|
|
// Safe map building
|
|
const safeAccounts = accounts || [];
|
|
const safeStatuses = statuses || [];
|
|
const safeVendors = vendors || [];
|
|
|
|
const vendorMap = new Map(safeVendors.map((vendor) => [vendor.id, vendor]));
|
|
const statusMap = new Map(safeStatuses.map((status) => [status.id, status]));
|
|
|
|
if (safeAccounts.length > 0) {
|
|
return safeAccounts
|
|
.map((account) => ({
|
|
account: { ...account, isDefault: account.id === defaultAccountId },
|
|
vendor: vendorMap.get(account.vendorId) || PROVIDER_TYPE_INFO.find(p => p.id === account.vendorId) as any,
|
|
status: statusMap.get(account.id),
|
|
}))
|
|
.sort((left, right) => {
|
|
if (left.account.id === defaultAccountId) return -1;
|
|
if (right.account.id === defaultAccountId) return 1;
|
|
return right.account.updatedAt.localeCompare(left.account.updatedAt);
|
|
});
|
|
}
|
|
return [];
|
|
});
|
|
|
|
const availableProviders = computed(() => {
|
|
return PROVIDER_TYPE_INFO.filter(p => !p.hidden);
|
|
});
|
|
|
|
onMounted(() => {
|
|
providerStore.init();
|
|
});
|
|
|
|
const handleSetDefault = async (id: string) => {
|
|
try {
|
|
await providerStore.setDefaultAccount(id);
|
|
ElMessage.success(t('models.aiProviders.setDefaultSuccess', 'Default provider set successfully'));
|
|
} catch (e: any) {
|
|
ElMessage.error(e.message || 'Failed to set default provider');
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (id: string) => {
|
|
try {
|
|
await providerStore.deleteAccount(id);
|
|
ElMessage.success(t('models.aiProviders.deleteSuccess', 'Provider deleted successfully'));
|
|
} catch (e: any) {
|
|
ElMessage.error(e.message || 'Failed to delete provider');
|
|
}
|
|
};
|
|
|
|
const handleEdit = (item: ProviderListItem) => {
|
|
editingItem.value = item;
|
|
editForm.value = {
|
|
apiKey: '', // Don't fetch actual key to UI unless requested, usually handled via backend updates
|
|
baseUrl: item.account.baseUrl || '',
|
|
model: item.account.model || ''
|
|
};
|
|
editDialogVisible.value = true;
|
|
};
|
|
|
|
const selectNewProvider = (provider: ProviderTypeInfo) => {
|
|
showAddDialog.value = false;
|
|
|
|
// Setup a new mock item to edit
|
|
editingItem.value = {
|
|
account: {
|
|
id: `${provider.id}-${Date.now()}`,
|
|
vendorId: provider.id,
|
|
label: provider.name,
|
|
authMode: provider.requiresApiKey ? 'api_key' : 'local',
|
|
baseUrl: provider.defaultBaseUrl || '',
|
|
model: provider.defaultModelId || '',
|
|
enabled: true,
|
|
isDefault: false,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
},
|
|
vendor: provider as any,
|
|
status: undefined
|
|
};
|
|
|
|
editForm.value = {
|
|
apiKey: '',
|
|
baseUrl: provider.defaultBaseUrl || '',
|
|
model: provider.defaultModelId || ''
|
|
};
|
|
editDialogVisible.value = true;
|
|
};
|
|
|
|
const validateKey = async (providerId: string, apiKey: string, baseUrl?: string) => {
|
|
if (!apiKey) {
|
|
ElMessage.warning(t('models.aiProviders.apiKeyRequired', 'Please enter an API Key first'));
|
|
return;
|
|
}
|
|
validating.value = true;
|
|
try {
|
|
// Note: If adding a new provider, we pass the vendor ID. If updating, we pass the account ID.
|
|
// The backend `validateApiKey` handles this mapping or we can adjust logic accordingly.
|
|
const vendorId = editingItem.value?.account.vendorId || providerId;
|
|
const res = await providerStore.validateApiKey(vendorId, apiKey, { baseUrl });
|
|
if (res.valid) {
|
|
ElMessage.success(t('models.aiProviders.validateSuccess', 'API Key is valid!'));
|
|
} else {
|
|
ElMessage.error(res.error || t('models.aiProviders.validateFail', 'Invalid API Key'));
|
|
}
|
|
} catch (e: any) {
|
|
ElMessage.error(e.message || 'Validation failed');
|
|
} finally {
|
|
validating.value = false;
|
|
}
|
|
};
|
|
|
|
const saveEdit = async () => {
|
|
if (!editingItem.value) return;
|
|
saving.value = true;
|
|
|
|
try {
|
|
const isNew = !displayProviders.value.find(p => p.account.id === editingItem.value!.account.id);
|
|
const updates = {
|
|
baseUrl: editForm.value.baseUrl || undefined,
|
|
model: editForm.value.model || undefined
|
|
};
|
|
|
|
if (isNew) {
|
|
// It's a new provider
|
|
const accountToCreate = {
|
|
...editingItem.value.account,
|
|
...updates
|
|
};
|
|
await providerStore.createAccount(accountToCreate, editForm.value.apiKey);
|
|
} else {
|
|
// Update existing
|
|
await providerStore.updateAccount(editingItem.value.account.id, updates, editForm.value.apiKey || undefined);
|
|
}
|
|
|
|
ElMessage.success(t('models.aiProviders.saveSuccess', 'Provider saved successfully'));
|
|
editDialogVisible.value = false;
|
|
} catch (e: any) {
|
|
ElMessage.error(e.message || 'Failed to save provider');
|
|
} finally {
|
|
saving.value = false;
|
|
}
|
|
};
|
|
</script>
|
|
|
|
<style scoped>
|
|
.provider-card {
|
|
border-radius: 12px;
|
|
border: 1px solid #E5E8EE;
|
|
box-shadow: none;
|
|
}
|
|
</style>
|
|
<style>
|
|
.custom-provider-dialog {
|
|
background-color: #F4F3EB !important;
|
|
border-radius: 20px !important;
|
|
}
|
|
.dark .custom-provider-dialog {
|
|
background-color: #1f1f22 !important;
|
|
}
|
|
.custom-provider-dialog .el-dialog__header {
|
|
display: none !important;
|
|
}
|
|
.custom-provider-dialog .el-dialog__body {
|
|
padding: 0 !important;
|
|
}
|
|
.custom-form .el-form-item__label {
|
|
font-weight: 600 !important;
|
|
color: #171717 !important;
|
|
padding-bottom: 8px !important;
|
|
font-size: 15px !important;
|
|
}
|
|
.dark .custom-form .el-form-item__label {
|
|
color: #f3f4f6 !important;
|
|
}
|
|
.custom-input .el-input__wrapper {
|
|
background-color: #F4F3EB !important;
|
|
border: 1px solid rgba(0,0,0,0.1) !important;
|
|
border-radius: 12px !important;
|
|
box-shadow: none !important;
|
|
padding: 10px 16px !important;
|
|
}
|
|
.dark .custom-input .el-input__wrapper {
|
|
background-color: #222225 !important;
|
|
border-color: rgba(255,255,255,0.1) !important;
|
|
}
|
|
.custom-input .el-input__wrapper:hover, .custom-input .el-input__wrapper.is-focus {
|
|
border-color: rgba(0,0,0,0.2) !important;
|
|
}
|
|
.dark .custom-input .el-input__wrapper:hover, .dark .custom-input .el-input__wrapper.is-focus {
|
|
border-color: rgba(255,255,255,0.2) !important;
|
|
}
|
|
.custom-input .el-input__inner {
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !important;
|
|
color: #171717 !important;
|
|
}
|
|
.dark .custom-input .el-input__inner {
|
|
color: #f3f4f6 !important;
|
|
}
|
|
.dark .custom-input .el-input__inner::placeholder {
|
|
color: #9ca3af !important;
|
|
}
|
|
</style> |