mateclaw/mateclaw-ui/src/views/Settings/Models/modals/ProviderConfigModal.vue
matevip 7b12c5f0c9 feat(llm): provider health tracker + UI editor for failover priority
UI — Failover priority editor
- ProviderConfigRequest + ProviderInfoDTO carry fallbackPriority
- ModelProviderService.updateProviderConfig persists it (null = unchanged);
  toProviderInfo exposes the current value to the UI (defaults to 0)
- ProviderConfigModal advanced panel exposes a number input with hint
- ProviderCard shows a "Fallback #N" badge for chain members so the
  priority order is visible at a glance without opening the modal
- 5 new i18n keys (zh + en) — verified to resolve at runtime via i18n.global.t

Backend — Per-provider health tracker
- ProviderHealthTracker: ConcurrentHashMap-backed counters; N consecutive
  failures (default 3) push the provider into a cooldown window (default
  5 min) during which the chain walker skips it. Success resets both
  counter and cooldown atomically. Lazy expiry on lookup so dead entries
  do not accumulate.
- ProviderHealthProperties exposed under mateclaw.llm.failover.health.*
  with sane production defaults
- New FallbackEntry record (providerId + ChatModel) replaces raw
  List<ChatModel> in the chain so the walker can correlate cooldown
  state to entries; AgentGraphBuilder.buildFallbackChain returns the
  new type
- NodeStreamingChatHelper takes the tracker through a new 4-arg
  constructor and consults it before each fallback call; records
  success/failure on each chain attempt. Legacy 2/3-arg constructors
  preserved as @Deprecated wrappers (synthetic providerId means no
  health tracking on the legacy path — that path is opt-out anyway)

Tests
- ProviderHealthTrackerTest (9 tests): below/at threshold, success
  reset, cooldown expiry (via reflection on the min-clamp setter),
  disabled-tracker no-op, null-providerId safety, per-provider
  isolation, snapshot output
- NodeStreamingChatHelperFallbackChainTest updated to FallbackEntry
  field type — verifies providerId + ChatModel survive the chain
- 168 tests pass (was 159 + 9 new)

Verification
- mvn test green; vue-tsc clean; live UI confirms i18n resolution
2026-04-19 16:57:03 +08:00

