//
// SPDX-FileCopyrightText: Copyright (c) 1993-2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
"use client"
import { useState, useEffect, useRef } from "react"
import { useDocuments } from "@/contexts/document-context"
import type { Triple } from "@/utils/text-processing"
import { Pencil, Trash2, Plus, Download, ChevronDown, FileJson, FileText, List, Network, Check, X, Database } from "lucide-react"
import { TripleEditor } from "./triple-editor"
// Add this new EntityEditor component before the TripleViewer component
interface EntityEditorProps {
entity: string
onSave: (oldEntity: string, newEntity: string) => void
onCancel: () => void
}
function EntityEditor({ entity, onSave, onCancel }: EntityEditorProps) {
const [newEntityName, setNewEntityName] = useState(entity)
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (newEntityName.trim()) {
onSave(entity, newEntityName.trim())
}
}
return (
)
}
export function TripleViewer() {
const { documents, addTriple, editTriple, deleteTriple, updateTriples } = useDocuments()
const [selectedDocId, setSelectedDocId] = useState(null)
const [editingIndex, setEditingIndex] = useState(null)
const [isAddingTriple, setIsAddingTriple] = useState(false)
const [showExportMenu, setShowExportMenu] = useState(false)
const [viewMode, setViewMode] = useState<'triples' | 'entities'>('triples')
const [editingEntityIndex, setEditingEntityIndex] = useState(null)
const [isAddingEntity, setIsAddingEntity] = useState(false)
const [newEntityName, setNewEntityName] = useState('')
const [isStoringToDb, setIsStoringToDb] = useState(false)
const [storeStatus, setStoreStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle')
const [isDropdownOpen, setIsDropdownOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const dropdownRef = useRef(null)
// Handle click outside to close dropdown
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsDropdownOpen(false)
}
}
document.addEventListener("mousedown", handleClickOutside)
return () => {
document.removeEventListener("mousedown", handleClickOutside)
}
}, [])
const processedDocs = documents.filter((doc) => doc.status === "Processed")
const selectedDoc = selectedDocId
? documents.find((doc) => doc.id === selectedDocId)
: processedDocs.length > 0
? processedDocs[0]
: null
// Filter documents based on search query
const filteredDocs = processedDocs.filter(doc =>
doc.name.toLowerCase().includes(searchQuery.toLowerCase())
)
// Extract unique entities from triples
const getUniqueEntities = () => {
if (!selectedDoc?.triples) return [];
const entitiesSet = new Set();
selectedDoc.triples.forEach(triple => {
if (triple.subject && typeof triple.subject === 'string') {
entitiesSet.add(triple.subject);
}
if (triple.object && typeof triple.object === 'string') {
entitiesSet.add(triple.object);
}
});
return Array.from(entitiesSet).sort();
};
const uniqueEntities = getUniqueEntities();
// Helper function to normalize triple text by removing parentheses and quotes
const normalizeText = (text: string | null | undefined): string => {
if (!text || typeof text !== 'string') return '';
return text.replace(/['"()]/g, '').trim();
};
if (processedDocs.length === 0) {
return (
No processed documents available
Upload markdown, CSV, or text files and click "Generate Graph" to extract knowledge triples
)
}
const handleSaveTriple = (triple: Triple, index?: number) => {
if (selectedDoc) {
if (index !== undefined) {
editTriple(selectedDoc.id, index, triple)
} else {
addTriple(selectedDoc.id, triple)
}
}
setEditingIndex(null)
setIsAddingTriple(false)
}
const handleDeleteTriple = (index: number) => {
if (selectedDoc) {
if (confirm("Are you sure you want to delete this triple?")) {
deleteTriple(selectedDoc.id, index)
}
}
}
const exportTriplesCSV = () => {
if (!selectedDoc || !selectedDoc.triples) return
const tripleCsv = [
"Subject,Predicate,Object",
...selectedDoc.triples.map((t) => `"${normalizeText(t.subject)}","${normalizeText(t.predicate)}","${normalizeText(t.object)}"`),
].join("\n")
const blob = new Blob([tripleCsv], { type: "text/csv" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = `${selectedDoc.name.replace(/\.[^/.]+$/, "")}_triples.csv`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
setShowExportMenu(false)
}
const exportTriplesJSON = () => {
if (!selectedDoc || !selectedDoc.triples) return
// Export the triples in the exact format expected by the graph viewer
const triplesJSON = JSON.stringify(selectedDoc.triples, null, 2)
const blob = new Blob([triplesJSON], { type: "application/json" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = `${selectedDoc.name.replace(/\.[^/.]+$/, "")}_triples.json`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
setShowExportMenu(false)
}
// Export entities list to CSV
const exportEntitiesCSV = () => {
if (!uniqueEntities.length) return;
const entitiesCsv = [
"Entity",
...uniqueEntities.map(entity => `"${normalizeText(entity)}"`),
].join("\n");
const blob = new Blob([entitiesCsv], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${selectedDoc?.name.replace(/\.[^/.]+$/, "") || "graph"}_entities.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
setShowExportMenu(false);
}
const handleSaveEntity = (oldEntity: string, newEntity: string) => {
if (selectedDoc && selectedDoc.triples) {
// Create new triples array with updated entity names
const updatedTriples = selectedDoc.triples.map(triple => {
const updatedTriple = { ...triple };
// Update subject if it matches the old entity name
if (updatedTriple.subject === oldEntity) {
updatedTriple.subject = newEntity;
}
// Update object if it matches the old entity name
if (updatedTriple.object === oldEntity) {
updatedTriple.object = newEntity;
}
return updatedTriple;
});
// Update the document with the new triples
updateTriples(selectedDoc.id, updatedTriples);
}
// Reset editing state
setEditingEntityIndex(null);
};
const handleAddEntity = () => {
if (!newEntityName.trim() || !selectedDoc) return
// Add a self-referential triple: "entity" is "entity"
// This is a simple way to add an entity to the graph
const selfReferentialTriple: Triple = {
subject: newEntityName.trim(),
predicate: 'is',
object: newEntityName.trim()
}
// Add the new triple to the document
addTriple(selectedDoc.id, selfReferentialTriple)
// Reset state
setNewEntityName('')
setIsAddingEntity(false)
}
const handleDeleteEntity = (entity: string) => {
if (!selectedDoc || !selectedDoc.triples) return;
if (confirm(`Are you sure you want to delete the entity "${entity}"? This will remove all triples containing this entity.`)) {
// Filter out all triples that contain the entity
const filteredTriples = selectedDoc.triples.filter(triple =>
triple.subject !== entity && triple.object !== entity
);
// Update the document with the filtered triples
updateTriples(selectedDoc.id, filteredTriples);
}
};
// Function to store triples in the Neo4j database
const storeInGraphDb = async () => {
if (!selectedDoc || !selectedDoc.triples || selectedDoc.triples.length === 0) {
alert("No triples to store in the database");
return;
}
try {
setIsStoringToDb(true);
setStoreStatus('loading');
const response = await fetch('/api/graph-db/triples', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
triples: selectedDoc.triples,
documentName: selectedDoc.name
}),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to store triples in the database');
}
setStoreStatus('success');
setTimeout(() => setStoreStatus('idle'), 3000);
} catch (error) {
console.error("Error storing triples in graph database:", error);
setStoreStatus('error');
alert(error instanceof Error ? error.message : 'An error occurred while storing triples');
} finally {
setIsStoringToDb(false);
}
};
// Function to store all triples from all documents in the graph database
const storeAllTriplesInGraphDb = async () => {
// Get all documents with triples
const docsWithTriples = documents.filter(doc => doc.triples && doc.triples.length > 0);
if (docsWithTriples.length === 0) {
alert("No documents with triples to store in the database");
return;
}
try {
setIsStoringToDb(true);
setStoreStatus('loading');
// Collect all triples from all documents
const allTriples = docsWithTriples.flatMap(doc => doc.triples || []);
const response = await fetch('/api/graph-db/triples', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
triples: allTriples,
documentName: 'All Documents'
}),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to store all triples in the database');
}
setStoreStatus('success');
setTimeout(() => setStoreStatus('idle'), 3000);
} catch (error) {
console.error("Error storing all triples in graph database:", error);
setStoreStatus('error');
alert(error instanceof Error ? error.message : 'An error occurred while storing all triples');
} finally {
setIsStoringToDb(false);
}
};
return (
{/* Header Section with improved layout */}
Select Document
setIsDropdownOpen(!isDropdownOpen)}
>
{selectedDoc?.name || "Select document"}
{isDropdownOpen && (
setSearchQuery(e.target.value)}
onClick={(e) => e.stopPropagation()}
/>
{filteredDocs.length === 0 ? (
No documents found
) : (
filteredDocs.map((doc) => (
{
setSelectedDocId(doc.id)
setEditingIndex(null)
setIsAddingTriple(false)
setIsDropdownOpen(false)
setSearchQuery('')
}}
>
{doc.name}
))
)}
)}
{/* Primary Action - Store All Documents */}
doc.triples && doc.triples.length > 0).length === 0}
className={`inline-flex items-center gap-2 px-6 py-3 text-sm font-medium rounded-lg transition-all shadow-sm ${
storeStatus === 'success'
? 'bg-green-50 border border-green-200 text-green-700 dark:bg-green-900/20 dark:border-green-800 dark:text-green-400'
: storeStatus === 'error'
? 'bg-red-50 border border-red-200 text-red-700 dark:bg-red-900/20 dark:border-red-800 dark:text-red-400'
: 'bg-nvidia-green hover:bg-nvidia-green/90 text-white border-nvidia-green hover:shadow-md disabled:opacity-50 disabled:cursor-not-allowed'
}`}
>
{storeStatus === 'loading' ? 'Storing All Documents...' :
storeStatus === 'success' ? 'All Documents Stored!' :
storeStatus === 'error' ? 'Failed' :
'Store All in Graph DB'}
{selectedDoc && (
<>
{/* Knowledge Graph Stats */}
Document Statistics
{selectedDoc.triples?.length || 0}
Triples
{uniqueEntities.length}
Entities
{/* Tab Navigation */}
setViewMode('triples')}
className={`inline-flex items-center justify-center gap-3 whitespace-nowrap rounded-lg px-4 py-3 text-sm font-medium transition-all duration-200 hover:bg-background/60 ${
viewMode === 'triples'
? 'bg-background text-foreground shadow-sm border border-border/20'
: 'text-muted-foreground'
}`}
>
Triples
setViewMode('entities')}
className={`inline-flex items-center justify-center gap-3 whitespace-nowrap rounded-lg px-4 py-3 text-sm font-medium transition-all duration-200 hover:bg-background/60 ${
viewMode === 'entities'
? 'bg-background text-foreground shadow-sm border border-border/20'
: 'text-muted-foreground'
}`}
>
Entities
{selectedDoc.chunkCount && selectedDoc.chunkCount > 1 && (
Processed in {selectedDoc.chunkCount} chunks
)}
{viewMode === 'triples' ? (
selectedDoc.triples && selectedDoc.triples.length > 0 ? (
{/* Action Buttons Section */}
Knowledge Triples ({selectedDoc.triples?.length || 0})
{/* Primary Action - Add Triple */}
{
setIsAddingTriple(true)
setEditingIndex(null)
}}
className="inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium bg-nvidia-green hover:bg-nvidia-green/90 text-white rounded-lg transition-all shadow-sm hover:shadow-md"
>
Add Triple
{/* Secondary Actions Group */}
{storeStatus === 'loading' ? 'Storing...' :
storeStatus === 'success' ? 'Stored!' :
storeStatus === 'error' ? 'Failed' :
'Store in Graph DB'}
setShowExportMenu(!showExportMenu)}
className="inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium bg-background border border-border hover:bg-muted/50 text-foreground rounded-lg transition-all shadow-sm hover:shadow-md relative z-40"
>
Export
{showExportMenu && (
Export as JSON
For Graph Viewer
Export as CSV
For spreadsheets
)}
{isAddingTriple && (
setIsAddingTriple(false)} />
)}
{selectedDoc.triples.map((triple, index) => (
{editingIndex === index ? (
setEditingIndex(null)}
/>
) : (
{normalizeText(triple.subject)}
{normalizeText(triple.predicate)}
{normalizeText(triple.object)}
setEditingIndex(index)}
className="p-1.5 text-muted-foreground hover:text-foreground rounded-full hover:bg-muted/50 transition-colors"
title="Edit Triple"
>
handleDeleteTriple(index)}
className="p-1.5 text-muted-foreground hover:text-destructive rounded-full hover:bg-destructive/10 transition-colors"
title="Delete Triple"
>
)}
))}
) : (
No triples found in this document
Try regenerating the graph or add triples manually
setIsAddingTriple(true)}
className="inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium bg-nvidia-green hover:bg-nvidia-green/90 text-white rounded-lg transition-all shadow-sm hover:shadow-md"
>
Add First Triple
)
) : (
// Entities View
{/* Entities Action Buttons Section */}
{uniqueEntities.length > 0
? `Entities (${uniqueEntities.length})`
: "No Entities Found"}
{/* Primary Action - Add Entity */}
setIsAddingEntity(true)}
className="inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium bg-nvidia-green hover:bg-nvidia-green/90 text-white rounded-lg transition-all shadow-sm hover:shadow-md"
>
Add Entity
{/* Secondary Action - Export */}
setShowExportMenu(!showExportMenu)}
className="inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium bg-background border border-border hover:bg-muted/50 text-foreground rounded-lg transition-all shadow-sm hover:shadow-md relative z-40"
>
Export
{showExportMenu && (
Export Entities as CSV
For spreadsheets
)}
{isAddingEntity && (
)}
{uniqueEntities.length > 0 ? (
{uniqueEntities.map((entity, index) => (
{editingEntityIndex === index ? (
setEditingEntityIndex(null)}
/>
) : (
{normalizeText(entity)}
setEditingEntityIndex(index)}
className="p-1.5 text-muted-foreground hover:text-foreground rounded-full hover:bg-muted/30"
title="Edit Entity"
>
handleDeleteEntity(entity)}
className="p-1.5 text-muted-foreground hover:text-destructive rounded-full hover:bg-destructive/10"
title="Delete Entity"
>
)}
))}
) : (
No entities found
Add triples to create entities in the knowledge graph
)}
)}
>
)}
)
}