mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +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 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<>();
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 / 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 {
|
||||
// 旧后端不返回 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 {
|
||||
|
||||
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
|
||||
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',
|
||||
|
||||
@ -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: '配置中心',
|
||||
|
||||
@ -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>
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user