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:
2026-03-02 23:45:07 +01:00
parent 2a0496b915
commit f580fa81a6
5 changed files with 444 additions and 167 deletions
+53 -25
View File
@@ -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">