|
import React, { useState, useEffect, useCallback } from "react"; |
|
import { createRoot } from "react-dom/client"; |
|
|
|
interface Document { |
|
id: number; |
|
title: string; |
|
content: string; |
|
snippet: string; |
|
source: string; |
|
source_type: string; |
|
url?: string; |
|
relevance_score: number; |
|
rank: number; |
|
metadata?: Record<string, any>; |
|
} |
|
|
|
interface SearchResponse { |
|
documents: Document[]; |
|
search_time: number; |
|
query: string; |
|
total_count: number; |
|
} |
|
|
|
interface KnowledgeBrowserProps { |
|
query?: string; |
|
results?: Document[]; |
|
search_type?: "semantic" | "keyword" | "hybrid"; |
|
max_results?: number; |
|
elem_id?: string; |
|
elem_classes?: string[]; |
|
visible?: boolean; |
|
onSubmit?: (query: string) => void; |
|
onSelect?: (document: Document) => void; |
|
} |
|
|
|
const KnowledgeBrowser: React.FC<KnowledgeBrowserProps> = ({ |
|
query: initialQuery = "", |
|
results: initialResults = [], |
|
search_type: initialSearchType = "semantic", |
|
max_results: initialMaxResults = 10, |
|
elem_id, |
|
elem_classes = [], |
|
visible = true, |
|
onSubmit, |
|
onSelect, |
|
}) => { |
|
const [query, setQuery] = useState(initialQuery); |
|
const [results, setResults] = useState<Document[]>(initialResults); |
|
const [searchType, setSearchType] = useState(initialSearchType); |
|
const [maxResults, setMaxResults] = useState(initialMaxResults); |
|
const [isLoading, setIsLoading] = useState(false); |
|
const [expandedResults, setExpandedResults] = useState<Set<number>>(new Set()); |
|
const [searchTime, setSearchTime] = useState(0); |
|
|
|
const performSearch = useCallback(async (searchQuery: string) => { |
|
if (!searchQuery.trim()) return; |
|
|
|
setIsLoading(true); |
|
|
|
try { |
|
const response = await fetch("/api/search", { |
|
method: "POST", |
|
headers: { |
|
"Content-Type": "application/json", |
|
}, |
|
body: JSON.stringify({ |
|
query: searchQuery, |
|
search_type: searchType, |
|
limit: maxResults, |
|
offset: 0, |
|
}), |
|
}); |
|
|
|
if (!response.ok) { |
|
throw new Error(`Search failed: ${response.statusText}`); |
|
} |
|
|
|
const data: SearchResponse = await response.json(); |
|
setResults(data.documents || []); |
|
setSearchTime(data.search_time || 0); |
|
|
|
|
|
if (onSubmit) { |
|
onSubmit(searchQuery); |
|
} |
|
} catch (error) { |
|
console.error("Search error:", error); |
|
setResults([]); |
|
} finally { |
|
setIsLoading(false); |
|
} |
|
}, [searchType, maxResults, onSubmit]); |
|
|
|
const handleSubmit = (e: React.FormEvent) => { |
|
e.preventDefault(); |
|
performSearch(query); |
|
}; |
|
|
|
const handleKeyDown = (e: React.KeyboardEvent) => { |
|
if (e.key === "Enter" && !e.shiftKey) { |
|
e.preventDefault(); |
|
performSearch(query); |
|
} |
|
}; |
|
|
|
const toggleExpanded = (documentId: number) => { |
|
const newExpanded = new Set(expandedResults); |
|
if (newExpanded.has(documentId)) { |
|
newExpanded.delete(documentId); |
|
} else { |
|
newExpanded.add(documentId); |
|
} |
|
setExpandedResults(newExpanded); |
|
}; |
|
|
|
const handleSelect = (document: Document) => { |
|
if (onSelect) { |
|
onSelect(document); |
|
} |
|
}; |
|
|
|
const getSourceTypeBadgeClass = (sourceType: string) => { |
|
const baseClass = "source-type-badge"; |
|
switch (sourceType) { |
|
case "academic": |
|
return `${baseClass} source-type-academic`; |
|
case "web": |
|
return `${baseClass} source-type-web`; |
|
case "pdf": |
|
return `${baseClass} source-type-pdf`; |
|
case "code": |
|
return `${baseClass} source-type-code`; |
|
default: |
|
return `${baseClass} source-type-web`; |
|
} |
|
}; |
|
|
|
const formatRelevanceScore = (score: number) => { |
|
return `${Math.round(score * 100)}%`; |
|
}; |
|
|
|
const formatSearchTime = (time: number) => { |
|
return time < 1 ? `${Math.round(time * 1000)}ms` : `${time.toFixed(2)}s`; |
|
}; |
|
|
|
if (!visible) { |
|
return null; |
|
} |
|
|
|
const containerClasses = ["kb-browser", ...elem_classes].join(" "); |
|
|
|
return ( |
|
<div id={elem_id} className={containerClasses}> |
|
{/* Search Interface */} |
|
<div className="search-interface"> |
|
<form onSubmit={handleSubmit}> |
|
<div className="search-input-container"> |
|
<input |
|
type="text" |
|
className="search-input" |
|
placeholder="Enter your search query..." |
|
value={query} |
|
onChange={(e) => setQuery(e.target.value)} |
|
onKeyDown={handleKeyDown} |
|
disabled={isLoading} |
|
/> |
|
<button |
|
type="submit" |
|
className="search-button" |
|
disabled={!query.trim() || isLoading} |
|
> |
|
{isLoading ? ( |
|
<> |
|
<span className="loading-spinner"></span> |
|
<span style={{ marginLeft: "8px" }}>Searching...</span> |
|
</> |
|
) : ( |
|
"Search" |
|
)} |
|
</button> |
|
</div> |
|
|
|
<div className="search-options"> |
|
<label> |
|
Search Type: |
|
<select |
|
className="search-type-select" |
|
value={searchType} |
|
onChange={(e) => setSearchType(e.target.value as any)} |
|
disabled={isLoading} |
|
> |
|
<option value="semantic">Semantic</option> |
|
<option value="keyword">Keyword</option> |
|
<option value="hybrid">Hybrid</option> |
|
</select> |
|
</label> |
|
|
|
<label> |
|
Max Results: |
|
<select |
|
className="search-type-select" |
|
value={maxResults} |
|
onChange={(e) => setMaxResults(parseInt(e.target.value))} |
|
disabled={isLoading} |
|
> |
|
<option value={5}>5</option> |
|
<option value={10}>10</option> |
|
<option value={20}>20</option> |
|
</select> |
|
</label> |
|
</div> |
|
</form> |
|
</div> |
|
|
|
{} |
|
<div className="results-container"> |
|
{results.length > 0 && ( |
|
<div className="results-header"> |
|
<div className="results-info"> |
|
<strong>{results.length}</strong> results found in{" "} |
|
<strong>{formatSearchTime(searchTime)}</strong> |
|
</div> |
|
</div> |
|
)} |
|
|
|
{isLoading ? ( |
|
<div className="empty-state"> |
|
<div className="loading-spinner" style={{ marginBottom: "12px" }}></div> |
|
<p>Searching knowledge base...</p> |
|
</div> |
|
) : results.length === 0 && query ? ( |
|
<div className="empty-state"> |
|
<p>No results found for "{query}"</p> |
|
<p style={{ fontSize: "14px", marginTop: "8px" }}> |
|
Try adjusting your search terms or search type. |
|
</p> |
|
</div> |
|
) : results.length === 0 ? ( |
|
<div className="empty-state"> |
|
<p>Enter a search query to find relevant documents</p> |
|
</div> |
|
) : ( |
|
<div> |
|
{results.map((result) => ( |
|
<div key={result.id} className="result-card"> |
|
<div className="result-header"> |
|
<div className="result-meta"> |
|
<span className={getSourceTypeBadgeClass(result.source_type)}> |
|
{result.source_type} |
|
</span> |
|
<span className="relevance-score"> |
|
{formatRelevanceScore(result.relevance_score)} |
|
</span> |
|
</div> |
|
|
|
<h3 className="result-title">{result.title}</h3> |
|
<p className="result-source">{result.source}</p> |
|
|
|
<div className="result-snippet">{result.snippet}</div> |
|
|
|
<div className="result-actions"> |
|
<button |
|
className="action-button" |
|
onClick={() => toggleExpanded(result.id)} |
|
> |
|
{expandedResults.has(result.id) ? "Collapse" : "Expand"} |
|
</button> |
|
|
|
{result.url && ( |
|
<button |
|
className="action-button" |
|
onClick={() => window.open(result.url, "_blank")} |
|
> |
|
View Source |
|
</button> |
|
)} |
|
|
|
<button |
|
className="action-button primary" |
|
onClick={() => handleSelect(result)} |
|
> |
|
Use This |
|
</button> |
|
</div> |
|
</div> |
|
|
|
{expandedResults.has(result.id) && ( |
|
<div className="expanded-content"> |
|
<h4>Full Content</h4> |
|
<p style={{ lineHeight: "1.6", marginBottom: "16px" }}> |
|
{result.content.length > 500 |
|
? `${result.content.substring(0, 500)}...` |
|
: result.content} |
|
</p> |
|
|
|
{result.metadata && Object.keys(result.metadata).length > 0 && ( |
|
<> |
|
<h4>Metadata</h4> |
|
<div className="metadata-grid"> |
|
{Object.entries(result.metadata).map(([key, value]) => ( |
|
<div key={key} className="metadata-item"> |
|
<span className="metadata-label"> |
|
{key.charAt(0).toUpperCase() + key.slice(1)}: |
|
</span> |
|
<span className="metadata-value"> |
|
{Array.isArray(value) ? value.join(", ") : String(value)} |
|
</span> |
|
</div> |
|
))} |
|
</div> |
|
</> |
|
)} |
|
</div> |
|
)} |
|
</div> |
|
))} |
|
</div> |
|
)} |
|
</div> |
|
</div> |
|
); |
|
}; |
|
|
|
|
|
const initializeComponent = () => { |
|
const container = document.getElementById("kb-browser-root"); |
|
if (container) { |
|
const root = createRoot(container); |
|
root.render( |
|
<KnowledgeBrowser |
|
onSubmit={(query) => { |
|
console.log("Search submitted:", query); |
|
// Dispatch custom event for Gradio integration |
|
window.dispatchEvent(new CustomEvent("kb-browser-submit", { |
|
detail: { query } |
|
})); |
|
}} |
|
onSelect={(document) => { |
|
console.log("Document selected:", document); |
|
// Dispatch custom event for Gradio integration |
|
window.dispatchEvent(new CustomEvent("kb-browser-select", { |
|
detail: { document } |
|
})); |
|
}} |
|
/> |
|
); |
|
} |
|
}; |
|
|
|
|
|
if (document.readyState === "loading") { |
|
document.addEventListener("DOMContentLoaded", initializeComponent); |
|
} else { |
|
initializeComponent(); |
|
} |
|
|
|
export default KnowledgeBrowser; |