diff --git a/mateclaw-server/src/main/java/vip/mate/llm/model/ModelConfigEntity.java b/mateclaw-server/src/main/java/vip/mate/llm/model/ModelConfigEntity.java index 09bbe302..746ee6a5 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/model/ModelConfigEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/model/ModelConfigEntity.java @@ -77,6 +77,16 @@ public class ModelConfigEntity { */ private String modalities; + /** + * Transient, request-scoped flag set by {@link vip.mate.llm.service.ModelConfigService#listByType} + * when a modality filter is supplied: {@code true} when this row's declared or + * heuristically-resolved capabilities cover the requested modality. Lets the + * sidecar selector list every enabled chat model while still highlighting the + * ones already known to support the modality. Never persisted. + */ + @TableField(exist = false) + private Boolean modalityCapable; + @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; diff --git a/mateclaw-server/src/main/java/vip/mate/llm/routing/MultimodalRouter.java b/mateclaw-server/src/main/java/vip/mate/llm/routing/MultimodalRouter.java index 9a51d46c..b4ca2273 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/routing/MultimodalRouter.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/routing/MultimodalRouter.java @@ -125,11 +125,19 @@ public class MultimodalRouter { } /** - * Resolve the configured sidecar model for a modality. Returns null when: + * Resolve the configured sidecar model for a modality. Returns null only when: * - the setting is empty / blank; - * - the referenced row no longer exists or has been disabled; - * - the row's resolved capability set does not actually contain the modality. + * - the referenced row no longer exists or has been disabled. * The caller treats null as "ask the user to configure one." + *

+ * An explicit sidecar selection is treated as the user's own capability + * declaration: a provider-compatible model can be vision-capable in practice + * even when the built-in heuristics don't recognize its name and it carries no + * declared {@code modalities}. Rejecting such a model here made it impossible to + * use a perfectly good compatible-mode vision model as the sidecar. We therefore + * honour the explicit choice and only emit a diagnostic when the heuristics + * can't confirm it — a wrong pick degrades gracefully (the caption call fails and + * the attachment is reported as un-processed) rather than being silently ignored. */ private ModelConfigEntity resolveSidecar(Modality modality) { SystemSettingsDTO settings = systemSettingService.getSettings(); @@ -148,9 +156,9 @@ public class MultimodalRouter { } if (model == null || !Boolean.TRUE.equals(model.getEnabled())) return null; if (!capabilityService.supports(model.getModelName(), model.getModalities(), modality)) { - log.warn("Configured sidecar model {}/{} does not actually support {} — ignoring", - model.getProvider(), model.getModelName(), modality); - return null; + log.info("Configured sidecar model {}/{} is not recognized as {}-capable by the " + + "built-in heuristics; honouring the explicit selection anyway", + model.getProvider(), model.getModelName(), modality.name().toLowerCase()); } return model; } diff --git a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelConfigService.java b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelConfigService.java index 983cd49e..24be318b 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelConfigService.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelConfigService.java @@ -11,6 +11,7 @@ import vip.mate.llm.event.ModelConfigChangedEvent; import vip.mate.llm.model.ModelConfigEntity; import vip.mate.llm.repository.ModelConfigMapper; +import java.util.Comparator; import java.util.List; import org.springframework.context.ApplicationEventPublisher; @@ -73,9 +74,18 @@ public class ModelConfigService { /** * Optional modality filter (case-insensitive: {@code "vision" / "video" / "audio"}). - * When non-null, only enabled rows whose resolved capability set contains the - * requested modality survive — used by the multimodal sidecar settings UI to - * populate "default vision model" / "default video model" dropdowns. + * Used by the multimodal sidecar settings UI to populate "default vision model" / + * "default video model" dropdowns. + *

+ * The filter does not hide models the built-in heuristics fail to recognize: + * a provider-compatible model (e.g. a DashScope OpenAI-compatible vision model with + * a custom name) is vision-capable in practice even though its name matches no + * built-in prefix and it carries no declared {@code modalities}. Hard-filtering + * those out left them un-selectable as a sidecar. Instead every enabled + * chat model is returned; each row's transient {@link ModelConfigEntity#getModalityCapable()} + * flag records whether its declared / heuristic capabilities already cover the + * requested modality, and known-capable rows are sorted to the top so the UI can + * highlight them while still letting the user pick any model. */ public List listByType(String modelType, String modality) { List rows; @@ -100,7 +110,12 @@ public class ModelConfigService { } return rows.stream() .filter(m -> Boolean.TRUE.equals(m.getEnabled())) - .filter(m -> modelCapabilityService.supports(m.getModelName(), m.getModalities(), required)) + .peek(m -> m.setModalityCapable( + modelCapabilityService.supports(m.getModelName(), m.getModalities(), required))) + // Known-capable models first; preserve the existing default-then-name + // order within each group. + .sorted(Comparator.comparing( + (ModelConfigEntity m) -> Boolean.TRUE.equals(m.getModalityCapable())).reversed()) .toList(); } diff --git a/mateclaw-ui/src/components/common/ModelPicker.vue b/mateclaw-ui/src/components/common/ModelPicker.vue index 3c97abac..b7d5555d 100644 --- a/mateclaw-ui/src/components/common/ModelPicker.vue +++ b/mateclaw-ui/src/components/common/ModelPicker.vue @@ -68,6 +68,7 @@ > {{ m.modelName }} {{ m.name }} + {{ badgeText }} (), { searchable: true, clearable: true, @@ -387,6 +392,17 @@ onBeforeUnmount(() => { color: var(--mc-text-tertiary); font-family: inherit; } +.model-picker__item-badge { + flex-shrink: 0; + font-size: 10px; + font-weight: 600; + padding: 1px 6px; + border-radius: 999px; + font-family: inherit; + letter-spacing: 0.02em; + background: color-mix(in srgb, var(--mc-primary) 12%, transparent); + color: var(--mc-primary); +} .model-picker__check { flex-shrink: 0; color: var(--mc-primary); diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index d7c4b8df..78bbea79 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -622,15 +622,16 @@ export default { notConfigured: 'Not configured (skip attachment)', idle: 'Off', reserved: 'Reserved', + capable: 'Vision', vision: { label: 'Vision sidecar model', - desc: 'Invoked once per uploaded image to produce a structured description. The primary chat model is unchanged.', - empty: 'No vision-capable model is enabled yet. Add one under "Cloud Models" above.', + desc: 'Invoked once per uploaded image to produce a structured description. The primary chat model is unchanged. Any enabled model can be selected; the "Vision" tag marks models already recognized as supporting image input.', + empty: 'No chat model is enabled yet. Add and enable one under "Cloud Models" above.', }, video: { label: 'Video sidecar model (reserved)', desc: 'Reserved for a future iteration: when the user uploads a video, this model will sample frames and describe them. Not yet wired in v1.', - empty: 'No video-capable model is enabled yet.', + empty: 'No chat model is enabled yet.', }, }, }, diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index 0169a087..2047accd 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -502,15 +502,16 @@ export default { notConfigured: '未配置(不路由附件)', idle: '未启用', reserved: '预留', + capable: '视觉', vision: { label: '视觉旁路模型', - desc: '用户上传图片时调用一次,把图片转成结构化描述。主对话模型不变。', - empty: '尚未发现支持图片输入的模型。请到上方"云端模型"中先添加一个视觉模型。', + desc: '用户上传图片时调用一次,把图片转成结构化描述。主对话模型不变。可选择任意已启用模型;带"视觉"标记的为已识别支持图片输入的模型。', + empty: '尚未发现可用的对话模型。请到上方"云端模型"中先添加并启用一个模型。', }, video: { label: '视频旁路模型(预留)', desc: '用于未来版本:当用户上传视频时,由该模型负责拆帧和描述。当前版本暂不接入路由。', - empty: '尚未发现支持视频输入的模型。', + empty: '尚未发现可用的对话模型。', }, }, }, diff --git a/mateclaw-ui/src/views/Settings/Models/MultimodalSidecarSection.vue b/mateclaw-ui/src/views/Settings/Models/MultimodalSidecarSection.vue index 7a6ec1b3..3215c99f 100644 --- a/mateclaw-ui/src/views/Settings/Models/MultimodalSidecarSection.vue +++ b/mateclaw-ui/src/views/Settings/Models/MultimodalSidecarSection.vue @@ -39,6 +39,7 @@ :models="visionModels" :placeholder="t('settings.models.sidecar.notConfigured')" :empty-text="t('settings.models.sidecar.vision.empty')" + :badge-text="t('settings.models.sidecar.capable')" :disabled="visionModels.length === 0" /> @@ -75,6 +76,7 @@ :models="videoModels" :placeholder="t('settings.models.sidecar.notConfigured')" :empty-text="t('settings.models.sidecar.video.empty')" + :badge-text="t('settings.models.sidecar.capable')" :disabled="videoModels.length === 0" /> @@ -102,6 +104,8 @@ interface ModelOption { name: string provider: string modelName: string + /** Backend flag: declared/heuristic capabilities already cover this modality. */ + modalityCapable?: boolean } const { t } = useI18n()