mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-16 04:18:17 +08:00
fix(chat,llm): liveness-aware model popup with row-based configured check (#81)
This commit is contained in:
parent
95b7fed6d5
commit
d5969bb32c
@ -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.
|
||||||
|
*
|
||||||
|
* <p>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}.
|
||||||
|
*
|
||||||
|
* <p>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<String, Object> 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<String, Object> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@ package vip.mate.llm.model;
|
|||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@ -41,4 +42,35 @@ public class ProviderInfoDTO {
|
|||||||
private Long cooldownRemainingMs;
|
private Long cooldownRemainingMs;
|
||||||
/** RFC-074: whether the user has explicitly enabled this provider. False = lives in the catalog drawer only. */
|
/** RFC-074: whether the user has explicitly enabled this provider. False = lives in the catalog drawer only. */
|
||||||
private Boolean enabled;
|
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<String, Object> suggestedActionHintArgs = new LinkedHashMap<>();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import vip.mate.llm.event.ModelConfigChangedEvent;
|
|||||||
import vip.mate.llm.failover.AvailableProviderPool;
|
import vip.mate.llm.failover.AvailableProviderPool;
|
||||||
import vip.mate.llm.failover.ProviderHealthTracker;
|
import vip.mate.llm.failover.ProviderHealthTracker;
|
||||||
import vip.mate.llm.failover.ProviderInitProbe;
|
import vip.mate.llm.failover.ProviderInitProbe;
|
||||||
|
import vip.mate.llm.failover.ProviderRequirements;
|
||||||
import vip.mate.llm.model.*;
|
import vip.mate.llm.model.*;
|
||||||
import vip.mate.llm.repository.ModelProviderMapper;
|
import vip.mate.llm.repository.ModelProviderMapper;
|
||||||
|
|
||||||
@ -240,12 +241,22 @@ public class ModelProviderService {
|
|||||||
public String getProviderUnavailableReason(String providerId) {
|
public String getProviderUnavailableReason(String providerId) {
|
||||||
ModelProviderEntity provider = getProvider(providerId);
|
ModelProviderEntity provider = getProvider(providerId);
|
||||||
if (!isProviderConfigured(provider)) {
|
if (!isProviderConfigured(provider)) {
|
||||||
if (Boolean.TRUE.equals(provider.getRequireApiKey())) {
|
// Issue #81: emit a precise reason based on which row-level fields are
|
||||||
return "Provider 未配置有效的 API Key";
|
// 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";
|
return "Provider 未配置 Base URL";
|
||||||
}
|
}
|
||||||
|
if (req.needsApiKey() && !hasApiKey) {
|
||||||
|
return "Provider 未配置 API Key";
|
||||||
|
}
|
||||||
return "Provider 未完成配置";
|
return "Provider 未完成配置";
|
||||||
}
|
}
|
||||||
if (!hasModels(providerId)) {
|
if (!hasModels(providerId)) {
|
||||||
@ -416,9 +427,85 @@ public class ModelProviderService {
|
|||||||
}
|
}
|
||||||
dto.setModels(builtinModels);
|
dto.setModels(builtinModels);
|
||||||
dto.setExtraModels(extraModels);
|
dto.setExtraModels(extraModels);
|
||||||
|
applySuggestedAction(dto, provider, providerLiveness);
|
||||||
return dto;
|
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<String> 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) {
|
private boolean hasModels(String providerId) {
|
||||||
return !modelConfigService.listModelsByProvider(providerId).isEmpty();
|
return !modelConfigService.listModelsByProvider(providerId).isEmpty();
|
||||||
}
|
}
|
||||||
@ -427,13 +514,11 @@ public class ModelProviderService {
|
|||||||
if (provider == null) {
|
if (provider == null) {
|
||||||
return false;
|
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())) {
|
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())) {
|
if (CLAUDE_CODE_PROVIDER_ID.equals(provider.getProviderId())) {
|
||||||
ClaudeCodeOAuthService svc = claudeCodeOAuthServiceProvider.getIfAvailable();
|
ClaudeCodeOAuthService svc = claudeCodeOAuthServiceProvider.getIfAvailable();
|
||||||
return svc != null && svc.isLoggedIn();
|
return svc != null && svc.isLoggedIn();
|
||||||
@ -441,16 +526,21 @@ public class ModelProviderService {
|
|||||||
return StringUtils.hasText(provider.getOauthAccessToken());
|
return StringUtils.hasText(provider.getOauthAccessToken());
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean hasBaseUrl = StringUtils.hasText(provider.getBaseUrl());
|
// Issue #81: decide required fields from the provider row, not from the
|
||||||
boolean hasApiKey = hasUsableApiKey(provider.getApiKey());
|
// protocol enum. Every OpenAI-compatible provider (cloud or local) shares
|
||||||
|
// OPENAI_COMPATIBLE, so a protocol-keyed table cannot tell OpenAI cloud
|
||||||
if (Boolean.TRUE.equals(provider.getIsCustom())) {
|
// (needs api_key, no base url) apart from llama.cpp local (no api_key,
|
||||||
return hasBaseUrl && (!Boolean.TRUE.equals(provider.getRequireApiKey()) || hasApiKey);
|
// 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())) {
|
if (req.needsBaseUrl() && !StringUtils.hasText(provider.getBaseUrl())) {
|
||||||
return hasBaseUrl;
|
return false;
|
||||||
}
|
}
|
||||||
return hasApiKey;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean hasUsableApiKey(String apiKey) {
|
public boolean hasUsableApiKey(String apiKey) {
|
||||||
|
|||||||
@ -34,25 +34,47 @@
|
|||||||
<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),
|
<!-- Liveness dot — UNPROBED = grey, COOLDOWN = amber, LIVE = none.
|
||||||
COOLDOWN = amber (transient backoff). LIVE has no dot. -->
|
v2 R3: when showAllStates, also surface UNCONFIGURED / REMOVED chips
|
||||||
|
so the user can see what's wrong without leaving chat. -->
|
||||||
<span
|
<span
|
||||||
v-if="group.provider.liveness === 'UNPROBED'"
|
v-if="group.provider.liveness === 'UNPROBED'"
|
||||||
class="model-group-header__dot model-group-header__dot--unprobed"
|
class="model-group-header__dot model-group-header__dot--unprobed"
|
||||||
:title="$t('chat.modelLivenessUnprobed')"
|
:title="$t('provider.status.unprobed')"
|
||||||
></span>
|
></span>
|
||||||
<span
|
<span
|
||||||
v-else-if="group.provider.liveness === 'COOLDOWN'"
|
v-else-if="group.provider.liveness === 'COOLDOWN'"
|
||||||
class="model-group-header__dot model-group-header__dot--cooldown"
|
class="model-group-header__dot model-group-header__dot--cooldown"
|
||||||
:title="$t('chat.modelLivenessCooldown', { seconds: cooldownSeconds(group.provider) })"
|
:title="$t('provider.status.cooldown', { s: cooldownSeconds(group.provider) })"
|
||||||
></span>
|
></span>
|
||||||
|
<span
|
||||||
|
v-if="group.provider.liveness === 'UNCONFIGURED'"
|
||||||
|
class="model-group-header__chip model-group-header__chip--warn"
|
||||||
|
>{{ $t('provider.status.unconfigured') }}</span>
|
||||||
|
<span
|
||||||
|
v-else-if="group.provider.liveness === 'REMOVED'"
|
||||||
|
class="model-group-header__chip model-group-header__chip--err"
|
||||||
|
>{{ $t('provider.status.removed') }}</span>
|
||||||
|
<span
|
||||||
|
v-else-if="group.provider.liveness === 'COOLDOWN'"
|
||||||
|
class="model-group-header__chip model-group-header__chip--info"
|
||||||
|
>{{ $t('provider.status.cooldown', { s: cooldownSeconds(group.provider) }) }}</span>
|
||||||
|
<button
|
||||||
|
v-if="!isSelectable(group.provider)"
|
||||||
|
class="model-group-header__fix"
|
||||||
|
@click.stop="emitNavigateFix(group.provider)"
|
||||||
|
type="button"
|
||||||
|
>{{ $t('chat.promptAction.fixThis') }}</button>
|
||||||
</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, dimmed: group.provider.liveness === 'COOLDOWN' || group.provider.liveness === 'UNPROBED' }"
|
:class="{
|
||||||
@click="handleSelect(item.value)"
|
active: item.value === activeValue,
|
||||||
|
dimmed: !isSelectable(group.provider),
|
||||||
|
}"
|
||||||
|
@click="onItemClick(group.provider, item.value)"
|
||||||
>
|
>
|
||||||
<span class="model-dropdown-item__name">{{ item.name }}</span>
|
<span class="model-dropdown-item__name">{{ item.name }}</span>
|
||||||
<svg v-if="item.value === activeValue" class="model-dropdown-item__check" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="20 6 9 17 4 12"/></svg>
|
<svg v-if="item.value === activeValue" class="model-dropdown-item__check" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="20 6 9 17 4 12"/></svg>
|
||||||
@ -66,7 +88,7 @@
|
|||||||
state. Push them into Settings/Models with the drawer
|
state. Push them into Settings/Models with the drawer
|
||||||
pre-opened via ?addProvider=1. -->
|
pre-opened via ?addProvider=1. -->
|
||||||
<template v-if="query.trim() === '' && groups.length === 0">
|
<template v-if="query.trim() === '' && groups.length === 0">
|
||||||
{{ $t('chat.noProvidersConfigured') }}
|
{{ emptyHint || $t('chat.noProvidersConfigured') }}
|
||||||
<RouterLink class="model-empty__cta" to="/settings/models?addProvider=1" @click="open = false">
|
<RouterLink class="model-empty__cta" to="/settings/models?addProvider=1" @click="open = false">
|
||||||
{{ $t('chat.goConfigure') }}
|
{{ $t('chat.goConfigure') }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
@ -106,10 +128,19 @@ const props = defineProps<{
|
|||||||
activeValue: string
|
activeValue: string
|
||||||
activeLabel: string
|
activeLabel: string
|
||||||
saving?: boolean
|
saving?: boolean
|
||||||
|
/**
|
||||||
|
* v2 R3: when true, render UNCONFIGURED / REMOVED rows too (dimmed, with
|
||||||
|
* status chip + Fix button) so users see what's wrong without leaving chat.
|
||||||
|
* Defaults to false to keep the legacy filtered behavior for any other call site.
|
||||||
|
*/
|
||||||
|
showAllStates?: boolean
|
||||||
|
/** Optional override for the empty-state text. */
|
||||||
|
emptyHint?: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
select: [value: string]
|
select: [value: string]
|
||||||
|
'navigate-fix': [provider: ProviderInfo]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const open = ref(false)
|
const open = ref(false)
|
||||||
@ -143,13 +174,20 @@ function toggle() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 按 provider 分组,云端在前,本地在后
|
// 按 provider 分组,云端在前,本地在后
|
||||||
// RFC-073: 仅过滤 UNCONFIGURED / REMOVED;UNPROBED + COOLDOWN 仍显示但视觉上区分。
|
// v2 R3: when showAllStates is true, keep UNCONFIGURED/REMOVED visible (dimmed +
|
||||||
|
// status chip + Fix button) so the user can act in-place. Default behavior
|
||||||
|
// (showAllStates=false) preserves the original filter for non-popup contexts.
|
||||||
function isHidden(p: ProviderInfo): boolean {
|
function isHidden(p: ProviderInfo): boolean {
|
||||||
// 旧后端不返回 liveness 时退回 available 行为,避免渐进升级期间 UI 全空。
|
if (props.showAllStates) return false
|
||||||
if (!p.liveness) return !p.available
|
if (!p.liveness) return !p.available
|
||||||
return p.liveness === 'UNCONFIGURED' || p.liveness === 'REMOVED'
|
return p.liveness === 'UNCONFIGURED' || p.liveness === 'REMOVED'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSelectable(p: ProviderInfo): boolean {
|
||||||
|
if (!p.liveness) return p.available === true
|
||||||
|
return p.liveness === 'LIVE' || p.liveness === 'COOLDOWN'
|
||||||
|
}
|
||||||
|
|
||||||
const groups = computed<ModelGroup[]>(() => {
|
const groups = computed<ModelGroup[]>(() => {
|
||||||
const cloud: ModelGroup[] = []
|
const cloud: ModelGroup[] = []
|
||||||
const local: ModelGroup[] = []
|
const local: ModelGroup[] = []
|
||||||
@ -157,7 +195,19 @@ const groups = computed<ModelGroup[]>(() => {
|
|||||||
for (const provider of props.providers) {
|
for (const provider of props.providers) {
|
||||||
if (isHidden(provider)) continue
|
if (isHidden(provider)) continue
|
||||||
const allModels = [...(provider.models || []), ...(provider.extraModels || [])]
|
const allModels = [...(provider.models || []), ...(provider.extraModels || [])]
|
||||||
if (allModels.length === 0) continue
|
// When showAllStates is on we still want UNCONFIGURED rows to appear even
|
||||||
|
// if they have no models — synthesize one placeholder so the header chip +
|
||||||
|
// Fix button render. Otherwise (legacy call site) skip empty groups.
|
||||||
|
if (allModels.length === 0) {
|
||||||
|
if (!props.showAllStates) continue
|
||||||
|
const group: ModelGroup = {
|
||||||
|
provider,
|
||||||
|
models: [],
|
||||||
|
}
|
||||||
|
if (provider.isLocal) local.push(group)
|
||||||
|
else cloud.push(group)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
const group: ModelGroup = {
|
const group: ModelGroup = {
|
||||||
provider,
|
provider,
|
||||||
@ -213,6 +263,25 @@ function handleSelect(value: string) {
|
|||||||
emit('select', value)
|
emit('select', value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v2 R3: a row is selectable iff its provider can serve a request now (LIVE) or
|
||||||
|
* is just throttled (COOLDOWN — backend lets the request go and the chain
|
||||||
|
* walker handles fallback). UNCONFIGURED / REMOVED / UNPROBED need user action,
|
||||||
|
* so clicking those routes to the Fix flow instead of trying to switch.
|
||||||
|
*/
|
||||||
|
function onItemClick(provider: ProviderInfo, value: string) {
|
||||||
|
if (isSelectable(provider)) {
|
||||||
|
handleSelect(value)
|
||||||
|
} else {
|
||||||
|
emitNavigateFix(provider)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitNavigateFix(provider: ProviderInfo) {
|
||||||
|
open.value = false
|
||||||
|
emit('navigate-fix', provider)
|
||||||
|
}
|
||||||
|
|
||||||
// 打开时聚焦搜索框 + 滚动到当前选中项
|
// 打开时聚焦搜索框 + 滚动到当前选中项
|
||||||
watch(open, async (isOpen) => {
|
watch(open, async (isOpen) => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
@ -385,6 +454,52 @@ watch(open, async (isOpen) => {
|
|||||||
50% { opacity: 1; }
|
50% { opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Liveness status chips — surfaced when ModelSelector is in show-all-states mode. */
|
||||||
|
.model-group-header__chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 16px;
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
.model-group-header__chip--warn {
|
||||||
|
background: rgba(245, 158, 11, 0.15);
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
.model-group-header__chip--err {
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
.model-group-header__chip--info {
|
||||||
|
background: rgba(59, 130, 246, 0.15);
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
.dark .model-group-header__chip--warn { color: #fbbf24; }
|
||||||
|
.dark .model-group-header__chip--err { color: #fca5a5; }
|
||||||
|
.dark .model-group-header__chip--info { color: #93c5fd; }
|
||||||
|
|
||||||
|
.model-group-header__fix {
|
||||||
|
margin-left: auto;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--mc-border);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--mc-text-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s, color 0.12s, border-color 0.12s;
|
||||||
|
}
|
||||||
|
.model-group-header__fix:hover {
|
||||||
|
background: var(--mc-primary);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--mc-primary);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- Items ---- */
|
/* ---- Items ---- */
|
||||||
|
|
||||||
.model-dropdown-item {
|
.model-dropdown-item {
|
||||||
|
|||||||
78
mateclaw-ui/src/components/chat/RecoverableModelBanner.vue
Normal file
78
mateclaw-ui/src/components/chat/RecoverableModelBanner.vue
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
<template>
|
||||||
|
<div class="recoverable-banner" role="status">
|
||||||
|
<span class="recoverable-banner__icon">⚠️</span>
|
||||||
|
<span class="recoverable-banner__text">
|
||||||
|
{{ $t('chat.recoverableBanner.message', { name: providerName, fallback: fallbackName }) }}
|
||||||
|
</span>
|
||||||
|
<button class="recoverable-banner__dismiss" type="button" @click="$emit('dismiss')">
|
||||||
|
<svg width="14" height="14" 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>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Issue #81: shown when the active provider is unhealthy but the chain walker
|
||||||
|
* can fall back to a LIVE one. Non-blocking — input stays enabled, send still
|
||||||
|
* works; this is just a heads-up.
|
||||||
|
*/
|
||||||
|
defineProps<{
|
||||||
|
providerName: string
|
||||||
|
fallbackName: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
dismiss: []
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.recoverable-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 8px 16px 0;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.35);
|
||||||
|
background: rgba(245, 158, 11, 0.10);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--mc-text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recoverable-banner__icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recoverable-banner__text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recoverable-banner__dismiss {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--mc-text-tertiary);
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s, color 0.12s;
|
||||||
|
}
|
||||||
|
.recoverable-banner__dismiss:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.06);
|
||||||
|
color: var(--mc-text-primary);
|
||||||
|
}
|
||||||
|
.dark .recoverable-banner__dismiss:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -180,6 +180,49 @@ export default {
|
|||||||
// RFC-074 PR-2: empty-state inside the model dropdown
|
// RFC-074 PR-2: empty-state inside the model dropdown
|
||||||
noProvidersConfigured: 'No models available yet',
|
noProvidersConfigured: 'No models available yet',
|
||||||
goConfigure: 'Configure',
|
goConfigure: 'Configure',
|
||||||
|
// Issue #81: liveness-aware popup state machine.
|
||||||
|
prompt: {
|
||||||
|
noActive: {
|
||||||
|
title: 'Pick a model first',
|
||||||
|
desc: 'No active model. Choose one in Model Management before chatting.',
|
||||||
|
},
|
||||||
|
unconfigured: {
|
||||||
|
title: '{name} is not fully configured',
|
||||||
|
desc: 'Missing: {fields}. {hint}',
|
||||||
|
},
|
||||||
|
removed: {
|
||||||
|
title: '{name} is unavailable',
|
||||||
|
descFallback: 'The last probe failed — check whether the service is running.',
|
||||||
|
},
|
||||||
|
cooldown: {
|
||||||
|
title: '{name} is temporarily unavailable',
|
||||||
|
desc: 'Auto-retrying in about {seconds}s.',
|
||||||
|
},
|
||||||
|
unprobed: {
|
||||||
|
title: 'Checking model availability',
|
||||||
|
desc: 'Hold on or refresh to retry.',
|
||||||
|
},
|
||||||
|
noModels: {
|
||||||
|
title: '{name} has no usable models',
|
||||||
|
desc: 'Discover models or add one manually.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
promptAction: {
|
||||||
|
fillBaseUrl: 'Fill Base URL',
|
||||||
|
fillApiKey: 'Fill API Key',
|
||||||
|
fillRequiredFields: 'Complete required fields',
|
||||||
|
startOAuth: 'Sign in',
|
||||||
|
testConnection: 'Test connection',
|
||||||
|
pullModel: 'Discover models',
|
||||||
|
waitCooldown: 'Retry now',
|
||||||
|
reprobe: 'Re-probe',
|
||||||
|
switchToModel: 'Switch to {name}',
|
||||||
|
fixThis: 'Fix',
|
||||||
|
},
|
||||||
|
recoverableBanner: {
|
||||||
|
message: '{name} is unavailable; sends will fall back to {fallback} automatically',
|
||||||
|
switched: 'Primary model unavailable; switched to {fallback}',
|
||||||
|
},
|
||||||
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',
|
||||||
@ -429,6 +472,22 @@ export default {
|
|||||||
lastChecked: 'Checked {time} ago',
|
lastChecked: 'Checked {time} ago',
|
||||||
diagnose: 'Diagnose',
|
diagnose: 'Diagnose',
|
||||||
},
|
},
|
||||||
|
// Issue #81: provider-level hint / status text shared across chat popup and ModelSelector.
|
||||||
|
provider: {
|
||||||
|
hint: {
|
||||||
|
ollamaBaseUrlExample: 'Example: {example}',
|
||||||
|
lmstudioBaseUrlExample: 'Example: {example} (LM Studio default port)',
|
||||||
|
llamacppBaseUrlExample: 'Example: {example} (llama-server default port)',
|
||||||
|
vllmBaseUrlExample: 'Example: {example}',
|
||||||
|
openaiCompatBaseUrlExample: 'Example: OpenAI-compatible endpoint, e.g. {example}',
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
unconfigured: 'Not configured',
|
||||||
|
removed: 'Unavailable',
|
||||||
|
cooldown: 'Retry in {s}s',
|
||||||
|
unprobed: 'Probing',
|
||||||
|
},
|
||||||
|
},
|
||||||
settings: {
|
settings: {
|
||||||
title: 'Settings',
|
title: 'Settings',
|
||||||
kicker: 'Configuration',
|
kicker: 'Configuration',
|
||||||
|
|||||||
@ -180,6 +180,49 @@ export default {
|
|||||||
// RFC-074 PR-2: empty-state inside the model dropdown
|
// RFC-074 PR-2: empty-state inside the model dropdown
|
||||||
noProvidersConfigured: '还没有可用的模型',
|
noProvidersConfigured: '还没有可用的模型',
|
||||||
goConfigure: '去配置',
|
goConfigure: '去配置',
|
||||||
|
// Issue #81: liveness-aware popup state machine.
|
||||||
|
prompt: {
|
||||||
|
noActive: {
|
||||||
|
title: '请先选择一个模型',
|
||||||
|
desc: '当前没有激活模型,先到模型管理选一个再开始对话。',
|
||||||
|
},
|
||||||
|
unconfigured: {
|
||||||
|
title: '{name} 还没配置完',
|
||||||
|
desc: '缺少:{fields}。{hint}',
|
||||||
|
},
|
||||||
|
removed: {
|
||||||
|
title: '{name} 不可用',
|
||||||
|
descFallback: '上一次探测失败,请检查服务是否在运行。',
|
||||||
|
},
|
||||||
|
cooldown: {
|
||||||
|
title: '{name} 暂时不可用',
|
||||||
|
desc: '约 {seconds} 秒后自动重试。',
|
||||||
|
},
|
||||||
|
unprobed: {
|
||||||
|
title: '正在检查模型可用性',
|
||||||
|
desc: '稍候片刻或刷新重试。',
|
||||||
|
},
|
||||||
|
noModels: {
|
||||||
|
title: '{name} 下没有可用模型',
|
||||||
|
desc: '尝试发现模型或手动添加一个。',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
promptAction: {
|
||||||
|
fillBaseUrl: '填写 Base URL',
|
||||||
|
fillApiKey: '填写 API Key',
|
||||||
|
fillRequiredFields: '完成必填项',
|
||||||
|
startOAuth: '登录授权',
|
||||||
|
testConnection: '测试连接',
|
||||||
|
pullModel: '发现模型',
|
||||||
|
waitCooldown: '立即重试',
|
||||||
|
reprobe: '重新探测',
|
||||||
|
switchToModel: '切换到 {name}',
|
||||||
|
fixThis: '修复',
|
||||||
|
},
|
||||||
|
recoverableBanner: {
|
||||||
|
message: '{name} 暂时不可用,发送时会自动用 {fallback} 兜底',
|
||||||
|
switched: '主模型不可用,已切换到 {fallback}',
|
||||||
|
},
|
||||||
uploadFailed: '文件上传失败',
|
uploadFailed: '文件上传失败',
|
||||||
dropToUpload: '拖放文件或文件夹到此处',
|
dropToUpload: '拖放文件或文件夹到此处',
|
||||||
copyFailed: '复制失败',
|
copyFailed: '复制失败',
|
||||||
@ -315,6 +358,22 @@ export default {
|
|||||||
roleUser: '用户',
|
roleUser: '用户',
|
||||||
roleAdmin: '管理员',
|
roleAdmin: '管理员',
|
||||||
},
|
},
|
||||||
|
// Issue #81: provider-level hint / status text shared across chat popup and ModelSelector.
|
||||||
|
provider: {
|
||||||
|
hint: {
|
||||||
|
ollamaBaseUrlExample: '示例:{example}',
|
||||||
|
lmstudioBaseUrlExample: '示例:{example}(LM Studio 默认端口)',
|
||||||
|
llamacppBaseUrlExample: '示例:{example}(llama-server 默认端口)',
|
||||||
|
vllmBaseUrlExample: '示例:{example}',
|
||||||
|
openaiCompatBaseUrlExample: '示例:OpenAI 兼容端点,例如 {example}',
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
unconfigured: '未配置',
|
||||||
|
removed: '不可用',
|
||||||
|
cooldown: '{s}s 后重试',
|
||||||
|
unprobed: '检测中',
|
||||||
|
},
|
||||||
|
},
|
||||||
settings: {
|
settings: {
|
||||||
title: '设置',
|
title: '设置',
|
||||||
kicker: '配置中心',
|
kicker: '配置中心',
|
||||||
|
|||||||
@ -742,6 +742,24 @@ export interface ProviderInfo {
|
|||||||
cooldownRemainingMs?: number
|
cooldownRemainingMs?: number
|
||||||
/** RFC-074: whether the user has explicitly opted this provider into the dropdown. */
|
/** RFC-074: whether the user has explicitly opted this provider into the dropdown. */
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
|
|
||||||
|
// Issue #81: derived liveness fields powering the chat-console popup state machine.
|
||||||
|
/** CONFIGURED / MISSING / NOT_REQUIRED / OAUTH_PENDING. */
|
||||||
|
authStatus?: string
|
||||||
|
/** null = base url N/A; true/false = applicable and complete/incomplete. */
|
||||||
|
baseUrlComplete?: boolean | null
|
||||||
|
/** Comma-joined missing field names ("apiKey", "baseUrl"); empty when nothing missing. */
|
||||||
|
missingFields?: string
|
||||||
|
/**
|
||||||
|
* Machine-readable next-step key driving the popup primary button.
|
||||||
|
* fill_base_url / fill_api_key / start_oauth / configure_required_fields /
|
||||||
|
* test_connection / pull_model / wait_cooldown / reprobe / none.
|
||||||
|
*/
|
||||||
|
suggestedAction?: string
|
||||||
|
/** i18n key for the actionable hint, e.g. "provider.hint.llamacppBaseUrlExample". */
|
||||||
|
suggestedActionHintKey?: string | null
|
||||||
|
/** Template params for vue-i18n t(key, args). */
|
||||||
|
suggestedActionHintArgs?: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -174,18 +174,18 @@
|
|||||||
<div v-else class="no-agent-hint">{{ $t('chat.selectAgent') }}</div>
|
<div v-else class="no-agent-hint">{{ $t('chat.selectAgent') }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="chat-header-right">
|
<div class="chat-header-right">
|
||||||
<!-- Model selector -->
|
<!-- Model selector — Issue #81 v2 R3: always pass full providers + show-all-states
|
||||||
|
so unhealthy rows render as dimmed entries with status chips and a Fix
|
||||||
|
button instead of disappearing entirely. -->
|
||||||
<ModelSelector
|
<ModelSelector
|
||||||
v-if="eligibleModels.length > 0"
|
:providers="providers"
|
||||||
:providers="availableProviders"
|
|
||||||
:active-value="activeModelValue"
|
:active-value="activeModelValue"
|
||||||
:active-label="activeModelLabel"
|
:active-label="activeModelLabel"
|
||||||
:saving="modelSaving"
|
:saving="modelSaving"
|
||||||
|
:show-all-states="true"
|
||||||
@select="selectModel"
|
@select="selectModel"
|
||||||
|
@navigate-fix="onModelSelectorFix"
|
||||||
/>
|
/>
|
||||||
<button v-else class="header-btn" @click="goToModelSettings" :title="$t('chat.configModel')">
|
|
||||||
<el-icon><Setting /></el-icon>
|
|
||||||
</button>
|
|
||||||
<!-- Overflow menu -->
|
<!-- Overflow menu -->
|
||||||
<div class="header-overflow-wrap">
|
<div class="header-overflow-wrap">
|
||||||
<button class="header-btn" @click="headerMenuOpen = !headerMenuOpen" :title="$t('common.more') || 'More'">
|
<button class="header-btn" @click="headerMenuOpen = !headerMenuOpen" :title="$t('common.more') || 'More'">
|
||||||
@ -218,25 +218,46 @@
|
|||||||
:loading="isGenerating"
|
:loading="isGenerating"
|
||||||
:assistant-icon="currentAgent?.icon || '🤖'"
|
:assistant-icon="currentAgent?.icon || '🤖'"
|
||||||
:user-icon="userInitial"
|
:user-icon="userInitial"
|
||||||
:title="showModelPrompt ? modelPromptTitle : $t('app.title')"
|
:title="blockingPrompt ? modelPromptText.title : $t('app.title')"
|
||||||
:subtitle="showModelPrompt ? modelPromptDesc : $t('chat.subtitle')"
|
:subtitle="blockingPrompt ? modelPromptText.desc : $t('chat.subtitle')"
|
||||||
:suggestions="showModelPrompt ? [] : suggestions"
|
:suggestions="blockingPrompt ? [] : suggestions"
|
||||||
@regenerate="handleRegenerate"
|
@regenerate="handleRegenerate"
|
||||||
@suggestion-click="sendSuggestion"
|
@suggestion-click="sendSuggestion"
|
||||||
@toggle-thinking="handleToggleThinking"
|
@toggle-thinking="handleToggleThinking"
|
||||||
@approve="handleApprove"
|
@approve="handleApprove"
|
||||||
@deny="handleDeny"
|
@deny="handleDeny"
|
||||||
>
|
>
|
||||||
<!-- 自定义模型提示空状态 -->
|
<!-- Issue #81 v2 R2: blocking-only popup. Recoverable cases use the
|
||||||
<template v-if="showModelPrompt" #empty>
|
non-blocking <RecoverableModelBanner> below instead. -->
|
||||||
|
<template v-if="blockingPrompt" #empty>
|
||||||
<div class="model-prompt">
|
<div class="model-prompt">
|
||||||
<div class="model-prompt-title">{{ modelPromptTitle }}</div>
|
<div class="model-prompt-title">{{ modelPromptText.title }}</div>
|
||||||
<div class="model-prompt-desc">{{ modelPromptDesc }}</div>
|
<div class="model-prompt-desc">{{ modelPromptText.desc }}</div>
|
||||||
<button class="btn-primary" @click="goToModelSettings">{{ $t('chat.goToModelSettings') }}</button>
|
<div class="model-prompt-actions">
|
||||||
|
<button class="btn-primary" @click="handlePrimaryAction">
|
||||||
|
{{ primaryActionLabel }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="bestSwitchTarget"
|
||||||
|
class="btn-secondary"
|
||||||
|
@click="switchToBestTarget"
|
||||||
|
>
|
||||||
|
{{ $t('chat.promptAction.switchToModel', { name: bestSwitchTarget.label }) }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</MessageList>
|
</MessageList>
|
||||||
|
|
||||||
|
<!-- Issue #81: non-blocking banner — active provider is unhealthy but the
|
||||||
|
backend fallback chain has a LIVE provider to take over. -->
|
||||||
|
<RecoverableModelBanner
|
||||||
|
v-if="recoverablePrompt && activeProvider && bestFallbackName"
|
||||||
|
:provider-name="activeProvider.name"
|
||||||
|
:fallback-name="bestFallbackName"
|
||||||
|
@dismiss="recoverableDismissed = true"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Cron job in-flight placeholder — visible while T2 hasn't committed
|
<!-- Cron job in-flight placeholder — visible while T2 hasn't committed
|
||||||
the assistant message yet. Populated by pollActivity → /cron-jobs/active-runs. -->
|
the assistant message yet. Populated by pollActivity → /cron-jobs/active-runs. -->
|
||||||
<div v-if="activeCronRuns.length > 0" class="cron-running-bar">
|
<div v-if="activeCronRuns.length > 0" class="cron-running-bar">
|
||||||
@ -254,7 +275,7 @@
|
|||||||
|
|
||||||
<!-- 流式处理 Loading 栏(消息和输入框之间) -->
|
<!-- 流式处理 Loading 栏(消息和输入框之间) -->
|
||||||
<StreamLoadingBar
|
<StreamLoadingBar
|
||||||
:is-loading="isGenerating && !showModelPrompt"
|
:is-loading="isGenerating && !blockingPrompt"
|
||||||
:tool-count="toolCallCount"
|
:tool-count="toolCallCount"
|
||||||
:completion-tokens="currentGeneratingTokens"
|
:completion-tokens="currentGeneratingTokens"
|
||||||
:prompt-tokens="currentPromptTokens"
|
:prompt-tokens="currentPromptTokens"
|
||||||
@ -270,7 +291,7 @@
|
|||||||
ref="chatInputRef"
|
ref="chatInputRef"
|
||||||
v-model="inputText"
|
v-model="inputText"
|
||||||
:loading="isGenerating && !hasPendingApproval"
|
:loading="isGenerating && !hasPendingApproval"
|
||||||
:disabled="showModelPrompt || !currentAgent"
|
:disabled="blockingPrompt || !currentAgent"
|
||||||
:placeholder="$t('chat.messagePlaceholder')"
|
:placeholder="$t('chat.messagePlaceholder')"
|
||||||
:hint="currentRuntimeModel"
|
:hint="currentRuntimeModel"
|
||||||
:attachments="pendingAttachments"
|
:attachments="pendingAttachments"
|
||||||
@ -325,6 +346,7 @@ import type { Conversation, Agent, ModelConfig, ProviderInfo, ActiveModelsInfo,
|
|||||||
|
|
||||||
// 导入组件化组件
|
// 导入组件化组件
|
||||||
import MessageList from '@/components/chat/MessageList.vue'
|
import MessageList from '@/components/chat/MessageList.vue'
|
||||||
|
import RecoverableModelBanner from '@/components/chat/RecoverableModelBanner.vue'
|
||||||
import SkillIcon from '@/components/common/SkillIcon.vue'
|
import SkillIcon from '@/components/common/SkillIcon.vue'
|
||||||
import { parsePrompt, deriveTagline } from '@/utils/agentPromptProfile'
|
import { parsePrompt, deriveTagline } from '@/utils/agentPromptProfile'
|
||||||
import { agentIconColor } from '@/utils/agentIconColor'
|
import { agentIconColor } from '@/utils/agentIconColor'
|
||||||
@ -403,7 +425,13 @@ const selectedAgentId = ref<string | number>('')
|
|||||||
const currentConversationId = ref<string>('')
|
const currentConversationId = ref<string>('')
|
||||||
const inputText = ref('')
|
const inputText = ref('')
|
||||||
const modelSaving = ref(false)
|
const modelSaving = ref(false)
|
||||||
const showModelPrompt = ref(false)
|
// Issue #81 v2 R2: split the single showModelPrompt boolean into two flags so
|
||||||
|
// the chat surface can either hard-block (blockingPrompt) or warn but let the
|
||||||
|
// backend fallback chain take over (recoverablePrompt). Driven by
|
||||||
|
// recomputePromptFlags() — see the watcher below.
|
||||||
|
const blockingPrompt = ref(false)
|
||||||
|
const recoverablePrompt = ref(false)
|
||||||
|
const recoverableDismissed = ref(false)
|
||||||
const defaultModel = ref<ModelConfig | null>(null)
|
const defaultModel = ref<ModelConfig | null>(null)
|
||||||
const providers = ref<ProviderInfo[]>([])
|
const providers = ref<ProviderInfo[]>([])
|
||||||
const activeModels = ref<ActiveModelsInfo | null>(null)
|
const activeModels = ref<ActiveModelsInfo | null>(null)
|
||||||
@ -812,26 +840,107 @@ const activeProvider = computed(() => {
|
|||||||
return providerId ? providers.value.find((provider) => provider.id === providerId) || null : null
|
return providerId ? providers.value.find((provider) => provider.id === providerId) || null : null
|
||||||
})
|
})
|
||||||
|
|
||||||
const modelPromptTitle = computed(() => {
|
// Issue #81: liveness-aware popup state machine. modelPromptKind picks one of
|
||||||
if (!activeModels.value?.activeLlm?.providerId || !activeModels.value?.activeLlm?.model) {
|
// six branches; modelPromptText derives title + desc; primaryActionLabel +
|
||||||
return t('chat.configModelFirst')
|
// handlePrimaryAction map to the suggestedAction the backend computed.
|
||||||
|
type ModelPromptKind = 'no-active' | 'unconfigured' | 'removed' | 'cooldown' | 'unprobed' | 'no-models'
|
||||||
|
|
||||||
|
const modelPromptKind = computed<ModelPromptKind>(() => {
|
||||||
|
if (!activeModels.value?.activeLlm?.providerId) return 'no-active'
|
||||||
|
const p = activeProvider.value
|
||||||
|
if (!p) return 'no-active'
|
||||||
|
switch (p.liveness) {
|
||||||
|
case 'UNCONFIGURED': return 'unconfigured'
|
||||||
|
case 'REMOVED': return 'removed'
|
||||||
|
case 'COOLDOWN': return 'cooldown'
|
||||||
|
case 'UNPROBED': return 'unprobed'
|
||||||
|
case 'LIVE': return 'no-models'
|
||||||
|
default: return 'no-active'
|
||||||
}
|
}
|
||||||
if (activeProvider.value && !activeProvider.value.available) {
|
|
||||||
return t('chat.modelUnavailable')
|
|
||||||
}
|
|
||||||
return t('chat.configModelFirst')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const modelPromptDesc = computed(() => {
|
const hintText = computed(() => {
|
||||||
if (!activeModels.value?.activeLlm?.providerId || !activeModels.value?.activeLlm?.model) {
|
const p = activeProvider.value
|
||||||
return t('chat.noActiveModel')
|
if (!p?.suggestedActionHintKey) return ''
|
||||||
}
|
return t(p.suggestedActionHintKey, (p.suggestedActionHintArgs || {}) as Record<string, unknown>)
|
||||||
if (activeProvider.value && !activeProvider.value.available) {
|
|
||||||
return t('chat.providerNotReady', { name: activeProvider.value.name })
|
|
||||||
}
|
|
||||||
return t('chat.noAvailableModel')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const modelPromptText = computed<{ title: string; desc: string }>(() => {
|
||||||
|
const p = activeProvider.value
|
||||||
|
switch (modelPromptKind.value) {
|
||||||
|
case 'no-active':
|
||||||
|
return { title: t('chat.prompt.noActive.title'), desc: t('chat.prompt.noActive.desc') }
|
||||||
|
case 'unconfigured':
|
||||||
|
return {
|
||||||
|
title: t('chat.prompt.unconfigured.title', { name: p?.name || '' }),
|
||||||
|
desc: t('chat.prompt.unconfigured.desc', { fields: p?.missingFields || '', hint: hintText.value }),
|
||||||
|
}
|
||||||
|
case 'removed':
|
||||||
|
return {
|
||||||
|
title: t('chat.prompt.removed.title', { name: p?.name || '' }),
|
||||||
|
desc: p?.unavailableReason || t('chat.prompt.removed.descFallback'),
|
||||||
|
}
|
||||||
|
case 'cooldown':
|
||||||
|
return {
|
||||||
|
title: t('chat.prompt.cooldown.title', { name: p?.name || '' }),
|
||||||
|
desc: t('chat.prompt.cooldown.desc', {
|
||||||
|
seconds: Math.max(1, Math.ceil((p?.cooldownRemainingMs || 0) / 1000)),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
case 'unprobed':
|
||||||
|
return { title: t('chat.prompt.unprobed.title'), desc: t('chat.prompt.unprobed.desc') }
|
||||||
|
case 'no-models':
|
||||||
|
return {
|
||||||
|
title: t('chat.prompt.noModels.title', { name: p?.name || '' }),
|
||||||
|
desc: t('chat.prompt.noModels.desc'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const primaryActionLabel = computed(() => {
|
||||||
|
const action = activeProvider.value?.suggestedAction || 'configure_required_fields'
|
||||||
|
switch (action) {
|
||||||
|
case 'fill_base_url': return t('chat.promptAction.fillBaseUrl')
|
||||||
|
case 'fill_api_key': return t('chat.promptAction.fillApiKey')
|
||||||
|
case 'start_oauth': return t('chat.promptAction.startOAuth')
|
||||||
|
case 'test_connection': return t('chat.promptAction.testConnection')
|
||||||
|
case 'pull_model': return t('chat.promptAction.pullModel')
|
||||||
|
case 'wait_cooldown': return t('chat.promptAction.waitCooldown')
|
||||||
|
case 'reprobe': return t('chat.promptAction.reprobe')
|
||||||
|
case 'configure_required_fields':
|
||||||
|
default: return t('chat.goToModelSettings')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function handlePrimaryAction() {
|
||||||
|
goToModelSettings(activeProvider.value?.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First eligible model that is NOT the active one — what the secondary button switches to. */
|
||||||
|
const bestSwitchTarget = computed<{ value: string; label: string } | null>(() => {
|
||||||
|
for (const m of eligibleModels.value) {
|
||||||
|
if (m.value !== activeModelValue.value) return m
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
|
/** First LIVE provider name — used by RecoverableModelBanner. */
|
||||||
|
const bestFallbackName = computed<string>(() => {
|
||||||
|
const target = bestSwitchTarget.value
|
||||||
|
if (!target) return ''
|
||||||
|
const [providerId] = target.value.split('::')
|
||||||
|
return providers.value.find(p => p.id === providerId)?.name || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
function switchToBestTarget() {
|
||||||
|
const t = bestSwitchTarget.value
|
||||||
|
if (t) selectModel(t.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onModelSelectorFix(provider: { id: string }) {
|
||||||
|
goToModelSettings(provider.id)
|
||||||
|
}
|
||||||
|
|
||||||
const availableProviders = computed(() =>
|
const availableProviders = computed(() =>
|
||||||
providers.value.filter((p) => p.available && [...(p.models || []), ...(p.extraModels || [])].length > 0)
|
providers.value.filter((p) => p.available && [...(p.models || []), ...(p.extraModels || [])].length > 0)
|
||||||
)
|
)
|
||||||
@ -1049,19 +1158,53 @@ async function loadModelState() {
|
|||||||
defaultModel.value = defaultRes.data || null
|
defaultModel.value = defaultRes.data || null
|
||||||
providers.value = providersRes.data || []
|
providers.value = providersRes.data || []
|
||||||
activeModels.value = activeRes.data || null
|
activeModels.value = activeRes.data || null
|
||||||
const providerId = activeModels.value?.activeLlm?.providerId
|
recomputePromptFlags()
|
||||||
const activeProviderInfo = providerId
|
|
||||||
? providers.value.find((provider) => provider.id === providerId)
|
|
||||||
: null
|
|
||||||
showModelPrompt.value = !activeModels.value?.activeLlm?.providerId
|
|
||||||
|| !activeModels.value?.activeLlm?.model
|
|
||||||
|| (Boolean(providerId) && !activeProviderInfo?.available)
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ElMessage.error(t('chat.loadModelFailed'))
|
ElMessage.error(t('chat.loadModelFailed'))
|
||||||
showModelPrompt.value = true
|
blockingPrompt.value = true
|
||||||
|
recoverablePrompt.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Issue #81 v2 R2: derive blocking / recoverable prompt flags from the current
|
||||||
|
* providers + active model snapshot. Called from loadModelState after every
|
||||||
|
* /providers refresh, and from a watcher when the user switches model. The
|
||||||
|
* runtime fallback chain in NodeStreamingChatHelper picks the first LIVE
|
||||||
|
* provider regardless of which one is "active", so as long as ANY provider is
|
||||||
|
* LIVE we should NOT block — we just hint with a banner.
|
||||||
|
*/
|
||||||
|
function recomputePromptFlags() {
|
||||||
|
const active = activeModels.value?.activeLlm
|
||||||
|
if (!active?.providerId || !active?.model) {
|
||||||
|
blockingPrompt.value = true
|
||||||
|
recoverablePrompt.value = false
|
||||||
|
recoverableDismissed.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const ap = providers.value.find(p => p.id === active.providerId) || null
|
||||||
|
const apHasModels = ap
|
||||||
|
? ((ap.models?.length || 0) + (ap.extraModels?.length || 0)) > 0
|
||||||
|
: false
|
||||||
|
const activeUsable = ap?.liveness === 'LIVE' && apHasModels
|
||||||
|
if (activeUsable) {
|
||||||
|
blockingPrompt.value = false
|
||||||
|
recoverablePrompt.value = false
|
||||||
|
recoverableDismissed.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const anyUsable = providers.value.some(p =>
|
||||||
|
p.liveness === 'LIVE'
|
||||||
|
&& ((p.models?.length || 0) + (p.extraModels?.length || 0)) > 0)
|
||||||
|
blockingPrompt.value = !anyUsable
|
||||||
|
recoverablePrompt.value = anyUsable && !recoverableDismissed.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issue #81 v2 R2: keep blocking/recoverable in sync with the providers list
|
||||||
|
// and the active model selection without forcing every mutation site to call
|
||||||
|
// recomputePromptFlags() manually.
|
||||||
|
watch([providers, activeModels], recomputePromptFlags, { deep: true })
|
||||||
|
|
||||||
async function loadConversations() {
|
async function loadConversations() {
|
||||||
try {
|
try {
|
||||||
const res: any = await conversationApi.list()
|
const res: any = await conversationApi.list()
|
||||||
@ -1315,8 +1458,12 @@ async function clearMessages() {
|
|||||||
|
|
||||||
// onModelChange removed — replaced by selectModel()
|
// onModelChange removed — replaced by selectModel()
|
||||||
|
|
||||||
function goToModelSettings() {
|
function goToModelSettings(providerId?: string) {
|
||||||
router.push('/settings/models')
|
// Issue #81: when called with a providerId (e.g. from the unhealthy popup or
|
||||||
|
// ModelSelector's Fix button), pass it as a query param so a follow-up PR can
|
||||||
|
// scroll/focus the right card on the settings page. Today the consumer just
|
||||||
|
// ignores it; harmless meanwhile.
|
||||||
|
router.push({ path: '/settings/models', query: providerId ? { focus: providerId } : {} })
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ 计算属性:是否有待审批 ============
|
// ============ 计算属性:是否有待审批 ============
|
||||||
@ -1370,7 +1517,7 @@ async function handleSendMessage(content: string) {
|
|||||||
// 允许在等待审批时发送审批命令
|
// 允许在等待审批时发送审批命令
|
||||||
const isApprovalCommand = /^\/(approve|deny)$/i.test(content.trim())
|
const isApprovalCommand = /^\/(approve|deny)$/i.test(content.trim())
|
||||||
|
|
||||||
if ((!content && pendingAttachments.value.length === 0) || !selectedAgentId.value || showModelPrompt.value) return
|
if ((!content && pendingAttachments.value.length === 0) || !selectedAgentId.value || blockingPrompt.value) return
|
||||||
// 不再阻止运行中发送 — useChat 会自动走 interrupt/queue 路径
|
// 不再阻止运行中发送 — useChat 会自动走 interrupt/queue 路径
|
||||||
|
|
||||||
// 拦截 /approve 和 /deny 命令 —— 通过 SSE 流发送(和普通消息相同通道)
|
// 拦截 /approve 和 /deny 命令 —— 通过 SSE 流发送(和普通消息相同通道)
|
||||||
@ -1421,12 +1568,15 @@ async function handleSendMessage(content: string) {
|
|||||||
currentConversationId.value = `conv_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
currentConversationId.value = `conv_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Issue #81 v2 R2: only abort when there is genuinely no usable provider.
|
||||||
|
// If the active provider is unhealthy but another is LIVE, let the request
|
||||||
|
// through — NodeStreamingChatHelper's fallback walker will pick it up and
|
||||||
|
// emit a "warning" SSE delta which the input handler surfaces as a toast.
|
||||||
if (!activeModels.value?.activeLlm?.providerId || !activeModels.value?.activeLlm?.model) {
|
if (!activeModels.value?.activeLlm?.providerId || !activeModels.value?.activeLlm?.model) {
|
||||||
showModelPrompt.value = true
|
blockingPrompt.value = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!activeProvider.value?.available) {
|
if (blockingPrompt.value) {
|
||||||
showModelPrompt.value = true
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2523,6 +2673,30 @@ function handleCodeCopy(e: MouseEvent) {
|
|||||||
background: var(--mc-primary-hover);
|
background: var(--mc-primary-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Issue #81: side-by-side primary + secondary actions in the model prompt. */
|
||||||
|
.model-prompt-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--mc-text-primary);
|
||||||
|
border: 1px solid var(--mc-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: var(--mc-panel-raised);
|
||||||
|
border-color: var(--mc-primary);
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== 移动端元素(桌面端隐藏) ===== */
|
/* ===== 移动端元素(桌面端隐藏) ===== */
|
||||||
.conv-backdrop {
|
.conv-backdrop {
|
||||||
display: none;
|
display: none;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user