diff --git a/frontend/src/components/ExerciseResearchPanel.jsx b/frontend/src/components/ExerciseResearchPanel.jsx index 4fdb640..5a4b585 100644 --- a/frontend/src/components/ExerciseResearchPanel.jsx +++ b/frontend/src/components/ExerciseResearchPanel.jsx @@ -1,46 +1,119 @@ -import { useState } from 'react' +import { useState, useCallback } from 'react' import ResearchDisplay from './ResearchDisplay' const API_URL = '/api' +const PROVIDER_LABELS = { + ollama: { label: 'Local AI', icon: '🏠', className: 'provider-local' }, + gemini: { label: 'Gemini', icon: '✨', className: 'provider-gemini' }, + openrouter: { label: 'OpenRouter', icon: '🔀', className: 'provider-openrouter' }, + opencode: { label: 'OpenCode', icon: '💡', className: 'provider-opencode' }, + exa: { label: 'Exa Search', icon: '🔍', className: 'provider-exa' }, +} + +const ERROR_MESSAGES = { + 'Failed to fetch': 'Network error — check your connection and try again.', + 'Load failed': 'Network error — check your connection and try again.', + '429': 'AI provider is rate-limited. Retrying with fallback…', + '503': 'Service unavailable. Retrying with fallback…', + '500': 'Server error while fetching research.', +} + +function normalizeErrorMessage(err) { + for (const [key, msg] of Object.entries(ERROR_MESSAGES)) { + if (err.message.includes(key)) return msg + } + return err.message || 'An unexpected error occurred.' +} + +function ProviderBadge({ provider }) { + if (!provider) return null + const meta = PROVIDER_LABELS[provider] || { label: provider, icon: '🤖', className: 'provider-unknown' } + return ( + + {meta.icon} {meta.label} + + ) +} + function ExerciseResearchPanel({ exerciseId, exerciseName }) { const [loading, setLoading] = useState(false) const [research, setResearch] = useState(null) const [error, setError] = useState(null) + const [provider, setProvider] = useState(null) + const [retryCount, setRetryCount] = useState(0) - const fetchResearch = async () => { + const fetchResearch = useCallback(async (attempt = 0) => { setLoading(true) setError(null) + try { const res = await fetch(`${API_URL}/exercises/${exerciseId}/research`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }) + if (!res.ok) { - const data = await res.json() - throw new Error(data.error || 'Failed to fetch research') + let errMsg = `Request failed (${res.status})` + try { + const data = await res.json() + errMsg = data.error || errMsg + } catch { + // ignore JSON parse error + } + + // Auto-retry once on 429/503 (provider fallback happening on backend) + if ((res.status === 429 || res.status === 503) && attempt < 1) { + console.warn(`[Research] Status ${res.status}, retrying (attempt ${attempt + 1})…`) + setRetryCount(c => c + 1) + setTimeout(() => fetchResearch(attempt + 1), 2000) + return + } + + throw new Error(errMsg) } + const data = await res.json() setResearch(data) + + // Extract provider from stored record or response metadata + const detectedProvider = + data.provider || + (data.stored && data.stored.provider) || + null + setProvider(detectedProvider) + setRetryCount(0) } catch (err) { - setError(err.message) + console.error('[Research] Fetch failed:', err) + setError(normalizeErrorMessage(err)) } finally { setLoading(false) } - } + }, [exerciseId]) + + const handleFetch = () => fetchResearch(0) return (

Research

- +
+ {provider && } + +
setError(null)} + onRetry={handleFetch} />
) diff --git a/frontend/src/components/ResearchDisplay.jsx b/frontend/src/components/ResearchDisplay.jsx index 227c5d7..34198bc 100644 --- a/frontend/src/components/ResearchDisplay.jsx +++ b/frontend/src/components/ResearchDisplay.jsx @@ -9,16 +9,23 @@ function ResearchLoadingSkeleton({ exerciseName }) { ) } -function ResearchError({ message, onDismiss }) { +function ResearchError({ message, onDismiss, onRetry }) { return (
{message} - {onDismiss && ( - - )} +
+ {onRetry && ( + + )} + {onDismiss && ( + + )} +
) } @@ -52,14 +59,15 @@ function ResearchSourceCard({ result, index }) { * data {object} Research data: { summary, results } * name {string} Exercise name (shown during loading) * onDismiss {function} Clear error callback + * onRetry {function} Retry fetch callback */ -function ResearchDisplay({ loading, error, data, name, onDismiss }) { +function ResearchDisplay({ loading, error, data, name, onDismiss, onRetry }) { if (loading) { return } if (error) { - return + return } if (!data) return null diff --git a/frontend/src/pages/ExerciseEncyclopediaPage.css b/frontend/src/pages/ExerciseEncyclopediaPage.css index 2a6cdd2..09ae0bc 100644 --- a/frontend/src/pages/ExerciseEncyclopediaPage.css +++ b/frontend/src/pages/ExerciseEncyclopediaPage.css @@ -563,3 +563,50 @@ padding: var(--space-2) var(--space-3) var(--space-3); } } + +/* ============================================ + PROVIDER BADGE — AI fallback indicator + ============================================ */ + +.research-panel-controls { + display: flex; + align-items: center; + gap: var(--space-2); +} + +.provider-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: 9999px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.03em; + background: var(--bg-elevated); + border: 1px solid var(--border); + color: var(--text-muted); + white-space: nowrap; +} + +.provider-local { border-color: rgba(34, 197, 94, 0.4); color: #4ade80; } +.provider-gemini { border-color: rgba(99, 102, 241, 0.4); color: #818cf8; } +.provider-openrouter { border-color: rgba(234, 179, 8, 0.4); color: #facc15; } +.provider-opencode { border-color: rgba(251, 146, 60, 0.4); color: #fb923c; } +.provider-exa { border-color: rgba(56, 189, 248, 0.4); color: #38bdf8; } +.provider-unknown { border-color: var(--border); color: var(--text-muted); } + +/* Error actions row */ +.rd-error-actions { + display: flex; + align-items: center; + gap: var(--space-2); + flex-shrink: 0; +} + +.rd-retry { + padding: 2px 10px; + font-size: var(--font-xs); + min-height: 28px; + border-radius: var(--radius-sm); +}