fix(chat,llm): liveness-aware model popup with row-based configured check (#81)

This commit is contained in:
matevip 2026-05-09 11:07:08 +08:00
parent 95b7fed6d5
commit d5969bb32c
9 changed files with 787 additions and 72 deletions

View File

@ -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);
}
}

View File

@ -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<String, Object> suggestedActionHintArgs = new LinkedHashMap<>();
}

View File

@ -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<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) {
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) {

View File

@ -34,25 +34,47 @@
<div class="model-group-header">
<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>
<!-- RFC-073: liveness dot. UNPROBED = grey (still booting),
COOLDOWN = amber (transient backoff). LIVE has no dot. -->
<!-- Liveness dot UNPROBED = grey, COOLDOWN = amber, LIVE = none.
v2 R3: when showAllStates, also surface UNCONFIGURED / REMOVED chips
so the user can see what's wrong without leaving chat. -->
<span
v-if="group.provider.liveness === 'UNPROBED'"
class="model-group-header__dot model-group-header__dot--unprobed"
:title="$t('chat.modelLivenessUnprobed')"
:title="$t('provider.status.unprobed')"
></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) })"
:title="$t('provider.status.cooldown', { s: cooldownSeconds(group.provider) })"
></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
v-for="item in group.models"
:key="item.value"
class="model-dropdown-item"
:class="{ active: item.value === activeValue, dimmed: group.provider.liveness === 'COOLDOWN' || group.provider.liveness === 'UNPROBED' }"
@click="handleSelect(item.value)"
:class="{
active: item.value === activeValue,
dimmed: !isSelectable(group.provider),
}"
@click="onItemClick(group.provider, item.value)"
>
<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>
@ -66,7 +88,7 @@
state. Push them into Settings/Models with the drawer
pre-opened via ?addProvider=1. -->
<template v-if="query.trim() === '' && groups.length === 0">
{{ $t('chat.noProvidersConfigured') }}
{{ emptyHint || $t('chat.noProvidersConfigured') }}
<RouterLink class="model-empty__cta" to="/settings/models?addProvider=1" @click="open = false">
{{ $t('chat.goConfigure') }}
</RouterLink>
@ -106,10 +128,19 @@ const props = defineProps<{
activeValue: string
activeLabel: string
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<{
select: [value: string]
'navigate-fix': [provider: ProviderInfo]
}>()
const open = ref(false)
@ -143,13 +174,20 @@ function toggle() {
}
// provider
// RFC-073: UNCONFIGURED / REMOVEDUNPROBED + 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 {
// liveness 退 available UI
if (props.showAllStates) return false
if (!p.liveness) return !p.available
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 cloud: ModelGroup[] = []
const local: ModelGroup[] = []
@ -157,7 +195,19 @@ const groups = computed<ModelGroup[]>(() => {
for (const provider of props.providers) {
if (isHidden(provider)) continue
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 = {
provider,
@ -213,6 +263,25 @@ function handleSelect(value: string) {
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) => {
if (isOpen) {
@ -385,6 +454,52 @@ watch(open, async (isOpen) => {
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 ---- */
.model-dropdown-item {

View 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>

View File

@ -180,6 +180,49 @@ export default {
// RFC-074 PR-2: empty-state inside the model dropdown
noProvidersConfigured: 'No models available yet',
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',
dropToUpload: 'Drop files or folders here',
copyFailed: 'Copy failed',
@ -429,6 +472,22 @@ export default {
lastChecked: 'Checked {time} ago',
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: {
title: 'Settings',
kicker: 'Configuration',

View File

@ -180,6 +180,49 @@ export default {
// RFC-074 PR-2: empty-state inside the model dropdown
noProvidersConfigured: '还没有可用的模型',
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: '文件上传失败',
dropToUpload: '拖放文件或文件夹到此处',
copyFailed: '复制失败',
@ -315,6 +358,22 @@ export default {
roleUser: '用户',
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: {
title: '设置',
kicker: '配置中心',

View File

@ -742,6 +742,24 @@ export interface ProviderInfo {
cooldownRemainingMs?: number
/** RFC-074: whether the user has explicitly opted this provider into the dropdown. */
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>
}
/**

View File

@ -174,18 +174,18 @@
<div v-else class="no-agent-hint">{{ $t('chat.selectAgent') }}</div>
</div>
<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
v-if="eligibleModels.length > 0"
:providers="availableProviders"
:providers="providers"
:active-value="activeModelValue"
:active-label="activeModelLabel"
:saving="modelSaving"
:show-all-states="true"
@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 -->
<div class="header-overflow-wrap">
<button class="header-btn" @click="headerMenuOpen = !headerMenuOpen" :title="$t('common.more') || 'More'">
@ -218,25 +218,46 @@
:loading="isGenerating"
:assistant-icon="currentAgent?.icon || '🤖'"
:user-icon="userInitial"
:title="showModelPrompt ? modelPromptTitle : $t('app.title')"
:subtitle="showModelPrompt ? modelPromptDesc : $t('chat.subtitle')"
:suggestions="showModelPrompt ? [] : suggestions"
:title="blockingPrompt ? modelPromptText.title : $t('app.title')"
:subtitle="blockingPrompt ? modelPromptText.desc : $t('chat.subtitle')"
:suggestions="blockingPrompt ? [] : suggestions"
@regenerate="handleRegenerate"
@suggestion-click="sendSuggestion"
@toggle-thinking="handleToggleThinking"
@approve="handleApprove"
@deny="handleDeny"
>
<!-- 自定义模型提示空状态 -->
<template v-if="showModelPrompt" #empty>
<!-- Issue #81 v2 R2: blocking-only popup. Recoverable cases use the
non-blocking <RecoverableModelBanner> below instead. -->
<template v-if="blockingPrompt" #empty>
<div class="model-prompt">
<div class="model-prompt-title">{{ modelPromptTitle }}</div>
<div class="model-prompt-desc">{{ modelPromptDesc }}</div>
<button class="btn-primary" @click="goToModelSettings">{{ $t('chat.goToModelSettings') }}</button>
<div class="model-prompt-title">{{ modelPromptText.title }}</div>
<div class="model-prompt-desc">{{ modelPromptText.desc }}</div>
<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>
</template>
</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
the assistant message yet. Populated by pollActivity /cron-jobs/active-runs. -->
<div v-if="activeCronRuns.length > 0" class="cron-running-bar">
@ -254,7 +275,7 @@
<!-- 流式处理 Loading 消息和输入框之间 -->
<StreamLoadingBar
:is-loading="isGenerating && !showModelPrompt"
:is-loading="isGenerating && !blockingPrompt"
:tool-count="toolCallCount"
:completion-tokens="currentGeneratingTokens"
:prompt-tokens="currentPromptTokens"
@ -270,7 +291,7 @@
ref="chatInputRef"
v-model="inputText"
:loading="isGenerating && !hasPendingApproval"
:disabled="showModelPrompt || !currentAgent"
:disabled="blockingPrompt || !currentAgent"
:placeholder="$t('chat.messagePlaceholder')"
:hint="currentRuntimeModel"
:attachments="pendingAttachments"
@ -325,6 +346,7 @@ import type { Conversation, Agent, ModelConfig, ProviderInfo, ActiveModelsInfo,
//
import MessageList from '@/components/chat/MessageList.vue'
import RecoverableModelBanner from '@/components/chat/RecoverableModelBanner.vue'
import SkillIcon from '@/components/common/SkillIcon.vue'
import { parsePrompt, deriveTagline } from '@/utils/agentPromptProfile'
import { agentIconColor } from '@/utils/agentIconColor'
@ -403,7 +425,13 @@ const selectedAgentId = ref<string | number>('')
const currentConversationId = ref<string>('')
const inputText = ref('')
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 providers = ref<ProviderInfo[]>([])
const activeModels = ref<ActiveModelsInfo | null>(null)
@ -812,26 +840,107 @@ const activeProvider = computed(() => {
return providerId ? providers.value.find((provider) => provider.id === providerId) || null : null
})
const modelPromptTitle = computed(() => {
if (!activeModels.value?.activeLlm?.providerId || !activeModels.value?.activeLlm?.model) {
return t('chat.configModelFirst')
// Issue #81: liveness-aware popup state machine. modelPromptKind picks one of
// six branches; modelPromptText derives title + desc; primaryActionLabel +
// 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(() => {
if (!activeModels.value?.activeLlm?.providerId || !activeModels.value?.activeLlm?.model) {
return t('chat.noActiveModel')
}
if (activeProvider.value && !activeProvider.value.available) {
return t('chat.providerNotReady', { name: activeProvider.value.name })
}
return t('chat.noAvailableModel')
const hintText = computed(() => {
const p = activeProvider.value
if (!p?.suggestedActionHintKey) return ''
return t(p.suggestedActionHintKey, (p.suggestedActionHintArgs || {}) as Record<string, unknown>)
})
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(() =>
providers.value.filter((p) => p.available && [...(p.models || []), ...(p.extraModels || [])].length > 0)
)
@ -1049,19 +1158,53 @@ async function loadModelState() {
defaultModel.value = defaultRes.data || null
providers.value = providersRes.data || []
activeModels.value = activeRes.data || null
const providerId = activeModels.value?.activeLlm?.providerId
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)
recomputePromptFlags()
} catch (e) {
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() {
try {
const res: any = await conversationApi.list()
@ -1315,8 +1458,12 @@ async function clearMessages() {
// onModelChange removed replaced by selectModel()
function goToModelSettings() {
router.push('/settings/models')
function goToModelSettings(providerId?: string) {
// 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())
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
// /approve /deny SSE
@ -1421,12 +1568,15 @@ async function handleSendMessage(content: string) {
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) {
showModelPrompt.value = true
blockingPrompt.value = true
return
}
if (!activeProvider.value?.available) {
showModelPrompt.value = true
if (blockingPrompt.value) {
return
}
@ -2523,6 +2673,30 @@ function handleCodeCopy(e: MouseEvent) {
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 {
display: none;