fix(web): explain ai provider fallback state
All checks were successful
CD Pipeline / tests (push) Successful in 1m31s
Code Review / ai-code-review (push) Successful in 13s
CD Pipeline / build-and-deploy (push) Successful in 4m19s
CD Pipeline / post-deploy-checks (push) Successful in 1m30s

This commit is contained in:
Your Name
2026-06-04 09:46:35 +08:00
parent 017dba8b00
commit a56580fc11
4 changed files with 150 additions and 1 deletions

View File

@@ -24,6 +24,7 @@ interface ModelInfo {
interface HealthComponent {
status?: 'up' | 'down' | 'degraded'
latency_ms?: number | null
provider_name?: string | null
diagnosis_code?: string | null
retry_after_seconds?: number | null
is_cooldown?: boolean
@@ -49,6 +50,12 @@ const PROVIDER_ROLES: Record<string, ModelInfo['role']> = {
openclaw: 'agent',
}
interface RouteSummary {
tone: 'healthy' | 'warning' | 'critical' | 'unknown'
title: string
detail: string
}
function statusColor(status: ModelInfo['status']) {
if (status === 'up') return '#22C55E'
if (status === 'degraded') return '#F59E0B'
@@ -56,6 +63,19 @@ function statusColor(status: ModelInfo['status']) {
return '#87867f'
}
function summaryToneStyle(tone: RouteSummary['tone']) {
if (tone === 'healthy') {
return { border: '#b8d8bd', background: '#f0faf2', color: '#17602a' }
}
if (tone === 'warning') {
return { border: '#d9b36f', background: '#fff7e8', color: '#7a4d05' }
}
if (tone === 'critical') {
return { border: '#e1aaa2', background: '#fff3f1', color: '#9f2f25' }
}
return { border: '#d8d3c7', background: '#faf9f3', color: '#5f5b52' }
}
function modelDetail(model: ModelInfo, t: ReturnType<typeof useTranslations>) {
if (typeof model.latencyMs === 'number' && model.status === 'up') {
return `${Math.round(model.latencyMs)}ms`
@@ -81,6 +101,60 @@ function modelDetail(model: ModelInfo, t: ReturnType<typeof useTranslations>) {
return t(`aiModelRoles.${model.role}` as never)
}
function buildRouteSummary(
components: Record<string, HealthComponent>,
t: ReturnType<typeof useTranslations>,
): RouteSummary {
const aggregate = components.ollama
const selected = aggregate?.provider_name
const selectedLabel = selected ? (PROVIDER_LABELS[selected] ?? selected) : '--'
const gcpAUp = components.ollama_gcp_a?.status === 'up'
const gcpBUp = components.ollama_gcp_b?.status === 'up'
const local = components.ollama_local
const localDown = local?.status === 'down' || local?.status === 'degraded'
const localCooling = Boolean(local?.is_cooldown)
if (aggregate?.status === 'up' && localDown && (gcpAUp || gcpBUp)) {
return {
tone: 'warning',
title: t('aiModelSummary.localFallbackDownTitle', { provider: selectedLabel }),
detail: localCooling
? t('aiModelSummary.localFallbackCooldownDetail')
: t('aiModelSummary.localFallbackDownDetail'),
}
}
if (aggregate?.status === 'up') {
return {
tone: 'healthy',
title: t('aiModelSummary.healthyTitle', { provider: selectedLabel }),
detail: t('aiModelSummary.healthyDetail'),
}
}
if (aggregate?.status === 'degraded') {
return {
tone: 'warning',
title: t('aiModelSummary.degradedTitle', { provider: selectedLabel }),
detail: t('aiModelSummary.degradedDetail'),
}
}
if (aggregate?.status === 'down') {
return {
tone: 'critical',
title: t('aiModelSummary.downTitle'),
detail: t('aiModelSummary.downDetail'),
}
}
return {
tone: 'unknown',
title: t('aiModelSummary.unknownTitle'),
detail: t('aiModelSummary.unknownDetail'),
}
}
export function AIModelStatus() {
const t = useTranslations('dashboard')
const [models, setModels] = useState<ModelInfo[]>([
@@ -89,12 +163,18 @@ export function AIModelStatus() {
{ name: '111', role: 'local', status: 'unknown' },
{ name: 'OpenClaw', role: 'agent', status: 'unknown' },
])
const [summary, setSummary] = useState<RouteSummary>({
tone: 'unknown',
title: t('aiModelSummary.unknownTitle'),
detail: t('aiModelSummary.unknownDetail'),
})
useEffect(() => {
fetch(`${API_BASE}/api/v1/health`)
.then(r => r.ok ? r.json() : null)
.then((d: HealthResponse | null) => {
if (!d?.components) return
setSummary(buildRouteSummary(d.components, t))
const routeOrder = d.ollama_route_order?.length
? d.ollama_route_order
: ['ollama_gcp_a', 'ollama_gcp_b', 'ollama_local']
@@ -110,7 +190,9 @@ export function AIModelStatus() {
})))
})
.catch(() => {})
}, [])
}, [t])
const summaryStyle = summaryToneStyle(summary.tone)
return (
<div style={{
@@ -124,6 +206,20 @@ export function AIModelStatus() {
<div style={{ width: 6, height: 6, borderRadius: '50%', background: '#d97757' }} />
<span style={{ fontSize: 14, fontWeight: 700, color: '#141413' }}>{t('aiModelStatus')}</span>
</div>
<div style={{
margin: '12px 14px 0',
border: `0.5px solid ${summaryStyle.border}`,
background: summaryStyle.background,
borderRadius: 6,
padding: '8px 10px',
}}>
<div style={{ fontSize: 12, fontWeight: 700, color: '#141413', lineHeight: 1.35 }}>
{summary.title}
</div>
<div style={{ marginTop: 3, fontSize: 11, color: summaryStyle.color, lineHeight: 1.45 }}>
{summary.detail}
</div>
</div>
<div style={{ padding: 14, display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6 }}>
{models.map(m => (
<div key={m.name} style={{