feat(llm): enabled column for providers + Add Provider drawer

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.
This commit is contained in:
matevip 2026-04-28 15:03:40 +08:00
parent a168f91215
commit 6a3df2a6e0
18 changed files with 783 additions and 14 deletions

View File

@ -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<ProviderInfoDTO>> list() {
return R.ok(modelProviderService.listProviders());
}
@Operation(summary = "RFC-074: 获取 Provider 全量目录(含未启用),供 Add Provider 抽屉使用")
@GetMapping("/catalog")
public R<List<ProviderInfoDTO>> catalog() {
return R.ok(modelProviderService.listCatalog());
}
@Operation(summary = "RFC-074: 启用 Provider")
@PostMapping("/{providerId}/enable")
public R<EnableResult> enableProvider(@PathVariable String providerId) {
return R.ok(modelProviderService.setEnabled(providerId, true));
}
@Operation(summary = "RFC-074: 禁用 Provider如其下模型为当前默认会自动切换")
@PostMapping("/{providerId}/disable")
public R<EnableResult> disableProvider(@PathVariable String providerId) {
return R.ok(modelProviderService.setEnabled(providerId, false));
}
@Operation(summary = "获取启用模型列表")
@GetMapping("/enabled")
public R<List<ModelConfigEntity>> listEnabled() {

View File

@ -230,7 +230,11 @@ public class ProviderInitProbe {
}
private List<ModelProviderEntity> 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();
}

View File

@ -0,0 +1,25 @@
package vip.mate.llm.model;
/**
* RFC-074: payload for {@code POST /models/{id}/enable} and {@code .../disable}.
*
* <p>{@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.</p>
*
* <p>{@code newDefaultProviderId} / {@code newDefaultModel} are populated
* only when {@code defaultSwitched} is true; both null otherwise.</p>
*/
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);
}
}

View File

@ -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;

View File

@ -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;
}

View File

@ -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<ProviderInfoDTO> listProviders() {
List<ModelProviderEntity> providers = modelProviderMapper.selectList(new LambdaQueryWrapper<ModelProviderEntity>()
.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<ProviderInfoDTO> listCatalog() {
return listProvidersInternal(false);
}
private List<ProviderInfoDTO> listProvidersInternal(boolean enabledOnly) {
LambdaQueryWrapper<ModelProviderEntity> qw = new LambdaQueryWrapper<>();
if (enabledOnly) {
qw.eq(ModelProviderEntity::getEnabled, true);
}
qw.orderByDesc(ModelProviderEntity::getIsLocal)
.orderByAsc(ModelProviderEntity::getIsCustom)
.orderByAsc(ModelProviderEntity::getName);
List<ModelProviderEntity> providers = modelProviderMapper.selectList(qw);
Map<String, List<ModelConfigEntity>> 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<ModelProviderEntity> candidates = modelProviderMapper.selectList(
new LambdaQueryWrapper<ModelProviderEntity>()
.eq(ModelProviderEntity::getEnabled, true)
.ne(ModelProviderEntity::getProviderId, disabledProviderId)
.orderByDesc(ModelProviderEntity::getIsLocal)
.orderByAsc(ModelProviderEntity::getName));
for (ModelProviderEntity candidate : candidates) {
if (!isProviderConfigured(candidate)) continue;
List<ModelConfigEntity> 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());

View File

@ -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);

View File

@ -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);

View File

@ -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 } }),

View File

