mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 20:08:18 +08:00
feat(llm): provider liveness model + honor requireApiKey on chat path
Phase 1 of the model-module refactor: combine pool / cooldown / probe-
completion signals into a single Liveness state surfaced through the
provider DTO, so the dropdown stops listing providers that are provably
unreachable. Zero schema change; one PR backend + frontend.
Backend
- Liveness enum with five mutually-exclusive states: LIVE, COOLDOWN,
REMOVED, UNPROBED, UNCONFIGURED. Computed in ModelProviderService
from AvailableProviderPool / ProviderHealthTracker / ProviderInitProbe
snapshots batched once per listProviders() call.
- ProviderInitProbe.hasBeenProbed exposes a monotonic Set so the UI
can distinguish 'still booting' from 'probed and removed' — without
it the startup window flashes false REMOVED states.
- ProviderInfoDTO gains liveness + unavailableReason +
cooldownRemainingMs + lastProbedAtMs. The legacy 'available' boolean
stays but is now derived from liveness == LIVE so the chat fallback
walker and the dropdown agree about what's usable.
- ProviderInitProbe injected into ModelProviderService via
ObjectProvider to break the startup cycle (probe already depends on
the service).
Frontend
- ProviderInfo type extended with liveness + the three detail fields.
- ModelSelector filters UNCONFIGURED + REMOVED out of the dropdown,
shows COOLDOWN / UNPROBED with a status dot and dimmed rows that the
user can still click to override.
- ProviderCard renders a five-state badge driven by liveness instead
of the old configured + pool-entry combo. Reprobe button now keys
off liveness in {REMOVED, COOLDOWN}.
- useProviders drops loadProviderPool / providerPool — pool data ships
inline on each ProviderInfo, saves a round trip per page load and
keeps a single source of truth.
- i18n: 8 new keys across zh-CN and en-US for liveness labels and the
cooldown countdown tooltips.
Bonus fix (discovered during verification): AgentGraphBuilder.buildOpenAiApi
hard-required a usable API key on every OpenAI-compat provider, ignoring
the per-provider requireApiKey flag. That bug stranded keyless local
runtimes (LM Studio / MLX / llama.cpp) the moment a user actually
launched them; Ollama only worked by accident because its seed row
carries a placeholder string in api_key. keyRequired now honors
requireApiKey, and Spring AI's NoopApiKey is used when no key is needed
so the Authorization header is omitted entirely.
Test
- ModelProviderServiceLivenessTest covers all five Liveness states +
the probe-bean-absent fallback branch.
- vip.mate.llm.** suite (118 tests) green; vue-tsc clean.
- End-to-end browser sanity: 27 raw providers reduce to 6 LIVE groups
in the chat dropdown; LM Studio / MLX / llama.cpp render REMOVED red
badges with reprobe buttons; cloud providers without keys show
UNCONFIGURED.
This commit is contained in:
parent
b4aef56c89
commit
c0c642380a
@ -15,6 +15,8 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
// PR-0b: Anthropic imports moved with the construction code into AgentAnthropicChatModelBuilder.
|
// PR-0b: Anthropic imports moved with the construction code into AgentAnthropicChatModelBuilder.
|
||||||
import org.springframework.ai.chat.client.ChatClient;
|
import org.springframework.ai.chat.client.ChatClient;
|
||||||
import org.springframework.ai.chat.model.ChatModel;
|
import org.springframework.ai.chat.model.ChatModel;
|
||||||
|
import org.springframework.ai.model.ApiKey;
|
||||||
|
import org.springframework.ai.model.NoopApiKey;
|
||||||
import org.springframework.ai.model.SimpleApiKey;
|
import org.springframework.ai.model.SimpleApiKey;
|
||||||
import org.springframework.ai.openai.OpenAiChatModel;
|
import org.springframework.ai.openai.OpenAiChatModel;
|
||||||
import org.springframework.ai.openai.OpenAiChatOptions;
|
import org.springframework.ai.openai.OpenAiChatOptions;
|
||||||
@ -1006,7 +1008,13 @@ public class AgentGraphBuilder {
|
|||||||
throw new MateClawException("err.agent.provider_not_configured", "Provider 未完成配置,请在模型设置中填写有效的 API Key 和 Base URL");
|
throw new MateClawException("err.agent.provider_not_configured", "Provider 未完成配置,请在模型设置中填写有效的 API Key 和 Base URL");
|
||||||
}
|
}
|
||||||
String apiKey = provider.getApiKey();
|
String apiKey = provider.getApiKey();
|
||||||
if (!modelProviderService.hasUsableApiKey(apiKey)) {
|
// Honor the provider's requireApiKey flag instead of hard-failing on every empty key.
|
||||||
|
// Local + key-free providers (Ollama, LM Studio, MLX, llama.cpp, OpenCode) declare
|
||||||
|
// requireApiKey=false; for them an empty / placeholder key means "no Authorization
|
||||||
|
// header" — Spring AI's NoopApiKey expresses that. Without this the chat path
|
||||||
|
// rejected providers that probe / discovery / connection-test all considered usable.
|
||||||
|
boolean keyRequired = !Boolean.FALSE.equals(provider.getRequireApiKey());
|
||||||
|
if (keyRequired && !modelProviderService.hasUsableApiKey(apiKey)) {
|
||||||
throw new MateClawException("err.agent.provider_apikey_invalid", "Provider API Key 未配置或无效: " + provider.getProviderId());
|
throw new MateClawException("err.agent.provider_apikey_invalid", "Provider API Key 未配置或无效: " + provider.getProviderId());
|
||||||
}
|
}
|
||||||
String baseUrl = normalizeOpenAiBaseUrl(provider.getBaseUrl());
|
String baseUrl = normalizeOpenAiBaseUrl(provider.getBaseUrl());
|
||||||
@ -1042,9 +1050,12 @@ public class AgentGraphBuilder {
|
|||||||
boolean kimiSearchEnabled = isKimiProvider(provider)
|
boolean kimiSearchEnabled = isKimiProvider(provider)
|
||||||
&& Boolean.TRUE.equals(kwargs.get("enableSearch"));
|
&& Boolean.TRUE.equals(kwargs.get("enableSearch"));
|
||||||
|
|
||||||
|
ApiKey apiKeyImpl = (keyRequired && StringUtils.hasText(apiKey))
|
||||||
|
? new SimpleApiKey(apiKey.trim())
|
||||||
|
: new NoopApiKey();
|
||||||
return new OpenAiApi(
|
return new OpenAiApi(
|
||||||
baseUrl,
|
baseUrl,
|
||||||
new SimpleApiKey(apiKey.trim()),
|
apiKeyImpl,
|
||||||
headers,
|
headers,
|
||||||
completionsPath,
|
completionsPath,
|
||||||
"/v1/embeddings",
|
"/v1/embeddings",
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import vip.mate.llm.service.ModelProviderService;
|
|||||||
import java.util.EnumMap;
|
import java.util.EnumMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.concurrent.*;
|
import java.util.concurrent.*;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -51,6 +52,14 @@ public class ProviderInitProbe {
|
|||||||
private final AvailableProviderPool pool;
|
private final AvailableProviderPool pool;
|
||||||
private final Map<ModelProtocol, ProviderProbeStrategy> strategies;
|
private final Map<ModelProtocol, ProviderProbeStrategy> strategies;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-073: providers we've made a probe decision for (success / fail /
|
||||||
|
* deferred / fail-open). Lets the UI distinguish "still booting" from
|
||||||
|
* "probed and removed" without inventing a parallel state machine.
|
||||||
|
* Monotonic — entries are never removed; re-probing simply re-asserts.
|
||||||
|
*/
|
||||||
|
private final Set<String> probedProviderIds = ConcurrentHashMap.newKeySet();
|
||||||
|
|
||||||
public ProviderInitProbe(ModelProviderMapper providerMapper,
|
public ProviderInitProbe(ModelProviderMapper providerMapper,
|
||||||
ModelProviderService providerService,
|
ModelProviderService providerService,
|
||||||
AvailableProviderPool pool,
|
AvailableProviderPool pool,
|
||||||
@ -122,6 +131,7 @@ public class ProviderInitProbe {
|
|||||||
log.debug("[ProviderInitProbe] no probe strategy for {} (protocol={}), defaulting to in-pool",
|
log.debug("[ProviderInitProbe] no probe strategy for {} (protocol={}), defaulting to in-pool",
|
||||||
provider.getProviderId(), provider.getChatModel());
|
provider.getProviderId(), provider.getChatModel());
|
||||||
pool.add(provider.getProviderId());
|
pool.add(provider.getProviderId());
|
||||||
|
probedProviderIds.add(provider.getProviderId());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
futures.put(provider.getProviderId(),
|
futures.put(provider.getProviderId(),
|
||||||
@ -161,6 +171,7 @@ public class ProviderInitProbe {
|
|||||||
log.warn("[ProviderInitProbe] provider={} FAIL ({} ms): {}",
|
log.warn("[ProviderInitProbe] provider={} FAIL ({} ms): {}",
|
||||||
id, result.latencyMs(), result.errorMessage());
|
id, result.latencyMs(), result.errorMessage());
|
||||||
}
|
}
|
||||||
|
probedProviderIds.add(id);
|
||||||
}
|
}
|
||||||
log.info("[ProviderInitProbe] done — passed={}, failed={}, deferred={}, pool size={}",
|
log.info("[ProviderInitProbe] done — passed={}, failed={}, deferred={}, pool size={}",
|
||||||
passed, failed, deferred, pool.snapshot().size());
|
passed, failed, deferred, pool.snapshot().size());
|
||||||
@ -193,6 +204,7 @@ public class ProviderInitProbe {
|
|||||||
if (strategy == null) {
|
if (strategy == null) {
|
||||||
// No strategy for this protocol — fail-open: assume usable.
|
// No strategy for this protocol — fail-open: assume usable.
|
||||||
pool.add(providerId);
|
pool.add(providerId);
|
||||||
|
probedProviderIds.add(providerId);
|
||||||
return ProbeResult.ok(0);
|
return ProbeResult.ok(0);
|
||||||
}
|
}
|
||||||
ProbeResult result = strategy.probe(provider);
|
ProbeResult result = strategy.probe(provider);
|
||||||
@ -202,9 +214,21 @@ public class ProviderInitProbe {
|
|||||||
pool.remove(providerId, AvailableProviderPool.RemovalSource.INIT_PROBE,
|
pool.remove(providerId, AvailableProviderPool.RemovalSource.INIT_PROBE,
|
||||||
"reprobe failed: " + result.errorMessage());
|
"reprobe failed: " + result.errorMessage());
|
||||||
}
|
}
|
||||||
|
probedProviderIds.add(providerId);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-073: has this provider been through at least one probe attempt
|
||||||
|
* (success, failure, deferred fail-open, or strategy-missing fail-open)?
|
||||||
|
* Returns false during the startup window before {@code probeAllConfigured}
|
||||||
|
* has touched it. Used by {@code ModelProviderService} to distinguish
|
||||||
|
* UNPROBED from REMOVED in the UI.
|
||||||
|
*/
|
||||||
|
public boolean hasBeenProbed(String providerId) {
|
||||||
|
return providerId != null && probedProviderIds.contains(providerId);
|
||||||
|
}
|
||||||
|
|
||||||
private List<ModelProviderEntity> listConfiguredProviders() {
|
private List<ModelProviderEntity> listConfiguredProviders() {
|
||||||
return providerMapper.selectList(null).stream()
|
return providerMapper.selectList(null).stream()
|
||||||
.filter(p -> providerService.isProviderConfigured(p.getProviderId()))
|
.filter(p -> providerService.isProviderConfigured(p.getProviderId()))
|
||||||
|
|||||||
@ -0,0 +1,22 @@
|
|||||||
|
package vip.mate.llm.model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-073: runtime liveness state of a provider, surfaced to the UI so the
|
||||||
|
* dropdown / settings page can show truth instead of "configured = available".
|
||||||
|
*
|
||||||
|
* <p>Computed by {@code ModelProviderService.computeLiveness} from three
|
||||||
|
* orthogonal signals: configuration completeness, init-probe progress, and
|
||||||
|
* pool / cooldown membership. The five values are mutually exclusive.</p>
|
||||||
|
*/
|
||||||
|
public enum Liveness {
|
||||||
|
/** In pool, not in cooldown. The default healthy state. */
|
||||||
|
LIVE,
|
||||||
|
/** In pool but in transient cooldown (consecutive failures tripped the threshold). */
|
||||||
|
COOLDOWN,
|
||||||
|
/** Probed and removed from pool with a HARD reason (auth, billing, model-not-found, init-probe failed). */
|
||||||
|
REMOVED,
|
||||||
|
/** Not yet probed — startup window or no probe strategy registered for this protocol. */
|
||||||
|
UNPROBED,
|
||||||
|
/** User-side configuration is incomplete (missing api_key / oauth token / base_url). */
|
||||||
|
UNCONFIGURED
|
||||||
|
}
|
||||||
@ -31,4 +31,12 @@ public class ProviderInfoDTO {
|
|||||||
private Long oauthExpiresAt;
|
private Long oauthExpiresAt;
|
||||||
/** RFC-009 P3.5: position in the failover chain (0 = excluded, 1..N = priority). */
|
/** RFC-009 P3.5: position in the failover chain (0 = excluded, 1..N = priority). */
|
||||||
private Integer fallbackPriority;
|
private Integer fallbackPriority;
|
||||||
|
/** RFC-073: combined runtime state — UI source of truth for "is this provider usable right now". */
|
||||||
|
private Liveness liveness;
|
||||||
|
/** Human-readable reason populated only when liveness ∈ {REMOVED, COOLDOWN}. */
|
||||||
|
private String unavailableReason;
|
||||||
|
/** Epoch ms of the most recent removal, populated only when liveness == REMOVED. */
|
||||||
|
private Long lastProbedAtMs;
|
||||||
|
/** Remaining cooldown window in ms, populated only when liveness == COOLDOWN. */
|
||||||
|
private Long cooldownRemainingMs;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,6 +11,9 @@ import org.springframework.util.StringUtils;
|
|||||||
import vip.mate.exception.MateClawException;
|
import vip.mate.exception.MateClawException;
|
||||||
import vip.mate.llm.anthropic.oauth.ClaudeCodeOAuthService;
|
import vip.mate.llm.anthropic.oauth.ClaudeCodeOAuthService;
|
||||||
import vip.mate.llm.event.ModelConfigChangedEvent;
|
import vip.mate.llm.event.ModelConfigChangedEvent;
|
||||||
|
import vip.mate.llm.failover.AvailableProviderPool;
|
||||||
|
import vip.mate.llm.failover.ProviderHealthTracker;
|
||||||
|
import vip.mate.llm.failover.ProviderInitProbe;
|
||||||
import vip.mate.llm.model.*;
|
import vip.mate.llm.model.*;
|
||||||
import vip.mate.llm.repository.ModelProviderMapper;
|
import vip.mate.llm.repository.ModelProviderMapper;
|
||||||
|
|
||||||
@ -32,6 +35,15 @@ public class ModelProviderService {
|
|||||||
private final ApplicationEventPublisher eventPublisher;
|
private final ApplicationEventPublisher eventPublisher;
|
||||||
/** Lazy provider — avoids forcing the bean to exist in test contexts that don't load the anthropic package. */
|
/** Lazy provider — avoids forcing the bean to exist in test contexts that don't load the anthropic package. */
|
||||||
private final ObjectProvider<ClaudeCodeOAuthService> claudeCodeOAuthServiceProvider;
|
private final ObjectProvider<ClaudeCodeOAuthService> claudeCodeOAuthServiceProvider;
|
||||||
|
/** RFC-073: pool / cooldown / probe-completion signals that drive {@link Liveness}. */
|
||||||
|
private final AvailableProviderPool providerPool;
|
||||||
|
private final ProviderHealthTracker providerHealthTracker;
|
||||||
|
/**
|
||||||
|
* Lazy provider — {@link ProviderInitProbe} depends on this service, so direct injection
|
||||||
|
* would create a startup cycle. The probe always exists at runtime; the indirection only
|
||||||
|
* defers Spring's wiring decision past construction.
|
||||||
|
*/
|
||||||
|
private final ObjectProvider<ProviderInitProbe> providerInitProbeProvider;
|
||||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
|
||||||
/** Plugin-registered ChatModel instances: providerId -> ChatModel */
|
/** Plugin-registered ChatModel instances: providerId -> ChatModel */
|
||||||
@ -67,8 +79,13 @@ public class ModelProviderService {
|
|||||||
.orderByAsc(ModelProviderEntity::getName));
|
.orderByAsc(ModelProviderEntity::getName));
|
||||||
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)
|
||||||
|
// instead of N pool/tracker round-trips per render.
|
||||||
|
LivenessContext liveness = livenessContext();
|
||||||
|
|
||||||
return providers.stream().map(provider -> toProviderInfo(provider, modelsByProvider.get(provider.getProviderId()))).toList();
|
return providers.stream()
|
||||||
|
.map(provider -> toProviderInfo(provider, modelsByProvider.get(provider.getProviderId()), liveness))
|
||||||
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public ProviderInfoDTO updateProviderConfig(String providerId, ProviderConfigRequest request) {
|
public ProviderInfoDTO updateProviderConfig(String providerId, ProviderConfigRequest request) {
|
||||||
@ -229,6 +246,12 @@ public class ModelProviderService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private ProviderInfoDTO toProviderInfo(ModelProviderEntity provider, List<ModelConfigEntity> models) {
|
private ProviderInfoDTO toProviderInfo(ModelProviderEntity provider, List<ModelConfigEntity> models) {
|
||||||
|
return toProviderInfo(provider, models, livenessContext());
|
||||||
|
}
|
||||||
|
|
||||||
|
private ProviderInfoDTO toProviderInfo(ModelProviderEntity provider,
|
||||||
|
List<ModelConfigEntity> models,
|
||||||
|
LivenessContext liveness) {
|
||||||
ProviderInfoDTO dto = new ProviderInfoDTO();
|
ProviderInfoDTO dto = new ProviderInfoDTO();
|
||||||
dto.setId(provider.getProviderId());
|
dto.setId(provider.getProviderId());
|
||||||
dto.setName(provider.getName());
|
dto.setName(provider.getName());
|
||||||
@ -242,9 +265,15 @@ public class ModelProviderService {
|
|||||||
dto.setFreezeUrl(Boolean.TRUE.equals(provider.getFreezeUrl()));
|
dto.setFreezeUrl(Boolean.TRUE.equals(provider.getFreezeUrl()));
|
||||||
dto.setRequireApiKey(Boolean.TRUE.equals(provider.getRequireApiKey()));
|
dto.setRequireApiKey(Boolean.TRUE.equals(provider.getRequireApiKey()));
|
||||||
boolean configured = isProviderConfigured(provider);
|
boolean configured = isProviderConfigured(provider);
|
||||||
boolean available = configured && models != null && !models.isEmpty();
|
// RFC-073: `available` retains its boolean meaning ("usable right now") but is now
|
||||||
|
// gated on Liveness.LIVE rather than just configuration completeness, so the chat
|
||||||
|
// path and the dropdown stop disagreeing about local providers.
|
||||||
|
Liveness providerLiveness = computeLiveness(provider, configured, liveness);
|
||||||
|
boolean available = providerLiveness == Liveness.LIVE && models != null && !models.isEmpty();
|
||||||
dto.setConfigured(configured);
|
dto.setConfigured(configured);
|
||||||
dto.setAvailable(available);
|
dto.setAvailable(available);
|
||||||
|
dto.setLiveness(providerLiveness);
|
||||||
|
applyLivenessDetails(dto, provider.getProviderId(), providerLiveness, liveness);
|
||||||
dto.setApiKey(maskApiKey(provider.getApiKey()));
|
dto.setApiKey(maskApiKey(provider.getApiKey()));
|
||||||
dto.setBaseUrl(provider.getBaseUrl());
|
dto.setBaseUrl(provider.getBaseUrl());
|
||||||
dto.setGenerateKwargs(readJson(provider.getGenerateKwargs()));
|
dto.setGenerateKwargs(readJson(provider.getGenerateKwargs()));
|
||||||
@ -363,4 +392,56 @@ public class ModelProviderService {
|
|||||||
return "{}";
|
return "{}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// RFC-073: Liveness computation
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/** Take one snapshot per render-batch so {@link #toProviderInfo} stays O(1) per provider. */
|
||||||
|
private LivenessContext livenessContext() {
|
||||||
|
return new LivenessContext(providerPool.snapshot(), providerHealthTracker.snapshot(),
|
||||||
|
providerInitProbeProvider.getIfAvailable());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Liveness computeLiveness(ModelProviderEntity provider, boolean configured, LivenessContext ctx) {
|
||||||
|
if (!configured) return Liveness.UNCONFIGURED;
|
||||||
|
String id = provider.getProviderId();
|
||||||
|
// Probe absent in test contexts → fail-open to LIVE so test fixtures don't trip on UNPROBED.
|
||||||
|
if (ctx.initProbe() != null && !ctx.initProbe().hasBeenProbed(id)) {
|
||||||
|
return Liveness.UNPROBED;
|
||||||
|
}
|
||||||
|
AvailableProviderPool.RemovalReason reason = ctx.poolSnapshot().get(id);
|
||||||
|
boolean inPool = ctx.poolSnapshot().containsKey(id) && reason == null;
|
||||||
|
if (!inPool) return Liveness.REMOVED;
|
||||||
|
ProviderHealthTracker.ProviderHealthSnapshot health = ctx.healthSnapshot().get(id);
|
||||||
|
if (health != null && health.cooldownRemainingMs() > 0) return Liveness.COOLDOWN;
|
||||||
|
return Liveness.LIVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyLivenessDetails(ProviderInfoDTO dto, String providerId,
|
||||||
|
Liveness liveness, LivenessContext ctx) {
|
||||||
|
switch (liveness) {
|
||||||
|
case REMOVED -> {
|
||||||
|
AvailableProviderPool.RemovalReason reason = ctx.poolSnapshot().get(providerId);
|
||||||
|
if (reason != null) {
|
||||||
|
dto.setUnavailableReason(reason.message());
|
||||||
|
dto.setLastProbedAtMs(reason.removedAtMs());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case COOLDOWN -> {
|
||||||
|
ProviderHealthTracker.ProviderHealthSnapshot health = ctx.healthSnapshot().get(providerId);
|
||||||
|
if (health != null) {
|
||||||
|
dto.setCooldownRemainingMs(health.cooldownRemainingMs());
|
||||||
|
}
|
||||||
|
dto.setUnavailableReason("provider in cooldown after consecutive failures");
|
||||||
|
}
|
||||||
|
default -> { /* LIVE / UNPROBED / UNCONFIGURED — no extra fields */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-render snapshot of pool / cooldown / probe-completion state. */
|
||||||
|
private record LivenessContext(
|
||||||
|
Map<String, AvailableProviderPool.RemovalReason> poolSnapshot,
|
||||||
|
Map<String, ProviderHealthTracker.ProviderHealthSnapshot> healthSnapshot,
|
||||||
|
ProviderInitProbe initProbe) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -34,12 +34,24 @@
|
|||||||
<div class="model-group-header">
|
<div class="model-group-header">
|
||||||
<span class="model-group-header__name">{{ group.provider.name }}</span>
|
<span class="model-group-header__name">{{ group.provider.name }}</span>
|
||||||
<span v-if="group.provider.isLocal" class="model-group-header__badge model-group-header__badge--local">Local</span>
|
<span v-if="group.provider.isLocal" class="model-group-header__badge model-group-header__badge--local">Local</span>
|
||||||
|
<!-- RFC-073: liveness dot. UNPROBED = grey (still booting),
|
||||||
|
COOLDOWN = amber (transient backoff). LIVE has no dot. -->
|
||||||
|
<span
|
||||||
|
v-if="group.provider.liveness === 'UNPROBED'"
|
||||||
|
class="model-group-header__dot model-group-header__dot--unprobed"
|
||||||
|
:title="$t('chat.modelLivenessUnprobed')"
|
||||||
|
></span>
|
||||||
|
<span
|
||||||
|
v-else-if="group.provider.liveness === 'COOLDOWN'"
|
||||||
|
class="model-group-header__dot model-group-header__dot--cooldown"
|
||||||
|
:title="$t('chat.modelLivenessCooldown', { seconds: cooldownSeconds(group.provider) })"
|
||||||
|
></span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-for="item in group.models"
|
v-for="item in group.models"
|
||||||
:key="item.value"
|
:key="item.value"
|
||||||
class="model-dropdown-item"
|
class="model-dropdown-item"
|
||||||
:class="{ active: item.value === activeValue }"
|
:class="{ active: item.value === activeValue, dimmed: group.provider.liveness === 'COOLDOWN' || group.provider.liveness === 'UNPROBED' }"
|
||||||
@click="handleSelect(item.value)"
|
@click="handleSelect(item.value)"
|
||||||
>
|
>
|
||||||
<span class="model-dropdown-item__name">{{ item.name }}</span>
|
<span class="model-dropdown-item__name">{{ item.name }}</span>
|
||||||
@ -118,12 +130,19 @@ function toggle() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 按 provider 分组,云端在前,本地在后
|
// 按 provider 分组,云端在前,本地在后
|
||||||
|
// RFC-073: 仅过滤 UNCONFIGURED / REMOVED;UNPROBED + COOLDOWN 仍显示但视觉上区分。
|
||||||
|
function isHidden(p: ProviderInfo): boolean {
|
||||||
|
// 旧后端不返回 liveness 时退回 available 行为,避免渐进升级期间 UI 全空。
|
||||||
|
if (!p.liveness) return !p.available
|
||||||
|
return p.liveness === 'UNCONFIGURED' || p.liveness === 'REMOVED'
|
||||||
|
}
|
||||||
|
|
||||||
const groups = computed<ModelGroup[]>(() => {
|
const groups = computed<ModelGroup[]>(() => {
|
||||||
const cloud: ModelGroup[] = []
|
const cloud: ModelGroup[] = []
|
||||||
const local: ModelGroup[] = []
|
const local: ModelGroup[] = []
|
||||||
|
|
||||||
for (const provider of props.providers) {
|
for (const provider of props.providers) {
|
||||||
if (!provider.available) continue
|
if (isHidden(provider)) continue
|
||||||
const allModels = [...(provider.models || []), ...(provider.extraModels || [])]
|
const allModels = [...(provider.models || []), ...(provider.extraModels || [])]
|
||||||
if (allModels.length === 0) continue
|
if (allModels.length === 0) continue
|
||||||
|
|
||||||
@ -146,6 +165,10 @@ const groups = computed<ModelGroup[]>(() => {
|
|||||||
return [...cloud, ...local]
|
return [...cloud, ...local]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function cooldownSeconds(provider: ProviderInfo): number {
|
||||||
|
return Math.max(1, Math.ceil((provider.cooldownRemainingMs || 0) / 1000))
|
||||||
|
}
|
||||||
|
|
||||||
const totalCount = computed(() =>
|
const totalCount = computed(() =>
|
||||||
groups.value.reduce((n, g) => n + g.models.length, 0)
|
groups.value.reduce((n, g) => n + g.models.length, 0)
|
||||||
)
|
)
|
||||||
@ -329,6 +352,26 @@ watch(open, async (isOpen) => {
|
|||||||
color: var(--mc-success, #34c759);
|
color: var(--mc-success, #34c759);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* RFC-073 liveness dot — sits next to the provider name */
|
||||||
|
.model-group-header__dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
.model-group-header__dot--unprobed {
|
||||||
|
background: var(--mc-text-quaternary, #c0c4cc);
|
||||||
|
animation: model-dot-pulse 1.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.model-group-header__dot--cooldown {
|
||||||
|
background: #f59e0b;
|
||||||
|
}
|
||||||
|
@keyframes model-dot-pulse {
|
||||||
|
0%, 100% { opacity: 0.4; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- Items ---- */
|
/* ---- Items ---- */
|
||||||
|
|
||||||
.model-dropdown-item {
|
.model-dropdown-item {
|
||||||
@ -350,6 +393,14 @@ watch(open, async (isOpen) => {
|
|||||||
background: var(--mc-primary-bg);
|
background: var(--mc-primary-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* RFC-073: cooldown / unprobed models render dimmed but still selectable. */
|
||||||
|
.model-dropdown-item.dimmed {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
.model-dropdown-item.dimmed:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
.model-dropdown-item__name {
|
.model-dropdown-item__name {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--mc-text-primary);
|
color: var(--mc-text-primary);
|
||||||
|
|||||||
@ -143,6 +143,9 @@ export default {
|
|||||||
switchModelFailed: 'Failed to switch model',
|
switchModelFailed: 'Failed to switch model',
|
||||||
searchModel: 'Search models…',
|
searchModel: 'Search models…',
|
||||||
noMatchModel: 'No matching models',
|
noMatchModel: 'No matching models',
|
||||||
|
// RFC-073: liveness hints shown in the model dropdown header
|
||||||
|
modelLivenessUnprobed: 'Checking availability',
|
||||||
|
modelLivenessCooldown: 'In cooldown ({seconds}s remaining)',
|
||||||
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',
|
||||||
@ -353,6 +356,13 @@ export default {
|
|||||||
poolReprobing: 'Probing...',
|
poolReprobing: 'Probing...',
|
||||||
poolReprobeOk: 'Probe passed; back in the available pool',
|
poolReprobeOk: 'Probe passed; back in the available pool',
|
||||||
poolReprobeFail: 'Probe failed: {error}',
|
poolReprobeFail: 'Probe failed: {error}',
|
||||||
|
// RFC-073: status-pill labels driven by Liveness
|
||||||
|
livenessLive: 'Live',
|
||||||
|
livenessCooldown: 'Cooling down',
|
||||||
|
livenessRemoved: 'Disconnected',
|
||||||
|
livenessUnprobed: 'Probing',
|
||||||
|
livenessUnprobedTooltip: 'Checking availability after startup; will refresh shortly',
|
||||||
|
livenessUnconfigured: 'Needs setup',
|
||||||
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',
|
||||||
|
|||||||
@ -143,6 +143,9 @@ export default {
|
|||||||
switchModelFailed: '切换模型失败',
|
switchModelFailed: '切换模型失败',
|
||||||
searchModel: '搜索模型…',
|
searchModel: '搜索模型…',
|
||||||
noMatchModel: '没有匹配的模型',
|
noMatchModel: '没有匹配的模型',
|
||||||
|
// RFC-073: liveness hints shown in the model dropdown header
|
||||||
|
modelLivenessUnprobed: '正在检查可用性',
|
||||||
|
modelLivenessCooldown: '冷却中({seconds} 秒后自动恢复)',
|
||||||
uploadFailed: '文件上传失败',
|
uploadFailed: '文件上传失败',
|
||||||
dropToUpload: '拖放文件或文件夹到此处',
|
dropToUpload: '拖放文件或文件夹到此处',
|
||||||
copyFailed: '复制失败',
|
copyFailed: '复制失败',
|
||||||
@ -343,6 +346,13 @@ export default {
|
|||||||
poolReprobing: '检测中...',
|
poolReprobing: '检测中...',
|
||||||
poolReprobeOk: '检测通过,已重新加入可用池',
|
poolReprobeOk: '检测通过,已重新加入可用池',
|
||||||
poolReprobeFail: '检测失败:{error}',
|
poolReprobeFail: '检测失败:{error}',
|
||||||
|
// RFC-073: status-pill labels driven by Liveness
|
||||||
|
livenessLive: '可用',
|
||||||
|
livenessCooldown: '冷却中',
|
||||||
|
livenessRemoved: '未连接',
|
||||||
|
livenessUnprobed: '检测中',
|
||||||
|
livenessUnprobedTooltip: '启动后正在检测可用性,稍候自动更新',
|
||||||
|
livenessUnconfigured: '需要配置',
|
||||||
searchHint: '开启后,大模型将在回答时自动调用内置搜索引擎获取实时信息(DashScope/Kimi/OpenAI 支持)。',
|
searchHint: '开启后,大模型将在回答时自动调用内置搜索引擎获取实时信息(DashScope/Kimi/OpenAI 支持)。',
|
||||||
searchStrategyDefault: '默认',
|
searchStrategyDefault: '默认',
|
||||||
oauthTitle: 'OpenAI OAuth 登录',
|
oauthTitle: 'OpenAI OAuth 登录',
|
||||||
|
|||||||
@ -368,7 +368,7 @@ export const CHANNEL_FIELD_DEFS: Record<string, ChannelFieldDef[]> = {
|
|||||||
{ key: 'connection_mode', label: '接入模式', placeholder: '', type: 'select', defaultValue: 'stream', tooltip: 'Stream 长连接无需公网 IP(推荐);Webhook 需要公网回调地址', options: [{ label: 'Stream(长连接,推荐)', value: 'stream' }, { label: 'Webhook(HTTP 回调)', value: 'webhook' }] },
|
{ key: 'connection_mode', label: '接入模式', placeholder: '', type: 'select', defaultValue: 'stream', tooltip: 'Stream 长连接无需公网 IP(推荐);Webhook 需要公网回调地址', options: [{ label: 'Stream(长连接,推荐)', value: 'stream' }, { label: 'Webhook(HTTP 回调)', value: 'webhook' }] },
|
||||||
{ key: 'message_type', label: '消息格式', placeholder: '', type: 'select', defaultValue: 'markdown', tooltip: 'markdown: 普通消息;card: AI 流式卡片(需配置模板 ID)', options: [{ label: 'Markdown', value: 'markdown' }, { label: 'AI Card(流式卡片)', value: 'card' }] },
|
{ key: 'message_type', label: '消息格式', placeholder: '', type: 'select', defaultValue: 'markdown', tooltip: 'markdown: 普通消息;card: AI 流式卡片(需配置模板 ID)', options: [{ label: 'Markdown', value: 'markdown' }, { label: 'AI Card(流式卡片)', value: 'card' }] },
|
||||||
{ key: 'card_template_id', label: '卡片模板 ID', placeholder: 'dt_card_1234', required: true, type: 'text', tooltip: '钉钉 AI Card 模板 ID', showIf: { field: 'message_type', value: 'card' } },
|
{ key: 'card_template_id', label: '卡片模板 ID', placeholder: 'dt_card_1234', required: true, type: 'text', tooltip: '钉钉 AI Card 模板 ID', showIf: { field: 'message_type', value: 'card' } },
|
||||||
{ key: 'robot_code', label: '机器人编码', placeholder: 'dingxxxxxxxx', type: 'text', tooltip: '机器人 robot_code,群聊场景建议配置', showIf: { field: 'message_type', value: 'card' } },
|
{ key: 'robot_code', label: '机器人编码', placeholder: '留空将自动使用 AppKey(适用于自建应用机器人)', type: 'text', tooltip: '钉钉机器人 robotCode,用于发送附件(图片 / DOCX)和 AI Card。绝大多数自建应用机器人 robotCode == AppKey,不填会自动 fallback;只有第三方应用 / 单独申请的机器人才必须显式填' },
|
||||||
],
|
],
|
||||||
feishu: [
|
feishu: [
|
||||||
{ key: 'app_id', label: 'App ID', placeholder: 'cli_xxxxxxxx', required: true, type: 'text', tooltip: '飞书开放平台应用的 App ID' },
|
{ key: 'app_id', label: 'App ID', placeholder: 'cli_xxxxxxxx', required: true, type: 'text', tooltip: '飞书开放平台应用的 App ID' },
|
||||||
@ -610,6 +610,16 @@ export interface ProviderModelInfo {
|
|||||||
supportsThinking?: boolean
|
supportsThinking?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-073: combined runtime state of a provider.
|
||||||
|
* - LIVE pool member, not in cooldown — usable
|
||||||
|
* - COOLDOWN pool member, transient backoff after consecutive failures
|
||||||
|
* - REMOVED probed and HARD-removed (auth/billing/init-probe failure)
|
||||||
|
* - UNPROBED startup window, decision not made yet
|
||||||
|
* - UNCONFIGURED user hasn't supplied required credentials
|
||||||
|
*/
|
||||||
|
export type Liveness = 'LIVE' | 'COOLDOWN' | 'REMOVED' | 'UNPROBED' | 'UNCONFIGURED'
|
||||||
|
|
||||||
export interface ProviderInfo {
|
export interface ProviderInfo {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@ -634,6 +644,14 @@ export interface ProviderInfo {
|
|||||||
oauthExpiresAt?: number
|
oauthExpiresAt?: number
|
||||||
/** RFC-009 P3.5: position in the multi-model failover chain (0 = excluded). */
|
/** RFC-009 P3.5: position in the multi-model failover chain (0 = excluded). */
|
||||||
fallbackPriority?: number
|
fallbackPriority?: number
|
||||||
|
/** RFC-073: runtime state — UI source of truth for whether this provider is usable now. */
|
||||||
|
liveness?: Liveness
|
||||||
|
/** Populated only when liveness ∈ {REMOVED, COOLDOWN}. */
|
||||||
|
unavailableReason?: string
|
||||||
|
/** Epoch ms of the most recent removal, populated only when liveness == REMOVED. */
|
||||||
|
lastProbedAtMs?: number
|
||||||
|
/** Remaining cooldown window in ms, populated only when liveness == COOLDOWN. */
|
||||||
|
cooldownRemainingMs?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActiveModelsInfo {
|
export interface ActiveModelsInfo {
|
||||||
|
|||||||
@ -27,37 +27,42 @@
|
|||||||
>
|
>
|
||||||
{{ t('settings.model.fallbackBadge', { priority: provider.fallbackPriority }) }}
|
{{ t('settings.model.fallbackBadge', { priority: provider.fallbackPriority }) }}
|
||||||
</span>
|
</span>
|
||||||
<!-- RFC-009 Phase 4: pool status. Three mutually-exclusive states:
|
<!-- RFC-073: liveness badge. Single source of truth replacing the old
|
||||||
removed > cooldown > in-pool. Hidden when no pool data is loaded yet
|
configured / pool-entry combo. UNCONFIGURED renders no badge — the
|
||||||
or when the provider isn't configured (pool would never have probed it). -->
|
status pill on the right already says "needs configuration". -->
|
||||||
<template v-if="poolEntry && provider.configured">
|
<span
|
||||||
<span
|
v-if="provider.liveness === 'LIVE'"
|
||||||
v-if="!poolEntry.inPool"
|
class="provider-badge pool-in"
|
||||||
class="provider-badge pool-removed"
|
:title="t('settings.model.poolBadgeInPoolTitle')"
|
||||||
:title="t('settings.model.poolBadgeRemovedTitle', {
|
>
|
||||||
source: poolSourceLabel(poolEntry.removalSource),
|
{{ t('settings.model.poolBadgeInPool') }}
|
||||||
message: poolEntry.removalMessage || '—'
|
</span>
|
||||||
})"
|
<span
|
||||||
>
|
v-else-if="provider.liveness === 'COOLDOWN'"
|
||||||
{{ t('settings.model.poolBadgeRemoved') }}
|
class="provider-badge pool-cooldown"
|
||||||
</span>
|
:title="t('settings.model.poolBadgeCooldownTitle', {
|
||||||
<span
|
seconds: Math.max(1, Math.ceil((provider.cooldownRemainingMs || 0) / 1000))
|
||||||
v-else-if="poolEntry.inCooldown"
|
})"
|
||||||
class="provider-badge pool-cooldown"
|
>
|
||||||
:title="t('settings.model.poolBadgeCooldownTitle', {
|
{{ t('settings.model.poolBadgeCooldown') }}
|
||||||
seconds: Math.ceil(poolEntry.cooldownRemainingMs / 1000)
|
</span>
|
||||||
})"
|
<span
|
||||||
>
|
v-else-if="provider.liveness === 'REMOVED'"
|
||||||
{{ t('settings.model.poolBadgeCooldown') }}
|
class="provider-badge pool-removed"
|
||||||
</span>
|
:title="t('settings.model.poolBadgeRemovedTitle', {
|
||||||
<span
|
source: t('settings.model.poolSourceInitProbe'),
|
||||||
v-else
|
message: provider.unavailableReason || '—'
|
||||||
class="provider-badge pool-in"
|
})"
|
||||||
:title="t('settings.model.poolBadgeInPoolTitle')"
|
>
|
||||||
>
|
{{ t('settings.model.poolBadgeRemoved') }}
|
||||||
{{ t('settings.model.poolBadgeInPool') }}
|
</span>
|
||||||
</span>
|
<span
|
||||||
</template>
|
v-else-if="provider.liveness === 'UNPROBED'"
|
||||||
|
class="provider-badge pool-unprobed"
|
||||||
|
:title="t('settings.model.livenessUnprobedTooltip')"
|
||||||
|
>
|
||||||
|
{{ t('settings.model.livenessUnprobed') }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="provider-id">{{ provider.id }}</p>
|
<p class="provider-id">{{ provider.id }}</p>
|
||||||
</div>
|
</div>
|
||||||
@ -115,10 +120,11 @@
|
|||||||
>
|
>
|
||||||
{{ t('common.delete') }}
|
{{ t('common.delete') }}
|
||||||
</button>
|
</button>
|
||||||
<!-- RFC-009 Phase 4: manual reprobe — visible when the provider has been
|
<!-- RFC-073: manual reprobe — visible when the provider was HARD-removed,
|
||||||
HARD-removed from the pool, lets the user recover without restart. -->
|
lets the user recover without restart. Also useful in COOLDOWN to
|
||||||
|
short-circuit the wait. -->
|
||||||
<button
|
<button
|
||||||
v-if="poolEntry && !poolEntry.inPool && provider.configured"
|
v-if="provider.liveness === 'REMOVED' || provider.liveness === 'COOLDOWN'"
|
||||||
class="card-btn"
|
class="card-btn"
|
||||||
:class="{ testing: reprobing }"
|
:class="{ testing: reprobing }"
|
||||||
:disabled="reprobing"
|
:disabled="reprobing"
|
||||||
@ -142,15 +148,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import type { ProviderInfo } from '@/types'
|
import type { ProviderInfo } from '@/types'
|
||||||
import type { ProviderPoolEntry } from '@/api'
|
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
provider: ProviderInfo
|
provider: ProviderInfo
|
||||||
connectionTestingId: string | null
|
connectionTestingId: string | null
|
||||||
connectionResults: Record<string, any>
|
connectionResults: Record<string, any>
|
||||||
// RFC-009 Phase 4: pool status for this provider; null when pool API hasn't loaded yet.
|
// RFC-073: true while a manual reprobe is in flight for this provider.
|
||||||
poolEntry?: ProviderPoolEntry | null
|
|
||||||
// RFC-009 Phase 4: true while a manual reprobe is in flight for this provider.
|
|
||||||
reprobing?: boolean
|
reprobing?: boolean
|
||||||
isProviderActive: (provider: ProviderInfo) => boolean
|
isProviderActive: (provider: ProviderInfo) => boolean
|
||||||
providerStatus: (provider: ProviderInfo) => { type: string; label: string }
|
providerStatus: (provider: ProviderInfo) => { type: string; label: string }
|
||||||
@ -167,18 +170,6 @@ defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
/** Translate the backend RemovalSource enum into a human-readable label. */
|
|
||||||
function poolSourceLabel(source: string | null): string {
|
|
||||||
switch (source) {
|
|
||||||
case 'AUTH_ERROR': return t('settings.model.poolSourceAuthError')
|
|
||||||
case 'BILLING': return t('settings.model.poolSourceBilling')
|
|
||||||
case 'MODEL_NOT_FOUND': return t('settings.model.poolSourceModelNotFound')
|
|
||||||
case 'INIT_PROBE': return t('settings.model.poolSourceInitProbe')
|
|
||||||
case 'MANUAL': return t('settings.model.poolSourceManual')
|
|
||||||
default: return source || '—'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@ -230,6 +221,14 @@ function poolSourceLabel(source: string | null): string {
|
|||||||
.provider-badge.pool-in { background: rgba(34, 197, 94, 0.12); color: #16a34a; cursor: help; }
|
.provider-badge.pool-in { background: rgba(34, 197, 94, 0.12); color: #16a34a; cursor: help; }
|
||||||
.provider-badge.pool-cooldown { background: rgba(245, 158, 11, 0.14); color: #b45309; cursor: help; }
|
.provider-badge.pool-cooldown { background: rgba(245, 158, 11, 0.14); color: #b45309; cursor: help; }
|
||||||
.provider-badge.pool-removed { background: rgba(239, 68, 68, 0.14); color: #dc2626; cursor: help; }
|
.provider-badge.pool-removed { background: rgba(239, 68, 68, 0.14); color: #dc2626; cursor: help; }
|
||||||
|
/* RFC-073: UNPROBED — neutral grey with a gentle pulse so it's clearly transient. */
|
||||||
|
.provider-badge.pool-unprobed {
|
||||||
|
background: rgba(156, 163, 175, 0.16);
|
||||||
|
color: var(--mc-text-tertiary, #6b7280);
|
||||||
|
cursor: help;
|
||||||
|
animation: mc-card-dot-pulse 1.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes mc-card-dot-pulse { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } }
|
||||||
.provider-status { flex-shrink: 0; padding: 4px 10px; border-radius: 999px; font-size: 12px; font-weight: 700; }
|
.provider-status { flex-shrink: 0; padding: 4px 10px; border-radius: 999px; font-size: 12px; font-weight: 700; }
|
||||||
.provider-status.configured { background: var(--mc-primary-bg); color: var(--mc-primary); }
|
.provider-status.configured { background: var(--mc-primary-bg); color: var(--mc-primary); }
|
||||||
.provider-status.partial { background: var(--mc-primary-bg); color: var(--mc-primary-hover); }
|
.provider-status.partial { background: var(--mc-primary-bg); color: var(--mc-primary-hover); }
|
||||||
|
|||||||
@ -24,7 +24,6 @@
|
|||||||
:provider="provider"
|
:provider="provider"
|
||||||
:connection-testing-id="connectionTestingId"
|
:connection-testing-id="connectionTestingId"
|
||||||
:connection-results="connectionResults"
|
:connection-results="connectionResults"
|
||||||
:pool-entry="providerPool[provider.id] || null"
|
|
||||||
:reprobing="reprobingId === provider.id"
|
:reprobing="reprobingId === provider.id"
|
||||||
:is-provider-active="isProviderActive"
|
:is-provider-active="isProviderActive"
|
||||||
:provider-status="providerStatus"
|
:provider-status="providerStatus"
|
||||||
@ -54,7 +53,6 @@
|
|||||||
:provider="provider"
|
:provider="provider"
|
||||||
:connection-testing-id="connectionTestingId"
|
:connection-testing-id="connectionTestingId"
|
||||||
:connection-results="connectionResults"
|
:connection-results="connectionResults"
|
||||||
:pool-entry="providerPool[provider.id] || null"
|
|
||||||
:reprobing="reprobingId === provider.id"
|
:reprobing="reprobingId === provider.id"
|
||||||
:is-provider-active="isProviderActive"
|
:is-provider-active="isProviderActive"
|
||||||
:provider-status="providerStatus"
|
:provider-status="providerStatus"
|
||||||
@ -157,9 +155,7 @@ const {
|
|||||||
providerBaseUrlPlaceholder,
|
providerBaseUrlPlaceholder,
|
||||||
providerBaseUrlHint,
|
providerBaseUrlHint,
|
||||||
providerApiKeyPlaceholder,
|
providerApiKeyPlaceholder,
|
||||||
providerPool,
|
|
||||||
reprobingId,
|
reprobingId,
|
||||||
loadProviderPool,
|
|
||||||
reprobeProvider,
|
reprobeProvider,
|
||||||
loadProviders,
|
loadProviders,
|
||||||
loadActiveModel,
|
loadActiveModel,
|
||||||
@ -192,7 +188,7 @@ 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))
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([loadProviders(), loadActiveModel(), loadProviderPool()])
|
await Promise.all([loadProviders(), loadActiveModel()])
|
||||||
})
|
})
|
||||||
|
|
||||||
async function onSaveProvider() {
|
async function onSaveProvider() {
|
||||||
|
|||||||
@ -2,7 +2,6 @@ import { computed, reactive, ref } from 'vue'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { claudeCodeOAuthApi, modelApi, oauthApi, providerPoolApi } from '@/api'
|
import { claudeCodeOAuthApi, modelApi, oauthApi, providerPoolApi } from '@/api'
|
||||||
import type { ProviderPoolEntry } from '@/api'
|
|
||||||
import type { ActiveModelsInfo, DiscoverResult, ProviderInfo, ProviderModelInfo, TestResult } from '@/types'
|
import type { ActiveModelsInfo, DiscoverResult, ProviderInfo, ProviderModelInfo, TestResult } from '@/types'
|
||||||
|
|
||||||
export function useProviders() {
|
export function useProviders() {
|
||||||
@ -10,9 +9,10 @@ export function useProviders() {
|
|||||||
|
|
||||||
const providers = ref<ProviderInfo[]>([])
|
const providers = ref<ProviderInfo[]>([])
|
||||||
const activeModels = ref<ActiveModelsInfo | null>(null)
|
const activeModels = ref<ActiveModelsInfo | null>(null)
|
||||||
// RFC-009 Phase 4: pool snapshot, indexed by providerId for O(1) lookup in templates.
|
// RFC-073: pool / cooldown / probe state now ships inline on each ProviderInfo
|
||||||
const providerPool = ref<Record<string, ProviderPoolEntry>>({})
|
// via `liveness`. Separate snapshot / indexed map removed — see ProviderCard.vue
|
||||||
// RFC-009 Phase 4 PR-1e: providerId currently being manually reprobed.
|
// for how the five states render.
|
||||||
|
// PR-1e manual reprobe still tracks which provider is in flight.
|
||||||
const reprobingId = ref<string | null>(null)
|
const reprobingId = ref<string | null>(null)
|
||||||
const editingProvider = ref<ProviderInfo | null>(null)
|
const editingProvider = ref<ProviderInfo | null>(null)
|
||||||
const currentProvider = ref<ProviderInfo | null>(null)
|
const currentProvider = ref<ProviderInfo | null>(null)
|
||||||
@ -70,35 +70,16 @@ export function useProviders() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RFC-009 Phase 4: fetch the pool snapshot. Best-effort — if it 404s
|
* RFC-073: synchronously re-probe one provider, then re-fetch the
|
||||||
* (older backend) or errors, the badges just don't render. Don't block
|
* provider list — `liveness` ships inline now so a single round-trip
|
||||||
* the rest of the model settings page on it.
|
* refreshes everything the UI needs.
|
||||||
*/
|
|
||||||
async function loadProviderPool() {
|
|
||||||
try {
|
|
||||||
const res: any = await providerPoolApi.snapshot()
|
|
||||||
const list: ProviderPoolEntry[] = res.data || []
|
|
||||||
providerPool.value = list.reduce((acc, entry) => {
|
|
||||||
acc[entry.providerId] = entry
|
|
||||||
return acc
|
|
||||||
}, {} as Record<string, ProviderPoolEntry>)
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('[ProviderPool] snapshot failed (badges will be hidden)', err)
|
|
||||||
providerPool.value = {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* RFC-009 Phase 4 PR-1e: synchronously re-probe one provider, then
|
|
||||||
* refresh the pool snapshot so the badge updates. Returns the result
|
|
||||||
* so callers can show a toast.
|
|
||||||
*/
|
*/
|
||||||
async function reprobeProvider(provider: ProviderInfo) {
|
async function reprobeProvider(provider: ProviderInfo) {
|
||||||
reprobingId.value = provider.id
|
reprobingId.value = provider.id
|
||||||
try {
|
try {
|
||||||
const res: any = await providerPoolApi.reprobe(provider.id)
|
const res: any = await providerPoolApi.reprobe(provider.id)
|
||||||
const data = res.data || {}
|
const data = res.data || {}
|
||||||
await loadProviderPool()
|
await loadProviders()
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
ElMessage.success(t('settings.model.poolReprobeOk'))
|
ElMessage.success(t('settings.model.poolReprobeOk'))
|
||||||
} else {
|
} else {
|
||||||
@ -411,11 +392,23 @@ export function useProviders() {
|
|||||||
: t('settings.model.apiKeyInput')
|
: t('settings.model.apiKeyInput')
|
||||||
})
|
})
|
||||||
|
|
||||||
// Utility functions
|
// RFC-073: status pill is driven by liveness. Falls back to the legacy
|
||||||
|
// configured/available booleans for older backends that don't yet send liveness.
|
||||||
function providerStatus(provider: ProviderInfo) {
|
function providerStatus(provider: ProviderInfo) {
|
||||||
if (provider.available) {
|
switch (provider.liveness) {
|
||||||
return { type: 'configured', label: t('settings.model.configured') }
|
case 'LIVE':
|
||||||
|
return { type: 'configured', label: t('settings.model.livenessLive') }
|
||||||
|
case 'COOLDOWN':
|
||||||
|
return { type: 'partial', label: t('settings.model.livenessCooldown') }
|
||||||
|
case 'REMOVED':
|
||||||
|
return { type: 'unavailable', label: t('settings.model.livenessRemoved') }
|
||||||
|
case 'UNPROBED':
|
||||||
|
return { type: 'partial', label: t('settings.model.livenessUnprobed') }
|
||||||
|
case 'UNCONFIGURED':
|
||||||
|
return { type: 'unavailable', label: t('settings.model.livenessUnconfigured') }
|
||||||
}
|
}
|
||||||
|
// Legacy backend (no liveness field)
|
||||||
|
if (provider.available) return { type: 'configured', label: t('settings.model.configured') }
|
||||||
if (provider.configured || (provider.models?.length || 0) + (provider.extraModels?.length || 0) > 0) {
|
if (provider.configured || (provider.models?.length || 0) + (provider.extraModels?.length || 0) > 0) {
|
||||||
return { type: 'partial', label: t('settings.model.partial') }
|
return { type: 'partial', label: t('settings.model.partial') }
|
||||||
}
|
}
|
||||||
@ -553,10 +546,9 @@ export function useProviders() {
|
|||||||
providerBaseUrlPlaceholder,
|
providerBaseUrlPlaceholder,
|
||||||
providerBaseUrlHint,
|
providerBaseUrlHint,
|
||||||
providerApiKeyPlaceholder,
|
providerApiKeyPlaceholder,
|
||||||
// RFC-009 Phase 4
|
// RFC-073: pool/cooldown/probe data is now baked into providers[].liveness.
|
||||||
providerPool,
|
// The standalone snapshot/loadProviderPool are gone — saves a round trip.
|
||||||
reprobingId,
|
reprobingId,
|
||||||
loadProviderPool,
|
|
||||||
reprobeProvider,
|
reprobeProvider,
|
||||||
// Methods
|
// Methods
|
||||||
loadProviders,
|
loadProviders,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user