fix(web): explain ai provider fallback state
This commit is contained in:
@@ -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={{
|
||||
|
||||
Reference in New Issue
Block a user