@ -61,7 +61,19 @@
<!-- 无结果 -->
<div v-if="filteredGroups.length === 0" class="model-empty">
{{ $t('chat.noMatchModel') }}
<!-- RFC-074 PR-2: when there are zero usable models AND the user
isn't searching, this is the "no providers configured" empty
state. Push them into Settings/Models with the drawer
pre-opened via ?addProvider=1. -->
<template v-if="query.trim() === '' && groups.length === 0">
{{ $t('chat.noProvidersConfigured') }}
<RouterLink class="model-empty__cta" to="/settings/models?addProvider=1" @click="open = false">
{{ $t('chat.goConfigure') }}
</RouterLink>
</template>
<template v-else>
{{ $t('chat.noMatchModel') }}
</template>
</div>
</div>
</div>
@ -73,6 +85,7 @@
<script setup lang="ts">
import { ref, computed, watch, nextTick, type CSSProperties } from 'vue'
import { useI18n } from 'vue-i18n'
import { RouterLink } from 'vue-router'
import type { ProviderInfo } from '@/types'
const { t } = useI18n()
@ -422,6 +435,18 @@ watch(open, async (isOpen) => {
font-size: 13px;
color: var(--mc-text-quaternary);
}
.model-empty__cta {
display: inline-block;
margin-top: 8px;
padding: 6px 12px;
border-radius: 8px;
background: var(--mc-primary);
color: white;
font-size: 12px;
font-weight: 600;
text-decoration: none;
}
.model-empty__cta:hover { background: var(--mc-primary-hover, var(--mc-primary)); }
/* ---- Transition ---- */

View File

@ -18,6 +18,7 @@ export default {
copy: 'Copy',
copied: 'Copied',
confirm: 'Confirm',
close: 'Close',
add: 'Add',
search: 'Search',
expandSidebar: 'Expand sidebar',
@ -146,6 +147,9 @@ export default {
// RFC-073: liveness hints shown in the model dropdown header
modelLivenessUnprobed: 'Checking availability',
modelLivenessCooldown: 'In cooldown ({seconds}s remaining)',
// RFC-074 PR-2: empty-state inside the model dropdown
noProvidersConfigured: 'No models available yet',
goConfigure: 'Configure',
uploadFailed: 'File upload failed',
dropToUpload: 'Drop files or folders here',
copyFailed: 'Copy failed',
@ -363,6 +367,20 @@ export default {
livenessUnprobed: 'Probing',
livenessUnprobedTooltip: 'Checking availability after startup; will refresh shortly',
livenessUnconfigured: 'Needs setup',
// RFC-074 PR-2: enable / disable / drawer
enableProviderCta: '+ Enable Provider',
addCustomProvider: '+ Custom',
enable: 'Enable',
disable: 'Disable',
alreadyEnabled: 'Enabled',
disableConfirm: 'Disable provider "{name}"? It will disappear from the dropdown but its config is kept — you can re-enable any time.',
defaultSwitchedToast: 'Default model switched to {provider} / {model}',
addProviderDrawerTitle: 'Add Provider',
addProviderDrawerSubtitle: 'Once enabled the provider appears in the model dropdown. Cloud providers still need an API key after enabling.',
catalogEmpty: 'Catalog is empty',
enabledToast: 'Enabled {name}',
emptyTitle: 'No providers enabled yet',
emptyDesc: 'Enable a built-in from the catalog, or create a custom provider.',
searchHint: 'When enabled, the LLM will use its built-in search engine to retrieve real-time information (DashScope/Kimi/OpenAI supported).',
searchStrategyDefault: 'Default',
oauthTitle: 'OpenAI OAuth Login',

View File

@ -18,6 +18,7 @@ export default {
copy: '复制',
copied: '已复制',
confirm: '确认',
close: '关闭',
add: '添加',
search: '搜索',
expandSidebar: '展开侧边栏',
@ -146,6 +147,9 @@ export default {
// RFC-073: liveness hints shown in the model dropdown header
modelLivenessUnprobed: '正在检查可用性',
modelLivenessCooldown: '冷却中({seconds} 秒后自动恢复)',
// RFC-074 PR-2: empty-state inside the model dropdown
noProvidersConfigured: '还没有可用的模型',
goConfigure: '去配置',
uploadFailed: '文件上传失败',
dropToUpload: '拖放文件或文件夹到此处',
copyFailed: '复制失败',
@ -353,6 +357,20 @@ export default {
livenessUnprobed: '检测中',
livenessUnprobedTooltip: '启动后正在检测可用性,稍候自动更新',
livenessUnconfigured: '需要配置',
// RFC-074 PR-2: enable / disable / drawer
enableProviderCta: '+ 启用 Provider',
addCustomProvider: '+ 自定义',
enable: '启用',
disable: '禁用',
alreadyEnabled: '已启用',
disableConfirm: '确定禁用 Provider "{name}"?它将从下拉中消失,但配置保留,可随时再启用。',
defaultSwitchedToast: '已将默认模型切换到 {provider} / {model}',
addProviderDrawerTitle: '添加 Provider',
addProviderDrawerSubtitle: '启用后会出现在模型下拉中。如果是云端 Provider启用后还需要填 API Key。',
catalogEmpty: '目录为空',
enabledToast: '已启用 {name}',
emptyTitle: '还没有可用的 Provider',
emptyDesc: '从内置目录中启用一个,或新建一个自定义 Provider。',
searchHint: '开启后大模型将在回答时自动调用内置搜索引擎获取实时信息DashScope/Kimi/OpenAI 支持)。',
searchStrategyDefault: '默认',
oauthTitle: 'OpenAI OAuth 登录',

View File

@ -652,6 +652,18 @@ export interface ProviderInfo {
lastProbedAtMs?: number
/** Remaining cooldown window in ms, populated only when liveness == COOLDOWN. */
cooldownRemainingMs?: number
/** RFC-074: whether the user has explicitly opted this provider into the dropdown. */
enabled?: boolean
}
/**
* RFC-074: response payload from POST /models/{id}/enable | disable.
* Frontend reads this to decide whether to fire a "switched default to X" toast.
*/
export interface EnableResult {
defaultSwitched: boolean
newDefaultProviderId?: string | null
newDefaultModel?: string | null
}
export interface ActiveModelsInfo {

View File

@ -0,0 +1,238 @@
<template>
<Teleport to="body">
<Transition name="drawer-fade">
<div v-if="visible" class="drawer-overlay" @click.self="$emit('close')">
<div class="drawer-panel">
<div class="drawer-header">
<div>
<h3 class="drawer-title">{{ t('settings.model.addProviderDrawerTitle') }}</h3>
<p class="drawer-subtitle">{{ t('settings.model.addProviderDrawerSubtitle') }}</p>
</div>
<button class="drawer-close" :title="t('common.close')" @click="$emit('close')">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="drawer-content">
<!-- Cloud providers -->
<section v-if="cloudGroup.length" class="drawer-group">
<h4 class="drawer-group-title">{{ t('settings.model.cloudProviders') }}</h4>
<div class="provider-row" v-for="p in cloudGroup" :key="p.id">
<img class="row-icon" :src="getProviderIcon(p.id)" :alt="p.name" @error="onIconError" />
<div class="row-meta">
<div class="row-name">{{ p.name }}</div>
<div class="row-id">{{ p.id }}</div>
</div>
<button
v-if="!p.enabled"
class="row-cta"
:disabled="togglingId === p.id"
@click="onEnable(p)"
>
{{ togglingId === p.id ? t('common.loading') : t('settings.model.enable') }}
</button>
<span v-else class="row-enabled-badge">{{ t('settings.model.alreadyEnabled') }}</span>
</div>
</section>
<!-- Local providers -->
<section v-if="localGroup.length" class="drawer-group">
<h4 class="drawer-group-title">{{ t('settings.model.localProviders') }}</h4>
<div class="provider-row" v-for="p in localGroup" :key="p.id">
<img class="row-icon" :src="getProviderIcon(p.id)" :alt="p.name" @error="onIconError" />
<div class="row-meta">
<div class="row-name">{{ p.name }}</div>
<div class="row-id">{{ p.id }}</div>
</div>
<button
v-if="!p.enabled"
class="row-cta"
:disabled="togglingId === p.id"
@click="onEnable(p)"
>
{{ togglingId === p.id ? t('common.loading') : t('settings.model.enable') }}
</button>
<span v-else class="row-enabled-badge">{{ t('settings.model.alreadyEnabled') }}</span>
</div>
</section>
<div v-if="!cloudGroup.length && !localGroup.length" class="drawer-empty">
{{ t('settings.model.catalogEmpty') }}
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import type { ProviderInfo } from '@/types'
const props = defineProps<{
visible: boolean
catalog: ProviderInfo[]
togglingId: string | null
getProviderIcon: (id: string) => string
onIconError: (e: Event) => void
enableProvider: (id: string) => Promise<unknown>
}>()
const emit = defineEmits<{
close: []
}>()
const { t } = useI18n()
// Sort: enabled rows sink to the bottom of each group so the actionable
// (still-disabled) options sit at the top where the user lands.
const cloudGroup = computed(() =>
props.catalog
.filter(p => !p.isLocal)
.sort((a, b) => Number(!!a.enabled) - Number(!!b.enabled) || a.name.localeCompare(b.name))
)
const localGroup = computed(() =>
props.catalog
.filter(p => p.isLocal)
.sort((a, b) => Number(!!a.enabled) - Number(!!b.enabled) || a.name.localeCompare(b.name))
)
async function onEnable(p: ProviderInfo) {
await props.enableProvider(p.id)
ElMessage.success(t('settings.model.enabledToast', { name: p.name }))
}
</script>
<style scoped>
.drawer-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.3);
z-index: 1500;
display: flex;
justify-content: flex-end;
}
.drawer-panel {
width: 460px;
max-width: 92vw;
height: 100%;
background: var(--mc-bg-elevated);
border-left: 1px solid var(--mc-border);
display: flex;
flex-direction: column;
animation: drawer-slide 0.22s ease;
}
@keyframes drawer-slide {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
.drawer-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 20px 24px;
border-bottom: 1px solid var(--mc-border-light);
}
.drawer-title {
margin: 0 0 4px;
font-size: 17px;
font-weight: 600;
color: var(--mc-text-primary);
}
.drawer-subtitle {
margin: 0;
font-size: 13px;
color: var(--mc-text-tertiary);
}
.drawer-close {
background: transparent;
border: 0;
padding: 6px;
border-radius: 8px;
cursor: pointer;
color: var(--mc-text-secondary);
flex-shrink: 0;
}
.drawer-close:hover { background: var(--mc-bg-sunken); }
.drawer-content {
flex: 1;
overflow-y: auto;
padding: 16px 20px 32px;
}
.drawer-group + .drawer-group { margin-top: 28px; }
.drawer-group-title {
margin: 0 0 10px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--mc-text-tertiary);
}
.provider-row {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border: 1px solid var(--mc-border);
border-radius: 12px;
background: var(--mc-bg-elevated);
}
.provider-row + .provider-row { margin-top: 8px; }
.row-icon { width: 32px; height: 32px; flex-shrink: 0; border-radius: 8px; object-fit: contain; }
.row-meta { flex: 1; min-width: 0; }
.row-name { font-size: 14px; font-weight: 600; color: var(--mc-text-primary); }
.row-id { margin-top: 2px; font-size: 12px; color: var(--mc-text-tertiary); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.row-cta {
flex-shrink: 0;
padding: 7px 14px;
border-radius: 8px;
border: 0;
cursor: pointer;
background: var(--mc-primary);
color: white;
font-size: 13px;
font-weight: 600;
transition: background 0.15s;
}
.row-cta:hover:not(:disabled) { background: var(--mc-primary-hover, var(--mc-primary)); }
.row-cta:disabled { opacity: 0.6; cursor: wait; }
.row-enabled-badge {
flex-shrink: 0;
padding: 6px 10px;
border-radius: 8px;
background: var(--mc-bg-sunken);
color: var(--mc-text-tertiary);
font-size: 12px;
}
.drawer-empty {
padding: 40px 20px;
text-align: center;
color: var(--mc-text-tertiary);
}
.drawer-fade-enter-active, .drawer-fade-leave-active {
transition: opacity 0.18s ease;
}
.drawer-fade-enter-from, .drawer-fade-leave-to { opacity: 0; }
/* Mobile: full-screen sheet that slides up. */
@media (max-width: 768px) {
.drawer-overlay { justify-content: stretch; }
.drawer-panel {
width: 100%;
max-width: 100%;
border-left: 0;
animation: drawer-slide-up 0.22s ease;
}
@keyframes drawer-slide-up {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
}
</style>

View File

@ -132,6 +132,15 @@
>
{{ reprobing ? t('settings.model.poolReprobing') : t('settings.model.poolReprobe') }}
</button>
<!-- RFC-074 PR-2: hide an enabled provider. Soft-disable; the row moves
back into the catalog drawer where the user can re-enable later. -->
<button
v-if="provider.enabled"
class="card-btn danger-soft"
@click="$emit('disable-provider', provider)"
>
{{ t('settings.model.disable') }}
</button>
</div>
<div v-if="connectionResults[provider.id]" class="connection-result" :class="connectionResults[provider.id].success ? 'success' : 'error'">
@ -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); }

View File

@ -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<void>
/** Re-fetch active model so the dropdown reflects auto-promoted defaults. */
loadActiveModel: () => Promise<void>
}
/**
* 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<ProviderInfo[]>([])
/** 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<string | null>(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<EnableResult | null> {
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<EnableResult | null> {
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,
}
}

View File

@ -5,9 +5,16 @@
<h2 class="section-title">{{ t('settings.model.title') }}</h2>
<p class="section-desc">{{ t('settings.model.desc') }}</p>
</div>
<button class="btn-primary" @click="openCreateProviderModal">
{{ t('settings.model.addProvider') }}
</button>
<div class="section-header__actions">
<!-- RFC-074 PR-2: primary entry to enable a built-in provider. -->
<button class="btn-primary" @click="openDrawer">
{{ t('settings.model.enableProviderCta') }}
</button>
<!-- Secondary: create a fully custom provider (your own base URL etc.). -->
<button class="btn-secondary" @click="openCreateProviderModal">
{{ t('settings.model.addCustomProvider') }}
</button>
</div>
</div>
<!-- RFC-074 PR-1: skeleton placeholder so the page paints something
@ -16,6 +23,20 @@
<el-skeleton :rows="4" animated />
</div>
<!-- RFC-074 PR-2: empty state shown when the user has no enabled providers.
The drawer auto-opens via onMounted, so this CTA is the recovery path
for users who closed it. -->
<div
v-if="!loading && !localProviders.length && !cloudProviders.length"
class="provider-empty"
>
<h3>{{ t('settings.model.emptyTitle') }}</h3>
<p>{{ t('settings.model.emptyDesc') }}</p>
<button class="btn-primary" @click="openDrawer">
{{ t('settings.model.enableProviderCta') }}
</button>
</div>
<!-- 本地模型 -->
<div v-if="!loading && localProviders.length" class="provider-group">
<h3 class="group-title">
@ -39,6 +60,7 @@
@provider-settings="openProviderConfigModal"
@test-connection="handleTestConnection"
@delete-provider="onDeleteProvider"
@disable-provider="onDisableProvider"
@reprobe="reprobeProvider"
/>
</div>
@ -68,6 +90,7 @@
@provider-settings="openProviderConfigModal"
@test-connection="handleTestConnection"
@delete-provider="onDeleteProvider"
@disable-provider="onDisableProvider"
@reprobe="reprobeProvider"
/>
</div>
@ -122,6 +145,17 @@
@remove-model="onRemoveProviderModel"
@add-model="onAddProviderModel"
/>
<!-- RFC-074 PR-2: Add Provider Drawer (catalog of opt-in built-ins). -->
<AddProviderDrawer
:visible="drawerOpen"
:catalog="catalog"
:toggling-id="togglingId"
:get-provider-icon="getProviderIcon"
:on-icon-error="onIconError"
:enable-provider="enableProvider"
@close="closeDrawer"
/>
</div>
</template>
@ -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); }

View File

@ -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,
}
}