Spaces:
Running
Running
Hetansh Waghela
Add code for Integrate memory and knowledge graph services with profile data synchronization and new dashboard components.
b812380 | import React, { useState, useEffect, useCallback } from 'react'; | |
| import { motion, AnimatePresence } from 'framer-motion'; | |
| import { | |
| Lightbulb, | |
| AlertTriangle, | |
| AlertCircle, | |
| Info, | |
| ChevronRight, | |
| ChevronDown, | |
| ChevronUp, | |
| Shield, | |
| RefreshCw, | |
| CheckCircle2, | |
| Dumbbell, | |
| Apple, | |
| Moon, | |
| Heart, | |
| Droplets, | |
| TestTube, | |
| Stethoscope, | |
| Sparkles, | |
| XCircle, | |
| } from 'lucide-react'; | |
| import { API_BASE_URL } from '../config/api'; | |
| import './RecommendationsPanel.css'; | |
| // Types | |
| interface Action { | |
| type: string; | |
| text: string; | |
| } | |
| interface Source { | |
| name: string; | |
| url?: string; | |
| } | |
| interface Provenance { | |
| memory?: boolean; | |
| graph?: boolean; | |
| metrics?: boolean; | |
| profile?: boolean; | |
| } | |
| interface Recommendation { | |
| id: string; | |
| title: string; | |
| severity: 'URGENT' | 'WARNING' | 'INFO'; | |
| why: string; | |
| actions: Action[]; | |
| followup: Action[]; | |
| sources: Source[]; | |
| provenance?: Provenance; | |
| } | |
| interface RecommendationsResponse { | |
| updated_at: string; | |
| disclaimer: string; | |
| items: Recommendation[]; | |
| total_count: number; | |
| urgent_count: number; | |
| warning_count: number; | |
| } | |
| /** Stored API returns priority as "high"|"medium"|"low" and description instead of why */ | |
| interface StoredItem { | |
| id: string; | |
| title: string; | |
| description?: string; | |
| priority: string; | |
| actions?: string[] | { type?: string; text: string }[]; | |
| evidence?: string[]; | |
| provenance?: Provenance; | |
| } | |
| interface RecommendationsPanelProps { | |
| authToken?: string; | |
| apiBaseUrl?: string; | |
| maxInitialDisplay?: number; | |
| refreshTrigger?: number; // Increment this to trigger a refresh (from WebSocket events) | |
| showGenerateButton?: boolean; // Show generate button for generating new recommendations | |
| onRecommendationsGenerated?: () => void; // Callback after generating recommendations | |
| variant?: 'dashboard' | 'page'; // Layout variant | |
| } | |
| // Action type to icon mapping | |
| const actionIcons: Record<string, React.ReactNode> = { | |
| EXERCISE: <Dumbbell size={14} />, | |
| DIET: <Apple size={14} />, | |
| HABIT: <Sparkles size={14} />, | |
| SLEEP: <Moon size={14} />, | |
| STRESS: <Heart size={14} />, | |
| HYDRATION: <Droplets size={14} />, | |
| TEST: <TestTube size={14} />, | |
| DOCTOR: <Stethoscope size={14} />, | |
| GENERAL: <CheckCircle2 size={14} />, | |
| }; | |
| // Severity icons | |
| const severityIcons: Record<string, React.ReactNode> = { | |
| URGENT: <AlertCircle size={18} />, | |
| WARNING: <AlertTriangle size={18} />, | |
| INFO: <Info size={18} />, | |
| }; | |
| function mapStoredToRecommendation(stored: StoredItem): Recommendation { | |
| const severityMap = { high: 'URGENT' as const, medium: 'WARNING' as const, low: 'INFO' as const }; | |
| const severity = severityMap[stored.priority as keyof typeof severityMap] ?? 'INFO'; | |
| const rawActions = stored.actions ?? []; | |
| const actions: Action[] = rawActions.map((a) => { | |
| if (typeof a === 'string') return { type: 'GENERAL' as const, text: a }; | |
| const text = a?.text != null ? String(a.text) : ''; | |
| return { type: ((a?.type as Action['type']) ?? 'GENERAL'), text }; | |
| }); | |
| return { | |
| id: stored.id, | |
| title: stored.title ?? '', | |
| severity, | |
| why: stored.description ?? '', | |
| actions, | |
| followup: [], | |
| sources: [], | |
| provenance: stored.provenance, | |
| }; | |
| } | |
| // Provenance tooltip component - shows data sources | |
| const ProvenanceTooltip: React.FC<{ provenance?: Provenance }> = ({ provenance }) => { | |
| if (!provenance) return null; | |
| const sources = []; | |
| if (provenance.memory) sources.push({ label: 'Memory', icon: '🧠', desc: 'Your preferences & facts' }); | |
| if (provenance.graph) sources.push({ label: 'Graph', icon: '🕸️', desc: 'Health relationships' }); | |
| if (provenance.metrics) sources.push({ label: 'Metrics', icon: '📊', desc: 'Lab values & observations' }); | |
| if (provenance.profile) sources.push({ label: 'Profile', icon: '👤', desc: 'Your health profile' }); | |
| if (sources.length === 0) return null; | |
| return ( | |
| <div className="provenance-badge" title="Powered by"> | |
| <span className="provenance-trigger">✨ Sources</span> | |
| <div className="provenance-tooltip"> | |
| <span className="provenance-title">Recommendation Sources</span> | |
| {sources.map((src, i) => ( | |
| <div key={i} className="provenance-item"> | |
| <span className="provenance-icon">{src.icon}</span> | |
| <span className="provenance-label">{src.label}</span> | |
| <span className="provenance-desc">{src.desc}</span> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| const RecommendationsPanel: React.FC<RecommendationsPanelProps> = ({ | |
| authToken, | |
| apiBaseUrl = API_BASE_URL, | |
| maxInitialDisplay = 3, | |
| refreshTrigger = 0, | |
| showGenerateButton = true, | |
| onRecommendationsGenerated, | |
| variant = 'dashboard', | |
| }) => { | |
| const [recommendations, setRecommendations] = useState<RecommendationsResponse | null>(null); | |
| const [loading, setLoading] = useState(true); | |
| const [generating, setGenerating] = useState(false); | |
| const [error, setError] = useState<string | null>(null); | |
| const [expanded, setExpanded] = useState(false); | |
| const [expandedCards, setExpandedCards] = useState<Set<string>>(new Set()); | |
| const fetchRecommendations = useCallback(async () => { | |
| if (!authToken) { | |
| setError('Authentication required'); | |
| setLoading(false); | |
| return; | |
| } | |
| setLoading(true); | |
| setError(null); | |
| const headers = { | |
| Authorization: `Bearer ${authToken}`, | |
| 'Content-Type': 'application/json', | |
| }; | |
| try { | |
| // Prefer stored (Grok-generated) when available; otherwise use rule-based | |
| const [storedRes, ruleRes] = await Promise.all([ | |
| fetch(`${apiBaseUrl}/api/recommendations/stored`, { headers }), | |
| fetch(`${apiBaseUrl}/api/recommendations`, { headers }), | |
| ]); | |
| if (storedRes.ok) { | |
| const storedData = await storedRes.json(); | |
| if (storedData.items?.length > 0) { | |
| const mapped: RecommendationsResponse = { | |
| ...storedData, | |
| items: storedData.items.map((item: StoredItem) => mapStoredToRecommendation(item)), | |
| }; | |
| setRecommendations(mapped); | |
| return; | |
| } | |
| } | |
| if (!ruleRes.ok) { | |
| throw new Error(`Failed to fetch recommendations: ${ruleRes.status}`); | |
| } | |
| const ruleData: RecommendationsResponse = await ruleRes.json(); | |
| setRecommendations(ruleData); | |
| } catch (err) { | |
| setError(err instanceof Error ? err.message : 'Failed to load recommendations'); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }, [authToken, apiBaseUrl]); | |
| useEffect(() => { | |
| fetchRecommendations(); | |
| }, [fetchRecommendations]); | |
| // Refresh when refreshTrigger changes (triggered by WebSocket events from parent) | |
| useEffect(() => { | |
| if (refreshTrigger > 0) { | |
| fetchRecommendations(); | |
| } | |
| }, [refreshTrigger, fetchRecommendations]); | |
| // Generate new recommendations | |
| const handleGenerateRecommendations = async () => { | |
| if (!authToken) return; | |
| setGenerating(true); | |
| setError(null); | |
| try { | |
| const response = await fetch(`${apiBaseUrl}/api/recommendations/generate`, { | |
| method: 'POST', | |
| headers: { | |
| Authorization: `Bearer ${authToken}`, | |
| 'Content-Type': 'application/json', | |
| }, | |
| }); | |
| if (response.ok) { | |
| await fetchRecommendations(); | |
| onRecommendationsGenerated?.(); | |
| } else { | |
| let message = `Generate failed (${response.status})`; | |
| try { | |
| const body = await response.json(); | |
| if (body?.error && typeof body.error === 'string') { | |
| message = body.error; | |
| } | |
| } catch { | |
| // ignore parse error | |
| } | |
| setError(message); | |
| } | |
| } catch (err) { | |
| const message = err instanceof Error ? err.message : 'Failed to generate recommendations'; | |
| setError(message); | |
| console.error('Error generating recommendations:', err); | |
| } finally { | |
| setGenerating(false); | |
| } | |
| }; | |
| const toggleCardExpanded = (id: string) => { | |
| setExpandedCards(prev => { | |
| const next = new Set(prev); | |
| if (next.has(id)) { | |
| next.delete(id); | |
| } else { | |
| next.add(id); | |
| } | |
| return next; | |
| }); | |
| }; | |
| const displayedItems = recommendations?.items.slice( | |
| 0, | |
| expanded ? undefined : maxInitialDisplay | |
| ) || []; | |
| const hasMore = (recommendations?.items.length || 0) > maxInitialDisplay; | |
| if (loading) { | |
| return ( | |
| <div className="recommendations-panel"> | |
| <div className="recommendations-loading"> | |
| <div className="spinner" /> | |
| <p>Loading personalized recommendations...</p> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| if (error) { | |
| return ( | |
| <div className="recommendations-panel"> | |
| <div className="recommendations-error"> | |
| <XCircle size={48} /> | |
| <p>{error}</p> | |
| <button className="retry-button" onClick={fetchRecommendations}> | |
| <RefreshCw size={16} /> | |
| Retry | |
| </button> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| if (!recommendations || recommendations.items.length === 0) { | |
| return ( | |
| <div className={`recommendations-panel ${variant === 'page' ? 'recommendations-panel-page' : ''}`}> | |
| <div className="recommendations-header"> | |
| <h2> | |
| <Lightbulb size={22} /> | |
| Recommendations | |
| </h2> | |
| {showGenerateButton && ( | |
| <button | |
| className="recommendations-generate-btn" | |
| onClick={handleGenerateRecommendations} | |
| disabled={generating} | |
| > | |
| {generating ? ( | |
| <><RefreshCw size={16} className="spinning" /> Generating...</> | |
| ) : ( | |
| <><Sparkles size={16} /> Generate</>)} | |
| </button> | |
| )} | |
| </div> | |
| <div className="recommendations-empty"> | |
| <Sparkles size={48} /> | |
| <h3>No recommendations yet</h3> | |
| <p> | |
| Generate personalized recommendations based on your health profile and reports. | |
| </p> | |
| {showGenerateButton && ( | |
| <button | |
| className="recommendations-generate-btn recommendations-generate-btn-lg" | |
| onClick={handleGenerateRecommendations} | |
| disabled={generating} | |
| > | |
| {generating ? ( | |
| <><RefreshCw size={18} className="spinning" /> Generating...</> | |
| ) : ( | |
| <><Sparkles size={18} /> Generate Recommendations</>)} | |
| </button> | |
| )} | |
| </div> | |
| <div className="recommendations-disclaimer"> | |
| <Shield size={18} /> | |
| <p>{recommendations?.disclaimer || 'This is wellness guidance, not medical advice.'}</p> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| return ( | |
| <div className={`recommendations-panel ${variant === 'page' ? 'recommendations-panel-page' : ''}`}> | |
| <div className="recommendations-header"> | |
| <div className="recommendations-header-left"> | |
| <h2> | |
| <Lightbulb size={22} /> | |
| Recommendations | |
| </h2> | |
| {showGenerateButton && ( | |
| <button | |
| className="recommendations-generate-btn" | |
| onClick={handleGenerateRecommendations} | |
| disabled={generating} | |
| > | |
| {generating ? ( | |
| <><RefreshCw size={16} className="spinning" /> Generating...</> | |
| ) : ( | |
| <><RefreshCw size={16} /> Refresh</>)} | |
| </button> | |
| )} | |
| </div> | |
| <div className="recommendations-badges"> | |
| {recommendations.urgent_count > 0 && ( | |
| <span className="badge urgent"> | |
| <AlertCircle size={14} /> | |
| {recommendations.urgent_count} Urgent | |
| </span> | |
| )} | |
| {recommendations.warning_count > 0 && ( | |
| <span className="badge warning"> | |
| <AlertTriangle size={14} /> | |
| {recommendations.warning_count} Attention | |
| </span> | |
| )} | |
| {recommendations.total_count - recommendations.urgent_count - recommendations.warning_count > 0 && ( | |
| <span className="badge info"> | |
| <Info size={14} /> | |
| {recommendations.total_count - recommendations.urgent_count - recommendations.warning_count} Tips | |
| </span> | |
| )} | |
| </div> | |
| </div> | |
| <div className="recommendations-list"> | |
| <AnimatePresence mode="popLayout"> | |
| {displayedItems.map((item, index) => ( | |
| <motion.div | |
| key={item.id} | |
| initial={{ opacity: 0, y: 20 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| exit={{ opacity: 0, y: -20 }} | |
| transition={{ delay: index * 0.05 }} | |
| className={`recommendation-card ${(item.severity ?? 'info').toLowerCase()}`} | |
| > | |
| <div className="recommendation-header"> | |
| <h3 className="recommendation-title">{item.title ?? ''}</h3> | |
| <div className="recommendation-header-right"> | |
| <ProvenanceTooltip provenance={item.provenance} /> | |
| <span className={`severity-tag ${(item.severity ?? 'info').toLowerCase()}`}> | |
| {severityIcons[item.severity ?? 'INFO']} | |
| {item.severity ?? 'INFO'} | |
| </span> | |
| </div> | |
| </div> | |
| <p className="recommendation-why">{item.why}</p> | |
| <div className="recommendation-actions"> | |
| <h4>Suggested Actions</h4> | |
| <div className="actions-list"> | |
| {(item.actions ?? []).slice(0, expandedCards.has(item.id) ? undefined : 3).map((action, idx) => ( | |
| <span key={idx} className={`action-tag ${(action.type ?? 'general').toLowerCase()}`}> | |
| {actionIcons[action.type] || <ChevronRight size={14} />} | |
| {action.text ?? ''} | |
| </span> | |
| ))} | |
| {(item.actions ?? []).length > 3 && !expandedCards.has(item.id) && ( | |
| <button | |
| className="action-tag" | |
| onClick={() => toggleCardExpanded(item.id)} | |
| style={{ cursor: 'pointer', background: '#f0f0f0' }} | |
| > | |
| +{(item.actions ?? []).length - 3} more | |
| </button> | |
| )} | |
| </div> | |
| </div> | |
| {(item.followup ?? []).length > 0 && ( | |
| <div className="recommendation-followup"> | |
| <h4>Follow-up</h4> | |
| <div className="followup-list"> | |
| {(item.followup ?? []).map((follow, idx) => ( | |
| <div key={idx} className="followup-item"> | |
| {actionIcons[follow.type] || <ChevronRight size={14} />} | |
| {follow.text ?? ''} | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| {(item.sources ?? []).length > 0 && expandedCards.has(item.id) && ( | |
| <div className="recommendation-sources"> | |
| <div className="sources-list"> | |
| {(item.sources ?? []).map((source, idx) => ( | |
| source.url ? ( | |
| <a | |
| key={idx} | |
| href={source.url} | |
| target="_blank" | |
| rel="noopener noreferrer" | |
| className="source-link" | |
| > | |
| 📎 {source.name ?? 'Source'} | |
| </a> | |
| ) : ( | |
| <span key={idx} className="source-link"> | |
| 📎 {source.name ?? 'Source'} | |
| </span> | |
| ) | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| {((item.actions ?? []).length > 3 || (item.sources ?? []).length > 0) && ( | |
| <button | |
| className="expand-toggle" | |
| onClick={() => toggleCardExpanded(item.id)} | |
| style={{ marginTop: '12px' }} | |
| > | |
| {expandedCards.has(item.id) ? ( | |
| <> | |
| <ChevronUp size={16} /> | |
| Show less | |
| </> | |
| ) : ( | |
| <> | |
| <ChevronDown size={16} /> | |
| Show more details | |
| </> | |
| )} | |
| </button> | |
| )} | |
| </motion.div> | |
| ))} | |
| </AnimatePresence> | |
| </div> | |
| {hasMore && ( | |
| <button className="expand-toggle" onClick={() => setExpanded(!expanded)}> | |
| {expanded ? ( | |
| <> | |
| <ChevronUp size={16} /> | |
| Show fewer recommendations | |
| </> | |
| ) : ( | |
| <> | |
| <ChevronDown size={16} /> | |
| Show {recommendations.items.length - maxInitialDisplay} more recommendations | |
| </> | |
| )} | |
| </button> | |
| )} | |
| <div className="recommendations-disclaimer"> | |
| <Shield size={18} /> | |
| <p>{recommendations.disclaimer}</p> | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| export default RecommendationsPanel; | |