| 'use client'; |
|
|
| import { useState, useRef, useEffect, useCallback, type DragEvent } from 'react'; |
| import { motion, AnimatePresence } from 'framer-motion'; |
| import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; |
| import { cn } from '@/lib/utils'; |
| import { |
| GlassPanel, |
| GlassButton, |
| GlassTextarea, |
| GlassTooltip, |
| GlassDialog, |
| } from '@/components/ui'; |
| import { useToast } from '@/components/ui/glass-toast'; |
| import type { DetectedError, QuickAnalyzeRequest } from '@/types'; |
| import { |
| useAppStore, |
| useInputText, |
| useIsAnalyzing, |
| useConnectionStatus, |
| useAnalysisResult, |
| useAnalysisStage, |
| } from '@/store/app-store'; |
| import type { AnalysisResult } from '@/types'; |
| import { sampleNotes } from '@/data/sample-notes'; |
| import { useDocumentImport } from '@/hooks/use-document-import'; |
| import { useNoteExport, type NoteExportFormat } from '@/hooks/use-note-export'; |
| import { useAutoSave } from '@/hooks/use-auto-save'; |
| import { useRecentFiles } from '@/hooks/use-recent-files'; |
|
|
| |
| function UploadIcon({ className }: { className?: string }) { |
| return ( |
| <svg |
| className={className} |
| fill="none" |
| viewBox="0 0 24 24" |
| stroke="currentColor" |
| strokeWidth={2} |
| > |
| <path |
| strokeLinecap="round" |
| strokeLinejoin="round" |
| d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" |
| /> |
| </svg> |
| ); |
| } |
|
|
| |
| function DocumentIcon({ className }: { className?: string }) { |
| return ( |
| <svg |
| className={className} |
| fill="none" |
| viewBox="0 0 24 24" |
| stroke="currentColor" |
| strokeWidth={1.5} |
| > |
| <path |
| strokeLinecap="round" |
| strokeLinejoin="round" |
| d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" |
| /> |
| </svg> |
| ); |
| } |
|
|
| export function ClinicalNoteEditor() { |
| const inputText = useInputText(); |
| const setInputText = useAppStore((state) => state.setInputText); |
| const isAnalyzing = useIsAnalyzing(); |
| const setIsAnalyzing = useAppStore((state) => state.setIsAnalyzing); |
| const connectionStatus = useConnectionStatus(); |
| const settings = useAppStore((state) => state.settings); |
| const setAnalysisResult = useAppStore((state) => state.setAnalysisResult); |
| const setAnalysisError = useAppStore((state) => state.setAnalysisError); |
| const clearInput = useAppStore((state) => state.clearInput); |
| const analysisResult = useAnalysisResult(); |
| const showAnnotatedView = useAppStore((state) => state.showAnnotatedView); |
| const setShowAnnotatedView = useAppStore((state) => state.setShowAnnotatedView); |
| const analysisStage = useAnalysisStage(); |
| const setAnalysisStage = useAppStore((state) => state.setAnalysisStage); |
| const setQuickResult = useAppStore((state) => state.setQuickResult); |
| const ragChunks = useAppStore((state) => state.ragChunks); |
|
|
| const [showSamples, setShowSamples] = useState(false); |
| const [isDragOver, setIsDragOver] = useState(false); |
| const [showReplaceConfirm, setShowReplaceConfirm] = useState(false); |
| const [showClearConfirm, setShowClearConfirm] = useState(false); |
| const [pendingImportText, setPendingImportText] = useState<string | null>(null); |
| const [exportMenuOpen, setExportMenuOpen] = useState(false); |
| const [isTranslating, setIsTranslating] = useState(false); |
| const [translateProgress, setTranslateProgress] = useState<string | null>(null); |
| const [multiagentEnabled, setMultiagentEnabled] = useState(false); |
| const [showMultiagentInfo, setShowMultiagentInfo] = useState(false); |
|
|
| const { showToast } = useToast(); |
| const { importFile, isImporting, importProgress, fileInputRef, acceptedTypes } = useDocumentImport(); |
| const { exportNote } = useNoteExport(); |
| const { clearSaved } = useAutoSave(inputText); |
| const { recentFiles, addRecentFile } = useRecentFiles(); |
| const [importMenuOpen, setImportMenuOpen] = useState(false); |
| const dropZoneRef = useRef<HTMLDivElement>(null); |
|
|
| |
| const quickAbortControllerRef = useRef<AbortController | null>(null); |
| const fullAbortControllerRef = useRef<AbortController | null>(null); |
|
|
| |
| useEffect(() => { |
| return () => { |
| quickAbortControllerRef.current?.abort(); |
| fullAbortControllerRef.current?.abort(); |
| }; |
| }, []); |
|
|
| |
| const handleImportSuccess = useCallback((text: string, fileName?: string) => { |
| setInputText(text); |
| setAnalysisResult(null); |
| setShowAnnotatedView(false); |
| showToast('Document imported successfully', 'success'); |
|
|
| |
| if (fileName) { |
| addRecentFile(fileName, text); |
| } |
| }, [setInputText, setAnalysisResult, setShowAnnotatedView, showToast, addRecentFile]); |
|
|
| |
| const processFileImport = useCallback(async (file: File) => { |
| const result = await importFile(file); |
|
|
| if (result.success && result.text) { |
| |
| if (inputText.trim().length > 0) { |
| setPendingImportText(result.text); |
| setShowReplaceConfirm(true); |
| } else { |
| handleImportSuccess(result.text, file.name); |
| } |
| } else if (result.errorMessage) { |
| showToast(result.errorMessage, 'error'); |
| } |
| }, [importFile, inputText, handleImportSuccess, showToast]); |
|
|
| |
| const handleFileInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { |
| const file = e.target.files?.[0]; |
| if (file) { |
| processFileImport(file); |
| } |
| |
| e.target.value = ''; |
| }, [processFileImport]); |
|
|
| |
| const handleImportClick = useCallback(() => { |
| fileInputRef.current?.click(); |
| }, [fileInputRef]); |
|
|
| |
| const handleConfirmReplace = useCallback(() => { |
| if (pendingImportText) { |
| setInputText(pendingImportText); |
| setAnalysisResult(null); |
| setShowAnnotatedView(false); |
| setPendingImportText(null); |
| setShowReplaceConfirm(false); |
| } |
| }, [pendingImportText, setInputText, setAnalysisResult, setShowAnnotatedView]); |
|
|
| |
| const handleCancelReplace = useCallback(() => { |
| setPendingImportText(null); |
| }, []); |
|
|
| |
| const handleDragEnter = useCallback((e: DragEvent) => { |
| e.preventDefault(); |
| e.stopPropagation(); |
|
|
| |
| if (e.dataTransfer.types.includes('Files')) { |
| setIsDragOver(true); |
| } |
| }, []); |
|
|
| const handleDragLeave = useCallback((e: DragEvent) => { |
| e.preventDefault(); |
| e.stopPropagation(); |
|
|
| |
| const relatedTarget = e.relatedTarget as Node | null; |
| if (!dropZoneRef.current?.contains(relatedTarget)) { |
| setIsDragOver(false); |
| } |
| }, []); |
|
|
| const handleDragOver = useCallback((e: DragEvent) => { |
| e.preventDefault(); |
| e.stopPropagation(); |
| }, []); |
|
|
| const handleDrop = useCallback((e: DragEvent) => { |
| e.preventDefault(); |
| e.stopPropagation(); |
| setIsDragOver(false); |
|
|
| const file = e.dataTransfer.files?.[0]; |
| if (file) { |
| processFileImport(file); |
| } |
| }, [processFileImport]); |
|
|
| |
| const runFullAnalysisOllama = async () => { |
| setAnalysisStage('full'); |
| fullAbortControllerRef.current = new AbortController(); |
|
|
| try { |
| const response = await fetch('/api/analyze', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ |
| text: inputText, |
| model: settings.selectedModel, |
| temperature: settings.temperature, |
| categories: settings.enabledCategories, |
| }), |
| signal: fullAbortControllerRef.current.signal, |
| }); |
|
|
| const data = await response.json(); |
| if (data.success) { |
| setAnalysisResult(data.data); |
| } else { |
| setAnalysisError(data.error || 'Analysis failed'); |
| } |
| } catch (error) { |
| if (error instanceof Error && error.name === 'AbortError') return; |
| setAnalysisError(error instanceof Error ? error.message : 'An unexpected error occurred'); |
| } finally { |
| setIsAnalyzing(false); |
| setAnalysisStage('idle'); |
| } |
| }; |
|
|
| |
| const runFullAnalysisMedgemma = async () => { |
| setAnalysisStage('full'); |
| fullAbortControllerRef.current = new AbortController(); |
|
|
| try { |
| const response = await fetch('/api/analyze-medgemma', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ |
| text: inputText, |
| temperature: settings.temperature, |
| provider: settings.provider === 'local' ? 'local' : 'vastai', |
| rag_context: ragChunks.map((c) => ({ |
| text: c.text, |
| title: c.title, |
| source: c.source, |
| })), |
| }), |
| signal: fullAbortControllerRef.current.signal, |
| }); |
|
|
| const data = await response.json(); |
| if (data.success) { |
| setAnalysisResult(data.data); |
| } else { |
| setAnalysisError(data.error || 'Analysis failed'); |
| } |
| } catch (error) { |
| if (error instanceof Error && error.name === 'AbortError') return; |
| setAnalysisError(error instanceof Error ? error.message : 'An unexpected error occurred'); |
| } finally { |
| setIsAnalyzing(false); |
| setAnalysisStage('idle'); |
| } |
| }; |
|
|
| |
| const runFullAnalysisMultiagent = async () => { |
| setAnalysisStage('full'); |
| fullAbortControllerRef.current = new AbortController(); |
|
|
| try { |
| const response = await fetch('/api/analyze-multiagent', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ |
| text: inputText, |
| temperature: settings.temperature, |
| provider: settings.provider === 'local' ? 'local' : 'vastai', |
| rag_context: ragChunks.map((c) => ({ |
| text: c.text, |
| title: c.title, |
| source: c.source, |
| })), |
| }), |
| signal: fullAbortControllerRef.current.signal, |
| }); |
|
|
| const data = await response.json(); |
| if (data.success) { |
| setAnalysisResult(data.data); |
| } else { |
| setAnalysisError(data.error || 'Multiagent analysis failed'); |
| } |
| } catch (error) { |
| if (error instanceof Error && error.name === 'AbortError') return; |
| setAnalysisError(error instanceof Error ? error.message : 'An unexpected error occurred'); |
| } finally { |
| setIsAnalyzing(false); |
| setAnalysisStage('idle'); |
| } |
| }; |
|
|
| |
| const handleAnalyze = async () => { |
| const useBackend = settings.provider === 'medgemma' || settings.provider === 'local'; |
|
|
| |
| if (!inputText.trim() || isAnalyzing) return; |
| if (!useBackend && !settings.selectedModel) return; |
|
|
| |
| quickAbortControllerRef.current?.abort(); |
| fullAbortControllerRef.current?.abort(); |
|
|
| setIsAnalyzing(true); |
| setAnalysisError(null); |
| setAnalysisResult(null); |
| setQuickResult(null); |
|
|
| |
| if (useBackend) { |
| if (multiagentEnabled) { |
| runFullAnalysisMultiagent(); |
| } else { |
| runFullAnalysisMedgemma(); |
| } |
| return; |
| } |
|
|
| setAnalysisStage('quick'); |
|
|
| |
| quickAbortControllerRef.current = new AbortController(); |
|
|
| try { |
| |
| const quickRequest: QuickAnalyzeRequest = { |
| text: inputText, |
| model: settings.selectedModel, |
| }; |
|
|
| const quickResponse = await fetch('/api/analyze-quick', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify(quickRequest), |
| signal: quickAbortControllerRef.current.signal, |
| }); |
|
|
| const quickData = await quickResponse.json(); |
|
|
| if (quickData.success) { |
| setQuickResult(quickData.data); |
| |
| runFullAnalysisOllama(); |
| } else { |
| |
| console.warn('Quick detection failed, falling back to full analysis:', quickData.error); |
| runFullAnalysisOllama(); |
| } |
| } catch (error) { |
| |
| if (error instanceof Error && error.name === 'AbortError') { |
| console.log('Quick detection cancelled'); |
| return; |
| } |
|
|
| |
| console.warn('Quick detection failed, falling back to full analysis:', error); |
| runFullAnalysisOllama(); |
| } |
| }; |
|
|
| const handleLoadSample = (content: string) => { |
| setInputText(content); |
| setShowSamples(false); |
| setAnalysisResult(null); |
| setShowAnnotatedView(false); |
| }; |
|
|
| const handleExportNote = (format: NoteExportFormat) => { |
| if (!inputText.trim()) return; |
| exportNote(format, inputText); |
| setExportMenuOpen(false); |
| }; |
|
|
| const handleClearClick = () => { |
| |
| if (inputText.trim().length > 100) { |
| setShowClearConfirm(true); |
| } else { |
| clearInput(); |
| } |
| }; |
|
|
| const handleConfirmClear = () => { |
| clearInput(); |
| clearSaved(); |
| setShowClearConfirm(false); |
| }; |
|
|
| |
| const handleTranslate = useCallback(async () => { |
| if (!inputText.trim() || isTranslating || isAnalyzing) return; |
|
|
| |
| |
| const translateProvider = settings.provider === 'medgemma' ? 'vastai' : 'local'; |
|
|
| setIsTranslating(true); |
| setTranslateProgress( |
| translateProvider === 'vastai' |
| ? 'Translating via Vast.ai...' |
| : 'Translating locally...' |
| ); |
|
|
| try { |
| const response = await fetch('/api/translate', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ |
| text: inputText, |
| source_lang: 'hr', |
| target_lang: 'en', |
| provider: translateProvider, |
| }), |
| }); |
|
|
| const data = await response.json(); |
|
|
| if (data.success && data.translated_text) { |
| setInputText(data.translated_text); |
| setAnalysisResult(null); |
| setShowAnnotatedView(false); |
| const secs = Math.round((data.elapsed_ms || 0) / 1000); |
| const via = data.provider === 'vastai' ? 'Vast.ai' : 'local GPU'; |
| showToast( |
| `Translated via ${via} (${data.chunks_count} chunk${data.chunks_count !== 1 ? 's' : ''}, ${secs}s)`, |
| 'success' |
| ); |
| } else { |
| showToast(data.error || 'Translation failed', 'error'); |
| } |
| } catch (error) { |
| showToast( |
| error instanceof Error ? error.message : 'Translation failed', |
| 'error' |
| ); |
| } finally { |
| setIsTranslating(false); |
| setTranslateProgress(null); |
| } |
| }, [inputText, isTranslating, isAnalyzing, settings.provider, setInputText, setAnalysisResult, setShowAnnotatedView, showToast]); |
|
|
| const handleLoadRecentFile = (content: string) => { |
| if (inputText.trim().length > 0) { |
| setPendingImportText(content); |
| setShowReplaceConfirm(true); |
| } else { |
| setInputText(content); |
| setAnalysisResult(null); |
| setShowAnnotatedView(false); |
| } |
| setImportMenuOpen(false); |
| }; |
|
|
| const isBackendProvider = settings.provider === 'medgemma' || settings.provider === 'local'; |
| const canAnalyze = isBackendProvider |
| ? inputText.trim().length > 0 && !isAnalyzing && !isTranslating |
| : connectionStatus === 'connected' && |
| inputText.trim().length > 0 && |
| settings.selectedModel && |
| !isAnalyzing && |
| !isTranslating; |
|
|
| return ( |
| <> |
| <GlassPanel className="p-4 sm:p-6 h-full flex flex-col"> |
| {/* Header */} |
| <div className="flex items-center justify-between mb-4"> |
| <h2 className="text-lg font-semibold text-[var(--foreground)]"> |
| Clinical Note |
| </h2> |
| <div className="flex items-center gap-2"> |
| {/* Import Dropdown */} |
| <DropdownMenu.Root open={importMenuOpen} onOpenChange={setImportMenuOpen}> |
| <DropdownMenu.Trigger asChild> |
| <button |
| disabled={isAnalyzing || isImporting} |
| className={cn( |
| 'text-sm font-medium px-3 py-1.5 rounded-lg', |
| 'transition-colors duration-200', |
| 'flex items-center gap-1.5', |
| 'text-[var(--foreground)]/60 hover:text-[var(--foreground)]', |
| 'hover:bg-[var(--glass-bg-muted)]', |
| 'disabled:opacity-50 disabled:pointer-events-none' |
| )} |
| > |
| <UploadIcon className="w-4 h-4" /> |
| {isImporting ? importProgress : 'Import'} |
| <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"> |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /> |
| </svg> |
| </button> |
| </DropdownMenu.Trigger> |
| |
| <AnimatePresence> |
| {importMenuOpen && ( |
| <DropdownMenu.Portal forceMount> |
| <DropdownMenu.Content |
| asChild |
| sideOffset={8} |
| align="start" |
| 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-[280px] max-w-[320px] 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', |
| 'font-medium' |
| )} |
| onSelect={handleImportClick} |
| > |
| <UploadIcon className="w-4 h-4" /> |
| Import from File... |
| </DropdownMenu.Item> |
| |
| {recentFiles.length > 0 && ( |
| <> |
| <DropdownMenu.Separator className="h-px bg-[var(--glass-border-subtle)] my-1" /> |
| <div className="px-2 py-1.5 text-xs text-[var(--foreground)]/40 font-medium"> |
| Recent Files |
| </div> |
| |
| {recentFiles.map((file) => ( |
| <DropdownMenu.Item |
| key={file.id} |
| className={cn( |
| 'flex flex-col gap-1 px-3 py-2 rounded-lg', |
| 'text-sm cursor-pointer outline-none', |
| 'hover:bg-[var(--glass-bg-muted)]', |
| 'transition-colors duration-150' |
| )} |
| onSelect={() => handleLoadRecentFile(file.content)} |
| > |
| <div className="flex items-center gap-2 w-full"> |
| <svg className="w-3.5 h-3.5 flex-shrink-0 text-[var(--foreground)]/60" 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> |
| <span className="font-medium truncate">{file.name}</span> |
| </div> |
| <div className="text-xs text-[var(--foreground)]/50 pl-5 truncate"> |
| {file.preview}... |
| </div> |
| </DropdownMenu.Item> |
| ))} |
| </> |
| )} |
| </motion.div> |
| </DropdownMenu.Content> |
| </DropdownMenu.Portal> |
| )} |
| </AnimatePresence> |
| </DropdownMenu.Root> |
| |
| {/* Edit Mode / View Annotations Button */} |
| {analysisResult && analysisResult.errors.length > 0 && ( |
| <button |
| onClick={() => setShowAnnotatedView(!showAnnotatedView)} |
| className={cn( |
| 'text-sm font-medium px-3 py-1.5 rounded-lg', |
| 'transition-colors duration-200', |
| showAnnotatedView |
| ? 'bg-[var(--suggestion-bg)] text-[var(--suggestion-text)]' |
| : 'text-[var(--foreground)]/60 hover:text-[var(--foreground)]' |
| )} |
| > |
| {showAnnotatedView ? 'Edit Mode' : 'View Annotations'} |
| </button> |
| )} |
| </div> |
| </div> |
| |
| {/* Text Area or Annotated View with Drag-Drop Zone */} |
| <div |
| ref={dropZoneRef} |
| className="flex-1 min-h-0 overflow-hidden relative" |
| onDragEnter={handleDragEnter} |
| onDragLeave={handleDragLeave} |
| onDragOver={handleDragOver} |
| onDrop={handleDrop} |
| > |
| {showAnnotatedView && analysisResult ? ( |
| <AnnotatedView text={inputText} errors={analysisResult.errors} /> |
| ) : ( |
| <GlassTextarea |
| value={inputText} |
| onChange={(e) => setInputText(e.target.value)} |
| placeholder="Paste or type a clinical note here to analyze for errors..." |
| showCharCount |
| className="h-full" |
| disabled={isAnalyzing || isImporting || isTranslating} |
| /> |
| )} |
| |
| {/* Drag-Drop Overlay */} |
| <AnimatePresence> |
| {isDragOver && ( |
| <motion.div |
| initial={{ opacity: 0 }} |
| animate={{ opacity: 1 }} |
| exit={{ opacity: 0 }} |
| transition={{ duration: 0.15 }} |
| className={cn( |
| 'absolute inset-0 z-10', |
| 'bg-[var(--suggestion-accent)]/10', |
| 'backdrop-blur-[2px]', |
| 'border-2 border-dashed border-[var(--suggestion-accent)]', |
| 'rounded-xl', |
| 'flex flex-col items-center justify-center gap-3', |
| 'pointer-events-none' |
| )} |
| > |
| <motion.div |
| initial={{ scale: 0.8, y: 10 }} |
| animate={{ scale: 1, y: 0 }} |
| exit={{ scale: 0.8, y: 10 }} |
| transition={{ duration: 0.15 }} |
| className="flex flex-col items-center gap-3" |
| > |
| <div className="p-4 rounded-full bg-[var(--suggestion-accent)]/20"> |
| <DocumentIcon className="w-10 h-10 text-[var(--suggestion-accent)]" /> |
| </div> |
| <p className="text-lg font-medium text-[var(--suggestion-accent)]"> |
| Drop file to import |
| </p> |
| <p className="text-sm text-[var(--foreground)]/60"> |
| Supports PDF, DOCX, and TXT files |
| </p> |
| </motion.div> |
| </motion.div> |
| )} |
| </AnimatePresence> |
| |
| {/* Importing Overlay */} |
| <AnimatePresence> |
| {isImporting && ( |
| <motion.div |
| initial={{ opacity: 0 }} |
| animate={{ opacity: 1 }} |
| exit={{ opacity: 0 }} |
| transition={{ duration: 0.15 }} |
| className={cn( |
| 'absolute inset-0 z-10', |
| 'bg-[var(--background)]/60', |
| 'backdrop-blur-[2px]', |
| 'rounded-xl', |
| 'flex flex-col items-center justify-center gap-3' |
| )} |
| > |
| <div className="flex items-center gap-3"> |
| <svg |
| className="animate-spin h-6 w-6 text-[var(--suggestion-accent)]" |
| fill="none" |
| viewBox="0 0 24 24" |
| > |
| <circle |
| className="opacity-25" |
| cx="12" |
| cy="12" |
| r="10" |
| stroke="currentColor" |
| strokeWidth="4" |
| /> |
| <path |
| className="opacity-75" |
| fill="currentColor" |
| d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" |
| /> |
| </svg> |
| <span className="text-[var(--foreground)] font-medium"> |
| {importProgress || 'Importing...'} |
| </span> |
| </div> |
| </motion.div> |
| )} |
| </AnimatePresence> |
| |
| {/* Translating Overlay */} |
| <AnimatePresence> |
| {isTranslating && ( |
| <motion.div |
| initial={{ opacity: 0 }} |
| animate={{ opacity: 1 }} |
| exit={{ opacity: 0 }} |
| transition={{ duration: 0.15 }} |
| className={cn( |
| 'absolute inset-0 z-10', |
| 'bg-[var(--background)]/60', |
| 'backdrop-blur-[2px]', |
| 'rounded-xl', |
| 'flex flex-col items-center justify-center gap-3' |
| )} |
| > |
| <div className="flex flex-col items-center gap-3"> |
| <svg |
| className="animate-spin h-8 w-8 text-[var(--suggestion-accent)]" |
| fill="none" |
| viewBox="0 0 24 24" |
| > |
| <circle |
| className="opacity-25" |
| cx="12" |
| cy="12" |
| r="10" |
| stroke="currentColor" |
| strokeWidth="4" |
| /> |
| <path |
| className="opacity-75" |
| fill="currentColor" |
| d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" |
| /> |
| </svg> |
| <span className="text-[var(--foreground)] font-medium text-lg"> |
| Translating HR → EN... |
| </span> |
| <span className="text-[var(--foreground)]/60 text-sm"> |
| Using TranslateGemma 4B — this may take a few minutes |
| </span> |
| </div> |
| </motion.div> |
| )} |
| </AnimatePresence> |
| </div> |
| |
| {/* Hidden File Input */} |
| <input |
| ref={fileInputRef} |
| type="file" |
| accept={acceptedTypes} |
| onChange={handleFileInputChange} |
| className="hidden" |
| /> |
| |
| {/* Actions */} |
| <div className="flex flex-col gap-3 mt-4"> |
| {/* Row 1: Analyze + secondary actions */} |
| <div className="flex flex-wrap gap-2"> |
| <GlassButton |
| variant="primary" |
| size="lg" |
| onClick={handleAnalyze} |
| disabled={!canAnalyze} |
| isLoading={isAnalyzing} |
| > |
| {isAnalyzing |
| ? multiagentEnabled ? 'Multiagent...' : 'Analyzing...' |
| : settings.provider === 'local' |
| ? 'Analyze (Local)' |
| : 'Analyze Note'} |
| </GlassButton> |
| |
| <GlassButton |
| variant="secondary" |
| size="lg" |
| onClick={handleTranslate} |
| disabled={isTranslating || isAnalyzing || !inputText.trim()} |
| isLoading={isTranslating} |
| > |
| {isTranslating ? (translateProgress || 'Translating...') : 'Translate HR → EN'} |
| </GlassButton> |
| |
| <GlassButton |
| variant="secondary" |
| size="lg" |
| onClick={handleClearClick} |
| disabled={isAnalyzing || isTranslating || !inputText} |
| > |
| Clear |
| </GlassButton> |
| |
| {/* Export Dropdown */} |
| <DropdownMenu.Root open={exportMenuOpen} onOpenChange={setExportMenuOpen}> |
| <DropdownMenu.Trigger asChild> |
| <button |
| disabled={isAnalyzing || !inputText.trim()} |
| className="p-1.5 rounded-lg hover:bg-[var(--glass-bg-elevated)] transition-colors disabled:opacity-50 disabled:pointer-events-none" |
| title="Export" |
| > |
| <svg className="w-4 h-4 text-[var(--foreground)]/60" fill="none" viewBox="0 0 24 24" stroke="currentColor"> |
| <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={() => handleExportNote('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={() => handleExportNote('docx')} |
| > |
| <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 DOCX |
| </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={() => handleExportNote('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 TXT |
| </DropdownMenu.Item> |
| </motion.div> |
| </DropdownMenu.Content> |
| </DropdownMenu.Portal> |
| )} |
| </AnimatePresence> |
| </DropdownMenu.Root> |
| </div> |
| |
| {/* Multiagent toggle (only for MedGemma / local providers) */} |
| {isBackendProvider && ( |
| <div className="flex items-center gap-2"> |
| <button |
| onClick={() => setMultiagentEnabled((v) => !v)} |
| disabled={isAnalyzing} |
| className={cn( |
| 'relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent', |
| 'transition-colors duration-200 ease-in-out focus:outline-none', |
| 'disabled:opacity-50 disabled:cursor-not-allowed', |
| multiagentEnabled |
| ? 'bg-[var(--suggestion-accent)]' |
| : 'bg-[var(--glass-bg-elevated)]' |
| )} |
| role="switch" |
| aria-checked={multiagentEnabled} |
| aria-label="Enable multiagent pipeline" |
| > |
| <span |
| className={cn( |
| 'pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0', |
| 'transition duration-200 ease-in-out', |
| multiagentEnabled ? 'translate-x-4' : 'translate-x-0' |
| )} |
| /> |
| </button> |
| <span className="text-xs text-[var(--foreground)]/60"> |
| Multiagent pipeline |
| </span> |
| <div className="relative"> |
| <button |
| onClick={() => setShowMultiagentInfo((v) => !v)} |
| onMouseEnter={() => setShowMultiagentInfo(true)} |
| onMouseLeave={() => setShowMultiagentInfo(false)} |
| className="w-4 h-4 rounded-full bg-[var(--glass-bg-elevated)] text-[var(--foreground)]/40 hover:text-[var(--foreground)]/70 flex items-center justify-center text-[10px] font-bold transition-colors" |
| aria-label="What is multiagent pipeline?" |
| > |
| ? |
| </button> |
| <AnimatePresence> |
| {showMultiagentInfo && ( |
| <motion.div |
| initial={{ opacity: 0, y: 4 }} |
| animate={{ opacity: 1, y: 0 }} |
| exit={{ opacity: 0, y: 4 }} |
| transition={{ duration: 0.15 }} |
| className={cn( |
| 'absolute left-0 bottom-full mb-2 z-50', |
| 'w-64 p-3 rounded-xl text-xs leading-relaxed', |
| 'glass-elevated border border-[var(--glass-border)]', |
| 'shadow-xl text-[var(--foreground)]/80' |
| )} |
| > |
| Runs 6 sequential LLM passes: 2 first-pass reviewers |
| (conservative + exploratory), 3 specialist critics |
| (diagnostics, treatment, follow-up), and a final adjudicator |
| that selects the top 3 highest-risk errors. Takes longer but |
| catches more subtle issues. |
| </motion.div> |
| )} |
| </AnimatePresence> |
| </div> |
| </div> |
| )} |
| |
| <GlassButton |
| variant="ghost" |
| onClick={() => setShowSamples(!showSamples)} |
| className="text-sm" |
| > |
| {showSamples ? 'Hide Samples' : 'Load Sample Note'} |
| </GlassButton> |
| </div> |
| |
| {/* Sample Notes */} |
| <AnimatePresence> |
| {showSamples && ( |
| <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="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-4 pt-4 border-t border-[var(--glass-border-subtle)]"> |
| {sampleNotes.map((sample) => ( |
| <button |
| key={sample.id} |
| onClick={() => handleLoadSample(sample.content)} |
| className={cn( |
| 'text-left p-3 rounded-xl', |
| 'bg-[var(--glass-bg-muted)]', |
| 'border border-[var(--glass-border-subtle)]', |
| 'hover:bg-[var(--glass-bg)] hover:border-[var(--glass-border)]', |
| 'transition-all duration-200', |
| 'group' |
| )} |
| > |
| <div className="flex items-start justify-between"> |
| <div> |
| <h4 className="font-medium text-[var(--foreground)] group-hover:text-[var(--suggestion-accent)] transition-colors"> |
| {sample.title} |
| </h4> |
| <p className="text-xs text-[var(--foreground)]/60 mt-1"> |
| {sample.description} |
| </p> |
| </div> |
| <span className="text-xs px-2 py-0.5 rounded-full bg-[var(--warning-bg)] text-[var(--warning-text)]"> |
| ~{sample.expectedErrors} errors |
| </span> |
| </div> |
| </button> |
| ))} |
| </div> |
| </motion.div> |
| )} |
| </AnimatePresence> |
| </GlassPanel> |
| |
| {/* Replace Confirmation Dialog */} |
| <GlassDialog |
| open={showReplaceConfirm} |
| onOpenChange={setShowReplaceConfirm} |
| title="Replace existing note?" |
| description="The current note will be replaced with the imported content. This action cannot be undone." |
| confirmLabel="Replace" |
| cancelLabel="Cancel" |
| onConfirm={handleConfirmReplace} |
| onCancel={handleCancelReplace} |
| /> |
| |
| {/* Clear Confirmation Dialog */} |
| <GlassDialog |
| open={showClearConfirm} |
| onOpenChange={setShowClearConfirm} |
| title="Clear this note?" |
| description="This will permanently delete the current note. This action cannot be undone." |
| confirmLabel="Clear" |
| cancelLabel="Cancel" |
| onConfirm={handleConfirmClear} |
| onCancel={() => setShowClearConfirm(false)} |
| /> |
| </> |
| ); |
| } |
|
|
| |
| function AnnotatedView({ |
| text, |
| }: { |
| text: string; |
| errors: DetectedError[]; |
| }) { |
| return ( |
| <div |
| className={cn( |
| 'h-full overflow-y-auto p-4 rounded-xl', |
| 'bg-[var(--glass-bg-muted)]', |
| 'border border-[var(--glass-border-subtle)]', |
| 'font-mono text-sm leading-relaxed whitespace-pre-wrap' |
| )} |
| > |
| {text} |
| </div> |
| ); |
| } |
|
|