From e8b7f6789bb47ba229b6e3831746034863f39776 Mon Sep 17 00:00:00 2001 From: Adityavardhan Agrawal Date: Sat, 26 Apr 2025 23:18:32 -0700 Subject: [PATCH] Make UI comp tastier (#119) --- .../components/ForceGraphComponent.tsx | 106 ++- ee/ui-component/components/GraphSection.tsx | 861 +++++++++--------- ee/ui-component/components/MorphikUI.tsx | 11 - .../components/chat/ChatMessage.tsx | 115 --- .../components/chat/ChatMessages.tsx | 149 +++ .../components/chat/ChatOptionsDialog.tsx | 190 ---- .../components/chat/ChatOptionsPanel.tsx | 158 ---- .../components/chat/ChatSection.tsx | 735 ++++++++------- ee/ui-component/components/chat/icons.tsx | 95 ++ ee/ui-component/components/chat/index.ts | 3 + .../components/documents/DocumentsSection.tsx | 657 ++++--------- .../components/documents/FolderList.tsx | 39 +- .../components/search/SearchSection.tsx | 15 +- ee/ui-component/components/ui/skeleton.tsx | 15 + ee/ui-component/hooks/useMorphikChat.ts | 222 +++++ ee/ui-component/lib/utils.ts | 51 +- ee/ui-component/package.json | 5 +- ee/ui-component/tsconfig.json | 5 +- utils/printer.py | 40 +- 19 files changed, 1683 insertions(+), 1789 deletions(-) delete mode 100644 ee/ui-component/components/chat/ChatMessage.tsx create mode 100644 ee/ui-component/components/chat/ChatMessages.tsx delete mode 100644 ee/ui-component/components/chat/ChatOptionsDialog.tsx delete mode 100644 ee/ui-component/components/chat/ChatOptionsPanel.tsx create mode 100644 ee/ui-component/components/chat/icons.tsx create mode 100644 ee/ui-component/components/chat/index.ts create mode 100644 ee/ui-component/components/ui/skeleton.tsx create mode 100644 ee/ui-component/hooks/useMorphikChat.ts diff --git a/ee/ui-component/components/ForceGraphComponent.tsx b/ee/ui-component/components/ForceGraphComponent.tsx index 034ac90..dde99dd 100644 --- a/ee/ui-component/components/ForceGraphComponent.tsx +++ b/ee/ui-component/components/ForceGraphComponent.tsx @@ -66,10 +66,29 @@ const ForceGraphComponent: React.FC = ({ try { // Dynamic import const ForceGraphModule = await import('force-graph'); - - // Get the ForceGraph constructor function const ForceGraphConstructor = ForceGraphModule.default; + // Get theme colors from CSS variables for links only + const computedStyle = getComputedStyle(containerRef.current!); + // Use muted-foreground for links, convert HSL string to RGB and then add alpha + let linkColor = 'rgba(128, 128, 128, 0.3)'; // Default fallback grey + let arrowColor = 'rgba(128, 128, 128, 0.6)'; // Default fallback grey + const mutedFg = computedStyle.getPropertyValue('--muted-foreground').trim(); + + if (mutedFg) { + // Attempt to parse HSL color (format: % %) + const hslMatch = mutedFg.match(/^(\d+(?:.\d+)?)\s+(\d+(?:.\d+)?)%\s+(\d+(?:.\d+)?)%$/); + if (hslMatch) { + const [, h, s, l] = hslMatch.map(Number); + const rgb = hslToRgb(h / 360, s / 100, l / 100); + linkColor = `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.3)`; + arrowColor = `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.6)`; + } else { + // Fallback if not HSL (e.g., direct hex or rgb - unlikely for shadcn) + console.warn('Could not parse --muted-foreground HSL value, using default link color.'); + } + } + // Create a new graph instance using the 'new' keyword if (containerRef.current) { graphInstance = new ForceGraphConstructor(containerRef.current) as ForceGraphInstance; @@ -95,40 +114,25 @@ const ForceGraphComponent: React.FC = ({ // Always use nodeCanvasObject to have consistent rendering regardless of label visibility if (graph.nodeCanvasObject) { graph.nodeCanvasObject((node: NodeObject, ctx: CanvasRenderingContext2D, globalScale: number) => { - // Draw the node circle const nodeR = 5; - if (typeof node.x !== 'number' || typeof node.y !== 'number') return; - const x = node.x; const y = node.y; - ctx.beginPath(); ctx.arc(x, y, nodeR, 0, 2 * Math.PI); ctx.fillStyle = node.color; ctx.fill(); - // Only draw the text label if showNodeLabels is true if (showNodeLabels) { const label = node.label; const fontSize = 12/globalScale; - ctx.font = `${fontSize}px Sans-Serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; - - // Add a background for better readability const textWidth = ctx.measureText(label).width; const bckgDimensions = [textWidth, fontSize].map(n => n + fontSize * 0.2); - ctx.fillStyle = 'rgba(255, 255, 255, 0.8)'; - ctx.fillRect( - x - bckgDimensions[0] / 2, - y - bckgDimensions[1] / 2, - bckgDimensions[0], - bckgDimensions[1] - ); - + ctx.fillRect(x - bckgDimensions[0] / 2, y - bckgDimensions[1] / 2, bckgDimensions[0], bckgDimensions[1]); ctx.fillStyle = 'black'; ctx.fillText(label, x, y); } @@ -138,10 +142,8 @@ const ForceGraphComponent: React.FC = ({ // Always use linkCanvasObject for consistent rendering if (graph.linkCanvasObject) { graph.linkCanvasObject((link: LinkObject, ctx: CanvasRenderingContext2D, globalScale: number) => { - // Draw the link line const start = link.source as NodeObject; const end = link.target as NodeObject; - if (!start || !end || typeof start.x !== 'number' || typeof end.x !== 'number' || typeof start.y !== 'number' || typeof end.y !== 'number') return; @@ -150,61 +152,43 @@ const ForceGraphComponent: React.FC = ({ const endX = end.x; const endY = end.y; + // Draw the link line with theme color ctx.beginPath(); ctx.moveTo(startX, startY); ctx.lineTo(endX, endY); - ctx.strokeStyle = 'rgba(0, 0, 0, 0.2)'; + ctx.strokeStyle = linkColor; ctx.lineWidth = 1; ctx.stroke(); - // Draw arrowhead regardless of label visibility + // Draw arrowhead with theme color const arrowLength = 5; const dx = endX - startX; const dy = endY - startY; const angle = Math.atan2(dy, dx); - - // Calculate a position near the target for the arrow - const arrowDistance = 15; // Distance from target node + const arrowDistance = 15; const arrowX = endX - Math.cos(angle) * arrowDistance; const arrowY = endY - Math.sin(angle) * arrowDistance; ctx.beginPath(); ctx.moveTo(arrowX, arrowY); - ctx.lineTo( - arrowX - arrowLength * Math.cos(angle - Math.PI / 6), - arrowY - arrowLength * Math.sin(angle - Math.PI / 6) - ); - ctx.lineTo( - arrowX - arrowLength * Math.cos(angle + Math.PI / 6), - arrowY - arrowLength * Math.sin(angle + Math.PI / 6) - ); + ctx.lineTo(arrowX - arrowLength * Math.cos(angle - Math.PI / 6), arrowY - arrowLength * Math.sin(angle - Math.PI / 6)); + ctx.lineTo(arrowX - arrowLength * Math.cos(angle + Math.PI / 6), arrowY - arrowLength * Math.sin(angle + Math.PI / 6)); ctx.closePath(); - ctx.fillStyle = 'rgba(0, 0, 0, 0.5)'; + ctx.fillStyle = arrowColor; ctx.fill(); - // Only draw label if showLinkLabels is true + // Keep original label rendering if (showLinkLabels) { const label = link.type; if (label) { const fontSize = 10/globalScale; ctx.font = `${fontSize}px Sans-Serif`; - - // Calculate middle point const middleX = startX + (endX - startX) / 2; const middleY = startY + (endY - startY) / 2; - - // Add a background for better readability const textWidth = ctx.measureText(label).width; const bckgDimensions = [textWidth, fontSize].map(n => n + fontSize * 0.2); - ctx.fillStyle = 'rgba(255, 255, 255, 0.8)'; - ctx.fillRect( - middleX - bckgDimensions[0] / 2, - middleY - bckgDimensions[1] / 2, - bckgDimensions[0], - bckgDimensions[1] - ); - + ctx.fillRect(middleX - bckgDimensions[0] / 2, middleY - bckgDimensions[1] / 2, bckgDimensions[0], bckgDimensions[1]); ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = 'black'; @@ -233,13 +217,41 @@ const ForceGraphComponent: React.FC = ({ } }; + // HSL to RGB conversion function (needed because canvas needs RGB) + function hslToRgb(h: number, s: number, l: number): [number, number, number] { + let r, g, b; + if (s === 0) { + r = g = b = l; // achromatic + } else { + const hue2rgb = (p: number, q: number, t: number) => { + if (t < 0) t += 1; + if (t > 1) t -= 1; + if (t < 1 / 6) return p + (q - p) * 6 * t; + if (t < 1 / 2) return q; + if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; + return p; + }; + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + r = hue2rgb(p, q, h + 1 / 3); + g = hue2rgb(p, q, h); + b = hue2rgb(p, q, h - 1 / 3); + } + return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)]; + } + initGraph(); // Cleanup function + const currentContainer = containerRef.current; // Store ref value return () => { if (graphInstance && typeof graphInstance._destructor === 'function') { graphInstance._destructor(); } + // Ensure container is cleared on cleanup too + if (currentContainer) { // Use the stored value in cleanup + currentContainer.innerHTML = ''; + } }; }, [data, width, height, showNodeLabels, showLinkLabels]); diff --git a/ee/ui-component/components/GraphSection.tsx b/ee/ui-component/components/GraphSection.tsx index 936e88d..417c5ad 100644 --- a/ee/ui-component/components/GraphSection.tsx +++ b/ee/ui-component/components/GraphSection.tsx @@ -2,22 +2,21 @@ import React, { useState, useEffect, useRef, useCallback } from 'react'; import dynamic from 'next/dynamic'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; -import { ScrollArea } from '@/components/ui/scroll-area'; import { Label } from '@/components/ui/label'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; import { AlertCircle, Share2, - Database, Plus, Network, Tag, - Link + Link, + ArrowLeft } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; import { Switch } from '@/components/ui/switch'; @@ -96,13 +95,17 @@ const GraphSection: React.FC = ({ apiBaseUrl, onSelectGraph, const [additionalFilters, setAdditionalFilters] = useState('{}'); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const [activeTab, setActiveTab] = useState('list'); + const [activeTab, setActiveTab] = useState('list'); // 'list', 'details', 'update', 'visualize' (no longer a tab, but a state) + const [showCreateDialog, setShowCreateDialog] = useState(false); const [showNodeLabels, setShowNodeLabels] = useState(true); const [showLinkLabels, setShowLinkLabels] = useState(true); + const [showVisualization, setShowVisualization] = useState(false); + const [graphDimensions, setGraphDimensions] = useState({ width: 0, height: 0 }); + // Refs for graph visualization const graphContainerRef = useRef(null); - const graphInstance = useRef<{ width: (width: number) => unknown } | null>(null); + // Removed graphInstance ref as it's not needed with the dynamic component // Prepare data for force-graph const prepareGraphData = useCallback((graph: Graph | null) => { @@ -131,22 +134,37 @@ const GraphSection: React.FC = ({ apiBaseUrl, onSelectGraph, return { nodes, links }; }, []); - // Initialize force-graph visualization - const initializeGraph = useCallback(() => { - // No need for implementation as ForceGraphComponent handles this - }, []); + // Removed initializeGraph function as it's no longer needed - // Handle window resize for responsive graph + // Observe graph container size changes useEffect(() => { - const handleResize = () => { - if (graphContainerRef.current && graphInstance.current) { - graphInstance.current.width(graphContainerRef.current.clientWidth); - } - }; + if (!showVisualization || !graphContainerRef.current) return; - window.addEventListener('resize', handleResize); - return () => window.removeEventListener('resize', handleResize); - }, []); + const resizeObserver = new ResizeObserver(entries => { + for (const entry of entries) { + setGraphDimensions({ + width: entry.contentRect.width, + height: entry.contentRect.height + }); + } + }); + + resizeObserver.observe(graphContainerRef.current); + + // Set initial size + setGraphDimensions({ + width: graphContainerRef.current.clientWidth, + height: graphContainerRef.current.clientHeight + }); + + const currentGraphContainer = graphContainerRef.current; // Store ref value + return () => { + if (currentGraphContainer) { // Use stored value in cleanup + resizeObserver.unobserve(currentGraphContainer); + } + resizeObserver.disconnect(); + }; + }, [showVisualization]); // Rerun when visualization becomes active/inactive // Fetch all graphs const fetchGraphs = useCallback(async () => { @@ -179,6 +197,7 @@ const GraphSection: React.FC = ({ apiBaseUrl, onSelectGraph, const fetchGraph = async (graphName: string) => { try { setLoading(true); + setError(null); // Clear previous errors const headers = createHeaders(); const response = await fetch( `${apiBaseUrl}/graph/${encodeURIComponent(graphName)}`, @@ -191,22 +210,23 @@ const GraphSection: React.FC = ({ apiBaseUrl, onSelectGraph, const data = await response.json(); setSelectedGraph(data); + setActiveTab('details'); // Set tab to details view // Call the callback if provided if (onSelectGraph) { onSelectGraph(graphName); } - // Change to visualize tab if we're not on the list tab - if (activeTab !== 'list' && activeTab !== 'create') { - setActiveTab('visualize'); - } - return data; } catch (err: unknown) { const error = err as Error; setError(`Error fetching graph: ${error.message}`); console.error('Error fetching graph:', err); + setSelectedGraph(null); // Reset selected graph on error + setActiveTab('list'); // Go back to list view on error + if (onSelectGraph) { + onSelectGraph(undefined); + } return null; } finally { setLoading(false); @@ -255,6 +275,7 @@ const GraphSection: React.FC = ({ apiBaseUrl, onSelectGraph, const data = await response.json(); setSelectedGraph(data); + setActiveTab('details'); // Switch to details tab after creation // Refresh the graphs list await fetchGraphs(); @@ -264,13 +285,14 @@ const GraphSection: React.FC = ({ apiBaseUrl, onSelectGraph, setGraphDocuments([]); setGraphFilters('{}'); - // Switch to visualize tab - setActiveTab('visualize'); + // Close dialog + setShowCreateDialog(false); } catch (err: unknown) { const error = err as Error; setError(`Error creating graph: ${error.message}`); console.error('Error creating graph:', err); + // Keep the dialog open on error so user can fix it } finally { setLoading(false); } @@ -311,7 +333,7 @@ const GraphSection: React.FC = ({ apiBaseUrl, onSelectGraph, } const data = await response.json(); - setSelectedGraph(data); + setSelectedGraph(data); // Update the selected graph data // Refresh the graphs list await fetchGraphs(); @@ -320,466 +342,441 @@ const GraphSection: React.FC = ({ apiBaseUrl, onSelectGraph, setAdditionalDocuments([]); setAdditionalFilters('{}'); - // Switch to visualize tab - setActiveTab('visualize'); + // Switch back to details tab + setActiveTab('details'); } catch (err: unknown) { const error = err as Error; setError(`Error updating graph: ${error.message}`); console.error('Error updating graph:', err); + // Keep the update form visible on error } finally { setLoading(false); } }; - // Initialize or update graph visualization when the selected graph changes - useEffect(() => { - if (selectedGraph && activeTab === 'visualize') { - // Use setTimeout to ensure the container is rendered - setTimeout(() => { - initializeGraph(); - }, 100); - } - }, [selectedGraph, activeTab, initializeGraph, showNodeLabels, showLinkLabels]); - - // Handle tab change - const handleTabChange = (value: string) => { - setActiveTab(value); - // Initialize graph if switching to visualize tab and a graph is selected - if (value === 'visualize' && selectedGraph) { - setTimeout(() => { - initializeGraph(); - }, 100); - } - }; - - // Render the graph visualization tab - const renderVisualization = () => { - if (!selectedGraph) { - return ( -
-
- -

No Graph Selected

-

- Select a graph from the list to visualize it here. -

-
-
- ); - } + // Removed useEffect that depended on initializeGraph + // Conditional rendering based on visualization state + if (showVisualization && selectedGraph) { return ( -
-
-
-

