Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
160 changes: 160 additions & 0 deletions src/features/ai/EntityReviewDialog.tsx
Original file line number Diff line number Diff line change
@@ -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<EntityReviewDialogProps> = ({

Check warning on line 15 in src/features/ai/EntityReviewDialog.tsx

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/features/ai/EntityReviewDialog.tsx#L15

Non-serializable expression must be wrapped with $(...)
result,
sourceNoteId,
onClose,
onComplete,
}) => {
const repository = useRepository();
const [selectedEntities, setSelectedEntities] = useState<string[]>(
result.entities.map(e => e.name)
);
const [selectedRelationships, setSelectedRelationships] = useState<string[]>(
result.relationships.map(r => `${r.from}->${r.to}`)
);
const [isApplying, setIsApplying] = useState(false);

const toggleEntity = (name: string) => {

Check warning on line 30 in src/features/ai/EntityReviewDialog.tsx

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/features/ai/EntityReviewDialog.tsx#L30

Non-serializable expression must be wrapped with $(...)
setSelectedEntities(prev =>
prev.includes(name) ? prev.filter(n => n !== name) : [...prev, name]
);
};

const toggleRelationship = (from: string, to: string) => {

Check warning on line 36 in src/features/ai/EntityReviewDialog.tsx

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/features/ai/EntityReviewDialog.tsx#L36

Non-serializable expression must be wrapped with $(...)
const key = `${from}->${to}`;
setSelectedRelationships(prev =>
prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]
);
};