240 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div v-if="show" class="modal-overlay">
<div class="modal">
<div class="modal-header">
<h2>{{ editingProvider ? t('settings.model.editTitle') : t('settings.model.createTitle') }}</h2>
<button class="modal-close" @click="$emit('close')">×</button>
</div>
<div class="modal-body">
<div class="form-grid">
<div class="form-group" v-if="!editingProvider">
<label class="form-label">{{ t('settings.model.fields.providerId') }}</label>
<input v-model="form.id" class="form-input" />
</div>
<div class="form-group" v-if="!editingProvider">
<label class="form-label">{{ t('settings.model.fields.providerName') }}</label>
<input v-model="form.name" class="form-input" />
</div>
<div class="form-group">
<label class="form-label">{{ t('settings.model.baseUrl') }}</label>
<input
v-model="form.baseUrl"
class="form-input mono"
:placeholder="baseUrlPlaceholder"
/>
<div class="field-hint">{{ baseUrlHint }}</div>
</div>
<!-- OAuth 登录区域auth_type === 'oauth' 时显示) -->
<div v-if="editingProvider?.authType === 'oauth'" class="form-group full-width oauth-group">
<label class="form-label">{{ t('settings.model.oauthTitle') }}</label>
<div class="oauth-status-row">
<span v-if="editingProvider?.oauthConnected" class="oauth-badge oauth-connected">
{{ t('settings.model.oauthConnected') }}
</span>
<span v-else class="oauth-badge oauth-disconnected">
{{ t('settings.model.oauthDisconnected') }}
</span>
<button
v-if="!editingProvider?.oauthConnected"
class="btn-oauth"
type="button"
@click="$emit('oauthLogin')"
>
{{ t('settings.model.oauthLogin') }}
</button>
<button
v-else
class="btn-oauth btn-oauth-revoke"
type="button"
@click="$emit('oauthRevoke')"
>
{{ t('settings.model.oauthDisconnect') }}
</button>
</div>
<div class="field-hint">{{ t('settings.model.oauthHint') }}</div>
</div>
<!-- API Key 输入区域(非 OAuth 时显示) -->
<div v-else class="form-group">
<label class="form-label">{{ t('settings.model.apiKey') }}</label>
<input
v-model="form.apiKey"
type="password"
class="form-input mono"
:placeholder="apiKeyPlaceholder"
autocomplete="off"
/>
<div class="field-hint">{{ t('settings.model.leaveBlankKeep') }}</div>
</div>
<div class="form-group">
<label class="form-label">{{ t('settings.model.fields.apiKeyPrefix') }}</label>
<input v-model="form.apiKeyPrefix" class="form-input" />
</div>
<div class="form-group">
<label class="form-label">{{ t('settings.model.fields.protocol') }}</label>
<select
v-model="form.protocol"
class="form-input"
:disabled="!!editingProvider && !editingProvider.isCustom"
>
<option v-for="item in protocolOptions" :key="item.value" :value="item.value">
{{ item.label }}
</option>
</select>
<div class="field-hint">{{ t('settings.model.protocolHint') }}</div>
</div>
<!-- 内置搜索开关:仅 DashScope 和 OpenAI 协议显示 -->
<div
v-if="form.protocol === 'dashscope-native' || form.protocol === 'openai-compatible'"
class="form-group full-width search-group"
>
<div class="search-toggle-row">
<label class="form-label" style="margin-bottom: 0">{{ t('settings.model.fields.enableSearch') }}</label>
<label class="toggle-switch">
<input type="checkbox" v-model="form.enableSearch" />
<span class="toggle-slider"></span>
</label>
</div>
<div class="field-hint">{{ t('settings.model.searchHint') }}</div>
<div v-if="form.enableSearch" class="search-strategy-row">
<label class="form-label">{{ t('settings.model.fields.searchStrategy') }}</label>
<select v-model="form.searchStrategy" class="form-input">
<template v-if="form.protocol === 'dashscope-native'">
<option value="">{{ t('settings.model.searchStrategyDefault') }}</option>
<option value="agent">agent</option>
<option value="agent_max">agent_max</option>
</template>
<template v-else>
<option value="">{{ t('settings.model.searchStrategyDefault') }}</option>
<option value="low">low</option>
<option value="medium">medium</option>
<option value="high">high</option>
</template>
</select>
</div>
</div>
<div class="form-group full-width advanced-group">
<button type="button" class="advanced-toggle" @click="$emit('toggleAdvanced')">
<span>{{ t('settings.model.advancedSettings') }}</span>
<span>{{ advancedOpen ? '' : '+' }}</span>
</button>
<div v-if="advancedOpen" class="advanced-panel">
<!-- RFC-009 P3.5: failover chain priority editor.
0 = excluded; 1..N defines try-order after primary fails. -->
<label class="form-label">{{ t('settings.model.fields.fallbackPriority') }}</label>
<input
v-model.number="form.fallbackPriority"
type="number"
min="0"
max="99"
step="1"
class="form-input"
style="max-width: 160px"
/>
<div class="field-hint">{{ t('settings.model.fallbackPriorityHint') }}</div>
<label class="form-label" style="margin-top: 14px">{{ t('settings.model.fields.generateKwargs') }}</label>
<textarea v-model="form.generateKwargsText" rows="6" class="form-textarea mono"></textarea>
<div class="field-hint">{{ t('settings.model.advancedHint') }}</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn-secondary" @click="$emit('close')">{{ t('common.cancel') }}</button>
<button class="btn-primary" @click="$emit('save')">
{{ editingProvider ? t('common.save') : t('common.create') }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { ProviderInfo } from '@/types'
const { t } = useI18n()
defineProps<{
show: boolean
editingProvider: ProviderInfo | null
form: {
id: string
name: string
baseUrl: string
apiKey: string
apiKeyPrefix: string
protocol: string
chatModel: string
generateKwargsText: string
enableSearch: boolean
searchStrategy: string
fallbackPriority: number
}
advancedOpen: boolean
protocolOptions: Array<{ value: string; label: string }>
baseUrlPlaceholder: string
baseUrlHint: string
apiKeyPlaceholder: string
}>()
defineEmits<{
close: []
save: []
toggleAdvanced: []
oauthLogin: []
oauthRevoke: []
}>()
</script>
<style scoped>
.modal-overlay { position: fixed; inset: 0; background: rgba(124, 63, 30, 0.45); display: flex; align-items: center; justify-content: center; padding: 20px; z-index: 40; }
.modal { width: min(760px, 100%); max-height: calc(100vh - 40px); background: var(--mc-bg-elevated); border: 1px solid var(--mc-border); border-radius: 18px; overflow: hidden; display: flex; flex-direction: column; }
.modal-header, .modal-footer { display: flex; align-items: center; justify-content: space-between; padding: 18px 20px; border-bottom: 1px solid var(--mc-border-light); }
.modal-header h2 { color: var(--mc-text-primary); margin: 0; font-size: 18px; display: flex; align-items: center; }
.modal-footer { border-top: 1px solid var(--mc-border-light); border-bottom: none; justify-content: flex-end; gap: 10px; }
.modal-close { background: none; border: none; font-size: 24px; line-height: 1; cursor: pointer; color: var(--mc-text-secondary); }
.modal-close:hover { color: var(--mc-text-primary); }
.modal-body { padding: 20px; overflow-y: auto; flex: 1; min-height: 0; }
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
.form-group.full-width { grid-column: 1 / -1; }
.form-label { display: block; font-size: 13px; color: var(--mc-text-secondary); margin-bottom: 6px; }
.form-input, .form-textarea { width: 100%; border: 1px solid var(--mc-border); border-radius: 10px; padding: 10px 12px; font-size: 14px; background: var(--mc-bg-sunken); color: var(--mc-text-primary); }
.form-input:focus, .form-textarea:focus { outline: none; border-color: var(--mc-primary); box-shadow: 0 0 0 2px rgba(217, 119, 87, 0.1); }
.form-textarea { resize: vertical; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
.field-hint { margin-top: 6px; font-size: 12px; line-height: 1.5; color: var(--mc-text-tertiary); }
.advanced-group { margin-top: 4px; }
.advanced-toggle { display: flex; align-items: center; justify-content: space-between; width: 100%; padding: 10px 12px; border: 1px solid var(--mc-border); border-radius: 10px; background: var(--mc-bg-sunken); color: var(--mc-text-primary); font-size: 13px; font-weight: 600; cursor: pointer; }
.advanced-panel { margin-top: 10px; padding: 14px; border: 1px solid var(--mc-border); border-radius: 12px; background: var(--mc-bg-sunken); }
.btn-primary, .btn-secondary { border: none; border-radius: 10px; padding: 9px 14px; font-size: 14px; cursor: pointer; transition: all 0.15s; }
.btn-primary { background: var(--mc-primary); color: white; }
.btn-primary:hover { background: var(--mc-primary-hover); }
.btn-secondary { background: var(--mc-bg-elevated); color: var(--mc-text-primary); border: 1px solid var(--mc-border); }
.btn-secondary:hover { background: var(--mc-bg-sunken); }
.search-group { margin-top: 4px; }
.search-toggle-row { display: flex; align-items: center; justify-content: space-between; }
.search-strategy-row { margin-top: 10px; }
.toggle-switch { position: relative; display: inline-block; width: 40px; height: 22px; cursor: pointer; }
.toggle-switch input { opacity: 0; width: 0; height: 0; }
.toggle-slider { position: absolute; inset: 0; background: var(--mc-border); border-radius: 22px; transition: 0.2s; }
.toggle-slider::before { content: ''; position: absolute; height: 16px; width: 16px; left: 3px; bottom: 3px; background: white; border-radius: 50%; transition: 0.2s; }
.toggle-switch input:checked + .toggle-slider { background: var(--mc-primary); }
.toggle-switch input:checked + .toggle-slider::before { transform: translateX(18px); }
.oauth-group { margin-top: 4px; }
.oauth-status-row { display: flex; align-items: center; gap: 12px; margin-top: 6px; }
.oauth-badge { display: inline-flex; align-items: center; padding: 4px 10px; border-radius: 8px; font-size: 13px; font-weight: 600; }
.oauth-connected { background: rgba(34, 197, 94, 0.12); color: #22c55e; }
.oauth-disconnected { background: rgba(156, 163, 175, 0.12); color: var(--mc-text-tertiary); }
.btn-oauth { border: none; border-radius: 10px; padding: 8px 16px; font-size: 13px; font-weight: 600; cursor: pointer; background: var(--mc-primary); color: white; transition: all 0.15s; }
.btn-oauth:hover { background: var(--mc-primary-hover); }
.btn-oauth-revoke { background: rgba(239, 68, 68, 0.1); color: #ef4444; }
.btn-oauth-revoke:hover { background: rgba(239, 68, 68, 0.2); }
@media (max-width: 900px) {
.form-grid { grid-template-columns: 1fr; }
}
</style>