From 6627fd53c21b3ded7d8003722ed22eea6f5586e3 Mon Sep 17 00:00:00 2001 From: Weilong Liao <37870767+Soulter@users.noreply.github.com> Date: Mon, 6 Jul 2026 20:38:30 +0800 Subject: [PATCH] feat: show model metadata in selectors Surface cached model metadata in model/provider selectors, show neutral capability/context badges, and add provider test actions. --- astrbot/dashboard/services/config_service.py | 21 +- .../src/components/chat/ProviderModelMenu.vue | 180 +++++++++++---- .../src/components/chat/RegenerateMenu.vue | 112 ++++++--- .../provider/ProviderModelsPanel.vue | 218 ++++++++++++++---- .../components/shared/ProviderSelector.vue | 142 +++++++++++- .../useProviderModelConfigDialog.ts | 5 +- .../src/composables/useProviderSources.ts | 17 +- .../i18n/locales/en-US/features/provider.json | 11 + .../i18n/locales/ru-RU/features/provider.json | 12 + .../i18n/locales/zh-CN/features/provider.json | 11 + dashboard/src/utils/providerMetadata.ts | 79 +++++++ 11 files changed, 665 insertions(+), 143 deletions(-) create mode 100644 dashboard/src/utils/providerMetadata.ts diff --git a/astrbot/dashboard/services/config_service.py b/astrbot/dashboard/services/config_service.py index f7c06ce25..da6e01ef2 100644 --- a/astrbot/dashboard/services/config_service.py +++ b/astrbot/dashboard/services/config_service.py @@ -24,6 +24,7 @@ from astrbot.core.platform.register import platform_cls_map, platform_registry from astrbot.core.provider.register import provider_registry from astrbot.core.star.star import star_registry from astrbot.core.utils.astrbot_path import get_astrbot_plugin_data_path +from astrbot.core.utils.llm_metadata import LLM_METADATAS from astrbot.core.utils.totp import ( is_totp_enabled, revoke_user_trusted_devices, @@ -1297,9 +1298,14 @@ class ProviderConfigService: for provider in provider_registry: if provider.default_config_tmpl: provider_default_tmpl[provider.type] = provider.default_config_tmpl + providers = copy.deepcopy(self.config.get("provider", [])) + for provider in providers: + model_id = provider.get("model") + if isinstance(model_id, str) and model_id in LLM_METADATAS: + provider["model_metadata"] = LLM_METADATAS[model_id] return { "config_schema": config_schema, - "providers": self.config.get("provider", []), + "providers": providers, "provider_sources": self.config.get("provider_sources", []), } @@ -1562,11 +1568,15 @@ class ProviderConfigService: if provider_type and effective_type != provider_type: continue if provider.get("provider_source_id"): - providers.append( - self.provider_manager.get_merged_provider_config(provider) + provider_response = self.provider_manager.get_merged_provider_config( + provider ) else: - providers.append(copy.deepcopy(provider)) + provider_response = copy.deepcopy(provider) + model_id = provider_response.get("model") + if isinstance(model_id, str) and model_id in LLM_METADATAS: + provider_response["model_metadata"] = LLM_METADATAS[model_id] + providers.append(provider_response) return {"providers": providers} def list_providers_for_dashboard_types( @@ -1597,6 +1607,9 @@ class ProviderConfigService: ) if provider is None: raise ValueError(f"Provider {provider_id} not found") + model_id = provider.get("model") + if isinstance(model_id, str) and model_id in LLM_METADATAS: + provider["model_metadata"] = LLM_METADATAS[model_id] return {"provider": provider} async def create_provider(self, config: dict, source_id: str | None = None) -> None: diff --git a/dashboard/src/components/chat/ProviderModelMenu.vue b/dashboard/src/components/chat/ProviderModelMenu.vue index 956155aea..0e29bed5d 100644 --- a/dashboard/src/components/chat/ProviderModelMenu.vue +++ b/dashboard/src/components/chat/ProviderModelMenu.vue @@ -54,28 +54,70 @@ {{ provider.model }} - - mdi-eye-outline - - - mdi-music-note-outline - - - mdi-wrench - - - mdi-brain - + + + {{ item.tooltip }} + + + + {{ + tm("models.metadata.context", { + tokens: formatContextLimit(provider), + }) + }} + @@ -94,18 +136,18 @@ @@ -226,6 +241,29 @@ function supportsReasoning(provider: ProviderConfig): boolean { color: var(--chat-muted, rgba(var(--v-theme-on-surface), 0.62)); } +.regenerate-model-icon-badge { + display: inline-flex; + align-items: center; + color: rgba(var(--v-theme-on-surface), 0.72); +} + +.regenerate-model-icon-badge--disabled { + color: rgba(var(--v-theme-on-surface), 0.34); +} + +.regenerate-model-context-badge { + display: inline-flex; + align-items: center; + height: 16px; + padding: 0 5px; + border-radius: 999px; + background: rgba(var(--v-theme-on-surface), 0.06); + color: rgba(var(--v-theme-on-surface), 0.72); + font-size: 10px; + font-weight: 650; + line-height: 16px; +} + .regenerate-empty { padding: 14px 16px; color: var(--chat-muted, rgba(var(--v-theme-on-surface), 0.62)); diff --git a/dashboard/src/components/provider/ProviderModelsPanel.vue b/dashboard/src/components/provider/ProviderModelsPanel.vue index 5816962ef..0420e93bd 100644 --- a/dashboard/src/components/provider/ProviderModelsPanel.vue +++ b/dashboard/src/components/provider/ProviderModelsPanel.vue @@ -66,19 +66,47 @@
{{ entry.provider.id }}
{{ entry.provider.model }}
- - {{ item.icon }} - - + + {{ item.icon }} + + + {{ item.tooltip }} + + - {{ formatContextLimit(entry.metadata) }} - + + {{ + tm('models.metadata.context', { + tokens: formatContextLimit(entry.metadata) + }) + }} +
@@ -94,14 +122,20 @@ @update:modelValue="emit('toggle-provider-enable', entry.provider, $event)" > - + + + {{ tm('models.testButton') }} + -
{{ tm('models.tooltips.providerId') }}: {{ entry.provider.id }}
-
{{ tm('models.tooltips.modelId') }}: {{ entry.provider.model }}
+
+ {{ tm('models.tooltips.providerId') }}: + {{ entry.provider.id }} +
+
+ {{ tm('models.tooltips.modelId') }}: + {{ entry.provider.model }} +
@@ -152,19 +192,47 @@ >
{{ entry.model }}
- - {{ item.icon }} - - + + {{ item.icon }} + + + {{ item.tooltip }} + + - {{ formatContextLimit(entry.metadata) }} - + + {{ + tm('models.metadata.context', { + tokens: formatContextLimit(entry.metadata) + }) + }} +
@@ -179,7 +247,10 @@ -
{{ tm('models.tooltips.modelId') }}: {{ entry.model }}
+
+ {{ tm('models.tooltips.modelId') }}: + {{ entry.model }} +
@@ -275,21 +346,64 @@ const availableEntries = computed(() => (props.entries || []).filter((entry) => entry.type === 'available') ) -const capabilityIcons = (metadata) => { - const icons = [] - if (props.supportsImageInput(metadata)) { - icons.push({ icon: 'mdi-image-outline' }) - } - if (props.supportsAudioInput(metadata)) { - icons.push({ icon: 'mdi-music-note-outline' }) - } - if (props.supportsToolCall(metadata)) { - icons.push({ icon: 'mdi-wrench-outline' }) - } - if (props.supportsReasoning(metadata)) { - icons.push({ icon: 'mdi-brain' }) - } - return icons +const capabilityBadges = (entry) => { + const metadata = entry?.metadata + const provider = entry?.provider + const modalities = Array.isArray(provider?.modalities) ? provider.modalities : [] + const isConfigured = entry?.type === 'configured' + const hasModelMetadata = Boolean(entry?.hasModelMetadata) + const definitions = [ + { + key: 'image', + icon: 'mdi-image-outline', + supported: props.supportsImageInput(metadata), + enabled: !isConfigured || modalities.includes('image'), + label: props.tm('models.metadata.image') + }, + { + key: 'audio', + icon: 'mdi-music-note-outline', + supported: props.supportsAudioInput(metadata), + enabled: !isConfigured || modalities.includes('audio'), + label: props.tm('models.metadata.audio') + }, + { + key: 'tool_use', + icon: 'mdi-wrench-outline', + supported: props.supportsToolCall(metadata), + enabled: !isConfigured || modalities.includes('tool_use'), + label: props.tm('models.metadata.toolUse') + }, + { + key: 'reasoning', + icon: 'mdi-brain', + supported: props.supportsReasoning(metadata), + enabled: !isConfigured || Boolean(provider?.reasoning), + label: props.tm('models.metadata.reasoning') + } + ] + + return definitions + .filter((item) => item.supported || (isConfigured && item.enabled)) + .map((item) => { + const enabled = !isConfigured || !hasModelMetadata || item.enabled + let tooltip = props.tm('models.metadata.available', { + capability: item.label + }) + if (isConfigured) { + tooltip = enabled + ? props.tm('models.metadata.enabled', { capability: item.label }) + : props.tm('models.metadata.supportedDisabled', { + capability: item.label + }) + } + return { + key: item.key, + icon: item.icon, + enabled, + tooltip + } + }) } const isProviderTesting = (providerId) => props.testingProviders.includes(providerId) @@ -445,13 +559,21 @@ const isProviderSaving = (providerId) => props.savingProviders.includes(provider width: 24px; height: 24px; border-radius: 999px; - background: rgba(var(--v-theme-on-surface), 0.04); - color: rgba(var(--v-theme-on-surface), 0.58); display: inline-flex; align-items: center; justify-content: center; } +.provider-model-row__badge--enabled { + background: rgba(var(--v-theme-on-surface), 0.06); + color: rgba(var(--v-theme-on-surface), 0.72); +} + +.provider-model-row__badge--disabled { + background: rgba(var(--v-theme-on-surface), 0.04); + color: rgba(var(--v-theme-on-surface), 0.34); +} + .provider-model-row__badge--text { width: auto; padding: 0 8px; diff --git a/dashboard/src/components/shared/ProviderSelector.vue b/dashboard/src/components/shared/ProviderSelector.vue index 114bfd9e3..50e3040b8 100644 --- a/dashboard/src/components/shared/ProviderSelector.vue +++ b/dashboard/src/components/shared/ProviderSelector.vue @@ -106,7 +106,7 @@ {{ tm('providerSelector.clearSelectionSubtitle') }} @@ -124,10 +124,69 @@ {{ provider.type || provider.provider_type || tm('providerSelector.unknownType') }} - {{ provider.model }} + + + + {{ item.tooltip }} + + + + {{ + providerTm('models.metadata.context', { + tokens: formatContextLimit(provider) + }) + }} + + @@ -181,6 +240,8 @@ import { computed, ref, watch } from 'vue' import { providerApi } from '@/api/v1' import { useModuleI18n } from '@/i18n/composables' +import { useToast } from '@/utils/toast' +import { formatContextLimit, providerCapabilityBadges } from '@/utils/providerMetadata' import ProviderChatCompletionPanel from '@/components/provider/ProviderChatCompletionPanel.vue' import ProviderPage from '@/views/ProviderPage.vue' @@ -209,6 +270,8 @@ const props = defineProps({ const emit = defineEmits(['update:modelValue']) const { tm } = useModuleI18n('core.shared') +const { tm: providerTm } = useModuleI18n('features/provider') +const { success: toastSuccess, error: toastError } = useToast() const dialog = ref(false) const providerList = ref([]) @@ -216,6 +279,7 @@ const loading = ref(false) const selectedProvider = ref('') const selectedProviders = ref([]) const providerDrawer = ref(false) +const testingProviders = ref([]) const hasSelection = computed(() => { if (props.multiple) { @@ -333,6 +397,42 @@ function isProviderSelected(providerId) { return selectedProvider.value === providerId } +function capabilityBadges(provider) { + return providerCapabilityBadges(provider, providerTm) +} + +function isProviderTesting(providerId) { + return testingProviders.value.includes(providerId) +} + +function isProviderTestable(provider) { + return Boolean(provider?.id) && provider.enable !== false && !isProviderTesting(provider.id) +} + +async function testProvider(provider) { + if (!isProviderTestable(provider)) { + return + } + testingProviders.value.push(provider.id) + try { + const startTime = performance.now() + const response = await providerApi.test(String(provider.id)) + if (response.data.status === 'ok' && response.data.data.error === null) { + const latency = Math.max(0, Math.round(performance.now() - startTime)) + toastSuccess(providerTm('models.testSuccessWithLatency', { + id: provider.id, + latency + })) + } else { + throw new Error(response.data.data.error || providerTm('models.testError')) + } + } catch (error) { + toastError(error.response?.data?.message || error.message || providerTm('models.testError')) + } finally { + testingProviders.value = testingProviders.value.filter((id) => id !== provider.id) + } +} + function removeSelected(providerId) { const idx = selectedProviders.value.indexOf(providerId) if (idx >= 0) { @@ -384,6 +484,44 @@ function closeProviderDrawer() { border-radius: 10px; } +.provider-selector-meta { + display: inline-flex; + align-items: center; + gap: 4px; + margin-left: 8px; + vertical-align: middle; +} + +.provider-selector-meta__badge { + display: inline-flex; + align-items: center; + color: rgba(var(--v-theme-on-surface), 0.72); +} + +.provider-selector-meta__badge--disabled { + color: rgba(var(--v-theme-on-surface), 0.34); +} + +.provider-selector-meta__context { + display: inline-flex; + align-items: center; + height: 16px; + padding: 0 5px; + border-radius: 999px; + background: rgba(var(--v-theme-on-surface), 0.06); + color: rgba(var(--v-theme-on-surface), 0.72); + font-size: 10px; + font-weight: 650; + line-height: 16px; +} + +.provider-selector-actions { + display: inline-flex; + align-items: center; + gap: 2px; + color: rgba(var(--v-theme-on-surface), 0.72); +} + .v-list-item { transition: all 0.2s ease; } diff --git a/dashboard/src/composables/useProviderModelConfigDialog.ts b/dashboard/src/composables/useProviderModelConfigDialog.ts index ef07917d1..f1ad3caec 100644 --- a/dashboard/src/composables/useProviderModelConfigDialog.ts +++ b/dashboard/src/composables/useProviderModelConfigDialog.ts @@ -58,7 +58,9 @@ export function useProviderModelConfigDialog(options: UseProviderModelConfigDial }) function openProviderEdit(provider: any) { - providerEditData.value = JSON.parse(JSON.stringify(provider)) + const editableProvider = JSON.parse(JSON.stringify(provider)) + delete editableProvider.model_metadata + providerEditData.value = editableProvider providerEditOriginalId.value = provider.id providerEditMode.value = 'edit' showProviderEditDialog.value = true @@ -89,6 +91,7 @@ export function useProviderModelConfigDialog(options: UseProviderModelConfigDial savingProviders.value.push(providerEditData.value.id) try { + delete providerEditData.value.model_metadata const isAdding = providerEditMode.value === 'add' const sourceId = providerEditData.value.provider_source_id || selectedProviderSource.value?.id if (isAdding && !sourceId) { diff --git a/dashboard/src/composables/useProviderSources.ts b/dashboard/src/composables/useProviderSources.ts index b97c167e9..f24da5e2c 100644 --- a/dashboard/src/composables/useProviderSources.ts +++ b/dashboard/src/composables/useProviderSources.ts @@ -150,11 +150,15 @@ export function useProviderSources(options: UseProviderSourcesOptions) { } const mergedModelEntries = computed(() => { - const configuredEntries = (sourceProviders.value || []).map((provider: any) => ({ - type: 'configured', - provider, - metadata: getModelMetadata(provider.model) || buildMetadataFromProvider(provider) - })) + const configuredEntries = (sourceProviders.value || []).map((provider: any) => { + const metadata = getModelMetadata(provider.model) || provider.model_metadata || null + return { + type: 'configured', + provider, + metadata: metadata || buildMetadataFromProvider(provider), + hasModelMetadata: Boolean(metadata) + } + }) const availableEntries = (sortedAvailableModels.value || []) .filter((item: any) => { @@ -166,7 +170,8 @@ export function useProviderSources(options: UseProviderSourcesOptions) { return { type: 'available', model: name, - metadata: typeof item === 'object' ? item?.metadata : getModelMetadata(name) + metadata: typeof item === 'object' ? item?.metadata : getModelMetadata(name), + hasModelMetadata: Boolean(typeof item === 'object' ? item?.metadata : getModelMetadata(name)) } }) diff --git a/dashboard/src/i18n/locales/en-US/features/provider.json b/dashboard/src/i18n/locales/en-US/features/provider.json index 11832bfd8..4dd1a1fe6 100644 --- a/dashboard/src/i18n/locales/en-US/features/provider.json +++ b/dashboard/src/i18n/locales/en-US/features/provider.json @@ -146,6 +146,17 @@ "manualModelRequired": "Please enter a model ID", "manualModelExists": "Model already exists", "configure": "Configure", + "testButton": "Test model", + "metadata": { + "image": "Image input", + "audio": "Audio input", + "toolUse": "Tool use", + "reasoning": "Reasoning", + "context": "{tokens} context window", + "available": "Model metadata supports {capability}", + "enabled": "{capability} enabled", + "supportedDisabled": "Model metadata supports {capability}, but it is not enabled in this provider's modalities." + }, "tooltips": { "providerId": "Provider ID", "modelId": "Model ID" diff --git a/dashboard/src/i18n/locales/ru-RU/features/provider.json b/dashboard/src/i18n/locales/ru-RU/features/provider.json index 0ce704aea..7c597df4f 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/provider.json +++ b/dashboard/src/i18n/locales/ru-RU/features/provider.json @@ -136,6 +136,7 @@ "deleteSuccess": "Модель удалена", "deleteError": "Ошибка удаления модели", "testSuccess": "Тест модели «{id}» пройден успешно", + "testSuccessWithLatency": "Тест модели «{id}» пройден успешно, задержка {latency} мс", "testError": "Тест модели провален", "searchPlaceholder": "Поиск по имени или ID", "manualAddButton": "Добавить вручную", @@ -146,6 +147,17 @@ "manualModelRequired": "Укажите ID модели", "manualModelExists": "Эта модель уже добавлена", "configure": "Настроить", + "testButton": "Тестировать модель", + "metadata": { + "image": "Ввод изображений", + "audio": "Ввод аудио", + "toolUse": "Использование инструментов", + "reasoning": "Рассуждение", + "context": "Контекстное окно {tokens}", + "available": "Metadata модели поддерживает {capability}", + "enabled": "{capability} включено", + "supportedDisabled": "Metadata модели поддерживает {capability}, но эта возможность не включена в modalities текущего провайдера." + }, "tooltips": { "providerId": "ID провайдера", "modelId": "ID модели" diff --git a/dashboard/src/i18n/locales/zh-CN/features/provider.json b/dashboard/src/i18n/locales/zh-CN/features/provider.json index d7bd1c631..63809fbc4 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/provider.json +++ b/dashboard/src/i18n/locales/zh-CN/features/provider.json @@ -147,6 +147,17 @@ "manualModelRequired": "请输入模型 ID", "manualModelExists": "该模型已存在", "configure": "配置", + "testButton": "测试模型", + "metadata": { + "image": "图像输入", + "audio": "音频输入", + "toolUse": "工具使用", + "reasoning": "推理", + "context": "{tokens} 上下文窗口", + "available": "模型 metadata 支持{capability}", + "enabled": "已启用{capability}", + "supportedDisabled": "模型 metadata 支持{capability},但当前提供商的模型能力未启用。" + }, "tooltips": { "providerId": "提供商 ID", "modelId": "模型 ID" diff --git a/dashboard/src/utils/providerMetadata.ts b/dashboard/src/utils/providerMetadata.ts new file mode 100644 index 000000000..988c6de47 --- /dev/null +++ b/dashboard/src/utils/providerMetadata.ts @@ -0,0 +1,79 @@ +export interface ProviderModelMetadata { + modalities?: { input?: string[] } + tool_call?: boolean + reasoning?: boolean + limit?: { context?: number } +} + +export interface ProviderMetadataSource { + modalities?: string[] + model_metadata?: ProviderModelMetadata | null + max_context_tokens?: number + reasoning?: boolean +} + +export interface ProviderCapabilityBadge { + key: string + icon: string + enabled: boolean + tooltip: string +} + +export function formatContextLimit(provider: ProviderMetadataSource | null | undefined): string { + const context = provider?.model_metadata?.limit?.context || provider?.max_context_tokens + if (!context || typeof context !== 'number') return '' + if (context >= 1_000_000) return `${Math.round(context / 1_000_000)}M` + if (context >= 1_000) return `${Math.round(context / 1_000)}K` + return `${context}` +} + +export function providerCapabilityBadges( + provider: ProviderMetadataSource | null | undefined, + tm: (key: string, params?: Record) => string +): ProviderCapabilityBadge[] { + const inputs = provider?.model_metadata?.modalities?.input || [] + const providerModalities = provider?.modalities + const modalities = Array.isArray(providerModalities) ? providerModalities : [] + const definitions = [ + { + key: 'image', + icon: 'mdi-image-outline', + supported: inputs.includes('image'), + enabled: modalities.includes('image'), + label: tm('models.metadata.image') + }, + { + key: 'audio', + icon: 'mdi-music-note-outline', + supported: inputs.includes('audio'), + enabled: modalities.includes('audio'), + label: tm('models.metadata.audio') + }, + { + key: 'tool_use', + icon: 'mdi-wrench-outline', + supported: Boolean(provider?.model_metadata?.tool_call), + enabled: modalities.includes('tool_use'), + label: tm('models.metadata.toolUse') + }, + { + key: 'reasoning', + icon: 'mdi-brain', + supported: Boolean(provider?.model_metadata?.reasoning), + enabled: Boolean(provider?.reasoning), + label: tm('models.metadata.reasoning') + } + ] + + return definitions + .filter((item) => item.supported || item.enabled) + .map((item) => ({ + key: item.key, + icon: item.icon, + enabled: !provider?.model_metadata || item.enabled, + tooltip: + provider?.model_metadata && !item.enabled + ? tm('models.metadata.supportedDisabled', { capability: item.label }) + : tm('models.metadata.enabled', { capability: item.label }) + })) +}