From 89dd1c766c96089f7dcc7c87170e966d4a36fc57 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 8 Jun 2026 14:36:01 +0000 Subject: [PATCH 1/3] feat: AI-assisted knowledge graph auto-linking Implemented LLM-powered entity extraction and relationship discovery from notes. Added a review UI for users to selectively add extracted entities and edges to the graph. Integrated extraction into the editor (manual and auto-trigger) and added batch analysis to graph controls. Includes duplicate checking and backlinks to source notes in metadata. Co-authored-by: d-oit <6849456+d-oit@users.noreply.github.com> --- src/features/ai/EntityReviewDialog.tsx | 160 ++++++++++++++++++ src/features/editor/Editor.tsx | 108 +++++++++++- src/features/graph/GraphControls.tsx | 65 ++++++- src/lib/ai/__tests__/entity-extractor.test.ts | 67 ++++++++ src/lib/ai/__tests__/graph-linker.test.ts | 89 ++++++++++ src/lib/ai/entity-extractor.ts | 61 +++++++ src/lib/ai/graph-linker.ts | 71 ++++++++ 7 files changed, 619 insertions(+), 2 deletions(-) create mode 100644 src/features/ai/EntityReviewDialog.tsx create mode 100644 src/lib/ai/__tests__/entity-extractor.test.ts create mode 100644 src/lib/ai/__tests__/graph-linker.test.ts create mode 100644 src/lib/ai/entity-extractor.ts create mode 100644 src/lib/ai/graph-linker.ts diff --git a/src/features/ai/EntityReviewDialog.tsx b/src/features/ai/EntityReviewDialog.tsx new file mode 100644 index 0000000..78bdc46 --- /dev/null +++ b/src/features/ai/EntityReviewDialog.tsx @@ -0,0 +1,160 @@ +import React, { useState } from 'react'; +import { X, CheckCircle2, Link2, PlusCircle } from 'lucide-react'; +import { EntityExtractionResult } from '../../lib/ai/entity-extractor'; +import { useRepository } from '../../db/useRepository'; +import { applyEntitiesToGraph } from '../../lib/ai/graph-linker'; +import { logger } from '../../lib/logger'; + +interface EntityReviewDialogProps { + result: EntityExtractionResult; + sourceNoteId?: string; + onClose: () => void; + onComplete: () => void; +} + +const EntityReviewDialog: React.FC = ({ + result, + sourceNoteId, + onClose, + onComplete, +}) => { + const repository = useRepository(); + const [selectedEntities, setSelectedEntities] = useState( + result.entities.map(e => e.name) + ); + const [selectedRelationships, setSelectedRelationships] = useState( + result.relationships.map(r => `${r.from}->${r.to}`) + ); + const [isApplying, setIsApplying] = useState(false); + + const toggleEntity = (name: string) => { + setSelectedEntities(prev => + prev.includes(name) ? prev.filter(n => n !== name) : [...prev, name] + ); + }; + + const toggleRelationship = (from: string, to: string) => { + const key = `${from}->${to}`; + setSelectedRelationships(prev => + prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key] + ); + }; + + const handleApply = async () => { + setIsApplying(true); + try { + await applyEntitiesToGraph( + result, + repository, + selectedEntities, + selectedRelationships, + sourceNoteId + ); + onComplete(); + onClose(); + } catch (err) { + logger.error('Failed to apply entities to graph', err); + } finally { + setIsApplying(false); + } + }; + + return ( +
e.target === e.currentTarget && onClose()}> +
+
+

Review Extracted Entities

+ +
+ +
+

+ AI analyzed your note and found the following entities and relationships. + Select the ones you want to add to your knowledge graph. +

+ +
+

+ Entities ({result.entities.length}) +

+
+ {result.entities.map((entity, idx) => ( + + ))} +
+
+ +
+

+ Relationships ({result.relationships.length}) +

