diff --git a/astrbot/api/provider/__init__.py b/astrbot/api/provider/__init__.py index f62b340f8d..817e8c812d 100644 --- a/astrbot/api/provider/__init__.py +++ b/astrbot/api/provider/__init__.py @@ -1,11 +1,18 @@ from astrbot.core.db.po import Personality -from astrbot.core.provider import Provider, STTProvider +from astrbot.core.provider import ( + EmbeddingProvider, + Provider, + RerankProvider, + STTProvider, + TTSProvider, +) from astrbot.core.provider.entities import ( LLMResponse, ProviderMetaData, ProviderRequest, ProviderType, ) +from astrbot.core.provider.register import register_provider_adapter __all__ = [ "LLMResponse", @@ -15,4 +22,8 @@ "ProviderRequest", "ProviderType", "STTProvider", + "TTSProvider", + "EmbeddingProvider", + "RerankProvider", + "register_provider_adapter", ] diff --git a/astrbot/core/provider/__init__.py b/astrbot/core/provider/__init__.py index 812e021715..22a19afdd9 100644 --- a/astrbot/core/provider/__init__.py +++ b/astrbot/core/provider/__init__.py @@ -1,4 +1,17 @@ from .entities import ProviderMetaData -from .provider import Provider, STTProvider +from .provider import ( + EmbeddingProvider, + Provider, + RerankProvider, + STTProvider, + TTSProvider, +) -__all__ = ["Provider", "ProviderMetaData", "STTProvider"] +__all__ = [ + "Provider", + "ProviderMetaData", + "STTProvider", + "TTSProvider", + "EmbeddingProvider", + "RerankProvider", +] diff --git a/astrbot/core/provider/entities.py b/astrbot/core/provider/entities.py index 20c5a7947d..7a59c8121c 100644 --- a/astrbot/core/provider/entities.py +++ b/astrbot/core/provider/entities.py @@ -58,6 +58,10 @@ class ProviderMetaData(ProviderMeta): """the default configuration template of the provider adapter""" provider_display_name: str | None = None """the display name of the provider shown in the WebUI configuration page; if empty, the type is used""" + i18n_resources: dict[str, dict] | None = None + """the i18n resource data of the provider adapter, such as {"zh-CN": {...}, "en-US": {...}}""" + config_metadata: dict | None = None + """the configuration metadata used by WebUI to generate provider source forms""" @dataclass diff --git a/astrbot/core/provider/register.py b/astrbot/core/provider/register.py index 3ad83784ec..62059d70a4 100644 --- a/astrbot/core/provider/register.py +++ b/astrbot/core/provider/register.py @@ -17,6 +17,8 @@ def register_provider_adapter( provider_type: ProviderType = ProviderType.CHAT_COMPLETION, default_config_tmpl: dict | None = None, provider_display_name: str | None = None, + i18n_resources: dict[str, dict] | None = None, + config_metadata: dict | None = None, ): """用于注册平台适配器的带参装饰器""" @@ -44,6 +46,8 @@ def decorator(cls): cls_type=cls, default_config_tmpl=default_config_tmpl, provider_display_name=provider_display_name, + i18n_resources=i18n_resources, + config_metadata=config_metadata, ) provider_registry.append(pm) provider_cls_map[provider_type_name] = pm diff --git a/astrbot/dashboard/routes/config.py b/astrbot/dashboard/routes/config.py index bcd7e075c7..406ce524b2 100644 --- a/astrbot/dashboard/routes/config.py +++ b/astrbot/dashboard/routes/config.py @@ -508,24 +508,15 @@ async def update_provider_source(self): return Response().ok(message="更新 provider source 成功").__dict__ async def get_provider_template(self): - provider_metadata = ConfigMetadataI18n.convert_to_i18n_keys( - { - "provider_group": { - "metadata": { - "provider": CONFIG_METADATA_2["provider_group"]["metadata"][ - "provider" - ] - } - } - } + config_schema, provider_i18n_translations, provider_type_metadata = ( + self._build_provider_schema_with_i18n() ) - config_schema = { - "provider": provider_metadata["provider_group"]["metadata"]["provider"] - } data = { "config_schema": config_schema, "providers": astrbot_config["provider"], "provider_sources": astrbot_config["provider_sources"], + "provider_i18n_translations": provider_i18n_translations, + "provider_type_metadata": provider_type_metadata, } return Response().ok(data=data).__dict__ @@ -1435,6 +1426,87 @@ def _inject_platform_metadata_with_i18n( platform_items_to_inject ) + def _apply_dynamic_i18n_keys(self, items: dict, i18n_prefix: str): + """Replace configurable text fields with dynamic i18n keys recursively.""" + for field_key, field_value in items.items(): + if not isinstance(field_value, dict): + continue + + field_prefix = f"{i18n_prefix}.{field_key}" + for key in ("description", "hint", "labels", "name"): + if key in field_value: + field_value[key] = f"{field_prefix}.{key}" + + if isinstance(field_value.get("items"), dict): + self._apply_dynamic_i18n_keys(field_value["items"], field_prefix) + + if isinstance(field_value.get("template_schema"), dict): + self._apply_dynamic_i18n_keys( + field_value["template_schema"], + f"{field_prefix}.template_schema", + ) + + def _collect_provider_i18n_translations( + self, provider, provider_i18n_translations: dict + ): + """Collect runtime i18n resources for a provider adapter.""" + if not provider.i18n_resources: + return + + for lang, lang_data in provider.i18n_resources.items(): + provider_i18n_translations.setdefault(lang, {}).setdefault( + "provider_group", {} + ).setdefault("provider", {})[provider.type] = lang_data + + def _build_provider_schema_with_i18n(self) -> tuple[dict, dict, dict]: + provider_metadata = ConfigMetadataI18n.convert_to_i18n_keys( + { + "provider_group": { + "metadata": { + "provider": copy.deepcopy( + CONFIG_METADATA_2["provider_group"]["metadata"]["provider"] + ) + } + } + } + ) + provider_i18n_translations = {} + provider_type_metadata = {} + + provider_default_tmpl = provider_metadata["provider_group"]["metadata"][ + "provider" + ]["config_template"] + for provider in provider_registry: + self._collect_provider_i18n_translations( + provider, provider_i18n_translations + ) + + if provider.default_config_tmpl: + provider_default_tmpl[provider.type] = copy.deepcopy( + provider.default_config_tmpl + ) + display_name = provider.provider_display_name or provider.type + if provider.i18n_resources: + display_name = f"provider_group.provider.{provider.type}.name" + provider_default_tmpl[provider.type]["_display_name"] = display_name + + if provider.config_metadata: + provider_items_to_inject = copy.deepcopy(provider.config_metadata) + if provider.i18n_resources: + self._apply_dynamic_i18n_keys( + provider_items_to_inject, + f"provider_group.provider.{provider.type}", + ) + provider_type_metadata[provider.type] = { + "items": provider_items_to_inject + } + + return ( + {"provider": provider_metadata["provider_group"]["metadata"]["provider"]}, + provider_i18n_translations, + provider_type_metadata, + ) + async def _get_astrbot_config(self): config = self.config metadata = copy.deepcopy(CONFIG_METADATA_2) @@ -1484,17 +1556,17 @@ async def _get_astrbot_config(self): await asyncio.gather(*logo_registration_tasks, return_exceptions=True) # 服务提供商的默认配置模板注入 - provider_default_tmpl = metadata["provider_group"]["metadata"]["provider"][ - "config_template" - ] - for provider in provider_registry: - if provider.default_config_tmpl: - provider_default_tmpl[provider.type] = provider.default_config_tmpl + provider_schema, provider_i18n_translations, provider_type_metadata = ( + self._build_provider_schema_with_i18n() + ) + metadata["provider_group"]["metadata"]["provider"] = provider_schema["provider"] return { "metadata": metadata, "config": config, "platform_i18n_translations": platform_i18n_translations, + "provider_i18n_translations": provider_i18n_translations, + "provider_type_metadata": provider_type_metadata, } async def _get_plugin_config(self, plugin_name: str): diff --git a/dashboard/src/components/chat/ProviderConfigDialog.vue b/dashboard/src/components/chat/ProviderConfigDialog.vue index 51ff37677f..f4c05728bf 100644 --- a/dashboard/src/components/chat/ProviderConfigDialog.vue +++ b/dashboard/src/components/chat/ProviderConfigDialog.vue @@ -42,7 +42,7 @@
-
@@ -54,7 +54,7 @@ + :metadata="providerSourceSchema" metadataKey="provider" :is-editing="true" /> @@ -174,6 +174,7 @@ const { testingProviders, isSourceModified, configSchema, + providerSourceSchema, manualModelId, modelSearch, availableSourceTypes, diff --git a/dashboard/src/components/shared/AstrBotConfig.vue b/dashboard/src/components/shared/AstrBotConfig.vue index bc1c86bdfc..8c6d8569f6 100644 --- a/dashboard/src/components/shared/AstrBotConfig.vue +++ b/dashboard/src/components/shared/AstrBotConfig.vue @@ -43,9 +43,21 @@ const translateIfKey = (value) => { } const filteredIterable = computed(() => { - if (!props.iterable) return {} - const { hint, ...rest } = props.iterable - return rest + const result = {} + const metadataItems = props.metadata?.[props.metadataKey]?.items || {} + const iterable = props.iterable || {} + + for (const key of Object.keys(metadataItems)) { + if (key === 'hint') continue + result[key] = iterable[key] + } + + for (const [key, value] of Object.entries(iterable)) { + if (key === 'hint' || key in result) continue + result[key] = value + } + + return result }) const providerHint = computed(() => { @@ -155,6 +167,28 @@ function getItemPath(key) { return props.pathPrefix ? `${props.pathPrefix}.${key}` : key } +function ensureStructuredValue(key, itemMeta) { + if (!props.iterable || typeof props.iterable !== 'object') { + return undefined + } + + if (props.iterable[key] !== undefined && props.iterable[key] !== null) { + return props.iterable[key] + } + + if (itemMeta?.type === 'object' || itemMeta?.type === 'dict') { + props.iterable[key] = {} + return props.iterable[key] + } + + if (itemMeta?.type === 'list' || itemMeta?.type === 'template_list') { + props.iterable[key] = [] + return props.iterable[key] + } + + return props.iterable[key] +} + function hasVisibleItemsAfter(items, currentIndex) { const itemEntries = Object.entries(items) @@ -204,7 +238,7 @@ function hasVisibleItemsAfter(items, currentIndex) { diff --git a/dashboard/src/composables/useProviderSources.ts b/dashboard/src/composables/useProviderSources.ts index 2891d8976c..a33ebadde1 100644 --- a/dashboard/src/composables/useProviderSources.ts +++ b/dashboard/src/composables/useProviderSources.ts @@ -1,8 +1,9 @@ -import { ref, computed, onMounted, nextTick, watch } from 'vue' +import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch } from 'vue' import axios from 'axios' import { getProviderIcon } from '@/utils/providerUtils' import { askForConfirmation as askForConfirmationDialog, useConfirmDialog } from '@/utils/confirmDialog' import { normalizeTextInput } from '@/utils/inputValue' +import { mergeDynamicTranslations, useModuleI18n } from '@/i18n/composables' export interface UseProviderSourcesOptions { defaultTab?: string @@ -38,6 +39,7 @@ export function resolveDefaultTab(value?: string) { export function useProviderSources(options: UseProviderSourcesOptions) { const { tm, showMessage } = options + const { tm: tmConfigMetadata, getRaw } = useModuleI18n('features/config-metadata') const confirmDialog = useConfirmDialog() @@ -62,6 +64,7 @@ export function useProviderSources(options: UseProviderSourcesOptions) { const isSourceModified = ref(false) const configSchema = ref>({}) const providerTemplates = ref>({}) + const providerTypeMetadata = ref>({}) const manualModelId = ref('') const modelSearch = ref('') @@ -87,7 +90,7 @@ export function useProviderSources(options: UseProviderSourcesOptions) { if (template.provider_type === selectedProviderType.value) { types.push({ value: templateName, - label: templateName, + label: getTemplateDisplayName(templateName, template), icon: getProviderIcon(template.provider) }) } @@ -237,6 +240,15 @@ export function useProviderSources(options: UseProviderSourcesOptions) { // 创建一个深拷贝以避免修改原始 schema const customSchema = JSON.parse(JSON.stringify(configSchema.value)) + const providerType = editableProviderSource.value?.type || selectedProviderSource.value?.type + const typeSpecificMetadata = providerType ? providerTypeMetadata.value?.[providerType] : null + + if (typeSpecificMetadata?.items) { + customSchema.provider.items = { + ...(customSchema.provider.items || {}), + ...typeSpecificMetadata.items + } + } // 为 provider source 的 id 字段添加自定义 hint if (customSchema.provider?.items?.id) { @@ -274,6 +286,15 @@ export function useProviderSources(options: UseProviderSourcesOptions) { return getProviderIcon(source.provider) || '' } + function translateConfigMetadataKey(value?: string) { + if (!value || typeof value !== 'string') return value || '' + return getRaw(value) ? tmConfigMetadata(value) : value + } + + function getTemplateDisplayName(templateName: string, template: Record) { + return translateConfigMetadataKey(template?._display_name) || templateName + } + function getSourceDisplayName(source: any) { if (!source) return '' if (source.isPlaceholder) return source.templateKey || source.id || '' @@ -366,12 +387,28 @@ export function useProviderSources(options: UseProviderSourcesOptions) { source.ollama_disable_thinking = false } + const sourceType = source.type + const typeSpecificMetadata = sourceType ? providerTypeMetadata.value?.[sourceType] : null + if (typeSpecificMetadata?.items) { + for (const [key, itemMeta] of Object.entries(typeSpecificMetadata.items)) { + if (source[key] !== undefined) { + continue + } + + if ((itemMeta as Record)?.type === 'object') { + source[key] = {} + } else if ((itemMeta as Record)?.type === 'list' || (itemMeta as Record)?.type === 'template_list') { + source[key] = [] + } + } + } + return source } function extractSourceFieldsFromTemplate(template: Record) { const sourceFields: Record = {} - const excludeKeys = ['id', 'enable', 'model', 'provider_source_id', 'modalities', 'custom_extra_body'] + const excludeKeys = ['id', 'enable', 'model', 'provider_source_id', 'modalities', 'custom_extra_body', '_display_name'] for (const [key, value] of Object.entries(template)) { if (!excludeKeys.includes(key)) { @@ -629,6 +666,11 @@ export function useProviderSources(options: UseProviderSourcesOptions) { const response = await axios.get('/api/config/provider/template') if (response.data.status === 'ok') { configSchema.value = response.data.data.config_schema || {} + providerTypeMetadata.value = response.data.data.provider_type_metadata || {} + const providerI18n = response.data.data.provider_i18n_translations + if (providerI18n && typeof providerI18n === 'object') { + mergeDynamicTranslations('features.config-metadata', providerI18n) + } if (configSchema.value.provider?.config_template) { providerTemplates.value = configSchema.value.provider.config_template } @@ -646,6 +688,11 @@ export function useProviderSources(options: UseProviderSourcesOptions) { onMounted(async () => { await loadProviderTemplate() + window.addEventListener('astrbot-locale-changed', loadProviderTemplate) + }) + + onBeforeUnmount(() => { + window.removeEventListener('astrbot-locale-changed', loadProviderTemplate) }) return { @@ -666,6 +713,7 @@ export function useProviderSources(options: UseProviderSourcesOptions) { isSourceModified, configSchema, providerTemplates, + providerTypeMetadata, manualModelId, modelSearch,