mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
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:
parent
a168f91215
commit
6a3df2a6e0
@ -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() {
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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);
|
||||
@ -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);
|
||||
@ -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 } }),
|
||||
|
||||
@ -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 ---- */
|
||||
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 登录',
|
||||
|
||||
@ -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 {
|
||||
|
||||
238
mateclaw-ui/src/views/Settings/Models/AddProviderDrawer.vue
Normal file
238
mateclaw-ui/src/views/Settings/Models/AddProviderDrawer.vue
Normal 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>
|
||||
@ -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); }
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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); }
|
||||
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user