Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
b9ae8b4
feat(ontology): follow owl:imports from ontology graph, fix discovery…
Apr 29, 2026
df0894b
fix(ontologies): add ontologyUrl for tto/schema/pmdco, un-skip networ…
Apr 29, 2026
985fe00
chore(ontologies): remove iof-mat and iof-qual (all URLs 404)
Apr 29, 2026
aeaf76e
fix(ui): prevent dialog closing when selecting autocomplete dropdown …
Apr 29, 2026
dcd107b
fix(ui): use originalEvent.target for autocomplete portal detection
Apr 29, 2026
5a15bdd
Revert "fix(ui): use originalEvent.target for autocomplete portal det…
Apr 29, 2026
986d757
Revert "fix(ui): prevent dialog closing when selecting autocomplete d…
Apr 29, 2026
31a864c
fix(ui): stop pointerdown propagation on autocomplete items to preven…
Apr 29, 2026
44e8201
fix(ui): replace Radix Dialog with plain modal for Load Ontology
Apr 29, 2026
2cbe6b9
fix(ui): show correct load status in ontology widget
Apr 29, 2026
125c717
fix(ontology): resolve ontologyUrl before fetching in loadOntology
Apr 29, 2026
77ff0dd
fix(ontology): also try http form when resolving fetch URL for well-k…
Apr 29, 2026
dc928bc
feat(ontologies): add W3C recommended ontologies to well-known registry
Apr 29, 2026
60a9c5e
feat(ontologies): expand registry with 18 new ontologies + descriptio…
Apr 29, 2026
5533623
fix(ontologies): vendor ical and sioc RDF into public/ to avoid CORS …
Apr 29, 2026
4092092
Revert "fix(ontologies): vendor ical and sioc RDF into public/ to avo…
Apr 29, 2026
9e56b68
feat(mcp): add searchOntologies tool + wire discovery workflow into a…
Apr 29, 2026
648f70b
refactor(ontologies): extract searchWellKnownOntologies, eliminate fi…
Apr 29, 2026
6331958
chore(mcp): regenerate mcp.json (searchOntologies + updated tool desc…
Apr 29, 2026
360c994
refactor(mcp): merge searchOntologies into loadOntology
Apr 29, 2026
f91ce39
feat(startup): per-ontology sonner toasts for ?ontology= URL paramete…
Apr 29, 2026
dfd4d04
fix(ui): improve sonner toasts for ontology/data load paths
Apr 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions public/.well-known/mcp.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "Ontosphere",
"description": "Interactive RDF/ontology knowledge graph editor — ABox authoring, OWL-RL reasoning, layout, and export. All client-side, no backend.\n\nABOX vs TBOX — TWO SEPARATE CANVAS VIEWS (critical for AI agents):\nOntosphere maintains a strict ABox/TBox split. Every node you add is classified by its rdf:type and appears in exactly one view:\n • ABox view (\"abox\") — individuals and instance data. Nodes typed as owl:NamedIndividual, skos:Concept, or any non-schema type. This is the default view for authoring instance knowledge.\n • TBox view (\"tbox\") — ontology schema. Nodes typed as owl:Class, owl:ObjectProperty, owl:DatatypeProperty, owl:AnnotationProperty, rdfs:Class, etc. This view shows the schema/vocabulary layer.\n • \"Punned\" resources (typed as both, e.g. owl:Class AND owl:NamedIndividual) appear in both views.\nSwitching views replaces the entire canvas — ABox nodes are invisible in TBox view and vice versa. Use setViewMode before exportImage to capture the right layer. addNode writes triples to the store and the canvas populates automatically in the correct view.\n\nArchitecture for AI agents: The app has two coupled layers. (1) N3 RDF store (urn:vg:data) — source of truth for all triples. addNode/addLink write here first. (2) Reactodia canvas — mirrors the store subset matching the active view as draggable node cards and arrows. Nodes start collapsed; call expandNode or expandAll to reveal annotation property cards. OWL-RL reasoning writes inferred triples back to the store (urn:vg:inferred) and refreshes the canvas.\n\nRecommended workflow: setViewMode(\"tbox\") → loadOntology → addNode ×N (owl:Class etc.) → addLink ×N (subClassOf etc.) → runLayout → setViewMode(\"abox\") → addNode ×N (individuals) → addLink ×N → runLayout → runReasoning → fitCanvas → exportImage(svg).\n\nAgent integration: (1) Claude Code / Playwright — call window.__mcpTools[name](params) via browser_evaluate. (2) AI Relay Bridge — any AI chat (ChatGPT, Claude.ai, Gemini) can control Ontosphere via a bookmarklet relay that intercepts JSON-RPC 2.0 tool calls and injects results back automatically; see docs/relay-bridge.md. Full agent guide: AGENTS.md. Example sessions with SVG snapshots: docs/mcp-demo/.",
"description": "Interactive RDF/ontology knowledge graph editor — ABox authoring, OWL-RL reasoning, layout, and export. All client-side, no backend.\n\nABOX vs TBOX — TWO SEPARATE CANVAS VIEWS (critical for AI agents):\nOntosphere maintains a strict ABox/TBox split. Every node you add is classified by its rdf:type and appears in exactly one view:\n • ABox view (\"abox\") — individuals and instance data. Nodes typed as owl:NamedIndividual, skos:Concept, or any non-schema type. This is the default view for authoring instance knowledge.\n • TBox view (\"tbox\") — ontology schema. Nodes typed as owl:Class, owl:ObjectProperty, owl:DatatypeProperty, owl:AnnotationProperty, rdfs:Class, etc. This view shows the schema/vocabulary layer.\n • \"Punned\" resources (typed as both, e.g. owl:Class AND owl:NamedIndividual) appear in both views.\nSwitching views replaces the entire canvas — ABox nodes are invisible in TBox view and vice versa. Use setViewMode before exportImage to capture the right layer. addNode writes triples to the store and the canvas populates automatically in the correct view.\n\nArchitecture for AI agents: The app has two coupled layers. (1) N3 RDF store (urn:vg:data) — source of truth for all triples. addNode/addLink write here first. (2) Reactodia canvas — mirrors the store subset matching the active view as draggable node cards and arrows. Nodes start collapsed; call expandNode or expandAll to reveal annotation property cards. OWL-RL reasoning writes inferred triples back to the store (urn:vg:inferred) and refreshes the canvas.\n\nONTOLOGY DISCOVERY — always the first step:\nOWL, RDFS, RDF, and XSD are pre-loaded. All other ontologies must be loaded explicitly:\n 1. searchOntologies(\"use case\") — find the right prefix (\"calendar\" → ical, \"music\" → mo, \"building\" → bot, \"e-commerce\" → gr, …)\n 2. loadOntology(\"<prefix>\") — load into TBox. Repeat for each domain.\n 3. Register a namespace prefix: addNamespace(prefix, namespace) if you want short-form IRIs.\n\nRecommended workflow: searchOntologies → loadOntology ×N → addNamespace ×N → setViewMode(\"tbox\") → addNode ×N (owl:Class etc.) → addLink ×N (subClassOf etc.) → runLayout → setViewMode(\"abox\") → addNode ×N (individuals) → addLink ×N → runLayout → runReasoning → fitCanvas → exportImage(svg).\n\nAgent integration: (1) Claude Code / Playwright — call window.__mcpTools[name](params) via browser_evaluate. (2) AI Relay Bridge — any AI chat (ChatGPT, Claude.ai, Gemini) can control Ontosphere via a bookmarklet relay that intercepts JSON-RPC 2.0 tool calls and injects results back automatically; see docs/relay-bridge.md. Full agent guide: AGENTS.md. Example sessions with SVG snapshots: docs/mcp-demo/.",
"tools": [
{
"name": "loadRdf",
Expand Down Expand Up @@ -31,7 +31,7 @@
},
{
"name": "loadOntology",
"description": "Load TBox ontology for type hints and reasoning support. Does NOT add canvas nodes. Use for schema/class definitions. To load instance data as canvas nodes, use loadRdf instead.",
"description": "Discover or load well-known ontologies — three modes in one tool. (1) Load: pass url with a prefix name (e.g. \"ical\", \"mo\", \"bot\", \"gr\") or a namespace/file URL — loads the TBox, does NOT add canvas nodes. (2) Search: pass query with a use-case keyword (\"calendar\", \"music\", \"building\", \"e-commerce\", \"spatial\", \"IoT\") — returns matching entries with prefix/description/loadUrl. (3) List all: pass neither — returns all ~55 registered ontologies. If url does not match a known prefix or URL, load fails and suggestions are returned automatically. OWL/RDFS/RDF/XSD are always pre-loaded.",
"inputSchema": {
"type": "object",
"properties": {
Expand Down Expand Up @@ -595,4 +595,4 @@
}
}
]
}
}
6 changes: 6 additions & 0 deletions src/__tests__/fixtures/ont-imported.ttl
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
@prefix owl: <http://www.w3.org/2002/07/owl#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .

<https://test.example.org/imported-ont.ttl>
a owl:Ontology ;
rdfs:label "Imported Test Ontology" .
7 changes: 7 additions & 0 deletions src/__tests__/fixtures/ont-with-imports.ttl
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
@prefix owl: <http://www.w3.org/2002/07/owl#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .

<https://test.example.org/main-ont.ttl>
a owl:Ontology ;
owl:imports <https://test.example.org/imported-ont.ttl> ;
rdfs:label "Main Test Ontology" .
161 changes: 161 additions & 0 deletions src/__tests__/stores/ontologyStore.owlImports.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
// @vitest-environment node
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { Parser as N3Parser } from "n3";
import { useOntologyStore } from "../../stores/ontologyStore";
import { rdfManager } from "../../utils/rdfManager";

const MAIN_URL = "https://test.example.org/main-ont.ttl";
const IMPORTED_URL = "https://test.example.org/imported-ont.ttl";

const mainTtl = readFileSync(resolve(__dirname, "../fixtures/ont-with-imports.ttl"), "utf-8");
const importedTtl = readFileSync(resolve(__dirname, "../fixtures/ont-imported.ttl"), "utf-8");

function makeTurtleResponse(body: string): Response {
return new Response(body, {
status: 200,
headers: { "content-type": "text/turtle" },
});
}