{selectedGraph.name}

-

- {selectedGraph.entities.length} entities, {selectedGraph.relationships.length} relationships -

+
+ {/* Visualization header */} +
+
+ +
+ +

+ {selectedGraph.name} Visualization +

+
-
- - - -
-
- - - + +
+
+ + + +
+
+ + + +
-
- + + {/* Graph visualization container */} +
+ {graphDimensions.width > 0 && graphDimensions.height > 0 && ( + + )}
); - }; + } + // Default view (List or Details/Update) return ( -
-
-
-

- - Knowledge Graphs -

- {selectedGraph && ( -
- - Current Graph: {selectedGraph.name} - -
- )} -
-

- Knowledge graphs represent relationships between entities extracted from your documents. - Use them to enhance your queries with structured information and improve retrieval quality. -

-
- - - - Available Graphs - Create New Graph - Update Graph - Visualize Graph - - - {/* Graph List Tab */} - - - - - - Available Knowledge Graphs - - - Select a graph to view its details or visualize it. - - - - {loading ? ( -
-
-
- ) : graphs.length === 0 ? ( -
- -

No graphs available.

- -
- ) : ( - -
- {graphs.map((graph) => ( - handleGraphClick(graph)} - > - - - {graph.name} - - {new Date(graph.created_at).toLocaleDateString()} - - - - {graph.entities.length} entities, {graph.relationships.length} relationships - - - -
- {Array.from(new Set(graph.entities.map(e => e.type))).slice(0, 5).map(type => ( - - {type} - - ))} - {Array.from(new Set(graph.entities.map(e => e.type))).length > 5 && ( - +{Array.from(new Set(graph.entities.map(e => e.type))).length - 5} more - )} -
-
- {graph.document_ids.length} document{graph.document_ids.length !== 1 ? 's' : ''} -
-
-
- ))} -
-
- )} -
-
+ + + + + + Create New Knowledge Graph + + + Create a knowledge graph from documents in your Morphik collection to enhance your queries. + + +
+
+ + setGraphName(e.target.value)} + /> +

+ Give your graph a descriptive name that helps you identify its purpose. +

+
- {selectedGraph && ( - +
+

Document Selection

+
+
+ +