| 'use client'; |
|
|
| import { useState } from 'react'; |
| import { motion, AnimatePresence } from 'framer-motion'; |
| import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; |
| import { cn, formatDuration, SEVERITY_INFO } from '@/lib/utils'; |
| import { |
| GlassPanel, |
| SeverityBadge, |
| CategoryBadge, |
| ConfidenceBar, |
| AnalysisLoadingState, |
| } from '@/components/ui'; |
| import { |
| useIsAnalyzing, |
| useAnalysisError, |
| useAnalysisResult, |
| useFilteredErrors, |
| useFilteredSummary, |
| useQuickResult, |
| useAnalysisStage, |
| useAppStore, |
| useInputText, |
| } from '@/store/app-store'; |
| import { useExport } from '@/hooks/use-export'; |
| import { RagChunksPanel } from './rag-chunks-panel'; |
| import { ChunkModal } from './chunk-modal'; |
| import type { AnalysisResult, DetectedError, QuickSeverity, RagChunk } from '@/types'; |
| import type { ExportFormat } from '@/types/export'; |
|
|
| interface AnalysisResultsProps { |
| isCollapsed?: boolean; |
| onToggleCollapse?: () => void; |
| } |
|
|
| export function AnalysisResults({ |
| isCollapsed = false, |
| onToggleCollapse, |
| }: AnalysisResultsProps) { |
| const isAnalyzing = useIsAnalyzing(); |
| const analysisError = useAnalysisError(); |
| const analysisResult = useAnalysisResult(); |
| const filteredErrors = useFilteredErrors(); |
| const filteredSummary = useFilteredSummary(); |
| const quickResult = useQuickResult(); |
| const analysisStage = useAnalysisStage(); |
| const inputText = useInputText(); |
| const settings = useAppStore((state) => state.settings); |
| const streamingErrors = useAppStore((state) => state.streamingErrors); |
| const ragChunks = useAppStore((state) => state.ragChunks); |
| const isFetchingChunks = useAppStore((state) => state.isFetchingChunks); |
| const { exportAnalysis } = useExport(); |
| const [exportMenuOpen, setExportMenuOpen] = useState(false); |
| const [activeChunk, setActiveChunk] = useState<RagChunk | null>(null); |
| const [showSources, setShowSources] = useState(false); |
| const [showRawOutput, setShowRawOutput] = useState(false); |
|
|
| const handleExport = (format: ExportFormat) => { |
| if (!analysisResult || !inputText) return; |
|
|
| exportAnalysis(format, inputText, analysisResult, { |
| selectedModel: settings.selectedModel, |
| temperature: settings.temperature, |
| confidenceThreshold: settings.confidenceThreshold, |
| enabledCategories: settings.enabledCategories, |
| enabledSeverities: settings.enabledSeverities, |
| }); |
|
|
| setExportMenuOpen(false); |
| }; |
|
|
| |
| if (isCollapsed) { |
| return ( |
| <GlassPanel className="p-3 h-full flex flex-col"> |
| <button |
| onClick={onToggleCollapse} |
| className="p-2 mb-3 rounded-lg hover:bg-[var(--glass-bg-elevated)] transition-colors" |
| title="Expand" |
| > |
| <svg |
| className="w-4 h-4 text-[var(--foreground)]/60 mx-auto" |
| fill="none" |
| stroke="currentColor" |
| viewBox="0 0 24 24" |
| > |
| <path |
| strokeLinecap="round" |
| strokeLinejoin="round" |
| strokeWidth={2} |
| d="M11 19l-7-7 7-7m8 14l-7-7 7-7" |
| /> |
| </svg> |
| </button> |
| {analysisResult ? ( |
| <motion.div |
| className="flex flex-col gap-2 flex-1" |
| initial={{ opacity: 0 }} |
| animate={{ opacity: 1 }} |
| transition={{ duration: 0.2 }} |
| > |
| <CollapsedMetricCard |
| count={filteredSummary.total} |
| label="Total" |
| /> |
| <CollapsedMetricCard |
| count={filteredSummary.critical} |
| label="Critical" |
| severity="critical" |
| /> |
| <CollapsedMetricCard |
| count={filteredSummary.warning} |
| label="Warn" |
| severity="warning" |
| /> |
| <CollapsedMetricCard |
| count={filteredSummary.suggestion} |
| label="Info" |
| severity="suggestion" |
| /> |
| </motion.div> |
| ) : ( |
| <div className="flex-1 flex items-center justify-center"> |
| <span className="text-2xl">🔍</span> |
| </div> |
| )} |
| </GlassPanel> |
| ); |
| } |
|
|
| |
| if (isAnalyzing && streamingErrors.length > 0) { |
| const streamingSummary = { |
| total: streamingErrors.length, |
| critical: streamingErrors.filter((e) => e.severity === 'critical').length, |
| warning: streamingErrors.filter((e) => e.severity === 'warning').length, |
| suggestion: streamingErrors.filter((e) => e.severity === 'suggestion').length, |
| }; |
| return ( |
| <GlassPanel className="h-full overflow-y-auto relative"> |
| <div className="sticky top-0 z-10 backdrop-blur-xl bg-[var(--glass-bg)]/80 px-4 sm:px-6 pt-4 sm:pt-6 pb-4"> |
| <div className="flex items-center justify-between mb-4"> |
| <div className="flex items-center gap-2"> |
| <h2 className="text-lg font-semibold text-[var(--foreground)]">Analyzing…</h2> |
| {/* Pulsing dot */} |
| <motion.span |
| animate={{ opacity: [0.3, 1, 0.3] }} |
| transition={{ repeat: Infinity, duration: 1.4 }} |
| className="inline-block w-2 h-2 rounded-full bg-[var(--suggestion-accent)]" |
| /> |
| </div> |
| </div> |
| <div className="grid grid-cols-3 gap-3"> |
| <MetricCard label="Critical" count={streamingSummary.critical} severity="critical" /> |
| <MetricCard label="Warnings" count={streamingSummary.warning} severity="warning" /> |
| <MetricCard label="Suggestions" count={streamingSummary.suggestion} severity="suggestion" /> |
| </div> |
| </div> |
| |
| <div className="px-4 sm:px-6 pt-0 pb-4 space-y-4"> |
| <AnimatePresence> |
| {streamingErrors.map((error, index) => ( |
| <ErrorCard key={error.id} error={error} index={index} /> |
| ))} |
| </AnimatePresence> |
| |
| {/* Still-generating indicator */} |
| <motion.div |
| animate={{ opacity: [0.4, 0.9, 0.4] }} |
| transition={{ repeat: Infinity, duration: 1.6 }} |
| className="flex items-center justify-center gap-2 py-3 text-xs text-[var(--foreground)]/40" |
| > |
| <span className="inline-block w-1.5 h-1.5 rounded-full bg-current" /> |
| <span className="inline-block w-1.5 h-1.5 rounded-full bg-current" style={{ animationDelay: '0.2s' }} /> |
| <span className="inline-block w-1.5 h-1.5 rounded-full bg-current" style={{ animationDelay: '0.4s' }} /> |
| </motion.div> |
| </div> |
| |
| <div |
| className="sticky bottom-0 z-[9] h-6 pointer-events-none -mt-6" |
| style={{ |
| background: 'linear-gradient(to top, var(--glass-bg) 20%, transparent)', |
| }} |
| /> |
| </GlassPanel> |
| ); |
| } |
|
|
| |
| if (isAnalyzing) { |
| return ( |
| <GlassPanel className="p-6 h-full flex flex-col"> |
| <div className="flex items-center justify-between mb-4"> |
| <h2 className="text-lg font-semibold text-[var(--foreground)]"> |
| Analysis Results |
| </h2> |
| {onToggleCollapse && ( |
| <button |
| onClick={onToggleCollapse} |
| className="p-1.5 rounded-lg hover:bg-[var(--glass-bg-elevated)] transition-colors" |
| title="Collapse" |
| > |
| <svg className="w-4 h-4 text-[var(--foreground)]/60" fill="none" stroke="currentColor" viewBox="0 0 24 24"> |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 5l7 7-7 7M5 5l7 7-7 7" /> |
| </svg> |
| </button> |
| )} |
| </div> |
| |
| {quickResult && analysisStage === 'full' && ( |
| <QuickPreviewBanner quickResult={quickResult} /> |
| )} |
| |
| <AnalysisLoadingState stage={analysisStage} hasQuickResult={!!quickResult} /> |
| </GlassPanel> |
| ); |
| } |
|
|
| |
| if (!analysisResult && !analysisError) { |
| const showChunks = isFetchingChunks || ragChunks.length > 0; |
| return ( |
| <GlassPanel className="p-6 h-full flex flex-col"> |
| {onToggleCollapse && ( |
| <div className="flex justify-end mb-4"> |
| <button |
| onClick={onToggleCollapse} |
| className="p-1.5 rounded-lg hover:bg-[var(--glass-bg-elevated)] transition-colors" |
| title="Collapse" |
| > |
| <svg className="w-4 h-4 text-[var(--foreground)]/60" fill="none" stroke="currentColor" viewBox="0 0 24 24"> |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 5l7 7-7 7M5 5l7 7-7 7" /> |
| </svg> |
| </button> |
| </div> |
| )} |
| |
| {showChunks ? ( |
| <> |
| <RagChunksPanel |
| chunks={ragChunks} |
| isLoading={isFetchingChunks} |
| onChunkClick={(chunk) => setActiveChunk(chunk)} |
| /> |
| <ChunkModal |
| chunk={activeChunk} |
| open={activeChunk !== null} |
| onOpenChange={(open) => { if (!open) setActiveChunk(null); }} |
| /> |
| </> |
| ) : ( |
| <div className="flex-1 flex flex-col items-center justify-center text-center"> |
| <div className="text-5xl mb-4">🔍</div> |
| <h3 className="text-lg font-semibold text-[var(--foreground)] mb-2"> |
| Ready to Analyze |
| </h3> |
| <p className="text-sm text-[var(--foreground)]/60 max-w-xs"> |
| Paste a clinical note and click Analyze to detect potential errors and |
| inconsistencies. |
| </p> |
| </div> |
| )} |
| </GlassPanel> |
| ); |
| } |
|
|
| |
| if (analysisError) { |
| return ( |
| <GlassPanel |
| severity="critical" |
| className="p-6 h-full flex flex-col" |
| > |
| {onToggleCollapse && ( |
| <div className="flex justify-end mb-4"> |
| <button |
| onClick={onToggleCollapse} |
| className="p-1.5 rounded-lg hover:bg-[var(--glass-bg-elevated)] transition-colors" |
| title="Collapse" |
| > |
| <svg |
| className="w-4 h-4 text-[var(--foreground)]/60" |
| fill="none" |
| stroke="currentColor" |
| viewBox="0 0 24 24" |
| > |
| <path |
| strokeLinecap="round" |
| strokeLinejoin="round" |
| strokeWidth={2} |
| d="M13 5l7 7-7 7M5 5l7 7-7 7" |
| /> |
| </svg> |
| </button> |
| </div> |
| )} |
| <div className="flex-1 flex flex-col items-center justify-center text-center"> |
| <div className="text-5xl mb-4">⚠️</div> |
| <h3 className="text-lg font-semibold text-[var(--critical-text)] mb-2"> |
| Analysis Failed |
| </h3> |
| <p className="text-sm text-[var(--foreground)]/70 max-w-md mb-4"> |
| {analysisError} |
| </p> |
| <div className="text-xs text-[var(--foreground)]/50"> |
| Make sure Ollama is running and try again. |
| </div> |
| </div> |
| </GlassPanel> |
| ); |
| } |
|
|
| |
| if (analysisResult && filteredErrors.length === 0) { |
| return ( |
| <GlassPanel className="p-6 h-full"> |
| <h2 className="text-lg font-semibold text-[var(--foreground)] mb-4"> |
| Analysis Results |
| </h2> |
| <motion.div |
| initial={{ scale: 0.9, opacity: 0 }} |
| animate={{ scale: 1, opacity: 1 }} |
| className={cn( |
| 'flex flex-col items-center justify-center text-center py-12', |
| 'bg-[var(--success-bg-glass)] rounded-2xl border border-[var(--success-border)]' |
| )} |
| > |
| <motion.div |
| initial={{ scale: 0 }} |
| animate={{ scale: 1 }} |
| transition={{ delay: 0.2, type: 'spring', stiffness: 200 }} |
| className="text-6xl mb-4" |
| > |
| ✅ |
| </motion.div> |
| <h3 className="text-xl font-semibold text-[var(--success-text)] mb-2"> |
| No Issues Found |
| </h3> |
| <p className="text-sm text-[var(--foreground)]/60 max-w-xs"> |
| {analysisResult.errors.length === 0 |
| ? 'The clinical note appears to be free of detectable errors.' |
| : 'All detected issues are filtered out by your current settings.'} |
| </p> |
| {analysisResult.elapsed_ms && ( |
| <div className="mt-4 text-xs text-[var(--foreground)]/40"> |
| Analysis completed in {formatDuration(analysisResult.elapsed_ms)} |
| </div> |
| )} |
| <button |
| onClick={() => setShowRawOutput(true)} |
| className="mt-4 text-xs text-[var(--foreground)]/40 hover:text-[var(--foreground)]/70 underline underline-offset-2 transition-colors" |
| > |
| View raw model output |
| </button> |
| </motion.div> |
| |
| {/* Raw Output Modal */} |
| <RawOutputModal |
| open={showRawOutput} |
| onOpenChange={setShowRawOutput} |
| analysisResult={analysisResult} |
| /> |
| </GlassPanel> |
| ); |
| } |
|
|
| |
| return ( |
| <GlassPanel className="h-full overflow-y-auto relative"> |
| {/* Sticky Header with liquid glass effect */} |
| <div className="sticky top-0 z-10 backdrop-blur-xl bg-[var(--glass-bg)]/80 px-4 sm:px-6 pt-4 sm:pt-6 pb-4 relative"> |
| <div className="flex items-center justify-between mb-4"> |
| <h2 className="text-lg font-semibold text-[var(--foreground)]"> |
| Analysis Results |
| </h2> |
| <div className="flex items-center gap-2"> |
| {analysisResult?.elapsed_ms && ( |
| <span className="text-xs text-[var(--foreground)]/40"> |
| {formatDuration(analysisResult.elapsed_ms)} |
| </span> |
| )} |
| {/* Export Dropdown */} |
| <DropdownMenu.Root open={exportMenuOpen} onOpenChange={setExportMenuOpen}> |
| <DropdownMenu.Trigger asChild> |
| <button |
| className="p-1.5 rounded-lg hover:bg-[var(--glass-bg-elevated)] transition-colors" |
| title="Export" |
| > |
| <svg |
| className="w-4 h-4 text-[var(--foreground)]/60" |
| fill="none" |
| stroke="currentColor" |
| viewBox="0 0 24 24" |
| > |
| <path |
| strokeLinecap="round" |
| strokeLinejoin="round" |
| strokeWidth={2} |
| d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" |
| /> |
| </svg> |
| </button> |
| </DropdownMenu.Trigger> |
| |
| <AnimatePresence> |
| {exportMenuOpen && ( |
| <DropdownMenu.Portal forceMount> |
| <DropdownMenu.Content |
| asChild |
| sideOffset={8} |
| align="end" |
| className="z-50" |
| > |
| <motion.div |
| initial={{ opacity: 0, y: -8, scale: 0.96 }} |
| animate={{ opacity: 1, y: 0, scale: 1 }} |
| exit={{ opacity: 0, y: -8, scale: 0.96 }} |
| transition={{ duration: 0.15 }} |
| className={cn( |
| 'min-w-[160px] rounded-xl p-1', |
| 'glass-elevated', |
| 'border border-[var(--glass-border)]', |
| 'shadow-xl' |
| )} |
| > |
| <DropdownMenu.Item |
| className={cn( |
| 'flex items-center gap-2 px-3 py-2 rounded-lg', |
| 'text-sm cursor-pointer outline-none', |
| 'hover:bg-[var(--glass-bg-muted)]', |
| 'transition-colors duration-150' |
| )} |
| onSelect={() => handleExport('pdf')} |
| > |
| <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" /> |
| </svg> |
| Export as PDF |
| </DropdownMenu.Item> |
| |
| <DropdownMenu.Item |
| className={cn( |
| 'flex items-center gap-2 px-3 py-2 rounded-lg', |
| 'text-sm cursor-pointer outline-none', |
| 'hover:bg-[var(--glass-bg-muted)]', |
| 'transition-colors duration-150' |
| )} |
| onSelect={() => handleExport('csv')} |
| > |
| <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h18M3 14h18m-9-4v8m-7 0h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" /> |
| </svg> |
| Export as CSV |
| </DropdownMenu.Item> |
| |
| <DropdownMenu.Item |
| className={cn( |
| 'flex items-center gap-2 px-3 py-2 rounded-lg', |
| 'text-sm cursor-pointer outline-none', |
| 'hover:bg-[var(--glass-bg-muted)]', |
| 'transition-colors duration-150' |
| )} |
| onSelect={() => handleExport('txt')} |
| > |
| <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /> |
| </svg> |
| Export as Text |
| </DropdownMenu.Item> |
| |
| <DropdownMenu.Item |
| className={cn( |
| 'flex items-center gap-2 px-3 py-2 rounded-lg', |
| 'text-sm cursor-pointer outline-none', |
| 'hover:bg-[var(--glass-bg-muted)]', |
| 'transition-colors duration-150' |
| )} |
| onSelect={() => handleExport('json')} |
| > |
| <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" /> |
| </svg> |
| Export as JSON |
| </DropdownMenu.Item> |
| </motion.div> |
| </DropdownMenu.Content> |
| </DropdownMenu.Portal> |
| )} |
| </AnimatePresence> |
| </DropdownMenu.Root> |
| |
| {analysisResult?.raw_response && ( |
| <button |
| onClick={() => setShowRawOutput(true)} |
| className="p-1.5 rounded-lg hover:bg-[var(--glass-bg-elevated)] transition-colors" |
| title="View raw model output" |
| > |
| <svg className="w-4 h-4 text-[var(--foreground)]/60" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> |
| <path strokeLinecap="round" strokeLinejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5" /> |
| </svg> |
| </button> |
| )} |
| |
| {ragChunks.length > 0 && ( |
| <button |
| onClick={() => setShowSources((v) => !v)} |
| className={cn( |
| 'flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium transition-colors', |
| showSources |
| ? 'bg-[var(--suggestion-accent)]/15 text-[var(--suggestion-accent)]' |
| : 'hover:bg-[var(--glass-bg-elevated)] text-[var(--foreground)]/60' |
| )} |
| title={showSources ? 'Hide sources' : 'Show sources'} |
| > |
| <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> |
| <path strokeLinecap="round" strokeLinejoin="round" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 5.625c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125" /> |
| </svg> |
| {ragChunks.length} |
| </button> |
| )} |
| |
| {onToggleCollapse && ( |
| <button |
| onClick={onToggleCollapse} |
| className="p-1.5 rounded-lg hover:bg-[var(--glass-bg-elevated)] transition-colors" |
| title="Collapse" |
| > |
| <svg |
| className="w-4 h-4 text-[var(--foreground)]/60" |
| fill="none" |
| stroke="currentColor" |
| viewBox="0 0 24 24" |
| > |
| <path |
| strokeLinecap="round" |
| strokeLinejoin="round" |
| strokeWidth={2} |
| d="M13 5l7 7-7 7M5 5l7 7-7 7" |
| /> |
| </svg> |
| </button> |
| )} |
| </div> |
| </div> |
| |
| {/* Summary Metrics */} |
| <motion.div |
| className="grid grid-cols-3 gap-3" |
| initial={{ opacity: 0 }} |
| animate={{ opacity: 1 }} |
| transition={{ duration: 0.2 }} |
| > |
| <MetricCard |
| label="Critical" |
| count={filteredSummary.critical} |
| severity="critical" |
| /> |
| <MetricCard |
| label="Warnings" |
| count={filteredSummary.warning} |
| severity="warning" |
| /> |
| <MetricCard |
| label="Suggestions" |
| count={filteredSummary.suggestion} |
| severity="suggestion" |
| /> |
| </motion.div> |
| </div> |
| |
| {/* Error Cards */} |
| <div className="px-4 sm:px-6 pt-0 pb-4 space-y-4"> |
| <AnimatePresence> |
| {filteredErrors.map((error, index) => ( |
| <ErrorCard key={error.id} error={error} index={index} /> |
| ))} |
| </AnimatePresence> |
| </div> |
| |
| {/* RAG Sources (collapsible) */} |
| <AnimatePresence> |
| {showSources && ragChunks.length > 0 && ( |
| <motion.div |
| initial={{ height: 0, opacity: 0 }} |
| animate={{ height: 'auto', opacity: 1 }} |
| exit={{ height: 0, opacity: 0 }} |
| transition={{ duration: 0.2 }} |
| className="overflow-hidden" |
| > |
| <div className="px-4 sm:px-6 pb-6 pt-2 border-t border-[var(--glass-border-subtle)]"> |
| <RagChunksPanel |
| chunks={ragChunks} |
| isLoading={false} |
| onChunkClick={(chunk) => setActiveChunk(chunk)} |
| /> |
| <ChunkModal |
| chunk={activeChunk} |
| open={activeChunk !== null} |
| onOpenChange={(open) => { if (!open) setActiveChunk(null); }} |
| /> |
| </div> |
| </motion.div> |
| )} |
| </AnimatePresence> |
| |
| {/* Bottom fade - sticks at bottom */} |
| <div |
| className="sticky bottom-0 z-[9] h-6 backdrop-blur-sm bg-gradient-to-t from-[var(--glass-bg)]/90 to-transparent pointer-events-none -mt-6" |
| style={{ |
| maskImage: 'linear-gradient(to top, black 20%, transparent)', |
| WebkitMaskImage: 'linear-gradient(to top, black 20%, transparent)', |
| } as React.CSSProperties} |
| /> |
| |
| {/* Raw Output Modal */} |
| <RawOutputModal |
| open={showRawOutput} |
| onOpenChange={setShowRawOutput} |
| analysisResult={analysisResult} |
| /> |
| </GlassPanel> |
| ); |
| } |
|
|
| |
| function MetricCard({ |
| label, |
| count, |
| severity, |
| className, |
| }: { |
| label: string; |
| count: number; |
| severity?: 'critical' | 'warning' | 'suggestion'; |
| className?: string; |
| }) { |
| const info = severity ? SEVERITY_INFO[severity] : null; |
|
|
| return ( |
| <motion.div |
| initial={{ opacity: 0, y: 10 }} |
| animate={{ opacity: 1, y: 0 }} |
| className={cn( |
| 'p-4 rounded-xl glass', |
| severity && info?.className, |
| severity === 'critical' && count > 0 && 'pulse-glow-critical', |
| className |
| )} |
| > |
| <motion.div |
| key={count} |
| initial={{ scale: 1.2 }} |
| animate={{ scale: 1 }} |
| className={cn( |
| 'text-2xl font-bold tabular-nums', |
| info?.textColor || 'text-[var(--foreground)]' |
| )} |
| > |
| {count} |
| </motion.div> |
| <div className="text-xs text-[var(--foreground)]/60 mt-1">{label}</div> |
| </motion.div> |
| ); |
| } |
|
|
| |
| function ErrorCard({ error, index }: { error: DetectedError; index: number }) { |
| const info = SEVERITY_INFO[error.severity]; |
|
|
| return ( |
| <motion.div |
| initial={{ opacity: 0, y: 20 }} |
| animate={{ opacity: 1, y: 0 }} |
| exit={{ opacity: 0, x: -20 }} |
| transition={{ |
| duration: 0.3, |
| delay: index * 0.05, |
| ease: [0.25, 0.46, 0.45, 0.94], |
| }} |
| className={cn( |
| 'p-4 rounded-xl glass', |
| info.className, |
| 'hover:shadow-lg transition-shadow duration-200' |
| )} |
| > |
| {/* Header */} |
| <div className="flex items-center gap-2 mb-3"> |
| <SeverityBadge severity={error.severity} size="sm" /> |
| <CategoryBadge category={error.category} size="sm" /> |
| </div> |
| |
| {/* Explanation */} |
| <p className="text-sm text-[var(--foreground)]/80 mb-3"> |
| {error.explanation} |
| </p> |
| |
| {/* Reasoning (chain-of-thought) */} |
| {error.reasoning && ( |
| <div |
| className={cn( |
| 'p-3 rounded-lg mb-3', |
| 'bg-[var(--glass-bg-muted)]', |
| 'border-l-2', |
| info.borderColor, |
| 'text-sm text-[var(--foreground)]/70 italic' |
| )} |
| > |
| {error.reasoning} |
| </div> |
| )} |
| |
| {/* Suggestion */} |
| {error.suggestion && ( |
| <div |
| className={cn( |
| 'flex items-start gap-2 p-3 rounded-lg mb-3', |
| 'bg-[var(--success-bg-glass)]', |
| 'border border-[var(--success-border)]' |
| )} |
| > |
| <span className="text-base">💡</span> |
| <div> |
| <span className="text-xs font-medium text-[var(--success-text)]"> |
| Suggestion: |
| </span> |
| <p className="text-sm text-[var(--foreground)]/80 mt-0.5"> |
| {error.suggestion} |
| </p> |
| </div> |
| </div> |
| )} |
| |
| {/* Confidence */} |
| <div className="flex items-center gap-2"> |
| <span className="text-xs text-[var(--foreground)]/50">Confidence:</span> |
| <ConfidenceBar confidence={error.confidence} size="sm" className="flex-1" /> |
| </div> |
| </motion.div> |
| ); |
| } |
|
|
| |
| function CollapsedMetricCard({ |
| label, |
| count, |
| severity, |
| }: { |
| label: string; |
| count: number; |
| severity?: 'critical' | 'warning' | 'suggestion'; |
| }) { |
| const info = severity ? SEVERITY_INFO[severity] : null; |
|
|
| return ( |
| <div |
| className={cn( |
| 'p-2 rounded-lg glass text-center', |
| severity && info?.className, |
| severity === 'critical' && count > 0 && 'pulse-glow-critical' |
| )} |
| > |
| <div |
| className={cn( |
| 'text-lg font-bold tabular-nums', |
| info?.textColor || 'text-[var(--foreground)]' |
| )} |
| > |
| {count} |
| </div> |
| <div className="text-[10px] text-[var(--foreground)]/60">{label}</div> |
| </div> |
| ); |
| } |
|
|
| |
| function RawOutputModal({ |
| open, |
| onOpenChange, |
| analysisResult, |
| }: { |
| open: boolean; |
| onOpenChange: (open: boolean) => void; |
| analysisResult: AnalysisResult | null; |
| }) { |
| if (!open || !analysisResult) return null; |
|
|
| const rawResponse = analysisResult.raw_response || ''; |
| const medgemmaResult = analysisResult.medgemma_result; |
|
|
| let formattedJson = ''; |
| try { |
| const parsed = JSON.parse(rawResponse); |
| formattedJson = JSON.stringify(parsed, null, 2); |
| } catch { |
| formattedJson = ''; |
| } |
|
|
| return ( |
| <AnimatePresence> |
| {open && ( |
| <motion.div |
| initial={{ opacity: 0 }} |
| animate={{ opacity: 1 }} |
| exit={{ opacity: 0 }} |
| className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" |
| onClick={() => onOpenChange(false)} |
| > |
| <motion.div |
| initial={{ scale: 0.95, opacity: 0 }} |
| animate={{ scale: 1, opacity: 1 }} |
| exit={{ scale: 0.95, opacity: 0 }} |
| transition={{ duration: 0.2 }} |
| className={cn( |
| 'w-full max-w-3xl max-h-[80vh] flex flex-col rounded-2xl', |
| 'glass-elevated border border-[var(--glass-border)]', |
| 'shadow-2xl' |
| )} |
| onClick={(e) => e.stopPropagation()} |
| > |
| {/* Header */} |
| <div className="flex items-center justify-between px-6 py-4 border-b border-[var(--glass-border-subtle)]"> |
| <div> |
| <h3 className="text-base font-semibold text-[var(--foreground)]"> |
| Raw Model Output |
| </h3> |
| <p className="text-xs text-[var(--foreground)]/50 mt-0.5"> |
| {analysisResult.model_used || 'MedGemma'} · {analysisResult.provider || 'vastai'} |
| {analysisResult.elapsed_ms ? ` \u00b7 ${formatDuration(analysisResult.elapsed_ms)}` : ''} |
| </p> |
| </div> |
| <button |
| onClick={() => onOpenChange(false)} |
| className="p-1.5 rounded-lg hover:bg-[var(--glass-bg-elevated)] transition-colors" |
| > |
| <svg className="w-5 h-5 text-[var(--foreground)]/60" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> |
| <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /> |
| </svg> |
| </button> |
| </div> |
| |
| {/* Body */} |
| <div className="flex-1 overflow-y-auto p-6 space-y-4"> |
| {/* Parsed Result Summary */} |
| {medgemmaResult && ( |
| <div> |
| <h4 className="text-xs font-semibold text-[var(--foreground)]/60 uppercase tracking-wider mb-2"> |
| Parsed Result ({medgemmaResult.errors.length} error{medgemmaResult.errors.length !== 1 ? 's' : ''} extracted) |
| </h4> |
| {medgemmaResult.errors.length > 0 ? ( |
| <div className="space-y-2"> |
| {medgemmaResult.errors.map((err, i) => ( |
| <div |
| key={i} |
| className={cn( |
| 'p-3 rounded-lg text-xs', |
| 'bg-[var(--glass-bg-muted)] border border-[var(--glass-border-subtle)]' |
| )} |
| > |
| <div className="flex items-center gap-2 mb-1"> |
| <span className={cn( |
| 'px-1.5 py-0.5 rounded text-[10px] font-bold uppercase', |
| err.severity === 'critical' |
| ? 'bg-[var(--critical-bg)] text-[var(--critical-text)]' |
| : 'bg-[var(--warning-bg)] text-[var(--warning-text)]' |
| )}> |
| {err.severity} |
| </span> |
| <span className="font-mono text-[var(--foreground)]/70">{err.type}</span> |
| <span className="ml-auto text-[var(--foreground)]/40"> |
| {(err.confidence * 100).toFixed(0)}% |
| </span> |
| </div> |
| <p className="text-[var(--foreground)]/80">{err.problem}</p> |
| {err.reasoning && ( |
| <p className="mt-1 text-[var(--foreground)]/50 italic">{err.reasoning}</p> |
| )} |
| </div> |
| ))} |
| </div> |
| ) : ( |
| <p className="text-xs text-[var(--foreground)]/50 italic"> |
| No errors were parsed from the model output. |
| {medgemmaResult.summary && ` Summary: "${medgemmaResult.summary}"`} |
| </p> |
| )} |
| </div> |
| )} |
| |
| {/* Raw Text */} |
| <div> |
| <h4 className="text-xs font-semibold text-[var(--foreground)]/60 uppercase tracking-wider mb-2"> |
| Raw Response Text |
| </h4> |
| <pre |
| className={cn( |
| 'p-4 rounded-xl text-xs leading-relaxed overflow-x-auto', |
| 'bg-black/30 border border-[var(--glass-border-subtle)]', |
| 'text-[var(--foreground)]/80 font-mono whitespace-pre-wrap break-words' |
| )} |
| > |
| {formattedJson || rawResponse || '(empty response)'} |
| </pre> |
| </div> |
| </div> |
| |
| {/* Footer */} |
| <div className="flex justify-end px-6 py-3 border-t border-[var(--glass-border-subtle)]"> |
| <button |
| onClick={() => { |
| navigator.clipboard.writeText(rawResponse); |
| }} |
| className="text-xs px-3 py-1.5 rounded-lg hover:bg-[var(--glass-bg-elevated)] text-[var(--foreground)]/60 hover:text-[var(--foreground)] transition-colors" |
| > |
| Copy to clipboard |
| </button> |
| </div> |
| </motion.div> |
| </motion.div> |
| )} |
| </AnimatePresence> |
| ); |
| } |
|
|
|
|
| |
| function QuickPreviewBanner({ |
| quickResult, |
| }: { |
| quickResult: { errors_found: boolean; count: number; severity: QuickSeverity }; |
| }) { |
| const getSeverityStyles = (severity: QuickSeverity) => { |
| switch (severity) { |
| case 'critical': |
| return { |
| bg: 'bg-[var(--critical-bg-glass)]', |
| border: 'border-[var(--critical-border)]', |
| text: 'text-[var(--critical-text)]', |
| }; |
| case 'warning': |
| return { |
| bg: 'bg-[var(--warning-bg-glass)]', |
| border: 'border-[var(--warning-border)]', |
| text: 'text-[var(--warning-text)]', |
| }; |
| default: |
| return { |
| bg: 'bg-[var(--success-bg-glass)]', |
| border: 'border-[var(--success-border)]', |
| text: 'text-[var(--success-text)]', |
| }; |
| } |
| }; |
|
|
| const styles = getSeverityStyles(quickResult.severity); |
|
|
| if (!quickResult.errors_found) { |
| return ( |
| <motion.div |
| initial={{ opacity: 0, y: -10 }} |
| animate={{ opacity: 1, y: 0 }} |
| className={cn( |
| 'p-3 rounded-xl mb-4 border', |
| styles.bg, |
| styles.border |
| )} |
| > |
| <div className="flex items-center gap-2"> |
| <span className="text-lg">✓</span> |
| <span className={cn('text-sm font-medium', styles.text)}> |
| No issues detected |
| </span> |
| <span className="text-xs text-[var(--foreground)]/50 ml-auto"> |
| Confirming... |
| </span> |
| </div> |
| </motion.div> |
| ); |
| } |
|
|
| return ( |
| <motion.div |
| initial={{ opacity: 0, y: -10 }} |
| animate={{ opacity: 1, y: 0 }} |
| className={cn( |
| 'p-3 rounded-xl mb-4 border', |
| styles.bg, |
| styles.border |
| )} |
| > |
| <div className="flex items-center gap-2"> |
| <span className="text-lg"> |
| {quickResult.severity === 'critical' ? '⚠️' : '⚡'} |
| </span> |
| <span className={cn('text-sm font-medium', styles.text)}> |
| {quickResult.count} {quickResult.count === 1 ? 'issue' : 'issues'} found |
| </span> |
| <span className="text-xs text-[var(--foreground)]/50 ml-auto"> |
| Getting details... |
| </span> |
| </div> |
| </motion.div> |
| ); |
| } |
|
|