Skip to content

Commit 78706c5

Browse files
authored
Semantic Graph View (#249)
* feat: Add a new interactive Semantic Galaxy View for visualising vault relationships and integrating Visual RAG. * feat: Implement graph view auto-refresh on index updates, gracefully handle aborted graph layouts, and refine UI theming and camera centering. * refactor: reorder object properties for consistency in styling and camera state updates. * Refactor: enhance graph view stability by removing the visibility observer, adding width checks before refreshing, and improving theme colour resolution. * feat: refactor graph view to initialise Sigma.js robustly within a dedicated wrapper element and add `edgeType` to graph edges. * fix: Harden Semantic Galaxy rendering by addressing WebGL 0x0 death in hidden tabs, physics implosions, camera animation crashes, and improving theme color resilience. * fix: Re-enable Sigma graph interaction events, including hover previews with Sigma v3 payload extraction, and reset view path on open. * feat: improve graph label styling, add enhanced node hover effects, and implement smart camera behaviour for graph navigation. * feat: Implement topic-based node colouring, add graph view context menu controls, and enhance subgraph generation with relational clustering. * feat: implement custom node hover rendering with new interfaces and theme colours * feat: enhance graph layout with edge weighting and scaling, broaden topic extraction, and improve label visibility by lowering the rendering threshold. * refactor: simplify semantic graph camera logic to always fit view upon new cluster revelation. * feat: implement interactive node and edge hovering in the semantic graph view, displaying semantic scores on hovered edges, and store semantic edge scores in the indexer. * feat: enhance graph label visibility and theme integration, and implement a BM25 keyword search fallback for semantic injection. * feat: Augment edge label size in the graph view and implement physics weights for structural and semantic edges. * feat: Add an interactive attraction slider to the graph view for dynamic control over node clustering. * feat: add graph reshuffle button and attraction slider label, improve physics stability with dynamic repulsion, and refine semantic edge styling. * refactor: trigger semantic graph updates on slider input events instead of change events. * feat: add configurable structural and semantic edge thickness and refactor graph view debouncing * fix: Model selection dropdown in ResearchChatView now dynamically updates when new models are fetched. * fix: Researcher UI model selection dropdown now dynamically updates to display newly available models.
1 parent 0b3d45e commit 78706c5

15 files changed

Lines changed: 1003 additions & 42 deletions

CHANGELOG.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,41 @@ New features are added in the "Unreleased" section.
99

1010
## [Unreleased]
1111

12+
### Fixed
13+
14+
- Deep hardening of Semantic Galaxy rendering:
15+
- Fixed WebGL "0x0 death" in background tabs using `allowInvalidContainer` and `IntersectionObserver`.
16+
- Fixed physics engine "implosions" (NaN coordinates) using symmetric coordinate seeding and strict self-loop protection.
17+
- Fixed camera animation crashes by implementing multi-layer NaN guards for WebGL matrices.
18+
- Improved theme color resilience with robust CSS variable resolution and Hex color support.
19+
- Fixed Sigma v3 event payload extraction for native Obsidian hover previews.
20+
- **Researcher UI**: Fixed a bug where the model selection dropdown would fail to display newly available models (like Gemini 3.1 Pro) after a fresh API fetch. The dropdown now dynamically updates across all views in real-time.
21+
1222
### Breaking changes
1323

1424
- **Minimum Obsidian Version**: The minimum required version of Obsidian has been bumped to **v1.11.4** to support the native `SecretStorage` API. Users on older versions will not be able to install or update to this version.
1525
- **API Key Synchronization**: To improve security, your Google Gemini API key is now stored in your device's secure OS keychain (e.g., macOS Keychain, Windows Credential Manager) rather than in plain text. Because of this, **API keys will no longer sync across devices via Obsidian Sync or iCloud**. You will need to manually enter your API key once on each device you use.
1626

1727
### User features
1828

29+
- **Semantic Galaxy View**: Replaced the static relationship list with a high-performance, interactive 3D-like graph view. The "Semantic Galaxy" visualises your vault's relationships in real-time, centering on your active note.
30+
- **Visual RAG**: The graph now reacts to the Researcher agent. When the AI mentions files in its response, those notes are automatically highlighted in the galaxy, providing instant spatial context for the agent's reasoning.
31+
- **Structural & Semantic Discovery**: The view blends structural Wikilinks (BFS) with semantic vector similarities, allowing you to discover both explicit and hidden connections in your knowledge base.
32+
- **Fluid Interaction**: Supports smart-panning, node-hover previews (native Obsidian hover), and click-to-navigate functionality.
33+
- **Interactive Layout Controls**: Added a real-time "Attraction" slider to the graph view. Note clustering is driven by mathematical semantic scores; highly-related concepts will physically pull together, and the slider lets you tune this gravity. Includes a "Reshuffle" button to instantly regenerate the layout from scratch if it gets tangled.
34+
- **Adaptive Rendering**: Edge labels dynamically scale and shift coloring to support high contrast modes like Obsidian Dark Mode natively.
35+
- **Physics Stability**: Resolved an issue where high attraction could collapse the graph into a 1D line by dynamically scaling repulsive forces to maintain 2D dispersion.
1936
- **Improved Security**: Upgraded the plugin to use Obsidian's native Secure Storage. Your API keys are now encrypted and stored safely in your operating system's keychain rather than sitting in plain text in your vault folder.
2037
- **Linux Compatibility**: Added an intelligent fallback mechanism for Linux users. If your system (e.g., Flatpak or minimal distros) does not have a reachable keychain, the plugin will gracefully fall back to the legacy plain-text storage rather than crashing or nagging you.
2138

2239
### Developer features
2340

41+
- **High-performance WebGL Graphing**: Integrated Sigma.js and Graphology into the Obsidian UI. Implemented a Singleton-like Sigma managed instance with `IntersectionObserver` to ensure zero CPU/GPU overhead when the view is not visible.
42+
- **Yielding Worker Layout**: Refactored the ForceAtlas2 layout engine to run in the background worker with a yielding strategy (via `setTimeout(0)`), ensuring the main thread stays 100% responsive during complex graph calculations.
43+
- **BFS Subgraph Extraction**: Implemented a "Quota-limited BFS" algorithm in the indexer worker to extract local subgraphs (max 250 nodes) centered on active files, ensuring consistent performance regardless of vault size.
44+
- **Semantic Injection**: Added logic to inject top-K semantic neighbors into the structural graph, bridging the gap between vector search and graph theory.
45+
- **Smart Layout Seeding**: Implemented positional seeding to prevent graph "jumping" during updates by reusing previous node coordinates where available.
46+
- **Internal Event Bus**: Leveraged `GraphService` as a centralized, type-safe internal event bus for Visual RAG orchestration, eliminating `any` casts and collisions on `app.workspace`.
2447
- **Secure API key storage**: Migrated Google Gemini API keys from plain text `data.json` to Obsidian's native `SecretStorage` API (v1.11.4+).
2548
- **JIT initialization**: Refactored `GeminiService` to use asynchronous just-in-time client instantiation, preventing "Async Constructor" race conditions during plugin load.
2649
- **Stable secret IDs**: Mandated a persistent secret ID (`vault-intelligence-api-key`) to prevent sync-induced "ping-pong" conflicts between multiple devices.

docs/reference/configuration.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ Configure how connections and similar notes are discovered.
3939
| Embedding provider | `gemini` | Google Gemini: Cloud-based. Requires API key. <br>Local: Offline. Runs on your CPU. |
4040
| Embedding model | `gemini-embedding-001` | The vector engine. Choose from Gemini presets or various local ONNX models. |
4141
| Minimum similarity score | `0.5` | Relevance threshold (0.0 to 1.0). Matches below this are ignored. |
42+
| Semantic graph node limit | `250` | Maximum number of nodes to render in the Semantic Galaxy view. Controls the scale of the visualised universe. **How to set:** Keep at `250` for standard performance. Increase to `500+` on powerful desktops for massive overviews, or lower to `100` if the physics simulation stutters on older devices. |
43+
| Structural edge thickness | `1.0` | Visual weight of explicit wikilinks. Controls how thick the lines representing your actual markdown links are. **How to set:** The default `1.0` balances visibility. Increase to `2.0+` if you want your manual structure to clearly dominate the graph, or reduce to `0.5` to blend them evenly with AI suggestions. |
44+
| Semantic edge thickness | `0.5` | Visual weight of implied AI relationships. Controls how thick the lines representing vector similarity are. **How to set:** The default `0.5` keeps AI suggestions as a subtle background web. Increase to `1.0+` if you are actively using the galaxy to discover new connections and want them visually prioritised. |
4245
| Similar notes limit | `20` | Max number of related notes displayed in the sidebar. |
4346
| Primary context threshold | `0.9` | Similarity threshold for primary (direct match) context notes. |
4447
| Supporting context threshold | `0.7` | Similarity threshold for supporting (neighbor) context notes. |

package-lock.json

Lines changed: 33 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@
7474
"@xenova/transformers": "2.17.2",
7575
"comlink": "^4.4.2",
7676
"graphology": "^0.26.0",
77+
"graphology-layout-forceatlas2": "^0.10.1",
78+
"sigma": "^3.0.2",
7779
"zod": "^4.3.5"
7880
},
7981
"overrides": {

src/constants.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,7 @@ export const UI_CONSTANTS = {
272272

273273
export const VIEW_TYPES = {
274274
RESEARCH_CHAT: "research-chat-view",
275+
SEMANTIC_GRAPH: "semantic-graph-view",
275276
SIMILAR_NOTES: "similar-notes-view"
276277
};
277278

@@ -362,7 +363,8 @@ export const UI_STRINGS = {
362363
RESEARCHER_SYSTEM_NOTE_PREFIX: "*System Note:* ",
363364
RESEARCHER_TITLE: "Researcher: chat with vault",
364365
RIBBON_ICON: "brain-circuit",
365-
RIBBON_TOOLTIP: "Vault intelligence"
366+
RIBBON_TOOLTIP: "Vault intelligence",
367+
SEMANTIC_GRAPH_TITLE: "Semantic Galaxy"
366368
};
367369

368370
const DOCS_BASE = "https://cybaea.github.io/obsidian-vault-intelligence/";

src/main.ts

Lines changed: 77 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { DEFAULT_SETTINGS, VaultIntelligenceSettings, VaultIntelligenceSettingTa
1919
import { GardenerPlanRenderer } from "./ui/GardenerPlanRenderer";
2020
import { logger } from "./utils/logger";
2121
import { ResearchChatView } from "./views/ResearchChatView";
22+
import { SemanticGraphView } from "./views/SemanticGraphView";
2223
import { SimilarNotesView } from "./views/SimilarNotesView";
2324

2425
interface InternalSecretStorage {
@@ -216,6 +217,14 @@ export default class VaultIntelligencePlugin extends Plugin implements IVaultInt
216217
// Ribbon Icon
217218
this.addRibbonIcon(UI_STRINGS.RIBBON_ICON, UI_STRINGS.RIBBON_TOOLTIP, (evt: MouseEvent) => {
218219
const menu = new Menu();
220+
menu.addItem((item) =>
221+
item
222+
.setTitle(UI_STRINGS.EXPLORER_TITLE)
223+
.setIcon('layout-grid')
224+
.onClick(() => {
225+
void this.activateView(VIEW_TYPES.SIMILAR_NOTES);
226+
})
227+
);
219228
menu.addItem((item) =>
220229
item
221230
.setTitle(UI_STRINGS.RESEARCHER_TITLE)
@@ -226,41 +235,62 @@ export default class VaultIntelligencePlugin extends Plugin implements IVaultInt
226235
);
227236
menu.addItem((item) =>
228237
item
229-
.setTitle(UI_STRINGS.EXPLORER_TITLE)
230-
.setIcon('layout-grid')
238+
.setTitle(UI_STRINGS.SEMANTIC_GRAPH_TITLE)
239+
.setIcon('network')
231240
.onClick(() => {
232-
void this.activateView(VIEW_TYPES.SIMILAR_NOTES);
241+
void this.activateView(VIEW_TYPES.SEMANTIC_GRAPH);
233242
})
234243
);
235244
menu.showAtMouseEvent(evt);
236245
});
237246

238247
// Register Views
248+
this.registerView(
249+
VIEW_TYPES.RESEARCH_CHAT,
250+
(leaf) => new ResearchChatView(leaf, this, this.geminiService, this.graphService, this.embeddingService)
251+
);
252+
this.registerView(
253+
VIEW_TYPES.SEMANTIC_GRAPH,
254+
(leaf) => new SemanticGraphView(leaf, this, this.graphService)
255+
);
239256
this.registerView(
240257
VIEW_TYPES.SIMILAR_NOTES,
241258
(leaf) => new SimilarNotesView(leaf, this, this.graphService)
242259
);
243260

244-
this.registerView(
245-
VIEW_TYPES.RESEARCH_CHAT,
246-
(leaf) => new ResearchChatView(leaf, this, this.geminiService, this.graphService, this.embeddingService)
261+
// Event listeners for UI updates
262+
this.registerEvent(
263+
this.app.workspace.on('active-leaf-change', () => {
264+
const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPES.SIMILAR_NOTES);
265+
leaves.forEach(leaf => {
266+
if (leaf.view instanceof SimilarNotesView) {
267+
void leaf.view.updateView();
268+
}
269+
});
270+
271+
// FIX: Hook up the Semantic Graph to active-leaf-change
272+
const graphLeaves = this.app.workspace.getLeavesOfType(VIEW_TYPES.SEMANTIC_GRAPH);
273+
graphLeaves.forEach(leaf => {
274+
if (leaf.view instanceof SemanticGraphView) {
275+
const file = this.app.workspace.getActiveFile();
276+
void leaf.view.updateForFile(file);
277+
}
278+
});
279+
})
247280
);
248281

249282
// Commands
250283
this.addCommand({
251-
callback: () => {
252-
void this.activateView(VIEW_TYPES.SIMILAR_NOTES);
253-
},
254-
id: 'open-similar-notes-view',
255-
name: 'Explorer: view similar notes'
256-
});
257-
258-
this.addCommand({
259-
callback: () => {
260-
void this.activateView(VIEW_TYPES.RESEARCH_CHAT);
284+
callback: async () => {
285+
try {
286+
await this.gardenerService.purgeOldPlans();
287+
new Notice(UI_STRINGS.NOTICE_GARDENER_PURGED);
288+
} catch (error: unknown) {
289+
new Notice(`${UI_STRINGS.NOTICE_PURGE_FAILED}${error instanceof Error ? error.message : String(error)}`);
290+
}
261291
},
262-
id: 'open-research-chat-view',
263-
name: 'Researcher: chat with vault'
292+
id: 'gardener-purge-plans',
293+
name: UI_STRINGS.GARDENER_TITLE_PURGE
264294
});
265295

266296
this.addCommand({
@@ -281,16 +311,27 @@ export default class VaultIntelligencePlugin extends Plugin implements IVaultInt
281311
});
282312

283313
this.addCommand({
284-
callback: async () => {
285-
try {
286-
await this.gardenerService.purgeOldPlans();
287-
new Notice(UI_STRINGS.NOTICE_GARDENER_PURGED);
288-
} catch (error: unknown) {
289-
new Notice(`${UI_STRINGS.NOTICE_PURGE_FAILED}${error instanceof Error ? error.message : String(error)}`);
290-
}
314+
callback: () => {
315+
void this.activateView(VIEW_TYPES.RESEARCH_CHAT);
291316
},
292-
id: 'gardener-purge-plans',
293-
name: UI_STRINGS.GARDENER_TITLE_PURGE
317+
id: 'open-research-chat-view',
318+
name: 'Researcher: chat with vault'
319+
});
320+
321+
this.addCommand({
322+
callback: () => {
323+
void this.activateView(VIEW_TYPES.SEMANTIC_GRAPH);
324+
},
325+
id: 'open-semantic-graph-view',
326+
name: 'Explorer: view semantic galaxy'
327+
});
328+
329+
this.addCommand({
330+
callback: () => {
331+
void this.activateView(VIEW_TYPES.SIMILAR_NOTES);
332+
},
333+
id: 'open-similar-notes-view',
334+
name: 'Explorer: view similar notes'
294335
});
295336

296337
this.addCommand({
@@ -333,6 +374,15 @@ export default class VaultIntelligencePlugin extends Plugin implements IVaultInt
333374
void leaf.view.updateView();
334375
}
335376
});
377+
378+
// Notify Semantic Galaxy to update on file change
379+
const graphLeaves = this.app.workspace.getLeavesOfType(VIEW_TYPES.SEMANTIC_GRAPH);
380+
graphLeaves.forEach(leaf => {
381+
if (leaf.view instanceof SemanticGraphView) {
382+
const file = this.app.workspace.getActiveFile();
383+
void leaf.view.updateForFile(file);
384+
}
385+
});
336386
})
337387
);
338388

src/services/GraphService.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import Graph from "graphology";
12
import { Events, App } from "obsidian";
23

34
import { GRAPH_CONSTANTS } from "../constants";
@@ -173,6 +174,26 @@ export class GraphService extends Events {
173174
}
174175
}
175176

177+
/**
178+
* Gets a subgraph centered around a file with pre-calculated layout.
179+
*/
180+
public async getSemanticSubgraph(path: string, updateId: number, existingPositions?: Record<string, { x: number, y: number }>, attractionMultiplier: number = 1.0): Promise<Graph | null> {
181+
try {
182+
const raw = await this.workerManager.executeQuery(api => api.getSubgraph(path, updateId, existingPositions, attractionMultiplier));
183+
184+
// FIX: Gracefully handle null from aborted layout
185+
if (!raw) return null;
186+
187+
const sub = new Graph({ type: 'undirected' });
188+
sub.import(raw as Parameters<typeof sub.import>[0]);
189+
190+
return sub;
191+
} catch (e) {
192+
console.error(`[GraphService] Failed to get semantic subgraph for ${path}`, e);
193+
return null;
194+
}
195+
}
196+
176197
/**
177198
* Gets structural importance metrics for a node.
178199
*/

src/services/GraphSyncOrchestrator.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,10 @@ export class GraphSyncOrchestrator {
9797
indexingDelayMs: this.settings.indexingDelayMs || GRAPH_CONSTANTS.DEFAULT_INDEXING_DELAY_MS,
9898
minSimilarityScore: this.settings.minSimilarityScore ?? 0.5,
9999
ontologyPath: this.settings.ontologyPath,
100-
sanitizedModelId: this.persistenceManager.getSanitizedModelId(activeModelId, activeDimension)
100+
sanitizedModelId: this.persistenceManager.getSanitizedModelId(activeModelId, activeDimension),
101+
semanticEdgeThickness: this.settings.semanticEdgeThickness || 0.5,
102+
semanticGraphNodeLimit: this.settings.semanticGraphNodeLimit || 250,
103+
structuralEdgeThickness: this.settings.structuralEdgeThickness || 1.0
101104
};
102105
}
103106

@@ -372,6 +375,9 @@ export class GraphSyncOrchestrator {
372375
indexingDelayMs: settings.indexingDelayMs,
373376
minSimilarityScore: settings.minSimilarityScore,
374377
ontologyPath: settings.ontologyPath,
378+
semanticEdgeThickness: settings.semanticEdgeThickness,
379+
semanticGraphNodeLimit: settings.semanticGraphNodeLimit,
380+
structuralEdgeThickness: settings.structuralEdgeThickness
375381
});
376382
}
377383
}

src/services/ModelRegistry.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ export class ModelRegistry {
207207
};
208208
(app as unknown as InternalApp).saveLocalStorage?.(this.CACHE_KEY, JSON.stringify(cacheData));
209209
}
210+
app.workspace.trigger('vault-intelligence:models-updated');
210211
} catch (error) {
211212
logger.error("Error fetching Gemini models", error);
212213
if (throwOnError) throw error;

0 commit comments

Comments
 (0)