| <template> |
| <div class="provider-page"> |
| <v-container fluid class="pa-0"> |
| |
| <v-row class="d-flex justify-space-between align-center px-4 py-3 pb-4"> |
| <div> |
| <h1 class="text-h1 font-weight-bold mb-2"> |
| <v-icon color="black" class="me-2">mdi-creation</v-icon>{{ tm('title') }} |
| </h1> |
| <p class="text-subtitle-1 text-medium-emphasis mb-4"> |
| {{ tm('subtitle') }} |
| </p> |
| </div> |
| <div v-if="selectedProviderType !== 'chat_completion'"> |
| <v-btn color="primary" prepend-icon="mdi-plus" variant="tonal" @click="showAddProviderDialog = true" |
| rounded="xl" size="x-large"> |
| {{ tm('providers.addProvider') }} |
| </v-btn> |
| </div> |
| </v-row> |
| |
| <div> |
| |
| <v-tabs v-model="selectedProviderType" bg-color="transparent" class="mb-4"> |
| <v-tab v-for="type in providerTypes" :key="type.value" :value="type.value" class="font-weight-medium px-3"> |
| <v-icon start>{{ type.icon }}</v-icon> |
| {{ type.label }} |
| </v-tab> |
| </v-tabs> |
| |
| |
| <div v-if="selectedProviderType === 'chat_completion'" class="d-flex align-center justify-center"> |
| <v-row style="max-width: 1500px; "> |
| <v-col cols="12" md="4" lg="3" class="pr-md-4"> |
| <ProviderSourcesPanel |
| :displayed-provider-sources="displayedProviderSources" |
| :selected-provider-source="selectedProviderSource" |
| :available-source-types="availableSourceTypes" |
| :tm="tm" |
| :resolve-source-icon="resolveSourceIcon" |
| :get-source-display-name="getSourceDisplayName" |
| @add-provider-source="addProviderSource" |
| @select-provider-source="selectProviderSource" |
| @delete-provider-source="deleteProviderSource" |
| /> |
| </v-col> |
| |
| <v-col cols="12" md="8" lg="9"> |
| <v-card class="provider-config-card h-100" elevation="0" style="overflow-y: auto;"> |
| <v-card-title class="d-flex align-center justify-space-between flex-wrap ga-3 pt-4 pl-5"> |
| <div class="d-flex align-center ga-3" v-if="selectedProviderSource"> |
| <div> |
| <div class="text-h4 font-weight-bold">{{ selectedProviderSource.id }}</div> |
| <div class="text-caption text-medium-emphasis">{{ selectedProviderSource.api_base || 'N/A' }} |
| </div> |
| </div> |
| </div> |
| |
| <div class="d-flex align-center ga-2" v-if="selectedProviderSource"> |
| <v-btn color="success" prepend-icon="mdi-check" :loading="savingSource" |
| :disabled="!isSourceModified" @click="saveProviderSource" variant="flat"> |
| {{ tm('providerSources.save') }} |
| </v-btn> |
| </div> |
| </v-card-title> |
| |
| <v-card-text> |
| <template v-if="selectedProviderSource"> |
| <div> |
| <AstrBotConfig v-if="basicSourceConfig" :iterable="basicSourceConfig" :metadata="providerSourceSchema" |
| metadataKey="provider" :is-editing="true" /> |
| </div> |
| |
| <v-expansion-panels variant="accordion" class="mb-2"> |
| <v-expansion-panel elevation="0" class="border rounded-lg"> |
| <v-expansion-panel-title> |
| <span class="font-weight-medium">{{ tm('providerSources.advancedConfig') }}</span> |
| </v-expansion-panel-title> |
| <v-expansion-panel-text> |
| <AstrBotConfig v-if="advancedSourceConfig" :iterable="advancedSourceConfig" |
| :metadata="providerSourceSchema" metadataKey="provider" :is-editing="true" /> |
| </v-expansion-panel-text> |
| </v-expansion-panel> |
| </v-expansion-panels> |
| |
| <ProviderModelsPanel |
| :entries="filteredMergedModelEntries" |
| :available-count="availableModels.length" |
| v-model:model-search="modelSearch" |
| :loading-models="loadingModels" |
| :is-source-modified="isSourceModified" |
| :supports-image-input="supportsImageInput" |
| :supports-tool-call="supportsToolCall" |
| :supports-reasoning="supportsReasoning" |
| :format-context-limit="formatContextLimit" |
| :testing-providers="testingProviders" |
| :tm="tm" |
| @fetch-models="fetchAvailableModels" |
| @open-manual-model="openManualModelDialog" |
| @open-provider-edit="openProviderEdit" |
| @toggle-provider-enable="toggleProviderEnable" |
| @test-provider="testProvider" |
| @delete-provider="deleteProvider" |
| @add-model-provider="addModelProvider" |
| /> |
| </template> |
| <div v-else class="text-center py-8 text-medium-emphasis"> |
| <v-icon size="48" color="grey-lighten-1">mdi-cursor-default-click</v-icon> |
| <p class="mt-2">{{ tm('providerSources.selectHint') }}</p> |
| </div> |
| </v-card-text> |
| </v-card> |
| </v-col> |
| </v-row> |
| </div> |
| |
| |
| <template v-else> |
| <v-row v-if="filteredProviders.length === 0"> |
| <v-col cols="12" class="text-center pa-8"> |
| <v-icon size="64" color="grey-lighten-1">mdi-api-off</v-icon> |
| <p class="text-grey mt-4">{{ getEmptyText() }}</p> |
| </v-col> |
| </v-row> |
| <v-row v-else> |
| <v-col v-for="(provider, index) in filteredProviders" :key="index" cols="12" md="6" lg="4" xl="3"> |
| <item-card :item="provider" title-field="id" enabled-field="enable" |
| :loading="isProviderTesting(provider.id)" @toggle-enabled="toggleProviderEnable(provider, !provider.enable)" |
| :bglogo="getProviderIcon(provider.provider)" @delete="deleteProvider" @edit="configExistingProvider" |
| @copy="copyProvider" :show-copy-button="true"> |
| |
| <template #item-details="{ item }"> |
| |
| <v-tooltip v-if="getProviderStatus(item.id)" location="top" max-width="300"> |
| <template v-slot:activator="{ props }"> |
| <v-chip v-bind="props" :color="getStatusColor(getProviderStatus(item.id).status)" size="small"> |
| <v-icon start size="small"> |
| {{ getProviderStatus(item.id).status === 'available' ? 'mdi-check-circle' : |
| getProviderStatus(item.id).status === 'unavailable' ? 'mdi-alert-circle' : |
| 'mdi-clock-outline' }} |
| </v-icon> |
| {{ getStatusText(getProviderStatus(item.id).status) }} |
| </v-chip> |
| </template> |
| <span v-if="getProviderStatus(item.id).status === 'unavailable'"> |
| {{ getProviderStatus(item.id).error }} |
| </span> |
| <span v-else>{{ getStatusText(getProviderStatus(item.id).status) }}</span> |
| </v-tooltip> |
| </template> |
| <template #actions="{ item }"> |
| <v-btn style="z-index: 100000;" variant="tonal" color="info" rounded="xl" size="small" |
| :loading="isProviderTesting(item.id)" @click="testSingleProvider(item)"> |
| {{ tm('availability.test') }} |
| </v-btn> |
| </template> |
| </item-card> |
| </v-col> |
| </v-row> |
| </template> |
| </div> |
| </v-container> |
| |
| |
| <AddNewProvider v-model:show="showAddProviderDialog" :metadata="configSchema" |
| @select-template="selectProviderTemplate" /> |
| |
| |
| <v-dialog v-model="showManualModelDialog" max-width="400"> |
| <v-card :title="tm('models.manualDialogTitle')"> |
| <v-card-text class="py-4"> |
| <v-text-field v-model="manualModelId" :label="tm('models.manualDialogModelLabel')" flat variant="solo-filled" autofocus clearable></v-text-field> |
| <v-text-field :model-value="manualProviderId" flat variant="solo-filled" :label="tm('models.manualDialogPreviewLabel')" persistent-hint |
| :hint="tm('models.manualDialogPreviewHint')"></v-text-field> |
| </v-card-text> |
| <v-card-actions class="pa-4"> |
| <v-spacer></v-spacer> |
| <v-btn variant="text" @click="showManualModelDialog = false">取消</v-btn> |
| <v-btn color="primary" @click="confirmManualModel">添加</v-btn> |
| </v-card-actions> |
| </v-card> |
| </v-dialog> |
| |
| |
| <v-dialog v-model="showProviderCfg" width="900" persistent> |
| <v-card |
| :title="updatingMode ? tm('dialogs.config.editTitle') : tm('dialogs.config.addTitle') + ` ${newSelectedProviderName} ` + tm('dialogs.config.provider')"> |
| <v-card-text class="py-4"> |
| <AstrBotConfig :iterable="newSelectedProviderConfig" :metadata="configSchema" |
| metadataKey="provider" :is-editing="updatingMode" /> |
| </v-card-text> |
| |
| <v-divider></v-divider> |
| |
| <v-card-actions class="pa-4"> |
| <v-spacer></v-spacer> |
| <v-btn variant="text" @click="showProviderCfg = false" :disabled="loading"> |
| {{ tm('dialogs.config.cancel') }} |
| </v-btn> |
| <v-btn color="primary" @click="newProvider" :loading="loading"> |
| {{ tm('dialogs.config.save') }} |
| </v-btn> |
| </v-card-actions> |
| </v-card> |
| </v-dialog> |
| |
| |
| <v-dialog v-model="showProviderEditDialog" width="800"> |
| <v-card :title="providerEditData?.id || tm('dialogs.config.editTitle')"> |
| <v-card-text class="py-4"> |
| <small style="color: gray;">不建议修改 ID,可能会导致指向该模型的相关配置(如默认模型、插件相关配置等)失效。旧版本 AstrBot 的 “提供商 ID” 是下方的 “ID”。</small> |
| <AstrBotConfig v-if="providerEditData" :iterable="providerEditData" :metadata="configSchema" |
| metadataKey="provider" :is-editing="true" /> |
| </v-card-text> |
| <v-card-actions class="pa-4"> |
| <v-spacer></v-spacer> |
| <v-btn variant="text" @click="showProviderEditDialog = false" |
| :disabled="savingProviders.includes(providerEditData?.id)"> |
| {{ tm('dialogs.config.cancel') }} |
| </v-btn> |
| <v-btn color="primary" @click="saveEditedProvider" :loading="savingProviders.includes(providerEditData?.id)"> |
| {{ tm('dialogs.config.save') }} |
| </v-btn> |
| </v-card-actions> |
| </v-card> |
| </v-dialog> |
| |
| |
| <v-snackbar v-model="snackbar.show" :color="snackbar.color" :timeout="3000" location="top"> |
| {{ snackbar.message }} |
| </v-snackbar> |
| |
| |
| <v-dialog v-model="showAgentRunnerDialog" max-width="520" persistent> |
| <v-card> |
| <v-card-title class="text-h3 d-flex align-center"> |
| <v-icon start class="me-2">mdi-information</v-icon> |
| 请前往「配置文件」页测试 Agent 执行器 |
| </v-card-title> |
| <v-card-text class="py-4 text-body-1 text-medium-emphasis"> |
| Agent 执行器的测试请在「配置文件」页进行。 |
| <ol class="ml-4 mt-4 mb-4"> |
| <li>找到对应的配置文件并打开。</li> |
| <li>找到 Agent 执行方式部分,修改执行器后点击保存。</li> |
| <li>点击右下角的 💬 聊天按钮进行测试。</li> |
| </ol> |
| 要让机器人应用这个 Agent 执行器,你也需要前往修改 Agent 执行器。 |
| </v-card-text> |
| <v-card-actions> |
| <v-spacer></v-spacer> |
| <v-btn color="grey" variant="text" @click="showAgentRunnerDialog = false">好的</v-btn> |
| <v-btn color="primary" variant="flat" @click="goToConfigPage">点击前往</v-btn> |
| </v-card-actions> |
| </v-card> |
| </v-dialog> |
| </div> |
| </template> |
| |
| <script setup> |
| import { ref, watch } from 'vue' |
| import { useRouter } from 'vue-router' |
| import axios from 'axios' |
| import { useModuleI18n } from '@/i18n/composables' |
| import AstrBotConfig from '@/components/shared/AstrBotConfig.vue' |
| import ItemCard from '@/components/shared/ItemCard.vue' |
| import AddNewProvider from '@/components/provider/AddNewProvider.vue' |
| import ProviderModelsPanel from '@/components/provider/ProviderModelsPanel.vue' |
| import ProviderSourcesPanel from '@/components/provider/ProviderSourcesPanel.vue' |
| import { useProviderSources } from '@/composables/useProviderSources' |
| import { getProviderIcon } from '@/utils/providerUtils' |
| |
| const props = defineProps({ |
| defaultTab: { |
| type: String, |
| default: 'chat_completion' |
| } |
| }) |
| |
| const { tm } = useModuleI18n('features/provider') |
| const router = useRouter() |
| |
| const snackbar = ref({ |
| show: false, |
| message: '', |
| color: 'success' |
| }) |
| |
| function showMessage(message, color = 'success') { |
| snackbar.value = { show: true, message, color } |
| } |
| |
| const { |
| providers, |
| selectedProviderType, |
| selectedProviderSource, |
| availableModels, |
| loadingModels, |
| savingSource, |
| testingProviders, |
| isSourceModified, |
| configSchema, |
| providerSourceSchema, |
| manualModelId, |
| modelSearch, |
| providerTypes, |
| availableSourceTypes, |
| displayedProviderSources, |
| filteredMergedModelEntries, |
| filteredProviders, |
| basicSourceConfig, |
| advancedSourceConfig, |
| manualProviderId, |
| resolveSourceIcon, |
| getSourceDisplayName, |
| supportsImageInput, |
| supportsToolCall, |
| supportsReasoning, |
| formatContextLimit, |
| updateDefaultTab, |
| selectProviderSource, |
| addProviderSource, |
| deleteProviderSource, |
| saveProviderSource, |
| fetchAvailableModels, |
| addModelProvider, |
| deleteProvider, |
| modelAlreadyConfigured, |
| testProvider, |
| loadConfig, |
| } = useProviderSources({ |
| defaultTab: props.defaultTab, |
| tm, |
| showMessage |
| }) |
| |
| |
| const showAddProviderDialog = ref(false) |
| const showProviderCfg = ref(false) |
| const newSelectedProviderName = ref('') |
| const newSelectedProviderConfig = ref({}) |
| const newProviderOriginalId = ref('') |
| const updatingMode = ref(false) |
| const loading = ref(false) |
| const providerStatuses = ref([]) |
| const showAgentRunnerDialog = ref(false) |
| const showProviderEditDialog = ref(false) |
| const providerEditData = ref(null) |
| const providerEditOriginalId = ref('') |
| const showManualModelDialog = ref(false) |
| |
| const savingProviders = ref([]) |
| |
| function openProviderEdit(provider) { |
| providerEditData.value = JSON.parse(JSON.stringify(provider)) |
| providerEditOriginalId.value = provider.id |
| showProviderEditDialog.value = true |
| } |
| |
| function openManualModelDialog() { |
| if (!selectedProviderSource.value) { |
| showMessage(tm('providerSources.selectHint'), 'error') |
| return |
| } |
| manualModelId.value = '' |
| showManualModelDialog.value = true |
| } |
| |
| async function confirmManualModel() { |
| const modelId = manualModelId.value.trim() |
| if (!selectedProviderSource.value) { |
| showMessage(tm('providerSources.selectHint'), 'error') |
| return |
| } |
| if (!modelId) { |
| showMessage(tm('models.manualModelRequired'), 'error') |
| return |
| } |
| if (modelAlreadyConfigured(modelId)) { |
| showMessage(tm('models.manualModelExists'), 'error') |
| return |
| } |
| await addModelProvider(modelId) |
| showManualModelDialog.value = false |
| } |
| |
| watch(() => props.defaultTab, (val) => { |
| updateDefaultTab(val) |
| }) |
| |
| |
| function getEmptyText() { |
| return tm('providers.empty.typed', { type: selectedProviderType.value }) |
| } |
| |
| function selectProviderTemplate(name) { |
| newSelectedProviderName.value = name |
| newProviderOriginalId.value = '' |
| showProviderCfg.value = true |
| updatingMode.value = false |
| newSelectedProviderConfig.value = JSON.parse(JSON.stringify( |
| configSchema.value.provider.config_template[name] || {} |
| )) |
| } |
| |
| function configExistingProvider(provider) { |
| newSelectedProviderName.value = provider.id |
| newProviderOriginalId.value = provider.id |
| newSelectedProviderConfig.value = {} |
| |
| |
| let templates = configSchema.value.provider.config_template || {} |
| let defaultConfig = {} |
| for (let key in templates) { |
| if (templates[key]?.type === provider.type) { |
| defaultConfig = templates[key] |
| break |
| } |
| } |
| |
| const mergeConfigWithOrder = (target, source, reference) => { |
| if (source && typeof source === 'object' && !Array.isArray(source)) { |
| for (let key in source) { |
| if (source.hasOwnProperty(key)) { |
| if (typeof source[key] === 'object' && source[key] !== null) { |
| target[key] = Array.isArray(source[key]) ? [...source[key]] : { ...source[key] } |
| } else { |
| target[key] = source[key] |
| } |
| } |
| } |
| } |
| |
| for (let key in reference) { |
| if (typeof reference[key] === 'object' && reference[key] !== null) { |
| if (!(key in target)) { |
| if (Array.isArray(reference[key])) { |
| target[key] = [...reference[key]] |
| } else { |
| target[key] = {} |
| } |
| } |
| if (!Array.isArray(reference[key])) { |
| mergeConfigWithOrder( |
| target[key], |
| source && source[key] ? source[key] : {}, |
| reference[key] |
| ) |
| } |
| } else if (!(key in target)) { |
| target[key] = reference[key] |
| } |
| } |
| } |
| |
| if (defaultConfig) { |
| mergeConfigWithOrder(newSelectedProviderConfig.value, provider, defaultConfig) |
| } |
| |
| showProviderCfg.value = true |
| updatingMode.value = true |
| } |
| |
| async function newProvider() { |
| loading.value = true |
| const wasUpdating = updatingMode.value |
| try { |
| if (wasUpdating) { |
| const res = await axios.post('/api/config/provider/update', { |
| id: newProviderOriginalId.value || newSelectedProviderName.value, |
| config: newSelectedProviderConfig.value |
| }) |
| if (res.data.status === 'error') { |
| showMessage(res.data.message || "更新失败!", 'error') |
| return |
| } |
| showMessage(res.data.message || "更新成功!") |
| if (wasUpdating) { |
| updatingMode.value = false |
| } |
| } else { |
| const res = await axios.post('/api/config/provider/new', newSelectedProviderConfig.value) |
| if (res.data.status === 'error') { |
| showMessage(res.data.message || "添加失败!", 'error') |
| return |
| } |
| showMessage(res.data.message || "添加成功!") |
| } |
| showProviderCfg.value = false |
| } catch (err) { |
| showMessage(err.response?.data?.message || err.message, 'error') |
| } finally { |
| loading.value = false |
| await loadConfig() |
| } |
| } |
| |
| async function saveEditedProvider() { |
| if (!providerEditData.value) return |
| |
| savingProviders.value.push(providerEditData.value.id) |
| try { |
| const res = await axios.post('/api/config/provider/update', { |
| id: providerEditOriginalId.value || providerEditData.value.id, |
| config: providerEditData.value |
| }) |
| |
| if (res.data.status === 'error') { |
| throw new Error(res.data.message) |
| } |
| |
| showMessage(res.data.message || tm('providerSources.saveSuccess')) |
| showProviderEditDialog.value = false |
| await loadConfig() |
| } catch (err) { |
| showMessage(err.response?.data?.message || err.message || tm('providerSources.saveError'), 'error') |
| } finally { |
| savingProviders.value = savingProviders.value.filter(id => id !== providerEditData.value?.id) |
| } |
| } |
| |
| async function copyProvider(providerToCopy) { |
| const newProviderConfig = JSON.parse(JSON.stringify(providerToCopy)) |
| |
| const generateUniqueId = (baseId) => { |
| let newId = `${baseId}_copy` |
| let counter = 1 |
| const existingIds = providers.value.map(p => p.id) |
| while (existingIds.includes(newId)) { |
| newId = `${baseId}_copy_${counter}` |
| counter++ |
| } |
| return newId |
| } |
| newProviderConfig.id = generateUniqueId(providerToCopy.id) |
| newProviderConfig.enable = false |
| |
| loading.value = true |
| try { |
| const res = await axios.post('/api/config/provider/new', newProviderConfig) |
| showMessage(res.data.message || `成功复制并创建了 ${newProviderConfig.id}`) |
| await loadConfig() |
| } catch (err) { |
| showMessage(err.response?.data?.message || err.message, 'error') |
| } finally { |
| loading.value = false |
| } |
| } |
| |
| async function toggleProviderEnable(provider, value) { |
| provider.enable = value |
| |
| try { |
| const res = await axios.post('/api/config/provider/update', { |
| id: provider.id, |
| config: provider |
| }) |
| |
| if (res.data.status === 'error') { |
| throw new Error(res.data.message) |
| } |
| showMessage(res.data.message || tm('messages.success.statusUpdate')) |
| } catch (error) { |
| showMessage(error.response?.data?.message || error.message || tm('providerSources.saveError'), 'error') |
| } finally { |
| await loadConfig() |
| } |
| } |
| |
| function isProviderTesting(providerId) { |
| return testingProviders.value.includes(providerId) |
| } |
| |
| function getProviderStatus(providerId) { |
| return providerStatuses.value.find(s => s.id === providerId) |
| } |
| |
| async function testSingleProvider(provider) { |
| if (isProviderTesting(provider.id)) return |
| |
| testingProviders.value.push(provider.id) |
| |
| const statusIndex = providerStatuses.value.findIndex(s => s.id === provider.id) |
| const pendingStatus = { |
| id: provider.id, |
| name: provider.id, |
| status: 'pending', |
| error: null |
| } |
| if (statusIndex !== -1) { |
| providerStatuses.value.splice(statusIndex, 1, pendingStatus) |
| } else { |
| providerStatuses.value.unshift(pendingStatus) |
| } |
| |
| try { |
| if (!provider.enable) { |
| throw new Error('该提供商未被用户启用') |
| } |
| if (provider.provider_type === 'agent_runner') { |
| showAgentRunnerDialog.value = true |
| providerStatuses.value = providerStatuses.value.filter(s => s.id !== provider.id) |
| return |
| } |
| |
| const res = await axios.get(`/api/config/provider/check_one?id=${provider.id}`) |
| if (res.data && res.data.status === 'ok') { |
| const index = providerStatuses.value.findIndex(s => s.id === provider.id) |
| if (index !== -1) { |
| providerStatuses.value.splice(index, 1, res.data.data) |
| } |
| } else { |
| throw new Error(res.data?.message || `Failed to check status for ${provider.id}`) |
| } |
| } catch (err) { |
| const errorMessage = err.response?.data?.message || err.message || 'Unknown error' |
| const index = providerStatuses.value.findIndex(s => s.id === provider.id) |
| const failedStatus = { |
| id: provider.id, |
| name: provider.id, |
| status: 'unavailable', |
| error: errorMessage |
| } |
| if (index !== -1) { |
| providerStatuses.value.splice(index, 1, failedStatus) |
| } |
| } finally { |
| const index = testingProviders.value.indexOf(provider.id) |
| if (index > -1) { |
| testingProviders.value.splice(index, 1) |
| } |
| } |
| } |
| |
| function getStatusColor(status) { |
| switch (status) { |
| case 'available': |
| return 'success' |
| case 'unavailable': |
| return 'error' |
| case 'pending': |
| return 'grey' |
| default: |
| return 'default' |
| } |
| } |
| |
| function getStatusText(status) { |
| const messages = { |
| available: tm('availability.available'), |
| unavailable: tm('availability.unavailable'), |
| pending: tm('availability.pending') |
| } |
| return messages[status] || status |
| } |
| |
| function goToConfigPage() { |
| router.push('/config') |
| showAgentRunnerDialog.value = false |
| } |
| |
| </script> |
| |
| <style scoped> |
| .provider-page { |
| padding: 20px; |
| padding-top: 8px; |
| padding-bottom: 40px; |
| } |
| |
| .provider-config-card { |
| min-height: 280px; |
| } |
| |
| @media (max-width: 960px) { |
| .provider-config-card { |
| min-height: auto; |
| } |
| } |
| </style> |
| |