f580fa81a6
- 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
141 lines
4.3 KiB
React
141 lines
4.3 KiB
React
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
|