/**
* Minimal in-memory RDF manager mock. Parses Turtle on loadRDFIntoGraph/loadRDFFromUrl,
* returns quads via positional fetchQuadsPage(graph, offset, limit, opts) that
* ontologyStore.fetchSerializedQuads expects.
*/
function makeInMemoryRdfManager() {
const store: Map<string, Array<{ subject: string; predicate: string; object: string; graph: string }>> = new Map();

function addQuad(graph: string, s: string, p: string, o: string) {
let quads = store.get(graph);
if (!quads) { quads = []; store.set(graph, quads); }
quads.push({ subject: s, predicate: p, object: o, graph });
}

function parseTurtle(text: string, graph: string) {
const parser = new N3Parser({ format: "Turtle" });
const quads = parser.parse(text);
for (const q of quads) {
addQuad(graph, q.subject.value, q.predicate.value, q.object.value);
}
}

return {
// loadRDFIntoGraph: parse turtle and store quads
loadRDFIntoGraph: vi.fn().mockImplementation(async (text: string, graphName: string) => {
parseTurtle(text, graphName);
}),

// loadRDFFromUrl: fetch url and delegate to loadRDFIntoGraph
loadRDFFromUrl: vi.fn().mockImplementation(async (url: string, graphName: string) => {
const resp = await fetch(url, { signal: new AbortController().signal, redirect: "follow", headers: {} });
if (!resp.ok) throw new Error(`fetch failed: ${resp.status}`);
const text = await resp.text();
parseTurtle(text, graphName);
}),

// Positional fetchQuadsPage (matches RdfPageFetcher interface in ontologyStore)
fetchQuadsPage: vi.fn().mockImplementation(
async (graph: string, _offset: number, _limit: number, opts: any) => {
const all = store.get(graph) || [];
const predFilter = opts?.filter?.predicate;
const items = predFilter ? all.filter((q) => q.predicate === predFilter) : all;
return { items, total: items.length, limit: _limit };
},
),

emitAllSubjects: vi.fn().mockResolvedValue(undefined),

clear: vi.fn().mockImplementation(() => { store.clear(); }),
};
}

