delete: remove backup of ProviderPage.vue
This commit is contained in:
@@ -193,9 +193,67 @@ class ConfigRoute(Route):
|
||||
"POST",
|
||||
self.update_provider_source,
|
||||
),
|
||||
"/config/provider_sources/<provider_source_id>/delete": (
|
||||
"POST",
|
||||
self.delete_provider_source,
|
||||
),
|
||||
}
|
||||
self.register_routes()
|
||||
|
||||
async def delete_provider_source(self, provider_source_id: str):
|
||||
"""删除 provider_source,并更新关联的 providers"""
|
||||
|
||||
provider_sources = self.config.get("provider_sources", [])
|
||||
target_idx = next(
|
||||
(
|
||||
i
|
||||
for i, ps in enumerate(provider_sources)
|
||||
if ps.get("id") == provider_source_id
|
||||
),
|
||||
-1,
|
||||
)
|
||||
|
||||
if target_idx == -1:
|
||||
return Response().error("未找到对应的 provider source").__dict__
|
||||
|
||||
# 删除 provider_source
|
||||
del provider_sources[target_idx]
|
||||
|
||||
# 更新引用了该 provider_source 的 providers
|
||||
affected_providers = []
|
||||
for provider in self.config.get("provider", []):
|
||||
if provider.get("provider_source_id") == provider_source_id:
|
||||
provider["provider_source_id"] = None
|
||||
affected_providers.append(provider)
|
||||
|
||||
# 写回配置
|
||||
self.config["provider_sources"] = provider_sources
|
||||
|
||||
try:
|
||||
save_config(self.config, self.config, is_core=True)
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
return Response().error(str(e)).__dict__
|
||||
|
||||
# 重载受影响的 providers,使新的 source 配置生效
|
||||
reload_errors = []
|
||||
prov_mgr = self.core_lifecycle.provider_manager
|
||||
for provider in affected_providers:
|
||||
try:
|
||||
await prov_mgr.reload(provider)
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
reload_errors.append(f"{provider.get('id')}: {e}")
|
||||
|
||||
if reload_errors:
|
||||
return (
|
||||
Response()
|
||||
.error("删除成功,但部分提供商重载失败: " + ", ".join(reload_errors))
|
||||
.__dict__
|
||||
)
|
||||
|
||||
return Response().ok(message="删除 provider source 成功").__dict__
|
||||
|
||||
async def update_provider_source(self, provider_source_id: str):
|
||||
"""更新或新增 provider_source,并重载关联的 providers"""
|
||||
|
||||
@@ -740,7 +798,15 @@ class ConfigRoute(Route):
|
||||
data = await request.json
|
||||
config = data.get("config", None)
|
||||
conf_id = data.get("conf_id", None)
|
||||
|
||||
try:
|
||||
# 不更新 provider_sources, provider, platform
|
||||
# 这些配置有单独的接口进行更新
|
||||
if conf_id == "default":
|
||||
no_update_keys = ["provider_sources", "provider", "platform"]
|
||||
for key in no_update_keys:
|
||||
config[key] = self.acm.default_conf[key]
|
||||
|
||||
await self._save_astrbot_configs(config, conf_id)
|
||||
await self.core_lifecycle.reload_pipeline_scheduler(conf_id)
|
||||
return Response().ok(None, "保存成功~").__dict__
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
<template>
|
||||
<div class="mt-4">
|
||||
<div class="d-flex align-center ga-2 mb-2">
|
||||
<h3 class="text-h5 font-weight-bold mb-0">{{ tm('models.configured') }}</h3>
|
||||
<small style="color: grey;" v-if="availableCount">{{ tm('models.available') }} {{ availableCount }}</small>
|
||||
<v-text-field
|
||||
v-model="modelSearchProxy"
|
||||
density="compact"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
hide-details
|
||||
variant="solo-filled"
|
||||
flat
|
||||
class="ml-1"
|
||||
style="max-width: 240px;"
|
||||
:placeholder="tm('models.searchPlaceholder')"
|
||||
/>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn
|
||||
color="primary"
|
||||
prepend-icon="mdi-download"
|
||||
:loading="loadingModels"
|
||||
@click="emit('fetch-models')"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
>
|
||||
{{ isSourceModified ? tm('providerSources.saveAndFetchModels') : tm('providerSources.fetchModels') }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
prepend-icon="mdi-pencil-plus"
|
||||
variant="text"
|
||||
size="small"
|
||||
class="ml-1"
|
||||
@click="emit('open-manual-model')"
|
||||
>
|
||||
{{ tm('models.manualAddButton') }}
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<v-list
|
||||
density="compact"
|
||||
class="rounded-lg border"
|
||||
style="max-height: 520px; overflow-y: auto; font-family:system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;"
|
||||
>
|
||||
<template v-if="entries.length > 0">
|
||||
<template v-for="entry in entries" :key="entry.type === 'configured' ? `provider-${entry.provider.id}` : `model-${entry.model}`">
|
||||
<v-list-item
|
||||
v-if="entry.type === 'configured'"
|
||||
class="provider-compact-item"
|
||||
@click="emit('open-provider-edit', entry.provider)"
|
||||
>
|
||||
<v-list-item-title class="font-weight-medium text-truncate">
|
||||
{{ entry.provider.id }}
|
||||
</v-list-item-title>
|
||||
<v-list-item-subtitle class="text-caption text-grey d-flex align-center ga-1" style="font-family: monospace;">
|
||||
<span>{{ entry.provider.model }}</span>
|
||||
<v-icon v-if="supportsImageInput(entry.metadata)" size="14" color="grey">
|
||||
mdi-eye-outline
|
||||
</v-icon>
|
||||
<v-icon v-if="supportsToolCall(entry.metadata)" size="14" color="grey">
|
||||
mdi-wrench
|
||||
</v-icon>
|
||||
<v-icon v-if="supportsReasoning(entry.metadata)" size="14" color="grey">
|
||||
mdi-brain
|
||||
</v-icon>
|
||||
<span v-if="formatContextLimit(entry.metadata)">
|
||||
{{ formatContextLimit(entry.metadata) }}
|
||||
</span>
|
||||
</v-list-item-subtitle>
|
||||
<template #append>
|
||||
<div class="d-flex align-center ga-1" @click.stop>
|
||||
<v-switch
|
||||
v-model="entry.provider.enable"
|
||||
density="compact"
|
||||
inset
|
||||
hide-details
|
||||
color="primary"
|
||||
class="mr-1"
|
||||
@update:modelValue="emit('toggle-provider-enable', entry.provider, $event)"
|
||||
></v-switch>
|
||||
<v-tooltip location="top" max-width="300">
|
||||
{{ tm('availability.test') }}
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
icon="mdi-wrench"
|
||||
size="small"
|
||||
variant="text"
|
||||
:disabled="!entry.provider.enable"
|
||||
:loading="isProviderTesting(entry.provider.id)"
|
||||
v-bind="props"
|
||||
@click.stop="emit('test-provider', entry.provider)"
|
||||
></v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
|
||||
<v-btn icon="mdi-delete" size="small" variant="text" color="error" @click.stop="emit('delete-provider', entry.provider)"></v-btn>
|
||||
</div>
|
||||
</template>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item v-else class="cursor-pointer" @click="emit('add-model-provider', entry.model)">
|
||||
<v-list-item-title>{{ entry.model }}</v-list-item-title>
|
||||
<v-list-item-subtitle class="text-caption text-grey d-flex align-center ga-1">
|
||||
<span>{{ entry.model }}</span>
|
||||
<v-icon v-if="supportsImageInput(entry.metadata)" size="14" color="grey">
|
||||
mdi-eye-outline
|
||||
</v-icon>
|
||||
<v-icon v-if="supportsToolCall(entry.metadata)" size="14" color="grey">
|
||||
mdi-wrench
|
||||
</v-icon>
|
||||
<v-icon v-if="supportsReasoning(entry.metadata)" size="14" color="grey">
|
||||
mdi-brain
|
||||
</v-icon>
|
||||
<span v-if="formatContextLimit(entry.metadata)">
|
||||
{{ formatContextLimit(entry.metadata) }}
|
||||
</span>
|
||||
</v-list-item-subtitle>
|
||||
<template #append>
|
||||
<v-btn icon="mdi-plus" size="small" variant="text" color="primary"></v-btn>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="text-center pa-4 text-medium-emphasis">
|
||||
<v-icon size="48" color="grey-lighten-1">mdi-package-variant</v-icon>
|
||||
<p class="text-grey mt-2">{{ tm('models.empty') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</v-list>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
entries: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
availableCount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
modelSearch: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
loadingModels: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
isSourceModified: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
supportsImageInput: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
supportsToolCall: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
supportsReasoning: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
formatContextLimit: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
testingProviders: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
tm: {
|
||||
type: Function,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'update:modelSearch',
|
||||
'fetch-models',
|
||||
'open-manual-model',
|
||||
'open-provider-edit',
|
||||
'toggle-provider-enable',
|
||||
'test-provider',
|
||||
'delete-provider',
|
||||
'add-model-provider'
|
||||
])
|
||||
|
||||
const modelSearchProxy = computed({
|
||||
get: () => props.modelSearch,
|
||||
set: (val) => emit('update:modelSearch', val)
|
||||
})
|
||||
|
||||
const isProviderTesting = (providerId) => props.testingProviders.includes(providerId)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.border {
|
||||
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
}
|
||||
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<v-card class="provider-sources-panel h-100" elevation="0">
|
||||
<div class="d-flex align-center justify-space-between px-4 pt-4 pb-2">
|
||||
<div class="d-flex align-center ga-2">
|
||||
<h3 class="mb-0">{{ tm('providerSources.title') }}</h3>
|
||||
</div>
|
||||
<v-menu>
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
prepend-icon="mdi-plus"
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
rounded="xl"
|
||||
size="small"
|
||||
>
|
||||
新增
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list density="compact">
|
||||
<v-list-item
|
||||
v-for="sourceType in availableSourceTypes"
|
||||
:key="sourceType.value"
|
||||
@click="emitAddSource(sourceType.value)"
|
||||
>
|
||||
<v-list-item-title>{{ sourceType.label }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</div>
|
||||
|
||||
<div v-if="displayedProviderSources.length > 0">
|
||||
<v-list class="provider-source-list" nav density="compact" lines="two">
|
||||
<v-list-item
|
||||
v-for="source in displayedProviderSources"
|
||||
:key="source.isPlaceholder ? `template-${source.templateKey}` : source.id"
|
||||
:value="source.id"
|
||||
:active="isActive(source)"
|
||||
:class="['provider-source-list-item', { 'provider-source-list-item--active': isActive(source) }]"
|
||||
rounded="lg"
|
||||
@click="emitSelectSource(source)"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-avatar size="32" class="bg-grey-lighten-4" rounded="0">
|
||||
<v-img v-if="source?.provider" :src="resolveSourceIcon(source)" alt="logo" cover></v-img>
|
||||
<v-icon v-else size="32">mdi-creation</v-icon>
|
||||
</v-avatar>
|
||||
</template>
|
||||
<v-list-item-title class="font-weight-bold">{{ getSourceDisplayName(source) }}</v-list-item-title>
|
||||
<v-list-item-subtitle class="text-truncate">{{ source.api_base || 'N/A' }}</v-list-item-subtitle>
|
||||
<template #append>
|
||||
<div class="d-flex align-center ga-1">
|
||||
<v-btn
|
||||
v-if="!source.isPlaceholder"
|
||||
icon="mdi-delete"
|
||||
variant="text"
|
||||
size="x-small"
|
||||
color="error"
|
||||
@click.stop="emitDeleteSource(source)"
|
||||
></v-btn>
|
||||
</div>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</div>
|
||||
<div v-else class="text-center py-8 px-4">
|
||||
<v-icon size="48" color="grey-lighten-1">mdi-api-off</v-icon>
|
||||
<p class="text-grey mt-2">{{ tm('providerSources.empty') }}</p>
|
||||
</div>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
displayedProviderSources: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
selectedProviderSource: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
availableSourceTypes: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
tm: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
resolveSourceIcon: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
getSourceDisplayName: {
|
||||
type: Function,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'add-provider-source',
|
||||
'select-provider-source',
|
||||
'delete-provider-source'
|
||||
])
|
||||
|
||||
const selectedId = computed(() => props.selectedProviderSource?.id || null)
|
||||
|
||||
const isActive = (source) => {
|
||||
if (source.isPlaceholder) return false
|
||||
return selectedId.value !== null && selectedId.value === source.id
|
||||
}
|
||||
|
||||
const emitAddSource = (type) => emit('add-provider-source', type)
|
||||
const emitSelectSource = (source) => emit('select-provider-source', source)
|
||||
const emitDeleteSource = (source) => emit('delete-provider-source', source)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.provider-sources-panel {
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
.provider-source-list {
|
||||
max-height: calc(100vh - 335px);
|
||||
overflow-y: auto;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.provider-source-list-item {
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.provider-source-list-item--active {
|
||||
background-color: #E8F0FE;
|
||||
border: 1px solid rgba(var(--v-theme-primary), 0.25);
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.provider-source-list {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.provider-sources-panel {
|
||||
min-height: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,651 @@
|
||||
import { ref, computed, onMounted, nextTick, watch } from 'vue'
|
||||
import axios from 'axios'
|
||||
import { getProviderIcon } from '@/utils/providerUtils'
|
||||
|
||||
export interface UseProviderSourcesOptions {
|
||||
defaultTab?: string
|
||||
tm: (key: string, params?: Record<string, unknown>) => string
|
||||
showMessage: (message: string, color?: string) => void
|
||||
}
|
||||
|
||||
export function resolveDefaultTab(value?: string) {
|
||||
const normalized = (value || '').toLowerCase()
|
||||
|
||||
if (normalized.startsWith('select_agent_runner_provider') || normalized === 'agent_runner') {
|
||||
return 'agent_runner'
|
||||
}
|
||||
|
||||
if (normalized === 'select_provider_stt' || normalized === 'speech_to_text' || normalized.includes('stt')) {
|
||||
return 'speech_to_text'
|
||||
}
|
||||
|
||||
if (normalized === 'select_provider_tts' || normalized === 'text_to_speech' || normalized.includes('tts')) {
|
||||
return 'text_to_speech'
|
||||
}
|
||||
|
||||
if (normalized.includes('embedding')) {
|
||||
return 'embedding'
|
||||
}
|
||||
|
||||
if (normalized.includes('rerank')) {
|
||||
return 'rerank'
|
||||
}
|
||||
|
||||
return 'chat_completion'
|
||||
}
|
||||
|
||||
export function useProviderSources(options: UseProviderSourcesOptions) {
|
||||
const { tm, showMessage } = options
|
||||
|
||||
// ===== State =====
|
||||
const config = ref<Record<string, any>>({})
|
||||
const metadata = ref<Record<string, any>>({})
|
||||
const providerSources = ref<any[]>([])
|
||||
const providers = ref<any[]>([])
|
||||
const selectedProviderType = ref<string>(resolveDefaultTab(options.defaultTab))
|
||||
const selectedProviderSource = ref<any | null>(null)
|
||||
const selectedProviderSourceOriginalId = ref<string | null>(null)
|
||||
const editableProviderSource = ref<any | null>(null)
|
||||
const availableModels = ref<any[]>([])
|
||||
const modelMetadata = ref<Record<string, any>>({})
|
||||
const loadingModels = ref(false)
|
||||
const savingSource = ref(false)
|
||||
const testingProviders = ref<string[]>([])
|
||||
const isSourceModified = ref(false)
|
||||
const configSchema = ref<Record<string, any>>({})
|
||||
const providerTemplates = ref<Record<string, any>>({})
|
||||
const manualModelId = ref('')
|
||||
const modelSearch = ref('')
|
||||
|
||||
let suppressSourceWatch = false
|
||||
|
||||
const providerTypes = [
|
||||
{ value: 'chat_completion', label: tm('providers.tabs.chatCompletion'), icon: 'mdi-message-text' },
|
||||
{ value: 'agent_runner', label: tm('providers.tabs.agentRunner'), icon: 'mdi-robot' },
|
||||
{ value: 'speech_to_text', label: tm('providers.tabs.speechToText'), icon: 'mdi-microphone-message' },
|
||||
{ value: 'text_to_speech', label: tm('providers.tabs.textToSpeech'), icon: 'mdi-volume-high' },
|
||||
{ value: 'embedding', label: tm('providers.tabs.embedding'), icon: 'mdi-code-json' },
|
||||
{ value: 'rerank', label: tm('providers.tabs.rerank'), icon: 'mdi-compare-vertical' }
|
||||
]
|
||||
|
||||
// ===== Computed =====
|
||||
const availableSourceTypes = computed(() => {
|
||||
if (!providerTemplates.value || Object.keys(providerTemplates.value).length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const types: Array<{ value: string; label: string }> = []
|
||||
for (const [templateName, template] of Object.entries(providerTemplates.value)) {
|
||||
if (template.provider_type === selectedProviderType.value) {
|
||||
types.push({ value: templateName, label: templateName })
|
||||
}
|
||||
}
|
||||
|
||||
return types
|
||||
})
|
||||
|
||||
const filteredProviderSources = computed(() => {
|
||||
if (!providerSources.value) return []
|
||||
|
||||
return providerSources.value.filter((source) =>
|
||||
source.provider_type === selectedProviderType.value ||
|
||||
(source.type && isTypeMatchingProviderType(source.type, selectedProviderType.value))
|
||||
)
|
||||
})
|
||||
|
||||
const displayedProviderSources = computed(() => {
|
||||
const existing = filteredProviderSources.value || []
|
||||
const existingProviders = new Set(existing.map((src: any) => src.provider).filter(Boolean))
|
||||
const placeholders: any[] = []
|
||||
|
||||
if (providerTemplates.value && Object.keys(providerTemplates.value).length > 0) {
|
||||
for (const [templateKey, template] of Object.entries(providerTemplates.value)) {
|
||||
if (template.provider_type !== selectedProviderType.value) continue
|
||||
if (!template.provider) continue
|
||||
if (existingProviders.has(template.provider)) continue
|
||||
|
||||
placeholders.push({
|
||||
id: template.id || templateKey,
|
||||
provider: template.provider,
|
||||
provider_type: template.provider_type,
|
||||
type: template.type,
|
||||
api_base: template.api_base || '',
|
||||
templateKey,
|
||||
isPlaceholder: true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return [...existing, ...placeholders]
|
||||
})
|
||||
|
||||
const sourceProviders = computed(() => {
|
||||
if (!selectedProviderSource.value || !providers.value) return []
|
||||
|
||||
return providers.value.filter((p) => p.provider_source_id === selectedProviderSource.value.id)
|
||||
})
|
||||
|
||||
const existingModelsForSelectedSource = computed(() => {
|
||||
if (!selectedProviderSource.value) return new Set<string>()
|
||||
return new Set(sourceProviders.value.map((p: any) => p.model))
|
||||
})
|
||||
|
||||
const sortedAvailableModels = computed(() => {
|
||||
const existing = existingModelsForSelectedSource.value
|
||||
return [...(availableModels.value || [])].sort((a, b) => {
|
||||
const aName = typeof a === 'string' ? a : a?.name
|
||||
const bName = typeof b === 'string' ? b : b?.name
|
||||
const aExists = existing.has(aName)
|
||||
const bExists = existing.has(bName)
|
||||
if (aExists && !bExists) return -1
|
||||
if (!aExists && bExists) return 1
|
||||
return 0
|
||||
})
|
||||
})
|
||||
|
||||
const mergedModelEntries = computed(() => {
|
||||
const configuredEntries = (sourceProviders.value || []).map((provider: any) => ({
|
||||
type: 'configured',
|
||||
provider,
|
||||
metadata: getModelMetadata(provider.model)
|
||||
}))
|
||||
|
||||
const availableEntries = (sortedAvailableModels.value || [])
|
||||
.filter((item: any) => {
|
||||
const name = typeof item === 'string' ? item : item?.name
|
||||
return !existingModelsForSelectedSource.value.has(name)
|
||||
})
|
||||
.map((item: any) => {
|
||||
const name = typeof item === 'string' ? item : item?.name
|
||||
return {
|
||||
type: 'available',
|
||||
model: name,
|
||||
metadata: typeof item === 'object' ? item?.metadata : getModelMetadata(name)
|
||||
}
|
||||
})
|
||||
|
||||
return [...configuredEntries, ...availableEntries]
|
||||
})
|
||||
|
||||
const filteredMergedModelEntries = computed(() => {
|
||||
const term = modelSearch.value.trim().toLowerCase()
|
||||
if (!term) return mergedModelEntries.value
|
||||
|
||||
return mergedModelEntries.value.filter((entry: any) => {
|
||||
if (entry.type === 'configured') {
|
||||
const id = entry.provider.id?.toLowerCase() || ''
|
||||
const model = entry.provider.model?.toLowerCase() || ''
|
||||
return id.includes(term) || model.includes(term)
|
||||
}
|
||||
|
||||
const model = entry.model?.toLowerCase() || ''
|
||||
return model.includes(term)
|
||||
})
|
||||
})
|
||||
|
||||
const manualProviderId = computed(() => {
|
||||
if (!selectedProviderSource.value) return ''
|
||||
const modelId = manualModelId.value.trim()
|
||||
if (!modelId) return ''
|
||||
return `${selectedProviderSource.value.id}/${modelId}`
|
||||
})
|
||||
|
||||
const basicSourceConfig = computed(() => {
|
||||
if (!editableProviderSource.value) return null
|
||||
|
||||
const fields = ['id', 'key', 'api_base']
|
||||
const basic: Record<string, any> = {}
|
||||
|
||||
fields.forEach((field) => {
|
||||
Object.defineProperty(basic, field, {
|
||||
get() {
|
||||
return editableProviderSource.value![field]
|
||||
},
|
||||
set(val) {
|
||||
editableProviderSource.value![field] = val
|
||||
},
|
||||
enumerable: true
|
||||
})
|
||||
})
|
||||
|
||||
return basic
|
||||
})
|
||||
|
||||
const advancedSourceConfig = computed(() => {
|
||||
if (!editableProviderSource.value) return null
|
||||
|
||||
const excluded = ['id', 'key', 'api_base', 'enable', 'type', 'provider_type', 'provider']
|
||||
const advanced: Record<string, any> = {}
|
||||
|
||||
for (const key of Object.keys(editableProviderSource.value)) {
|
||||
if (excluded.includes(key)) continue
|
||||
Object.defineProperty(advanced, key, {
|
||||
get() {
|
||||
return editableProviderSource.value![key]
|
||||
},
|
||||
set(val) {
|
||||
editableProviderSource.value![key] = val
|
||||
},
|
||||
enumerable: true
|
||||
})
|
||||
}
|
||||
|
||||
return advanced
|
||||
})
|
||||
|
||||
const filteredProviders = computed(() => {
|
||||
if (!config.value.provider || selectedProviderType.value === 'chat_completion') {
|
||||
return []
|
||||
}
|
||||
|
||||
return config.value.provider.filter((provider: any) => getProviderType(provider) === selectedProviderType.value)
|
||||
})
|
||||
|
||||
// ===== Watches =====
|
||||
watch(editableProviderSource, () => {
|
||||
if (suppressSourceWatch) return
|
||||
if (!editableProviderSource.value) return
|
||||
isSourceModified.value = true
|
||||
}, { deep: true })
|
||||
|
||||
// ===== Helper Functions =====
|
||||
function isTypeMatchingProviderType(type?: string, providerType?: string) {
|
||||
if (!type || !providerType) return false
|
||||
if (providerType === 'chat_completion') {
|
||||
return type.includes('chat_completion')
|
||||
}
|
||||
return type.includes(providerType)
|
||||
}
|
||||
|
||||
function resolveSourceIcon(source: any) {
|
||||
if (!source) return ''
|
||||
return getProviderIcon(source.provider) || ''
|
||||
}
|
||||
|
||||
function getSourceDisplayName(source: any) {
|
||||
if (!source) return ''
|
||||
if (source.isPlaceholder) return source.templateKey || source.id || ''
|
||||
return source.id
|
||||
}
|
||||
|
||||
function getModelMetadata(modelName?: string) {
|
||||
if (!modelName) return null
|
||||
return modelMetadata.value?.[modelName] || null
|
||||
}
|
||||
|
||||
function supportsImageInput(meta: any) {
|
||||
const inputs = meta?.modalities?.input || []
|
||||
return inputs.includes('image')
|
||||
}
|
||||
|
||||
function supportsToolCall(meta: any) {
|
||||
return Boolean(meta?.tool_call)
|
||||
}
|
||||
|
||||
function supportsReasoning(meta: any) {
|
||||
return Boolean(meta?.reasoning)
|
||||
}
|
||||
|
||||
function formatContextLimit(meta: any) {
|
||||
const ctx = meta?.limit?.context
|
||||
if (!ctx || typeof ctx !== 'number') return ''
|
||||
if (ctx >= 1_000_000) return `${Math.round(ctx / 1_000_000)}M`
|
||||
if (ctx >= 1_000) return `${Math.round(ctx / 1_000)}K`
|
||||
return `${ctx}`
|
||||
}
|
||||
|
||||
function getProviderType(provider: any) {
|
||||
if (!provider) return undefined
|
||||
if (provider.provider_type) {
|
||||
return provider.provider_type
|
||||
}
|
||||
|
||||
const oldVersionProviderTypeMapping: Record<string, string> = {
|
||||
openai_chat_completion: 'chat_completion',
|
||||
anthropic_chat_completion: 'chat_completion',
|
||||
googlegenai_chat_completion: 'chat_completion',
|
||||
zhipu_chat_completion: 'chat_completion',
|
||||
dify: 'agent_runner',
|
||||
coze: 'agent_runner',
|
||||
dashscope: 'chat_completion',
|
||||
openai_whisper_api: 'speech_to_text',
|
||||
openai_whisper_selfhost: 'speech_to_text',
|
||||
sensevoice_stt_selfhost: 'speech_to_text',
|
||||
openai_tts_api: 'text_to_speech',
|
||||
edge_tts: 'text_to_speech',
|
||||
gsvi_tts_api: 'text_to_speech',
|
||||
fishaudio_tts_api: 'text_to_speech',
|
||||
dashscope_tts: 'text_to_speech',
|
||||
azure_tts: 'text_to_speech',
|
||||
minimax_tts_api: 'text_to_speech',
|
||||
volcengine_tts: 'text_to_speech'
|
||||
}
|
||||
return oldVersionProviderTypeMapping[provider.type]
|
||||
}
|
||||
|
||||
function selectProviderSource(source: any) {
|
||||
if (source?.isPlaceholder && source.templateKey) {
|
||||
addProviderSource(source.templateKey)
|
||||
return
|
||||
}
|
||||
|
||||
selectedProviderSource.value = source
|
||||
selectedProviderSourceOriginalId.value = source?.id || null
|
||||
suppressSourceWatch = true
|
||||
editableProviderSource.value = source ? JSON.parse(JSON.stringify(source)) : null
|
||||
nextTick(() => {
|
||||
suppressSourceWatch = false
|
||||
})
|
||||
availableModels.value = []
|
||||
modelMetadata.value = {}
|
||||
isSourceModified.value = false
|
||||
}
|
||||
|
||||
function extractSourceFieldsFromTemplate(template: Record<string, any>) {
|
||||
const sourceFields: Record<string, any> = {}
|
||||
const excludeKeys = ['id', 'enable', 'model', 'provider_source_id', 'modalities', 'custom_extra_body']
|
||||
|
||||
for (const [key, value] of Object.entries(template)) {
|
||||
if (!excludeKeys.includes(key)) {
|
||||
sourceFields[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return sourceFields
|
||||
}
|
||||
|
||||
function addProviderSource(templateKey: string) {
|
||||
const template = providerTemplates.value[templateKey]
|
||||
if (!template) {
|
||||
showMessage('未找到对应的模板配置', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
const newId = template.id
|
||||
const newSource = {
|
||||
...extractSourceFieldsFromTemplate(template),
|
||||
id: newId,
|
||||
type: template.type,
|
||||
provider_type: template.provider_type,
|
||||
provider: template.provider,
|
||||
enable: true
|
||||
}
|
||||
|
||||
providerSources.value.push(newSource)
|
||||
selectedProviderSource.value = newSource
|
||||
selectedProviderSourceOriginalId.value = newId
|
||||
editableProviderSource.value = JSON.parse(JSON.stringify(newSource))
|
||||
availableModels.value = []
|
||||
modelMetadata.value = {}
|
||||
isSourceModified.value = true
|
||||
}
|
||||
|
||||
async function deleteProviderSource(source: any) {
|
||||
if (!confirm(tm('providerSources.deleteConfirm', { id: source.id }))) return
|
||||
|
||||
try {
|
||||
await axios.post(`/api/config/provider_sources/${source.id}/delete`)
|
||||
|
||||
providers.value = providers.value.filter((p) => p.provider_source_id !== source.id)
|
||||
providerSources.value = providerSources.value.filter((s) => s.id !== source.id)
|
||||
|
||||
if (selectedProviderSource.value?.id === source.id) {
|
||||
selectedProviderSource.value = null
|
||||
selectedProviderSourceOriginalId.value = null
|
||||
editableProviderSource.value = null
|
||||
}
|
||||
|
||||
showMessage(tm('providerSources.deleteSuccess'))
|
||||
} catch (error: any) {
|
||||
showMessage(error.message || tm('providerSources.deleteError'), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async function saveProviderSource() {
|
||||
if (!selectedProviderSource.value) return
|
||||
|
||||
savingSource.value = true
|
||||
const originalId = selectedProviderSourceOriginalId.value || selectedProviderSource.value.id
|
||||
try {
|
||||
const response = await axios.post(`/api/config/provider_sources/${originalId}/update`, {
|
||||
config: editableProviderSource.value,
|
||||
original_id: originalId
|
||||
})
|
||||
|
||||
if (response.data.status !== 'ok') {
|
||||
throw new Error(response.data.message)
|
||||
}
|
||||
|
||||
if (editableProviderSource.value!.id !== originalId) {
|
||||
providers.value = providers.value.map((p) =>
|
||||
p.provider_source_id === originalId
|
||||
? { ...p, provider_source_id: editableProviderSource.value!.id }
|
||||
: p
|
||||
)
|
||||
selectedProviderSourceOriginalId.value = editableProviderSource.value!.id
|
||||
}
|
||||
|
||||
const idx = providerSources.value.findIndex((ps) => ps.id === originalId)
|
||||
if (idx !== -1) {
|
||||
providerSources.value[idx] = JSON.parse(JSON.stringify(editableProviderSource.value))
|
||||
selectedProviderSource.value = providerSources.value[idx]
|
||||
}
|
||||
|
||||
suppressSourceWatch = true
|
||||
editableProviderSource.value = selectedProviderSource.value
|
||||
nextTick(() => {
|
||||
suppressSourceWatch = false
|
||||
})
|
||||
|
||||
isSourceModified.value = false
|
||||
showMessage(response.data.message || tm('providerSources.saveSuccess'))
|
||||
return true
|
||||
} catch (error: any) {
|
||||
showMessage(error.response?.data?.message || error.message || tm('providerSources.saveError'), 'error')
|
||||
return false
|
||||
} finally {
|
||||
savingSource.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAvailableModels() {
|
||||
if (!selectedProviderSource.value) return
|
||||
|
||||
if (isSourceModified.value) {
|
||||
const saved = await saveProviderSource()
|
||||
if (!saved) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
loadingModels.value = true
|
||||
try {
|
||||
const sourceId = editableProviderSource.value?.id || selectedProviderSource.value.id
|
||||
const response = await axios.get(`/api/config/provider_sources/${sourceId}/models`)
|
||||
if (response.data.status === 'ok') {
|
||||
const metadataMap = response.data.data.model_metadata || {}
|
||||
modelMetadata.value = metadataMap
|
||||
availableModels.value = (response.data.data.models || []).map((model: string) => ({
|
||||
name: model,
|
||||
metadata: metadataMap?.[model] || null
|
||||
}))
|
||||
if (availableModels.value.length === 0) {
|
||||
showMessage(tm('models.noModelsFound'), 'info')
|
||||
}
|
||||
} else {
|
||||
throw new Error(response.data.message)
|
||||
}
|
||||
} catch (error: any) {
|
||||
modelMetadata.value = {}
|
||||
showMessage(error.response?.data?.message || error.message || tm('models.fetchError'), 'error')
|
||||
} finally {
|
||||
loadingModels.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function addModelProvider(modelName: string) {
|
||||
if (!selectedProviderSource.value) return
|
||||
|
||||
const sourceId = editableProviderSource.value?.id || selectedProviderSource.value.id
|
||||
const newId = `${sourceId}/${modelName}`
|
||||
|
||||
const modalities = ['text']
|
||||
if (supportsImageInput(getModelMetadata(modelName))) {
|
||||
modalities.push('image')
|
||||
}
|
||||
if (supportsToolCall(getModelMetadata(modelName))) {
|
||||
modalities.push('tool_use')
|
||||
}
|
||||
|
||||
const newProvider = {
|
||||
id: newId,
|
||||
enable: false,
|
||||
provider_source_id: sourceId,
|
||||
model: modelName,
|
||||
modalities,
|
||||
custom_extra_body: {}
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await axios.post('/api/config/provider/new', newProvider)
|
||||
if (res.data.status === 'error') {
|
||||
throw new Error(res.data.message)
|
||||
}
|
||||
config.value.provider.push(newProvider)
|
||||
showMessage(res.data.message || tm('models.addSuccess', { model: modelName }))
|
||||
} catch (error: any) {
|
||||
showMessage(error.response?.data?.message || error.message || tm('providerSources.saveError'), 'error')
|
||||
} finally {
|
||||
await loadConfig()
|
||||
}
|
||||
}
|
||||
|
||||
function modelAlreadyConfigured(modelName: string) {
|
||||
return existingModelsForSelectedSource.value.has(modelName)
|
||||
}
|
||||
|
||||
async function deleteProvider(provider: any) {
|
||||
if (!confirm(tm('models.deleteConfirm', { id: provider.id }))) return
|
||||
|
||||
try {
|
||||
await axios.post('/api/config/provider/delete', { id: provider.id })
|
||||
providers.value = providers.value.filter((p) => p.id !== provider.id)
|
||||
showMessage(tm('models.deleteSuccess'))
|
||||
} catch (error: any) {
|
||||
showMessage(error.message || tm('models.deleteError'), 'error')
|
||||
} finally {
|
||||
await loadConfig()
|
||||
}
|
||||
}
|
||||
|
||||
async function testProvider(provider: any) {
|
||||
testingProviders.value.push(provider.id)
|
||||
try {
|
||||
const response = await axios.get('/api/config/provider/check_one', { params: { id: provider.id } })
|
||||
if (response.data.status === 'ok' && response.data.data.error === null) {
|
||||
showMessage(tm('models.testSuccess', { id: provider.id }))
|
||||
} else {
|
||||
throw new Error(response.data.data.error || tm('models.testError'))
|
||||
}
|
||||
} catch (error: any) {
|
||||
showMessage(error.response?.data?.message || error.message || tm('models.testError'), 'error')
|
||||
} finally {
|
||||
testingProviders.value = testingProviders.value.filter((id) => id !== provider.id)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const response = await axios.get('/api/config/get')
|
||||
if (response.data.status === 'ok') {
|
||||
config.value = response.data.data.config
|
||||
providerSources.value = config.value.provider_sources || []
|
||||
providers.value = config.value.provider || []
|
||||
metadata.value = response.data.data.metadata
|
||||
}
|
||||
} catch (error: any) {
|
||||
showMessage(error.message || 'Failed to load config', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProviderTemplate() {
|
||||
try {
|
||||
const response = await axios.get('/api/config/provider/template')
|
||||
if (response.data.status === 'ok') {
|
||||
configSchema.value = response.data.data.config_schema || {}
|
||||
if (configSchema.value.provider?.config_template) {
|
||||
providerTemplates.value = configSchema.value.provider.config_template
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load provider template:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function updateDefaultTab(value: string) {
|
||||
selectedProviderType.value = resolveDefaultTab(value)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadConfig()
|
||||
await loadProviderTemplate()
|
||||
})
|
||||
|
||||
return {
|
||||
// state
|
||||
config,
|
||||
metadata,
|
||||
providerSources,
|
||||
providers,
|
||||
selectedProviderType,
|
||||
selectedProviderSource,
|
||||
selectedProviderSourceOriginalId,
|
||||
editableProviderSource,
|
||||
availableModels,
|
||||
modelMetadata,
|
||||
loadingModels,
|
||||
savingSource,
|
||||
testingProviders,
|
||||
isSourceModified,
|
||||
configSchema,
|
||||
providerTemplates,
|
||||
manualModelId,
|
||||
modelSearch,
|
||||
|
||||
// computed
|
||||
providerTypes,
|
||||
availableSourceTypes,
|
||||
displayedProviderSources,
|
||||
sourceProviders,
|
||||
mergedModelEntries,
|
||||
filteredMergedModelEntries,
|
||||
filteredProviders,
|
||||
basicSourceConfig,
|
||||
advancedSourceConfig,
|
||||
manualProviderId,
|
||||
|
||||
// helpers
|
||||
resolveSourceIcon,
|
||||
getSourceDisplayName,
|
||||
getModelMetadata,
|
||||
supportsImageInput,
|
||||
supportsToolCall,
|
||||
supportsReasoning,
|
||||
formatContextLimit,
|
||||
getProviderType,
|
||||
|
||||
// methods
|
||||
updateDefaultTab,
|
||||
selectProviderSource,
|
||||
addProviderSource,
|
||||
deleteProviderSource,
|
||||
saveProviderSource,
|
||||
fetchAvailableModels,
|
||||
addModelProvider,
|
||||
deleteProvider,
|
||||
modelAlreadyConfigured,
|
||||
testProvider,
|
||||
loadConfig,
|
||||
loadProviderTemplate
|
||||
}
|
||||
}
|
||||
@@ -32,56 +32,17 @@
|
||||
<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">
|
||||
<v-card class="provider-sources-panel h-100" elevation="0">
|
||||
<div class="d-flex align-center justify-space-between px-4 pt-4 pb-2">
|
||||
<div class="d-flex align-center ga-2">
|
||||
<h3 class="mb-0">{{ tm('providerSources.title') }}</h3>
|
||||
</div>
|
||||
<v-menu>
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn v-bind="props" prepend-icon="mdi-plus" color="primary" variant="tonal" rounded="xl"
|
||||
size="small">
|
||||
新增
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list density="compact">
|
||||
<v-list-item v-for="sourceType in availableSourceTypes" :key="sourceType.value"
|
||||
@click="addProviderSource(sourceType.value)">
|
||||
<v-list-item-title>{{ sourceType.label }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</div>
|
||||
|
||||
<div v-if="displayedProviderSources.length > 0">
|
||||
<v-list class="provider-source-list" nav density="compact" lines="two">
|
||||
<v-list-item v-for="source in displayedProviderSources"
|
||||
:key="source.isPlaceholder ? `template-${source.templateKey}` : source.id" :value="source.id"
|
||||
:active="!source.isPlaceholder && selectedProviderSource?.id === source.id"
|
||||
:class="['provider-source-list-item', { 'provider-source-list-item--active': !source.isPlaceholder && selectedProviderSource?.id === source.id }]"
|
||||
rounded="lg" @click="selectProviderSource(source)">
|
||||
<template #prepend>
|
||||
<v-avatar size="32" class="bg-grey-lighten-4" rounded="0">
|
||||
<v-img v-if="source?.provider" :src="resolveSourceIcon(source)" alt="logo" cover></v-img>
|
||||
<v-icon v-else size="32">mdi-creation</v-icon>
|
||||
</v-avatar>
|
||||
</template>
|
||||
<v-list-item-title class="font-weight-bold">{{ getSourceDisplayName(source) }}</v-list-item-title>
|
||||
<v-list-item-subtitle class="text-truncate">{{ source.api_base || 'N/A' }}</v-list-item-subtitle>
|
||||
<template #append>
|
||||
<div class="d-flex align-center ga-1">
|
||||
<v-btn v-if="!source.isPlaceholder" icon="mdi-delete" variant="text" size="x-small"
|
||||
color="error" @click.stop="deleteProviderSource(source)"></v-btn>
|
||||
</div>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</div>
|
||||
<div v-else class="text-center py-8 px-4">
|
||||
<v-icon size="48" color="grey-lighten-1">mdi-api-off</v-icon>
|
||||
<p class="text-grey mt-2">{{ tm('providerSources.empty') }}</p>
|
||||
</div>
|
||||
</v-card>
|
||||
<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">
|
||||
@@ -122,102 +83,26 @@
|
||||
</v-expansion-panel>
|
||||
</v-expansion-panels>
|
||||
|
||||
<div class="mt-4">
|
||||
<div class="d-flex align-center ga-2 mb-2">
|
||||
<h3 class="text-h5 font-weight-bold mb-0">{{ tm('models.configured') }}</h3>
|
||||
<small style="color: grey;" v-if="availableModels.length">{{ tm('models.available') }} {{
|
||||
availableModels.length }}</small>
|
||||
<v-text-field v-model="modelSearch" density="compact" prepend-inner-icon="mdi-magnify"
|
||||
hide-details variant="solo-filled" flat class="ml-1" style="max-width: 240px;"
|
||||
:placeholder="tm('models.searchPlaceholder')" />
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn color="primary" prepend-icon="mdi-download" :loading="loadingModels"
|
||||
@click="fetchAvailableModels" variant="tonal" size="small">
|
||||
{{ isSourceModified ? tm('providerSources.saveAndFetchModels') :
|
||||
tm('providerSources.fetchModels') }}
|
||||
</v-btn>
|
||||
<v-btn color="primary" prepend-icon="mdi-pencil-plus" variant="text" size="small"
|
||||
class="ml-1" @click="openManualModelDialog">
|
||||
{{ tm('models.manualAddButton') }}
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<v-list density="compact" class="rounded-lg border"
|
||||
style="max-height: 520px; overflow-y: auto; font-family:system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;">
|
||||
<template v-if="filteredMergedModelEntries.length > 0">
|
||||
<template v-for="entry in filteredMergedModelEntries"
|
||||
:key="entry.type === 'configured' ? `provider-${entry.provider.id}` : `model-${entry.model}`">
|
||||
<v-list-item v-if="entry.type === 'configured'" class="provider-compact-item"
|
||||
@click="openProviderEdit(entry.provider)">
|
||||
<v-list-item-title class="font-weight-medium text-truncate">
|
||||
{{ entry.provider.id }}
|
||||
</v-list-item-title>
|
||||
<v-list-item-subtitle class="text-caption text-grey d-flex align-center ga-1" style="font-family: monospace;">
|
||||
<span>{{ entry.provider.model }}</span>
|
||||
<v-icon v-if="supportsImageInput(entry.metadata)" size="14" color="grey">
|
||||
mdi-eye-outline
|
||||
</v-icon>
|
||||
<v-icon v-if="supportsToolCall(entry.metadata)" size="14" color="grey">
|
||||
mdi-wrench
|
||||
</v-icon>
|
||||
<v-icon v-if="supportsReasoning(entry.metadata)" size="14" color="grey">
|
||||
mdi-brain
|
||||
</v-icon>
|
||||
<span v-if="formatContextLimit(entry.metadata)">
|
||||
{{ formatContextLimit(entry.metadata) }}
|
||||
</span>
|
||||
</v-list-item-subtitle>
|
||||
<template #append>
|
||||
<div class="d-flex align-center ga-1" @click.stop>
|
||||
<v-switch v-model="entry.provider.enable" density="compact" inset hide-details
|
||||
color="primary" class="mr-1"
|
||||
@update:modelValue="toggleProviderEnable(entry.provider, $event)"></v-switch>
|
||||
<v-tooltip location="top" max-width="300">
|
||||
{{ tm('availability.test') }}
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn icon="mdi-wrench" size="small" variant="text" :disabled="!entry.provider.enable"
|
||||
:loading="testingProviders.includes(entry.provider.id)" v-bind="props"
|
||||
@click.stop="testProvider(entry.provider)"></v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
|
||||
<v-btn icon="mdi-delete" size="small" variant="text" color="error"
|
||||
@click.stop="deleteProvider(entry.provider)"></v-btn>
|
||||
</div>
|
||||
</template>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item v-else class="cursor-pointer" @click="addModelProvider(entry.model)">
|
||||
<v-list-item-title>{{ entry.model }}</v-list-item-title>
|
||||
<v-list-item-subtitle class="text-caption text-grey d-flex align-center ga-1">
|
||||
<span>{{ entry.model }}</span>
|
||||
<v-icon v-if="supportsImageInput(entry.metadata)" size="14" color="grey">
|
||||
mdi-eye-outline
|
||||
</v-icon>
|
||||
<v-icon v-if="supportsToolCall(entry.metadata)" size="14" color="grey">
|
||||
mdi-wrench
|
||||
</v-icon>
|
||||
<v-icon v-if="supportsReasoning(entry.metadata)" size="14" color="grey">
|
||||
mdi-brain
|
||||
</v-icon>
|
||||
<span v-if="formatContextLimit(entry.metadata)">
|
||||
{{ formatContextLimit(entry.metadata) }}
|
||||
</span>
|
||||
</v-list-item-subtitle>
|
||||
<template v-slot:append>
|
||||
<v-btn icon="mdi-plus" size="small" variant="text" color="primary"></v-btn>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="text-center pa-4 text-medium-emphasis">
|
||||
<v-icon size="48" color="grey-lighten-1">mdi-package-variant</v-icon>
|
||||
<p class="text-grey mt-2">{{ tm('models.empty') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</v-list>
|
||||
</div>
|
||||
<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>
|
||||
@@ -371,13 +256,16 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, nextTick, watch } from 'vue'
|
||||
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({
|
||||
@@ -390,24 +278,59 @@ const props = defineProps({
|
||||
const { tm } = useModuleI18n('features/provider')
|
||||
const router = useRouter()
|
||||
|
||||
// ===== State =====
|
||||
const config = ref({})
|
||||
const metadata = ref({})
|
||||
const providerSources = ref([])
|
||||
const providers = ref([])
|
||||
const selectedProviderType = ref(resolveDefaultTab(props.defaultTab))
|
||||
const selectedProviderSource = ref(null)
|
||||
const selectedProviderSourceOriginalId = ref(null)
|
||||
const editableProviderSource = ref(null)
|
||||
const availableModels = ref([])
|
||||
const modelMetadata = ref({})
|
||||
const loadingModels = ref(false)
|
||||
const savingSource = ref(false)
|
||||
const testingProviders = ref([])
|
||||
const savingProviders = ref([])
|
||||
const isSourceModified = ref(false)
|
||||
const configSchema = ref({})
|
||||
const providerTemplates = ref({})
|
||||
const snackbar = ref({
|
||||
show: false,
|
||||
message: '',
|
||||
color: 'success'
|
||||
})
|
||||
|
||||
function showMessage(message, color = 'success') {
|
||||
snackbar.value = { show: true, message, color }
|
||||
}
|
||||
|
||||
const {
|
||||
config,
|
||||
metadata,
|
||||
selectedProviderType,
|
||||
selectedProviderSource,
|
||||
availableModels,
|
||||
loadingModels,
|
||||
savingSource,
|
||||
testingProviders,
|
||||
isSourceModified,
|
||||
configSchema,
|
||||
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
|
||||
})
|
||||
|
||||
// 非 chat 类型的状态
|
||||
const showAddProviderDialog = ref(false)
|
||||
@@ -418,312 +341,11 @@ const updatingMode = ref(false)
|
||||
const loading = ref(false)
|
||||
const providerStatuses = ref([])
|
||||
const showAgentRunnerDialog = ref(false)
|
||||
const sourceProviderPanels = ref(null)
|
||||
const showProviderEditDialog = ref(false)
|
||||
const providerEditData = ref(null)
|
||||
const showManualModelDialog = ref(false)
|
||||
const manualModelId = ref('')
|
||||
const modelSearch = ref('')
|
||||
|
||||
let suppressSourceWatch = false
|
||||
|
||||
const snackbar = ref({
|
||||
show: false,
|
||||
message: '',
|
||||
color: 'success'
|
||||
})
|
||||
|
||||
// ===== Provider Types =====
|
||||
const providerTypes = [
|
||||
{ value: 'chat_completion', label: tm('providers.tabs.chatCompletion'), icon: 'mdi-message-text' },
|
||||
{ value: 'agent_runner', label: tm('providers.tabs.agentRunner'), icon: 'mdi-robot' },
|
||||
{ value: 'speech_to_text', label: tm('providers.tabs.speechToText'), icon: 'mdi-microphone-message' },
|
||||
{ value: 'text_to_speech', label: tm('providers.tabs.textToSpeech'), icon: 'mdi-volume-high' },
|
||||
{ value: 'embedding', label: tm('providers.tabs.embedding'), icon: 'mdi-code-json' },
|
||||
{ value: 'rerank', label: tm('providers.tabs.rerank'), icon: 'mdi-compare-vertical' }
|
||||
]
|
||||
|
||||
// ===== Computed =====
|
||||
const availableSourceTypes = computed(() => {
|
||||
// 从 providerTemplates 中动态获取可用的 source types
|
||||
if (!providerTemplates.value || Object.keys(providerTemplates.value).length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const types = []
|
||||
for (const [templateName, template] of Object.entries(providerTemplates.value)) {
|
||||
// 根据 provider_type 筛选
|
||||
if (template.provider_type === selectedProviderType.value) {
|
||||
types.push({
|
||||
value: templateName, // 使用 key 作为 value
|
||||
label: templateName
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return types
|
||||
})
|
||||
|
||||
const filteredProviderSources = computed(() => {
|
||||
if (!providerSources.value) return []
|
||||
|
||||
return providerSources.value.filter(source =>
|
||||
source.provider_type === selectedProviderType.value ||
|
||||
(source.type && isTypeMatchingProviderType(source.type, selectedProviderType.value))
|
||||
)
|
||||
})
|
||||
|
||||
const displayedProviderSources = computed(() => {
|
||||
const existing = filteredProviderSources.value || []
|
||||
const existingProviders = new Set(existing.map(src => src.provider).filter(Boolean))
|
||||
const placeholders = []
|
||||
|
||||
if (providerTemplates.value && Object.keys(providerTemplates.value).length > 0) {
|
||||
for (const [templateKey, template] of Object.entries(providerTemplates.value)) {
|
||||
if (template.provider_type !== selectedProviderType.value) continue
|
||||
if (!template.provider) continue
|
||||
if (existingProviders.has(template.provider)) continue
|
||||
|
||||
placeholders.push({
|
||||
id: template.id || templateKey,
|
||||
provider: template.provider,
|
||||
provider_type: template.provider_type,
|
||||
type: template.type,
|
||||
api_base: template.api_base || '',
|
||||
templateKey,
|
||||
isPlaceholder: true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return [...existing, ...placeholders]
|
||||
})
|
||||
|
||||
const sourceProviders = computed(() => {
|
||||
if (!selectedProviderSource.value || !providers.value) return []
|
||||
|
||||
return providers.value.filter(p =>
|
||||
p.provider_source_id === selectedProviderSource.value.id
|
||||
)
|
||||
})
|
||||
|
||||
const chatProviders = computed(() => {
|
||||
if (!providers.value) return []
|
||||
|
||||
return providers.value.filter(provider => {
|
||||
const type = getProviderType(provider)
|
||||
if (type === 'chat_completion') return true
|
||||
|
||||
const source = providerSources.value.find(s => s.id === provider.provider_source_id)
|
||||
if (!source) return false
|
||||
|
||||
return source.provider_type === 'chat_completion' || isTypeMatchingProviderType(source.type, 'chat_completion')
|
||||
})
|
||||
})
|
||||
|
||||
const displayedChatProviders = computed(() => {
|
||||
if (!chatProviders.value) return []
|
||||
|
||||
if (selectedProviderSource.value) {
|
||||
return chatProviders.value.filter(p => p.provider_source_id === selectedProviderSource.value.id)
|
||||
}
|
||||
|
||||
return chatProviders.value
|
||||
})
|
||||
|
||||
const existingModelsForSelectedSource = computed(() => {
|
||||
if (!selectedProviderSource.value) return new Set()
|
||||
return new Set(sourceProviders.value.map(p => p.model))
|
||||
})
|
||||
|
||||
const sortedAvailableModels = computed(() => {
|
||||
const existing = existingModelsForSelectedSource.value
|
||||
return [...(availableModels.value || [])].sort((a, b) => {
|
||||
const aName = typeof a === 'string' ? a : a?.name
|
||||
const bName = typeof b === 'string' ? b : b?.name
|
||||
const aExists = existing.has(aName)
|
||||
const bExists = existing.has(bName)
|
||||
if (aExists && !bExists) return -1
|
||||
if (!aExists && bExists) return 1
|
||||
return 0
|
||||
})
|
||||
})
|
||||
|
||||
const mergedModelEntries = computed(() => {
|
||||
const configuredEntries = (sourceProviders.value || []).map(provider => ({
|
||||
type: 'configured',
|
||||
provider,
|
||||
metadata: getModelMetadata(provider.model)
|
||||
}))
|
||||
|
||||
const availableEntries = (sortedAvailableModels.value || [])
|
||||
.filter(item => {
|
||||
const name = typeof item === 'string' ? item : item?.name
|
||||
return !existingModelsForSelectedSource.value.has(name)
|
||||
})
|
||||
.map(item => {
|
||||
const name = typeof item === 'string' ? item : item?.name
|
||||
return {
|
||||
type: 'available',
|
||||
model: name,
|
||||
metadata: typeof item === 'object' ? item?.metadata : getModelMetadata(name)
|
||||
}
|
||||
})
|
||||
|
||||
return [...configuredEntries, ...availableEntries]
|
||||
})
|
||||
|
||||
const filteredMergedModelEntries = computed(() => {
|
||||
const term = modelSearch.value.trim().toLowerCase()
|
||||
if (!term) return mergedModelEntries.value
|
||||
|
||||
return mergedModelEntries.value.filter(entry => {
|
||||
if (entry.type === 'configured') {
|
||||
const id = entry.provider.id?.toLowerCase() || ''
|
||||
const model = entry.provider.model?.toLowerCase() || ''
|
||||
return id.includes(term) || model.includes(term)
|
||||
}
|
||||
|
||||
const model = entry.model?.toLowerCase() || ''
|
||||
return model.includes(term)
|
||||
})
|
||||
})
|
||||
|
||||
const manualProviderId = computed(() => {
|
||||
if (!selectedProviderSource.value) return ''
|
||||
const modelId = manualModelId.value.trim()
|
||||
if (!modelId) return ''
|
||||
return `${selectedProviderSource.value.id}/${modelId}`
|
||||
})
|
||||
|
||||
// 基础配置:只包含常用字段
|
||||
const basicSourceConfig = computed(() => {
|
||||
if (!editableProviderSource.value) return null
|
||||
|
||||
const fields = ['id', 'key', 'api_base']
|
||||
const basic = {}
|
||||
|
||||
fields.forEach(field => {
|
||||
Object.defineProperty(basic, field, {
|
||||
get() {
|
||||
return editableProviderSource.value[field]
|
||||
},
|
||||
set(val) {
|
||||
editableProviderSource.value[field] = val
|
||||
},
|
||||
enumerable: true
|
||||
})
|
||||
})
|
||||
|
||||
return basic
|
||||
})
|
||||
|
||||
// 高级配置:过滤掉基础字段
|
||||
const advancedSourceConfig = computed(() => {
|
||||
if (!editableProviderSource.value) return null
|
||||
|
||||
const excluded = ['id', 'key', 'api_base', 'enable', 'type', 'provider_type', "provider"]
|
||||
const advanced = {}
|
||||
|
||||
for (const key of Object.keys(editableProviderSource.value)) {
|
||||
if (excluded.includes(key)) continue
|
||||
Object.defineProperty(advanced, key, {
|
||||
get() {
|
||||
return editableProviderSource.value[key]
|
||||
},
|
||||
set(val) {
|
||||
editableProviderSource.value[key] = val
|
||||
},
|
||||
enumerable: true
|
||||
})
|
||||
}
|
||||
|
||||
return advanced
|
||||
})
|
||||
|
||||
// 非 chat 类型的 providers
|
||||
const filteredProviders = computed(() => {
|
||||
if (!config.value.provider || selectedProviderType.value === 'chat_completion') {
|
||||
return []
|
||||
}
|
||||
|
||||
return config.value.provider.filter(provider => {
|
||||
return getProviderType(provider) === selectedProviderType.value
|
||||
})
|
||||
})
|
||||
|
||||
// ===== Helper Functions =====
|
||||
function isTypeMatchingProviderType(type, providerType) {
|
||||
// 根据 type 判断是否匹配 provider_type
|
||||
if (providerType === 'chat_completion') {
|
||||
return type && type.includes('chat_completion')
|
||||
}
|
||||
return type && type.includes(providerType)
|
||||
}
|
||||
|
||||
function resolveDefaultTab(value) {
|
||||
const normalized = (value || '').toLowerCase()
|
||||
|
||||
if (normalized.startsWith('select_agent_runner_provider') || normalized === 'agent_runner') {
|
||||
return 'agent_runner'
|
||||
}
|
||||
|
||||
if (normalized === 'select_provider_stt' || normalized === 'speech_to_text' || normalized.includes('stt')) {
|
||||
return 'speech_to_text'
|
||||
}
|
||||
|
||||
if (normalized === 'select_provider_tts' || normalized === 'text_to_speech' || normalized.includes('tts')) {
|
||||
return 'text_to_speech'
|
||||
}
|
||||
|
||||
if (normalized.includes('embedding')) {
|
||||
return 'embedding'
|
||||
}
|
||||
|
||||
if (normalized.includes('rerank')) {
|
||||
return 'rerank'
|
||||
}
|
||||
|
||||
return 'chat_completion'
|
||||
}
|
||||
|
||||
function resolveSourceIcon(source) {
|
||||
if (!source) return ''
|
||||
|
||||
return getProviderIcon(source.provider) || ''
|
||||
}
|
||||
|
||||
function getSourceDisplayName(source) {
|
||||
if (!source) return ''
|
||||
if (source.isPlaceholder) return source.templateKey || source.id || ''
|
||||
return source.id
|
||||
}
|
||||
|
||||
function getModelMetadata(modelName) {
|
||||
if (!modelName) return null
|
||||
return modelMetadata.value?.[modelName] || null
|
||||
}
|
||||
|
||||
function supportsImageInput(meta) {
|
||||
const inputs = meta?.modalities?.input || []
|
||||
return inputs.includes('image')
|
||||
}
|
||||
|
||||
function supportsToolCall(meta) {
|
||||
return Boolean(meta?.tool_call)
|
||||
}
|
||||
|
||||
function supportsReasoning(meta) {
|
||||
return Boolean(meta?.reasoning)
|
||||
}
|
||||
|
||||
function formatContextLimit(meta) {
|
||||
const ctx = meta?.limit?.context
|
||||
if (!ctx || typeof ctx !== 'number') return ''
|
||||
if (ctx >= 1_000_000) return `${Math.round(ctx / 1_000_000)}M`
|
||||
if (ctx >= 1_000) return `${Math.round(ctx / 1_000)}K`
|
||||
return `${ctx}`
|
||||
}
|
||||
const savingProviders = ref([])
|
||||
|
||||
function openProviderEdit(provider) {
|
||||
providerEditData.value = JSON.parse(JSON.stringify(provider))
|
||||
@@ -757,356 +379,11 @@ async function confirmManualModel() {
|
||||
showManualModelDialog.value = false
|
||||
}
|
||||
|
||||
// ===== Methods =====
|
||||
function showMessage(message, color = 'success') {
|
||||
snackbar.value = { show: true, message, color }
|
||||
}
|
||||
|
||||
function selectProviderSource(source) {
|
||||
if (source?.isPlaceholder && source.templateKey) {
|
||||
addProviderSource(source.templateKey)
|
||||
return
|
||||
}
|
||||
|
||||
selectedProviderSource.value = source
|
||||
selectedProviderSourceOriginalId.value = source?.id || null
|
||||
suppressSourceWatch = true
|
||||
editableProviderSource.value = source ? JSON.parse(JSON.stringify(source)) : null
|
||||
nextTick(() => {
|
||||
suppressSourceWatch = false
|
||||
})
|
||||
availableModels.value = []
|
||||
modelMetadata.value = {}
|
||||
isSourceModified.value = false
|
||||
sourceProviderPanels.value = null
|
||||
}
|
||||
|
||||
function addProviderSource(templateKey) {
|
||||
// 从模板中找到对应的配置,使用 key 而不是 type
|
||||
const template = providerTemplates.value[templateKey]
|
||||
if (!template) {
|
||||
showMessage('未找到对应的模板配置', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
// 使用模板中的默认 ID
|
||||
const newId = template.id
|
||||
const newSource = {
|
||||
// 复制模板中的字段(排除 id, enable 等 provider 特有字段)
|
||||
...extractSourceFieldsFromTemplate(template),
|
||||
id: newId,
|
||||
type: template.type,
|
||||
provider_type: template.provider_type,
|
||||
provider: template.provider,
|
||||
enable: true,
|
||||
}
|
||||
|
||||
providerSources.value.push(newSource)
|
||||
selectedProviderSource.value = newSource
|
||||
selectedProviderSourceOriginalId.value = newId
|
||||
editableProviderSource.value = JSON.parse(JSON.stringify(newSource))
|
||||
availableModels.value = []
|
||||
modelMetadata.value = {}
|
||||
sourceProviderPanels.value = null
|
||||
isSourceModified.value = true
|
||||
}
|
||||
|
||||
function extractSourceFieldsFromTemplate(template) {
|
||||
// 从模板中提取 source 相关的字段
|
||||
const sourceFields = {}
|
||||
const excludeKeys = [
|
||||
'id', 'enable', 'model',
|
||||
'provider_source_id', 'modalities',
|
||||
'custom_extra_body'
|
||||
]
|
||||
|
||||
for (const [key, value] of Object.entries(template)) {
|
||||
if (!excludeKeys.includes(key)) {
|
||||
sourceFields[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return sourceFields
|
||||
}
|
||||
|
||||
async function deleteProviderSource(source) {
|
||||
if (!confirm(tm('providerSources.deleteConfirm', { id: source.id }))) return
|
||||
|
||||
try {
|
||||
// 删除关联的 providers
|
||||
providers.value = providers.value.filter(p => p.provider_source_id !== source.id)
|
||||
// 删除 provider source
|
||||
providerSources.value = providerSources.value.filter(s => s.id !== source.id)
|
||||
|
||||
if (selectedProviderSource.value?.id === source.id) {
|
||||
selectedProviderSource.value = null
|
||||
selectedProviderSourceOriginalId.value = null
|
||||
editableProviderSource.value = null
|
||||
sourceProviderPanels.value = null
|
||||
}
|
||||
|
||||
await saveConfig()
|
||||
showMessage(tm('providerSources.deleteSuccess'))
|
||||
} catch (error) {
|
||||
showMessage(error.message || tm('providerSources.deleteError'), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async function saveProviderSource() {
|
||||
if (!selectedProviderSource.value) return
|
||||
|
||||
savingSource.value = true
|
||||
const originalId = selectedProviderSourceOriginalId.value || selectedProviderSource.value.id
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`/api/config/provider_sources/${originalId}/update`,
|
||||
{
|
||||
config: editableProviderSource.value,
|
||||
original_id: originalId
|
||||
}
|
||||
)
|
||||
|
||||
if (response.data.status !== 'ok') {
|
||||
throw new Error(response.data.message)
|
||||
}
|
||||
|
||||
if (editableProviderSource.value.id !== originalId) {
|
||||
providers.value = providers.value.map(p =>
|
||||
p.provider_source_id === originalId
|
||||
? { ...p, provider_source_id: editableProviderSource.value.id }
|
||||
: p
|
||||
)
|
||||
selectedProviderSourceOriginalId.value = editableProviderSource.value.id
|
||||
}
|
||||
|
||||
// 同步列表中的当前 source,并保持选中
|
||||
const idx = providerSources.value.findIndex(ps => ps.id === originalId)
|
||||
if (idx !== -1) {
|
||||
providerSources.value[idx] = JSON.parse(JSON.stringify(editableProviderSource.value))
|
||||
selectedProviderSource.value = providerSources.value[idx]
|
||||
}
|
||||
|
||||
// 重新建立可编辑副本
|
||||
suppressSourceWatch = true
|
||||
editableProviderSource.value = selectedProviderSource.value
|
||||
nextTick(() => {
|
||||
suppressSourceWatch = false
|
||||
})
|
||||
|
||||
isSourceModified.value = false
|
||||
showMessage(response.data.message || tm('providerSources.saveSuccess'))
|
||||
return true
|
||||
} catch (error) {
|
||||
showMessage(error.response?.data?.message || error.message || tm('providerSources.saveError'), 'error')
|
||||
return false
|
||||
} finally {
|
||||
savingSource.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function fetchAvailableModels() {
|
||||
if (!selectedProviderSource.value) return
|
||||
|
||||
// 如果配置被修改,先保存
|
||||
if (isSourceModified.value) {
|
||||
const saved = await saveProviderSource()
|
||||
if (!saved) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
loadingModels.value = true
|
||||
try {
|
||||
const sourceId = editableProviderSource.value?.id || selectedProviderSource.value.id
|
||||
const response = await axios.get(
|
||||
`/api/config/provider_sources/${sourceId}/models`
|
||||
)
|
||||
if (response.data.status === 'ok') {
|
||||
const metadataMap = response.data.data.model_metadata || {}
|
||||
modelMetadata.value = metadataMap
|
||||
availableModels.value = (response.data.data.models || []).map(model => ({
|
||||
name: model,
|
||||
metadata: metadataMap?.[model] || null
|
||||
}))
|
||||
if (availableModels.value.length === 0) {
|
||||
showMessage(tm('models.noModelsFound'), 'info')
|
||||
}
|
||||
} else {
|
||||
throw new Error(response.data.message)
|
||||
}
|
||||
} catch (error) {
|
||||
modelMetadata.value = {}
|
||||
showMessage(error.response?.data?.message || error.message || tm('models.fetchError'), 'error')
|
||||
} finally {
|
||||
loadingModels.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function addModelProvider(modelName) {
|
||||
if (!selectedProviderSource.value) return
|
||||
|
||||
const sourceId = editableProviderSource.value?.id || selectedProviderSource.value.id
|
||||
const newId = `${sourceId}/${modelName}`
|
||||
|
||||
let modalities = ["text"]
|
||||
if (supportsImageInput(getModelMetadata(modelName))) {
|
||||
modalities.push("image")
|
||||
}
|
||||
if (supportsToolCall(getModelMetadata(modelName))) {
|
||||
modalities.push("tool_use")
|
||||
}
|
||||
|
||||
const newProvider = {
|
||||
id: newId,
|
||||
enable: false,
|
||||
provider_source_id: sourceId,
|
||||
model: modelName,
|
||||
modalities: modalities,
|
||||
custom_extra_body: {}
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await axios.post('/api/config/provider/new', newProvider)
|
||||
if (res.data.status === 'error') {
|
||||
throw new Error(res.data.message)
|
||||
}
|
||||
config.value.provider.push(newProvider)
|
||||
showMessage(res.data.message || tm('models.addSuccess', { model: modelName }))
|
||||
} catch (error) {
|
||||
showMessage(error.response?.data?.message || error.message || tm('providerSources.saveError'), 'error')
|
||||
} finally {
|
||||
await loadConfig()
|
||||
}
|
||||
}
|
||||
|
||||
function modelAlreadyConfigured(modelName) {
|
||||
return existingModelsForSelectedSource.value.has(modelName)
|
||||
}
|
||||
|
||||
async function deleteProvider(provider) {
|
||||
if (!confirm(tm('models.deleteConfirm', { id: provider.id }))) return
|
||||
|
||||
try {
|
||||
await axios.post('/api/config/provider/delete', { id: provider.id })
|
||||
providers.value = providers.value.filter(p => p.id !== provider.id)
|
||||
showMessage(tm('models.deleteSuccess'))
|
||||
} catch (error) {
|
||||
showMessage(error.message || tm('models.deleteError'), 'error')
|
||||
} finally {
|
||||
await loadConfig()
|
||||
}
|
||||
}
|
||||
|
||||
async function testProvider(provider) {
|
||||
testingProviders.value.push(provider.id)
|
||||
try {
|
||||
const response = await axios.get('/api/config/provider/check_one', {
|
||||
params: { id: provider.id }
|
||||
})
|
||||
if (response.data.status === 'ok' && response.data.data.error === null) {
|
||||
showMessage(tm('models.testSuccess', { id: provider.id }))
|
||||
} else {
|
||||
throw new Error(response.data.data.error || tm('models.testError'))
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage(error.response?.data?.message || error.message || tm('models.testError'), 'error')
|
||||
} finally {
|
||||
testingProviders.value = testingProviders.value.filter(id => id !== provider.id)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
try {
|
||||
config.value.provider_sources = providerSources.value
|
||||
config.value.provider = providers.value
|
||||
|
||||
await axios.post('/api/config/astrbot/update', {
|
||||
config: config.value,
|
||||
conf_id: 'default'
|
||||
})
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const response = await axios.get('/api/config/get')
|
||||
if (response.data.status === 'ok') {
|
||||
config.value = response.data.data.config
|
||||
providerSources.value = config.value.provider_sources || []
|
||||
providers.value = config.value.provider || []
|
||||
metadata.value = response.data.data.metadata
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage(error.message || 'Failed to load config', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function loadProviderTemplate() {
|
||||
try {
|
||||
const response = await axios.get('/api/config/provider/template')
|
||||
if (response.data.status === 'ok') {
|
||||
configSchema.value = response.data.data.config_schema || {}
|
||||
// 从 config_schema 中提取 provider templates
|
||||
if (configSchema.value.provider?.config_template) {
|
||||
providerTemplates.value = configSchema.value.provider.config_template
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load provider template:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Lifecycle =====
|
||||
onMounted(async () => {
|
||||
await loadConfig()
|
||||
await loadProviderTemplate()
|
||||
})
|
||||
|
||||
watch(() => props.defaultTab, (val) => {
|
||||
selectedProviderType.value = resolveDefaultTab(val)
|
||||
updateDefaultTab(val)
|
||||
})
|
||||
|
||||
// 跟踪编辑中的 provider source 是否被修改
|
||||
watch(editableProviderSource, () => {
|
||||
if (suppressSourceWatch) return
|
||||
if (!editableProviderSource.value) return
|
||||
isSourceModified.value = true
|
||||
}, { deep: true })
|
||||
|
||||
// ===== 非 chat 类型的方法 =====
|
||||
function getProviderType(provider) {
|
||||
if (!provider) return undefined
|
||||
if (provider.provider_type) {
|
||||
return provider.provider_type
|
||||
}
|
||||
// 兼容旧版本的 mapping
|
||||
const oldVersionProviderTypeMapping = {
|
||||
"openai_chat_completion": "chat_completion",
|
||||
"anthropic_chat_completion": "chat_completion",
|
||||
"googlegenai_chat_completion": "chat_completion",
|
||||
"zhipu_chat_completion": "chat_completion",
|
||||
"dify": "agent_runner",
|
||||
"coze": "agent_runner",
|
||||
"dashscope": "chat_completion",
|
||||
"openai_whisper_api": "speech_to_text",
|
||||
"openai_whisper_selfhost": "speech_to_text",
|
||||
"sensevoice_stt_selfhost": "speech_to_text",
|
||||
"openai_tts_api": "text_to_speech",
|
||||
"edge_tts": "text_to_speech",
|
||||
"gsvi_tts_api": "text_to_speech",
|
||||
"fishaudio_tts_api": "text_to_speech",
|
||||
"dashscope_tts": "text_to_speech",
|
||||
"azure_tts": "text_to_speech",
|
||||
"minimax_tts_api": "text_to_speech",
|
||||
"volcengine_tts": "text_to_speech",
|
||||
}
|
||||
return oldVersionProviderTypeMapping[provider.type]
|
||||
}
|
||||
|
||||
function getEmptyText() {
|
||||
return tm('providers.empty.typed', { type: selectedProviderType.value })
|
||||
}
|
||||
@@ -1403,43 +680,11 @@ function goToConfigPage() {
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
|
||||
.provider-sources-panel {
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
.provider-source-list {
|
||||
max-height: calc(100vh - 335px);
|
||||
overflow-y: auto;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.provider-source-list-item {
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.provider-source-list-item--active {
|
||||
background-color: #E8F0FE;
|
||||
border: 1px solid rgba(var(--v-theme-primary), 0.25);
|
||||
}
|
||||
|
||||
.provider-config-card {
|
||||
min-height: 280px;
|
||||
}
|
||||
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.border {
|
||||
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.provider-source-list {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.provider-sources-panel,
|
||||
.provider-config-card {
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
@@ -1,892 +0,0 @@
|
||||
<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-8">
|
||||
<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-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="activeProviderTypeTab" bg-color="transparent" class="mb-4">
|
||||
<v-tab value="all" class="font-weight-medium px-3">
|
||||
<v-icon start>mdi-filter-variant</v-icon>
|
||||
{{ tm('providers.tabs.all') }}
|
||||
</v-tab>
|
||||
<v-tab value="chat_completion" class="font-weight-medium px-3">
|
||||
<v-icon start>mdi-message-text</v-icon>
|
||||
{{ tm('providers.tabs.chatCompletion') }}
|
||||
</v-tab>
|
||||
<v-tab value="agent_runner" class="font-weight-medium px-3">
|
||||
<v-icon start>mdi-message-text</v-icon>
|
||||
{{ tm('providers.tabs.agentRunner') }}
|
||||
</v-tab>
|
||||
<v-tab value="speech_to_text" class="font-weight-medium px-3">
|
||||
<v-icon start>mdi-microphone-message</v-icon>
|
||||
{{ tm('providers.tabs.speechToText') }}
|
||||
</v-tab>
|
||||
<v-tab value="text_to_speech" class="font-weight-medium px-3">
|
||||
<v-icon start>mdi-volume-high</v-icon>
|
||||
{{ tm('providers.tabs.textToSpeech') }}
|
||||
</v-tab>
|
||||
<v-tab value="embedding" class="font-weight-medium px-3">
|
||||
<v-icon start>mdi-code-json</v-icon>
|
||||
{{ tm('providers.tabs.embedding') }}
|
||||
</v-tab>
|
||||
<v-tab value="rerank" class="font-weight-medium px-3">
|
||||
<v-icon start>mdi-compare-vertical</v-icon>
|
||||
{{ tm('providers.tabs.rerank') }}
|
||||
</v-tab>
|
||||
</v-tabs>
|
||||
|
||||
<template v-if="activeProviderTypeTab === 'all'">
|
||||
<v-row v-if="groupedProviders.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>
|
||||
<div v-else>
|
||||
<div v-for="group in groupedProviders" :key="group.typeKey" class="mb-8">
|
||||
<h1 class="text-h3 font-weight-bold mb-4">{{ group.label }}</h1>
|
||||
<v-row>
|
||||
<v-col v-for="(provider, index) in group.items" :key="`${group.typeKey}-${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="providerStatusChange"
|
||||
:bglogo="getProviderIcon(provider.provider)" @delete="deleteProvider" @edit="configExistingProvider"
|
||||
@copy="copyProvider" :show-copy-button="true">
|
||||
<template #item-details="{ item }">
|
||||
<!-- 测试状态 chip -->
|
||||
<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>
|
||||
<template v-slot:details="{ item }">
|
||||
</template>
|
||||
</item-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<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="providerStatusChange"
|
||||
:bglogo="getProviderIcon(provider.provider)" @delete="deleteProvider" @edit="configExistingProvider"
|
||||
@copy="copyProvider" :show-copy-button="true">
|
||||
|
||||
<template #item-details="{ item }">
|
||||
<!-- 测试状态 chip -->
|
||||
<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-card elevation="0" class="mt-4 mb-10">
|
||||
<v-card-title class="d-flex align-center py-3 px-4">
|
||||
<v-icon class="me-2">mdi-console-line</v-icon>
|
||||
<span class="text-h4">{{ tm('logs.title') }}</span>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn variant="text" color="primary" @click="showConsole = !showConsole">
|
||||
{{ showConsole ? tm('logs.collapse') : tm('logs.expand') }}
|
||||
<v-icon>{{ showConsole ? 'mdi-chevron-up' : 'mdi-chevron-down' }}</v-icon>
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
|
||||
<v-expand-transition>
|
||||
<v-card-text class="pa-0" v-if="showConsole">
|
||||
<ConsoleDisplayer style="background-color: #1e1e1e; height: 300px; border-radius: 0"></ConsoleDisplayer>
|
||||
</v-card-text>
|
||||
</v-expand-transition>
|
||||
</v-card>
|
||||
</v-container>
|
||||
|
||||
<!-- 添加提供商对话框 -->
|
||||
<AddNewProvider v-model:show="showAddProviderDialog" :metadata="metadata"
|
||||
@select-template="selectProviderTemplate" />
|
||||
|
||||
<!-- 配置对话框 -->
|
||||
<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="metadata['provider_group']?.metadata"
|
||||
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-snackbar :timeout="3000" elevation="24" :color="save_message_success" v-model="save_message_snack"
|
||||
location="top">
|
||||
{{ save_message }}
|
||||
</v-snackbar>
|
||||
|
||||
<WaitingForRestart ref="wfr"></WaitingForRestart>
|
||||
|
||||
<!-- Agent Runner 测试提示对话框 -->
|
||||
<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>
|
||||
|
||||
<!-- ID冲突确认对话框 -->
|
||||
<v-dialog v-model="showIdConflictDialog" max-width="450" persistent>
|
||||
<v-card>
|
||||
<v-card-title class="text-h6 bg-warning d-flex align-center">
|
||||
<v-icon start class="me-2">mdi-alert-circle-outline</v-icon>
|
||||
ID 冲突警告
|
||||
</v-card-title>
|
||||
<v-card-text class="py-4 text-body-1 text-medium-emphasis">
|
||||
检测到 ID "{{ conflictId }}" 重复。请使用一个新的 ID。
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn color="grey" variant="text" @click="handleIdConflictConfirm(false)">好的</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Key为空的确认对话框 -->
|
||||
<v-dialog v-model="showKeyConfirm" max-width="450" persistent>
|
||||
<v-card>
|
||||
<v-card-title class="text-h6 bg-error d-flex align-center">
|
||||
<v-icon start class="me-2">mdi-alert-circle-outline</v-icon>
|
||||
确认保存
|
||||
</v-card-title>
|
||||
<v-card-text class="py-4 text-body-1 text-medium-emphasis">
|
||||
您没有填写 API Key,确定要保存吗?这可能会导致该模型无法正常工作。
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn color="grey" variant="text" @click="handleKeyConfirm(false)">取消</v-btn>
|
||||
<v-btn color="error" variant="flat" @click="handleKeyConfirm(true)">确定</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios';
|
||||
import AstrBotConfig from '@/components/shared/AstrBotConfig.vue';
|
||||
import WaitingForRestart from '@/components/shared/WaitingForRestart.vue';
|
||||
import ConsoleDisplayer from '@/components/shared/ConsoleDisplayer.vue';
|
||||
import ItemCard from '@/components/shared/ItemCard.vue';
|
||||
import AddNewProvider from '@/components/provider/AddNewProvider.vue';
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
import { getProviderIcon } from '@/utils/providerUtils';
|
||||
|
||||
export default {
|
||||
name: 'ProviderPage',
|
||||
components: {
|
||||
AstrBotConfig,
|
||||
WaitingForRestart,
|
||||
ConsoleDisplayer,
|
||||
ItemCard,
|
||||
AddNewProvider
|
||||
},
|
||||
setup() {
|
||||
const { tm } = useModuleI18n('features/provider');
|
||||
return { tm };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
config_data: {},
|
||||
fetched: false,
|
||||
metadata: {},
|
||||
showProviderCfg: false,
|
||||
|
||||
// ID冲突确认对话框
|
||||
showIdConflictDialog: false,
|
||||
conflictId: '',
|
||||
idConflictResolve: null,
|
||||
|
||||
// Key确认对话框
|
||||
showKeyConfirm: false,
|
||||
keyConfirmResolve: null,
|
||||
|
||||
// Agent Runner 提示对话框
|
||||
showAgentRunnerDialog: false,
|
||||
|
||||
newSelectedProviderName: '',
|
||||
newSelectedProviderConfig: {},
|
||||
updatingMode: false,
|
||||
|
||||
loading: false,
|
||||
|
||||
save_message_snack: false,
|
||||
save_message: "",
|
||||
save_message_success: "success",
|
||||
|
||||
showConsole: false,
|
||||
|
||||
// 显示状态部分
|
||||
showStatus: false,
|
||||
|
||||
// 供应商状态相关
|
||||
providerStatuses: [],
|
||||
testingProviders: [], // 存储正在测试的 provider ID
|
||||
|
||||
// 新增提供商对话框相关
|
||||
showAddProviderDialog: false,
|
||||
|
||||
// 添加提供商类型分类
|
||||
activeProviderTypeTab: 'all',
|
||||
|
||||
// 兼容旧版本(< v3.5.11)的 mapping,用于映射到对应的提供商能力类型
|
||||
oldVersionProviderTypeMapping: {
|
||||
"openai_chat_completion": "chat_completion",
|
||||
"anthropic_chat_completion": "chat_completion",
|
||||
"googlegenai_chat_completion": "chat_completion",
|
||||
"zhipu_chat_completion": "chat_completion",
|
||||
"dify": "agent_runner",
|
||||
"coze": "agent_runner",
|
||||
"dashscope": "chat_completion",
|
||||
"openai_whisper_api": "speech_to_text",
|
||||
"openai_whisper_selfhost": "speech_to_text",
|
||||
"sensevoice_stt_selfhost": "speech_to_text",
|
||||
"openai_tts_api": "text_to_speech",
|
||||
"edge_tts": "text_to_speech",
|
||||
"gsvi_tts_api": "text_to_speech",
|
||||
"fishaudio_tts_api": "text_to_speech",
|
||||
"dashscope_tts": "text_to_speech",
|
||||
"azure_tts": "text_to_speech",
|
||||
"minimax_tts_api": "text_to_speech",
|
||||
"volcengine_tts": "text_to_speech",
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
showIdConflictDialog(newValue) {
|
||||
// 当对话框关闭时,如果 Promise 还在等待,则拒绝它以防止内存泄漏
|
||||
if (!newValue && this.idConflictResolve) {
|
||||
this.idConflictResolve(false);
|
||||
this.idConflictResolve = null;
|
||||
}
|
||||
},
|
||||
showKeyConfirm(newValue) {
|
||||
// 当对话框关闭时,如果 Promise 还在等待,则拒绝它以防止内存泄漏
|
||||
if (!newValue && this.keyConfirmResolve) {
|
||||
this.keyConfirmResolve(false);
|
||||
this.keyConfirmResolve = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
// 翻译消息的计算属性
|
||||
messages() {
|
||||
return {
|
||||
emptyText: {
|
||||
all: this.tm('providers.empty.all'),
|
||||
typed: this.tm('providers.empty.typed')
|
||||
},
|
||||
tabTypes: {
|
||||
'chat_completion': this.tm('providers.tabs.chatCompletion'),
|
||||
'agent_runner': this.tm('providers.tabs.agentRunner'),
|
||||
'speech_to_text': this.tm('providers.tabs.speechToText'),
|
||||
'text_to_speech': this.tm('providers.tabs.textToSpeech'),
|
||||
'embedding': this.tm('providers.tabs.embedding'),
|
||||
'rerank': this.tm('providers.tabs.rerank')
|
||||
},
|
||||
success: {
|
||||
update: this.tm('messages.success.update'),
|
||||
add: this.tm('messages.success.add'),
|
||||
delete: this.tm('messages.success.delete'),
|
||||
statusUpdate: this.tm('messages.success.statusUpdate'),
|
||||
},
|
||||
error: {
|
||||
fetchStatus: this.tm('messages.error.fetchStatus'),
|
||||
testError: this.tm('messages.error.testError')
|
||||
},
|
||||
confirm: {
|
||||
delete: this.tm('messages.confirm.delete')
|
||||
},
|
||||
status: {
|
||||
available: this.tm('availability.available'),
|
||||
unavailable: this.tm('availability.unavailable'),
|
||||
pending: this.tm('availability.pending')
|
||||
},
|
||||
availability: {
|
||||
test: this.tm('availability.test')
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
groupedProviders() {
|
||||
if (!this.config_data.provider) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const typeOrder = [
|
||||
'chat_completion',
|
||||
'agent_runner',
|
||||
'speech_to_text',
|
||||
'text_to_speech',
|
||||
'embedding',
|
||||
'rerank',
|
||||
];
|
||||
|
||||
const assigned = new Set();
|
||||
const groups = typeOrder
|
||||
.map((typeKey) => {
|
||||
const items = this.config_data.provider.filter((provider) => {
|
||||
const resolved = this.getProviderType(provider);
|
||||
if (resolved === typeKey) {
|
||||
assigned.add(provider.id);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
return {
|
||||
typeKey,
|
||||
label: this.messages.tabTypes[typeKey] || typeKey,
|
||||
items,
|
||||
};
|
||||
})
|
||||
.filter((group) => group.items.length > 0);
|
||||
|
||||
const remaining = this.config_data.provider.filter(
|
||||
(provider) => !assigned.has(provider.id),
|
||||
);
|
||||
if (remaining.length > 0) {
|
||||
groups.push({
|
||||
typeKey: 'others',
|
||||
label: this.tm('providers.tabs.all'),
|
||||
items: remaining,
|
||||
});
|
||||
}
|
||||
return groups;
|
||||
},
|
||||
|
||||
// 根据选择的标签过滤提供商列表
|
||||
filteredProviders() {
|
||||
if (!this.config_data.provider || this.activeProviderTypeTab === 'all') {
|
||||
return this.config_data.provider || [];
|
||||
}
|
||||
|
||||
return this.config_data.provider.filter(provider => {
|
||||
// 如果provider.provider_type已经存在,直接使用它
|
||||
return this.getProviderType(provider) === this.activeProviderTypeTab;
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.getConfig();
|
||||
},
|
||||
|
||||
methods: {
|
||||
getProviderType(provider) {
|
||||
if (!provider) return undefined;
|
||||
if (provider.provider_type) {
|
||||
return provider.provider_type;
|
||||
}
|
||||
return this.oldVersionProviderTypeMapping[provider.type];
|
||||
},
|
||||
|
||||
getConfig() {
|
||||
axios.get('/api/config/get').then((res) => {
|
||||
this.config_data = res.data.data.config;
|
||||
this.fetched = true
|
||||
this.metadata = res.data.data.metadata;
|
||||
}).catch((err) => {
|
||||
this.showError(err.response?.data?.message || err.message);
|
||||
});
|
||||
},
|
||||
|
||||
// 从工具函数导入
|
||||
getProviderIcon,
|
||||
|
||||
// 获取空列表文本
|
||||
getEmptyText() {
|
||||
if (this.activeProviderTypeTab === 'all') {
|
||||
return this.messages.emptyText.all;
|
||||
} else {
|
||||
return this.tm('providers.empty.typed', { type: this.getTabTypeName(this.activeProviderTypeTab) });
|
||||
}
|
||||
},
|
||||
|
||||
// 获取Tab类型的中文名称
|
||||
getTabTypeName(tabType) {
|
||||
return this.messages.tabTypes[tabType] || tabType;
|
||||
},
|
||||
|
||||
// 选择提供商模板
|
||||
selectProviderTemplate(name) {
|
||||
this.newSelectedProviderName = name;
|
||||
this.showProviderCfg = true;
|
||||
this.updatingMode = false;
|
||||
this.newSelectedProviderConfig = JSON.parse(JSON.stringify(
|
||||
this.metadata['provider_group']?.metadata?.provider?.config_template[name] || {}
|
||||
));
|
||||
},
|
||||
|
||||
configExistingProvider(provider) {
|
||||
this.newSelectedProviderName = provider.id;
|
||||
this.newSelectedProviderConfig = {};
|
||||
|
||||
// 比对默认配置模版,看看是否有更新
|
||||
let templates = this.metadata['provider_group']?.metadata?.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) => {
|
||||
// 首先复制所有source中的属性到target
|
||||
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];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 然后根据reference的结构添加或覆盖属性
|
||||
for (let key in reference) {
|
||||
if (typeof reference[key] === 'object' && reference[key] !== null) {
|
||||
if (!(key in target)) {
|
||||
// 如果target中没有这个key
|
||||
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(this.newSelectedProviderConfig, provider, defaultConfig);
|
||||
}
|
||||
|
||||
this.showProviderCfg = true;
|
||||
this.updatingMode = true;
|
||||
},
|
||||
|
||||
async newProvider() {
|
||||
// 检查 key 是否为空
|
||||
if (
|
||||
'key' in this.newSelectedProviderConfig &&
|
||||
(!this.newSelectedProviderConfig.key || this.newSelectedProviderConfig.key.length === 0)
|
||||
) {
|
||||
const confirmed = await this.confirmEmptyKey();
|
||||
if (!confirmed) {
|
||||
return; // 如果用户取消,则中止保存
|
||||
}
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
const wasUpdating = this.updatingMode;
|
||||
try {
|
||||
if (wasUpdating) {
|
||||
const res = await axios.post('/api/config/provider/update', {
|
||||
id: this.newSelectedProviderName,
|
||||
config: this.newSelectedProviderConfig
|
||||
});
|
||||
if (res.data.status === 'error') {
|
||||
this.showError(res.data.message || "更新失败!");
|
||||
return
|
||||
}
|
||||
this.showSuccess(res.data.message || "更新成功!");
|
||||
if (wasUpdating) {
|
||||
this.updatingMode = false;
|
||||
}
|
||||
} else {
|
||||
// 检查 ID 是否已存在
|
||||
const existingProvider = this.config_data.provider?.find(p => p.id === this.newSelectedProviderConfig.id);
|
||||
if (existingProvider) {
|
||||
const confirmed = await this.confirmIdConflict(this.newSelectedProviderConfig.id);
|
||||
if (!confirmed) {
|
||||
this.loading = false;
|
||||
return; // 如果用户取消,则中止保存
|
||||
}
|
||||
}
|
||||
|
||||
const res = await axios.post('/api/config/provider/new', this.newSelectedProviderConfig);
|
||||
if (res.data.status === 'error') {
|
||||
this.showError(res.data.message || "添加失败!");
|
||||
return
|
||||
}
|
||||
this.showSuccess(res.data.message || "添加成功!");
|
||||
}
|
||||
this.showProviderCfg = false;
|
||||
} catch (err) {
|
||||
this.showError(err.response?.data?.message || err.message);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
this.getConfig();
|
||||
}
|
||||
},
|
||||
|
||||
async copyProvider(providerToCopy) {
|
||||
console.log('copyProvider triggered for:', providerToCopy);
|
||||
// 1. 创建深拷贝
|
||||
const newProviderConfig = JSON.parse(JSON.stringify(providerToCopy));
|
||||
|
||||
// 2. 生成唯一的 ID
|
||||
const generateUniqueId = (baseId) => {
|
||||
let newId = `${baseId}_copy`;
|
||||
let counter = 1;
|
||||
const existingIds = this.config_data.provider.map(p => p.id);
|
||||
while (existingIds.includes(newId)) {
|
||||
newId = `${baseId}_copy_${counter}`;
|
||||
counter++;
|
||||
}
|
||||
return newId;
|
||||
};
|
||||
newProviderConfig.id = generateUniqueId(providerToCopy.id);
|
||||
|
||||
// 3. 设置为禁用状态,等待用户手动开启
|
||||
newProviderConfig.enable = false;
|
||||
|
||||
this.loading = true;
|
||||
try {
|
||||
// 4. 调用后端接口创建
|
||||
const res = await axios.post('/api/config/provider/new', newProviderConfig);
|
||||
this.showSuccess(res.data.message || `成功复制并创建了 ${newProviderConfig.id}`);
|
||||
this.getConfig(); // 5. 刷新列表
|
||||
} catch (err) {
|
||||
this.showError(err.response?.data?.message || err.message);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
deleteProvider(provider) {
|
||||
if (confirm(this.tm('messages.confirm.delete', { id: provider.id }))) {
|
||||
axios.post('/api/config/provider/delete', { id: provider.id }).then((res) => {
|
||||
this.getConfig();
|
||||
this.showSuccess(res.data.message || this.messages.success.delete);
|
||||
}).catch((err) => {
|
||||
this.showError(err.response?.data?.message || err.message);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
providerStatusChange(provider) {
|
||||
provider.enable = !provider.enable; // 切换状态
|
||||
|
||||
axios.post('/api/config/provider/update', {
|
||||
id: provider.id,
|
||||
config: provider
|
||||
}).then((res) => {
|
||||
if (res.data.status === 'error') {
|
||||
this.showError(res.data.message)
|
||||
return
|
||||
}
|
||||
this.getConfig();
|
||||
this.showSuccess(res.data.message || this.messages.success.statusUpdate);
|
||||
}).catch((err) => {
|
||||
provider.enable = !provider.enable; // 发生错误时回滚状态
|
||||
this.showError(err.response?.data?.message || err.message);
|
||||
});
|
||||
},
|
||||
|
||||
showSuccess(message) {
|
||||
this.save_message = message;
|
||||
this.save_message_success = "success";
|
||||
this.save_message_snack = true;
|
||||
},
|
||||
|
||||
showError(message) {
|
||||
this.save_message = message;
|
||||
this.save_message_success = "error";
|
||||
this.save_message_snack = true;
|
||||
},
|
||||
|
||||
// 获取供应商状态
|
||||
async fetchProviderStatus() {
|
||||
if (this.testingProviders.length > 0) return;
|
||||
|
||||
this.showStatus = true; // 自动展开状态部分
|
||||
|
||||
const providersToTest = this.config_data.provider.filter(p => p.enable);
|
||||
if (providersToTest.length === 0) return;
|
||||
|
||||
// 1. 初始化UI为pending状态,并将所有待测试的 provider ID 加入 loading 列表
|
||||
this.providerStatuses = providersToTest.map(p => {
|
||||
this.testingProviders.push(p.id);
|
||||
return { id: p.id, name: p.id, status: 'pending', error: null };
|
||||
});
|
||||
|
||||
// 2. 为每个provider创建一个并发的测试请求
|
||||
const promises = providersToTest.map(p =>
|
||||
axios.get(`/api/config/provider/check_one?id=${p.id}`)
|
||||
.then(res => {
|
||||
if (res.data && res.data.status === 'ok') {
|
||||
const index = this.providerStatuses.findIndex(s => s.id === p.id);
|
||||
if (index !== -1) this.providerStatuses.splice(index, 1, res.data.data);
|
||||
} else {
|
||||
throw new Error(res.data?.message || `Failed to check status for ${p.id}`);
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
const errorMessage = err.response?.data?.message || err.message || 'Unknown error';
|
||||
const index = this.providerStatuses.findIndex(s => s.id === p.id);
|
||||
if (index !== -1) {
|
||||
const failedStatus = { ...this.providerStatuses[index], status: 'unavailable', error: errorMessage };
|
||||
this.providerStatuses.splice(index, 1, failedStatus);
|
||||
}
|
||||
return Promise.reject(errorMessage); // Propagate error for Promise.allSettled
|
||||
})
|
||||
);
|
||||
|
||||
// 3. 等待所有请求完成
|
||||
try {
|
||||
await Promise.allSettled(promises);
|
||||
} finally {
|
||||
// 4. 关闭所有加载状态
|
||||
this.testingProviders = [];
|
||||
}
|
||||
},
|
||||
|
||||
isProviderTesting(providerId) {
|
||||
return this.testingProviders.includes(providerId);
|
||||
},
|
||||
|
||||
getProviderStatus(providerId) {
|
||||
return this.providerStatuses.find(s => s.id === providerId);
|
||||
},
|
||||
|
||||
async testSingleProvider(provider) {
|
||||
if (this.isProviderTesting(provider.id)) return;
|
||||
|
||||
this.testingProviders.push(provider.id);
|
||||
|
||||
// 更新UI为pending状态
|
||||
const statusIndex = this.providerStatuses.findIndex(s => s.id === provider.id);
|
||||
const pendingStatus = {
|
||||
id: provider.id,
|
||||
name: provider.id,
|
||||
status: 'pending',
|
||||
error: null
|
||||
};
|
||||
if (statusIndex !== -1) {
|
||||
this.providerStatuses.splice(statusIndex, 1, pendingStatus);
|
||||
} else {
|
||||
this.providerStatuses.unshift(pendingStatus);
|
||||
}
|
||||
|
||||
try {
|
||||
if (!provider.enable) {
|
||||
throw new Error('该提供商未被用户启用');
|
||||
}
|
||||
if (provider.provider_type === 'agent_runner') {
|
||||
this.showAgentRunnerDialog = true;
|
||||
this.providerStatuses = this.providerStatuses.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 = this.providerStatuses.findIndex(s => s.id === provider.id);
|
||||
if (index !== -1) {
|
||||
this.providerStatuses.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 = this.providerStatuses.findIndex(s => s.id === provider.id);
|
||||
const failedStatus = {
|
||||
id: provider.id,
|
||||
name: provider.id,
|
||||
status: 'unavailable',
|
||||
error: errorMessage
|
||||
};
|
||||
if (index !== -1) {
|
||||
this.providerStatuses.splice(index, 1, failedStatus);
|
||||
}
|
||||
// 不再显示全局的错误提示,因为卡片本身会显示错误信息
|
||||
// this.showError(this.tm('messages.error.testError', { id: provider.id, error: errorMessage }));
|
||||
} finally {
|
||||
const index = this.testingProviders.indexOf(provider.id);
|
||||
if (index > -1) {
|
||||
this.testingProviders.splice(index, 1);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
confirmEmptyKey() {
|
||||
this.showKeyConfirm = true;
|
||||
return new Promise((resolve) => {
|
||||
this.keyConfirmResolve = resolve;
|
||||
});
|
||||
},
|
||||
|
||||
handleKeyConfirm(confirmed) {
|
||||
if (this.keyConfirmResolve) {
|
||||
this.keyConfirmResolve(confirmed);
|
||||
}
|
||||
this.showKeyConfirm = false;
|
||||
},
|
||||
|
||||
confirmIdConflict(id) {
|
||||
this.conflictId = id;
|
||||
this.showIdConflictDialog = true;
|
||||
return new Promise((resolve) => {
|
||||
this.idConflictResolve = resolve;
|
||||
});
|
||||
},
|
||||
|
||||
handleIdConflictConfirm(confirmed) {
|
||||
if (this.idConflictResolve) {
|
||||
this.idConflictResolve(confirmed);
|
||||
}
|
||||
this.showIdConflictDialog = false;
|
||||
},
|
||||
goToConfigPage() {
|
||||
this.showAgentRunnerDialog = false;
|
||||
this.$router.push({ name: 'Configs' });
|
||||
},
|
||||
getStatusColor(status) {
|
||||
switch (status) {
|
||||
case 'available':
|
||||
return 'success';
|
||||
case 'unavailable':
|
||||
return 'error';
|
||||
case 'pending':
|
||||
return 'grey';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
},
|
||||
|
||||
getStatusText(status) {
|
||||
return this.messages.status[status] || status;
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.provider-page {
|
||||
padding: 20px;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
|
||||
.status-card {
|
||||
height: 120px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user