diff --git a/mateclaw-server/src/main/java/vip/mate/system/controller/SystemSettingController.java b/mateclaw-server/src/main/java/vip/mate/system/controller/SystemSettingController.java index 9f055aea..741f5b8d 100644 --- a/mateclaw-server/src/main/java/vip/mate/system/controller/SystemSettingController.java +++ b/mateclaw-server/src/main/java/vip/mate/system/controller/SystemSettingController.java @@ -41,8 +41,39 @@ public class SystemSettingController { return R.ok(systemSettingService.saveLanguage(request.getLanguage())); } + /** + * Dedicated endpoint for the multimodal sidecar configuration. + *

+ * Separated from the bulk {@code PUT /settings} because the bulk endpoint + * now guards sidecar keys with null checks (so unrelated settings pages + * can't clobber them via partial payloads). This endpoint always writes + * both fields, so passing {@code null} for either explicitly clears that + * sidecar — preserving the "clear via UI" UX without leaking the + * write-on-null semantics into every other settings save. + */ + @Operation(summary = "更新多模态 sidecar 配置") + @PutMapping("/sidecar") + public R saveSidecar(@RequestBody SidecarRequest request) { + return R.ok(systemSettingService.updateSidecarSettings( + request.getDefaultVisionModelId(), + request.getDefaultVideoModelId())); + } + @Data public static class LanguageRequest { private String language; } + + /** + * Body for {@code PUT /settings/sidecar}. Both fields are nullable; + * {@code null} means "explicit clear". Field absence in the JSON + * payload also deserializes to null, which is the same outcome — the + * sidecar UI is the only caller of this endpoint and always sends both + * fields, so the absent-vs-null distinction doesn't matter here. + */ + @Data + public static class SidecarRequest { + private Long defaultVisionModelId; + private Long defaultVideoModelId; + } } diff --git a/mateclaw-server/src/main/java/vip/mate/system/service/SystemSettingService.java b/mateclaw-server/src/main/java/vip/mate/system/service/SystemSettingService.java index fa1232d5..1a22ca6a 100644 --- a/mateclaw-server/src/main/java/vip/mate/system/service/SystemSettingService.java +++ b/mateclaw-server/src/main/java/vip/mate/system/service/SystemSettingService.java @@ -351,13 +351,47 @@ public class SystemSettingService { saveValue(MODEL3D_FALLBACK_ENABLED_KEY, String.valueOf(dto.getModel3dFallbackEnabled()), "3D Provider 级 Fallback"); } - // Multimodal sidecar routing — write empty string to clear (parse-back returns null) - // Always written so users can revert to "not configured" via the UI. + // Multimodal sidecar routing — guarded with null check, matching the + // pattern used for music / 3D / image / video / tts / stt blocks + // above. The bulk PUT /settings is used by every settings page (System, + // Music, Video, Image, Stt, Tts, Model3D), each sending a partial + // payload that omits sidecar fields. Without this guard, saving any + // unrelated setting would silently write "" into the sidecar keys + // (Long? defaultVisionModelId deserializes to null when absent), which + // wiped users' configured vision/video models the moment they touched + // an unrelated settings page. Explicit clearing via the sidecar UI now + // routes through {@link #updateSidecarSettings} instead. + if (dto.getDefaultVisionModelId() != null) { + saveValue(DEFAULT_VISION_MODEL_KEY, + String.valueOf(dto.getDefaultVisionModelId()), + "Default vision-capable model id (mate_model_config.id) for sidecar routing"); + } + if (dto.getDefaultVideoModelId() != null) { + saveValue(DEFAULT_VIDEO_MODEL_KEY, + String.valueOf(dto.getDefaultVideoModelId()), + "Default video-capable model id (mate_model_config.id) for sidecar routing"); + } + return getSettings(); + } + + /** + * Dedicated update path for the multimodal sidecar configuration. + *

+ * This endpoint is the ONLY place vision/video model ids can be written + * unconditionally — null is treated as an explicit "clear" and writes + * an empty string (parse-back returns null). The bulk + * {@link #saveSettings} now guards both keys with non-null checks so + * unrelated settings pages can't accidentally clobber sidecar config. + *

+ * Both fields are always written so a single API call can independently + * assign / clear either modality. + */ + public SystemSettingsDTO updateSidecarSettings(Long visionModelId, Long videoModelId) { saveValue(DEFAULT_VISION_MODEL_KEY, - dto.getDefaultVisionModelId() == null ? "" : String.valueOf(dto.getDefaultVisionModelId()), + visionModelId == null ? "" : String.valueOf(visionModelId), "Default vision-capable model id (mate_model_config.id) for sidecar routing"); saveValue(DEFAULT_VIDEO_MODEL_KEY, - dto.getDefaultVideoModelId() == null ? "" : String.valueOf(dto.getDefaultVideoModelId()), + videoModelId == null ? "" : String.valueOf(videoModelId), "Default video-capable model id (mate_model_config.id) for sidecar routing"); return getSettings(); } diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index 6a33abab..6ff54a20 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -518,6 +518,13 @@ export const settingsApi = { update: (data: any) => http.put('/settings', data), getLanguage: () => http.get('/settings/language'), updateLanguage: (language: string) => http.put('/settings/language', { language }), + // Dedicated endpoint for the multimodal sidecar configuration. The bulk + // /settings PUT now guards vision/video model ids with non-null checks so + // unrelated settings pages can't clobber them via partial payloads. This + // endpoint is the only path that writes those fields unconditionally — + // pass {defaultVisionModelId: null} here to explicitly clear a sidecar. + updateSidecar: (data: { defaultVisionModelId: number | null; defaultVideoModelId: number | null }) => + http.put('/settings/sidecar', data), } // ==================== Workspace ==================== diff --git a/mateclaw-ui/src/views/Settings/Models/MultimodalSidecarSection.vue b/mateclaw-ui/src/views/Settings/Models/MultimodalSidecarSection.vue index 7f9c7ed9..842afc45 100644 --- a/mateclaw-ui/src/views/Settings/Models/MultimodalSidecarSection.vue +++ b/mateclaw-ui/src/views/Settings/Models/MultimodalSidecarSection.vue @@ -144,7 +144,12 @@ async function loadAll() { } async function persistSettings(payload: { defaultVisionModelId: number | null; defaultVideoModelId: number | null }) { - await settingsApi.update(payload) + // Use the dedicated sidecar endpoint so the bulk /settings PUT can keep + // guarding vision/video keys with non-null checks (preventing unrelated + // settings pages from clobbering this configuration via partial payloads). + // This endpoint always writes both keys, so passing null here means + // "explicit clear" which is the original UX of this card. + await settingsApi.updateSidecar(payload) } async function onSaveVision() {