From d5969bb32c1274d45c8e681f336df9cd4ee584e2 Mon Sep 17 00:00:00 2001 From: matevip Date: Sat, 9 May 2026 11:07:08 +0800 Subject: [PATCH] fix(chat,llm): liveness-aware model popup with row-based configured check (#81) --- .../llm/failover/ProviderRequirements.java | 90 ++++++ .../vip/mate/llm/model/ProviderInfoDTO.java | 32 +++ .../llm/service/ModelProviderService.java | 122 ++++++-- .../src/components/chat/ModelSelector.vue | 135 ++++++++- .../chat/RecoverableModelBanner.vue | 78 +++++ mateclaw-ui/src/i18n/locales/en-US.ts | 59 ++++ mateclaw-ui/src/i18n/locales/zh-CN.ts | 59 ++++ mateclaw-ui/src/types/index.ts | 18 ++ mateclaw-ui/src/views/ChatConsole.vue | 266 +++++++++++++++--- 9 files changed, 787 insertions(+), 72 deletions(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/llm/failover/ProviderRequirements.java create mode 100644 mateclaw-ui/src/components/chat/RecoverableModelBanner.vue 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 @@

{{ group.provider.name }} Local - + + {{ $t('provider.status.unconfigured') }} + {{ $t('provider.status.removed') }} + {{ $t('provider.status.cooldown', { s: cooldownSeconds(group.provider) }) }} +
{{ item.name }} @@ -66,7 +88,7 @@ state. Push them into Settings/Models with the drawer pre-opened via ?addProvider=1. -->