Files
AstrBot/dashboard/src/components/chat/ProviderConfigDialog.vue
2026-03-18 20:12:22 +08:00

496 lines
14 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>
<v-dialog
v-model="dialog"
:max-width="isMobile ? undefined : '1400'"
:fullscreen="isMobile"
scrollable
>
<v-card
class="provider-config-dialog"
:class="{ 'mobile-dialog': isMobile }"
>
<v-card-title class="d-flex align-center justify-space-between pa-4 pb-0">
<div class="d-flex align-center ga-2">
<span class="text-h2 font-weight-bold">{{ tm('title') }}</span>
</div>
<v-btn
icon
variant="text"
@click="closeDialog"
>
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-text
class="pa-4 pt-0"
:class="{ 'mobile-content': isMobile }"
:style="isMobile ? {} : { height: 'calc(100vh - 200px); max-height: 800px;' }"
>
<div
:class="isMobile ? 'mobile-layout' : 'd-flex'"
:style="isMobile ? {} : { height: '100%' }"
>
<!-- 左侧Provider Sources 列表 -->
<div
class="provider-sources-column"
:class="{ 'mobile-sources': isMobile }"
:style="isMobile ? {} : { width: '320px', minWidth: '320px', borderRight: '1px solid rgba(var(--v-border-color), var(--v-border-opacity))', overflowY: 'auto' }"
>
<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-config-column"
:class="{ 'mobile-config': isMobile }"
:style="isMobile ? {} : { flex: 1, overflowY: 'auto', minWidth: 0 }"
>
<div
v-if="selectedProviderSource"
class="pa-4"
>
<!-- Provider Source 配置 -->
<div class="mb-4">
<div class="d-flex align-center justify-space-between mb-3">
<div>
<div class="text-h5 font-weight-bold">
{{ selectedProviderSource.id }}
</div>
<div class="text-caption text-medium-emphasis">
{{ selectedProviderSource.api_base || 'N/A' }}
</div>
</div>
<v-btn
color="success"
prepend-icon="mdi-check"
:loading="savingSource"
:disabled="!isSourceModified"
variant="flat"
@click="saveProviderSource"
>
{{ tm('providerSources.save') }}
</v-btn>
</div>
<!-- 基础配置 -->
<div class="mb-4">
<AstrBotConfig
v-if="basicSourceConfig"
:iterable="basicSourceConfig"
:metadata="configSchema"
metadata-key="provider"
:is-editing="true"
/>
</div>
<!-- 高级配置 -->
<v-expansion-panels
variant="accordion"
class="mb-4"
>
<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="configSchema"
metadata-key="provider"
:is-editing="true"
/>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
<!-- 模型配置 -->
<ProviderModelsPanel
v-model:model-search="modelSearch"
:entries="filteredMergedModelEntries"
:available-count="availableModels.length"
: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"
/>
</div>
</div>
<div
v-else
class="d-flex align-center justify-center"
style="height: 100%;"
>
<div class="text-center text-medium-emphasis">
<v-icon
size="64"
color="grey-lighten-1"
>
mdi-cursor-default-click
</v-icon>
<p class="mt-4 text-h6">
{{ tm('providerSources.selectHint') }}
</p>
</div>
</div>
</div>
</div>
</v-card-text>
</v-card>
<!-- 手动添加模型对话框 -->
<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
:model-value="manualProviderId"
flat
variant="solo-filled"
:label="tm('models.manualDialogPreviewLabel')"
persistent-hint
:hint="tm('models.manualDialogPreviewHint')"
/>
</v-card-text>
<v-card-actions class="pa-4">
<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="showProviderEditDialog"
width="800"
>
<v-card :title="providerEditData?.id || tm('dialogs.config.editTitle')">
<v-card-text class="py-4">
<small style="color: gray;">不建议修改 ID可能会导致指向该模型的相关配置如默认模型插件相关配置等失效</small>
<AstrBotConfig
v-if="providerEditData"
:iterable="providerEditData"
:metadata="configSchema"
metadata-key="provider"
:is-editing="true"
/>
</v-card-text>
<v-card-actions class="pa-4">
<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-dialog>
</template>
<script setup>
import { ref, watch, computed, onMounted, onBeforeUnmount } from 'vue'
import { useModuleI18n } from '@/i18n/composables'
import AstrBotConfig from '@/components/shared/AstrBotConfig.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'
import axios from 'axios'
const props = defineProps({
modelValue: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:modelValue'])
const { tm } = useModuleI18n('features/provider')
// 检测是否为手机端
const isMobile = ref(false)
function checkMobile() {
isMobile.value = window.innerWidth <= 768
}
const dialog = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
const snackbar = ref({
show: false,
message: '',
color: 'success'
})
function showMessage(message, color = 'success') {
snackbar.value = { show: true, message, color }
}
const {
selectedProviderSource,
availableModels,
loadingModels,
savingSource,
testingProviders,
isSourceModified,
configSchema,
manualModelId,
modelSearch,
availableSourceTypes,
displayedProviderSources,
filteredMergedModelEntries,
basicSourceConfig,
advancedSourceConfig,
manualProviderId,
resolveSourceIcon,
getSourceDisplayName,
supportsImageInput,
supportsToolCall,
supportsReasoning,
formatContextLimit,
selectProviderSource,
addProviderSource,
deleteProviderSource,
saveProviderSource,
fetchAvailableModels,
addModelProvider,
deleteProvider,
testProvider,
loadConfig,
modelAlreadyConfigured,
} = useProviderSources({
defaultTab: 'chat_completion',
tm,
showMessage
})
const showManualModelDialog = ref(false)
const showProviderEditDialog = ref(false)
const providerEditData = ref(null)
const providerEditOriginalId = ref('')
const savingProviders = ref([])
function closeDialog() {
dialog.value = false
}
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
}
function openProviderEdit(provider) {
providerEditData.value = JSON.parse(JSON.stringify(provider))
providerEditOriginalId.value = provider.id
showProviderEditDialog.value = true
}
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 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()
}
}
// 监听 dialog 打开,加载配置
watch(dialog, (newVal) => {
if (newVal) {
loadConfig()
checkMobile()
}
})
onMounted(() => {
checkMobile()
window.addEventListener('resize', checkMobile)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', checkMobile)
})
</script>
<style scoped>
.provider-config-dialog {
height: calc(100vh - 100px);
display: flex;
flex-direction: column;
}
.provider-config-dialog.mobile-dialog {
height: 100vh;
}
.provider-sources-column {
overflow-y: auto;
background-color: var(--v-theme-surface);
}
.provider-config-column {
background-color: var(--v-theme-background);
}
.border {
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
/* 手机端样式 */
.mobile-content {
padding: 8px !important;
padding-top: 0 !important;
height: calc(100vh - 64px) !important;
max-height: none !important;
}
.mobile-layout {
display: flex;
flex-direction: column;
height: 100%;
gap: 16px;
}
.mobile-sources {
width: 100% !important;
min-width: 100% !important;
border-right: none !important;
border-bottom: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
max-height: 40vh;
overflow-y: auto;
}
.mobile-config {
flex: 1;
overflow-y: auto;
min-width: 100% !important;
}
@media (max-width: 768px) {
.provider-config-dialog :deep(.v-card-title) {
padding: 12px 16px !important;
}
.provider-config-dialog :deep(.v-card-title .text-h2) {
font-size: 1.5rem !important;
}
}
</style>