const handleApply = async () => {

Check warning on line 43 in src/features/ai/EntityReviewDialog.tsx

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/features/ai/EntityReviewDialog.tsx#L43

Non-serializable expression must be wrapped with $(...)
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 (
<div className="modal-overlay" onClick={(e) => e.target === e.currentTarget && onClose()}>

Check warning on line 63 in src/features/ai/EntityReviewDialog.tsx

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/features/ai/EntityReviewDialog.tsx#L63

Enforce to have the onClick mouse event with the onKeyUp, the onKeyDown, or the onKeyPress keyboard event.

Check warning on line 63 in src/features/ai/EntityReviewDialog.tsx

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/features/ai/EntityReviewDialog.tsx#L63

Static Elements should not be interactive.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JSX tree is too deeply nested. Found 5 levels of nesting


Nesting JSX elements too deeply can confuse developers reading the code. To make maintenance and refactoring easier, DeepSource recommends limiting the maximum JSX tree depth to 4.

<div className="modal-content" style={{ maxWidth: '600px', maxHeight: '90vh', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<div className="inspector-header">
<h3><PlusCircle size={18} /> Review Extracted Entities</h3>
<button className="close-button" onClick={onClose} aria-label="Close">

Check warning on line 67 in src/features/ai/EntityReviewDialog.tsx

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/features/ai/EntityReviewDialog.tsx#L67

Provide an explicit type prop for the button element.
<X size={18} />
</button>
</div>

<div className="modal-body" style={{ overflowY: 'auto', padding: '16px', flex: 1 }}>
<p style={{ fontSize: '13px', color: 'var(--text-muted)', marginBottom: '16px' }}>
AI analyzed your note and found the following entities and relationships.
Select the ones you want to add to your knowledge graph.
</p>

<section style={{ marginBottom: '24px' }}>
<h4 style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px', fontSize: '14px' }}>
<CheckCircle2 size={16} /> Entities ({result.entities.length})
</h4>
<div className="entity-list" style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{result.entities.map((entity, idx) => (
<label key={idx} style={{

Check warning on line 84 in src/features/ai/EntityReviewDialog.tsx

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/features/ai/EntityReviewDialog.tsx#L84

Avoid using the index of an array as key property in an element.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do not use Array index in keys


When rendering a list of items in React, it is necessary to pass a "key" prop.
This key is used by React to identify which items have changed, are added, or are removed and should be stable.
It is not recommended to use the index of an element as key because it doesn't uniquely identify the element.
When elements are added/removed from an array, the index of an element may change, which will result in unnecessary re-renders.

display: 'flex',
alignItems: 'flex-start',
gap: '10px',
padding: '10px',
border: '1px solid var(--border-default)',
borderRadius: '6px',
cursor: 'pointer',
background: selectedEntities.includes(entity.name) ? 'var(--bg-surface-active)' : 'transparent'
}}>
<input
type="checkbox"
checked={selectedEntities.includes(entity.name)}
onChange={() => toggleEntity(entity.name)}

Check warning on line 97 in src/features/ai/EntityReviewDialog.tsx

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/features/ai/EntityReviewDialog.tsx#L97

Returning a void expression from an arrow function shorthand is forbidden. Please add braces to the arrow function.
style={{ marginTop: '3px' }}
/>
<div>
<div style={{ fontWeight: 600, fontSize: '14px' }}>{entity.name}</div>
<div style={{ fontSize: '11px', color: 'var(--interactive-primary)', textTransform: 'uppercase', marginBottom: '4px' }}>{entity.type}</div>
<div style={{ fontSize: '12px', color: 'var(--text-secondary)' }}>{entity.description}</div>
</div>
</label>
))}
</div>
</section>

<section>
<h4 style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px', fontSize: '14px' }}>
<Link2 size={16} /> Relationships ({result.relationships.length})
</h4>
<div className="relationship-list" style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{result.relationships.map((rel, idx) => {
const key = `${rel.from}->${rel.to}`;
return (
<label key={idx} style={{

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do not use Array index in keys


When rendering a list of items in React, it is necessary to pass a "key" prop.
This key is used by React to identify which items have changed, are added, or are removed and should be stable.
It is not recommended to use the index of an element as key because it doesn't uniquely identify the element.
When elements are added/removed from an array, the index of an element may change, which will result in unnecessary re-renders.

display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '10px',
border: '1px solid var(--border-default)',
borderRadius: '6px',
cursor: 'pointer',
background: selectedRelationships.includes(key) ? 'var(--bg-surface-active)' : 'transparent'
}}>
<input
type="checkbox"
checked={selectedRelationships.includes(key)}
onChange={() => toggleRelationship(rel.from, rel.to)}

Check warning on line 131 in src/features/ai/EntityReviewDialog.tsx

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/features/ai/EntityReviewDialog.tsx#L131

Returning a void expression from an arrow function shorthand is forbidden. Please add braces to the arrow function.
/>
<div style={{ fontSize: '13px' }}>
<span style={{ fontWeight: 600 }}>{rel.from}</span>
<span style={{ margin: '0 8px', color: 'var(--text-muted)' }}>— {rel.label} →</span>
<span style={{ fontWeight: 600 }}>{rel.to}</span>
</div>
</label>
);
})}
</div>
</section>
</div>

<div className="modal-actions" style={{ padding: '16px', borderTop: '1px solid var(--border-default)' }}>
<button onClick={onClose} disabled={isApplying}>Cancel</button>

Check warning on line 146 in src/features/ai/EntityReviewDialog.tsx

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/features/ai/EntityReviewDialog.tsx#L146

Provide an explicit type prop for the button element.
<button

Check warning on line 147 in src/features/ai/EntityReviewDialog.tsx

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/features/ai/EntityReviewDialog.tsx#L147

Provide an explicit type prop for the button element.
onClick={handleApply}

Check warning on line 148 in src/features/ai/EntityReviewDialog.tsx

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/features/ai/EntityReviewDialog.tsx#L148

Promise-returning function provided to attribute where a void return was expected.
className="primary"
disabled={isApplying || (selectedEntities.length === 0 && selectedRelationships.length === 0)}
>
{isApplying ? 'Adding...' : `Add Selected to Graph (${selectedEntities.length + selectedRelationships.length})`}
</button>
</div>
</div>
</div>
);
};

export default EntityReviewDialog;
108 changes: 107 additions & 1 deletion src/features/editor/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,11 @@
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' },
Expand All @@ -34,6 +37,12 @@
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<EntityExtractionResult | null>(null);
const [showExtractionReview, setShowExtractionReview] = useState(false);
const [extractionSourceId, setExtractionSourceId] = useState<string | undefined>(undefined);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove redundant `undefined` from function call


When an argument is omitted from a function call, it will default to undefined. It is therefore redundant to explicitly pass an undefined literal as the last argument.

const [showExtractionNotice, setShowExtractionNotice] = useState(false);

const editor = useEditor({
extensions: [
StarterKit,
Expand Down Expand Up @@ -94,6 +103,31 @@
setSourceUrl(e.target.value);
}, []);

const handleExtractEntities = useCallback(async (entityId?: string, forceContent?: string) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function has a cyclomatic complexity of 10 with "medium" risk


A function with high cyclomatic complexity can be hard to understand and
maintain. Cyclomatic complexity is a software metric that measures the number of
independent paths through a function. A higher cyclomatic complexity indicates
that the function has more decision points and is more complex.

if (!editor || isExtracting) return;

const content = forceContent || editor.getHTML();
if (!content.trim() || content === '<p></p>') 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;

Expand Down Expand Up @@ -190,6 +224,14 @@
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Expected 'undefined' and instead saw 'void'


The void operator takes an operand and returns undefined. It can be used to ignore the value produced by an expression. However, this can lead to code that is difficult to understand and maintain. Historically, the void operator was used to get a "pure" undefined value, as the undefined variable was mutable prior to ES5.

}, 3000);

setTitle('');
setSourceUrl('');
editor.commands.setContent('<p></p>');
Expand Down Expand Up @@ -272,6 +314,20 @@
>
<CheckCircle size={16} aria-hidden="true" /> Claim
</button>
<button
onClick={() => void handleExtractEntities()}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Expected 'undefined' and instead saw 'void'


The void operator takes an operand and returns undefined. It can be used to ignore the value produced by an expression. However, this can lead to code that is difficult to understand and maintain. Historically, the void operator was used to get a "pure" undefined value, as the undefined variable was mutable prior to ES5.

disabled={isExtracting}
title="Extract entities with AI"
aria-label="Extract entities with AI"
style={{ color: 'var(--interactive-primary)' }}
>
{isExtracting ? (
<span className="animate-spin" style={{ display: 'inline-block' }}>⌛</span>
) : (
<Sparkles size={16} aria-hidden="true" />
)}
AI Extract
</button>
<div className="toolbar-spacer" />
<button type="button" onClick={() => void handleSave()} className="primary">{editingEntityId ? 'Update Entity' : 'Save to DB'}</button>
{editingEntityId && (
Expand All @@ -282,6 +338,56 @@
</div>
<EditorContent editor={editor} className="tiptap-content" />

{showExtractionNotice && extractionResult && (
<div style={{
marginTop: '16px',
padding: '12px 16px',
background: 'var(--interactive-primary-subtle)',
borderRadius: '8px',
border: '1px solid var(--interactive-primary)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '12px'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '13px' }}>
<Sparkles size={16} style={{ color: 'var(--interactive-primary)' }} />
<span>
AI found <strong>{extractionResult.entities.length} entities</strong> and <strong>{extractionResult.relationships.length} relationships</strong> in this note.
</span>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button
onClick={() => setShowExtractionReview(true)}

Check warning on line 361 in src/features/editor/Editor.tsx

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/features/editor/Editor.tsx#L361

Returning a void expression from an arrow function shorthand is forbidden. Please add braces to the arrow function.
className="primary"
style={{ padding: '4px 12px', fontSize: '12px', minHeight: '32px' }}
>
Review
</button>
<button
onClick={() => setShowExtractionNotice(false)}

Check warning on line 368 in src/features/editor/Editor.tsx

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/features/editor/Editor.tsx#L368

Returning a void expression from an arrow function shorthand is forbidden. Please add braces to the arrow function.
style={{ padding: '4px 8px', fontSize: '12px', minHeight: '32px', background: 'transparent', border: 'none' }}
aria-label="Dismiss"
>
<X size={16} />
</button>
</div>
</div>
)}

{showExtractionReview && extractionResult && (
<EntityReviewDialog
result={extractionResult}
sourceNoteId={extractionSourceId}
onClose={() => setShowExtractionReview(false)}

Check warning on line 382 in src/features/editor/Editor.tsx

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/features/editor/Editor.tsx#L382

Returning a void expression from an arrow function shorthand is forbidden. Please add braces to the arrow function.
onComplete={() => {
setShowExtractionNotice(false);
setExtractionResult(null);
onEditComplete?.();
}}
/>
)}

<button
onClick={() => setShowAdvanced(!showAdvanced)}
className="advanced-toggle"
Expand Down
2 changes: 2 additions & 0 deletions src/features/editor/__tests__/Editor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ vi.mock('lucide-react', () => ({
ChevronDown: () => <div />,
ChevronRight: () => <div />,
Pencil: () => <div />,
Sparkles: () => <div />,
X: () => <div />,
}));

vi.mock('../ClaimExtension', () => ({
Expand Down
Loading
Loading