From 6a3df2a6e0f5aadd62b11efb65c719f2fb1f78a0 Mon Sep 17 00:00:00 2001 From: matevip Date: Tue, 28 Apr 2026 15:03:40 +0800 Subject: [PATCH] feat(llm): enabled column for providers + Add Provider drawer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds explicit user-intent gating to the provider catalog. Fresh installs get an empty dropdown by default — built-in cloud + local providers (OpenAI, Anthropic, Ollama, LM Studio, MLX, llama.cpp, etc.) live in a new 'Add Provider' drawer until the user opts them in. Existing installs upgrade conservatively: V55 promotes any provider with evidence of use (real api_key, OAuth token, recent chat usage, or current default model). Backend - V55 migration (H2 + MySQL): adds enabled BOOLEAN DEFAULT FALSE on mate_model_provider, plus 4 promote-to-true UPDATE rules. Also CREATE INDEX idx_message_runtime_provider_time so the 30-day usage lookup doesn't full-scan mate_message on heavy users. - ModelProviderEntity, ProviderInfoDTO: enabled field. - ModelProviderService: * listProviders() now filters WHERE enabled = TRUE — chat path, ModelSelector, Settings/Models main grid see only opted-in rows. * listCatalog() new — full catalog (enabled + disabled) for the drawer. * setEnabled(id, enabled) flips the flag, publishes ModelConfigChangedEvent (re-probe via the existing listener), and on disable auto-promotes a replacement default model when the disabled provider owned the current default. Returns EnableResult so the frontend can fire a toast. * createCustomProvider sets enabled=true (user just made the row). - ProviderInitProbe.listConfiguredProviders also filters enabled=true — no point probing rows the user can't see. - ModelConfigController: GET /catalog, POST /{id}/enable, POST /{id}/disable. - Plugin-registered ChatModels are unaffected — they live in pluginChatModels (in-memory map), don't go through DB listProviders, so the enabled filter doesn't strand them. Frontend - New types: ProviderInfo.enabled, EnableResult. - New API: catalog / enableProvider / disableProvider. - New composable useProviderEnablement: catalog ref, drawerOpen, togglingId, loadCatalog, openDrawer / closeDrawer, enableProvider, disableProvider (fires defaultSwitchedToast on auto-switch). - AddProviderDrawer.vue: lazy-loaded, reuses DoctorDrawer's Teleport + overlay + slide-in panel pattern. Two groups (cloud / local), unenabled rows surface to the top of each group, enabled rows show an 'Enabled' badge instead of a button. Mobile: full-screen sheet that slides up from below. - ProviderCard: new 'Disable' button with soft-danger styling on enabled providers — soft-hide that keeps the config; user can re-enable from the drawer. - Settings/Models index.vue: * Two top CTAs: 'Enable Provider' (drawer) and 'Custom' (existing custom-create modal) — distinct workflows, both surfaced. * Empty state with prominent 'Enable Provider' CTA when zero enabled providers — paired with onMounted auto-open of the drawer (sessionStorage guard so closing it doesn't bring it back on the next route visit in the same session). * Deep-link: ?addProvider=1 query forces the drawer open and strips itself after, so a back/forward doesn't re-fire the open. - ModelSelector: when groups.length === 0 and not searching, show 'No providers configured -> Configure' CTA linking to /settings/models?addProvider=1 — the natural flow when a fresh user opens chat before configuring anything. - i18n: 13 new keys per locale (zh-CN + en-US) plus common.close. Migration safety - Conservative default policy: only rows with concrete evidence of use are auto-enabled; everything else stays hidden. Upgrade users may notice unused built-ins disappearing from their dropdown — that's the intended cleanup. - mate_message index added so the 30-day usage rule doesn't full-scan on large installations; FlywayRepairConfig handles redeploy idempotency. Tests - ModelProviderServiceEnableTest covers all 7 enable/disable branches: flag flip + event publish, no-op on already-{enabled,disabled}, default-switch when disabled provider owned current default, no-switch when default belongs elsewhere, no-replacement returns unchanged, getDefaultModel exception path, candidates with no models are skipped. - ProviderInitProbeTest: helper provider() now sets enabled=true so the new probe filter doesn't strand existing fixtures. - vip.mate.llm.** suite: 125 tests green. vue-tsc 0 errors. Browser page renders with both new buttons + drawer. --- .../llm/controller/ModelConfigController.java | 20 +- .../mate/llm/failover/ProviderInitProbe.java | 4 + .../java/vip/mate/llm/model/EnableResult.java | 25 ++ .../mate/llm/model/ModelProviderEntity.java | 10 + .../vip/mate/llm/model/ProviderInfoDTO.java | 2 + .../llm/service/ModelProviderService.java | 90 ++++++- .../db/migration/h2/V55__provider_enabled.sql | 44 ++++ .../migration/mysql/V55__provider_enabled.sql | 51 ++++ mateclaw-ui/src/api/index.ts | 8 + .../src/components/chat/ModelSelector.vue | 27 +- mateclaw-ui/src/i18n/locales/en-US.ts | 18 ++ mateclaw-ui/src/i18n/locales/zh-CN.ts | 18 ++ mateclaw-ui/src/types/index.ts | 12 + .../Settings/Models/AddProviderDrawer.vue | 238 ++++++++++++++++++ .../views/Settings/Models/ProviderCard.vue | 13 + .../composables/useProviderEnablement.ts | 99 ++++++++ .../src/views/Settings/Models/index.vue | 101 +++++++- .../src/views/Settings/Models/useProviders.ts | 17 +- 18 files changed, 783 insertions(+), 14 deletions(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/llm/model/EnableResult.java create mode 100644 mateclaw-server/src/main/resources/db/migration/h2/V55__provider_enabled.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/mysql/V55__provider_enabled.sql create mode 100644 mateclaw-ui/src/views/Settings/Models/AddProviderDrawer.vue create mode 100644 mateclaw-ui/src/views/Settings/Models/composables/useProviderEnablement.ts diff --git a/mateclaw-server/src/main/java/vip/mate/llm/controller/ModelConfigController.java b/mateclaw-server/src/main/java/vip/mate/llm/controller/ModelConfigController.java index 29eab47e..db7ed298 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/controller/ModelConfigController.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/controller/ModelConfigController.java @@ -35,12 +35,30 @@ public class ModelConfigController { private static final String SYSTEM_SETTING_DEFAULT_EMBEDDING_ID = "embedding.default.model.id"; - @Operation(summary = "获取 Provider 列表") + @Operation(summary = "获取 Provider 列表(仅 enabled)") @GetMapping public R> list() { return R.ok(modelProviderService.listProviders()); } + @Operation(summary = "RFC-074: 获取 Provider 全量目录(含未启用),供 Add Provider 抽屉使用") + @GetMapping("/catalog") + public R> catalog() { + return R.ok(modelProviderService.listCatalog()); + } + + @Operation(summary = "RFC-074: 启用 Provider") + @PostMapping("/{providerId}/enable") + public R enableProvider(@PathVariable String providerId) { + return R.ok(modelProviderService.setEnabled(providerId, true)); + } + + @Operation(summary = "RFC-074: 禁用 Provider(如其下模型为当前默认会自动切换)") + @PostMapping("/{providerId}/disable") + public R disableProvider(@PathVariable String providerId) { + return R.ok(modelProviderService.setEnabled(providerId, false)); + } + @Operation(summary = "获取启用模型列表") @GetMapping("/enabled") public R> listEnabled() { diff --git a/mateclaw-server/src/main/java/vip/mate/llm/failover/ProviderInitProbe.java b/mateclaw-server/src/main/java/vip/mate/llm/failover/ProviderInitProbe.java index babe6bf7..845e934c 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/failover/ProviderInitProbe.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/failover/ProviderInitProbe.java @@ -230,7 +230,11 @@ public class ProviderInitProbe { } private List listConfiguredProviders() { + // RFC-074: skip rows the user hasn't opted into — no point spending + // probe budget on disabled built-ins (Ollama / LM Studio / etc.) that + // wouldn't show up in the dropdown anyway. return providerMapper.selectList(null).stream() + .filter(p -> Boolean.TRUE.equals(p.getEnabled())) .filter(p -> providerService.isProviderConfigured(p.getProviderId())) .toList(); } diff --git a/mateclaw-server/src/main/java/vip/mate/llm/model/EnableResult.java b/mateclaw-server/src/main/java/vip/mate/llm/model/EnableResult.java new file mode 100644 index 00000000..a7629a80 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/llm/model/EnableResult.java @@ -0,0 +1,25 @@ +package vip.mate.llm.model; + +/** + * RFC-074: payload for {@code POST /models/{id}/enable} and {@code .../disable}. + * + *

{@code defaultSwitched} is true when the disable action invalidated the + * current default model and the service auto-promoted a replacement. The + * frontend uses this to fire a toast like "Switched default model to X" so + * the user isn't surprised next time they send a message.

+ * + *

{@code newDefaultProviderId} / {@code newDefaultModel} are populated + * only when {@code defaultSwitched} is true; both null otherwise.

+ */ +public record EnableResult(boolean defaultSwitched, + String newDefaultProviderId, + String newDefaultModel) { + + public static EnableResult unchanged() { + return new EnableResult(false, null, null); + } + + public static EnableResult switched(String providerId, String modelName) { + return new EnableResult(true, providerId, modelName); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/llm/model/ModelProviderEntity.java b/mateclaw-server/src/main/java/vip/mate/llm/model/ModelProviderEntity.java index 2ceaf7d8..b10bb1f3 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/model/ModelProviderEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/model/ModelProviderEntity.java @@ -56,6 +56,16 @@ public class ModelProviderEntity { */ private Integer fallbackPriority; + /** + * RFC-074: explicit user-enabled flag. {@code FALSE} means the provider is + * known to the catalog but hidden from the dropdown / chat fallback chain + * — the user must opt in via the "Add Provider" drawer. Default {@code FALSE} + * for fresh installs; V55 migration backfills {@code TRUE} for rows with + * evidence of prior use (real api_key, OAuth token, recent chat usage, + * or current default model). + */ + private Boolean enabled; + @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; diff --git a/mateclaw-server/src/main/java/vip/mate/llm/model/ProviderInfoDTO.java b/mateclaw-server/src/main/java/vip/mate/llm/model/ProviderInfoDTO.java index 3164bd09..c5cfd317 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/model/ProviderInfoDTO.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/model/ProviderInfoDTO.java @@ -39,4 +39,6 @@ public class ProviderInfoDTO { private Long lastProbedAtMs; /** Remaining cooldown window in ms, populated only when liveness == COOLDOWN. */ private Long cooldownRemainingMs; + /** RFC-074: whether the user has explicitly enabled this provider. False = lives in the catalog drawer only. */ + private Boolean enabled; } diff --git a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelProviderService.java b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelProviderService.java index 36d278ed..7244ee5a 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelProviderService.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelProviderService.java @@ -72,11 +72,28 @@ public class ModelProviderService { return pluginChatModels.get(providerId); } + /** RFC-074: visible providers — only the rows the user has explicitly enabled. + * This is what powers the chat dropdown, the failover walker, and the + * Settings/Models main grid. Disabled rows live in {@link #listCatalog()}. */ public List listProviders() { - List providers = modelProviderMapper.selectList(new LambdaQueryWrapper() - .orderByDesc(ModelProviderEntity::getIsLocal) - .orderByAsc(ModelProviderEntity::getIsCustom) - .orderByAsc(ModelProviderEntity::getName)); + return listProvidersInternal(true); + } + + /** RFC-074: full catalog — enabled and disabled rows alike. Drives the + * "Add Provider" drawer where the user opts into a hidden built-in. */ + public List listCatalog() { + return listProvidersInternal(false); + } + + private List listProvidersInternal(boolean enabledOnly) { + LambdaQueryWrapper qw = new LambdaQueryWrapper<>(); + if (enabledOnly) { + qw.eq(ModelProviderEntity::getEnabled, true); + } + qw.orderByDesc(ModelProviderEntity::getIsLocal) + .orderByAsc(ModelProviderEntity::getIsCustom) + .orderByAsc(ModelProviderEntity::getName); + List providers = modelProviderMapper.selectList(qw); Map> modelsByProvider = modelConfigService.listModels().stream() .collect(Collectors.groupingBy(ModelConfigEntity::getProvider)); // RFC-073: batch the runtime snapshots once so each toProviderInfo call is O(1) @@ -124,6 +141,9 @@ public class ModelProviderService { provider.setGenerateKwargs("{}"); provider.setIsCustom(true); provider.setIsLocal(false); + // RFC-074: custom providers are user-created, so opt them in by default + // — the user just made the row, no need to make them flip a second toggle. + provider.setEnabled(true); provider.setSupportModelDiscovery(false); provider.setSupportConnectionCheck(false); provider.setFreezeUrl(false); @@ -213,6 +233,67 @@ public class ModelProviderService { return null; } + /** + * RFC-074: flip the {@code enabled} flag for a provider. Enabling republishes + * {@link ModelConfigChangedEvent} so {@code ProviderInitProbe} re-probes the + * fresh row; disabling that owns the current default model auto-promotes + * a replacement so chat doesn't break on the next request. + * + * @return an {@link EnableResult} describing whether the default model was + * switched and to what — the frontend uses it to fire a toast. + */ + public EnableResult setEnabled(String providerId, boolean enabled) { + ModelProviderEntity provider = getProvider(providerId); + boolean current = Boolean.TRUE.equals(provider.getEnabled()); + if (current == enabled) { + return EnableResult.unchanged(); + } + provider.setEnabled(enabled); + modelProviderMapper.updateById(provider); + EnableResult result = enabled + ? EnableResult.unchanged() + : pickReplacementDefaultIfNeeded(providerId); + eventPublisher.publishEvent(new ModelConfigChangedEvent( + enabled ? "provider-enabled" : "provider-disabled")); + return result; + } + + /** + * If the current default model belongs to {@code disabledProviderId}, find + * the first enabled + configured provider that has at least one model and + * promote its first model to default. Returns {@link EnableResult#unchanged()} + * when no swap was needed (or no replacement exists — in that case the + * default stays broken and the empty-state UI will catch it). + */ + private EnableResult pickReplacementDefaultIfNeeded(String disabledProviderId) { + ModelConfigEntity currentDefault; + try { + currentDefault = modelConfigService.getDefaultModel(); + } catch (MateClawException e) { + // No default at all → nothing to switch. + return EnableResult.unchanged(); + } + if (!disabledProviderId.equals(currentDefault.getProvider())) { + return EnableResult.unchanged(); + } + // Walk enabled providers in DB order, take the first one with a model. + List candidates = modelProviderMapper.selectList( + new LambdaQueryWrapper() + .eq(ModelProviderEntity::getEnabled, true) + .ne(ModelProviderEntity::getProviderId, disabledProviderId) + .orderByDesc(ModelProviderEntity::getIsLocal) + .orderByAsc(ModelProviderEntity::getName)); + for (ModelProviderEntity candidate : candidates) { + if (!isProviderConfigured(candidate)) continue; + List models = modelConfigService.listModelsByProvider(candidate.getProviderId()); + if (models.isEmpty()) continue; + ModelConfigEntity first = models.get(0); + modelConfigService.setDefaultModel(candidate.getProviderId(), first.getModelName()); + return EnableResult.switched(candidate.getProviderId(), first.getModelName()); + } + return EnableResult.unchanged(); + } + private void tryAutoActivateModel(String providerId, ModelProviderEntity provider) { if (!isProviderConfigured(provider)) { return; @@ -273,6 +354,7 @@ public class ModelProviderService { dto.setConfigured(configured); dto.setAvailable(available); dto.setLiveness(providerLiveness); + dto.setEnabled(Boolean.TRUE.equals(provider.getEnabled())); applyLivenessDetails(dto, provider.getProviderId(), providerLiveness, liveness); dto.setApiKey(maskApiKey(provider.getApiKey())); dto.setBaseUrl(provider.getBaseUrl()); diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V55__provider_enabled.sql b/mateclaw-server/src/main/resources/db/migration/h2/V55__provider_enabled.sql new file mode 100644 index 00000000..0edba1af --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V55__provider_enabled.sql @@ -0,0 +1,44 @@ +-- V55 (RFC-074): explicit user-enabled flag on providers. +-- +-- Default FALSE so seeded local providers (Ollama / LM Studio / MLX / llama.cpp) +-- and key-free providers (OpenCode) don't pollute the dropdown until the user +-- turns them on. Existing rows are promoted to enabled=TRUE only when there's +-- evidence the user is using them — the goal is upgrade-time UI cleanup. +-- +-- Note: mate_model_config also has an `enabled` column (model-level visibility). +-- The new column here is provider-level. Same name, different table — no clash. +ALTER TABLE mate_model_provider ADD COLUMN IF NOT EXISTS enabled BOOLEAN DEFAULT FALSE; + +-- Index supporting Rule 3's "30-day local usage" lookup. mate_message can be +-- huge on heavy users; without this the EXISTS subquery scans the entire table. +-- Kept after migration — also useful for any future per-provider usage analytics. +CREATE INDEX IF NOT EXISTS idx_message_runtime_provider_time + ON mate_message(runtime_provider, create_time); + +-- Rule 1: real (non-masked, non-empty) API key → user is using it. +UPDATE mate_model_provider + SET enabled = TRUE + WHERE api_key IS NOT NULL AND api_key <> '' AND POSITION('*' IN api_key) = 0; + +-- Rule 2: OAuth provider with token → user is using it. +UPDATE mate_model_provider + SET enabled = TRUE + WHERE oauth_access_token IS NOT NULL AND oauth_access_token <> ''; + +-- Rule 3: local provider with messages in last 30 days → user is using it. +-- IN-subquery (not EXISTS) reads better in both H2 and MySQL EXPLAIN, and the +-- distinct set is small (≤ N providers, not N messages). +UPDATE mate_model_provider + SET enabled = TRUE + WHERE is_local = TRUE + AND provider_id IN ( + SELECT DISTINCT runtime_provider + FROM mate_message + WHERE runtime_provider IS NOT NULL + AND create_time >= DATEADD('DAY', -30, CURRENT_TIMESTAMP) + ); + +-- Rule 4: provider whose model is the current default → user is using it. +UPDATE mate_model_provider + SET enabled = TRUE + WHERE provider_id IN (SELECT provider FROM mate_model_config WHERE is_default = TRUE); diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V55__provider_enabled.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V55__provider_enabled.sql new file mode 100644 index 00000000..ea6d7c22 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V55__provider_enabled.sql @@ -0,0 +1,51 @@ +-- V55 (RFC-074): explicit user-enabled flag on providers. See H2 sibling for +-- the full rationale; this file only differs in dialect-specific syntax. +-- +-- MySQL lacks `ADD COLUMN IF NOT EXISTS` and `CREATE INDEX IF NOT EXISTS` — +-- guard via INFORMATION_SCHEMA + dynamic SQL so re-runs are no-ops. + +-- ── Add `enabled` column ─────────────────────────────────────────────────── +SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'mate_model_provider' + AND COLUMN_NAME = 'enabled'); +SET @s := IF(@c = 0, + 'ALTER TABLE mate_model_provider ADD COLUMN enabled BOOLEAN DEFAULT FALSE', + 'SELECT 1'); +PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ── Index supporting Rule 3's 30-day usage lookup ────────────────────────── +SET @i := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'mate_message' + AND INDEX_NAME = 'idx_message_runtime_provider_time'); +SET @s := IF(@i = 0, + 'CREATE INDEX idx_message_runtime_provider_time ON mate_message(runtime_provider, create_time)', + 'SELECT 1'); +PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ── Rule 1: real (non-masked, non-empty) API key → user is using it ──────── +UPDATE mate_model_provider + SET enabled = TRUE + WHERE api_key IS NOT NULL AND api_key <> '' AND INSTR(api_key, '*') = 0; + +-- ── Rule 2: OAuth provider with token → user is using it ─────────────────── +UPDATE mate_model_provider + SET enabled = TRUE + WHERE oauth_access_token IS NOT NULL AND oauth_access_token <> ''; + +-- ── Rule 3: local provider with messages in last 30 days → user is using it ─ +UPDATE mate_model_provider + SET enabled = TRUE + WHERE is_local = TRUE + AND provider_id IN ( + SELECT DISTINCT runtime_provider + FROM mate_message + WHERE runtime_provider IS NOT NULL + AND create_time >= DATE_SUB(NOW(), INTERVAL 30 DAY) + ); + +-- ── Rule 4: provider whose model is the current default → user is using it ─ +UPDATE mate_model_provider + SET enabled = TRUE + WHERE provider_id IN (SELECT provider FROM mate_model_config WHERE is_default = TRUE); diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index 2800bce0..872d1be1 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -295,6 +295,14 @@ export const modelApi = { testModel: (providerId: string, modelId: string) => http.post(`/models/${providerId}/models/${encodeURIComponent(modelId)}/test`), + // ==================== RFC-074: enabled / catalog ==================== + /** Full provider catalog including enabled=false rows; powers the Add Provider drawer. */ + catalog: () => http.get('/models/catalog'), + /** Opt a provider into the dropdown; backend triggers re-probe via ModelConfigChangedEvent. */ + enableProvider: (providerId: string) => http.post(`/models/${providerId}/enable`), + /** Hide a provider; if it owned the current default model, backend auto-promotes a replacement. */ + disableProvider: (providerId: string) => http.post(`/models/${providerId}/disable`), + // ==================== Embedding Model (RFC Embedding UI) ==================== listByType: (modelType: 'chat' | 'embedding') => http.get('/models/by-type', { params: { modelType } }), diff --git a/mateclaw-ui/src/components/chat/ModelSelector.vue b/mateclaw-ui/src/components/chat/ModelSelector.vue index 23ce4bba..8bd869a1 100644 --- a/mateclaw-ui/src/components/chat/ModelSelector.vue +++ b/mateclaw-ui/src/components/chat/ModelSelector.vue @@ -61,7 +61,19 @@
- {{ $t('chat.noMatchModel') }} + + +
@@ -73,6 +85,7 @@ + + diff --git a/mateclaw-ui/src/views/Settings/Models/ProviderCard.vue b/mateclaw-ui/src/views/Settings/Models/ProviderCard.vue index 4c59c8b2..abc8c0b4 100644 --- a/mateclaw-ui/src/views/Settings/Models/ProviderCard.vue +++ b/mateclaw-ui/src/views/Settings/Models/ProviderCard.vue @@ -132,6 +132,15 @@ > {{ reprobing ? t('settings.model.poolReprobing') : t('settings.model.poolReprobe') }} + +
@@ -166,6 +175,7 @@ defineEmits<{ 'provider-settings': [provider: ProviderInfo] 'test-connection': [provider: ProviderInfo] 'delete-provider': [provider: ProviderInfo] + 'disable-provider': [provider: ProviderInfo] 'reprobe': [provider: ProviderInfo] }>() @@ -242,6 +252,9 @@ const { t } = useI18n() .card-btn { border: none; border-radius: 10px; padding: 9px 14px; font-size: 14px; cursor: pointer; transition: all 0.15s; background: var(--mc-primary-bg); color: var(--mc-primary); } .card-btn:hover { background: rgba(217, 119, 87, 0.18); } .card-btn.danger { background: var(--mc-danger-bg); color: var(--mc-danger); } +/* RFC-074 PR-2: soft-danger (disable) — softer than delete to signal reversibility. */ +.card-btn.danger-soft { background: var(--mc-bg-sunken); color: var(--mc-text-secondary); } +.card-btn.danger-soft:hover { background: var(--mc-danger-bg); color: var(--mc-danger); } .card-btn.testing { opacity: 0.6; cursor: wait; } .connection-result { margin-top: 10px; padding: 8px 12px; border-radius: 8px; font-size: 12px; } .connection-result.success { background: var(--mc-primary-bg); color: var(--mc-primary); } diff --git a/mateclaw-ui/src/views/Settings/Models/composables/useProviderEnablement.ts b/mateclaw-ui/src/views/Settings/Models/composables/useProviderEnablement.ts new file mode 100644 index 00000000..27482ec6 --- /dev/null +++ b/mateclaw-ui/src/views/Settings/Models/composables/useProviderEnablement.ts @@ -0,0 +1,99 @@ +import { ref, type Ref } from 'vue' +import { useI18n } from 'vue-i18n' +import { ElMessage } from 'element-plus' +import { modelApi } from '@/api' +import type { EnableResult, ProviderInfo } from '@/types' + +interface ListDeps { + /** Re-fetch the visible (enabled) providers list after a toggle. */ + loadProviders: () => Promise + /** Re-fetch active model so the dropdown reflects auto-promoted defaults. */ + loadActiveModel: () => Promise +} + +/** + * RFC-074 PR-2: catalog browse + enable / disable lifecycle for the + * "Add Provider" drawer. + * + * State is intentionally split from useProviderList: the catalog endpoint + * returns ALL providers (enabled + disabled) and is only fetched on demand + * when the drawer opens, while loadProviders() returns the user-facing + * enabled subset. + */ +export function useProviderEnablement(deps: ListDeps) { + const { t } = useI18n() + + /** Full catalog (enabled + disabled). Loaded lazily when drawer opens. */ + const catalog = ref([]) + /** Drawer visibility — both index.vue and useProviderEnablement read/write. */ + const drawerOpen = ref(false) + /** Provider id currently being toggled (for spinner state on the row). */ + const togglingId = ref(null) + + async function loadCatalog() { + const res: any = await modelApi.catalog() + catalog.value = res.data || [] + } + + function openDrawer() { + drawerOpen.value = true + // fire-and-forget refresh so revisits get fresh data without blocking the open animation + loadCatalog().catch(err => { + console.warn('[ProviderEnablement] catalog load failed', err) + }) + } + + function closeDrawer() { + drawerOpen.value = false + } + + async function enableProvider(providerId: string): Promise { + togglingId.value = providerId + try { + const res: any = await modelApi.enableProvider(providerId) + // Refresh both catalog (now this row shows enabled) and main list (now this row appears). + await Promise.all([loadCatalog(), deps.loadProviders()]) + return res.data as EnableResult + } catch (err) { + ElMessage.error(err instanceof Error ? err.message : String(err)) + return null + } finally { + togglingId.value = null + } + } + + async function disableProvider(providerId: string): Promise { + togglingId.value = providerId + try { + const res: any = await modelApi.disableProvider(providerId) + const result: EnableResult = res.data + // Re-fetch list (row disappears) + active model (default may have switched). + await Promise.all([loadCatalog(), deps.loadProviders(), deps.loadActiveModel()]) + // RFC-074 §2: silent switch + toast. Show the toast here so every caller + // (drawer disable, card more-menu, etc.) gets consistent UX. + if (result?.defaultSwitched) { + ElMessage.success(t('settings.model.defaultSwitchedToast', { + provider: result.newDefaultProviderId, + model: result.newDefaultModel, + })) + } + return result + } catch (err) { + ElMessage.error(err instanceof Error ? err.message : String(err)) + return null + } finally { + togglingId.value = null + } + } + + return { + catalog, + drawerOpen, + togglingId, + loadCatalog, + openDrawer, + closeDrawer, + enableProvider, + disableProvider, + } +} diff --git a/mateclaw-ui/src/views/Settings/Models/index.vue b/mateclaw-ui/src/views/Settings/Models/index.vue index db645cb2..dc0bf478 100644 --- a/mateclaw-ui/src/views/Settings/Models/index.vue +++ b/mateclaw-ui/src/views/Settings/Models/index.vue @@ -5,9 +5,16 @@

{{ t('settings.model.title') }}

{{ t('settings.model.desc') }}

- +
+ + + + +
+
+

{{ t('settings.model.emptyTitle') }}

+

{{ t('settings.model.emptyDesc') }}

+ +
+

@@ -39,6 +60,7 @@ @provider-settings="openProviderConfigModal" @test-connection="handleTestConnection" @delete-provider="onDeleteProvider" + @disable-provider="onDisableProvider" @reprobe="reprobeProvider" />

@@ -68,6 +90,7 @@ @provider-settings="openProviderConfigModal" @test-connection="handleTestConnection" @delete-provider="onDeleteProvider" + @disable-provider="onDisableProvider" @reprobe="reprobeProvider" /> @@ -122,6 +145,17 @@ @remove-model="onRemoveProviderModel" @add-model="onAddProviderModel" /> + + + @@ -129,6 +163,7 @@ import { computed, defineAsyncComponent, onMounted, ref } from 'vue' import { useI18n } from 'vue-i18n' import { ElMessage } from 'element-plus' +import { useRoute, useRouter } from 'vue-router' import type { ProviderInfo, ProviderModelInfo } from '@/types' import { useProviders } from './useProviders' import ProviderCard from './ProviderCard.vue' @@ -138,6 +173,8 @@ import EmbeddingModelsSection from './EmbeddingModelsSection.vue' // initial Settings/Models route chunk. const ProviderConfigModal = defineAsyncComponent(() => import('./modals/ProviderConfigModal.vue')) const ManageModelsModal = defineAsyncComponent(() => import('./modals/ManageModelsModal.vue')) +// RFC-074 PR-2: drawer for browsing the catalog and opting into hidden built-ins. +const AddProviderDrawer = defineAsyncComponent(() => import('./AddProviderDrawer.vue')) const { t } = useI18n() const savedTip = ref('') @@ -196,19 +233,52 @@ const { onIconError, handleOAuthLogin, handleOAuthRevoke, + // RFC-074 PR-2 — enablement / drawer + catalog, + drawerOpen, + togglingId, + openDrawer, + closeDrawer, + enableProvider, + disableProvider, } = useProviders() const localProviders = computed(() => providers.value.filter(p => p.isLocal)) const cloudProviders = computed(() => providers.value.filter(p => !p.isLocal)) +const route = useRoute() +const router = useRouter() +/** sessionStorage guard so the drawer auto-opens at most once per session per workspace. */ +const AUTO_OPEN_KEY = 'rfc074-add-provider-auto-opened' + onMounted(async () => { try { await Promise.all([loadProviders(), loadActiveModel()]) } finally { loading.value = false } + // Deep-link: ?addProvider=1 forces the drawer open (used by ModelSelector empty link). + const forceOpen = route.query.addProvider === '1' + // Empty state auto-open: when the user has zero enabled providers, fling the + // drawer open immediately. Guard with sessionStorage so closing it once + // doesn't bring it back on the next route visit in the same session. + const noProviders = providers.value.length === 0 + const alreadyAutoOpened = sessionStorage.getItem(AUTO_OPEN_KEY) === '1' + if (forceOpen || (noProviders && !alreadyAutoOpened)) { + sessionStorage.setItem(AUTO_OPEN_KEY, '1') + openDrawer() + if (forceOpen) { + // Strip the query so a manual close + back doesn't re-fire the open. + router.replace({ query: { ...route.query, addProvider: undefined } }) + } + } }) +async function onDisableProvider(provider: ProviderInfo) { + if (!confirm(t('settings.model.disableConfirm', { name: provider.name }))) return + await disableProvider(provider.id) +} + async function onSaveProvider() { try { await saveProvider() @@ -306,6 +376,31 @@ function showSavedTip(message: string) { } .btn-primary { border: none; border-radius: 10px; padding: 9px 14px; font-size: 14px; cursor: pointer; transition: all 0.15s; background: var(--mc-primary); color: white; } .btn-primary:hover { background: var(--mc-primary-hover); } +/* RFC-074 PR-2: section-header now has two CTAs (enable + create custom). */ +.section-header__actions { display: flex; gap: 8px; flex-shrink: 0; } +.btn-secondary { + border: 1px solid var(--mc-border); + border-radius: 10px; + padding: 9px 14px; + font-size: 14px; + cursor: pointer; + transition: all 0.15s; + background: var(--mc-bg-elevated); + color: var(--mc-text-primary); +} +.btn-secondary:hover { background: var(--mc-bg-sunken); } +/* RFC-074 PR-2: empty state when no providers are enabled. */ +.provider-empty { + margin: 32px auto; + max-width: 480px; + padding: 36px 28px; + text-align: center; + border: 1px dashed var(--mc-border); + border-radius: 16px; + background: var(--mc-bg-elevated); +} +.provider-empty h3 { margin: 0 0 8px; font-size: 16px; color: var(--mc-text-primary); } +.provider-empty p { margin: 0 0 18px; font-size: 13px; color: var(--mc-text-tertiary); } .save-tip { position: fixed; right: 24px; bottom: 24px; background: var(--mc-text-primary); color: var(--mc-text-inverse); padding: 10px 14px; border-radius: 10px; box-shadow: 0 10px 30px rgba(124, 63, 30, 0.22); } diff --git a/mateclaw-ui/src/views/Settings/Models/useProviders.ts b/mateclaw-ui/src/views/Settings/Models/useProviders.ts index 82f1b78a..7866990e 100644 --- a/mateclaw-ui/src/views/Settings/Models/useProviders.ts +++ b/mateclaw-ui/src/views/Settings/Models/useProviders.ts @@ -3,6 +3,7 @@ import { useProviderForm } from './composables/useProviderForm' import { useProviderDiscovery } from './composables/useProviderDiscovery' import { useProviderOAuth } from './composables/useProviderOAuth' import { useProviderPool } from './composables/useProviderPool' +import { useProviderEnablement } from './composables/useProviderEnablement' /** * RFC-074 PR-1: composition facade. Each sub-composable owns one slice of @@ -13,11 +14,12 @@ import { useProviderPool } from './composables/useProviderPool' * state), so each composable stays independently testable. * * Owned slices: - * - useProviderList — providers / activeModels / currentProvider, status pill, icons - * - useProviderForm — provider create/edit modal + form state, save/delete - * - useProviderDiscovery — manage-models modal, model discovery, connection / model tests - * - useProviderOAuth — OAuth flows (openai-chatgpt + claude-code) - * - useProviderPool — manual reprobe trigger (most pool surface inlined in RFC-073) + * - useProviderList — providers / activeModels / currentProvider, status pill, icons + * - useProviderForm — provider create/edit modal + form state, save/delete + * - useProviderDiscovery — manage-models modal, model discovery, connection / model tests + * - useProviderOAuth — OAuth flows (openai-chatgpt + claude-code) + * - useProviderPool — manual reprobe trigger (most pool surface inlined in RFC-073) + * - useProviderEnablement — RFC-074 PR-2: catalog + enable/disable + Add Provider drawer */ export function useProviders() { const list = useProviderList() @@ -37,6 +39,10 @@ export function useProviders() { const pool = useProviderPool({ loadProviders: list.loadProviders, }) + const enablement = useProviderEnablement({ + loadProviders: list.loadProviders, + loadActiveModel: list.loadActiveModel, + }) return { ...list, @@ -44,5 +50,6 @@ export function useProviders() { ...discovery, ...oauth, ...pool, + ...enablement, } }