diff --git a/astrbot/dashboard/routes/config.py b/astrbot/dashboard/routes/config.py index f0502dcf8..3ee200b21 100644 --- a/astrbot/dashboard/routes/config.py +++ b/astrbot/dashboard/routes/config.py @@ -193,9 +193,67 @@ class ConfigRoute(Route): "POST", self.update_provider_source, ), + "/config/provider_sources//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__ diff --git a/dashboard/src/components/provider/ProviderModelsPanel.vue b/dashboard/src/components/provider/ProviderModelsPanel.vue new file mode 100644 index 000000000..cdd1349ed --- /dev/null +++ b/dashboard/src/components/provider/ProviderModelsPanel.vue @@ -0,0 +1,211 @@ + + + + + diff --git a/dashboard/src/components/provider/ProviderSourcesPanel.vue b/dashboard/src/components/provider/ProviderSourcesPanel.vue new file mode 100644 index 000000000..30e18385c --- /dev/null +++ b/dashboard/src/components/provider/ProviderSourcesPanel.vue @@ -0,0 +1,150 @@ + + + + + diff --git a/dashboard/src/composables/useProviderSources.ts b/dashboard/src/composables/useProviderSources.ts new file mode 100644 index 000000000..1eb69a210 --- /dev/null +++ b/dashboard/src/composables/useProviderSources.ts @@ -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 + 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>({}) + const metadata = ref>({}) + const providerSources = ref([]) + const providers = ref([]) + const selectedProviderType = ref(resolveDefaultTab(options.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 isSourceModified = ref(false) + const configSchema = ref>({}) + const providerTemplates = ref>({}) + 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() + 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 = {} + + 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 = {} + + 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 = { + 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) { + const sourceFields: Record = {} + 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 + } +} diff --git a/dashboard/src/views/ProviderPage.vue b/dashboard/src/views/ProviderPage.vue index 330cbcf95..f4121f794 100644 --- a/dashboard/src/views/ProviderPage.vue +++ b/dashboard/src/views/ProviderPage.vue @@ -32,56 +32,17 @@
- -
-
-

{{ tm('providerSources.title') }}

-
- - - - - {{ sourceType.label }} - - - -
- -
- - - - {{ getSourceDisplayName(source) }} - {{ source.api_base || 'N/A' }} - - - -
-
- mdi-api-off -

{{ tm('providerSources.empty') }}

-
-
+
@@ -122,102 +83,26 @@ -
-
-

{{ tm('models.configured') }}

- {{ tm('models.available') }} {{ - availableModels.length }} - - - - {{ isSourceModified ? tm('providerSources.saveAndFetchModels') : - tm('providerSources.fetchModels') }} - - - {{ tm('models.manualAddButton') }} - -
- - - - - -
+
mdi-cursor-default-click @@ -371,13 +256,16 @@ - -