describe("loadOntology — owl:imports fire-and-forget discovery", () => {
let savedRdfManager: any;

beforeEach(() => {
savedRdfManager = useOntologyStore.getState().rdfManager;
useOntologyStore.getState().clearOntologies();
});

afterEach(() => {
vi.unstubAllGlobals();
useOntologyStore.setState({ rdfManager: savedRdfManager } as any);
});

it("auto-loads owl:imports after explicit loadOntology, even when autoDiscoverOntologies is false", async () => {
const mockMgr = makeInMemoryRdfManager();
useOntologyStore.setState({ rdfManager: mockMgr as any } as any);

vi.stubGlobal(
"fetch",
vi.fn().mockImplementation((url: string) => {
if (url === MAIN_URL) return Promise.resolve(makeTurtleResponse(mainTtl));
if (url === IMPORTED_URL) return Promise.resolve(makeTurtleResponse(importedTtl));
return Promise.reject(new TypeError(`Unexpected fetch: ${url}`));
}),
);

// Disable autoDiscoverOntologies — fire-and-forget must still run (unconditional:true)
const { useAppConfigStore } = await import("../../stores/appConfigStore");
const prevConfig = useAppConfigStore.getState().config;
useAppConfigStore.setState({
config: { ...prevConfig, autoDiscoverOntologies: false },
} as any);

try {
await useOntologyStore.getState().loadOntology(MAIN_URL);

// Wait for fire-and-forget discovery + inner loadOntology to complete
await vi.waitFor(
() => {
const loaded = useOntologyStore.getState().loadedOntologies;
const urls = loaded.map((o: any) => String(o.url));
if (!urls.some((u: string) => u.includes("imported-ont"))) {
throw new Error(`imported-ont not yet in loadedOntologies: ${JSON.stringify(urls)}`);
}
},
{ timeout: 5000, interval: 50 },
);

const loaded = useOntologyStore.getState().loadedOntologies;
const urls = loaded.map((o: any) => String(o.url));
expect(urls.some((u: string) => u.includes("main-ont"))).toBe(true);
expect(urls.some((u: string) => u.includes("imported-ont"))).toBe(true);
} finally {
useAppConfigStore.setState({ config: prevConfig } as any);
}
}, 10000);

it("does not re-trigger discovery for inner loadOntology calls (discovered:true guard)", async () => {
const mockMgr = makeInMemoryRdfManager();
useOntologyStore.setState({ rdfManager: mockMgr as any } as any);

const fetchMock = vi.fn().mockImplementation((url: string) => {
if (url === MAIN_URL) return Promise.resolve(makeTurtleResponse(mainTtl));
if (url === IMPORTED_URL) return Promise.resolve(makeTurtleResponse(importedTtl));
return Promise.reject(new TypeError(`Unexpected fetch: ${url}`));
});
vi.stubGlobal("fetch", fetchMock);

await useOntologyStore.getState().loadOntology(MAIN_URL);

await vi.waitFor(
() => {
const loaded = useOntologyStore.getState().loadedOntologies;
const urls = loaded.map((o: any) => String(o.url));
if (!urls.some((u: string) => u.includes("imported-ont"))) {
throw new Error("imported-ont not yet loaded");
}
},
{ timeout: 5000, interval: 50 },
);

// Each URL should be fetched exactly once — no infinite recursion
const mainFetches = fetchMock.mock.calls.filter(([url]: [string]) => url === MAIN_URL).length;
const importedFetches = fetchMock.mock.calls.filter(([url]: [string]) => url === IMPORTED_URL).length;
expect(mainFetches).toBe(1);
expect(importedFetches).toBe(1);
}, 10000);
});
66 changes: 65 additions & 1 deletion src/__tests__/stores/ontologyStore.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, it, expect, beforeEach } from "vitest";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { useOntologyStore } from "../../stores/ontologyStore";
import { rdfManager } from "../../utils/rdfManager";
import validateGraph from "../../utils/graphValidation";
import { WELL_KNOWN } from "../../utils/wellKnownOntologies";

Expand Down Expand Up @@ -158,3 +159,66 @@ describe("Ontology Store", () => {
});
});
});

