mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-07-02 14:31:01 +02:00
Merge commit '9b2f675702' as 'ai-context/context-graph-demo'
This commit is contained in:
commit
ecaf3489f1
54 changed files with 10078 additions and 0 deletions
393
ai-context/context-graph-demo/src/pages/DataView.tsx
Normal file
393
ai-context/context-graph-demo/src/pages/DataView.tsx
Normal file
|
|
@ -0,0 +1,393 @@
|
|||
import { useState, useCallback, useMemo } from "react";
|
||||
import { SectionLabel, Card, LoadingState, SearchInput, FilterBar } from "../components";
|
||||
import type { FilterItem } from "../components";
|
||||
import { useSchemas, useEmbeddings, useRowEmbeddingsQuery, useRowsQuery } from "@trustgraph/react-state";
|
||||
import { COLLECTION } from "../config";
|
||||
import { semantic, palette, text, border, surface } from "../theme";
|
||||
|
||||
// Schema field type
|
||||
interface SchemaField {
|
||||
name: string;
|
||||
type: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// Schema type based on what useSchemas returns
|
||||
interface SchemaData {
|
||||
name: string;
|
||||
description?: string;
|
||||
fields?: SchemaField[];
|
||||
indexes?: { name: string; fields: string[] }[];
|
||||
}
|
||||
|
||||
interface SchemaInfo {
|
||||
key: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
fields: SchemaField[];
|
||||
indexes: { name: string; fields: string[] }[];
|
||||
}
|
||||
|
||||
// Type for accumulated results with schema info and row data
|
||||
interface AccumulatedMatch {
|
||||
schemaKey: string;
|
||||
index_name: string;
|
||||
index_value: string[];
|
||||
text: string;
|
||||
score: number;
|
||||
rowData?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function DataView() {
|
||||
// Input state
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
// Filter state (display only - doesn't trigger re-fetch)
|
||||
const [selectedSchema, setSelectedSchema] = useState<string | null>(null);
|
||||
|
||||
// Results state
|
||||
const [allMatches, setAllMatches] = useState<AccumulatedMatch[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [hasSearched, setHasSearched] = useState(false);
|
||||
|
||||
// Fetch schemas
|
||||
const { schemas: rawSchemas, schemasLoading, schemasError } = useSchemas();
|
||||
|
||||
// Embeddings hook - we'll use refetch for manual triggering
|
||||
const [embeddingsTerm, setEmbeddingsTerm] = useState("");
|
||||
const { embeddings, isLoading: embeddingsLoading, refetch: _refetchEmbeddings } = useEmbeddings({
|
||||
flow: "default",
|
||||
term: embeddingsTerm,
|
||||
});
|
||||
|
||||
// Row embeddings query
|
||||
const { executeQueryAsync } = useRowEmbeddingsQuery({ flow: "default" });
|
||||
|
||||
// Rows query for fetching full row data
|
||||
const { executeQueryAsync: executeRowsQueryAsync } = useRowsQuery({ flow: "default" });
|
||||
|
||||
// Parse schemas into usable format
|
||||
const schemas: SchemaInfo[] = useMemo(() => {
|
||||
return (rawSchemas || []).map((s: unknown, idx: number) => {
|
||||
if (Array.isArray(s)) {
|
||||
const schemaData = s[1] as SchemaData | undefined;
|
||||
return {
|
||||
key: String(s[0]),
|
||||
name: schemaData?.name || String(s[0]),
|
||||
description: schemaData?.description,
|
||||
fields: schemaData?.fields || [],
|
||||
indexes: schemaData?.indexes || [],
|
||||
};
|
||||
}
|
||||
const schemaObj = s as SchemaData & { key?: string };
|
||||
return {
|
||||
key: schemaObj.key || schemaObj.name || `schema-${idx}`,
|
||||
name: schemaObj.name || `Schema ${idx}`,
|
||||
description: schemaObj.description,
|
||||
fields: schemaObj.fields || [],
|
||||
indexes: schemaObj.indexes || [],
|
||||
};
|
||||
});
|
||||
}, [rawSchemas]);
|
||||
|
||||
// Build GraphQL query for a schema
|
||||
const buildGraphQLQuery = useCallback((schema: SchemaInfo) => {
|
||||
const gqlName = schema.key.replace(/-/g, '_');
|
||||
const fieldNames = schema.fields.map(f => f.name).join('\n ');
|
||||
return `query { ${gqlName} { ${fieldNames} } }`;
|
||||
}, []);
|
||||
|
||||
// Core search function - searches ALL schemas, stores ALL results
|
||||
const performSearch = useCallback(async (vectors: number[][]) => {
|
||||
try {
|
||||
// Always search ALL schemas
|
||||
const embeddingsResults = await Promise.all(
|
||||
schemas.map(async (schema) => {
|
||||
try {
|
||||
const matches = await executeQueryAsync({
|
||||
vectors,
|
||||
schemaName: schema.key,
|
||||
collection: COLLECTION,
|
||||
limit: 10,
|
||||
});
|
||||
return matches.map(m => ({ ...m, schemaKey: schema.key }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const flatMatches = embeddingsResults.flat();
|
||||
|
||||
// Deduplicate
|
||||
const seen = new Set<string>();
|
||||
const uniqueMatches = flatMatches.filter(match => {
|
||||
const key = `${match.schemaKey}:${match.index_value.join(',')}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
|
||||
// Fetch full row data for schemas with matches
|
||||
const schemaKeysWithMatches = [...new Set(uniqueMatches.map(m => m.schemaKey))];
|
||||
const rowDataBySchema: Record<string, Record<string, unknown>[]> = {};
|
||||
|
||||
await Promise.all(
|
||||
schemaKeysWithMatches.map(async (schemaKey) => {
|
||||
const schema = schemas.find(s => s.key === schemaKey);
|
||||
if (!schema || schema.fields.length === 0) return;
|
||||
|
||||
try {
|
||||
const query = buildGraphQLQuery(schema);
|
||||
const result = await executeRowsQueryAsync({ query, collection: COLLECTION });
|
||||
const gqlName = schemaKey.replace(/-/g, '_');
|
||||
const rows = (result?.data as Record<string, unknown[]>)?.[gqlName] || [];
|
||||
rowDataBySchema[schemaKey] = rows as Record<string, unknown>[];
|
||||
} catch (err) {
|
||||
console.error(`Failed to fetch rows for ${schemaKey}:`, err);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Match row data to embeddings results
|
||||
const matchesWithRowData = uniqueMatches.map(match => {
|
||||
const rows = rowDataBySchema[match.schemaKey] || [];
|
||||
const indexFields = match.index_name.split('.');
|
||||
const indexFieldName = indexFields[indexFields.length - 1];
|
||||
|
||||
const matchedRow = rows.find(row => {
|
||||
const rowValue = row[indexFieldName];
|
||||
return match.index_value.some(iv =>
|
||||
String(rowValue).toLowerCase() === iv.toLowerCase()
|
||||
);
|
||||
});
|
||||
|
||||
return { ...match, rowData: matchedRow };
|
||||
});
|
||||
|
||||
setAllMatches(matchesWithRowData);
|
||||
setHasSearched(true);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
}, [schemas, executeQueryAsync, executeRowsQueryAsync, buildGraphQLQuery]);
|
||||
|
||||
// Handle search button click
|
||||
const handleSearch = useCallback(async () => {
|
||||
const term = searchTerm.trim();
|
||||
if (!term) return;
|
||||
|
||||
setIsSearching(true);
|
||||
setAllMatches([]);
|
||||
|
||||
// If same term, use refetch; otherwise set new term
|
||||
if (term === embeddingsTerm && embeddings && embeddings.length > 0) {
|
||||
// Same term - we already have embeddings, just re-run the search
|
||||
await performSearch(embeddings);
|
||||
} else {
|
||||
// New term - update embeddings term and wait for it
|
||||
setEmbeddingsTerm(term);
|
||||
}
|
||||
}, [searchTerm, embeddingsTerm, embeddings, performSearch]);
|
||||
|
||||
// When embeddings become available for a new term, run the search
|
||||
// This only triggers when embeddingsTerm changes and embeddings load
|
||||
const prevEmbeddingsTermRef = useMemo(() => ({ current: "" }), []);
|
||||
|
||||
if (
|
||||
isSearching &&
|
||||
embeddingsTerm &&
|
||||
embeddings &&
|
||||
embeddings.length > 0 &&
|
||||
!embeddingsLoading &&
|
||||
prevEmbeddingsTermRef.current !== embeddingsTerm
|
||||
) {
|
||||
prevEmbeddingsTermRef.current = embeddingsTerm;
|
||||
performSearch(embeddings);
|
||||
}
|
||||
|
||||
// Filter results for display (doesn't affect stored data)
|
||||
const displayMatches = useMemo(() => {
|
||||
if (!selectedSchema) return allMatches;
|
||||
return allMatches.filter(m => m.schemaKey === selectedSchema);
|
||||
}, [allMatches, selectedSchema]);
|
||||
|
||||
// Group filtered matches by schema for display
|
||||
const matchesBySchema = useMemo(() => {
|
||||
return displayMatches.reduce((acc, match) => {
|
||||
if (!acc[match.schemaKey]) {
|
||||
acc[match.schemaKey] = [];
|
||||
}
|
||||
acc[match.schemaKey].push(match);
|
||||
return acc;
|
||||
}, {} as Record<string, AccumulatedMatch[]>);
|
||||
}, [displayMatches]);
|
||||
|
||||
if (schemasLoading) {
|
||||
return <LoadingState message="Loading schemas..." />;
|
||||
}
|
||||
|
||||
if (schemasError) {
|
||||
return <LoadingState variant="error" message="Error loading schemas" />;
|
||||
}
|
||||
|
||||
// Build filter items from schemas
|
||||
const filterItems: FilterItem[] = schemas.slice(0, 10).map((schema) => ({
|
||||
key: schema.key,
|
||||
label: schema.name,
|
||||
}));
|
||||
|
||||
const filterStats = selectedSchema
|
||||
? `${displayMatches.length} of ${allMatches.length} results`
|
||||
: `${allMatches.length} results`;
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "calc(100vh - 110px)" }}>
|
||||
{/* Schema Filter Bar */}
|
||||
<FilterBar
|
||||
items={filterItems}
|
||||
selectedKey={selectedSchema}
|
||||
onSelect={setSelectedSchema}
|
||||
stats={filterStats}
|
||||
/>
|
||||
|
||||
{/* Search Input */}
|
||||
<div style={{ padding: "20px 28px", borderBottom: `1px solid ${border.default}` }}>
|
||||
<SectionLabel marginBottom={12}>SEARCH DATA</SectionLabel>
|
||||
<SearchInput
|
||||
value={searchTerm}
|
||||
onChange={setSearchTerm}
|
||||
onSubmit={handleSearch}
|
||||
placeholder="Search for data across tables..."
|
||||
buttonText="Search"
|
||||
isLoading={isSearching}
|
||||
buttonColor={palette.blue}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Results Area */}
|
||||
<div style={{ flex: 1, padding: "24px 28px", overflowY: "auto" }}>
|
||||
{!hasSearched && !isSearching ? (
|
||||
<div style={{ color: text.hint, fontSize: 13, fontStyle: "italic" }}>
|
||||
Enter a search term to find data across tables.
|
||||
</div>
|
||||
) : isSearching ? (
|
||||
<div style={{ color: palette.blue, fontSize: 13 }}>
|
||||
Searching...
|
||||
</div>
|
||||
) : displayMatches.length === 0 ? (
|
||||
<div style={{ color: text.hint, fontSize: 13, fontStyle: "italic" }}>
|
||||
{selectedSchema ? "No matches in this schema. Try selecting 'All'." : "No matches found."}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
|
||||
{Object.entries(matchesBySchema).map(([schemaKey, schemaMatches]) => {
|
||||
if (!schemaMatches || schemaMatches.length === 0) return null;
|
||||
const schema = schemas.find(s => s.key === schemaKey);
|
||||
|
||||
return (
|
||||
<Card key={schemaKey} padding={0}>
|
||||
{/* Table Header */}
|
||||
<div style={{
|
||||
padding: "12px 16px",
|
||||
borderBottom: `1px solid ${border.default}`,
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}>
|
||||
<span style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: palette.blue,
|
||||
fontFamily: "'IBM Plex Mono', monospace",
|
||||
}}>
|
||||
▤ {schema?.name || schemaKey}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: 11,
|
||||
color: text.disabled,
|
||||
fontFamily: "'IBM Plex Mono', monospace",
|
||||
}}>
|
||||
{schemaMatches.length} matches
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Results List */}
|
||||
<div>
|
||||
{schemaMatches.map((match, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
borderBottom: `1px solid ${border.subtle}`,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = surface.card;
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = "transparent";
|
||||
}}
|
||||
>
|
||||
{match.rowData ? (
|
||||
<div style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))",
|
||||
gap: "8px 16px",
|
||||
marginBottom: 8,
|
||||
}}>
|
||||
{Object.entries(match.rowData).map(([key, value]) => (
|
||||
<div key={key}>
|
||||
<span style={{
|
||||
fontSize: 10,
|
||||
color: text.faint,
|
||||
fontFamily: "'IBM Plex Mono', monospace",
|
||||
textTransform: "uppercase",
|
||||
}}>
|
||||
{key}
|
||||
</span>
|
||||
<div style={{
|
||||
fontSize: 13,
|
||||
color: text.primary,
|
||||
marginTop: 2,
|
||||
wordBreak: "break-word",
|
||||
}}>
|
||||
{String(value ?? "")}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
fontSize: 13,
|
||||
color: text.primary,
|
||||
marginBottom: 6,
|
||||
lineHeight: 1.5,
|
||||
}}>
|
||||
{match.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
fontSize: 11,
|
||||
fontFamily: "'IBM Plex Mono', monospace",
|
||||
}}>
|
||||
<span style={{
|
||||
color: match.score > 0.8 ? semantic.success : match.score > 0.5 ? palette.amber : text.subtle,
|
||||
}}>
|
||||
{(match.score * 100).toFixed(1)}% match
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1411
ai-context/context-graph-demo/src/pages/ExplainView.tsx
Normal file
1411
ai-context/context-graph-demo/src/pages/ExplainView.tsx
Normal file
File diff suppressed because it is too large
Load diff
93
ai-context/context-graph-demo/src/pages/GraphView.tsx
Normal file
93
ai-context/context-graph-demo/src/pages/GraphView.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import type { DomainKey, Entity, OntologyDomain } from "../types";
|
||||
import { GraphCanvasSVG as GraphCanvas, NodeDetailPanel, LoadingState, FilterBar } from "../components";
|
||||
import type { FilterItem } from "../components";
|
||||
import { useGraphData } from "../state";
|
||||
|
||||
interface GraphViewProps {
|
||||
activeFilter: DomainKey | null;
|
||||
onFilterChange: (filter: DomainKey | null) => void;
|
||||
selectedNode: Entity | null;
|
||||
onNodeSelect: (node: Entity | null) => void;
|
||||
}
|
||||
|
||||
export function GraphView({ activeFilter, onFilterChange, selectedNode, onNodeSelect }: GraphViewProps) {
|
||||
const { entities, relationships, ontology, propertyLabels, isLoading, isError } = useGraphData();
|
||||
|
||||
const highlightedEntities = selectedNode
|
||||
? [selectedNode.id, ...relationships.filter(r => r.from === selectedNode.id || r.to === selectedNode.id).map(r => r.from === selectedNode.id ? r.to : r.from)]
|
||||
: [];
|
||||
|
||||
// Compute relevant filter domains based on selected node's connections
|
||||
const relevantDomains = selectedNode
|
||||
? (() => {
|
||||
const domains = new Set<DomainKey>([selectedNode.domain]);
|
||||
const connectedIds = relationships
|
||||
.filter(r => r.from === selectedNode.id || r.to === selectedNode.id)
|
||||
.map(r => r.from === selectedNode.id ? r.to : r.from);
|
||||
for (const id of connectedIds) {
|
||||
const entity = entities.find(e => e.id === id);
|
||||
if (entity) domains.add(entity.domain);
|
||||
}
|
||||
return domains;
|
||||
})()
|
||||
: null;
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingState message="Loading graph data..." />;
|
||||
}
|
||||
|
||||
if (isError || !ontology) {
|
||||
return <LoadingState variant="error" message="Error loading graph data" />;
|
||||
}
|
||||
|
||||
// Build filter items from relevant domains
|
||||
const filterItems: FilterItem[] = selectedNode
|
||||
? (Object.entries(ontology) as [DomainKey, OntologyDomain][])
|
||||
.filter(([key]) => relevantDomains?.has(key))
|
||||
.slice(0, 10)
|
||||
.map(([key, data]) => ({
|
||||
key,
|
||||
label: data.label,
|
||||
icon: data.icon,
|
||||
color: data.color,
|
||||
}))
|
||||
: [];
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Domain Filter Bar */}
|
||||
<FilterBar
|
||||
items={filterItems}
|
||||
selectedKey={activeFilter}
|
||||
onSelect={(key) => onFilterChange(key as DomainKey | null)}
|
||||
stats={`${entities.length} entities · ${relationships.length} relationships`}
|
||||
emptyMessage={selectedNode ? undefined : "Select a node to filter"}
|
||||
/>
|
||||
|
||||
{/* Main Content */}
|
||||
<div style={{ display: "flex", height: "calc(100vh - 150px)" }}>
|
||||
<div style={{ flex: 1, minWidth: 0, position: "relative", overflow: "hidden" }}>
|
||||
<GraphCanvas
|
||||
entities={entities}
|
||||
relationships={relationships}
|
||||
ontology={ontology}
|
||||
highlightedEntities={highlightedEntities}
|
||||
onNodeClick={(node) => onNodeSelect(selectedNode?.id === node.id ? null : node)}
|
||||
activeFilter={activeFilter}
|
||||
/>
|
||||
</div>
|
||||
{selectedNode && (
|
||||
<NodeDetailPanel
|
||||
node={selectedNode}
|
||||
relationships={relationships}
|
||||
entities={entities}
|
||||
ontology={ontology}
|
||||
propertyLabels={propertyLabels}
|
||||
onClose={() => onNodeSelect(null)}
|
||||
onNodeSelect={onNodeSelect}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
116
ai-context/context-graph-demo/src/pages/OntologyView.tsx
Normal file
116
ai-context/context-graph-demo/src/pages/OntologyView.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import type { DomainKey, OntologyDomain } from "../types";
|
||||
import { SectionLabel, Card, Badge, LoadingState } from "../components";
|
||||
import { useGraphData, useOntologySchema } from "../state";
|
||||
import { getLocalName } from "../utils";
|
||||
import { text, surface, border } from "../theme";
|
||||
|
||||
export function OntologyView() {
|
||||
const { ontology, isLoading: graphLoading } = useGraphData();
|
||||
const { schema, isLoading: schemaLoading } = useOntologySchema();
|
||||
|
||||
const isLoading = graphLoading || schemaLoading;
|
||||
|
||||
if (isLoading || !ontology || !schema) {
|
||||
return <LoadingState message="Loading ontology..." />;
|
||||
}
|
||||
|
||||
// Count total instances
|
||||
const totalInstances = Object.values(ontology).reduce((sum, d) => sum + d.subclasses.length, 0);
|
||||
|
||||
return (
|
||||
<div style={{ flex: 1, padding: "28px", overflowY: "auto", height: "calc(100vh - 110px)" }}>
|
||||
<div style={{ maxWidth: 900, margin: "0 auto" }}>
|
||||
<SectionLabel marginBottom={24}>ONTOLOGY SCHEMA</SectionLabel>
|
||||
|
||||
{/* Ontology class cards */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16, marginBottom: 32 }}>
|
||||
{(Object.entries(ontology) as [DomainKey, OntologyDomain][]).map(([key, data]) => {
|
||||
// Find datatype properties for this domain from schema
|
||||
const domainProps = schema.datatypeProperties
|
||||
.filter(p => p.domain && getLocalName(p.domain) === data.label)
|
||||
.map(p => p.label);
|
||||
|
||||
return (
|
||||
<Card key={key} borderColor={data.color + "22"}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 10 }}>
|
||||
<span style={{ fontSize: 24 }}>{data.icon}</span>
|
||||
<div>
|
||||
<div style={{ fontWeight: 700, fontSize: 18, color: data.color }}>{data.label}</div>
|
||||
<div style={{ fontSize: 11, color: text.faint, fontFamily: "'IBM Plex Mono', monospace" }}>owl:Class</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: text.subtle, lineHeight: 1.5, marginBottom: 14 }}>{data.description}</div>
|
||||
<SectionLabel marginBottom={8}>PROPERTIES ({domainProps.length})</SectionLabel>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 4 }}>
|
||||
{domainProps.map((p) => (
|
||||
<Badge key={p} color={data.color} size="small">{p}</Badge>
|
||||
))}
|
||||
</div>
|
||||
<SectionLabel marginTop={14} marginBottom={8}>INSTANCES ({data.subclasses.length})</SectionLabel>
|
||||
{data.subclasses.map((sc) => (
|
||||
<div key={sc.id} style={{
|
||||
padding: "6px 10px", marginBottom: 3, borderRadius: 4,
|
||||
background: surface.card, fontSize: 11, color: text.muted,
|
||||
display: "flex", justifyContent: "space-between",
|
||||
}}>
|
||||
<span>{sc.label}</span>
|
||||
<span style={{ color: text.disabled, fontFamily: "'IBM Plex Mono', monospace", fontSize: 10 }}>{sc.id}</span>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Relationship predicates (Object Properties) */}
|
||||
<Card borderColor={border.default}>
|
||||
<SectionLabel marginBottom={16}>RELATIONSHIP PREDICATES ({schema.objectProperties.length})</SectionLabel>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 8 }}>
|
||||
{schema.objectProperties.map((prop) => {
|
||||
const fromDomain = prop.domain ? getLocalName(prop.domain).toLowerCase() as DomainKey : null;
|
||||
const toDomain = prop.range ? getLocalName(prop.range).toLowerCase() as DomainKey : null;
|
||||
|
||||
return (
|
||||
<Card key={prop.uri} padding="10px 12px" borderRadius={6}>
|
||||
<div style={{ fontSize: 12, color: text.secondary, fontFamily: "'IBM Plex Mono', monospace", marginBottom: 4 }}>
|
||||
{prop.label}
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: text.disabled }}>
|
||||
{fromDomain && ontology[fromDomain] && (
|
||||
<span style={{ color: ontology[fromDomain].color }}>{ontology[fromDomain].label}</span>
|
||||
)}
|
||||
{fromDomain && toDomain && " → "}
|
||||
{toDomain && ontology[toDomain] && (
|
||||
<span style={{ color: ontology[toDomain].color }}>{ontology[toDomain].label}</span>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Triple count summary */}
|
||||
<div style={{
|
||||
marginTop: 20, padding: "16px 24px", borderRadius: 10,
|
||||
background: "linear-gradient(135deg, rgba(110,231,183,0.04) 0%, rgba(147,197,253,0.04) 50%, rgba(249,168,212,0.04) 100%)",
|
||||
border: `1px solid ${border.default}`,
|
||||
display: "flex", justifyContent: "space-around",
|
||||
fontFamily: "'IBM Plex Mono', monospace",
|
||||
}}>
|
||||
{[
|
||||
{ label: "Classes", value: schema.classes.length },
|
||||
{ label: "Instances", value: totalInstances },
|
||||
{ label: "Object Props", value: schema.objectProperties.length },
|
||||
{ label: "Data Props", value: schema.datatypeProperties.length },
|
||||
].map((s) => (
|
||||
<div key={s.label} style={{ textAlign: "center" }}>
|
||||
<div style={{ fontSize: 24, fontWeight: 700, color: "#fff" }}>{s.value}</div>
|
||||
<div style={{ fontSize: 10, color: text.faint, letterSpacing: "0.05em" }}>{s.label.toUpperCase()}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
231
ai-context/context-graph-demo/src/pages/QueryView.tsx
Normal file
231
ai-context/context-graph-demo/src/pages/QueryView.tsx
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
import { useState, useEffect, useRef } from "react";
|
||||
import { GraphCanvasSVG as GraphCanvas, NodeDetailPanel, SectionLabel, Badge, LoadingState, SearchInput, MessageBubble } from "../components";
|
||||
import { useGraphData } from "../state";
|
||||
import { COLLECTION } from "../config";
|
||||
import type { Entity } from "../types";
|
||||
import { useChat, useConversation, useEmbeddings, useGraphEmbeddings } from "@trustgraph/react-state";
|
||||
import { getLocalName } from "../utils";
|
||||
import { palette, text, border, withGlow } from "../theme";
|
||||
|
||||
// Type for embedding result items
|
||||
interface EmbeddingResultItem {
|
||||
id: string;
|
||||
uri: string;
|
||||
label: string;
|
||||
color: string;
|
||||
icon: string;
|
||||
isEntity: boolean;
|
||||
}
|
||||
|
||||
export function QueryView() {
|
||||
const [customInput, setCustomInput] = useState("");
|
||||
const [queryForEmbeddings, setQueryForEmbeddings] = useState<string | undefined>(undefined);
|
||||
const [selectedEntityId, setSelectedEntityId] = useState<string | null>(null);
|
||||
const [selectedNode, setSelectedNode] = useState<Entity | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { entities, relationships, ontology, propertyLabels, isLoading: graphLoading } = useGraphData();
|
||||
const { submitMessage, isSubmitting } = useChat();
|
||||
const messages = useConversation((state) => state.messages);
|
||||
const setChatMode = useConversation((state) => state.setChatMode);
|
||||
|
||||
// Get embeddings for the query text - only fetch when we have a committed query
|
||||
const { embeddings, isLoading: embeddingsLoading } = useEmbeddings({
|
||||
flow: "default",
|
||||
term: queryForEmbeddings || undefined,
|
||||
});
|
||||
|
||||
// Get graph entities from embeddings - only fetch when we have embeddings
|
||||
const hasEmbeddings = embeddings && embeddings.length > 0;
|
||||
const { graphEmbeddings, isLoading: graphEmbeddingsLoading } = useGraphEmbeddings({
|
||||
vecs: hasEmbeddings ? embeddings : [[]],
|
||||
limit: hasEmbeddings ? 10 : 0,
|
||||
collection: COLLECTION,
|
||||
});
|
||||
|
||||
// Set chat mode to agent on mount
|
||||
useEffect(() => {
|
||||
setChatMode("agent");
|
||||
}, [setChatMode]);
|
||||
|
||||
// Auto-scroll to bottom when messages change
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages]);
|
||||
|
||||
const handleSubmit = (query: string) => {
|
||||
if (query.trim() && !isSubmitting) {
|
||||
const trimmedQuery = query.trim();
|
||||
submitMessage({ input: trimmedQuery });
|
||||
setQueryForEmbeddings(trimmedQuery);
|
||||
setSelectedEntityId(null);
|
||||
setSelectedNode(null);
|
||||
setCustomInput("");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Match graph embedding entities to our loaded entities for labels and highlighting
|
||||
// graphEmbeddings returns RDF terms: { t: "i", i: "http://..." }
|
||||
// Only show matched entities, deduplicated by URI
|
||||
const embeddingResults: EmbeddingResultItem[] = [];
|
||||
const seenUris = new Set<string>();
|
||||
|
||||
for (const ge of (hasEmbeddings && graphEmbeddings || []) as { t: string; i?: string }[]) {
|
||||
const uri = ge.i;
|
||||
if (!uri || seenUris.has(uri)) continue;
|
||||
|
||||
const entityId = getLocalName(uri);
|
||||
const found = entities.find(e => e.id === entityId || e.uri === uri);
|
||||
|
||||
// Only include actual entities, not properties/concepts
|
||||
if (found) {
|
||||
seenUris.add(uri);
|
||||
embeddingResults.push({
|
||||
id: entityId,
|
||||
uri,
|
||||
label: found.label,
|
||||
color: found.color,
|
||||
icon: found.icon,
|
||||
isEntity: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-select first embedding result when results arrive
|
||||
useEffect(() => {
|
||||
if (embeddingResults.length > 0 && !selectedEntityId && !selectedNode) {
|
||||
setSelectedEntityId(embeddingResults[0].id);
|
||||
}
|
||||
}, [embeddingResults.length, selectedEntityId, selectedNode]);
|
||||
|
||||
// Extract entity IDs for highlighting on graph
|
||||
// Priority: selectedNode (graph click) > selectedEntityId (button click) > all embedding results
|
||||
const highlightedEntities = (() => {
|
||||
const focusId = selectedNode?.id || selectedEntityId;
|
||||
if (!focusId) {
|
||||
return embeddingResults.map(e => e.id);
|
||||
}
|
||||
// Find all entities connected to the focused entity
|
||||
const connected = new Set<string>([focusId]);
|
||||
for (const rel of relationships) {
|
||||
if (rel.from === focusId) {
|
||||
connected.add(rel.to);
|
||||
} else if (rel.to === focusId) {
|
||||
connected.add(rel.from);
|
||||
}
|
||||
}
|
||||
return Array.from(connected);
|
||||
})();
|
||||
|
||||
if (graphLoading || !ontology) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", height: "calc(100vh - 110px)" }}>
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column" }}>
|
||||
{/* Query input area */}
|
||||
<div style={{ padding: "20px 28px", borderBottom: `1px solid ${border.default}` }}>
|
||||
<SectionLabel marginBottom={12}>AGENT QUERIES</SectionLabel>
|
||||
|
||||
<SearchInput
|
||||
value={customInput}
|
||||
onChange={setCustomInput}
|
||||
onSubmit={() => handleSubmit(customInput)}
|
||||
placeholder="Type your own question..."
|
||||
buttonText="Ask"
|
||||
isLoading={isSubmitting}
|
||||
buttonColor={palette.amber}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Related entities from graph embeddings */}
|
||||
{queryForEmbeddings && (
|
||||
<div style={{ padding: "16px 28px", borderBottom: `1px solid ${border.default}` }}>
|
||||
<SectionLabel>
|
||||
RELATED ENTITIES {(embeddingsLoading || graphEmbeddingsLoading) && <span style={{ color: palette.amber }}>loading...</span>}
|
||||
</SectionLabel>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
|
||||
{embeddingResults.length === 0 && !embeddingsLoading && !graphEmbeddingsLoading && (
|
||||
<span style={{ fontSize: 11, color: text.disabled, fontStyle: "italic" }}>No related concepts found</span>
|
||||
)}
|
||||
{embeddingResults.map((item) => {
|
||||
const isSelected = selectedEntityId === item.id;
|
||||
return (
|
||||
<Badge
|
||||
key={item.uri}
|
||||
color={item.color}
|
||||
selected={isSelected}
|
||||
onClick={() => {
|
||||
setSelectedEntityId(isSelected ? null : item.id);
|
||||
setSelectedNode(null);
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 10 }}>{item.icon}</span>
|
||||
{item.label}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Response area */}
|
||||
<div style={{ flex: 1, padding: "24px 28px", overflowY: "auto" }}>
|
||||
{messages.length === 0 ? (
|
||||
<div style={{ color: text.hint, fontSize: 13, fontStyle: "italic" }}>
|
||||
Type your question to get started.
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
{messages.map((msg, idx) => (
|
||||
<MessageBubble key={idx} message={msg} />
|
||||
))}
|
||||
{isSubmitting && (
|
||||
<div style={{
|
||||
padding: "8px 12px",
|
||||
fontSize: 11,
|
||||
color: withGlow(palette.amber, 0.4),
|
||||
fontFamily: "'IBM Plex Mono', monospace"
|
||||
}}>
|
||||
Processing...
|
||||
</div>
|
||||
)}
|
||||
<div ref={scrollRef} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Graph visualization */}
|
||||
<div style={{ width: selectedNode ? "30%" : "45%", borderLeft: `1px solid ${border.default}`, transition: "width 0.2s" }}>
|
||||
<GraphCanvas
|
||||
entities={entities}
|
||||
relationships={relationships}
|
||||
ontology={ontology}
|
||||
highlightedEntities={highlightedEntities}
|
||||
onNodeClick={(node) => {
|
||||
setSelectedNode(selectedNode?.id === node.id ? null : node);
|
||||
setSelectedEntityId(null);
|
||||
}}
|
||||
activeFilter={null}
|
||||
/>
|
||||
</div>
|
||||
{selectedNode && (
|
||||
<NodeDetailPanel
|
||||
node={selectedNode}
|
||||
relationships={relationships}
|
||||
entities={entities}
|
||||
ontology={ontology}
|
||||
propertyLabels={propertyLabels}
|
||||
onClose={() => setSelectedNode(null)}
|
||||
onNodeSelect={(node) => {
|
||||
setSelectedNode(node);
|
||||
setSelectedEntityId(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
ai-context/context-graph-demo/src/pages/index.ts
Normal file
5
ai-context/context-graph-demo/src/pages/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export { GraphView } from "./GraphView";
|
||||
export { QueryView } from "./QueryView";
|
||||
export { ExplainView } from "./ExplainView";
|
||||
export { DataView } from "./DataView";
|
||||
export { OntologyView } from "./OntologyView";
|
||||
Loading…
Add table
Add a link
Reference in a new issue