diff --git a/mateclaw-server/src/main/java/vip/mate/llm/failover/ProviderRequirements.java b/mateclaw-server/src/main/java/vip/mate/llm/failover/ProviderRequirements.java
new file mode 100644
index 00000000..58790110
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/llm/failover/ProviderRequirements.java
@@ -0,0 +1,90 @@
+package vip.mate.llm.failover;
+
+import vip.mate.llm.model.ModelProviderEntity;
+
+import java.util.Map;
+
+/**
+ * Decide which fields a {@link ModelProviderEntity} needs to be considered
+ * "configured", based on the row's columns rather than its protocol enum.
+ *
+ *
Why row-based: every OpenAI-compatible provider (OpenAI / Kimi / DeepSeek
+ * cloud as well as llama.cpp / lmstudio / vllm / ollama local) shares the
+ * single {@code OPENAI_COMPATIBLE} protocol value. A protocol-keyed lookup
+ * cannot distinguish "cloud, needs api key" from "local, needs base url".
+ * The discriminating signals all live on the row: {@code requireApiKey},
+ * {@code isLocal}, {@code isCustom}, {@code authType}, {@code providerId}.
+ *
+ *
Hint key + args (no raw text) so the frontend renders via i18n
+ * {@code t(key, args)} without leaking Chinese into the English locale.
+ */
+public final class ProviderRequirements {
+
+ /** What this provider row needs to be considered configured. */
+ public record Required(
+ boolean needsApiKey,
+ boolean needsBaseUrl,
+ String hintKey, // i18n key, null when no hint applies
+ Map hintArgs // template params for vue-i18n; never raw text
+ ) {}
+
+ private static final Required NONE = new Required(false, false, null, Map.of());
+
+ private ProviderRequirements() {}
+
+ /**
+ * Compute the required-fields verdict for a provider row.
+ *
+ * Decision tree:
+ * - authType == "oauth" -> no api key, no base url (OAuth handled elsewhere)
+ * - requireApiKey == true -> needs api key
+ * - isLocal || isCustom -> needs base url (no sane SDK default)
+ * - cloud built-ins -> SDK ships hard-coded base url; no base url needed
+ *
+ * Hint key picked from a small providerId-substring map for the most common
+ * local providers; everything else falls back to a generic OpenAI-compatible
+ * hint so the user always sees an actionable example URL.
+ */
+ public static Required of(ModelProviderEntity provider) {
+ if (provider == null) return NONE;
+
+ // OAuth providers store credentials elsewhere (DB column or disk). Neither
+ // api key nor base url applies to the configured check.
+ if ("oauth".equals(provider.getAuthType())) {
+ return NONE;
+ }
+
+ boolean needsApiKey = Boolean.TRUE.equals(provider.getRequireApiKey());
+ boolean isLocal = Boolean.TRUE.equals(provider.getIsLocal());
+ boolean isCustom = Boolean.TRUE.equals(provider.getIsCustom());
+ boolean needsBaseUrl = isLocal || isCustom;
+
+ if (!needsBaseUrl) {
+ return new Required(needsApiKey, false, null, Map.of());
+ }
+
+ // Pick a hint by providerId substring. Order matters: more specific names
+ // first so "lm-studio" doesn't accidentally match a generic prefix later.
+ String pid = provider.getProviderId() == null ? "" : provider.getProviderId().toLowerCase();
+ String hintKey;
+ Map hintArgs;
+ if (pid.contains("ollama")) {
+ hintKey = "provider.hint.ollamaBaseUrlExample";
+ hintArgs = Map.of("example", "http://127.0.0.1:11434");
+ } else if (pid.contains("lmstudio") || pid.contains("lm-studio") || pid.contains("lm_studio")) {
+ hintKey = "provider.hint.lmstudioBaseUrlExample";
+ hintArgs = Map.of("example", "http://127.0.0.1:1234/v1");
+ } else if (pid.contains("llamacpp") || pid.contains("llama-cpp") || pid.contains("llama_cpp")
+ || pid.contains("llama.cpp")) {
+ hintKey = "provider.hint.llamacppBaseUrlExample";
+ hintArgs = Map.of("example", "http://127.0.0.1:8080/v1");
+ } else if (pid.contains("vllm")) {
+ hintKey = "provider.hint.vllmBaseUrlExample";
+ hintArgs = Map.of("example", "http://127.0.0.1:8000/v1");
+ } else {
+ hintKey = "provider.hint.openaiCompatBaseUrlExample";
+ hintArgs = Map.of("example", "http://127.0.0.1:8080/v1");
+ }
+ return new Required(needsApiKey, true, hintKey, hintArgs);
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/llm/model/ProviderInfoDTO.java b/mateclaw-server/src/main/java/vip/mate/llm/model/ProviderInfoDTO.java
index c5cfd317..1fac6e52 100644
--- a/mateclaw-server/src/main/java/vip/mate/llm/model/ProviderInfoDTO.java
+++ b/mateclaw-server/src/main/java/vip/mate/llm/model/ProviderInfoDTO.java
@@ -3,6 +3,7 @@ package vip.mate.llm.model;
import lombok.Data;
import java.util.ArrayList;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -41,4 +42,35 @@ public class ProviderInfoDTO {
private Long cooldownRemainingMs;
/** RFC-074: whether the user has explicitly enabled this provider. False = lives in the catalog drawer only. */
private Boolean enabled;
+
+ // Issue #81: derived fields powering the chat-console liveness-aware popup.
+ // All six are computed from existing columns; none are persisted.
+
+ /** Credential status: CONFIGURED / MISSING / NOT_REQUIRED / OAUTH_PENDING. */
+ private String authStatus;
+
+ /** Base URL completeness: null when not applicable; true/false when applicable. */
+ private Boolean baseUrlComplete;
+
+ /** Comma-joined missing field names ("apiKey", "baseUrl"); empty when nothing missing. */
+ private String missingFields;
+
+ /**
+ * Machine-readable next-step key. Switches the chat popup's primary button text + handler.
+ * Values: fill_base_url / fill_api_key / start_oauth / configure_required_fields /
+ * test_connection / pull_model / wait_cooldown / reprobe / none.
+ */
+ private String suggestedAction;
+
+ /**
+ * i18n key for an actionable hint (e.g. "provider.hint.llamacppBaseUrlExample").
+ * Frontend renders via t(key, args). Null when no hint applies.
+ */
+ private String suggestedActionHintKey;
+
+ /**
+ * Template parameters for {@link #suggestedActionHintKey}. Frontend passes
+ * this directly to vue-i18n. Empty map means no parameters.
+ */
+ private Map suggestedActionHintArgs = new LinkedHashMap<>();
}
diff --git a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelProviderService.java b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelProviderService.java
index f7d2cade..b3f2b999 100644
--- a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelProviderService.java
+++ b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelProviderService.java
@@ -14,6 +14,7 @@ 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.failover.ProviderRequirements;
import vip.mate.llm.model.*;
import vip.mate.llm.repository.ModelProviderMapper;
@@ -240,12 +241,22 @@ public class ModelProviderService {
public String getProviderUnavailableReason(String providerId) {
ModelProviderEntity provider = getProvider(providerId);
if (!isProviderConfigured(provider)) {
- if (Boolean.TRUE.equals(provider.getRequireApiKey())) {
- return "Provider 未配置有效的 API Key";
+ // Issue #81: emit a precise reason based on which row-level fields are
+ // missing, rather than the previous protocol-blind heuristic. The new
+ // frontend reads suggestedActionHintKey/Args; this string remains for
+ // logs and legacy callers.
+ ProviderRequirements.Required req = ProviderRequirements.of(provider);
+ boolean hasBaseUrl = StringUtils.hasText(provider.getBaseUrl());
+ boolean hasApiKey = hasUsableApiKey(provider.getApiKey());
+ if (req.needsBaseUrl() && !hasBaseUrl && req.needsApiKey() && !hasApiKey) {
+ return "Provider 未配置 Base URL 和 API Key";
}
- if (Boolean.TRUE.equals(provider.getIsCustom()) || !Boolean.TRUE.equals(provider.getIsLocal())) {
+ if (req.needsBaseUrl() && !hasBaseUrl) {
return "Provider 未配置 Base URL";
}
+ if (req.needsApiKey() && !hasApiKey) {
+ return "Provider 未配置 API Key";
+ }
return "Provider 未完成配置";
}
if (!hasModels(providerId)) {
@@ -416,9 +427,85 @@ public class ModelProviderService {
}
dto.setModels(builtinModels);
dto.setExtraModels(extraModels);
+ applySuggestedAction(dto, provider, providerLiveness);
return dto;
}
+ /**
+ * Issue #81: derive the chat-popup recovery hint from row + liveness, so the
+ * frontend can render a precise "next step" instead of a generic
+ * "model unavailable" toast. Six fields populated:
+ * - authStatus: CONFIGURED / MISSING / NOT_REQUIRED / OAUTH_PENDING
+ * - baseUrlComplete: null when not applicable, true/false otherwise
+ * - missingFields: comma-joined ("apiKey", "baseUrl") for required-field UX
+ * - suggestedAction: machine-readable next-step key (frontend switches on this)
+ * - suggestedActionHintKey + suggestedActionHintArgs: i18n key/args, no raw text
+ */
+ private void applySuggestedAction(ProviderInfoDTO dto, ModelProviderEntity provider, Liveness liveness) {
+ ProviderRequirements.Required req = ProviderRequirements.of(provider);
+ boolean hasBaseUrl = StringUtils.hasText(provider.getBaseUrl());
+ boolean hasApiKey = hasUsableApiKey(provider.getApiKey());
+ boolean hasModels = (dto.getModels() != null && !dto.getModels().isEmpty())
+ || (dto.getExtraModels() != null && !dto.getExtraModels().isEmpty());
+
+ // 1. authStatus
+ if ("oauth".equals(provider.getAuthType())) {
+ dto.setAuthStatus(Boolean.TRUE.equals(dto.getOauthConnected()) ? "CONFIGURED" : "OAUTH_PENDING");
+ } else if (req.needsApiKey()) {
+ dto.setAuthStatus(hasApiKey ? "CONFIGURED" : "MISSING");
+ } else {
+ dto.setAuthStatus("NOT_REQUIRED");
+ }
+
+ // 2. baseUrlComplete: null when this provider doesn't need a base URL.
+ dto.setBaseUrlComplete(req.needsBaseUrl() ? hasBaseUrl : null);
+
+ // 3. missingFields
+ java.util.List missing = new ArrayList<>();
+ if (req.needsApiKey() && !hasApiKey) missing.add("apiKey");
+ if (req.needsBaseUrl() && !hasBaseUrl) missing.add("baseUrl");
+ dto.setMissingFields(String.join(",", missing));
+
+ // 4. suggestedAction
+ String action;
+ if (liveness == Liveness.UNCONFIGURED) {
+ if ("oauth".equals(provider.getAuthType())) {
+ action = "start_oauth";
+ } else if (missing.size() == 1 && missing.get(0).equals("baseUrl")) {
+ action = "fill_base_url";
+ } else if (missing.size() == 1 && missing.get(0).equals("apiKey")) {
+ action = "fill_api_key";
+ } else {
+ action = "configure_required_fields";
+ }
+ } else if (liveness == Liveness.REMOVED) {
+ action = "reprobe";
+ } else if (liveness == Liveness.COOLDOWN) {
+ action = "wait_cooldown";
+ } else if (liveness == Liveness.UNPROBED) {
+ action = "reprobe";
+ } else if (liveness == Liveness.LIVE && !hasModels) {
+ action = Boolean.TRUE.equals(provider.getSupportModelDiscovery())
+ ? "pull_model"
+ : "configure_required_fields";
+ } else {
+ action = "none";
+ }
+ dto.setSuggestedAction(action);
+
+ // 5. hint key + args (NOT raw text). Frontend renders via t(key, args).
+ // Only emit hint when it actually applies to the action; suppress for
+ // REMOVED / COOLDOWN / UNPROBED to keep the popup clean.
+ if ("fill_base_url".equals(action) || "configure_required_fields".equals(action)) {
+ dto.setSuggestedActionHintKey(req.hintKey());
+ dto.setSuggestedActionHintArgs(req.hintArgs() == null ? new java.util.LinkedHashMap<>()
+ : new java.util.LinkedHashMap<>(req.hintArgs()));
+ } else {
+ dto.setSuggestedActionHintKey(null);
+ dto.setSuggestedActionHintArgs(new java.util.LinkedHashMap<>());
+ }
+ }
+
private boolean hasModels(String providerId) {
return !modelConfigService.listModelsByProvider(providerId).isEmpty();
}
@@ -427,13 +514,11 @@ public class ModelProviderService {
if (provider == null) {
return false;
}
- if (Boolean.TRUE.equals(provider.getIsLocal())) {
- return true;
- }
- // OAuth 认证的 provider:检查 OAuth token 是否存在
+ // OAuth providers store credentials elsewhere (DB column or disk for
+ // Claude Code). Resolve them via the OAuth service rather than the
+ // base-URL / api-key columns.
if ("oauth".equals(provider.getAuthType())) {
- // Claude Code OAuth (RFC-062) — token lives on disk, not in DB.
if (CLAUDE_CODE_PROVIDER_ID.equals(provider.getProviderId())) {
ClaudeCodeOAuthService svc = claudeCodeOAuthServiceProvider.getIfAvailable();
return svc != null && svc.isLoggedIn();
@@ -441,16 +526,21 @@ public class ModelProviderService {
return StringUtils.hasText(provider.getOauthAccessToken());
}
- boolean hasBaseUrl = StringUtils.hasText(provider.getBaseUrl());
- boolean hasApiKey = hasUsableApiKey(provider.getApiKey());
-
- if (Boolean.TRUE.equals(provider.getIsCustom())) {
- return hasBaseUrl && (!Boolean.TRUE.equals(provider.getRequireApiKey()) || hasApiKey);
+ // Issue #81: decide required fields from the provider row, not from the
+ // protocol enum. Every OpenAI-compatible provider (cloud or local) shares
+ // OPENAI_COMPATIBLE, so a protocol-keyed table cannot tell OpenAI cloud
+ // (needs api_key, no base url) apart from llama.cpp local (no api_key,
+ // needs base url). Without this, isLocal=true short-circuited to true
+ // for llama.cpp regardless of an empty Base URL, hiding the real cause
+ // behind a confusing REMOVED state.
+ ProviderRequirements.Required req = ProviderRequirements.of(provider);
+ if (req.needsApiKey() && !hasUsableApiKey(provider.getApiKey())) {
+ return false;
}
- if (Boolean.FALSE.equals(provider.getRequireApiKey())) {
- return hasBaseUrl;
+ if (req.needsBaseUrl() && !StringUtils.hasText(provider.getBaseUrl())) {
+ return false;
}
- return hasApiKey;
+ return true;
}
public boolean hasUsableApiKey(String apiKey) {
diff --git a/mateclaw-ui/src/components/chat/ModelSelector.vue b/mateclaw-ui/src/components/chat/ModelSelector.vue
index 8bd869a1..b31bd5aa 100644
--- a/mateclaw-ui/src/components/chat/ModelSelector.vue
+++ b/mateclaw-ui/src/components/chat/ModelSelector.vue
@@ -34,25 +34,47 @@