describe("discoverReferencedOntologies — graph parameter fix", () => {
const ONTOLOGY_GRAPH = "urn:vg:ontologies";
const IMPORT_URL = "https://example.org/imported-ontology.ttl";
const SUBJECT = "urn:test:myOntology";

const RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
const OWL_ONTOLOGY = "http://www.w3.org/2002/07/owl#Ontology";
const OWL_IMPORTS = "http://www.w3.org/2002/07/owl#imports";

let savedRdfManager: any;

beforeEach(() => {
savedRdfManager = useOntologyStore.getState().rdfManager;
useOntologyStore.getState().clearOntologies();
});

afterEach(() => {
useOntologyStore.setState({ rdfManager: savedRdfManager } as any);
});

it("returns owl:imports candidates from the requested graph, not hardcoded data graph", async () => {
const mockFetchQuadsPage = vi.fn().mockImplementation(
async (graph: string, _offset: number, _limit: number, opts: any) => {
if (graph !== ONTOLOGY_GRAPH) {
return { items: [], total: 0, limit: _limit };
}
const predicate = opts?.filter?.predicate;
if (predicate === RDF_TYPE) {
return {
items: [{ subject: SUBJECT, predicate: RDF_TYPE, object: OWL_ONTOLOGY, graph }],
total: 1,
limit: _limit,
};
}
if (predicate === OWL_IMPORTS) {
return {
items: [{ subject: SUBJECT, predicate: OWL_IMPORTS, object: IMPORT_URL, graph }],
total: 1,
limit: _limit,
};
}
return { items: [], total: 0, limit: _limit };
},
);

useOntologyStore.setState({
rdfManager: { fetchQuadsPage: mockFetchQuadsPage } as any,
} as any);

const store = useOntologyStore.getState();
const result = await store.discoverReferencedOntologies!({
graphName: ONTOLOGY_GRAPH,
load: false,
});

expect(result.candidates).toContain(IMPORT_URL);
// All fetchQuadsPage calls must target the ontology graph, never "urn:vg:data"
for (const call of mockFetchQuadsPage.mock.calls) {
expect(call[0]).toBe(ONTOLOGY_GRAPH);
}
});
});
67 changes: 67 additions & 0 deletions src/__tests__/stores/wellKnownOntologies.network.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// @vitest-environment node
import { describe, it, expect } from "vitest";
import { Readable } from "node:stream";
import { Parser as N3Parser } from "n3";
import { WELL_KNOWN_PREFIXES, resolveOntologyLoadUrl } from "../../utils/wellKnownOntologies";

async function fetchAndParse(url: string): Promise<number> {
const resp = await fetch(url, {
headers: { Accept: "text/turtle, application/rdf+xml, application/n-triples, text/n3, */*;q=0.1" },
redirect: "follow",
});

if (!resp.ok) {
throw new Error(`HTTP ${resp.status} from ${url}`);
}

const body = await resp.text();
const contentType = (resp.headers.get("content-type") || "").toLowerCase();

const isRdfXml =
contentType.includes("application/rdf+xml") ||
contentType.includes("text/xml") ||
contentType.includes("application/xml") ||
url.endsWith(".rdf") ||
url.endsWith(".owl") ||
body.trimStart().startsWith("<?xml") ||
body.trimStart().startsWith("<rdf:");

if (isRdfXml) {
const { RdfXmlParser } = await import("rdfxml-streaming-parser");
return new Promise<number>((resolve, reject) => {
const parser = new RdfXmlParser();
let count = 0;
parser.on("data", () => { count++; });
parser.on("end", () => resolve(count));
parser.on("error", (err: Error) => reject(new Error(`RDF/XML parse error from ${url}: ${err.message}`)));
const readable = Readable.from([body]);
readable.pipe(parser);
});
}

// Try N3 (Turtle / N-Triples / N3 / TriG)
try {
const parser = new N3Parser();
const quads = parser.parse(body);
return quads.length;
} catch (n3Err: any) {
throw new Error(
`Unknown RDF format from ${url} (content-type: ${contentType}): ${n3Err.message}`,
);
}
}

describe("well-known ontology reachability", () => {
for (const entry of WELL_KNOWN_PREFIXES) {
const loadUrl = resolveOntologyLoadUrl(entry.prefix);

it(
`${entry.prefix} — ${loadUrl} resolves and parses as RDF`,
{ timeout: 30000 },
async () => {
const count = await fetchAndParse(loadUrl);
expect(count).toBeGreaterThan(0);
},
);
}
});
Loading
Loading