feature/05-exercise-encyclopedia #4
@@ -1,47 +1,120 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useCallback } from 'react'
|
||||||
import ResearchDisplay from './ResearchDisplay'
|
import ResearchDisplay from './ResearchDisplay'
|
||||||
|
|
||||||
const API_URL = '/api'
|
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 }) {
|
function ExerciseResearchPanel({ exerciseId, exerciseName }) {
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [research, setResearch] = useState(null)
|
const [research, setResearch] = useState(null)
|
||||||
const [error, setError] = 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)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_URL}/exercises/${exerciseId}/research`, {
|
const res = await fetch(`${API_URL}/exercises/${exerciseId}/research`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({})
|
body: JSON.stringify({})
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
let errMsg = `Request failed (${res.status})`
|
||||||
|
try {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
throw new Error(data.error || 'Failed to fetch research')
|
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()
|
const data = await res.json()
|
||||||
setResearch(data)
|
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) {
|
} catch (err) {
|
||||||
setError(err.message)
|
console.error('[Research] Fetch failed:', err)
|
||||||
|
setError(normalizeErrorMessage(err))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}, [exerciseId])
|
||||||
|
|
||||||
|
const handleFetch = () => fetchResearch(0)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="research-panel">
|
<div className="research-panel">
|
||||||
<div className="research-panel-header">
|
<div className="research-panel-header">
|
||||||
<h3 className="research-panel-title">Research</h3>
|
<h3 className="research-panel-title">Research</h3>
|
||||||
|
<div className="research-panel-controls">
|
||||||
|
{provider && <ProviderBadge provider={provider} />}
|
||||||
<button
|
<button
|
||||||
className={`btn ${research ? 'btn-secondary' : 'btn-primary'} research-btn`}
|
className={`btn ${research ? 'btn-secondary' : 'btn-primary'} research-btn`}
|
||||||
onClick={fetchResearch}
|
onClick={handleFetch}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
{loading ? 'Fetching…' : research ? 'Refresh' : 'Get Research'}
|
{loading
|
||||||
|
? retryCount > 0
|
||||||
|
? `Retrying (${retryCount})…`
|
||||||
|
: 'Fetching…'
|
||||||
|
: research
|
||||||
|
? 'Refresh'
|
||||||
|
: 'Get Research'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ResearchDisplay
|
<ResearchDisplay
|
||||||
loading={loading}
|
loading={loading}
|
||||||
@@ -49,6 +122,7 @@ function ExerciseResearchPanel({ exerciseId, exerciseName }) {
|
|||||||
data={research}
|
data={research}
|
||||||
name={exerciseName}
|
name={exerciseName}
|
||||||
onDismiss={() => setError(null)}
|
onDismiss={() => setError(null)}
|
||||||
|
onRetry={handleFetch}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,17 +9,24 @@ function ResearchLoadingSkeleton({ exerciseName }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ResearchError({ message, onDismiss }) {
|
function ResearchError({ message, onDismiss, onRetry }) {
|
||||||
return (
|
return (
|
||||||
<div className="rd-error" role="alert">
|
<div className="rd-error" role="alert">
|
||||||
<span className="rd-error-icon" aria-hidden="true">⚠</span>
|
<span className="rd-error-icon" aria-hidden="true">⚠</span>
|
||||||
<span className="rd-error-message">{message}</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 && (
|
{onDismiss && (
|
||||||
<button className="rd-dismiss" onClick={onDismiss} aria-label="Dismiss error">
|
<button className="rd-dismiss" onClick={onDismiss} aria-label="Dismiss error">
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,14 +59,15 @@ function ResearchSourceCard({ result, index }) {
|
|||||||
* data {object} Research data: { summary, results }
|
* data {object} Research data: { summary, results }
|
||||||
* name {string} Exercise name (shown during loading)
|
* name {string} Exercise name (shown during loading)
|
||||||
* onDismiss {function} Clear error callback
|
* 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) {
|
if (loading) {
|
||||||
return <ResearchLoadingSkeleton exerciseName={name} />
|
return <ResearchLoadingSkeleton exerciseName={name} />
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return <ResearchError message={error} onDismiss={onDismiss} />
|
return <ResearchError message={error} onDismiss={onDismiss} onRetry={onRetry} />
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data) return null
|
if (!data) return null
|
||||||
|
|||||||
@@ -563,3 +563,50 @@
|
|||||||
padding: var(--space-2) var(--space-3) var(--space-3);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user