diff --git a/server/app/routes/convert.py b/server/app/routes/convert.py index 2c680831..7f18daa3 100644 --- a/server/app/routes/convert.py +++ b/server/app/routes/convert.py @@ -11,6 +11,11 @@ from concurrent.futures import ThreadPoolExecutor from dotenv import load_dotenv +from docling.datamodel.base_models import InputFormat +from docling.document_converter import DocumentConverter, PdfFormatOption +from docling.datamodel.pipeline_options import PdfPipelineOptions +from docling.backend.pypdfium2_backend import PyPdfiumDocumentBackend + # Load environment variables load_dotenv() @@ -42,10 +47,10 @@ def process_document_with_azure(file_path: str, endpoint: str, key: str) -> str: return f"Error processing document: {str(e)}" @router.post("/api/convert-documents") -async def convert_documents(files: List[UploadFile] = File(...)): - # First try Modal endpoint if there are no txt files +async def convert_documents(files: List[UploadFile] = File(...), use_docetl_server: bool = False): + # Only try Modal endpoint if use_docetl_server is true and there are no txt files all_txt_files = all(file.filename.lower().endswith('.txt') or file.filename.lower().endswith('.md') for file in files) - if not all_txt_files: + if use_docetl_server and not all_txt_files: try: async with aiohttp.ClientSession() as session: # Prepare files for multipart upload @@ -63,12 +68,8 @@ async def convert_documents(files: List[UploadFile] = File(...)): except Exception as e: print(f"Modal endpoint failed: {str(e)}. Falling back to local processing...") - # If Modal fails, fall back to local processing - from docling.datamodel.base_models import InputFormat - from docling.document_converter import DocumentConverter, PdfFormatOption - from docling.datamodel.pipeline_options import PdfPipelineOptions - from docling.backend.pypdfium2_backend import PyPdfiumDocumentBackend - + # Process locally if Modal wasn't used or failed + pipeline_options = PdfPipelineOptions() pipeline_options.do_ocr = False pipeline_options.do_table_structure = True diff --git a/tests/test_runner_caching.py b/tests/test_runner_caching.py index bf753512..7ff4c3fa 100644 --- a/tests/test_runner_caching.py +++ b/tests/test_runner_caching.py @@ -35,7 +35,7 @@ def temp_intermediate_dir(): yield tmpdirname -def create_pipeline(input_file, output_file, intermediate_dir, operation_prompt): +def create_pipeline(input_file, output_file, intermediate_dir, operation_prompt, bypass_cache=False): return Pipeline( name="test_pipeline", datasets={"test_input": Dataset(type="file", path=input_file)}, @@ -45,6 +45,7 @@ def create_pipeline(input_file, output_file, intermediate_dir, operation_prompt) type="map", prompt=operation_prompt, output={"schema": {"result": "string"}}, + bypass_cache=bypass_cache, ) ], steps=[ @@ -80,34 +81,16 @@ def test_pipeline_rerun_on_operation_change( # Check that the pipeline was not rerun (cost should be zero) assert unmodified_cost == 0 - # Record the start time - start_time = time.time() - - # Run again without changes - _ = pipeline.run() - - # Record the end time - end_time = time.time() - - # Calculate and store the runtime - unmodified_runtime = end_time - start_time # Modify the operation modified_prompt = "Count the words in the following text: '{{ input.text }}'" modified_pipeline = create_pipeline( - temp_input_file, temp_output_file, temp_intermediate_dir, modified_prompt + temp_input_file, temp_output_file, temp_intermediate_dir, modified_prompt, bypass_cache=True ) - # Record the start time - start_time = time.time() - - _ = modified_pipeline.run() - - # Record the end time - end_time = time.time() + modified_cost = modified_pipeline.run() - # Calculate and store the runtime - modified_runtime = end_time - start_time + # Check that the intermediate files were updated with open( @@ -116,8 +99,8 @@ def test_pipeline_rerun_on_operation_change( intermediate_data = json.load(f) assert any("word" in str(item).lower() for item in intermediate_data) - # Check that the runtime is faster when not modifying - assert unmodified_runtime < modified_runtime * 2 + # Check that the cost > 0 + assert modified_cost > 0 # Test with an incorrect later operation but correct earlier operation diff --git a/website/package-lock.json b/website/package-lock.json index b56e3f00..7d6d8e32 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -27,6 +27,7 @@ "@radix-ui/react-menubar": "^1.1.2", "@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-progress": "^1.1.0", + "@radix-ui/react-radio-group": "^1.2.1", "@radix-ui/react-scroll-area": "^1.1.0", "@radix-ui/react-select": "^2.1.1", "@radix-ui/react-slot": "^1.1.0", @@ -2812,6 +2813,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.2.tgz", "integrity": "sha512-cKmj5Gte7LVyuz+8gXinxZAZECQU+N7aq5pw7kUPpx3xjnDXDbsdzHtCCD2W72bwzy74AvrqdYnKYS42ueskUQ==", + "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.0", "@radix-ui/react-collection": "1.1.0", @@ -3285,6 +3287,77 @@ } } }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.2.1.tgz", + "integrity": "sha512-kdbv54g4vfRjja9DNWPMxKvXblzqbpEC8kspEkZ6dVP7kQksGCn+iZHkcCz2nb00+lPdRvxrqy4WrvvV1cNqrQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-presence": "1.1.1", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-roving-focus": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-use-size": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-context": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", + "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-presence": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.1.tgz", + "integrity": "sha512-IeFXVi4YS1K0wVZzXNrbaaUvIJ3qdY+/Ih4eHFhWA9SwGR9UDX7Ck8abvL57C4cv3wwMvUE0OG69Qc3NCcTe/A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-roving-focus": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.0.tgz", diff --git a/website/package.json b/website/package.json index a0c4dc69..5b222600 100644 --- a/website/package.json +++ b/website/package.json @@ -28,6 +28,7 @@ "@radix-ui/react-menubar": "^1.1.2", "@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-progress": "^1.1.0", + "@radix-ui/react-radio-group": "^1.2.1", "@radix-ui/react-scroll-area": "^1.1.0", "@radix-ui/react-select": "^2.1.1", "@radix-ui/react-slot": "^1.1.0", diff --git a/website/src/app/api/convertDocuments/route.ts b/website/src/app/api/convertDocuments/route.ts index da938e6f..cd1f9985 100644 --- a/website/src/app/api/convertDocuments/route.ts +++ b/website/src/app/api/convertDocuments/route.ts @@ -6,6 +6,7 @@ export async function POST(request: NextRequest) { try { const formData = await request.formData(); const files = formData.getAll("files"); + const conversionMethod = formData.get("conversion_method"); if (!files || files.length === 0) { return NextResponse.json({ error: "No files provided" }, { status: 400 }); @@ -17,6 +18,12 @@ export async function POST(request: NextRequest) { backendFormData.append("files", file); }); + // Add conversion method to form data + backendFormData.append( + "use_docetl_server", + conversionMethod === "docetl" ? "true" : "false" + ); + // Get Azure credentials from headers if they exist const azureEndpoint = request.headers.get("azure-endpoint"); const azureKey = request.headers.get("azure-key"); diff --git a/website/src/app/globals.css b/website/src/app/globals.css index f22227ef..772638bc 100644 --- a/website/src/app/globals.css +++ b/website/src/app/globals.css @@ -2,15 +2,6 @@ @tailwind components; @tailwind utilities; -/* :root { - --color-background: #f8f9fa; - --color-text: #212529; - --color-primary: #007bff; - --color-secondary: #6c757d; - --color-icon: #17a2b8; -} */ - - @layer base { :root { --background: 211 100% 98%; @@ -34,44 +25,12 @@ --ring: 211 100% 50%; --radius: 0.5rem; - /* Custom variables */ - /* --color-icon: #17a2b8; Kept as hex */ - --chart-1: 12 76% 61%; - --chart-2: 173 58% 39%; - --chart-3: 197 37% 24%; - --chart-4: 43 74% 66%; - --chart-5: 27 87% 67%; - } - - .dark { - --background: 211 50% 5%; - --foreground: 211 5% 90%; - --card: 211 50% 0%; - --card-foreground: 211 5% 90%; - --popover: 211 50% 5%; - --popover-foreground: 211 5% 90%; - --primary: 211 100% 50%; - --primary-foreground: 0 0% 100%; - --secondary: 211 30% 10%; - --secondary-foreground: 0 0% 100%; - --muted: 173 30% 15%; - --muted-foreground: 211 5% 60%; - --accent: 173 30% 15%; - --accent-foreground: 211 5% 90%; - --destructive: 0 100% 30%; - --destructive-foreground: 211 5% 90%; - --border: 211 30% 18%; - --input: 211 30% 18%; - --ring: 211 100% 50%; - --radius: 0.5rem; - - /* Custom variables for dark mode */ - /* --color-icon: #17a2b8; */ - --chart-1: 220 70% 50%; - --chart-2: 160 60% 45%; - --chart-3: 30 80% 55%; - --chart-4: 280 65% 60%; - --chart-5: 340 75% 55%; + /* Chart colors now use theme variables */ + --chart-1: var(--chart1); + --chart-2: var(--chart2); + --chart-3: var(--chart3); + --chart-4: var(--chart4); + --chart-5: var(--chart5); } } diff --git a/website/src/app/page.tsx b/website/src/app/page.tsx index 576ca3de..ac594cb6 100644 --- a/website/src/app/page.tsx +++ b/website/src/app/page.tsx @@ -7,129 +7,248 @@ import { Scroll, ChevronDown, ChevronUp } from "lucide-react"; import PresidentialDebateDemo from "@/components/PresidentialDebateDemo"; import { Button } from "@/components/ui/button"; import { sendGAEvent } from "@next/third-parties/google"; +import { Card, CardContent } from "@/components/ui/card"; export default function Home() { const [showDemo, setShowDemo] = useState(true); + const [showVision, setShowVision] = useState(false); + + const toggleDemo = () => { + setShowDemo(!showDemo); + if (!showDemo) { + setShowVision(false); + } + sendGAEvent("event", "buttonClicked", { value: "demo" }); + }; + + const toggleVision = () => { + setShowVision(!showVision); + if (!showVision) { + setShowDemo(false); + } + sendGAEvent("event", "buttonClicked", { value: "vision" }); + }; return (
-
-
- - docetl -
-

