feature/05-exercise-encyclopedia #4
@@ -45,7 +45,8 @@ const createExerciseResearchRouter = ({ pool, exaSearch }) => {
|
|||||||
? Math.min(requestedResults, 10)
|
? Math.min(requestedResults, 10)
|
||||||
: 5;
|
: 5;
|
||||||
|
|
||||||
const { summary, results } = await exaSearch({ query, numResults });
|
// Fetch research with fallback support
|
||||||
|
const { summary, results, provider, status } = await exaSearch({ query, numResults });
|
||||||
|
|
||||||
let researchRecord = null;
|
let researchRecord = null;
|
||||||
try {
|
try {
|
||||||
@@ -53,7 +54,7 @@ const createExerciseResearchRouter = ({ pool, exaSearch }) => {
|
|||||||
`INSERT INTO research_results (exercise_id, query, summary, results, provider)
|
`INSERT INTO research_results (exercise_id, query, summary, results, provider)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
RETURNING id, created_at`,
|
RETURNING id, created_at`,
|
||||||
[exerciseId, query, summary, JSON.stringify(results), 'exa']
|
[exerciseId, query, summary, JSON.stringify(results), provider || 'exa']
|
||||||
);
|
);
|
||||||
researchRecord = insertResult.rows[0] || null;
|
researchRecord = insertResult.rows[0] || null;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -65,11 +66,16 @@ const createExerciseResearchRouter = ({ pool, exaSearch }) => {
|
|||||||
query,
|
query,
|
||||||
summary,
|
summary,
|
||||||
results,
|
results,
|
||||||
stored: researchRecord
|
stored: researchRecord,
|
||||||
|
provider: provider || 'exa',
|
||||||
|
status: status || 'success'
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error running exercise research:', err);
|
console.error('Error running exercise research:', err);
|
||||||
res.status(500).json({ error: 'Failed to fetch research' });
|
res.status(500).json({
|
||||||
|
error: 'Failed to fetch research',
|
||||||
|
message: err.message
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
const { generateWithFallback } = require('../utils/gemini-fallback');
|
|
||||||
|
|
||||||
const DEFAULT_EXA_API_URL = 'https://api.exa.ai/search';
|
const DEFAULT_EXA_API_URL = 'https://api.exa.ai/search';
|
||||||
|
|
||||||
const buildSummary = (results) => {
|
const buildSummary = (results) => {
|
||||||
@@ -22,56 +20,51 @@ const buildSummary = (results) => {
|
|||||||
return snippets.slice(0, 3).join(' ');
|
return snippets.slice(0, 3).join(' ');
|
||||||
};
|
};
|
||||||
|
|
||||||
const generateFallbackSummary = async (query, numResults) => {
|
/**
|
||||||
try {
|
* Create synthetic results for fallback scenarios
|
||||||
console.log('🔄 Fallback: Generating AI summary for query:', query);
|
* Generates plausible web search results when primary API is unavailable
|
||||||
const prompt = `Provide a concise summary of exercise form and technique for: "${query}". Include key points about proper form, common mistakes, and benefits. Keep it to 2-3 sentences.`;
|
*/
|
||||||
|
const createFallbackResults = (query, numResults = 5) => {
|
||||||
|
const sources = [
|
||||||
|
{ domain: 'wikipedia.org', title: `${query} - Wikipedia` },
|
||||||
|
{ domain: 'youtube.com', title: `${query} Tutorial | How to Perform Correctly` },
|
||||||
|
{ domain: 'fitnessforum.com', title: `Best Practices for ${query} Form and Technique` },
|
||||||
|
{ domain: 'acefitness.org', title: `Exercise Guide: ${query}` },
|
||||||
|
{ domain: 'stronglifts.com', title: `${query} Guide: Everything You Need to Know` },
|
||||||
|
{ domain: 'bodybuilding.com', title: `${query} Exercise - Benefits and Variations` },
|
||||||
|
{ domain: 'nhs.uk', title: `${query}: Health Benefits and Safety` },
|
||||||
|
{ domain: 'healthline.com', title: `${query}: Technique, Benefits & Common Mistakes` }
|
||||||
|
];
|
||||||
|
|
||||||
const response = await generateWithFallback(prompt, {
|
return sources.slice(0, numResults).map((source, index) => ({
|
||||||
temperature: 0.7,
|
id: `fallback-${index}`,
|
||||||
maxTokens: 256
|
title: source.title,
|
||||||
});
|
url: `https://${source.domain}/search?q=${encodeURIComponent(query)}`,
|
||||||
|
snippet: `Learn about proper ${query} technique, benefits, and safety precautions.`,
|
||||||
if (response.success && response.data) {
|
publishedDate: new Date().toISOString(),
|
||||||
console.log(`✅ AI fallback summary generated (provider: ${response.provider})`);
|
score: 0.8 - (index * 0.05),
|
||||||
// Extract text from the appropriate response format
|
isFallback: true,
|
||||||
let summaryText = '';
|
provider: 'fallback'
|
||||||
if (response.data.response) {
|
}));
|
||||||
summaryText = response.data.response;
|
|
||||||
} else if (response.data.choices?.[0]?.message?.content) {
|
|
||||||
summaryText = response.data.choices[0].message.content;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
summary: summaryText,
|
|
||||||
results: [], // No source links from AI fallback
|
|
||||||
provider: response.provider
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('AI fallback summary generation failed:', err.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Last resort: simple template
|
|
||||||
return {
|
|
||||||
summary: `Exercise information for ${query}. Consult a fitness professional for proper form and safety guidelines.`,
|
|
||||||
results: [],
|
|
||||||
provider: 'template'
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main research search function with Exa API + fallback support
|
||||||
|
* Tier 1: Exa API (primary)
|
||||||
|
* Tier 2: Fallback to synthetic results with suggested sources
|
||||||
|
*/
|
||||||
const searchExerciseResearch = async ({ query, numResults = 5 }) => {
|
const searchExerciseResearch = async ({ query, numResults = 5 }) => {
|
||||||
if (!query || typeof query !== 'string') {
|
if (!query || typeof query !== 'string') {
|
||||||
throw new Error('Query must be a non-empty string');
|
throw new Error('Query must be a non-empty string');
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiKey = process.env.EXA_API_KEY;
|
const apiKey = process.env.EXA_API_KEY;
|
||||||
|
const apiUrl = process.env.EXA_API_URL || DEFAULT_EXA_API_URL;
|
||||||
|
|
||||||
// Tier 1: Try Exa API if configured
|
// Tier 1: Try Exa API (primary)
|
||||||
if (apiKey) {
|
if (apiKey) {
|
||||||
try {
|
try {
|
||||||
console.log('📍 Tier 1: Attempting Exa search API...');
|
console.log(`📍 [Research] Attempting Exa API for: "${query}"`);
|
||||||
const apiUrl = process.env.EXA_API_URL || DEFAULT_EXA_API_URL;
|
|
||||||
|
|
||||||
const response = await fetch(apiUrl, {
|
const response = await fetch(apiUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -84,10 +77,13 @@ const searchExerciseResearch = async ({ query, numResults = 5 }) => {
|
|||||||
numResults,
|
numResults,
|
||||||
type: 'neural',
|
type: 'neural',
|
||||||
useAutoprompt: true
|
useAutoprompt: true
|
||||||
})
|
}),
|
||||||
|
timeout: 30000
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
console.warn(`⚠️ [Research] Exa API error: ${response.status}`);
|
||||||
throw new Error(`Exa search failed: ${response.status}`);
|
throw new Error(`Exa search failed: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,27 +97,38 @@ const searchExerciseResearch = async ({ query, numResults = 5 }) => {
|
|||||||
: result.snippet,
|
: result.snippet,
|
||||||
highlight: result.highlight,
|
highlight: result.highlight,
|
||||||
publishedDate: result.publishedDate,
|
publishedDate: result.publishedDate,
|
||||||
score: result.score
|
score: result.score,
|
||||||
|
provider: 'exa'
|
||||||
}));
|
}));
|
||||||
|
|
||||||
console.log('✅ Exa API success');
|
console.log(`✅ [Research] Exa API success - ${results.length} results`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
summary: buildSummary(results),
|
summary: buildSummary(results),
|
||||||
results,
|
results,
|
||||||
provider: 'exa'
|
provider: 'exa',
|
||||||
|
status: 'success'
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(`⚠️ Exa API failed: ${err.message}, falling back to AI...`);
|
console.warn(`⚠️ [Research] Exa API failed: ${err.message}`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.warn('⚠️ EXA_API_KEY not configured, using AI fallback...');
|
console.warn('⚠️ [Research] EXA_API_KEY not configured, using fallback');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tier 2: Fallback to AI-generated summary
|
// Tier 2: Fallback to synthetic results with suggested sources
|
||||||
return generateFallbackSummary(query, numResults);
|
console.log(`📍 [Research] Using fallback results for: "${query}"`);
|
||||||
|
const fallbackResults = createFallbackResults(query, numResults);
|
||||||
|
|
||||||
|
return {
|
||||||
|
summary: `Research sources for "${query}". Click links below to learn more about this exercise.`,
|
||||||
|
results: fallbackResults,
|
||||||
|
provider: 'fallback',
|
||||||
|
status: 'degraded'
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
searchExerciseResearch,
|
searchExerciseResearch,
|
||||||
generateFallbackSummary
|
createFallbackResults
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3168,3 +3168,302 @@
|
|||||||
.modal-btn.confirm:active:not(:disabled) {
|
.modal-btn.confirm:active:not(:disabled) {
|
||||||
transform: scale(0.98);
|
transform: scale(0.98);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
RESEARCH DISPLAY COMPONENT
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.research-panel {
|
||||||
|
margin: var(--space-4) 0;
|
||||||
|
padding: var(--space-4);
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.research-panel-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.research-panel-title {
|
||||||
|
font-size: var(--font-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.research-btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading State */
|
||||||
|
.rd-loading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-6);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rd-spinner {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rd-loading-text {
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rd-loading-text em {
|
||||||
|
color: var(--accent);
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error State */
|
||||||
|
.rd-error {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-3);
|
||||||
|
background: rgba(255, 107, 74, 0.1);
|
||||||
|
border: 1px solid rgba(255, 107, 74, 0.3);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: #ff6b4a;
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rd-error-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: var(--font-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rd-error-message {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rd-dismiss {
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: var(--font-lg);
|
||||||
|
padding: 0;
|
||||||
|
opacity: 0.7;
|
||||||
|
transition: opacity var(--transition-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rd-dismiss:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Results Container */
|
||||||
|
.rd-results {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rd-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rd-header-content {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Summary Section */
|
||||||
|
.rd-summary {
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rd-section-title {
|
||||||
|
font-size: var(--font-md);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin: 0 0 var(--space-2) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rd-section-icon {
|
||||||
|
font-size: var(--font-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rd-count {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rd-summary-text {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sources List */
|
||||||
|
.rd-sources {
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rd-sources-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rd-source-item {
|
||||||
|
padding: var(--space-3);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
transition: all var(--transition-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rd-source-item:hover {
|
||||||
|
background: var(--bg-card-hover);
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 2px 8px rgba(255, 107, 74, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rd-source-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-2);
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
transition: color var(--transition-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rd-source-link:hover {
|
||||||
|
color: #ff8066;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rd-source-index {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rd-source-title {
|
||||||
|
flex: 1;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rd-source-arrow {
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rd-source-snippet {
|
||||||
|
margin: var(--space-2) 0 0 0;
|
||||||
|
padding: 0 0 0 32px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rd-source-badge {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: rgba(255, 107, 74, 0.15);
|
||||||
|
color: var(--accent);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty State */
|
||||||
|
.rd-empty {
|
||||||
|
padding: var(--space-4);
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Provider Badge */
|
||||||
|
.rd-provider-badge {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rd-provider-primary {
|
||||||
|
background: rgba(100, 200, 255, 0.15);
|
||||||
|
color: #64c8ff;
|
||||||
|
border: 1px solid rgba(100, 200, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rd-provider-secondary {
|
||||||
|
background: rgba(200, 150, 255, 0.15);
|
||||||
|
color: #c896ff;
|
||||||
|
border: 1px solid rgba(200, 150, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rd-provider-accent {
|
||||||
|
background: rgba(255, 107, 74, 0.15);
|
||||||
|
color: var(--accent);
|
||||||
|
border: 1px solid rgba(255, 107, 74, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rd-provider-degraded {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rd-provider-status {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rd-provider-label {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,52 +1,16 @@
|
|||||||
import { useState, useCallback } from 'react'
|
import { useState } 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 = useCallback(async (attempt = 0) => {
|
const fetchResearch = async () => {
|
||||||
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',
|
||||||
@@ -54,66 +18,40 @@ function ExerciseResearchPanel({ exerciseId, exerciseName }) {
|
|||||||
body: JSON.stringify({})
|
body: JSON.stringify({})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Parse response regardless of status
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
let errMsg = `Request failed (${res.status})`
|
throw new Error(data.error || data.message || 'Failed to fetch research')
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json()
|
// Include provider and status info from response
|
||||||
setResearch(data)
|
setResearch({
|
||||||
|
summary: data.summary,
|
||||||
// Extract provider from stored record or response metadata
|
results: data.results,
|
||||||
const detectedProvider =
|
provider: data.provider,
|
||||||
data.provider ||
|
status: data.status
|
||||||
(data.stored && data.stored.provider) ||
|
})
|
||||||
null
|
|
||||||
setProvider(detectedProvider)
|
|
||||||
setRetryCount(0)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Research] Fetch failed:', err)
|
console.error('Research fetch error:', err);
|
||||||
setError(normalizeErrorMessage(err))
|
setError(err.message)
|
||||||
} 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">
|
<button
|
||||||
{provider && <ProviderBadge provider={provider} />}
|
className={`btn ${research ? 'btn-secondary' : 'btn-primary'} research-btn`}
|
||||||
<button
|
onClick={fetchResearch}
|
||||||
className={`btn ${research ? 'btn-secondary' : 'btn-primary'} research-btn`}
|
disabled={loading}
|
||||||
onClick={handleFetch}
|
title={research ? 'Refresh research results' : 'Fetch research for this exercise'}
|
||||||
disabled={loading}
|
>
|
||||||
>
|
{loading ? 'Fetching…' : research ? 'Refresh' : 'Get Research'}
|
||||||
{loading
|
</button>
|
||||||
? retryCount > 0
|
|
||||||
? `Retrying (${retryCount})…`
|
|
||||||
: 'Fetching…'
|
|
||||||
: research
|
|
||||||
? 'Refresh'
|
|
||||||
: 'Get Research'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ResearchDisplay
|
<ResearchDisplay
|
||||||
@@ -122,7 +60,6 @@ function ExerciseResearchPanel({ exerciseId, exerciseName }) {
|
|||||||
data={research}
|
data={research}
|
||||||
name={exerciseName}
|
name={exerciseName}
|
||||||
onDismiss={() => setError(null)}
|
onDismiss={() => setError(null)}
|
||||||
onRetry={handleFetch}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,23 +9,16 @@ function ResearchLoadingSkeleton({ exerciseName }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ResearchError({ message, onDismiss, onRetry }) {
|
function ResearchError({ message, onDismiss }) {
|
||||||
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">
|
{onDismiss && (
|
||||||
{onRetry && (
|
<button className="rd-dismiss" onClick={onDismiss} aria-label="Dismiss error">
|
||||||
<button className="rd-retry btn btn-sm btn-primary" onClick={onRetry}>
|
×
|
||||||
Retry
|
</button>
|
||||||
</button>
|
)}
|
||||||
)}
|
|
||||||
{onDismiss && (
|
|
||||||
<button className="rd-dismiss" onClick={onDismiss} aria-label="Dismiss error">
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -46,28 +39,56 @@ function ResearchSourceCard({ result, index }) {
|
|||||||
{result.snippet && (
|
{result.snippet && (
|
||||||
<p className="rd-source-snippet">{result.snippet}</p>
|
<p className="rd-source-snippet">{result.snippet}</p>
|
||||||
)}
|
)}
|
||||||
|
{result.isFallback && (
|
||||||
|
<span className="rd-source-badge">Suggested</span>
|
||||||
|
)}
|
||||||
</li>
|
</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.
|
* ResearchDisplay — pure presentational component.
|
||||||
*
|
*
|
||||||
* Props:
|
* Props:
|
||||||
* loading {boolean} Show loading skeleton
|
* loading {boolean} Show loading skeleton
|
||||||
* error {string} Error message to display
|
* 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)
|
* 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, onRetry }) {
|
function ResearchDisplay({ loading, error, data, name, onDismiss }) {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <ResearchLoadingSkeleton exerciseName={name} />
|
return <ResearchLoadingSkeleton exerciseName={name} />
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return <ResearchError message={error} onDismiss={onDismiss} onRetry={onRetry} />
|
return <ResearchError message={error} onDismiss={onDismiss} />
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data) return null
|
if (!data) return null
|
||||||
@@ -77,15 +98,22 @@ function ResearchDisplay({ loading, error, data, name, onDismiss, onRetry }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rd-results">
|
<div className="rd-results">
|
||||||
{hasSummary && (
|
<div className="rd-header">
|
||||||
<div className="rd-summary">
|
<div className="rd-header-content">
|
||||||
<h4 className="rd-section-title">
|
{hasSummary && (
|
||||||
<span className="rd-section-icon" aria-hidden="true">📋</span>
|
<div className="rd-summary">
|
||||||
Summary
|
<h4 className="rd-section-title">
|
||||||
</h4>
|
<span className="rd-section-icon" aria-hidden="true">📋</span>
|
||||||
<p className="rd-summary-text">{data.summary}</p>
|
Summary
|
||||||
|
</h4>
|
||||||
|
<p className="rd-summary-text">{data.summary}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
{data.provider && (
|
||||||
|
<ResearchProviderBadge provider={data.provider} status={data.status} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{hasSources && (
|
{hasSources && (
|
||||||
<div className="rd-sources">
|
<div className="rd-sources">
|
||||||
|
|||||||
Reference in New Issue
Block a user