Files
gravl/frontend/src/components/ResearchDisplay.jsx
T
clawd f580fa81a6 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
2026-03-02 23:45:07 +01:00

141 lines
4.3 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
function ResearchLoadingSkeleton({ exerciseName }) {
return (
<div className="rd-loading">
<div className="rd-spinner" aria-hidden="true" />
<span className="rd-loading-text">
Searching for information on <em>{exerciseName}</em>
</span>
</div>
)
}
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>
{onDismiss && (
<button className="rd-dismiss" onClick={onDismiss} aria-label="Dismiss error">
×
</button>
)}
</div>
)
}
function ResearchSourceCard({ result, index }) {
return (
<li className="rd-source-item">
<a
href={result.url}
target="_blank"
rel="noopener noreferrer"
className="rd-source-link"
>
<span className="rd-source-index">{index + 1}</span>
<span className="rd-source-title">{result.title}</span>
<span className="rd-source-arrow" aria-hidden="true"></span>
</a>
{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, provider, status }
* name {string} Exercise name (shown during loading)
* onDismiss {function} Clear error callback
*/
function ResearchDisplay({ loading, error, data, name, onDismiss }) {
if (loading) {
return <ResearchLoadingSkeleton exerciseName={name} />
}
if (error) {
return <ResearchError message={error} onDismiss={onDismiss} />
}
if (!data) return null
const hasSummary = Boolean(data.summary)
const hasSources = Array.isArray(data.results) && data.results.length > 0
return (
<div className="rd-results">
<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">
<h4 className="rd-section-title">
<span className="rd-section-icon" aria-hidden="true">🔗</span>
Sources
<span className="rd-count">{data.results.length}</span>
</h4>
<ul className="rd-sources-list" aria-label="Research sources">
{data.results.map((result, i) => (
<ResearchSourceCard key={i} result={result} index={i} />
))}
</ul>
</div>
)}
{!hasSummary && !hasSources && (
<p className="rd-empty">No research data found for this exercise.</p>
)}
</div>
)
}
export default ResearchDisplay