- Powering complex document processing pipelines -

- -
-

- New IDE Released!{" "} - - Dec 2, 2024 - - ! Try out our new web-based IDE. +

+
+
+ + docetl +
+

+ Powering complex document processing pipelines

-

- New blog post!{" "} - +

+ New IDE Released!{" "} + + Dec 2, 2024 + + ! Try out our new web-based IDE. +

+

+ New blog post!{" "} + + September 24, 2024 + +

+
+ +
+
+ Demo + {showDemo ? ( + + ) : ( + + )} + -
- - - {/* */} - + + {/* */} + - + - - + + GitHub + + + + +
-
-
-
- + {showVision && ( + + +
+

+ Reimagining Data Systems for Semantic Operations +

+

+ While traditional database systems excel at structured data + processing, semantic operations powered by LLMs bring + unprecedented expressiveness and flexibility. However, these + operations introduce new challenges: they can be incorrect, + are computationally intensive, and typically rely on remote + API calls. We're reimagining data systems throughout the + stack to address these unique challenges. Here are some + projects we are working on: +

+ +
+
+

+ Query Optimizer +

+

+ Current LLM-powered systems focus mainly on cost + reduction. But for complex tasks, even well-crafted + operations can produce inaccurate results. The DocETL + optimizer uses LLM agents to automatically rewrite + pipelines, by breaking operations down into smaller, + well-scoped tasks to improve accuracy.{" "} + + Read our paper + +

+
+ +
+

+ Execution Engine +

+

+ Our users consistently highlight map operations as the + most valuable feature, but these require at least one LLM + call per document—making them prohibitively expensive at + scale. We're exploring novel techniques to + dramatically reduce costs for open-ended map operations + without sacrificing accuracy. +

+
+ +
+

+ Interactive Interface +

+

+ Semantic operations are highly expressive, but this power + comes with a challenge—they can be fuzzy and ambiguous in + practice. Consequently, users often need many iterations + to get semantic operations right. Through the DocETL IDE, + we're designing interfaces that help users explore + data, refine their intents, and quickly iterate on prompts + and operations. +

+
+
+ +

+ There are many domain-specific unstructured data processing + needs that can benefit from systems like DocETL. We work with + partners at universities, governments, and institutions to + explore how AI can improve data workflows, especially for + domain experts and those who may not have data or ML + expertise. If you'd like to learn more (e.g., bring + DocETL to your team or join our case studies), please reach + out to{" "} + + shreyashankar@berkeley.edu + + . +

+
+
+
+ )} + + {showDemo && ( +
+
+
+ +
-
+ )}
= ({ isActive }) => ( { const [showDatasetView, setShowDatasetView] = useState(false); const [showChat, setShowChat] = useState(false); const [showNamespaceDialog, setShowNamespaceDialog] = useState(false); + const { theme, setTheme } = useTheme(); useEffect(() => { setIsMounted(true); @@ -194,7 +201,7 @@ const CodeEditorPipelineApp: React.FC = () => { const handleSaveAs = async () => { try { // Collect all localStorage data - const data: Record = {}; + const data: Record = {}; Object.values(localStorageKeys).forEach((key) => { const value = localStorage.getItem(key); if (value) { @@ -267,11 +274,28 @@ const CodeEditorPipelineApp: React.FC = () => { } }; + const topBarStyles = + "p-2 flex justify-between items-center border-b bg-white shadow-sm"; + const controlGroupStyles = "flex items-center gap-2"; + const panelControlsStyles = "flex items-center gap-1 px-2 border-l"; + const saveButtonStyles = `relative h-8 px-3 ${ + unsavedChanges + ? "bg-orange-100 border-orange-500 hover:bg-orange-200" + : "hover:bg-gray-100" + }`; + const costDisplayStyles = + "px-3 py-1.5 text-sm text-gray-600 flex items-center gap-1"; + const panelToggleStyles = + "flex items-center gap-2 px-3 py-1.5 rounded-md transition-colors duration-200"; + const mainContentStyles = "flex-grow overflow-hidden bg-gray-50"; + const resizeHandleStyles = + "w-2 bg-gray-100 hover:bg-blue-200 transition-colors duration-200"; + return (
-
-
+
+
File @@ -308,9 +332,40 @@ const CodeEditorPipelineApp: React.FC = () => { Open Save As + + + + Edit + setShowNamespaceDialog(true)}> Change Namespace + + Change Theme + + setTheme(value as Theme)} + > + + Default + + + Forest + + + Magestic + + + Sunset + + Ruby + + Monochrome + + + + @@ -339,131 +394,120 @@ const CodeEditorPipelineApp: React.FC = () => { saveProgress(); toast({ title: "Progress Saved", - description: "Your pipeline progress has been saved.", + description: + "Your pipeline progress has been saved to browser storage.", duration: 3000, }); }} - className={`relative h-8 px-2 ${ - unsavedChanges ? "border-orange-500" : "" - }`} + className={saveButtonStyles} > - {unsavedChanges && ( - - )} + {unsavedChanges ? "Quick Save" : "Quick Save"} {unsavedChanges - ? "Save changes to avoid losing progress!" - : "No unsaved changes"} + ? "Save changes to browser storage (use File > Save As to save to disk)" + : "No changes compared to the version in browser storage"} + + + + + +

About DocETL

+

+ This is a research project from the EPIC Data Lab at the + University of California, Berkeley. To learn more, visit{" "} + + docetl.org + + . +

+ +
-
- +
+

DocETL

{isMounted && ( - ({namespace}) + ({namespace}) )}
-
- - - - - - - - -

About DocETL

-

- This is a research project from the EPIC Data Lab at the - University of California, Berkeley. To learn more, visit{" "} - - docetl.org - - . -

-
-
-
- -

About DocETL

-
-
-
- {/* Only render the cost when client-side */} +
{isMounted && ( - - Cost: ${cost.toFixed(2)} - +
+ Cost: + ${cost.toFixed(2)} +
)} - - - - - - -

Toggle File Explorer

-
-
- - - - - -

Toggle Output Panel

-
-
- - - - - -

Toggle Dataset View

-
-
-
+
+ + + + + + Toggle File Explorer + + + + + + + Toggle Output Panel + + + + + + + Toggle Dataset View + + +
{showChat && setShowChat(false)} />} {/* Main content */} {showFileExplorer && ( @@ -492,10 +536,7 @@ const CodeEditorPipelineApp: React.FC = () => { namespace={namespace} /> - + { )} {showFileExplorer && ( - + )} {showOutput && ( - + )} {showOutput && ( @@ -542,10 +577,7 @@ const CodeEditorPipelineApp: React.FC = () => { {showDatasetView && currentFile && ( <> - + { } return ( - - - - - + + + + + + + ); }; diff --git a/website/src/components/AnsiRenderer.tsx b/website/src/components/AnsiRenderer.tsx index 56611caf..ed05325b 100644 --- a/website/src/components/AnsiRenderer.tsx +++ b/website/src/components/AnsiRenderer.tsx @@ -1,7 +1,6 @@ import React, { useEffect, useRef, useState } from "react"; import Convert from "ansi-to-html"; import { useWebSocket } from "@/contexts/WebSocketContext"; -import { useToast } from "@/hooks/use-toast"; const convert = new Convert({ fg: "#000", @@ -26,7 +25,6 @@ const AnsiRenderer: React.FC = ({ const scrollRef = useRef(null); const [userInput, setUserInput] = useState(""); const { sendMessage } = useWebSocket(); - const { toast } = useToast(); useEffect(() => { if (scrollRef.current) { @@ -38,10 +36,7 @@ const AnsiRenderer: React.FC = ({ const trimmedInput = userInput.trim(); if (trimmedInput) { sendMessage(trimmedInput); - toast({ - title: "Terminal input received", - description: `You sent: ${trimmedInput}`, - }); + setTerminalOutput(text + "\n$ " + trimmedInput); setUserInput(""); } }; @@ -61,57 +56,49 @@ const AnsiRenderer: React.FC = ({ />
-
+
+ $ setUserInput(e.target.value)} onKeyPress={(e) => e.key === "Enter" && handleSendMessage()} - className={`flex-grow bg-gray-800 text-white px-2 py-1 rounded-l ${ + className={`flex-grow bg-transparent text-white outline-none ${ isWebSocketClosed ? "cursor-not-allowed" : "" }`} - placeholder={ - isWebSocketClosed - ? "WebSocket disconnected..." - : "Type a message..." - } + placeholder={isWebSocketClosed ? "WebSocket disconnected..." : ""} disabled={isWebSocketClosed} /> - + {userInput.trim() && !isWebSocketClosed && ( + + )}
-
-
- WebSocket State:{" "} +
+
+ Status:{" "} {readyState === WebSocket.CONNECTING ? "Connecting" : readyState === WebSocket.OPEN - ? "Open" + ? "Connected" : readyState === WebSocket.CLOSING ? "Closing" : readyState === WebSocket.CLOSED - ? "Closed" + ? "Disconnected" : "Unknown"}
diff --git a/website/src/components/BookmarksPanel.tsx b/website/src/components/BookmarksPanel.tsx index c9d6d2f3..f5763b6c 100644 --- a/website/src/components/BookmarksPanel.tsx +++ b/website/src/components/BookmarksPanel.tsx @@ -90,19 +90,19 @@ const BookmarksPanel: React.FC = () => { }; return ( -
-
-

- - Notes & Feedback +
+
+

+ + NOTES

@@ -167,41 +167,37 @@ const BookmarksPanel: React.FC = () => { {filteredBookmarks.map((bookmark) => (
toggleBookmarkExpansion(bookmark.id)} >
-
+
- "{bookmark.notes[0]?.note || "No notes"}" + {bookmark.notes[0]?.note || "No notes"}
-
+
{expandedBookmarkId === bookmark.id ? ( - + ) : ( - + )}
{expandedBookmarkId === bookmark.id && ( -
+
{bookmark.notes.map((note, index) => (
{renderNoteContent(note)} @@ -211,9 +207,9 @@ const BookmarksPanel: React.FC = () => { variant="destructive" size="sm" onClick={(e) => handleDeleteBookmark(e, bookmark.id)} - className="mt-2" + className="mb-1" > - + Delete
diff --git a/website/src/components/ColumnDialog.tsx b/website/src/components/ColumnDialog.tsx index e26d278e..64c32c26 100644 --- a/website/src/components/ColumnDialog.tsx +++ b/website/src/components/ColumnDialog.tsx @@ -9,7 +9,14 @@ import { import { SearchableCell } from "@/components/SearchableCell"; import { PrettyJSON } from "@/components/PrettyJSON"; import { RowNavigator } from "@/components/RowNavigator"; -import { ChevronDown, Eye, ChevronLeft, ChevronRight } from "lucide-react"; +import { + ChevronDown, + Eye, + ChevronLeft, + ChevronRight, + Wand2, + Trash2, +} from "lucide-react"; import { HoverCard, HoverCardContent, @@ -184,7 +191,8 @@ export function ColumnDialog>({ const renderRowContent = (row: T | null, value: unknown) => { if (!row) return null; - const { addBookmark, getNotesForRowAndColumn } = useBookmarkContext(); + const { addBookmark, getNotesForRowAndColumn, removeBookmark } = + useBookmarkContext(); const handleSubmitFeedback = (feedbackText: string) => { if (!feedbackText.trim()) return; @@ -324,128 +332,133 @@ export function ColumnDialog>({ -
-
-

Feedback

-

- Share your thoughts on this output +

+
+

Add Notes

+

+ Your notes will help improve prompts via the{" "} + {" "} + Improve Prompt feature in operation settings

-
+ +
{existingNotes.length > 0 && ( -
+
{showPreviousNotes && ( -
+
{existingNotes.map((note) => (
- "{note.note}" +
+ “{note.note}” +
+
))}
)}
)} -
+ +