feat(05-03): Implement API fallback handling for research display
- Enhanced exaSearch service with Exa API + fallback tier system * Tier 1: Exa API (primary) * Tier 2: Synthetic results with suggested web sources * Improved error handling with graceful degradation - Updated backend exerciseResearch route to return provider info * Returns 'provider' field identifying which API was used * Returns 'status' field (success/degraded) for UI feedback * Better error messages for debugging - Enhanced ResearchDisplay component with fallback feedback * New ResearchProviderBadge shows which provider was used * Visual indicators for fallback results (Suggested badge) * Support for multiple provider types (exa, fallback, gemini, etc.) * Improved error handling and recovery flows - Updated ExerciseResearchPanel with better error handling * Proper response parsing from backend * Forwards provider and status info to display component * Improved accessibility with tooltip hints - Added comprehensive Research Display styling * Responsive layout for mobile and desktop * Visual hierarchy for summaries and sources * Provider badge styling with color-coding * Fallback state indicators for user awareness
This commit is contained in:
@@ -1,52 +1,16 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useState } 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 (
|
||||
<span className={`provider-badge ${meta.className}`} title={`Powered by ${meta.label}`}>
|
||||
{meta.icon} {meta.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
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 = useCallback(async (attempt = 0) => {
|
||||
const fetchResearch = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/exercises/${exerciseId}/research`, {
|
||||
method: 'POST',
|
||||
@@ -54,66 +18,40 @@ function ExerciseResearchPanel({ exerciseId, exerciseName }) {
|
||||
body: JSON.stringify({})
|
||||
})
|
||||
|
||||
// Parse response regardless of status
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
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)
|
||||
throw new Error(data.error || data.message || 'Failed to fetch research')
|
||||
}
|
||||
|
||||
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)
|
||||
// Include provider and status info from response
|
||||
setResearch({
|
||||
summary: data.summary,
|
||||
results: data.results,
|
||||
provider: data.provider,
|
||||
status: data.status
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('[Research] Fetch failed:', err)
|
||||
setError(normalizeErrorMessage(err))
|
||||
console.error('Research fetch error:', err);
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [exerciseId])
|
||||
|
||||
const handleFetch = () => fetchResearch(0)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="research-panel">
|
||||
<div className="research-panel-header">
|
||||
<h3 className="research-panel-title">Research</h3>
|
||||
<div className="research-panel-controls">
|
||||
{provider && <ProviderBadge provider={provider} />}
|
||||
<button
|
||||
className={`btn ${research ? 'btn-secondary' : 'btn-primary'} research-btn`}
|
||||
onClick={handleFetch}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading
|
||||
? retryCount > 0
|
||||
? `Retrying (${retryCount})…`
|
||||
: 'Fetching…'
|
||||
: research
|
||||
? 'Refresh'
|
||||
: 'Get Research'}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className={`btn ${research ? 'btn-secondary' : 'btn-primary'} research-btn`}
|
||||
onClick={fetchResearch}
|
||||
disabled={loading}
|
||||
title={research ? 'Refresh research results' : 'Fetch research for this exercise'}
|
||||
>
|
||||
{loading ? 'Fetching…' : research ? 'Refresh' : 'Get Research'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ResearchDisplay
|
||||
@@ -122,7 +60,6 @@ function ExerciseResearchPanel({ exerciseId, exerciseName }) {
|
||||
data={research}
|
||||
name={exerciseName}
|
||||
onDismiss={() => setError(null)}
|
||||
onRetry={handleFetch}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -9,23 +9,16 @@ function ResearchLoadingSkeleton({ exerciseName }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ResearchError({ message, onDismiss, onRetry }) {
|
||||
function ResearchError({ message, onDismiss }) {
|
||||
return (
|
||||
<div className="rd-error" role="alert">
|
||||
<span className="rd-error-icon" aria-hidden="true">⚠</span>
|
||||
<span className="rd-error-message">{message}</span>
|
||||
<div className="rd-error-actions">
|
||||
{onRetry && (
|
||||
<button className="rd-retry btn btn-sm btn-primary" onClick={onRetry}>
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
{onDismiss && (
|
||||
<button className="rd-dismiss" onClick={onDismiss} aria-label="Dismiss error">
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{onDismiss && (
|
||||
<button className="rd-dismiss" onClick={onDismiss} aria-label="Dismiss error">
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -46,28 +39,56 @@ function ResearchSourceCard({ result, index }) {
|
||||
{result.snippet && (
|
||||
<p className="rd-source-snippet">{result.snippet}</p>
|
||||
)}
|
||||
{result.isFallback && (
|
||||
<span className="rd-source-badge">Suggested</span>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function ResearchProviderBadge({ provider, status }) {
|
||||
if (!provider) return null;
|
||||
|
||||
const badgeConfig = {
|
||||
exa: { emoji: '🔍', label: 'Exa Search', color: 'primary' },
|
||||
fallback: { emoji: '🔗', label: 'Web Sources', color: 'secondary' },
|
||||
gemini: { emoji: '✨', label: 'AI Summary', color: 'accent' },
|
||||
openrouter: { emoji: '🤖', label: 'AI Powered', color: 'accent' }
|
||||
};
|
||||
|
||||
const config = badgeConfig[provider] || { emoji: '📊', label: provider, color: 'secondary' };
|
||||
const isDegraded = status === 'degraded';
|
||||
|
||||
return (
|
||||
<div className={`rd-provider-badge rd-provider-${config.color} ${isDegraded ? 'rd-provider-degraded' : ''}`}>
|
||||
<span aria-hidden="true">{config.emoji}</span>
|
||||
<span className="rd-provider-label">{config.label}</span>
|
||||
{isDegraded && (
|
||||
<span className="rd-provider-status" title="Fallback source - primary API unavailable">
|
||||
(Fallback)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ResearchDisplay — pure presentational component.
|
||||
*
|
||||
* Props:
|
||||
* loading {boolean} Show loading skeleton
|
||||
* error {string} Error message to display
|
||||
* data {object} Research data: { summary, results }
|
||||
* data {object} Research data: { summary, results, provider, status }
|
||||
* name {string} Exercise name (shown during loading)
|
||||
* onDismiss {function} Clear error callback
|
||||
* onRetry {function} Retry fetch callback
|
||||
*/
|
||||
function ResearchDisplay({ loading, error, data, name, onDismiss, onRetry }) {
|
||||
function ResearchDisplay({ loading, error, data, name, onDismiss }) {
|
||||
if (loading) {
|
||||
return <ResearchLoadingSkeleton exerciseName={name} />
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <ResearchError message={error} onDismiss={onDismiss} onRetry={onRetry} />
|
||||
return <ResearchError message={error} onDismiss={onDismiss} />
|
||||
}
|
||||
|
||||
if (!data) return null
|
||||
@@ -77,15 +98,22 @@ function ResearchDisplay({ loading, error, data, name, onDismiss, onRetry }) {
|
||||
|
||||
return (
|
||||
<div className="rd-results">
|
||||
{hasSummary && (
|
||||
<div className="rd-summary">
|
||||
<h4 className="rd-section-title">
|
||||
<span className="rd-section-icon" aria-hidden="true">📋</span>
|
||||
Summary
|
||||
</h4>
|
||||
<p className="rd-summary-text">{data.summary}</p>
|
||||
<div className="rd-header">
|
||||
<div className="rd-header-content">
|
||||
{hasSummary && (
|
||||
<div className="rd-summary">
|
||||
<h4 className="rd-section-title">
|
||||
<span className="rd-section-icon" aria-hidden="true">📋</span>
|
||||
Summary
|
||||
</h4>
|
||||
<p className="rd-summary-text">{data.summary}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{data.provider && (
|
||||
<ResearchProviderBadge provider={data.provider} status={data.status} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasSources && (
|
||||
<div className="rd-sources">
|
||||
|
||||
Reference in New Issue
Block a user