+
+ {result.relationships.map((rel, idx) => { + const key = `${rel.from}->${rel.to}`; + return ( + + ); + })} +
+
+
+ +
+ + +
+
+
+ ); +}; + +export default EntityReviewDialog; diff --git a/src/features/editor/Editor.tsx b/src/features/editor/Editor.tsx index d0ac788..3033a40 100644 --- a/src/features/editor/Editor.tsx +++ b/src/features/editor/Editor.tsx @@ -9,8 +9,11 @@ import { useRepository } from '../../db/useRepository'; import { jobCoordinator } from '../../lib/jobs'; import { upsertToSearchIndex } from '../../lib/search'; import { perf } from '../../lib/perf'; -import { CheckCircle, AtSign, Link2, ChevronDown, ChevronRight, Pencil } from 'lucide-react'; +import { CheckCircle, AtSign, Link2, ChevronDown, ChevronRight, Pencil, Sparkles, X } from 'lucide-react'; import { Entity } from '../../lib/validation'; +import { extractEntities, EntityExtractionResult } from '../../lib/ai/entity-extractor'; +import { loadConfig, createProvider } from '../../lib/llm/config'; +import EntityReviewDialog from '../ai/EntityReviewDialog'; const ENTITY_TYPES = [ { value: 'note', label: 'Note' }, @@ -34,6 +37,12 @@ const Editor: React.FC = ({ editingEntityId, onEditComplete }) => { const [showAdvanced, setShowAdvanced] = useState(false); const [status, setStatus] = useState<{ type: 'success' | 'error', message: string } | null>(null); const [isLoadingEntity, setIsLoadingEntity] = useState(false); + const [isExtracting, setIsExtracting] = useState(false); + const [extractionResult, setExtractionResult] = useState(null); + const [showExtractionReview, setShowExtractionReview] = useState(false); + const [extractionSourceId, setExtractionSourceId] = useState(undefined); + const [showExtractionNotice, setShowExtractionNotice] = useState(false); + const editor = useEditor({ extensions: [ StarterKit, @@ -94,6 +103,31 @@ const Editor: React.FC = ({ editingEntityId, onEditComplete }) => { setSourceUrl(e.target.value); }, []); + const handleExtractEntities = useCallback(async (entityId?: string, forceContent?: string) => { + if (!editor || isExtracting) return; + + const content = forceContent || editor.getHTML(); + if (!content.trim() || content === '

') return; + + setIsExtracting(true); + try { + const config = await loadConfig(); + const provider = createProvider(config); + const providerConfig = config.providers[config.activeProvider]; + const model = providerConfig.defaultModel || 'google/gemini-2.0-flash-lite-preview-02-05:free'; + + const result = await extractEntities(content, provider, model); + setExtractionResult(result); + setExtractionSourceId(entityId || editingEntityId || undefined); + setShowExtractionNotice(true); + } catch (err) { + logger.error('Failed to extract entities', err); + setStatus({ type: 'error', message: 'Failed to extract entities with AI' }); + } finally { + setIsExtracting(false); + } + }, [editor, isExtracting, editingEntityId]); + const handleSave = useCallback(async () => { if (!title.trim() || !editor) return; @@ -190,6 +224,14 @@ const Editor: React.FC = ({ editingEntityId, onEditComplete }) => { jobCoordinator.enqueue('reindex-document', entity.id, { entityId: entity.id }); setStatus({ type: 'success', message: `Saved successfully! (${claims.length} claims, ${mentions.length} links)${sourceUrl.trim() ? ' — fetching source...' : ''}` }); + + // Auto-trigger extraction after 3s debounce + // Capture content before clearing editor + const savedContent = content; + setTimeout(() => { + void handleExtractEntities(entity.id, savedContent); + }, 3000); + setTitle(''); setSourceUrl(''); editor.commands.setContent('

'); @@ -272,6 +314,20 @@ const Editor: React.FC = ({ editingEntityId, onEditComplete }) => { >