mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 11:58:34 +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";
|
private static final String SYSTEM_SETTING_DEFAULT_EMBEDDING_ID = "embedding.default.model.id";
|
||||||
|
|
||||||
@Operation(summary = "获取 Provider 列表")
|
@Operation(summary = "获取 Provider 列表(仅 enabled)")
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public R<List<ProviderInfoDTO>> list() {
|
public R<List<ProviderInfoDTO>> list() {
|
||||||
return R.ok(modelProviderService.listProviders());
|
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 = "获取启用模型列表")
|
@Operation(summary = "获取启用模型列表")
|
||||||
@GetMapping("/enabled")
|
@GetMapping("/enabled")
|
||||||
public R<List<ModelConfigEntity>> listEnabled() {
|
public R<List<ModelConfigEntity>> listEnabled() {
|
||||||
|
|||||||
@ -230,7 +230,11 @@ public class ProviderInitProbe {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private List<ModelProviderEntity> listConfiguredProviders() {
|
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()
|
return providerMapper.selectList(null).stream()
|
||||||
|
.filter(p -> Boolean.TRUE.equals(p.getEnabled()))
|
||||||
.filter(p -> providerService.isProviderConfigured(p.getProviderId()))
|
.filter(p -> providerService.isProviderConfigured(p.getProviderId()))
|
||||||
.toList();
|
.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;
|
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)
|
@TableField(fill = FieldFill.INSERT)
|
||||||
private LocalDateTime createTime;
|
private LocalDateTime createTime;
|
||||||
|
|
||||||
|
|||||||
@ -39,4 +39,6 @@ public class ProviderInfoDTO {
|
|||||||
private Long lastProbedAtMs;
|
private Long lastProbedAtMs;
|
||||||
/** Remaining cooldown window in ms, populated only when liveness == COOLDOWN. */
|
/** Remaining cooldown window in ms, populated only when liveness == COOLDOWN. */
|
||||||
private Long cooldownRemainingMs;
|
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);
|
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() {
|
public List<ProviderInfoDTO> listProviders() {
|
||||||
List<ModelProviderEntity> providers = modelProviderMapper.selectList(new LambdaQueryWrapper<ModelProviderEntity>()
|
return listProvidersInternal(true);
|
||||||
.orderByDesc(ModelProviderEntity::getIsLocal)
|
}
|
||||||
.orderByAsc(ModelProviderEntity::getIsCustom)
|
|
||||||
.orderByAsc(ModelProviderEntity::getName));
|
/** 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()
|
Map<String, List<ModelConfigEntity>> modelsByProvider = modelConfigService.listModels().stream()
|
||||||
.collect(Collectors.groupingBy(ModelConfigEntity::getProvider));
|
.collect(Collectors.groupingBy(ModelConfigEntity::getProvider));
|
||||||
// RFC-073: batch the runtime snapshots once so each toProviderInfo call is O(1)
|
// RFC-073: batch the runtime snapshots once so each toProviderInfo call is O(1)
|
||||||
@ -124,6 +141,9 @@ public class ModelProviderService {
|
|||||||
provider.setGenerateKwargs("{}");
|
provider.setGenerateKwargs("{}");
|
||||||
provider.setIsCustom(true);
|
provider.setIsCustom(true);
|
||||||
provider.setIsLocal(false);
|
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.setSupportModelDiscovery(false);
|
||||||
provider.setSupportConnectionCheck(false);
|
provider.setSupportConnectionCheck(false);
|
||||||
provider.setFreezeUrl(false);
|
provider.setFreezeUrl(false);
|
||||||
@ -213,6 +233,67 @@ public class ModelProviderService {
|
|||||||
return null;
|
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) {
|
private void tryAutoActivateModel(String providerId, ModelProviderEntity provider) {
|
||||||
if (!isProviderConfigured(provider)) {
|
if (!isProviderConfigured(provider)) {
|
||||||
return;
|
return;
|
||||||
@ -273,6 +354,7 @@ public class ModelProviderService {
|
|||||||
dto.setConfigured(configured);
|
dto.setConfigured(configured);
|
||||||
dto.setAvailable(available);
|
dto.setAvailable(available);
|
||||||
dto.setLiveness(providerLiveness);
|
dto.setLiveness(providerLiveness);
|
||||||
|
dto.setEnabled(Boolean.TRUE.equals(provider.getEnabled()));
|
||||||
applyLivenessDetails(dto, provider.getProviderId(), providerLiveness, liveness);
|
applyLivenessDetails(dto, provider.getProviderId(), providerLiveness, liveness);
|
||||||
dto.setApiKey(maskApiKey(provider.getApiKey()));
|
dto.setApiKey(maskApiKey(provider.getApiKey()));
|
||||||
dto.setBaseUrl(provider.getBaseUrl());
|
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) =>
|
testModel: (providerId: string, modelId: string) =>
|
||||||
http.post(`/models/${providerId}/models/${encodeURIComponent(modelId)}/test`),
|
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) ====================
|
// ==================== Embedding Model (RFC Embedding UI) ====================
|
||||||
listByType: (modelType: 'chat' | 'embedding') =>
|
listByType: (modelType: 'chat' | 'embedding') =>
|
||||||
http.get('/models/by-type', { params: { modelType } }),
|
http.get('/models/by-type', { params: { modelType } }),
|
||||||
|
|||||||
@ -61,7 +61,19 @@
|
|||||||
|
|
||||||
<!-- 无结果 -->
|
<!-- 无结果 -->
|
||||||
<div v-if="filteredGroups.length === 0" class="model-empty">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -73,6 +85,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, nextTick, type CSSProperties } from 'vue'
|
import { ref, computed, watch, nextTick, type CSSProperties } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
import type { ProviderInfo } from '@/types'
|
import type { ProviderInfo } from '@/types'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@ -422,6 +435,18 @@ watch(open, async (isOpen) => {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--mc-text-quaternary);
|
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 ---- */
|
/* ---- Transition ---- */
|
||||||
|
|
||||||
|
|||||||
@ -18,6 +18,7 @@ export default {
|
|||||||
copy: 'Copy',
|
copy: 'Copy',
|
||||||
copied: 'Copied',
|
copied: 'Copied',
|
||||||
confirm: 'Confirm',
|
confirm: 'Confirm',
|
||||||
|
close: 'Close',
|
||||||
add: 'Add',
|
add: 'Add',
|
||||||
search: 'Search',
|
search: 'Search',
|
||||||
expandSidebar: 'Expand sidebar',
|
expandSidebar: 'Expand sidebar',
|
||||||
@ -146,6 +147,9 @@ export default {
|
|||||||
// RFC-073: liveness hints shown in the model dropdown header
|
// RFC-073: liveness hints shown in the model dropdown header
|
||||||
modelLivenessUnprobed: 'Checking availability',
|
modelLivenessUnprobed: 'Checking availability',
|
||||||
modelLivenessCooldown: 'In cooldown ({seconds}s remaining)',
|
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',
|
uploadFailed: 'File upload failed',
|
||||||
dropToUpload: 'Drop files or folders here',
|
dropToUpload: 'Drop files or folders here',
|
||||||
copyFailed: 'Copy failed',
|
copyFailed: 'Copy failed',
|
||||||
@ -363,6 +367,20 @@ export default {
|
|||||||
livenessUnprobed: 'Probing',
|
livenessUnprobed: 'Probing',
|
||||||
livenessUnprobedTooltip: 'Checking availability after startup; will refresh shortly',
|
livenessUnprobedTooltip: 'Checking availability after startup; will refresh shortly',
|
||||||
livenessUnconfigured: 'Needs setup',
|
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).',
|
searchHint: 'When enabled, the LLM will use its built-in search engine to retrieve real-time information (DashScope/Kimi/OpenAI supported).',
|
||||||
searchStrategyDefault: 'Default',
|
searchStrategyDefault: 'Default',
|
||||||
oauthTitle: 'OpenAI OAuth Login',
|
oauthTitle: 'OpenAI OAuth Login',
|
||||||
|
|||||||
@ -18,6 +18,7 @@ export default {
|
|||||||
copy: '复制',
|
copy: '复制',
|
||||||
copied: '已复制',
|
copied: '已复制',
|
||||||
confirm: '确认',
|
confirm: '确认',
|
||||||
|
close: '关闭',
|
||||||
add: '添加',
|
add: '添加',
|
||||||
search: '搜索',
|
search: '搜索',
|
||||||
expandSidebar: '展开侧边栏',
|
expandSidebar: '展开侧边栏',
|
||||||
@ -146,6 +147,9 @@ export default {
|
|||||||
// RFC-073: liveness hints shown in the model dropdown header
|
// RFC-073: liveness hints shown in the model dropdown header
|
||||||
modelLivenessUnprobed: '正在检查可用性',
|
modelLivenessUnprobed: '正在检查可用性',
|
||||||
modelLivenessCooldown: '冷却中({seconds} 秒后自动恢复)',
|
modelLivenessCooldown: '冷却中({seconds} 秒后自动恢复)',
|
||||||
|
// RFC-074 PR-2: empty-state inside the model dropdown
|
||||||
|
noProvidersConfigured: '还没有可用的模型',
|
||||||
|
goConfigure: '去配置',
|
||||||
uploadFailed: '文件上传失败',
|
uploadFailed: '文件上传失败',
|
||||||
dropToUpload: '拖放文件或文件夹到此处',
|
dropToUpload: '拖放文件或文件夹到此处',
|
||||||
copyFailed: '复制失败',
|
copyFailed: '复制失败',
|
||||||
@ -353,6 +357,20 @@ export default {
|
|||||||
livenessUnprobed: '检测中',
|
livenessUnprobed: '检测中',
|
||||||
livenessUnprobedTooltip: '启动后正在检测可用性,稍候自动更新',
|
livenessUnprobedTooltip: '启动后正在检测可用性,稍候自动更新',
|
||||||
livenessUnconfigured: '需要配置',
|
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 支持)。',
|
searchHint: '开启后,大模型将在回答时自动调用内置搜索引擎获取实时信息(DashScope/Kimi/OpenAI 支持)。',
|
||||||
searchStrategyDefault: '默认',
|
searchStrategyDefault: '默认',
|
||||||
oauthTitle: 'OpenAI OAuth 登录',
|
oauthTitle: 'OpenAI OAuth 登录',
|
||||||
|
|||||||
@ -652,6 +652,18 @@ export interface ProviderInfo {
|
|||||||
lastProbedAtMs?: number
|
lastProbedAtMs?: number
|
||||||
/** Remaining cooldown window in ms, populated only when liveness == COOLDOWN. */
|
/** Remaining cooldown window in ms, populated only when liveness == COOLDOWN. */
|
||||||
cooldownRemainingMs?: number
|
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 {
|
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') }}
|
{{ reprobing ? t('settings.model.poolReprobing') : t('settings.model.poolReprobe') }}
|
||||||
</button>
|
</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>
|
||||||
|
|
||||||
<div v-if="connectionResults[provider.id]" class="connection-result" :class="connectionResults[provider.id].success ? 'success' : 'error'">
|
<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]
|
'provider-settings': [provider: ProviderInfo]
|
||||||
'test-connection': [provider: ProviderInfo]
|
'test-connection': [provider: ProviderInfo]
|
||||||
'delete-provider': [provider: ProviderInfo]
|
'delete-provider': [provider: ProviderInfo]
|
||||||
|
'disable-provider': [provider: ProviderInfo]
|
||||||
'reprobe': [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 { 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:hover { background: rgba(217, 119, 87, 0.18); }
|
||||||
.card-btn.danger { background: var(--mc-danger-bg); color: var(--mc-danger); }
|
.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; }
|
.card-btn.testing { opacity: 0.6; cursor: wait; }
|
||||||
.connection-result { margin-top: 10px; padding: 8px 12px; border-radius: 8px; font-size: 12px; }
|
.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); }
|
.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>
|
<h2 class="section-title">{{ t('settings.model.title') }}</h2>
|
||||||
<p class="section-desc">{{ t('settings.model.desc') }}</p>
|
<p class="section-desc">{{ t('settings.model.desc') }}</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-primary" @click="openCreateProviderModal">
|
<div class="section-header__actions">
|
||||||
{{ t('settings.model.addProvider') }}
|
<!-- RFC-074 PR-2: primary entry to enable a built-in provider. -->
|
||||||
</button>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- RFC-074 PR-1: skeleton placeholder so the page paints something
|
<!-- RFC-074 PR-1: skeleton placeholder so the page paints something
|
||||||
@ -16,6 +23,20 @@
|
|||||||
<el-skeleton :rows="4" animated />
|
<el-skeleton :rows="4" animated />
|
||||||
</div>
|
</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">
|
<div v-if="!loading && localProviders.length" class="provider-group">
|
||||||
<h3 class="group-title">
|
<h3 class="group-title">
|
||||||
@ -39,6 +60,7 @@
|
|||||||
@provider-settings="openProviderConfigModal"
|
@provider-settings="openProviderConfigModal"
|
||||||
@test-connection="handleTestConnection"
|
@test-connection="handleTestConnection"
|
||||||
@delete-provider="onDeleteProvider"
|
@delete-provider="onDeleteProvider"
|
||||||
|
@disable-provider="onDisableProvider"
|
||||||
@reprobe="reprobeProvider"
|
@reprobe="reprobeProvider"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -68,6 +90,7 @@
|
|||||||
@provider-settings="openProviderConfigModal"
|
@provider-settings="openProviderConfigModal"
|
||||||
@test-connection="handleTestConnection"
|
@test-connection="handleTestConnection"
|
||||||
@delete-provider="onDeleteProvider"
|
@delete-provider="onDeleteProvider"
|
||||||
|
@disable-provider="onDisableProvider"
|
||||||
@reprobe="reprobeProvider"
|
@reprobe="reprobeProvider"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -122,6 +145,17 @@
|
|||||||
@remove-model="onRemoveProviderModel"
|
@remove-model="onRemoveProviderModel"
|
||||||
@add-model="onAddProviderModel"
|
@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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -129,6 +163,7 @@
|
|||||||
import { computed, defineAsyncComponent, onMounted, ref } from 'vue'
|
import { computed, defineAsyncComponent, onMounted, ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import type { ProviderInfo, ProviderModelInfo } from '@/types'
|
import type { ProviderInfo, ProviderModelInfo } from '@/types'
|
||||||
import { useProviders } from './useProviders'
|
import { useProviders } from './useProviders'
|
||||||
import ProviderCard from './ProviderCard.vue'
|
import ProviderCard from './ProviderCard.vue'
|
||||||
@ -138,6 +173,8 @@ import EmbeddingModelsSection from './EmbeddingModelsSection.vue'
|
|||||||
// initial Settings/Models route chunk.
|
// initial Settings/Models route chunk.
|
||||||
const ProviderConfigModal = defineAsyncComponent(() => import('./modals/ProviderConfigModal.vue'))
|
const ProviderConfigModal = defineAsyncComponent(() => import('./modals/ProviderConfigModal.vue'))
|
||||||
const ManageModelsModal = defineAsyncComponent(() => import('./modals/ManageModelsModal.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 { t } = useI18n()
|
||||||
const savedTip = ref('')
|
const savedTip = ref('')
|
||||||
@ -196,19 +233,52 @@ const {
|
|||||||
onIconError,
|
onIconError,
|
||||||
handleOAuthLogin,
|
handleOAuthLogin,
|
||||||
handleOAuthRevoke,
|
handleOAuthRevoke,
|
||||||
|
// RFC-074 PR-2 — enablement / drawer
|
||||||
|
catalog,
|
||||||
|
drawerOpen,
|
||||||
|
togglingId,
|
||||||
|
openDrawer,
|
||||||
|
closeDrawer,
|
||||||
|
enableProvider,
|
||||||
|
disableProvider,
|
||||||
} = useProviders()
|
} = useProviders()
|
||||||
|
|
||||||
const localProviders = computed(() => providers.value.filter(p => p.isLocal))
|
const localProviders = computed(() => providers.value.filter(p => p.isLocal))
|
||||||
const cloudProviders = 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 () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
await Promise.all([loadProviders(), loadActiveModel()])
|
await Promise.all([loadProviders(), loadActiveModel()])
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
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() {
|
async function onSaveProvider() {
|
||||||
try {
|
try {
|
||||||
await saveProvider()
|
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 { 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); }
|
.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); }
|
.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 { useProviderDiscovery } from './composables/useProviderDiscovery'
|
||||||
import { useProviderOAuth } from './composables/useProviderOAuth'
|
import { useProviderOAuth } from './composables/useProviderOAuth'
|
||||||
import { useProviderPool } from './composables/useProviderPool'
|
import { useProviderPool } from './composables/useProviderPool'
|
||||||
|
import { useProviderEnablement } from './composables/useProviderEnablement'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RFC-074 PR-1: composition facade. Each sub-composable owns one slice of
|
* 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.
|
* state), so each composable stays independently testable.
|
||||||
*
|
*
|
||||||
* Owned slices:
|
* Owned slices:
|
||||||
* - useProviderList — providers / activeModels / currentProvider, status pill, icons
|
* - useProviderList — providers / activeModels / currentProvider, status pill, icons
|
||||||
* - useProviderForm — provider create/edit modal + form state, save/delete
|
* - useProviderForm — provider create/edit modal + form state, save/delete
|
||||||
* - useProviderDiscovery — manage-models modal, model discovery, connection / model tests
|
* - useProviderDiscovery — manage-models modal, model discovery, connection / model tests
|
||||||
* - useProviderOAuth — OAuth flows (openai-chatgpt + claude-code)
|
* - useProviderOAuth — OAuth flows (openai-chatgpt + claude-code)
|
||||||
* - useProviderPool — manual reprobe trigger (most pool surface inlined in RFC-073)
|
* - 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() {
|
export function useProviders() {
|
||||||
const list = useProviderList()
|
const list = useProviderList()
|
||||||
@ -37,6 +39,10 @@ export function useProviders() {
|
|||||||
const pool = useProviderPool({
|
const pool = useProviderPool({
|
||||||
loadProviders: list.loadProviders,
|
loadProviders: list.loadProviders,
|
||||||
})
|
})
|
||||||
|
const enablement = useProviderEnablement({
|
||||||
|
loadProviders: list.loadProviders,
|
||||||
|
loadActiveModel: list.loadActiveModel,
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...list,
|
...list,
|
||||||
@ -44,5 +50,6 @@ export function useProviders() {
|
|||||||
...discovery,
|
...discovery,
|
||||||
...oauth,
|
...oauth,
|
||||||
...pool,
|
...pool,
|
||||||
|
...enablement,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user