Files
AstrBot/dashboard/src/views/ProviderPage.vue
Soulter f0a1dd79c4 perf: improve provider config ui (#7772)
* stage

* style: update font families and improve responsive design across components
2026-04-24 20:46:45 +08:00

919 lines
27 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 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"
rounded="xl"
size="x-large"
@click="showAddProviderDialog = true"
>
{{ 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="provider-workbench">
<div class="provider-workbench__sidebar">
<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"
/>
</div>
<div class="provider-workbench__divider"></div>
<div class="provider-workbench__main">
<div v-if="selectedProviderSource" class="provider-config-shell">
<div class="provider-config-header">
<div class="provider-config-headline">
<div class="provider-config-title">{{ selectedProviderSource.id }}</div>
<div class="provider-config-subtitle">
{{ selectedProviderSource.api_base || 'N/A' }}
</div>
</div>
<div class="provider-config-actions">
<v-btn
color="primary"
prepend-icon="mdi-content-save-outline"
:loading="savingSource"
:disabled="!isSourceModified"
variant="tonal"
rounded="xl"
@click="saveProviderSource"
>
{{ tm('providerSources.save') }}
</v-btn>
</div>
</div>
<v-divider></v-divider>
<div class="provider-config-body">
<section class="provider-section">
<div class="provider-section-head">
<div class="provider-section-title">{{ tm('providers.settings') }}</div>
</div>
<AstrBotConfig
v-if="basicSourceConfig"
:iterable="basicSourceConfig"
:metadata="providerSourceSchema"
metadataKey="provider"
:is-editing="true"
/>
</section>
<v-divider v-if="advancedSourceConfig"></v-divider>
<section v-if="advancedSourceConfig" class="provider-section">
<div class="provider-section-head">
<div class="provider-section-title">{{ tm('providerSources.advancedConfig') }}</div>
</div>
<AstrBotConfig
:iterable="advancedSourceConfig"
:metadata="providerSourceSchema"
metadataKey="provider"
:is-editing="true"
/>
</section>
<v-divider></v-divider>
<section class="provider-section provider-section--models">
<ProviderModelsPanel
:entries="filteredMergedModelEntries"
:available-count="availableModels.length"
v-model:model-search="modelSearch"
:loading-models="loadingModels"
:is-source-modified="isSourceModified"
:supports-image-input="supportsImageInput"
:supports-audio-input="supportsAudioInput"
: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"
/>
</section>
</div>
</div>
<div v-else class="provider-empty-state">
<v-icon size="48" color="grey-lighten-1">mdi-cursor-default-click</v-icon>
<p class="mt-2">{{ tm('providerSources.selectHint') }}</p>
</div>
</div>
</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)"
:bglogo="getProviderIcon(provider.provider)"
:show-copy-button="true"
@toggle-enabled="toggleProviderEnable(provider, !provider.enable)"
@delete="deleteProvider"
@edit="configExistingProvider"
@copy="copyProvider"
>
<template #item-details="{ item }">
<v-tooltip v-if="getProviderStatus(item.id)" location="top" max-width="300">
<template #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"
:current-provider-type="selectedProviderType"
@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" :disabled="loading" @click="showProviderCfg = false">
{{ tm('dialogs.config.cancel') }}
</v-btn>
<v-btn color="primary" :loading="loading" @click="newProvider">
{{ 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"
:disabled="savingProviders.includes(providerEditData?.id)"
@click="showProviderEditDialog = false"
>
{{ tm('dialogs.config.cancel') }}
</v-btn>
<v-btn
color="primary"
:loading="savingProviders.includes(providerEditData?.id)"
@click="saveEditedProvider"
>
{{ 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,
supportsAudioInput,
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 startTime = performance.now()
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)
}
const latency = Math.max(0, Math.round(performance.now() - startTime))
showMessage(tm('models.testSuccessWithLatency', { id: provider.id, latency }))
} 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 {
--provider-surface: rgb(var(--v-theme-surface));
--provider-border: rgba(var(--v-theme-on-surface), 0.08);
padding: 20px;
padding-top: 8px;
padding-bottom: 40px;
}
.provider-workbench {
border: 1px solid var(--provider-border);
border-radius: 24px;
background: var(--provider-surface);
display: grid;
grid-template-columns: minmax(280px, 320px) 1px minmax(0, 1fr);
min-height: 760px;
overflow: hidden;
}
.provider-workbench__sidebar,
.provider-workbench__main {
min-width: 0;
}
.provider-workbench__divider {
background: var(--provider-border);
}
.provider-workbench__main {
display: flex;
}
.provider-config-shell {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.provider-config-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
padding: 18px 22px 14px;
}
.provider-config-headline {
min-width: 0;
}
.provider-config-title {
font-size: 21px;
line-height: 1.1;
font-weight: 680;
letter-spacing: -0.03em;
overflow-wrap: anywhere;
}
.provider-config-subtitle {
margin-top: 6px;
color: rgba(var(--v-theme-on-surface), 0.62);
font-size: 13px;
line-height: 1.6;
overflow-wrap: anywhere;
}
.provider-config-actions {
flex-shrink: 0;
}
.provider-config-body {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.provider-section {
padding: 18px 22px;
}
.provider-section--models {
padding-top: 16px;
}
.provider-section-head {
margin-bottom: 10px;
}
.provider-section-title {
font-size: 16px;
font-weight: 650;
line-height: 1.4;
}
.provider-empty-state {
flex: 1;
min-height: 420px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: rgba(var(--v-theme-on-surface), 0.56);
}
@media (max-width: 960px) {
.provider-page {
padding: 12px;
padding-bottom: 32px;
}
.provider-workbench {
grid-template-columns: 1fr;
grid-template-rows: auto 1px auto;
min-height: auto;
}
.provider-workbench__divider {
height: 1px;
}
.provider-config-header {
flex-direction: column;
align-items: stretch;
padding: 16px;
}
.provider-config-actions :deep(.v-btn) {
width: 100%;
}
.provider-section {
padding: 16px;
}
}
@media (max-width: 600px) {
.provider-page {
padding: 8px;
padding-bottom: 24px;
}
.provider-page :deep(.v-container) > .v-row:first-child {
margin: 0;
padding: 8px 4px 16px !important;
}
.provider-page :deep(.v-container) > .v-row:first-child > div {
width: 100%;
}
.provider-page :deep(.v-container) > .v-row:first-child .v-btn {
width: 100%;
}
.provider-page :deep(.v-tabs) {
overflow-x: auto;
}
.provider-workbench {
border-radius: 16px;
overflow: visible;
}
.provider-workbench__main {
overflow: visible;
}
.provider-config-body {
overflow-y: visible;
}
.provider-config-title {
font-size: 18px;
}
.provider-empty-state {
min-height: 260px;
padding: 24px;
}
}
</style>