feat: implement inline autocomplete suggestions with FastAPI backend and Milkdown editor integration

This commit is contained in:
2026-01-18 19:42:58 +08:00
parent ba49f82953
commit 6102f905ee
10 changed files with 633 additions and 52 deletions
+104
View File
@@ -0,0 +1,104 @@
import { Plugin, PluginKey } from '@milkdown/prose/state';
import { EditorView } from '@milkdown/prose/view';
const INLINE_SUGGESTION_KEY = new PluginKey('inline-suggestion');
const DEBOUNCE_MS = 150;
let debounceTimer = null;
let currentSuggestion = '';
let suggestionPos = { from: 0, to: 0 };
interface InlineSuggestionOptions {
apiUrl?: string;
}
function createInlineSuggestionPlugin(options: InlineSuggestionOptions = {}) {
const apiUrl = options.apiUrl || 'http://localhost:8000/v1/completions';
return new Plugin({
key: INLINE_SUGGESTION_KEY,
state: {
init: () => ({ suggestion: '', visible: false }),
apply: (tr, value) => {
if (!tr.docChanged) return value;
const { from, to } = tr.selection;
if (from === suggestionPos.from && to === suggestionPos.to) {
return value;
}
return { suggestion: '', visible: false };
},
},
props: {
handleKeyDown: (view: EditorView, event: KeyboardEvent) => {
if (event.key === 'Tab' && INLINE_SUGGESTION_KEY.getState(view.state).visible) {
event.preventDefault();
const { suggestion } = INLINE_SUGGESTION_KEY.getState(view.state);
if (suggestion) {
view.dispatch(view.state.tr.insertText(suggestion, view.state.selection.from));
currentSuggestion = '';
return true;
}
}
if (event.key === 'Escape') {
const state = INLINE_SUGGESTION_KEY.getState(view.state);
if (state.visible) {
view.dispatch(view.state.tr.setMeta(INLINE_SUGGESTION_KEY, { suggestion: '', visible: false }));
currentSuggestion = '';
return true;
}
}
return false;
},
},
appendTransaction: (transactions, oldState, newState) => {
const lastTr = transactions[transactions.length - 1];
if (!lastTr || !lastTr.docChanged) return null;
clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
const { from, to } = newState.selection;
const prefix = newState.doc.textBetween(0, from);
const suffix = newState.doc.textBetween(to, newState.doc.content.size);
try {
const res = await fetch(apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prefix, suffix, languageId: 'markdown' }),
});
if (!res.ok) return;
const reader = res.body?.getReader();
if (!reader) return;
let text = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = new TextDecoder().decode(value);
const lines = chunk.split('\n').filter(l => l.startsWith('data: '));
for (const line of lines) {
try {
const data = JSON.parse(line.slice(6));
if (data.content) text += data.content;
if (data.done) break;
} catch {}
}
}
if (text && newState.selection.from === from) {
currentSuggestion = text;
suggestionPos = { from, to: from + text.length };
newState.apply(newState.tr.setMeta(INLINE_SUGGESTION_KEY, { suggestion: text, visible: true }));
}
} catch (e) {
console.error('Inline suggestion error:', e);
}
}, DEBOUNCE_MS);
return null;
},
});
}
export { createInlineSuggestionPlugin, INLINE_SUGGESTION_KEY };