Skip to content

Commit b1f3671

Browse files
committed
Spike: Add inline AI autocomplete for Lexical editor
Implements a prototype inline autocomplete feature for the custom Lexical editor: Backend changes: - Add new 'inline_autocomplete' prompt type in nlp.py that generates short, contextual text continuations (3-10 words) Frontend changes: - Create InlineAutocompletePlugin component with: * Debounced trigger (500ms after typing stops) * Ghost text overlay showing AI suggestions in gray * Tab or Right Arrow to accept suggestions * Escape or continued typing to reject suggestions - Integrate plugin into LexicalEditor component - Enable autocomplete by default in demo mode The feature provides real-time writing assistance without interrupting the writer's flow, only triggering suggestions during natural pauses.
1 parent c488664 commit b1f3671

File tree

4 files changed

+296
-0
lines changed

4 files changed

+296
-0
lines changed

backend/nlp.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,17 @@ async def warmup_nlp():
5050

5151

5252
prompts = {
53+
"inline_autocomplete": """\
54+
You are providing inline autocomplete suggestions to assist a writer as they type. Generate a short, natural continuation of the text that appears immediately after the cursor position.
55+
56+
Guidelines:
57+
- Generate only the next few words (typically 3-10 words) that would naturally continue from the cursor position.
58+
- If the writer is mid-sentence, complete the current thought naturally.
59+
- If the writer just finished a sentence, suggest the beginning of the next sentence.
60+
- Match the writer's tone, style, and vocabulary level.
61+
- Keep suggestions concise and relevant to the immediate context.
62+
- Return ONLY the suggested text continuation, with no additional formatting, quotation marks, or explanations.
63+
""",
5364
"example_sentences": """\
5465
You are assisting a writer in drafting a document. Generate three possible options for inspiring and fresh possible next sentences that would help the writer think about what they should write next.
5566
Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
/**
2+
* @format
3+
* Inline autocomplete plugin for Lexical editor
4+
*/
5+
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
6+
import {
7+
$getSelection,
8+
$isRangeSelection,
9+
COMMAND_PRIORITY_HIGH,
10+
KEY_ARROW_RIGHT_COMMAND,
11+
KEY_ESCAPE_COMMAND,
12+
KEY_TAB_COMMAND,
13+
type LexicalEditor,
14+
} from 'lexical';
15+
import { useEffect, useRef, useState } from 'react';
16+
17+
const SERVER_URL = import.meta.env.VITE_SERVER_URL || 'http://localhost:8000';
18+
const DEBOUNCE_MS = 500; // Wait 500ms after typing stops
19+
20+
interface InlineAutocompletePluginProps {
21+
username?: string;
22+
enabled?: boolean;
23+
}
24+
25+
function $getDocContext(): DocContext {
26+
const docContext: DocContext = {
27+
beforeCursor: '',
28+
selectedText: '',
29+
afterCursor: '',
30+
};
31+
32+
const selection = $getSelection();
33+
if (!$isRangeSelection(selection)) {
34+
return docContext;
35+
}
36+
37+
docContext.selectedText = selection.getTextContent();
38+
39+
// Get simple text context (simplified version)
40+
const anchor = selection.anchor;
41+
const anchorNode = anchor.getNode();
42+
const textContent = anchorNode.getTextContent();
43+
const offset = anchor.offset;
44+
45+
docContext.beforeCursor = textContent.substring(0, offset);
46+
docContext.afterCursor = textContent.substring(offset);
47+
48+
return docContext;
49+
}
50+
51+
function InlineAutocompletePlugin({
52+
username = '',
53+
enabled = true,
54+
}: InlineAutocompletePluginProps) {
55+
const [editor] = useLexicalComposerContext();
56+
const [suggestion, setSuggestion] = useState<string>('');
57+
const [cursorPosition, setCursorPosition] = useState<{
58+
x: number;
59+
y: number;
60+
} | null>(null);
61+
const debounceTimer = useRef<NodeJS.Timeout | null>(null);
62+
const abortController = useRef<AbortController | null>(null);
63+
const [isLoading, setIsLoading] = useState(false);
64+
65+
// Fetch autocomplete suggestion from backend
66+
const fetchSuggestion = async (docContext: DocContext) => {
67+
// Cancel any ongoing request
68+
if (abortController.current) {
69+
abortController.current.abort();
70+
}
71+
72+
// Don't fetch if there's no text before cursor
73+
if (docContext.beforeCursor.trim().length < 3) {
74+
setSuggestion('');
75+
return;
76+
}
77+
78+
abortController.current = new AbortController();
79+
setIsLoading(true);
80+
81+
try {
82+
const response = await fetch(`${SERVER_URL}/api/get_suggestion`, {
83+
method: 'POST',
84+
headers: {
85+
'Content-Type': 'application/json',
86+
},
87+
body: JSON.stringify({
88+
username: username,
89+
gtype: 'inline_autocomplete',
90+
doc_context: docContext,
91+
}),
92+
signal: abortController.current.signal,
93+
});
94+
95+
if (!response.ok) {
96+
console.error('Failed to fetch suggestion:', response.statusText);
97+
setSuggestion('');
98+
return;
99+
}
100+
101+
const data = await response.json();
102+
const suggestionText = data.result?.trim() || '';
103+
104+
// Clean up the suggestion (remove quotes if present)
105+
const cleanedSuggestion = suggestionText.replace(/^["']|["']$/g, '');
106+
107+
setSuggestion(cleanedSuggestion);
108+
} catch (error: any) {
109+
if (error.name !== 'AbortError') {
110+
console.error('Error fetching autocomplete:', error);
111+
}
112+
setSuggestion('');
113+
} finally {
114+
setIsLoading(false);
115+
}
116+
};
117+
118+
// Get cursor position for overlay
119+
const updateCursorPosition = () => {
120+
const selection = window.getSelection();
121+
if (selection && selection.rangeCount > 0) {
122+
const range = selection.getRangeAt(0);
123+
const rect = range.getBoundingClientRect();
124+
setCursorPosition({
125+
x: rect.left,
126+
y: rect.top,
127+
});
128+
}
129+
};
130+
131+
// Accept suggestion
132+
const acceptSuggestion = () => {
133+
if (!suggestion) return;
134+
135+
editor.update(() => {
136+
const selection = $getSelection();
137+
if ($isRangeSelection(selection)) {
138+
selection.insertText(suggestion);
139+
}
140+
});
141+
142+
setSuggestion('');
143+
};
144+
145+
// Reject suggestion
146+
const rejectSuggestion = () => {
147+
setSuggestion('');
148+
};
149+
150+
// Set up keyboard handlers
151+
useEffect(() => {
152+
if (!enabled) return;
153+
154+
// Handle Tab key to accept
155+
const removeTabCommand = editor.registerCommand(
156+
KEY_TAB_COMMAND,
157+
(event: KeyboardEvent) => {
158+
if (suggestion) {
159+
event.preventDefault();
160+
acceptSuggestion();
161+
return true; // Handled
162+
}
163+
return false; // Not handled
164+
},
165+
COMMAND_PRIORITY_HIGH,
166+
);
167+
168+
// Handle Right Arrow to accept
169+
const removeArrowCommand = editor.registerCommand(
170+
KEY_ARROW_RIGHT_COMMAND,
171+
(event: KeyboardEvent) => {
172+
if (suggestion) {
173+
event.preventDefault();
174+
acceptSuggestion();
175+
return true;
176+
}
177+
return false;
178+
},
179+
COMMAND_PRIORITY_HIGH,
180+
);
181+
182+
// Handle Escape to reject
183+
const removeEscapeCommand = editor.registerCommand(
184+
KEY_ESCAPE_COMMAND,
185+
(event: KeyboardEvent) => {
186+
if (suggestion) {
187+
event.preventDefault();
188+
rejectSuggestion();
189+
return true;
190+
}
191+
return false;
192+
},
193+
COMMAND_PRIORITY_HIGH,
194+
);
195+
196+
return () => {
197+
removeTabCommand();
198+
removeArrowCommand();
199+
removeEscapeCommand();
200+
};
201+
}, [editor, suggestion, enabled]);
202+
203+
// Listen to editor changes and trigger autocomplete
204+
useEffect(() => {
205+
if (!enabled) return;
206+
207+
const removeUpdateListener = editor.registerUpdateListener(
208+
({ editorState, dirtyElements, dirtyLeaves }) => {
209+
// Only trigger if there are actual changes
210+
if (dirtyElements.size === 0 && dirtyLeaves.size === 0) {
211+
return;
212+
}
213+
214+
// Clear any existing suggestion when user types
215+
if (suggestion) {
216+
setSuggestion('');
217+
}
218+
219+
// Clear existing timer
220+
if (debounceTimer.current) {
221+
clearTimeout(debounceTimer.current);
222+
}
223+
224+
// Set new timer
225+
debounceTimer.current = setTimeout(() => {
226+
editorState.read(() => {
227+
const docContext = $getDocContext();
228+
updateCursorPosition();
229+
fetchSuggestion(docContext);
230+
});
231+
}, DEBOUNCE_MS);
232+
},
233+
);
234+
235+
return () => {
236+
removeUpdateListener();
237+
if (debounceTimer.current) {
238+
clearTimeout(debounceTimer.current);
239+
}
240+
if (abortController.current) {
241+
abortController.current.abort();
242+
}
243+
};
244+
}, [editor, enabled, suggestion]);
245+
246+
// Render ghost text overlay
247+
if (!suggestion || !cursorPosition) {
248+
return null;
249+
}
250+
251+
return (
252+
<div
253+
style={{
254+
position: 'fixed',
255+
left: `${cursorPosition.x}px`,
256+
top: `${cursorPosition.y}px`,
257+
color: '#94a3b8', // Gray color for ghost text
258+
pointerEvents: 'none',
259+
whiteSpace: 'pre',
260+
fontFamily: 'inherit',
261+
fontSize: 'inherit',
262+
lineHeight: 'inherit',
263+
zIndex: 1000,
264+
}}
265+
>
266+
{suggestion}
267+
</div>
268+
);
269+
}
270+
271+
export default InlineAutocompletePlugin;

frontend/src/editor/editor.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
} from 'lexical';
2121

2222
import classes from './editor.module.css';
23+
import InlineAutocompletePlugin from './InlineAutocompletePlugin';
2324

2425
function $getDocContext(): DocContext {
2526
// Initialize default empty context
@@ -156,11 +157,15 @@ function LexicalEditor({
156157
initialState,
157158
storageKey = 'doc',
158159
preamble,
160+
enableAutocomplete = false,
161+
username = '',
159162
}: {
160163
updateDocContext: (docContext: DocContext) => void;
161164
initialState: InitialEditorStateType | null;
162165
storageKey?: string;
163166
preamble?: JSX.Element;
167+
enableAutocomplete?: boolean;
168+
username?: string;
164169
}) {
165170
return (
166171
<LexicalComposer // Main editor component
@@ -209,6 +214,13 @@ function LexicalEditor({
209214
<AutoFocusPlugin />
210215

211216
<HistoryPlugin />
217+
218+
{enableAutocomplete && (
219+
<InlineAutocompletePlugin
220+
username={username}
221+
enabled={enableAutocomplete}
222+
/>
223+
)}
212224
</div>
213225
</div>
214226
</LexicalComposer>

frontend/src/editor/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,8 @@ export function EditorScreen({
150150
updateDocContext={docUpdated}
151151
storageKey={getStorageKey()}
152152
preamble={editorPreamble}
153+
enableAutocomplete={isDemo}
154+
username={username}
153155
/>
154156
{isDemo || isStudy ? (
155157
<div className={`${classes.wordCount}`}>

0 commit comments

Comments
 (0)