feat(explorer): add Semantica Knowledge Explorer UI with full feature set
## Folder & Project
- Renamed `semantica-explorer/` → `explorer/` (cleaner path)
- Browser tab title: `Semantica Knowledge Explorer`
- Brand pill: `SEM` → `SKE` (tooltip: Semantica Knowledge Explorer)
- Nav rail label: `Explore` → `Knowledge Explorer`
- package.json name: `semantica-knowledge-explorer`
- Downgraded Vite 8 → Vite 5 for Node v20.17.0 compatibility
## App Shell
- Dynamic per-workspace kicker labels replacing static "Workspace" pill:
Graph Studio · Vocabulary Browser · Reasoning Engine · SPARQL Query ·
Decision Intelligence · Knowledge Audit · Graph Governance
## Enrich Workspace — 2 new tabs
### Entity Resolution tab
- Similarity threshold slider (0.50–0.99)
- Run Dedup Scan → POST /api/enrich/dedup
- Flagged pairs list with colour-coded score bars (red/amber/green)
- Expandable inline diff: primary vs duplicate side-by-side
- One-click Merge → POST /api/enrich/merge with logEvent dispatch
- Dismiss per pair; Clear all button
- Merge history sidebar pulled live from Registry store
### Registry tab (Document Registry)
- Live chronological audit log of all KG mutations in-session
- Colour-coded op-type badges: IMPORT · MERGE · ADD NODE · ADD EDGE ·
INFER · DELETE · EXPORT · VOCAB
- Filter pills to narrow by operation type
- Expandable JSON detail rows per entry
- Clear log button
- Entirely client-side via registryStore (no backend needed)
## Manage Workspace — 2 new tabs
### KG Overview tab
- Stats chips: total nodes, edges, graph density
- Node type breakdown bar chart (up to 8 types, colour-coded)
- Edge type breakdown bar chart from /api/graph/stats
- Top-10 most connected nodes ranked by degree
- Skeleton loading states + Refresh button
### Ontology Summary tab
- Read-only SKOS scheme tree (scheme → top concepts → narrower)
- Concept detail panel: labels, notation, description, narrower nav
- "Open Full Browser" button deep-links to Vocabulary Browser tab
## Decision Workspace polish
- CausalFlowDiagram: vertical node cards connected by relationship pills
- Outcome badges: colour-coded (green=approved, red=rejected, amber=deferred)
- Live filter input across decision ID, category, and outcome
- Animated skeleton loading while list fetches
## Graph Inspector polish
- PathFlowViz: clickable node chips connected by edge-type labels;
clicking a chip focuses that node in the canvas
- Link Prediction button shows spinner while computing
- Empty states for path trace and candidate links sections
## Registry dispatch — WebSocket
- ADD_NODE events → logEvent("add-node", …) in GraphWorkspace WS handler
- ADD_EDGE events → logEvent("add-edge", …) in GraphWorkspace WS handler
- Import, Export, Merge already dispatched logEvent on API response
## Graph visibility overhaul
### Edge colours (were nearly transparent, now clearly visible)
- edgeBackbone: rgba(…, 0.04) → rgba(…, 0.38)
- edgeStructure: rgba(…, 0.009) → rgba(…, 0.28)
- edgeInspection: rgba(…, 0.026) → rgba(…, 0.48)
- Muted edges: 0.009–0.02 → 0.12–0.26
- Focus edges: 0.16 → 0.42
### Edge sizes
- default minSize: 0.18 → 0.9 (always at least 1 pixel wide)
- path minSize: 1.8 → 2.4
- inactive/muted: hide:true → hide:false (dimmed not hidden)
### Node sizes
- default sizeMultiplier: 0.72 → 0.92
- default minSize: 0.68 → 3.5 (visible at all zoom levels)
- overview nodeScale: 0.66 → 0.88
- nodeTintMix (colour): 0.03 → 0.14
- nodeCoreMix (brightness): 0.52 → 0.72
### Label budget
- overview: 10 → 28 labels
- structure: 36 → 60 labels
- inspection: 80 → 120 labels
### Sigma settings
- renderEdgeLabels: false → true (relationship type on every edge)
- edgeLabelSize: — → 10
- labelRenderedSizeThreshold: 4 → 2
- labelDensity: 0.86 → 1.1
- hideLabelsOnMove: true → false (labels stay visible while panning)
- hideEdgesOnMove: true → false (edges stay visible while panning)
- minCameraRatio: — → 0.04 (prevents zooming inside a node)
- maxCameraRatio: — → 8 (graph stays visible when zoomed out)
### Zoom controls
- Added Zoom In (+) and Zoom Out (−) buttons to graph toolbar
- Smooth animated zoom via camera.animatedZoom / animatedUnzoom (200ms)
- Mouse scroll wheel clamped between minCameraRatio and maxCameraRatio
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>semantica-explorer</title>
|
||||
<title>Semantica Knowledge Explorer</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "semantica-explorer",
|
||||
"name": "semantica-knowledge-explorer",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
@@ -33,12 +33,11 @@
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.29.0",
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@rolldown/plugin-babel": "^0.2.1",
|
||||
"@types/babel__core": "^7.20.5",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
@@ -46,6 +45,6 @@
|
||||
"globals": "^17.4.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.57.0",
|
||||
"vite": "^8.0.1"
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.9 KiB |
@@ -10,11 +10,16 @@ const LineageDiagram = lazy(() => import('./workspaces/LineageWorkspace/LineageD
|
||||
const ReasoningWorkspace = lazy(() => import('./workspaces/ReasoningWorkspace').then((module) => ({ default: module.ReasoningWorkspace })));
|
||||
const SparqlWorkspace = lazy(() => import('./workspaces/SparqlWorkspace/SparqlWorkspace').then((module) => ({ default: module.SparqlWorkspace })));
|
||||
const VocabularyWorkspace = lazy(() => import('./workspaces/VocabularyWorkspace/VocabularyWorkspace').then((module) => ({ default: module.VocabularyWorkspace })));
|
||||
const RegistryTab = lazy(() => import('./workspaces/EnrichWorkspace/RegistryTab').then((module) => ({ default: module.RegistryTab })));
|
||||
const EntityResolutionTab = lazy(() => import('./workspaces/EnrichWorkspace/EntityResolutionTab').then((module) => ({ default: module.EntityResolutionTab })));
|
||||
const KGOverviewTab = lazy(() => import('./workspaces/ManageWorkspace/KGOverviewTab').then((module) => ({ default: module.KGOverviewTab })));
|
||||
const OntologySummaryTab = lazy(() => import('./workspaces/ManageWorkspace/OntologySummaryTab').then((module) => ({ default: module.OntologySummaryTab })));
|
||||
|
||||
type WorkspaceId = 'explore' | 'analyze' | 'decisions' | 'enrich' | 'manage';
|
||||
type ExploreView = 'graph' | 'vocabulary';
|
||||
type AnalyzeView = 'sparql' | 'reasoning';
|
||||
type EnrichView = 'import' | 'merge';
|
||||
type EnrichView = 'import' | 'merge' | 'registry' | 'resolve';
|
||||
type ManageView = 'lineage' | 'kg-overview' | 'ontology';
|
||||
|
||||
type NavItem = {
|
||||
id: WorkspaceId;
|
||||
@@ -26,7 +31,7 @@ type NavItem = {
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ id: 'explore', label: 'Explore', hint: 'Graph and vocabulary browsing', icon: Database },
|
||||
{ id: 'explore', label: 'Knowledge Explorer', hint: 'Graph and vocabulary browsing', icon: Database },
|
||||
{ id: 'analyze', label: 'Analyze', hint: 'Query and inspect the dataset', icon: FileSearch },
|
||||
{ id: 'decisions', label: 'Decisions', hint: 'Decision chains and precedent review', icon: Scale },
|
||||
{ id: 'enrich', label: 'Enrich', hint: 'Import, export, and merge workflows', icon: GitBranchPlus },
|
||||
@@ -275,19 +280,21 @@ function WorkspaceShell({
|
||||
subtitle,
|
||||
tabs,
|
||||
compact = false,
|
||||
kicker = 'Workspace',
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
tabs?: ReactNode;
|
||||
compact?: boolean;
|
||||
kicker?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="workspace-shell">
|
||||
<header className={`workspace-header${compact ? " workspace-header--compact" : ""}`}>
|
||||
<div className="workspace-header-main">
|
||||
<div className="workspace-kicker">Workspace</div>
|
||||
<div className="workspace-kicker">{kicker}</div>
|
||||
<div className="workspace-title-block">
|
||||
<h1 className="workspace-title">{title}</h1>
|
||||
{subtitle ? <div className="workspace-subtitle">{subtitle}</div> : null}
|
||||
@@ -309,6 +316,7 @@ export default function App() {
|
||||
const [exploreView, setExploreView] = useState<ExploreView>('graph');
|
||||
const [analyzeView, setAnalyzeView] = useState<AnalyzeView>('reasoning');
|
||||
const [enrichView, setEnrichView] = useState<EnrichView>('import');
|
||||
const [manageView, setManageView] = useState<ManageView>('lineage');
|
||||
|
||||
const renderWorkspace = () => {
|
||||
if (activeWorkspace === 'explore') {
|
||||
@@ -316,6 +324,7 @@ export default function App() {
|
||||
<WorkspaceShell
|
||||
title="Explore"
|
||||
subtitle={exploreView === 'graph' ? undefined : "Browse the graph and switch views without leaving the workspace."}
|
||||
kicker={exploreView === 'graph' ? 'Graph Studio' : 'Vocabulary Browser'}
|
||||
compact
|
||||
tabs={
|
||||
<>
|
||||
@@ -340,6 +349,7 @@ export default function App() {
|
||||
<WorkspaceShell
|
||||
title="Analyze"
|
||||
subtitle="Query the active graph and test inference rules."
|
||||
kicker={analyzeView === 'reasoning' ? 'Reasoning Engine' : 'SPARQL Query'}
|
||||
tabs={
|
||||
<>
|
||||
<button className="workspace-tab" data-active={analyzeView === 'reasoning'} onClick={() => setAnalyzeView('reasoning')}>
|
||||
@@ -363,6 +373,7 @@ export default function App() {
|
||||
<WorkspaceShell
|
||||
title="Decisions"
|
||||
subtitle="Inspect decision chains, causal context, and precedent matches."
|
||||
kicker="Decision Intelligence"
|
||||
>
|
||||
<Suspense fallback={<WorkspaceFallback />}>
|
||||
<DecisionWorkspace />
|
||||
@@ -375,7 +386,8 @@ export default function App() {
|
||||
return (
|
||||
<WorkspaceShell
|
||||
title="Enrich"
|
||||
subtitle="Import, export, and reconcile graph entities."
|
||||
subtitle="Import, export, reconcile, and audit graph entities."
|
||||
kicker="Knowledge Audit"
|
||||
tabs={
|
||||
<>
|
||||
<button className="workspace-tab" data-active={enrichView === 'import'} onClick={() => setEnrichView('import')}>
|
||||
@@ -384,11 +396,20 @@ export default function App() {
|
||||
<button className="workspace-tab" data-active={enrichView === 'merge'} onClick={() => setEnrichView('merge')}>
|
||||
Diff and Merge
|
||||
</button>
|
||||
<button className="workspace-tab" data-active={enrichView === 'resolve'} onClick={() => setEnrichView('resolve')}>
|
||||
Entity Resolution
|
||||
</button>
|
||||
<button className="workspace-tab" data-active={enrichView === 'registry'} onClick={() => setEnrichView('registry')}>
|
||||
Registry
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Suspense fallback={<WorkspaceFallback />}>
|
||||
{enrichView === 'import' ? <ImportExportWorkspace /> : <DiffMergeWorkspace />}
|
||||
{enrichView === 'import' ? <ImportExportWorkspace /> :
|
||||
enrichView === 'merge' ? <DiffMergeWorkspace /> :
|
||||
enrichView === 'resolve' ? <EntityResolutionTab /> :
|
||||
<RegistryTab />}
|
||||
</Suspense>
|
||||
</WorkspaceShell>
|
||||
);
|
||||
@@ -397,10 +418,29 @@ export default function App() {
|
||||
return (
|
||||
<WorkspaceShell
|
||||
title="Manage"
|
||||
subtitle="Review provenance, lineage, and governance context."
|
||||
subtitle="Review provenance, lineage, ontology, and governance context."
|
||||
kicker="Graph Governance"
|
||||
tabs={
|
||||
<>
|
||||
<button className="workspace-tab" data-active={manageView === 'lineage'} onClick={() => setManageView('lineage')}>
|
||||
PROV-O Lineage
|
||||
</button>
|
||||
<button className="workspace-tab" data-active={manageView === 'kg-overview'} onClick={() => setManageView('kg-overview')}>
|
||||
KG Overview
|
||||
</button>
|
||||
<button className="workspace-tab" data-active={manageView === 'ontology'} onClick={() => setManageView('ontology')}>
|
||||
Ontology Summary
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Suspense fallback={<WorkspaceFallback />}>
|
||||
<LineageDiagram />
|
||||
{manageView === 'lineage' ? <LineageDiagram /> :
|
||||
manageView === 'kg-overview' ? <KGOverviewTab /> :
|
||||
<OntologySummaryTab onOpenVocabularyBrowser={() => {
|
||||
setActiveWorkspace('explore');
|
||||
setExploreView('vocabulary');
|
||||
}} />}
|
||||
</Suspense>
|
||||
</WorkspaceShell>
|
||||
);
|
||||
@@ -411,7 +451,7 @@ export default function App() {
|
||||
<style>{shellStyles}</style>
|
||||
<div className="app-shell">
|
||||
<aside className="app-rail">
|
||||
<div className="brand-pill">SEM</div>
|
||||
<div className="brand-pill" title="Semantica Knowledge Explorer">SKE</div>
|
||||
{navItems.map(({ id, label, hint, icon: Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* src/store/registryStore.ts
|
||||
*
|
||||
* Lightweight client-side audit log for all KG / Ontology mutations.
|
||||
* No backend required — events are dispatched by each workspace after
|
||||
* a successful API call or WebSocket mutation.
|
||||
*
|
||||
* Any component can call logEvent() from anywhere (including non-React code).
|
||||
* React components subscribe via the useRegistry() hook.
|
||||
*/
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export type RegistryEntryOp =
|
||||
| "import"
|
||||
| "export"
|
||||
| "merge"
|
||||
| "add-node"
|
||||
| "add-edge"
|
||||
| "delete"
|
||||
| "infer"
|
||||
| "vocab-import";
|
||||
|
||||
export interface RegistryEntry {
|
||||
id: string;
|
||||
op: RegistryEntryOp;
|
||||
timestamp: Date;
|
||||
summary: string;
|
||||
detail?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
type Listener = (entries: readonly RegistryEntry[]) => void;
|
||||
|
||||
let _entries: RegistryEntry[] = [];
|
||||
const _listeners = new Set<Listener>();
|
||||
const MAX_ENTRIES = 500;
|
||||
|
||||
function _notify(): void {
|
||||
_listeners.forEach((fn) => fn(_entries));
|
||||
}
|
||||
|
||||
export function logEvent(
|
||||
op: RegistryEntryOp,
|
||||
summary: string,
|
||||
detail?: Record<string, unknown>,
|
||||
): void {
|
||||
const entry: RegistryEntry = {
|
||||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||
op,
|
||||
timestamp: new Date(),
|
||||
summary,
|
||||
detail,
|
||||
};
|
||||
_entries = [entry, ..._entries].slice(0, MAX_ENTRIES);
|
||||
_notify();
|
||||
}
|
||||
|
||||
export function clearRegistry(): void {
|
||||
_entries = [];
|
||||
_notify();
|
||||
}
|
||||
|
||||
export function getRegistryEntries(): readonly RegistryEntry[] {
|
||||
return _entries;
|
||||
}
|
||||
|
||||
export function useRegistry(): readonly RegistryEntry[] {
|
||||
const [snapshot, setSnapshot] = useState<readonly RegistryEntry[]>(_entries);
|
||||
useEffect(() => {
|
||||
// Sync any events that arrived between render and subscribe
|
||||
setSnapshot(_entries);
|
||||
_listeners.add(setSnapshot);
|
||||
return () => {
|
||||
_listeners.delete(setSnapshot);
|
||||
};
|
||||
}, []);
|
||||
return snapshot;
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
/**
|
||||
* src/workspaces/DecisionWorkspace/DecisionWorkspace.tsx
|
||||
*/
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { Scale, Search } from "lucide-react";
|
||||
|
||||
const THEME_CSS = `
|
||||
.glass-panel {
|
||||
background: linear-gradient(135deg, rgba(13,17,23,0.75), rgba(22,27,34,0.6));
|
||||
backdrop-filter: blur(16px) saturate(1.2);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(1.2);
|
||||
border: 1px solid rgba(88,166,255,0.2);
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5), inset 1px 1px 0 rgba(255,255,255,0.05);
|
||||
}
|
||||
|
||||
@keyframes skeleton-shimmer {
|
||||
0% { opacity: 0.45; }
|
||||
50% { opacity: 0.85; }
|
||||
100% { opacity: 0.45; }
|
||||
}
|
||||
.skeleton-item {
|
||||
border-radius: 8px;
|
||||
background: rgba(255,255,255,0.05);
|
||||
animation: skeleton-shimmer 1.4s ease-in-out infinite;
|
||||
}
|
||||
`;
|
||||
|
||||
type OutcomeKind = "approved" | "rejected" | "deferred" | "pending" | string;
|
||||
|
||||
function outcomeStyle(outcome: string): { color: string; bg: string; border: string } {
|
||||
const lower = (outcome ?? "").toLowerCase();
|
||||
if (lower.includes("approv") || lower.includes("accept"))
|
||||
return { color: "#4cc38a", bg: "rgba(76,195,138,0.12)", border: "rgba(76,195,138,0.28)" };
|
||||
if (lower.includes("reject") || lower.includes("denied") || lower.includes("fail"))
|
||||
return { color: "#ff7b72", bg: "rgba(255,123,114,0.12)", border: "rgba(255,123,114,0.28)" };
|
||||
if (lower.includes("defer") || lower.includes("pending") || lower.includes("review"))
|
||||
return { color: "#f2b66d", bg: "rgba(242,182,109,0.12)", border: "rgba(242,182,109,0.28)" };
|
||||
return { color: "#8fa8c6", bg: "rgba(143,168,198,0.08)", border: "rgba(143,168,198,0.18)" };
|
||||
}
|
||||
|
||||
function OutcomeBadge({ outcome }: { outcome: OutcomeKind }) {
|
||||
const style = outcomeStyle(outcome);
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
padding: "2px 8px",
|
||||
borderRadius: 999,
|
||||
fontSize: 10,
|
||||
fontWeight: 800,
|
||||
letterSpacing: "0.06em",
|
||||
textTransform: "uppercase",
|
||||
color: style.color,
|
||||
background: style.bg,
|
||||
border: `1px solid ${style.border}`,
|
||||
}}
|
||||
>
|
||||
{outcome || "unknown"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SkeletonList() {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="skeleton-item" style={{ height: 62 }} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Causal Flow Diagram ──────────────────────────────────────────── */
|
||||
|
||||
interface ChainStep {
|
||||
id: string;
|
||||
relationship: string;
|
||||
content?: string;
|
||||
type?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function RelationshipPill({ label }: { label: string }) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 0, position: "relative", margin: "0 auto" }}>
|
||||
{/* Connector line top */}
|
||||
<div style={{ width: 2, height: 12, background: "rgba(88,166,255,0.25)" }} />
|
||||
{/* Pill */}
|
||||
<div
|
||||
style={{
|
||||
padding: "3px 10px",
|
||||
borderRadius: 999,
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.06em",
|
||||
textTransform: "uppercase",
|
||||
color: "#79c0ff",
|
||||
background: "rgba(88,166,255,0.1)",
|
||||
border: "1px solid rgba(88,166,255,0.22)",
|
||||
whiteSpace: "nowrap",
|
||||
maxWidth: 260,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
title={label}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
{/* Connector line bottom + arrow */}
|
||||
<div style={{ width: 2, height: 10, background: "rgba(88,166,255,0.25)" }} />
|
||||
<div style={{ width: 0, height: 0, borderLeft: "5px solid transparent", borderRight: "5px solid transparent", borderTop: "6px solid rgba(88,166,255,0.4)" }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChainNodeCard({ step, index }: { step: ChainStep; index: number }) {
|
||||
const COLORS = ["#3E79F2", "#149287", "#2F9F61", "#555FD6", "#8A56D8", "#B65473", "#C9922E", "#4aa3ff"];
|
||||
const color = COLORS[index % COLORS.length];
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
padding: "14px 16px",
|
||||
borderRadius: 12,
|
||||
background: "linear-gradient(135deg, rgba(13,17,23,0.75), rgba(22,27,34,0.5))",
|
||||
border: `1px solid ${color}33`,
|
||||
boxShadow: `0 0 0 1px ${color}11, inset 0 1px 0 rgba(255,255,255,0.04)`,
|
||||
borderLeft: `3px solid ${color}`,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 4 }}>
|
||||
<span
|
||||
style={{
|
||||
width: 8, height: 8, borderRadius: "50%",
|
||||
background: color,
|
||||
boxShadow: `0 0 8px ${color}`,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
{step.type ? (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10, fontWeight: 700, letterSpacing: "0.06em",
|
||||
textTransform: "uppercase", color,
|
||||
}}
|
||||
>
|
||||
{step.type}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div style={{ color: "#e6edf3", fontSize: 14, fontWeight: 600 }}>
|
||||
{step.content || step.id}
|
||||
</div>
|
||||
{step.id && step.id !== step.content ? (
|
||||
<div style={{ color: "#6a7f97", fontSize: 11, fontFamily: "monospace", marginTop: 3 }}>{step.id}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CausalFlowDiagram({ chain, loading }: { chain: ChainStep[]; loading: boolean }) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="skeleton-item" style={{ height: 68 }} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (chain.length === 0) {
|
||||
return (
|
||||
<div style={{ textAlign: "center", padding: "40px 24px", color: "#8b949e", fontSize: 13 }}>
|
||||
No causal chain steps found for this decision.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "stretch" }}>
|
||||
{chain.map((step, index) => (
|
||||
<div key={`${step.id}-${index}`} style={{ display: "flex", flexDirection: "column" }}>
|
||||
<ChainNodeCard step={step} index={index} />
|
||||
{index < chain.length - 1 ? (
|
||||
<RelationshipPill label={chain[index + 1]?.relationship || "→"} />
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Main Workspace ──────────────────────────────────────────────── */
|
||||
|
||||
export function DecisionWorkspace() {
|
||||
const [decisions, setDecisions] = useState<any[]>([]);
|
||||
const [selectedDecision, setSelectedDecision] = useState<any | null>(null);
|
||||
const [chain, setChain] = useState<ChainStep[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [listLoading, setListLoading] = useState(true);
|
||||
const [filterQuery, setFilterQuery] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setListLoading(true);
|
||||
fetch("/api/decisions")
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
setDecisions(data);
|
||||
if (data.length > 0) void handleSelectDecision(data[0]);
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setListLoading(false));
|
||||
}, []);
|
||||
|
||||
const filteredDecisions = useMemo(() => {
|
||||
if (!filterQuery.trim()) return decisions;
|
||||
const q = filterQuery.toLowerCase();
|
||||
return decisions.filter(
|
||||
(d) =>
|
||||
String(d.decision_id ?? "").toLowerCase().includes(q) ||
|
||||
String(d.category ?? "").toLowerCase().includes(q) ||
|
||||
String(d.outcome ?? "").toLowerCase().includes(q),
|
||||
);
|
||||
}, [decisions, filterQuery]);
|
||||
|
||||
const handleSelectDecision = async (d: any) => {
|
||||
setSelectedDecision(d);
|
||||
setLoading(true);
|
||||
setChain([]);
|
||||
try {
|
||||
const res = await fetch(`/api/decisions/${d.decision_id}/chain`);
|
||||
const data = await res.json();
|
||||
setChain(data.chain || []);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", width: "100%", height: "100%", background: "#0d1117", overflow: "hidden" }}>
|
||||
<style>{THEME_CSS}</style>
|
||||
|
||||
{/* Left Column — Decision List */}
|
||||
<div
|
||||
className="glass-panel"
|
||||
style={{
|
||||
width: 300,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
borderRadius: 0,
|
||||
border: "none",
|
||||
borderRight: "1px solid rgba(88,166,255,0.16)",
|
||||
}}
|
||||
>
|
||||
{/* List header */}
|
||||
<div style={{ padding: "20px 20px 14px", borderBottom: "1px solid rgba(255,255,255,0.06)", flexShrink: 0 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 12 }}>
|
||||
<Scale size={16} color="#4aa3ff" />
|
||||
<h2 style={{ color: "#ebf3ff", margin: 0, fontSize: 15, fontWeight: 700 }}>Decisions</h2>
|
||||
{decisions.length > 0 ? (
|
||||
<span style={{ color: "#6a7f97", fontSize: 11, marginLeft: "auto" }}>{decisions.length}</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Filter input */}
|
||||
<div style={{ position: "relative" }}>
|
||||
<Search
|
||||
size={13}
|
||||
color="#8b949e"
|
||||
style={{ position: "absolute", left: 10, top: "50%", transform: "translateY(-50%)", pointerEvents: "none" }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter decisions…"
|
||||
value={filterQuery}
|
||||
onChange={(e) => setFilterQuery(e.target.value)}
|
||||
style={filterInputStyle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Decision list */}
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: "12px 14px" }}>
|
||||
{listLoading ? (
|
||||
<SkeletonList />
|
||||
) : filteredDecisions.length === 0 ? (
|
||||
<div style={{ color: "#6a7f97", fontSize: 13, textAlign: "center", padding: "32px 12px" }}>
|
||||
{decisions.length === 0 ? "No decisions available." : "No decisions match your filter."}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
{filteredDecisions.map((d) => {
|
||||
const isActive = selectedDecision?.decision_id === d.decision_id;
|
||||
return (
|
||||
<button
|
||||
key={d.decision_id}
|
||||
onClick={() => void handleSelectDecision(d)}
|
||||
style={{
|
||||
textAlign: "left",
|
||||
padding: "10px 12px",
|
||||
borderRadius: 10,
|
||||
cursor: "pointer",
|
||||
background: isActive
|
||||
? "rgba(74,163,255,0.15)"
|
||||
: "rgba(255,255,255,0.02)",
|
||||
border: isActive
|
||||
? "1px solid rgba(74,163,255,0.32)"
|
||||
: "1px solid rgba(255,255,255,0.06)",
|
||||
color: isActive ? "#ffffff" : "#c6d4e3",
|
||||
transition: "all 160ms ease",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 700, fontSize: 13, marginBottom: 4 }}>{d.decision_id}</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6, flexWrap: "wrap" }}>
|
||||
{d.category ? (
|
||||
<span style={{ fontSize: 11, color: "#8b949e" }}>{d.category}</span>
|
||||
) : null}
|
||||
{d.outcome ? <OutcomeBadge outcome={d.outcome} /> : null}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column — Decision Detail */}
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
|
||||
{/* Radial accent */}
|
||||
<div style={{ position: "absolute", inset: 0, background: "radial-gradient(ellipse at top right, rgba(88,166,255,0.04), transparent 55%)", pointerEvents: "none", zIndex: 0 }} />
|
||||
|
||||
{selectedDecision ? (
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: "28px 32px", position: "relative", zIndex: 1 }}>
|
||||
{/* Decision header */}
|
||||
<div style={{ marginBottom: 28 }}>
|
||||
<div style={{ display: "flex", alignItems: "flex-start", gap: 14, flexWrap: "wrap" }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ color: "#8b949e", fontSize: 11, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.07em", marginBottom: 6 }}>
|
||||
Decision ID
|
||||
</div>
|
||||
<h1 style={{ color: "#ffffff", fontSize: 24, fontWeight: 800, letterSpacing: "-0.03em", margin: "0 0 8px 0", wordBreak: "break-word" }}>
|
||||
{selectedDecision.decision_id}
|
||||
</h1>
|
||||
</div>
|
||||
{selectedDecision.outcome ? <OutcomeBadge outcome={selectedDecision.outcome} /> : null}
|
||||
</div>
|
||||
|
||||
{selectedDecision.category ? (
|
||||
<div style={{ display: "inline-flex", alignItems: "center", gap: 6, padding: "4px 10px", borderRadius: 999, background: "rgba(255,255,255,0.04)", border: "1px solid rgba(255,255,255,0.08)", color: "#8b949e", fontSize: 12 }}>
|
||||
{selectedDecision.category}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Causal Chain */}
|
||||
<div className="glass-panel" style={{ padding: 24, borderRadius: 16 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 20 }}>
|
||||
<div style={{ width: 6, height: 6, borderRadius: "50%", background: "linear-gradient(135deg, #4aa3ff, #f2b66d)", boxShadow: "0 0 10px rgba(74,163,255,0.4)" }} />
|
||||
<h3 style={{ color: "#e6edf3", margin: 0, fontSize: 14, fontWeight: 700, letterSpacing: "0.02em" }}>
|
||||
Causal Chain
|
||||
</h3>
|
||||
{chain.length > 0 && !loading ? (
|
||||
<span style={{ color: "#6a7f97", fontSize: 11, marginLeft: "auto" }}>
|
||||
{chain.length} step{chain.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<CausalFlowDiagram chain={chain} loading={loading} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", color: "#8b949e", fontSize: 14 }}>
|
||||
Select a decision to inspect its causal chain.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── styles ─────────────────────────────────────────────────────── */
|
||||
|
||||
const filterInputStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
padding: "7px 10px 7px 30px",
|
||||
background: "rgba(0,0,0,0.25)",
|
||||
border: "1px solid rgba(88,166,255,0.16)",
|
||||
borderRadius: 8,
|
||||
color: "#c6d4e3",
|
||||
fontSize: 12,
|
||||
outline: "none",
|
||||
boxSizing: "border-box",
|
||||
};
|
||||
@@ -2,6 +2,7 @@
|
||||
* src/workspaces/DiffMergeWorkspace/DiffMergeWorkspace.tsx
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { logEvent } from "../../store/registryStore";
|
||||
|
||||
const THEME_CSS = `
|
||||
.glass-panel {
|
||||
@@ -29,6 +30,11 @@ export function DiffMergeWorkspace() {
|
||||
const data = await res.json();
|
||||
if (data.merged_into) {
|
||||
setMsg(`Merge success: redirected ${data.edges_updated} edges to ${data.merged_into}`);
|
||||
logEvent("merge", `Merged ${duplicateId} → ${data.merged_into} · ${data.edges_updated} edges redirected`, {
|
||||
primary: data.merged_into,
|
||||
duplicate: duplicateId,
|
||||
edgesUpdated: data.edges_updated,
|
||||
});
|
||||
} else {
|
||||
setMsg("Merge failed...");
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
/**
|
||||
* src/workspaces/EnrichWorkspace/EntityResolutionTab.tsx
|
||||
*
|
||||
* Entity Resolution — run duplicate detection, review flagged pairs,
|
||||
* perform one-click merges, and view merge history from the Registry.
|
||||
*/
|
||||
import { useState, useCallback } from "react";
|
||||
import { ScanSearch, GitMerge, X, ChevronDown, ChevronRight, Loader2 } from "lucide-react";
|
||||
import { logEvent, useRegistry } from "../../store/registryStore";
|
||||
|
||||
interface DedupPair {
|
||||
a: { id: string; label: string; type: string };
|
||||
b: { id: string; label: string; type: string };
|
||||
score: number;
|
||||
dismissed?: boolean;
|
||||
}
|
||||
|
||||
interface RawDuplicateItem {
|
||||
entity_a?: string | Record<string, unknown>;
|
||||
entity_b?: string | Record<string, unknown>;
|
||||
similarity?: number;
|
||||
score?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function extractId(entity: string | Record<string, unknown> | undefined): string {
|
||||
if (!entity) return "";
|
||||
if (typeof entity === "string") return entity;
|
||||
return String(entity.id ?? entity.text ?? JSON.stringify(entity));
|
||||
}
|
||||
|
||||
function extractLabel(entity: string | Record<string, unknown> | undefined): string {
|
||||
if (!entity) return "";
|
||||
if (typeof entity === "string") return entity;
|
||||
return String(entity.text ?? entity.label ?? entity.content ?? entity.id ?? "");
|
||||
}
|
||||
|
||||
function extractType(entity: string | Record<string, unknown> | undefined): string {
|
||||
if (!entity || typeof entity === "string") return "entity";
|
||||
return String(entity.type ?? "entity");
|
||||
}
|
||||
|
||||
function parseDuplicates(raw: RawDuplicateItem[]): DedupPair[] {
|
||||
return raw.map((item) => ({
|
||||
a: {
|
||||
id: extractId(item.entity_a as string | Record<string, unknown>),
|
||||
label: extractLabel(item.entity_a as string | Record<string, unknown>),
|
||||
type: extractType(item.entity_a as string | Record<string, unknown>),
|
||||
},
|
||||
b: {
|
||||
id: extractId(item.entity_b as string | Record<string, unknown>),
|
||||
label: extractLabel(item.entity_b as string | Record<string, unknown>),
|
||||
type: extractType(item.entity_b as string | Record<string, unknown>),
|
||||
},
|
||||
score: Number(item.similarity ?? item.score ?? 0),
|
||||
}));
|
||||
}
|
||||
|
||||
function ScoreBar({ score }: { score: number }) {
|
||||
const pct = Math.min(100, Math.round(score * 100));
|
||||
const color = score >= 0.9 ? "#ff7b72" : score >= 0.75 ? "#f2b66d" : "#4cc38a";
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<div style={{ flex: 1, height: 4, borderRadius: 999, background: "rgba(255,255,255,0.06)", overflow: "hidden" }}>
|
||||
<div style={{ width: `${pct}%`, height: "100%", borderRadius: 999, background: color, transition: "width 300ms ease" }} />
|
||||
</div>
|
||||
<span style={{ fontSize: 11, fontWeight: 700, color, minWidth: 34, textAlign: "right" }}>
|
||||
{pct}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PairRow({
|
||||
pair,
|
||||
onMerge,
|
||||
onDismiss,
|
||||
}: {
|
||||
pair: DedupPair;
|
||||
onMerge: (primaryId: string, duplicateId: string) => Promise<void>;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [merging, setMerging] = useState(false);
|
||||
|
||||
const handleMerge = async () => {
|
||||
setMerging(true);
|
||||
await onMerge(pair.a.id, pair.b.id);
|
||||
setMerging(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={pairCardStyle}>
|
||||
<div style={{ display: "flex", alignItems: "flex-start", gap: 12 }}>
|
||||
{/* Expand */}
|
||||
<button onClick={() => setExpanded((v) => !v)} style={iconBtnStyle}>
|
||||
{expanded ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
|
||||
</button>
|
||||
|
||||
{/* Entity Labels */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
|
||||
<span style={entityChipStyle}>{pair.a.label || pair.a.id}</span>
|
||||
<span style={{ color: "#f2b66d", fontSize: 12, fontWeight: 700 }}>≈</span>
|
||||
<span style={entityChipStyle}>{pair.b.label || pair.b.id}</span>
|
||||
</div>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<ScoreBar score={pair.score} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: "flex", gap: 6, flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={() => void handleMerge()}
|
||||
disabled={merging}
|
||||
style={{
|
||||
...actionBtnStyle,
|
||||
background: "rgba(76,195,138,0.12)",
|
||||
border: "1px solid rgba(76,195,138,0.28)",
|
||||
color: "#4cc38a",
|
||||
}}
|
||||
>
|
||||
{merging ? <Loader2 size={12} className="animate-spin" /> : <GitMerge size={12} />}
|
||||
<span>Merge</span>
|
||||
</button>
|
||||
<button onClick={onDismiss} style={iconBtnStyle} title="Dismiss">
|
||||
<X size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded diff */}
|
||||
{expanded ? (
|
||||
<div style={{ marginTop: 12, display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
|
||||
{[
|
||||
{ label: "Primary (keep)", entity: pair.a, accentColor: "#4aa3ff" },
|
||||
{ label: "Duplicate (remove)", entity: pair.b, accentColor: "#ff7b72" },
|
||||
].map(({ label, entity, accentColor }) => (
|
||||
<div key={entity.id} style={{ ...diffCardStyle, borderColor: `${accentColor}33` }}>
|
||||
<div style={{ color: accentColor, fontSize: 10, fontWeight: 700, letterSpacing: "0.06em", textTransform: "uppercase", marginBottom: 6 }}>
|
||||
{label}
|
||||
</div>
|
||||
<div style={{ color: "#e6edf3", fontSize: 13, fontWeight: 600 }}>{entity.label || entity.id}</div>
|
||||
<div style={{ color: "#8b949e", fontSize: 11, marginTop: 3 }}>{entity.type}</div>
|
||||
<div style={{ color: "#6a7f97", fontSize: 10, marginTop: 4, fontFamily: "monospace" }}>{entity.id}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EntityResolutionTab() {
|
||||
const [threshold, setThreshold] = useState(0.82);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [pairs, setPairs] = useState<DedupPair[]>([]);
|
||||
const [scanError, setScanError] = useState("");
|
||||
const registryEntries = useRegistry();
|
||||
|
||||
const mergeHistory = registryEntries.filter((e) => e.op === "merge");
|
||||
|
||||
const handleScan = useCallback(async () => {
|
||||
setScanning(true);
|
||||
setScanError("");
|
||||
try {
|
||||
const res = await fetch("/api/enrich/dedup", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ threshold }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error((err as Record<string, string>).detail ?? `Scan failed (${res.status})`);
|
||||
}
|
||||
const data = await res.json();
|
||||
const rawDuplicates: RawDuplicateItem[] = Array.isArray(data.duplicates)
|
||||
? (data.duplicates as RawDuplicateItem[])
|
||||
: [];
|
||||
const parsed = parseDuplicates(rawDuplicates);
|
||||
setPairs(parsed);
|
||||
logEvent("import", `Dedup scan found ${parsed.length} flagged pair${parsed.length !== 1 ? "s" : ""} (threshold ${threshold.toFixed(2)})`, {
|
||||
threshold,
|
||||
flagged: parsed.length,
|
||||
});
|
||||
} catch (err) {
|
||||
setScanError(err instanceof Error ? err.message : "Scan failed");
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
}, [threshold]);
|
||||
|
||||
const handleMerge = useCallback(async (primaryId: string, duplicateId: string) => {
|
||||
try {
|
||||
const res = await fetch("/api/enrich/merge", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ primary_id: primaryId, duplicate_ids: [duplicateId] }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Merge failed (${res.status})`);
|
||||
const data = await res.json();
|
||||
logEvent("merge", `Merged ${duplicateId} → ${primaryId} · ${data.edges_updated ?? 0} edges redirected`, {
|
||||
primary: primaryId,
|
||||
duplicate: duplicateId,
|
||||
edgesUpdated: data.edges_updated,
|
||||
});
|
||||
setPairs((prev) => prev.filter((p) => !(p.a.id === primaryId && p.b.id === duplicateId)));
|
||||
} catch (err) {
|
||||
console.error("[EntityResolution] merge failed", err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDismiss = useCallback((index: number) => {
|
||||
setPairs((prev) => prev.filter((_, i) => i !== index));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={shellStyle}>
|
||||
{/* Header */}
|
||||
<div style={headerStyle}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<ScanSearch size={18} color="#f2b66d" />
|
||||
<div>
|
||||
<div style={{ color: "#ebf3ff", fontSize: 16, fontWeight: 700 }}>Entity Resolution</div>
|
||||
<div style={{ color: "#8b949e", fontSize: 12 }}>Detect and merge duplicate entities in the knowledge graph</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scan controls */}
|
||||
<div style={controlsCardStyle}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 16, flexWrap: "wrap" }}>
|
||||
<div style={{ flex: 1, minWidth: 240 }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 6 }}>
|
||||
<label style={{ color: "#c6d4e3", fontSize: 12, fontWeight: 600 }}>Similarity Threshold</label>
|
||||
<span style={{ color: "#f2b66d", fontSize: 12, fontWeight: 700 }}>{threshold.toFixed(2)}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0.5}
|
||||
max={0.99}
|
||||
step={0.01}
|
||||
value={threshold}
|
||||
onChange={(e) => setThreshold(parseFloat(e.target.value))}
|
||||
style={{ width: "100%", accentColor: "#f2b66d", cursor: "pointer" }}
|
||||
/>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", color: "#6a7f97", fontSize: 10, marginTop: 2 }}>
|
||||
<span>More results (0.50)</span>
|
||||
<span>Fewer, higher confidence (0.99)</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => void handleScan()}
|
||||
disabled={scanning}
|
||||
style={scanBtnStyle}
|
||||
>
|
||||
{scanning ? <Loader2 size={14} className="animate-spin" /> : <ScanSearch size={14} />}
|
||||
<span>{scanning ? "Scanning…" : "Run Dedup Scan"}</span>
|
||||
</button>
|
||||
</div>
|
||||
{scanError ? (
|
||||
<div style={{ color: "#ff7b72", fontSize: 12, marginTop: 8 }}>{scanError}</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: "hidden", display: "flex", gap: 0 }}>
|
||||
{/* Flagged pairs */}
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: "16px 24px", display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
{pairs.length > 0 ? (
|
||||
<>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 4 }}>
|
||||
<div style={{ color: "#8b949e", fontSize: 12, fontWeight: 600 }}>
|
||||
{pairs.length} flagged pair{pairs.length !== 1 ? "s" : ""}
|
||||
</div>
|
||||
<button onClick={() => setPairs([])} style={clearAllBtnStyle}>Clear all</button>
|
||||
</div>
|
||||
{pairs.map((pair, index) => (
|
||||
<PairRow
|
||||
key={`${pair.a.id}:${pair.b.id}`}
|
||||
pair={pair}
|
||||
onMerge={handleMerge}
|
||||
onDismiss={() => handleDismiss(index)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<div style={emptyStateStyle}>
|
||||
<ScanSearch size={36} color="rgba(242,182,109,0.15)" />
|
||||
<div style={{ color: "#8b949e", fontSize: 14, marginTop: 12, fontWeight: 500 }}>
|
||||
No flagged pairs
|
||||
</div>
|
||||
<div style={{ color: "#6a7f97", fontSize: 12, marginTop: 4, textAlign: "center", maxWidth: 280 }}>
|
||||
Set a similarity threshold and run a dedup scan to detect potential duplicates.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Merge history sidebar */}
|
||||
{mergeHistory.length > 0 ? (
|
||||
<div style={historyPanelStyle}>
|
||||
<div style={{ color: "#8b949e", fontSize: 11, fontWeight: 700, letterSpacing: "0.06em", textTransform: "uppercase", marginBottom: 10 }}>
|
||||
Merge History
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
{mergeHistory.map((entry) => (
|
||||
<div key={entry.id} style={historyRowStyle}>
|
||||
<GitMerge size={11} color="#f2b66d" />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ color: "#c6d4e3", fontSize: 11, fontWeight: 500, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{entry.summary}
|
||||
</div>
|
||||
<div style={{ color: "#6a7f97", fontSize: 10 }}>
|
||||
{entry.timestamp.toLocaleTimeString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── styles ─────────────────────────────────────────────────────── */
|
||||
|
||||
const shellStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
background: "#0d1117",
|
||||
overflow: "hidden",
|
||||
};
|
||||
|
||||
const headerStyle: React.CSSProperties = {
|
||||
padding: "20px 24px 16px",
|
||||
borderBottom: "1px solid rgba(88,166,255,0.1)",
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const controlsCardStyle: React.CSSProperties = {
|
||||
margin: "16px 24px",
|
||||
padding: "16px 20px",
|
||||
borderRadius: 14,
|
||||
background: "linear-gradient(135deg, rgba(13,17,23,0.75), rgba(22,27,34,0.6))",
|
||||
border: "1px solid rgba(242,182,109,0.18)",
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const scanBtnStyle: React.CSSProperties = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 7,
|
||||
padding: "10px 18px",
|
||||
borderRadius: 10,
|
||||
background: "linear-gradient(135deg, rgba(242,182,109,0.22), rgba(242,182,109,0.1))",
|
||||
border: "1px solid rgba(242,182,109,0.32)",
|
||||
color: "#f2b66d",
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
cursor: "pointer",
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const pairCardStyle: React.CSSProperties = {
|
||||
padding: "12px 14px",
|
||||
borderRadius: 12,
|
||||
background: "linear-gradient(135deg, rgba(13,17,23,0.6), rgba(22,27,34,0.4))",
|
||||
border: "1px solid rgba(255,255,255,0.07)",
|
||||
};
|
||||
|
||||
const entityChipStyle: React.CSSProperties = {
|
||||
display: "inline-block",
|
||||
padding: "4px 10px",
|
||||
borderRadius: 8,
|
||||
background: "rgba(255,255,255,0.04)",
|
||||
border: "1px solid rgba(255,255,255,0.08)",
|
||||
color: "#e6edf3",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
};
|
||||
|
||||
const actionBtnStyle: React.CSSProperties = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 5,
|
||||
padding: "5px 10px",
|
||||
borderRadius: 8,
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const iconBtnStyle: React.CSSProperties = {
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
color: "#8b949e",
|
||||
cursor: "pointer",
|
||||
padding: 4,
|
||||
borderRadius: 6,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
};
|
||||
|
||||
const diffCardStyle: React.CSSProperties = {
|
||||
padding: "10px 12px",
|
||||
borderRadius: 10,
|
||||
background: "rgba(0,0,0,0.2)",
|
||||
border: "1px solid transparent",
|
||||
};
|
||||
|
||||
const historyPanelStyle: React.CSSProperties = {
|
||||
width: 240,
|
||||
borderLeft: "1px solid rgba(255,255,255,0.06)",
|
||||
padding: "16px 16px",
|
||||
overflowY: "auto",
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const historyRowStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
gap: 7,
|
||||
padding: "8px 0",
|
||||
borderBottom: "1px solid rgba(255,255,255,0.04)",
|
||||
};
|
||||
|
||||
const emptyStateStyle: React.CSSProperties = {
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: 40,
|
||||
minHeight: 200,
|
||||
};
|
||||
|
||||
const clearAllBtnStyle: React.CSSProperties = {
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
color: "#8b949e",
|
||||
fontSize: 12,
|
||||
cursor: "pointer",
|
||||
padding: "2px 6px",
|
||||
borderRadius: 6,
|
||||
};
|
||||
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* src/workspaces/EnrichWorkspace/RegistryTab.tsx
|
||||
*
|
||||
* Document Registry — a live, filterable chronological audit log of every
|
||||
* KG / Ontology mutation that occurred in this session.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { ClipboardList, Filter, Trash2, ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { useRegistry, clearRegistry, type RegistryEntryOp } from "../../store/registryStore";
|
||||
|
||||
const OP_META: Record<
|
||||
RegistryEntryOp,
|
||||
{ label: string; color: string; bg: string; border: string }
|
||||
> = {
|
||||
import: { label: "IMPORT", color: "#4aa3ff", bg: "rgba(74,163,255,0.12)", border: "rgba(74,163,255,0.28)" },
|
||||
export: { label: "EXPORT", color: "#8fa8c6", bg: "rgba(143,168,198,0.08)", border: "rgba(143,168,198,0.18)" },
|
||||
merge: { label: "MERGE", color: "#f2b66d", bg: "rgba(242,182,109,0.12)", border: "rgba(242,182,109,0.28)" },
|
||||
"add-node": { label: "ADD NODE", color: "#4cc38a", bg: "rgba(76,195,138,0.12)", border: "rgba(76,195,138,0.28)" },
|
||||
"add-edge": { label: "ADD EDGE", color: "#4cc38a", bg: "rgba(76,195,138,0.10)", border: "rgba(76,195,138,0.22)" },
|
||||
delete: { label: "DELETE", color: "#ff7b72", bg: "rgba(255,123,114,0.12)", border: "rgba(255,123,114,0.28)" },
|
||||
infer: { label: "INFER", color: "#d2a8ff", bg: "rgba(210,168,255,0.12)", border: "rgba(210,168,255,0.28)" },
|
||||
"vocab-import": { label: "VOCAB", color: "#79c0ff", bg: "rgba(121,192,255,0.12)", border: "rgba(121,192,255,0.28)" },
|
||||
};
|
||||
|
||||
const ALL_OPS: (RegistryEntryOp | "all")[] = [
|
||||
"all", "import", "export", "merge", "add-node", "add-edge", "infer", "delete", "vocab-import",
|
||||
];
|
||||
|
||||
function formatTimestamp(date: Date): string {
|
||||
return date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
}
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
function EntryRow({ entry }: { entry: ReturnType<typeof useRegistry>[number] }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const meta = OP_META[entry.op];
|
||||
const hasDetail = entry.detail && Object.keys(entry.detail).length > 0;
|
||||
|
||||
return (
|
||||
<div style={entryCardStyle}>
|
||||
<div style={{ display: "flex", alignItems: "flex-start", gap: 12 }}>
|
||||
{/* Op Badge */}
|
||||
<span
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
display: "inline-block",
|
||||
padding: "3px 8px",
|
||||
borderRadius: 999,
|
||||
fontSize: 10,
|
||||
fontWeight: 800,
|
||||
letterSpacing: "0.07em",
|
||||
color: meta.color,
|
||||
background: meta.bg,
|
||||
border: `1px solid ${meta.border}`,
|
||||
marginTop: 1,
|
||||
}}
|
||||
>
|
||||
{meta.label}
|
||||
</span>
|
||||
|
||||
{/* Content */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ color: "#e6edf3", fontSize: 13, fontWeight: 500, wordBreak: "break-word" }}>
|
||||
{entry.summary}
|
||||
</div>
|
||||
<div style={{ color: "#8b949e", fontSize: 11, marginTop: 3 }}>
|
||||
{formatDate(entry.timestamp)} · {formatTimestamp(entry.timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expand toggle */}
|
||||
{hasDetail ? (
|
||||
<button
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
title={expanded ? "Collapse details" : "Expand details"}
|
||||
style={expandBtnStyle}
|
||||
>
|
||||
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Expanded detail */}
|
||||
{expanded && hasDetail ? (
|
||||
<pre style={detailPreStyle}>
|
||||
{JSON.stringify(entry.detail, null, 2)}
|
||||
</pre>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RegistryTab() {
|
||||
const entries = useRegistry();
|
||||
const [activeFilter, setActiveFilter] = useState<RegistryEntryOp | "all">("all");
|
||||
|
||||
const filtered = activeFilter === "all"
|
||||
? entries
|
||||
: entries.filter((e) => e.op === activeFilter);
|
||||
|
||||
return (
|
||||
<div style={shellStyle}>
|
||||
{/* Header */}
|
||||
<div style={headerStyle}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<ClipboardList size={18} color="#4aa3ff" />
|
||||
<div>
|
||||
<div style={{ color: "#ebf3ff", fontSize: 16, fontWeight: 700 }}>Document Registry</div>
|
||||
<div style={{ color: "#8b949e", fontSize: 12 }}>
|
||||
Audit log of all KG and Ontology mutations this session
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span style={{ color: "#8fa8c6", fontSize: 12 }}>
|
||||
{entries.length} event{entries.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
{entries.length > 0 ? (
|
||||
<button
|
||||
onClick={clearRegistry}
|
||||
title="Clear all events"
|
||||
style={clearBtnStyle}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
<span>Clear</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter pills */}
|
||||
<div style={filterBarStyle}>
|
||||
<Filter size={13} color="#8fa8c6" />
|
||||
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
|
||||
{ALL_OPS.map((op) => {
|
||||
const isActive = op === activeFilter;
|
||||
const meta = op === "all" ? null : OP_META[op as RegistryEntryOp];
|
||||
return (
|
||||
<button
|
||||
key={op}
|
||||
onClick={() => setActiveFilter(op as typeof activeFilter)}
|
||||
style={{
|
||||
padding: "4px 10px",
|
||||
borderRadius: 999,
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
border: isActive
|
||||
? `1px solid ${meta?.border ?? "rgba(127,208,255,0.35)"}`
|
||||
: "1px solid rgba(255,255,255,0.06)",
|
||||
background: isActive
|
||||
? (meta?.bg ?? "rgba(74,163,255,0.14)")
|
||||
: "transparent",
|
||||
color: isActive
|
||||
? (meta?.color ?? "#8ed3ff")
|
||||
: "#8b949e",
|
||||
transition: "all 140ms ease",
|
||||
}}
|
||||
>
|
||||
{op === "all" ? "All" : (meta?.label ?? op)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feed */}
|
||||
<div style={feedStyle}>
|
||||
{filtered.length === 0 ? (
|
||||
<div style={emptyStateStyle}>
|
||||
<ClipboardList size={36} color="rgba(127,208,255,0.15)" />
|
||||
<div style={{ color: "#8b949e", fontSize: 14, marginTop: 12, fontWeight: 500 }}>
|
||||
No events recorded yet
|
||||
</div>
|
||||
<div style={{ color: "#6a7f97", fontSize: 12, marginTop: 4, textAlign: "center", maxWidth: 300 }}>
|
||||
Import a file, run reasoning, or merge entities to see activity appear here.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((entry) => <EntryRow key={entry.id} entry={entry} />)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── styles ─────────────────────────────────────────────────────── */
|
||||
|
||||
const shellStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
background: "#0d1117",
|
||||
overflow: "hidden",
|
||||
};
|
||||
|
||||
const headerStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "20px 24px 16px",
|
||||
borderBottom: "1px solid rgba(88,166,255,0.1)",
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const filterBarStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "12px 24px",
|
||||
borderBottom: "1px solid rgba(255,255,255,0.05)",
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const feedStyle: React.CSSProperties = {
|
||||
flex: 1,
|
||||
overflowY: "auto",
|
||||
padding: "16px 24px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
};
|
||||
|
||||
const entryCardStyle: React.CSSProperties = {
|
||||
padding: "12px 14px",
|
||||
borderRadius: 12,
|
||||
background: "linear-gradient(135deg, rgba(13,17,23,0.6), rgba(22,27,34,0.4))",
|
||||
border: "1px solid rgba(255,255,255,0.06)",
|
||||
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.03)",
|
||||
};
|
||||
|
||||
const expandBtnStyle: React.CSSProperties = {
|
||||
flexShrink: 0,
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
color: "#8b949e",
|
||||
cursor: "pointer",
|
||||
padding: 4,
|
||||
borderRadius: 6,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
};
|
||||
|
||||
const detailPreStyle: React.CSSProperties = {
|
||||
marginTop: 10,
|
||||
padding: "10px 12px",
|
||||
borderRadius: 8,
|
||||
background: "rgba(0,0,0,0.28)",
|
||||
border: "1px solid rgba(255,255,255,0.06)",
|
||||
color: "#79c0ff",
|
||||
fontSize: 11,
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
overflowX: "auto",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-all",
|
||||
};
|
||||
|
||||
const clearBtnStyle: React.CSSProperties = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 5,
|
||||
padding: "5px 10px",
|
||||
borderRadius: 8,
|
||||
border: "1px solid rgba(255,123,114,0.22)",
|
||||
background: "rgba(255,123,114,0.06)",
|
||||
color: "#ff7b72",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const emptyStateStyle: React.CSSProperties = {
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: 40,
|
||||
minHeight: 280,
|
||||
};
|
||||
@@ -111,16 +111,20 @@ const FA2_SETTINGS = {
|
||||
|
||||
const SIGMA_SETTINGS = {
|
||||
allowInvalidContainer: true,
|
||||
labelRenderedSizeThreshold: 4,
|
||||
labelRenderedSizeThreshold: 2,
|
||||
defaultNodeType: "circle",
|
||||
defaultEdgeType: "line",
|
||||
hideLabelsOnMove: true,
|
||||
hideEdgesOnMove: true,
|
||||
hideLabelsOnMove: false,
|
||||
hideEdgesOnMove: false,
|
||||
enableEdgeEvents: true,
|
||||
renderEdgeLabels: false,
|
||||
labelDensity: 0.86,
|
||||
labelGridCellSize: 100,
|
||||
renderEdgeLabels: true,
|
||||
edgeLabelSize: 10,
|
||||
edgeLabelColor: { color: "rgba(180, 210, 255, 0.72)" },
|
||||
labelDensity: 1.1,
|
||||
labelGridCellSize: 80,
|
||||
zIndex: true,
|
||||
minCameraRatio: 0.04,
|
||||
maxCameraRatio: 8,
|
||||
webGLTarget: "webgl2" as const,
|
||||
nodeProgramClasses: SEMANTICA_NODE_PROGRAM_CLASSES,
|
||||
edgeProgramClasses: SEMANTICA_EDGE_PROGRAM_CLASSES,
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { graph } from "../../store/graphStore";
|
||||
import { GRAPH_THEME } from "./graphTheme";
|
||||
|
||||
@@ -22,11 +22,13 @@ export interface GraphInspectorPanelProps {
|
||||
predictionType: string;
|
||||
onPredictionTypeChange: (value: string) => void;
|
||||
onRunPredictions: () => void;
|
||||
isRunningPredictions?: boolean;
|
||||
pathTargetId: string;
|
||||
onPathTargetChange: (value: string) => void;
|
||||
onTracePath: () => void;
|
||||
pathResult: PathResponse | null;
|
||||
onDownloadProvenance: (format: "json" | "markdown") => void;
|
||||
onFocusNode?: (nodeId: string) => void;
|
||||
}
|
||||
|
||||
const PROVENANCE_KEYS = ["source", "source_url", "pmid", "pmids", "evidence", "provenance", "confidence"] as const;
|
||||
@@ -37,22 +39,132 @@ function sourceAttribution(properties: Record<string, unknown>) {
|
||||
.map((key) => ({ key, value: properties[key] }));
|
||||
}
|
||||
|
||||
/* ─── Path Flow Visualizer ──────────────────────────────────────── */
|
||||
|
||||
function getNodeLabel(nodeId: string): string {
|
||||
if (!graph.hasNode(nodeId)) return nodeId;
|
||||
const attrs = graph.getNodeAttributes(nodeId) as { label?: string; content?: string };
|
||||
return String(attrs.label ?? attrs.content ?? nodeId);
|
||||
}
|
||||
|
||||
function getEdgeLabelBetween(sourceId: string, targetId: string, edgeIds?: string[]): string {
|
||||
// Try to find the specific edge from edgeIds first
|
||||
if (edgeIds) {
|
||||
for (const edgeId of edgeIds) {
|
||||
if (graph.hasEdge(edgeId)) {
|
||||
const [src, tgt] = graph.extremities(edgeId);
|
||||
if ((src === sourceId && tgt === targetId) || (src === targetId && tgt === sourceId)) {
|
||||
const attrs = graph.getEdgeAttributes(edgeId) as { edgeType?: string };
|
||||
return attrs.edgeType ?? "→";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: find any edge between the pair
|
||||
if (graph.hasNode(sourceId) && graph.hasNode(targetId)) {
|
||||
let label = "→";
|
||||
graph.forEachEdge(sourceId, targetId, (_edgeId, attrs) => {
|
||||
const edgeAttrs = attrs as { edgeType?: string };
|
||||
if (edgeAttrs.edgeType) label = edgeAttrs.edgeType;
|
||||
});
|
||||
return label;
|
||||
}
|
||||
return "→";
|
||||
}
|
||||
|
||||
function PathFlowViz({
|
||||
path,
|
||||
edgeIds,
|
||||
totalWeight,
|
||||
onFocusNode,
|
||||
}: {
|
||||
path: string[];
|
||||
edgeIds?: string[];
|
||||
totalWeight: number;
|
||||
onFocusNode?: (nodeId: string) => void;
|
||||
}) {
|
||||
if (path.length === 0) {
|
||||
return <div style={emptyTextStyle}>No path found between the selected nodes.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Horizontal scrollable chip flow */}
|
||||
<div style={pathFlowContainerStyle}>
|
||||
{path.map((nodeId, index) => {
|
||||
const label = getNodeLabel(nodeId);
|
||||
const edgeLabel =
|
||||
index < path.length - 1
|
||||
? getEdgeLabelBetween(nodeId, path[index + 1], edgeIds)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div key={`${nodeId}-${index}`} style={{ display: "contents" }}>
|
||||
{/* Node chip */}
|
||||
<button
|
||||
onClick={() => onFocusNode?.(nodeId)}
|
||||
title={`Focus: ${nodeId}`}
|
||||
style={{
|
||||
...pathNodeChipStyle,
|
||||
cursor: onFocusNode ? "pointer" : "default",
|
||||
}}
|
||||
>
|
||||
<span style={pathNodeIndexStyle}>{index + 1}</span>
|
||||
<span style={{ maxWidth: 120, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Edge connector */}
|
||||
{edgeLabel !== null ? (
|
||||
<div style={pathEdgeConnectorStyle}>
|
||||
<div style={{ width: 16, height: 1, background: "rgba(88,166,255,0.3)" }} />
|
||||
<span style={pathEdgeLabelStyle}>{edgeLabel}</span>
|
||||
<div style={{ display: "flex", alignItems: "center" }}>
|
||||
<div style={{ width: 12, height: 1, background: "rgba(88,166,255,0.3)" }} />
|
||||
<div style={{ width: 0, height: 0, borderTop: "4px solid transparent", borderBottom: "4px solid transparent", borderLeft: "5px solid rgba(88,166,255,0.4)" }} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Weight badge */}
|
||||
<div style={{ marginTop: 8, display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span style={{ color: "#6a7f97", fontSize: 11 }}>Total weight:</span>
|
||||
<span style={{ color: "#79c0ff", fontSize: 12, fontWeight: 700 }}>{totalWeight.toFixed(3)}</span>
|
||||
<span style={{ color: "#6a7f97", fontSize: 11 }}>·</span>
|
||||
<span style={{ color: "#6a7f97", fontSize: 11 }}>{path.length} hops</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Main Panel ─────────────────────────────────────────────────── */
|
||||
|
||||
export function GraphInspectorPanel({
|
||||
nodeId,
|
||||
predictions,
|
||||
predictionType,
|
||||
onPredictionTypeChange,
|
||||
onRunPredictions,
|
||||
isRunningPredictions = false,
|
||||
pathTargetId,
|
||||
onPathTargetChange,
|
||||
onTracePath,
|
||||
pathResult,
|
||||
onDownloadProvenance,
|
||||
onFocusNode,
|
||||
}: GraphInspectorPanelProps) {
|
||||
if (!nodeId) {
|
||||
return (
|
||||
<div style={{ padding: 32, textAlign: "center" }}>
|
||||
<p style={{ color: "#8b949e", fontSize: 14, margin: 0 }}>
|
||||
<div style={{ padding: 32, textAlign: "center", display: "flex", flexDirection: "column", alignItems: "center", gap: 12, marginTop: 32 }}>
|
||||
<div style={{ width: 40, height: 40, borderRadius: "50%", background: "rgba(74,163,255,0.08)", border: "1px solid rgba(74,163,255,0.14)", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||
<div style={{ width: 14, height: 14, borderRadius: "50%", background: "rgba(127,208,255,0.3)" }} />
|
||||
</div>
|
||||
<p style={{ color: "#8b949e", fontSize: 14, margin: 0, lineHeight: 1.6 }}>
|
||||
Search for a node or click one in the canvas to inspect its properties.
|
||||
</p>
|
||||
</div>
|
||||
@@ -73,41 +185,21 @@ export function GraphInspectorPanel({
|
||||
const accentColor = attributes?.color || "#58a6ff";
|
||||
const propertyEntries = Object.entries(properties).filter(
|
||||
([key]) =>
|
||||
![
|
||||
"x",
|
||||
"y",
|
||||
"valid_from",
|
||||
"valid_until",
|
||||
"content",
|
||||
"source",
|
||||
"source_url",
|
||||
"pmid",
|
||||
"pmids",
|
||||
"evidence",
|
||||
"provenance",
|
||||
"confidence",
|
||||
].includes(key),
|
||||
!["x","y","valid_from","valid_until","content","source","source_url","pmid","pmids","evidence","provenance","confidence"].includes(key),
|
||||
);
|
||||
|
||||
return (
|
||||
<aside style={{ padding: 24, display: "flex", flexDirection: "column", gap: 18 }}>
|
||||
{/* Node identity */}
|
||||
<div style={{ borderBottom: "1px solid rgba(88, 166, 255, 0.2)", paddingBottom: 16 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 8 }}>
|
||||
<span
|
||||
style={{
|
||||
background: accentColor,
|
||||
boxShadow: `0 0 10px ${accentColor}`,
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: "50%",
|
||||
}}
|
||||
/>
|
||||
<span style={{ background: accentColor, boxShadow: `0 0 10px ${accentColor}`, width: 8, height: 8, borderRadius: "50%" }} />
|
||||
<span style={{ color: accentColor, fontSize: 12, fontWeight: 700 }}>{attributes?.nodeType || "Entity"}</span>
|
||||
</div>
|
||||
<h3 style={{ margin: 0, color: "#fff", fontSize: 20, fontWeight: 700, wordBreak: "break-word" }}>
|
||||
{String(attributes?.label ?? nodeId)}
|
||||
</h3>
|
||||
<div style={{ color: "#8b949e", fontSize: 12, marginTop: 6 }}>{nodeId}</div>
|
||||
<div style={{ color: "#8b949e", fontSize: 12, marginTop: 6, fontFamily: "monospace", wordBreak: "break-all" }}>{nodeId}</div>
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginTop: 12 }}>
|
||||
{attributes?.valid_from || attributes?.valid_until ? (
|
||||
<span style={subtleChipStyle}>temporal</span>
|
||||
@@ -117,28 +209,27 @@ export function GraphInspectorPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(attributes?.valid_from || attributes?.valid_until) && (
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 12px",
|
||||
background: "rgba(88, 166, 255, 0.08)",
|
||||
border: "1px solid rgba(88, 166, 255, 0.2)",
|
||||
borderRadius: 8,
|
||||
fontSize: 12,
|
||||
color: "#79c0ff",
|
||||
fontFamily: "monospace",
|
||||
}}
|
||||
>
|
||||
{/* Temporal bounds */}
|
||||
{(attributes?.valid_from || attributes?.valid_until) ? (
|
||||
<div style={{ padding: "10px 12px", background: "rgba(88,166,255,0.08)", border: "1px solid rgba(88,166,255,0.2)", borderRadius: 8, fontSize: 12, color: "#79c0ff", fontFamily: "monospace" }}>
|
||||
{attributes?.valid_from ? <div>from: {attributes.valid_from}</div> : null}
|
||||
{attributes?.valid_until ? <div>until: {attributes.valid_until}</div> : null}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{/* Actions */}
|
||||
<section style={sectionStyle}>
|
||||
<div style={sectionTitleStyle}>Actions</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<button style={{ ...actionButtonStyle, width: "100%", justifyContent: "center" }} onClick={onRunPredictions}>
|
||||
Run Link Prediction
|
||||
<button
|
||||
style={{ ...actionButtonStyle, width: "100%", justifyContent: "center", opacity: isRunningPredictions ? 0.7 : 1 }}
|
||||
onClick={onRunPredictions}
|
||||
disabled={isRunningPredictions}
|
||||
>
|
||||
{isRunningPredictions ? (
|
||||
<Loader2 size={14} className="animate-spin" style={{ marginRight: 6 }} />
|
||||
) : null}
|
||||
{isRunningPredictions ? "Running…" : "Run Link Prediction"}
|
||||
</button>
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
<button style={secondaryActionButtonStyle} onClick={() => onDownloadProvenance("json")}>
|
||||
@@ -157,6 +248,7 @@ export function GraphInspectorPanel({
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Trace Path */}
|
||||
<section style={sectionStyle}>
|
||||
<div style={sectionTitleStyle}>Trace Path</div>
|
||||
<input
|
||||
@@ -166,20 +258,22 @@ export function GraphInspectorPanel({
|
||||
style={inputStyle}
|
||||
/>
|
||||
<button style={actionButtonStyle} onClick={onTracePath}>Trace Causal Path</button>
|
||||
|
||||
{pathResult?.path?.length ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6, marginTop: 10 }}>
|
||||
{pathResult.path.map((step, index) => (
|
||||
<div key={`${step}-${index}`} style={pathStepStyle}>{index + 1}. {step}</div>
|
||||
))}
|
||||
<div style={{ color: "#79c0ff", fontSize: 12, marginTop: 4 }}>
|
||||
total weight: {pathResult.total_weight.toFixed(3)}
|
||||
</div>
|
||||
</div>
|
||||
<PathFlowViz
|
||||
path={pathResult.path}
|
||||
edgeIds={pathResult.edge_ids}
|
||||
totalWeight={pathResult.total_weight}
|
||||
onFocusNode={onFocusNode}
|
||||
/>
|
||||
) : (
|
||||
<div style={emptyTextStyle}>Choose a target or click a candidate prediction to prepare a path trace.</div>
|
||||
<div style={emptyTextStyle}>
|
||||
Choose a target or click a candidate prediction to prepare a path trace.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Candidate Links */}
|
||||
<details className="node-panel-collapse" open={predictions.length > 0}>
|
||||
<summary className="node-panel-summary">Candidate Links</summary>
|
||||
<div className="node-panel-body">
|
||||
@@ -191,20 +285,40 @@ export function GraphInspectorPanel({
|
||||
style={predictionCardStyle}
|
||||
onClick={() => onPathTargetChange(prediction.target)}
|
||||
>
|
||||
<div style={{ color: "#fff", fontWeight: 600 }}>{prediction.label || prediction.target}</div>
|
||||
<div style={{ color: "#8b949e", fontSize: 12 }}>{prediction.type}</div>
|
||||
<div style={{ color: "#58a6ff", fontSize: 12, marginTop: 4 }}>
|
||||
confidence {prediction.score.toFixed(3)}
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 8 }}>
|
||||
<div>
|
||||
<div style={{ color: "#fff", fontWeight: 600 }}>{prediction.label || prediction.target}</div>
|
||||
<div style={{ color: "#8b949e", fontSize: 12 }}>{prediction.type}</div>
|
||||
</div>
|
||||
<div style={{ flexShrink: 0 }}>
|
||||
<div style={{
|
||||
padding: "2px 7px",
|
||||
borderRadius: 999,
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
background: "rgba(88,166,255,0.12)",
|
||||
border: "1px solid rgba(88,166,255,0.22)",
|
||||
color: "#58a6ff",
|
||||
}}>
|
||||
{(prediction.score * 100).toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : isRunningPredictions ? (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, padding: 8, color: "#8b949e", fontSize: 12 }}>
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
<span>Computing candidate links…</span>
|
||||
</div>
|
||||
) : (
|
||||
<div style={emptyTextStyle}>Run link prediction to surface likely next-hop relationships.</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{/* Source Attribution */}
|
||||
<details className="node-panel-collapse">
|
||||
<summary className="node-panel-summary">Source Attribution</summary>
|
||||
<div className="node-panel-body">
|
||||
@@ -212,7 +326,7 @@ export function GraphInspectorPanel({
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{attribution.map(({ key, value }) => (
|
||||
<div key={key} style={propertyCardStyle}>
|
||||
<div style={{ color: "rgba(88, 166, 255, 0.7)", fontSize: 11, marginBottom: 4 }}>{key}</div>
|
||||
<div style={{ color: "rgba(88,166,255,0.7)", fontSize: 11, marginBottom: 4 }}>{key}</div>
|
||||
<div style={{ color: "#e6edf3", fontSize: 13, wordBreak: "break-word" }}>
|
||||
{typeof value === "object" ? JSON.stringify(value) : String(value)}
|
||||
</div>
|
||||
@@ -225,6 +339,7 @@ export function GraphInspectorPanel({
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{/* Properties */}
|
||||
<details className="node-panel-collapse">
|
||||
<summary className="node-panel-summary">Properties</summary>
|
||||
<div className="node-panel-body">
|
||||
@@ -232,7 +347,7 @@ export function GraphInspectorPanel({
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{propertyEntries.map(([key, value]) => (
|
||||
<div key={key} style={propertyCardStyle}>
|
||||
<div style={{ color: "rgba(88, 166, 255, 0.7)", fontSize: 11, marginBottom: 4 }}>{key}</div>
|
||||
<div style={{ color: "rgba(88,166,255,0.7)", fontSize: 11, marginBottom: 4 }}>{key}</div>
|
||||
<div style={{ color: "#e6edf3", fontSize: 13, wordBreak: "break-word" }}>
|
||||
{typeof value === "object" ? JSON.stringify(value) : String(value)}
|
||||
</div>
|
||||
@@ -248,6 +363,8 @@ export function GraphInspectorPanel({
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── styles ─────────────────────────────────────────────────────── */
|
||||
|
||||
const inputStyle: CSSProperties = {
|
||||
width: "100%",
|
||||
background: "rgba(4, 10, 18, 0.5)",
|
||||
@@ -284,19 +401,12 @@ const secondaryActionButtonStyle: CSSProperties = {
|
||||
|
||||
const predictionCardStyle: CSSProperties = {
|
||||
textAlign: "left",
|
||||
padding: 12,
|
||||
padding: "10px 12px",
|
||||
background: "rgba(88, 166, 255, 0.08)",
|
||||
border: "1px solid rgba(88, 166, 255, 0.12)",
|
||||
borderRadius: 10,
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const pathStepStyle: CSSProperties = {
|
||||
color: "#e6edf3",
|
||||
fontSize: 13,
|
||||
padding: "8px 10px",
|
||||
background: "rgba(255, 255, 255, 0.03)",
|
||||
borderRadius: 8,
|
||||
width: "100%",
|
||||
};
|
||||
|
||||
const propertyCardStyle: CSSProperties = {
|
||||
@@ -338,3 +448,58 @@ const sectionTitleStyle: CSSProperties = {
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.08em",
|
||||
};
|
||||
|
||||
const pathFlowContainerStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 0,
|
||||
flexWrap: "wrap",
|
||||
rowGap: 8,
|
||||
};
|
||||
|
||||
const pathNodeChipStyle: CSSProperties = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "5px 10px",
|
||||
borderRadius: 999,
|
||||
background: "rgba(88,166,255,0.1)",
|
||||
border: "1px solid rgba(88,166,255,0.22)",
|
||||
color: "#e6edf3",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
maxWidth: 160,
|
||||
};
|
||||
|
||||
const pathNodeIndexStyle: CSSProperties = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: "50%",
|
||||
background: "rgba(88,166,255,0.22)",
|
||||
color: "#79c0ff",
|
||||
fontSize: 9,
|
||||
fontWeight: 800,
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const pathEdgeConnectorStyle: CSSProperties = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 2,
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const pathEdgeLabelStyle: CSSProperties = {
|
||||
fontSize: 9,
|
||||
fontWeight: 700,
|
||||
color: "#6a7f97",
|
||||
letterSpacing: "0.04em",
|
||||
textTransform: "uppercase",
|
||||
maxWidth: 70,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { batchMergeEdges, batchMergeNodes, graph } from "../../store/graphStore";
|
||||
import { logEvent } from "../../store/registryStore";
|
||||
import type { EdgeAttributes, NodeAttributes } from "../../store/graphStore";
|
||||
import { curveGroupForPair } from "../../store/edgePairKeys.js";
|
||||
import { InspectorPanel, MetricChip, SurfaceCard } from "../../ui/primitives";
|
||||
@@ -630,6 +631,7 @@ export function GraphWorkspace() {
|
||||
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
|
||||
const [searchError, setSearchError] = useState("");
|
||||
const [predictionType, setPredictionType] = useState("");
|
||||
const [isRunningPredictions, setIsRunningPredictions] = useState(false);
|
||||
const [predictions, setPredictions] = useState<LinkPrediction[]>([]);
|
||||
const [pathTargetId, setPathTargetId] = useState("");
|
||||
const [pathResult, setPathResult] = useState<PathResponse | null>(null);
|
||||
@@ -899,6 +901,7 @@ export function GraphWorkspace() {
|
||||
attributes: buildRealtimeNodeAttributes(payload),
|
||||
},
|
||||
]);
|
||||
logEvent("add-node", `Added node ${payload.label ?? payload.id}${payload.nodeType ? ` (${payload.nodeType})` : ""} via realtime ws`, { nodeId: payload.id, nodeType: payload.nodeType });
|
||||
sceneRef.current?.getRuntime()?.requestRender();
|
||||
}
|
||||
if (eventType === "ADD_EDGE") {
|
||||
@@ -911,6 +914,7 @@ export function GraphWorkspace() {
|
||||
attributes: buildRealtimeEdgeAttributes(payload),
|
||||
},
|
||||
]);
|
||||
logEvent("add-edge", `Added edge ${payload.edgeType ?? payload.id} (${payload.source_id} → ${payload.target_id}) via realtime ws`, { edgeId: payload.id, edgeType: payload.edgeType, source: payload.source_id, target: payload.target_id });
|
||||
sceneRef.current?.getRuntime()?.requestRender();
|
||||
}
|
||||
} catch (socketError) {
|
||||
@@ -1293,10 +1297,34 @@ export function GraphWorkspace() {
|
||||
disabled: showLoadingOverlay || !searchQuery.trim(),
|
||||
onClick: () => void handleSearch(),
|
||||
},
|
||||
{
|
||||
id: "zoom-in",
|
||||
label: "+ Zoom In",
|
||||
title: "Zoom in (or scroll up on the canvas)",
|
||||
onClick: () => {
|
||||
const runtime = sceneRef.current?.getRuntime();
|
||||
if (runtime?.renderer === "sigma") {
|
||||
const camera = (runtime.scene as import("sigma").default).getCamera();
|
||||
camera.animatedZoom({ duration: 200 });
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "zoom-out",
|
||||
label: "- Zoom Out",
|
||||
title: "Zoom out (or scroll down on the canvas)",
|
||||
onClick: () => {
|
||||
const runtime = sceneRef.current?.getRuntime();
|
||||
if (runtime?.renderer === "sigma") {
|
||||
const camera = (runtime.scene as import("sigma").default).getCamera();
|
||||
camera.animatedUnzoom({ duration: 200 });
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "fit-view",
|
||||
label: "Fit View",
|
||||
title: "Reset the camera to the current view",
|
||||
title: "Reset the camera to fit the whole graph",
|
||||
onClick: () => sceneRef.current?.fitView(),
|
||||
},
|
||||
{
|
||||
@@ -265,16 +265,16 @@ export const GRAPH_THEME: GraphTheme = {
|
||||
],
|
||||
overview: {
|
||||
nodeBase: "#0B1320",
|
||||
nodeCore: "#435D7A",
|
||||
nodeCore: "#5A7A9E",
|
||||
nodeMuted: "#121927",
|
||||
nodeBorder: "#64758C",
|
||||
nodeTintMix: 0.03,
|
||||
nodeCoreMix: 0.52,
|
||||
nodeBorder: "#7A92AE",
|
||||
nodeTintMix: 0.14,
|
||||
nodeCoreMix: 0.72,
|
||||
nodeShellAlpha: 0.97,
|
||||
nodeCoreAlpha: 1,
|
||||
edgeBackbone: "rgba(83, 111, 148, 0.04)",
|
||||
edgeStructure: "rgba(72, 90, 118, 0.009)",
|
||||
edgeInspection: "rgba(98, 120, 148, 0.026)",
|
||||
edgeBackbone: "rgba(100, 148, 210, 0.38)",
|
||||
edgeStructure: "rgba(88, 140, 200, 0.28)",
|
||||
edgeInspection: "rgba(110, 165, 230, 0.48)",
|
||||
},
|
||||
accent: {
|
||||
selected: "#F2D288",
|
||||
@@ -285,12 +285,12 @@ export const GRAPH_THEME: GraphTheme = {
|
||||
inferred: "#D07B4D",
|
||||
},
|
||||
muted: {
|
||||
fallback: "rgba(96, 112, 136, 0.1)",
|
||||
nodeAlpha: 0.085,
|
||||
edgeOverview: "rgba(82, 100, 124, 0.009)",
|
||||
edgeStructure: "rgba(92, 112, 138, 0.02)",
|
||||
edgeInspection: "rgba(124, 148, 176, 0.066)",
|
||||
edgeFocus: "rgba(160, 186, 218, 0.16)",
|
||||
fallback: "rgba(96, 112, 136, 0.18)",
|
||||
nodeAlpha: 0.12,
|
||||
edgeOverview: "rgba(82, 100, 124, 0.12)",
|
||||
edgeStructure: "rgba(92, 112, 138, 0.18)",
|
||||
edgeInspection: "rgba(124, 148, 176, 0.26)",
|
||||
edgeFocus: "rgba(160, 186, 218, 0.42)",
|
||||
},
|
||||
background: {
|
||||
canvas: "#07101A",
|
||||
@@ -305,36 +305,36 @@ export const GRAPH_THEME: GraphTheme = {
|
||||
zoomTiers: {
|
||||
overview: {
|
||||
maxRatio: Number.POSITIVE_INFINITY,
|
||||
nodeScale: 0.66,
|
||||
labelThreshold: 0.985,
|
||||
labelBudget: 10,
|
||||
edgePriorityThreshold: 0.72,
|
||||
nodeScale: 0.88,
|
||||
labelThreshold: 0.92,
|
||||
labelBudget: 28,
|
||||
edgePriorityThreshold: 0.55,
|
||||
arrowPriorityThreshold: Number.POSITIVE_INFINITY,
|
||||
edgeSizeScale: 0.34,
|
||||
edgeSizeScale: 0.62,
|
||||
showBadges: false,
|
||||
showCurves: false,
|
||||
showContextualArrows: false,
|
||||
},
|
||||
structure: {
|
||||
maxRatio: 1.2,
|
||||
nodeScale: 0.98,
|
||||
labelThreshold: 0.88,
|
||||
labelBudget: 36,
|
||||
edgePriorityThreshold: 0.4,
|
||||
arrowPriorityThreshold: 0.75,
|
||||
edgeSizeScale: 0.92,
|
||||
nodeScale: 1.02,
|
||||
labelThreshold: 0.82,
|
||||
labelBudget: 60,
|
||||
edgePriorityThreshold: 0.3,
|
||||
arrowPriorityThreshold: 0.65,
|
||||
edgeSizeScale: 1.05,
|
||||
showBadges: true,
|
||||
showCurves: true,
|
||||
showContextualArrows: true,
|
||||
},
|
||||
inspection: {
|
||||
maxRatio: 0.5,
|
||||
nodeScale: 1,
|
||||
labelThreshold: 0.7,
|
||||
labelBudget: 80,
|
||||
nodeScale: 1.08,
|
||||
labelThreshold: 0.6,
|
||||
labelBudget: 120,
|
||||
edgePriorityThreshold: 0,
|
||||
arrowPriorityThreshold: 0.58,
|
||||
edgeSizeScale: 1.04,
|
||||
arrowPriorityThreshold: 0.45,
|
||||
edgeSizeScale: 1.18,
|
||||
showBadges: true,
|
||||
showCurves: true,
|
||||
showContextualArrows: true,
|
||||
@@ -398,13 +398,13 @@ export const GRAPH_THEME: GraphTheme = {
|
||||
inspection: { base: 1.2, emphasis: 1.7, muted: 0.6 },
|
||||
},
|
||||
states: {
|
||||
default: { color: "base", sizeMultiplier: 0.72, minSize: 0.68, forceLabel: false, zIndex: 0, borderBoost: -0.42 },
|
||||
hovered: { color: "hovered", sizeMultiplier: 1.18, minSize: 12.5, forceLabel: true, zIndex: 4, borderBoost: 0.22 },
|
||||
selected: { color: "selected", sizeMultiplier: 1.06, minSize: 10.5, forceLabel: true, zIndex: 3, borderBoost: 0.2 },
|
||||
neighbor: { color: "base", sizeMultiplier: 0.84, minSize: 4.8, forceLabel: true, zIndex: 2, borderBoost: -0.08 },
|
||||
path: { color: "path", sizeMultiplier: 1.01, minSize: 6.2, forceLabel: true, zIndex: 2, borderBoost: 0.08 },
|
||||
inactive: { color: "muted", sizeMultiplier: 0.32, minSize: 0.46, forceLabel: false, zIndex: 0, borderBoost: -0.42 },
|
||||
muted: { color: "muted", sizeMultiplier: 0.32, minSize: 0.46, forceLabel: false, zIndex: 0, borderBoost: -0.42 },
|
||||
default: { color: "base", sizeMultiplier: 0.92, minSize: 3.5, forceLabel: false, zIndex: 0, borderBoost: -0.18 },
|
||||
hovered: { color: "hovered", sizeMultiplier: 1.28, minSize: 13.5, forceLabel: true, zIndex: 4, borderBoost: 0.28 },
|
||||
selected: { color: "selected", sizeMultiplier: 1.14, minSize: 11.5, forceLabel: true, zIndex: 3, borderBoost: 0.24 },
|
||||
neighbor: { color: "base", sizeMultiplier: 0.96, minSize: 5.5, forceLabel: true, zIndex: 2, borderBoost: 0.04 },
|
||||
path: { color: "path", sizeMultiplier: 1.08, minSize: 7.0, forceLabel: true, zIndex: 2, borderBoost: 0.12 },
|
||||
inactive: { color: "muted", sizeMultiplier: 0.48, minSize: 1.8, forceLabel: false, zIndex: 0, borderBoost: -0.28 },
|
||||
muted: { color: "muted", sizeMultiplier: 0.48, minSize: 1.8, forceLabel: false, zIndex: 0, borderBoost: -0.28 },
|
||||
},
|
||||
variants: {
|
||||
default: { sizeMultiplier: 1, borderBoost: 0, haloBoost: 0, badgeVisibleFrom: "inspection" },
|
||||
@@ -437,14 +437,14 @@ export const GRAPH_THEME: GraphTheme = {
|
||||
},
|
||||
edges: {
|
||||
states: {
|
||||
default: { color: "structure", sizeMultiplier: 0.74, minSize: 0.18, zIndex: 0, forceArrow: false, hide: false },
|
||||
backbone: { color: "backbone", sizeMultiplier: 0.72, minSize: 0.18, zIndex: 1, forceArrow: false, hide: false },
|
||||
hovered: { color: "hover", sizeMultiplier: 1.42, minSize: 1.45, zIndex: 3, forceArrow: true, hide: false },
|
||||
selected: { color: "hover", sizeMultiplier: 1.42, minSize: 1.45, zIndex: 3, forceArrow: true, hide: false },
|
||||
neighbor: { color: "focus", sizeMultiplier: 0.92, minSize: 0.5, zIndex: 1, forceArrow: false, hide: false },
|
||||
path: { color: "path", sizeMultiplier: 1.5, minSize: 1.8, zIndex: 4, forceArrow: true, hide: false },
|
||||
inactive: { color: "muted", sizeMultiplier: 1, minSize: 0.16, zIndex: 0, forceArrow: false, hide: true },
|
||||
muted: { color: "muted", sizeMultiplier: 1, minSize: 0.16, zIndex: 0, forceArrow: false, hide: true },
|
||||
default: { color: "structure", sizeMultiplier: 0.96, minSize: 0.9, zIndex: 0, forceArrow: false, hide: false },
|
||||
backbone: { color: "backbone", sizeMultiplier: 1.0, minSize: 1.0, zIndex: 1, forceArrow: false, hide: false },
|
||||
hovered: { color: "hover", sizeMultiplier: 1.6, minSize: 2.2, zIndex: 3, forceArrow: true, hide: false },
|
||||
selected: { color: "hover", sizeMultiplier: 1.6, minSize: 2.2, zIndex: 3, forceArrow: true, hide: false },
|
||||
neighbor: { color: "focus", sizeMultiplier: 1.1, minSize: 1.2, zIndex: 1, forceArrow: false, hide: false },
|
||||
path: { color: "path", sizeMultiplier: 1.7, minSize: 2.4, zIndex: 4, forceArrow: true, hide: false },
|
||||
inactive: { color: "muted", sizeMultiplier: 0.6, minSize: 0.5, zIndex: 0, forceArrow: false, hide: false },
|
||||
muted: { color: "muted", sizeMultiplier: 0.6, minSize: 0.5, zIndex: 0, forceArrow: false, hide: false },
|
||||
},
|
||||
variants: {
|
||||
line: { baseType: "line", arrowPolicy: "hidden", curveStrength: 0, sizeMultiplier: 1, glowAlpha: 0 },
|
||||
@@ -4,6 +4,7 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import { UploadCloud, Download, FileJson, FileText, CheckCircle2, AlertCircle, Loader2 } from "lucide-react";
|
||||
import { logEvent } from "../../store/registryStore";
|
||||
|
||||
const THEME_CSS = `
|
||||
.glass-panel {
|
||||
@@ -107,6 +108,11 @@ export function ImportExportWorkspace() {
|
||||
|
||||
const data = await res.json();
|
||||
showToast("success", `Imported ${data.nodes_imported} nodes and ${data.edges_imported} edges!`);
|
||||
logEvent("import", `Imported ${data.nodes_imported} nodes · ${data.edges_imported} edges from ${file.name}`, {
|
||||
file: file.name,
|
||||
nodesImported: data.nodes_imported,
|
||||
edgesImported: data.edges_imported,
|
||||
});
|
||||
setFile(null);
|
||||
} catch (err: any) {
|
||||
showToast("error", err.message || "An error occurred during import");
|
||||
@@ -142,6 +148,7 @@ export function ImportExportWorkspace() {
|
||||
document.body.removeChild(a);
|
||||
|
||||
showToast("success", "Export complete! Your download should begin shortly.");
|
||||
logEvent("export", `Exported graph as ${exportFormat.toUpperCase()}`, { format: exportFormat });
|
||||
} catch (err: any) {
|
||||
showToast("error", err.message || "An error occurred during export");
|
||||
} finally {
|
||||
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* src/workspaces/ManageWorkspace/KGOverviewTab.tsx
|
||||
*
|
||||
* Quick-view dashboard for the Knowledge Graph: node/edge counts,
|
||||
* type distributions, and top connected nodes.
|
||||
*/
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Network, RefreshCw, Loader2 } from "lucide-react";
|
||||
|
||||
interface KGStats {
|
||||
node_count: number;
|
||||
edge_count: number;
|
||||
node_types?: Record<string, number>;
|
||||
edge_types?: Record<string, number>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface NodeItem {
|
||||
id: string;
|
||||
type: string;
|
||||
content: string;
|
||||
properties?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface NodeListResponse {
|
||||
nodes: NodeItem[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
function TypeBar({ label, count, total, color }: { label: string; count: number; total: number; color: string }) {
|
||||
const pct = total > 0 ? Math.round((count / total) * 100) : 0;
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "5px 0" }}>
|
||||
<div style={{ width: 120, flexShrink: 0, color: "#c6d4e3", fontSize: 12, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={label}>
|
||||
{label}
|
||||
</div>
|
||||
<div style={{ flex: 1, height: 6, borderRadius: 999, background: "rgba(255,255,255,0.06)", overflow: "hidden" }}>
|
||||
<div
|
||||
style={{
|
||||
width: `${pct}%`,
|
||||
height: "100%",
|
||||
borderRadius: 999,
|
||||
background: color,
|
||||
transition: "width 400ms ease",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ width: 52, textAlign: "right", flexShrink: 0, display: "flex", gap: 6, justifyContent: "flex-end" }}>
|
||||
<span style={{ color: "#8b949e", fontSize: 11 }}>{count.toLocaleString()}</span>
|
||||
<span style={{ color: "#6a7f97", fontSize: 11 }}>{pct}%</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const NODE_COLORS = ["#3E79F2", "#149287", "#2F9F61", "#555FD6", "#8A56D8", "#B65473", "#C9922E", "#4aa3ff", "#f2b66d"];
|
||||
const EDGE_COLORS = ["#4cc38a", "#79c0ff", "#d2a8ff", "#f2b66d", "#ff7b72", "#58a6ff", "#4aa3ff", "#8A56D8"];
|
||||
|
||||
function buildTypeMap(nodes: NodeItem[], key: keyof NodeItem): Record<string, number> {
|
||||
const map: Record<string, number> = {};
|
||||
for (const node of nodes) {
|
||||
const val = String(node[key] ?? "unknown");
|
||||
map[val] = (map[val] ?? 0) + 1;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export function KGOverviewTab() {
|
||||
const [stats, setStats] = useState<KGStats | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [topNodes, setTopNodes] = useState<{ node: NodeItem; neighborCount: number }[]>([]);
|
||||
const [nodeTypeMap, setNodeTypeMap] = useState<Record<string, number>>({});
|
||||
|
||||
const fetchOverview = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const [statsRes, nodesRes] = await Promise.all([
|
||||
fetch("/api/graph/stats"),
|
||||
fetch("/api/graph/nodes?limit=500"),
|
||||
]);
|
||||
|
||||
if (statsRes.ok) {
|
||||
const statsData: KGStats = await statsRes.json();
|
||||
setStats(statsData);
|
||||
}
|
||||
|
||||
if (nodesRes.ok) {
|
||||
const nodesData: NodeListResponse = await nodesRes.json();
|
||||
const nodes = nodesData.nodes ?? [];
|
||||
setNodeTypeMap(buildTypeMap(nodes, "type"));
|
||||
|
||||
// Simulate neighbor counts via edges fetch for top-N
|
||||
const edgesRes = await fetch("/api/graph/edges?limit=2000");
|
||||
if (edgesRes.ok) {
|
||||
const edgesData = await edgesRes.json();
|
||||
const edges: { source: string; target: string }[] = edgesData.edges ?? [];
|
||||
const degreeMap: Record<string, number> = {};
|
||||
for (const edge of edges) {
|
||||
degreeMap[edge.source] = (degreeMap[edge.source] ?? 0) + 1;
|
||||
degreeMap[edge.target] = (degreeMap[edge.target] ?? 0) + 1;
|
||||
}
|
||||
const sorted = nodes
|
||||
.map((n) => ({ node: n, neighborCount: degreeMap[n.id] ?? 0 }))
|
||||
.sort((a, b) => b.neighborCount - a.neighborCount)
|
||||
.slice(0, 10);
|
||||
setTopNodes(sorted);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
setError("Failed to load graph overview. Ensure the server is running.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchOverview();
|
||||
}, [fetchOverview]);
|
||||
|
||||
const nodeTypeEntries = Object.entries(nodeTypeMap).sort((a, b) => b[1] - a[1]);
|
||||
const edgeTypeEntries = stats?.edge_types
|
||||
? Object.entries(stats.edge_types).sort((a, b) => b[1] - a[1])
|
||||
: [];
|
||||
|
||||
const totalNodes = stats?.node_count ?? 0;
|
||||
const totalEdges = stats?.edge_count ?? 0;
|
||||
|
||||
return (
|
||||
<div style={shellStyle}>
|
||||
{/* Header */}
|
||||
<div style={headerStyle}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<Network size={18} color="#4aa3ff" />
|
||||
<div>
|
||||
<div style={{ color: "#ebf3ff", fontSize: 16, fontWeight: 700 }}>KG Overview</div>
|
||||
<div style={{ color: "#8b949e", fontSize: 12 }}>Quick view of the Knowledge Graph structure and health</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => void fetchOverview()} disabled={loading} style={refreshBtnStyle}>
|
||||
{loading ? <Loader2 size={13} className="animate-spin" /> : <RefreshCw size={13} />}
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div style={{ margin: "16px 24px", padding: "10px 14px", borderRadius: 10, background: "rgba(255,123,114,0.08)", border: "1px solid rgba(255,123,114,0.2)", color: "#ff7b72", fontSize: 13 }}>
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div style={scrollBodyStyle}>
|
||||
{/* Stats chips */}
|
||||
<div style={statsRowStyle}>
|
||||
{[
|
||||
{ label: "Nodes", value: totalNodes.toLocaleString(), color: "#4aa3ff", sub: `${nodeTypeEntries.length} types` },
|
||||
{ label: "Edges", value: totalEdges.toLocaleString(), color: "#4cc38a", sub: `${edgeTypeEntries.length} relationship types` },
|
||||
{ label: "Density", value: totalNodes > 1 ? ((totalEdges / (totalNodes * (totalNodes - 1))) * 100).toFixed(3) + "%" : "—", color: "#d2a8ff", sub: "graph density" },
|
||||
].map(({ label, value, color, sub }) => (
|
||||
<div key={label} style={statCardStyle}>
|
||||
<div style={{ color: "#8b949e", fontSize: 11, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.06em", marginBottom: 4 }}>{label}</div>
|
||||
<div style={{ color, fontSize: 28, fontWeight: 800, letterSpacing: "-0.04em", lineHeight: 1 }}>{loading ? "—" : value}</div>
|
||||
<div style={{ color: "#6a7f97", fontSize: 11, marginTop: 4 }}>{sub}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Type breakdowns */}
|
||||
<div style={sectionRowStyle}>
|
||||
{/* Node types */}
|
||||
<div style={breakdownCardStyle}>
|
||||
<div style={sectionTitleStyle}>Node Type Breakdown</div>
|
||||
{loading ? (
|
||||
<div style={skeletonWrapStyle}>
|
||||
{[80, 65, 45, 35, 25].map((w, i) => (
|
||||
<div key={i} style={{ ...skeletonBarStyle, width: `${w}%` }} />
|
||||
))}
|
||||
</div>
|
||||
) : nodeTypeEntries.length === 0 ? (
|
||||
<div style={{ color: "#6a7f97", fontSize: 12 }}>No data — load the graph first.</div>
|
||||
) : (
|
||||
nodeTypeEntries.slice(0, 8).map(([type, count], i) => (
|
||||
<TypeBar key={type} label={type} count={count} total={totalNodes || 1} color={NODE_COLORS[i % NODE_COLORS.length]} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Edge types */}
|
||||
<div style={breakdownCardStyle}>
|
||||
<div style={sectionTitleStyle}>Edge Type Breakdown</div>
|
||||
{loading ? (
|
||||
<div style={skeletonWrapStyle}>
|
||||
{[70, 55, 48, 30, 20].map((w, i) => (
|
||||
<div key={i} style={{ ...skeletonBarStyle, width: `${w}%` }} />
|
||||
))}
|
||||
</div>
|
||||
) : edgeTypeEntries.length === 0 ? (
|
||||
<div style={{ color: "#6a7f97", fontSize: 12 }}>Edge type breakdown requires the stats endpoint to return edge_types.</div>
|
||||
) : (
|
||||
edgeTypeEntries.slice(0, 8).map(([type, count], i) => (
|
||||
<TypeBar key={type} label={type} count={count} total={totalEdges || 1} color={EDGE_COLORS[i % EDGE_COLORS.length]} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top connected nodes */}
|
||||
{topNodes.length > 0 ? (
|
||||
<div style={breakdownCardStyle}>
|
||||
<div style={sectionTitleStyle}>Top Connected Nodes (by degree)</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))", gap: 8, marginTop: 2 }}>
|
||||
{topNodes.map(({ node, neighborCount }, rank) => (
|
||||
<div key={node.id} style={topNodeRowStyle}>
|
||||
<div style={{ color: "#6a7f97", fontSize: 12, fontWeight: 700, minWidth: 20 }}>#{rank + 1}</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ color: "#e6edf3", fontSize: 13, fontWeight: 600, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{node.content || node.id}
|
||||
</div>
|
||||
<div style={{ color: "#8b949e", fontSize: 11 }}>{node.type}</div>
|
||||
</div>
|
||||
<div style={{ color: "#4aa3ff", fontSize: 12, fontWeight: 700, flexShrink: 0 }}>
|
||||
{neighborCount} conn.
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── styles ─────────────────────────────────────────────────────── */
|
||||
|
||||
const shellStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
background: "#0d1117",
|
||||
overflow: "hidden",
|
||||
};
|
||||
|
||||
const headerStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "20px 24px 16px",
|
||||
borderBottom: "1px solid rgba(88,166,255,0.1)",
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const refreshBtnStyle: React.CSSProperties = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "6px 12px",
|
||||
borderRadius: 8,
|
||||
border: "1px solid rgba(127,208,255,0.16)",
|
||||
background: "rgba(74,163,255,0.08)",
|
||||
color: "#8fa8c6",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const scrollBodyStyle: React.CSSProperties = {
|
||||
flex: 1,
|
||||
overflowY: "auto",
|
||||
padding: "20px 24px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 16,
|
||||
};
|
||||
|
||||
const statsRowStyle: React.CSSProperties = {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))",
|
||||
gap: 12,
|
||||
};
|
||||
|
||||
const statCardStyle: React.CSSProperties = {
|
||||
padding: "18px 20px",
|
||||
borderRadius: 16,
|
||||
background: "linear-gradient(135deg, rgba(13,17,23,0.8), rgba(22,27,34,0.5))",
|
||||
border: "1px solid rgba(127,208,255,0.1)",
|
||||
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.04)",
|
||||
};
|
||||
|
||||
const sectionRowStyle: React.CSSProperties = {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
gap: 12,
|
||||
};
|
||||
|
||||
const breakdownCardStyle: React.CSSProperties = {
|
||||
padding: "16px 18px",
|
||||
borderRadius: 14,
|
||||
background: "linear-gradient(135deg, rgba(13,17,23,0.7), rgba(22,27,34,0.4))",
|
||||
border: "1px solid rgba(255,255,255,0.06)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
};
|
||||
|
||||
const sectionTitleStyle: React.CSSProperties = {
|
||||
color: "#8b949e",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.07em",
|
||||
marginBottom: 4,
|
||||
};
|
||||
|
||||
const topNodeRowStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "8px 10px",
|
||||
borderRadius: 10,
|
||||
background: "rgba(255,255,255,0.025)",
|
||||
border: "1px solid rgba(255,255,255,0.05)",
|
||||
};
|
||||
|
||||
const skeletonWrapStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
marginTop: 4,
|
||||
};
|
||||
|
||||
const skeletonBarStyle: React.CSSProperties = {
|
||||
height: 12,
|
||||
borderRadius: 999,
|
||||
background: "rgba(255,255,255,0.05)",
|
||||
animation: "skeleton-pulse 1.4s ease-in-out infinite",
|
||||
};
|
||||
@@ -0,0 +1,346 @@
|
||||
/**
|
||||
* src/workspaces/ManageWorkspace/OntologySummaryTab.tsx
|
||||
*
|
||||
* A compact read-only view of all loaded SKOS ConceptSchemes and their
|
||||
* top-level concepts. Clicking a concept deep-links to the Vocabulary Browser.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { BookOpen, ChevronRight, ChevronDown, ExternalLink } from "lucide-react";
|
||||
import { useVocabularies, useConceptHierarchy } from "../VocabularyWorkspace/queries";
|
||||
import type { ConceptNode, VocabularyScheme } from "../VocabularyWorkspace/types";
|
||||
|
||||
function countConcepts(nodes: ConceptNode[]): number {
|
||||
return nodes.reduce((acc, node) => {
|
||||
return acc + 1 + countConcepts(node.children ?? []);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function ConceptRow({
|
||||
concept,
|
||||
depth,
|
||||
onSelect,
|
||||
}: {
|
||||
concept: ConceptNode;
|
||||
depth: number;
|
||||
onSelect: (concept: ConceptNode) => void;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const children = concept.children ?? [];
|
||||
const hasChildren = children.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
paddingLeft: 12 + depth * 16,
|
||||
paddingRight: 12,
|
||||
paddingTop: 5,
|
||||
paddingBottom: 5,
|
||||
borderRadius: 6,
|
||||
cursor: "pointer",
|
||||
color: depth === 0 ? "#c6d4e3" : "#8b949e",
|
||||
fontSize: depth === 0 ? 13 : 12,
|
||||
transition: "background 120ms ease",
|
||||
}}
|
||||
onMouseEnter={(e) => { (e.currentTarget as HTMLDivElement).style.background = "rgba(74,163,255,0.07)"; }}
|
||||
onMouseLeave={(e) => { (e.currentTarget as HTMLDivElement).style.background = "transparent"; }}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<button
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
style={{ background: "transparent", border: "none", color: "#8b949e", cursor: "pointer", padding: 0, display: "flex", alignItems: "center" }}
|
||||
>
|
||||
{expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
</button>
|
||||
) : (
|
||||
<span style={{ width: 12, display: "inline-block" }} />
|
||||
)}
|
||||
<span
|
||||
onClick={() => onSelect(concept)}
|
||||
style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
|
||||
>
|
||||
{concept.pref_label || concept.uri}
|
||||
</span>
|
||||
{children.length > 0 ? (
|
||||
<span style={{ color: "#6a7f97", fontSize: 10 }}>{children.length}</span>
|
||||
) : null}
|
||||
</div>
|
||||
{expanded && hasChildren
|
||||
? children.map((child) => (
|
||||
<ConceptRow key={child.uri} concept={child} depth={depth + 1} onSelect={onSelect} />
|
||||
))
|
||||
: null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SchemePanel({
|
||||
scheme,
|
||||
onSelectConcept,
|
||||
}: {
|
||||
scheme: VocabularyScheme;
|
||||
onSelectConcept: (concept: ConceptNode) => void;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const { data: hierarchy = [], isLoading } = useConceptHierarchy(scheme.uri);
|
||||
const totalConcepts = countConcepts(hierarchy);
|
||||
|
||||
return (
|
||||
<div style={schemeCardStyle}>
|
||||
{/* Scheme header */}
|
||||
<button
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
style={schemeHeaderStyle}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
{expanded ? <ChevronDown size={14} color="#8b949e" /> : <ChevronRight size={14} color="#8b949e" />}
|
||||
<span style={{ color: "#e6edf3", fontSize: 14, fontWeight: 700 }}>{scheme.label}</span>
|
||||
</div>
|
||||
<span style={{ color: "#6a7f97", fontSize: 11 }}>
|
||||
{isLoading ? "…" : `${totalConcepts} concept${totalConcepts !== 1 ? "s" : ""}`}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Concept tree */}
|
||||
{expanded ? (
|
||||
<div style={{ paddingTop: 4, paddingBottom: 8 }}>
|
||||
{isLoading ? (
|
||||
<div style={{ padding: "8px 24px", color: "#6a7f97", fontSize: 12 }}>Loading concepts…</div>
|
||||
) : hierarchy.length === 0 ? (
|
||||
<div style={{ padding: "8px 24px", color: "#6a7f97", fontSize: 12, fontStyle: "italic" }}>
|
||||
No concepts found in this scheme.
|
||||
</div>
|
||||
) : (
|
||||
hierarchy.map((concept) => (
|
||||
<ConceptRow key={concept.uri} concept={concept} depth={0} onSelect={onSelectConcept} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OntologySummaryTab({
|
||||
onOpenVocabularyBrowser,
|
||||
}: {
|
||||
onOpenVocabularyBrowser?: () => void;
|
||||
}) {
|
||||
const { data: schemes = [], isLoading } = useVocabularies();
|
||||
const [selectedConcept, setSelectedConcept] = useState<ConceptNode | null>(null);
|
||||
|
||||
return (
|
||||
<div style={shellStyle}>
|
||||
{/* Header */}
|
||||
<div style={headerStyle}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<BookOpen size={18} color="#d2a8ff" />
|
||||
<div>
|
||||
<div style={{ color: "#ebf3ff", fontSize: 16, fontWeight: 700 }}>Ontology Summary</div>
|
||||
<div style={{ color: "#8b949e", fontSize: 12 }}>
|
||||
{isLoading
|
||||
? "Loading schemes…"
|
||||
: `${schemes.length} vocabulary scheme${schemes.length !== 1 ? "s" : ""} loaded`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{onOpenVocabularyBrowser ? (
|
||||
<button onClick={onOpenVocabularyBrowser} style={openBrowserBtnStyle}>
|
||||
<ExternalLink size={12} />
|
||||
<span>Open Full Browser</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, display: "flex", overflow: "hidden" }}>
|
||||
{/* Scheme tree column */}
|
||||
<div style={treeColumnStyle}>
|
||||
{isLoading ? (
|
||||
<div style={{ padding: 24, display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
{[90, 75, 60].map((w, i) => (
|
||||
<div key={i} style={{ height: 36, borderRadius: 8, background: "rgba(255,255,255,0.04)", width: `${w}%` }} />
|
||||
))}
|
||||
</div>
|
||||
) : schemes.length === 0 ? (
|
||||
<div style={emptyStateStyle}>
|
||||
<BookOpen size={32} color="rgba(210,168,255,0.15)" />
|
||||
<div style={{ color: "#8b949e", fontSize: 13, marginTop: 12 }}>No vocabulary schemes loaded</div>
|
||||
<div style={{ color: "#6a7f97", fontSize: 12, marginTop: 4, textAlign: "center", maxWidth: 240 }}>
|
||||
Import a .ttl or .rdf file via the Vocabulary Browser to see your ontology here.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ padding: "12px 8px", display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{schemes.map((scheme) => (
|
||||
<SchemePanel key={scheme.uri} scheme={scheme} onSelectConcept={setSelectedConcept} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Concept detail panel */}
|
||||
{selectedConcept ? (
|
||||
<div style={detailPanelStyle}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 16 }}>
|
||||
<div style={{ color: "#d2a8ff", fontSize: 11, fontWeight: 700, letterSpacing: "0.06em", textTransform: "uppercase" }}>
|
||||
Concept Detail
|
||||
</div>
|
||||
<button onClick={() => setSelectedConcept(null)} style={{ background: "transparent", border: "none", color: "#8b949e", cursor: "pointer", fontSize: 16 }}>×</button>
|
||||
</div>
|
||||
|
||||
<h3 style={{ color: "#ffffff", fontSize: 18, fontWeight: 800, letterSpacing: "-0.03em", margin: "0 0 6px 0" }}>
|
||||
{selectedConcept.pref_label}
|
||||
</h3>
|
||||
{selectedConcept.notation ? (
|
||||
<div style={{ color: "#8b949e", fontSize: 12, marginBottom: 8 }}>Notation: {selectedConcept.notation}</div>
|
||||
) : null}
|
||||
<div style={{ color: "#6a7f97", fontSize: 11, fontFamily: "monospace", wordBreak: "break-all", marginBottom: 14 }}>
|
||||
{selectedConcept.uri}
|
||||
</div>
|
||||
|
||||
{selectedConcept.description ? (
|
||||
<div style={detailSectionStyle}>
|
||||
<div style={detailLabelStyle}>Description</div>
|
||||
<div style={{ color: "#c6d4e3", fontSize: 13, lineHeight: 1.6 }}>{selectedConcept.description}</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{selectedConcept.alt_labels?.length ? (
|
||||
<div style={detailSectionStyle}>
|
||||
<div style={detailLabelStyle}>Alternative Labels</div>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
|
||||
{selectedConcept.alt_labels.map((label) => (
|
||||
<span key={label} style={altLabelChipStyle}>{label}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{(selectedConcept.children?.length ?? 0) > 0 ? (
|
||||
<div style={detailSectionStyle}>
|
||||
<div style={detailLabelStyle}>Narrower Concepts ({selectedConcept.children!.length})</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
{selectedConcept.children!.slice(0, 8).map((child) => (
|
||||
<div
|
||||
key={child.uri}
|
||||
onClick={() => setSelectedConcept(child)}
|
||||
style={{ color: "#79c0ff", fontSize: 12, cursor: "pointer", padding: "3px 0" }}
|
||||
>
|
||||
→ {child.pref_label}
|
||||
</div>
|
||||
))}
|
||||
{selectedConcept.children!.length > 8 ? (
|
||||
<div style={{ color: "#6a7f97", fontSize: 11 }}>+{selectedConcept.children!.length - 8} more</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── styles ─────────────────────────────────────────────────────── */
|
||||
|
||||
const shellStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
background: "#0d1117",
|
||||
overflow: "hidden",
|
||||
};
|
||||
|
||||
const headerStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "20px 24px 16px",
|
||||
borderBottom: "1px solid rgba(88,166,255,0.1)",
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const openBrowserBtnStyle: React.CSSProperties = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "6px 12px",
|
||||
borderRadius: 8,
|
||||
border: "1px solid rgba(210,168,255,0.22)",
|
||||
background: "rgba(210,168,255,0.08)",
|
||||
color: "#d2a8ff",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const treeColumnStyle: React.CSSProperties = {
|
||||
flex: 1,
|
||||
overflowY: "auto",
|
||||
borderRight: "1px solid rgba(255,255,255,0.06)",
|
||||
};
|
||||
|
||||
const schemeCardStyle: React.CSSProperties = {
|
||||
borderRadius: 10,
|
||||
border: "1px solid rgba(210,168,255,0.1)",
|
||||
background: "rgba(255,255,255,0.02)",
|
||||
overflow: "hidden",
|
||||
};
|
||||
|
||||
const schemeHeaderStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "10px 14px",
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
borderBottom: "1px solid rgba(255,255,255,0.05)",
|
||||
};
|
||||
|
||||
const detailPanelStyle: React.CSSProperties = {
|
||||
width: 300,
|
||||
padding: "20px",
|
||||
overflowY: "auto",
|
||||
borderLeft: "1px solid rgba(255,255,255,0.06)",
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const detailSectionStyle: React.CSSProperties = {
|
||||
marginTop: 14,
|
||||
paddingTop: 12,
|
||||
borderTop: "1px solid rgba(255,255,255,0.06)",
|
||||
};
|
||||
|
||||
const detailLabelStyle: React.CSSProperties = {
|
||||
color: "#8b949e",
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.07em",
|
||||
marginBottom: 6,
|
||||
};
|
||||
|
||||
const altLabelChipStyle: React.CSSProperties = {
|
||||
padding: "3px 8px",
|
||||
borderRadius: 999,
|
||||
background: "rgba(255,255,255,0.05)",
|
||||
border: "1px solid rgba(255,255,255,0.08)",
|
||||
color: "#8fa8c6",
|
||||
fontSize: 11,
|
||||
};
|
||||
|
||||
const emptyStateStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: 40,
|
||||
height: "100%",
|
||||
};
|
||||
@@ -1,13 +1,15 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react, { reactCompilerPreset } from '@vitejs/plugin-react'
|
||||
import babel from '@rolldown/plugin-babel'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
babel({ presets: [reactCompilerPreset()] })
|
||||
react({
|
||||
babel: {
|
||||
plugins: ['babel-plugin-react-compiler'],
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
base: '/',
|
||||
@@ -1,115 +0,0 @@
|
||||
/**
|
||||
* src/workspaces/DecisionWorkspace/DecisionWorkspace.tsx
|
||||
*/
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
const THEME_CSS = `
|
||||
.glass-panel {
|
||||
background: linear-gradient(135deg, rgba(13,17,23,0.75), rgba(22,27,34,0.6));
|
||||
backdrop-filter: blur(16px) saturate(1.2);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(1.2);
|
||||
border: 1px solid rgba(88,166,255,0.2);
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5), inset 1px 1px 0 rgba(255,255,255,0.05);
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
|
||||
function CausalChainNode({ hop, title, desc }: { hop: number, title: string, desc: string }) {
|
||||
return (
|
||||
<div style={{ marginLeft: hop * 24, paddingLeft: 16, borderLeft: "2px solid rgba(88,166,255,0.3)", position: "relative", marginBottom: 16 }}>
|
||||
<div style={{ position: "absolute", left: -6, top: 4, width: 10, height: 10, borderRadius: "50%", background: "#58a6ff", boxShadow: "0 0 8px #58a6ff" }} />
|
||||
<h4 style={{ margin: "0 0 4px 0", color: "#e6edf3", fontSize: 14 }}>{title}</h4>
|
||||
<p style={{ margin: 0, color: "#8b949e", fontSize: 13 }}>{desc}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DecisionWorkspace() {
|
||||
const [decisions, setDecisions] = useState<any[]>([]);
|
||||
const [selectedDecision, setSelectedDecision] = useState<any | null>(null);
|
||||
const [chain, setChain] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/decisions")
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setDecisions(data);
|
||||
if (data.length > 0) handleSelectDecision(data[0]);
|
||||
})
|
||||
.catch(console.error);
|
||||
}, []);
|
||||
|
||||
const handleSelectDecision = async (d: any) => {
|
||||
setSelectedDecision(d);
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/decisions/${d.decision_id}/chain`);
|
||||
const data = await res.json();
|
||||
setChain(data.chain || []);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", width: "100%", height: "100%", background: "#0d1117", overflow: "hidden" }}>
|
||||
<style>{THEME_CSS}</style>
|
||||
|
||||
{/* Left Column: Decisions List */}
|
||||
<div className="glass-panel" style={{ width: 320, padding: 24, display: "flex", flexDirection: "column", gap: 16, borderRight: "1px solid rgba(88,166,255,0.2)", borderTop: "none", borderLeft: "none", borderBottom: "none", borderRadius: 0 }}>
|
||||
<h2 style={{ color: "#ffffff", margin: 0, fontSize: 18, borderBottom: "1px solid rgba(255,255,255,0.1)", paddingBottom: 12 }}>
|
||||
Decision Tree
|
||||
</h2>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{decisions.map(d => (
|
||||
<button
|
||||
key={d.decision_id}
|
||||
onClick={() => handleSelectDecision(d)}
|
||||
style={{
|
||||
textAlign: "left", padding: "12px 16px", borderRadius: 8, cursor: "pointer",
|
||||
background: selectedDecision?.decision_id === d.decision_id ? "rgba(88,166,255,0.15)" : "transparent",
|
||||
border: `1px solid ${selectedDecision?.decision_id === d.decision_id ? "#58a6ff" : "rgba(255,255,255,0.1)"}`,
|
||||
color: selectedDecision?.decision_id === d.decision_id ? "#ffffff" : "#c9d1d9",
|
||||
transition: "all 0.2s"
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, fontSize: 14 }}>{d.decision_id}</div>
|
||||
<div style={{ fontSize: 12, color: "#8b949e", marginTop: 4 }}>{d.category || 'Uncategorized'}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Causal Chains */}
|
||||
<div style={{ flex: 1, padding: 32, overflowY: "auto", position: "relative" }}>
|
||||
<div style={{ position: "absolute", inset: 0, background: "radial-gradient(ellipse at top right, rgba(88,166,255,0.05), transparent 60%)", pointerEvents: "none" }} />
|
||||
|
||||
{selectedDecision ? (
|
||||
<>
|
||||
<h1 style={{ color: "#ffffff", fontSize: 28, margin: "0 0 8px 0" }}>{selectedDecision.decision_id}</h1>
|
||||
<div style={{ color: "#58a6ff", fontSize: 14, marginBottom: 40 }}>Outcome: {selectedDecision.outcome}</div>
|
||||
|
||||
<div className="glass-panel" style={{ padding: 32, borderRadius: 12 }}>
|
||||
<h3 style={{ color: "#ffffff", margin: "0 0 24px 0", fontSize: 16 }}>Causal Chain</h3>
|
||||
{loading ? (
|
||||
<div style={{ color: "#8b949e" }}>Loading chain...</div>
|
||||
) : chain.length > 0 ? (
|
||||
chain.map((c, i) => (
|
||||
<CausalChainNode key={i} hop={i} title={`${c.relationship} ${c.id}`} desc={c.content || '...'} />
|
||||
))
|
||||
) : (
|
||||
<div style={{ color: "#8b949e" }}>No causal chain found.</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ color: "#8b949e", textAlign: "center", marginTop: 100 }}>Select a decision to view details</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||