From 221dd69f490117d68b46085dd26662057e6eae15 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Fri, 26 Dec 2025 16:14:17 -0500 Subject: [PATCH] bro --- src/components/blog/TextEditor.tsx | 362 +++++++++++++++++++++++++---- 1 file changed, 314 insertions(+), 48 deletions(-) diff --git a/src/components/blog/TextEditor.tsx b/src/components/blog/TextEditor.tsx index 0f60bbb..7b54b6f 100644 --- a/src/components/blog/TextEditor.tsx +++ b/src/components/blog/TextEditor.tsx @@ -258,6 +258,20 @@ const KEYBOARD_SHORTCUTS: ShortcutCategory[] = [ }, { keys: "ESC", keysAlt: "ESC", description: "Exit Fullscreen" } ] + }, + { + name: "AI Autocomplete (Admin)", + shortcuts: [ + { + keys: "⌘ Space", + keysAlt: "Ctrl Space", + description: "Trigger AI suggestion" + }, + { keys: "→", keysAlt: "Right", description: "Accept word" }, + { keys: "⌥ Tab", keysAlt: "Alt Tab", description: "Accept line" }, + { keys: "⇧ Tab", keysAlt: "Shift Tab", description: "Accept full" }, + { keys: "ESC", keysAlt: "ESC", description: "Cancel suggestion" } + ] } ]; @@ -341,6 +355,77 @@ const IframeEmbed = Node.create({ }; } }); +const CONTEXT_SIZE = 128; + +// Custom Reference mark extension +import { Extension } from "@tiptap/core"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { Decoration, DecorationSet } from "@tiptap/pm/view"; + +// Suggestion decoration extension - shows inline AI suggestions +const SuggestionDecoration = Extension.create({ + name: "suggestionDecoration", + + addProseMirrorPlugins() { + const editor = this.editor; + + return [ + new Plugin({ + key: new PluginKey("suggestionDecoration"), + state: { + init() { + return DecorationSet.empty; + }, + apply(tr, oldSet, oldState, newState) { + // Get suggestion from editor storage + const suggestion = + (editor.storage as any).suggestionDecoration?.text || ""; + + if (!suggestion) { + return DecorationSet.empty; + } + + const { selection } = newState; + const pos = selection.$anchor.pos; + + // Create a widget decoration at cursor position + const decoration = Decoration.widget( + pos, + () => { + const span = document.createElement("span"); + span.textContent = suggestion; + span.style.color = "rgb(239, 68, 68)"; // Tailwind red-500 + span.style.opacity = "0.5"; + span.style.fontStyle = "italic"; + span.style.fontFamily = "monospace"; + span.style.pointerEvents = "none"; + span.style.whiteSpace = "pre-wrap"; + span.style.wordWrap = "break-word"; + return span; + }, + { + side: 1 // Place after the cursor + } + ); + + return DecorationSet.create(newState.doc, [decoration]); + } + }, + props: { + decorations(state) { + return this.getState(state); + } + } + }) + ]; + }, + + addStorage() { + return { + text: "" + }; + } +}); // Custom Reference mark extension import { Mark, mergeAttributes } from "@tiptap/core"; @@ -697,14 +782,25 @@ export default function TextEditor(props: TextEditorProps) { const config = await api.infill.getConfig.query(); if (config.endpoint && config.token) { setInfillConfig({ endpoint: config.endpoint, token: config.token }); - console.log("✅ Infill enabled for admin"); } } catch (error) { console.error("Failed to fetch infill config:", error); } }); - // Request LLM infill suggestion + // Update suggestion: Store in editor and force view update + createEffect(() => { + const instance = editor(); + const suggestion = currentSuggestion(); + + if (instance) { + // Store suggestion in editor storage (cast to any to avoid TS error) + (instance.storage as any).suggestionDecoration = { text: suggestion }; + // Force view update to show/hide decoration + instance.view.dispatch(instance.state.tr); + } + }); + const requestInfill = async (): Promise => { const config = infillConfig(); if (!config) return; @@ -713,25 +809,32 @@ export default function TextEditor(props: TextEditorProps) { if (!context) return; setIsInfillLoading(true); + try { + // llama.cpp infill format + const requestBody = { + input_prefix: context.prefix, + input_suffix: context.suffix, + n_predict: 100, + temperature: 0.3, + stop: ["\n\n", "", "<|endoftext|>"], + stream: false + }; + + console.log("[Infill] Request:", { + prefix: context.prefix, + suffix: context.suffix, + prefixLength: context.prefix.length, + suffixLength: context.suffix.length + }); + const response = await fetch(config.endpoint, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${config.token}` }, - body: JSON.stringify({ - model: "default", - messages: [ - { - role: "user", - content: `Continue writing from this context:\n\nBefore cursor: ${context.prefix}\n\nAfter cursor: ${context.suffix}` - } - ], - max_tokens: 100, - temperature: 0.3, - stop: ["\n\n"] - }) + body: JSON.stringify(requestBody) }); if (!response.ok) { @@ -739,7 +842,9 @@ export default function TextEditor(props: TextEditorProps) { } const data = await response.json(); - const suggestion = data.choices?.[0]?.message?.content || ""; + + // llama.cpp infill format returns { content: "..." } + const suggestion = data.content || ""; if (suggestion.trim()) { setCurrentSuggestion(suggestion.trim()); @@ -752,6 +857,60 @@ export default function TextEditor(props: TextEditorProps) { } }; + // Helper to check if suggestion is active + const hasSuggestion = () => currentSuggestion().length > 0; + + // Accept next word from suggestion + const acceptWord = () => { + const suggestion = currentSuggestion(); + if (!suggestion) return; + + // Take first word (split on whitespace) + const words = suggestion.split(/\s+/); + const firstWord = words[0] || ""; + + const instance = editor(); + if (instance) { + instance.commands.insertContent(firstWord + " "); + } + + // Update suggestion to remaining text + const remaining = words.slice(1).join(" "); + setCurrentSuggestion(remaining); + }; + + // Accept current line from suggestion + const acceptLine = () => { + const suggestion = currentSuggestion(); + if (!suggestion) return; + + // Take up to first newline + const lines = suggestion.split("\n"); + const firstLine = lines[0] || ""; + + const instance = editor(); + if (instance) { + instance.commands.insertContent(firstLine); + } + + // Update suggestion to remaining text + const remaining = lines.slice(1).join("\n"); + setCurrentSuggestion(remaining); + }; + + // Accept full suggestion + const acceptFull = () => { + const suggestion = currentSuggestion(); + if (!suggestion) return; + + const instance = editor(); + if (instance) { + instance.commands.insertContent(suggestion); + } + + setCurrentSuggestion(""); + }; + // Capture history snapshot const captureHistory = async (editorInstance: any) => { // Skip if initial load @@ -926,7 +1085,7 @@ export default function TextEditor(props: TextEditorProps) { } }; - // Extract editor context for LLM infill (512 chars before/after cursor) + // Extract editor context for LLM infill (CONTEXT_SIZE chars before/after cursor) const getEditorContext = (): { prefix: string; suffix: string; @@ -937,20 +1096,43 @@ export default function TextEditor(props: TextEditorProps) { const { state } = instance; const cursorPos = state.selection.$anchor.pos; - const text = state.doc.textContent; + // Convert ProseMirror position to text offset + // We need to count actual text characters, not node positions + let textOffset = 0; + let reachedCursor = false; + + state.doc.descendants((node, pos) => { + if (reachedCursor) return false; // Stop traversing + + if (node.isText) { + const nodeEnd = pos + node.nodeSize; + if (cursorPos <= nodeEnd) { + // Cursor is within or right after this text node + textOffset += Math.min(cursorPos - pos, node.text?.length || 0); + reachedCursor = true; + return false; + } + textOffset += node.text?.length || 0; + } + }); + + const text = state.doc.textContent; if (text.length === 0) return null; - const prefix = text.slice(Math.max(0, cursorPos - 512), cursorPos); + const prefix = text.slice( + Math.max(0, textOffset - CONTEXT_SIZE), + textOffset + ); const suffix = text.slice( - cursorPos, - Math.min(text.length, cursorPos + 512) + textOffset, + Math.min(text.length, textOffset + CONTEXT_SIZE) ); return { prefix, suffix, - cursorPos + cursorPos: textOffset }; }; @@ -1016,6 +1198,7 @@ export default function TextEditor(props: TextEditorProps) { }), Superscript, Subscript, + SuggestionDecoration, Reference, ReferenceSectionMarker ], @@ -1053,18 +1236,71 @@ export default function TextEditor(props: TextEditorProps) { } }, 100); } + + // CRITICAL FIX: Always set isInitialLoad to false after a delay + // This ensures infill works regardless of how content was loaded + setTimeout(() => { + isInitialLoad = false; + }, 1000); }, editorProps: { attributes: { class: "focus:outline-none" }, handleKeyDown(view, event) { + // Trigger infill: Ctrl+Space (or Cmd+Space) + if ((event.ctrlKey || event.metaKey) && event.key === " ") { + event.preventDefault(); + requestInfill(); + return true; + } + + // Cancel suggestion: Escape + if (event.key === "Escape" && hasSuggestion()) { + event.preventDefault(); + setCurrentSuggestion(""); + return true; + } + + // Accept word: Right Arrow (only when suggestion active) + if ( + event.key === "ArrowRight" && + hasSuggestion() && + !event.shiftKey && + !event.ctrlKey && + !event.metaKey + ) { + event.preventDefault(); + acceptWord(); + return true; + } + + // Accept line: Alt+Tab + if (event.altKey && event.key === "Tab" && hasSuggestion()) { + event.preventDefault(); + acceptLine(); + return true; + } + + // Accept full: Shift+Tab + if ( + event.shiftKey && + event.key === "Tab" && + hasSuggestion() && + !event.altKey + ) { + event.preventDefault(); + acceptFull(); + return true; + } + // Cmd/Ctrl+R for inserting reference if ((event.metaKey || event.ctrlKey) && event.key === "r") { event.preventDefault(); insertReference(); return true; } + return false; }, handleClickOn(view, pos, node, nodePos, event) { @@ -1114,6 +1350,16 @@ export default function TextEditor(props: TextEditorProps) { captureHistory(editor); }, 2000); } + + // Debounced infill trigger (250ms) + if (infillConfig() && !isInitialLoad) { + if (infillDebounceTimer) { + clearTimeout(infillDebounceTimer); + } + infillDebounceTimer = setTimeout(() => { + requestInfill(); + }, 250); + } }); }, onSelectionUpdate: ({ editor }) => { @@ -1145,42 +1391,55 @@ export default function TextEditor(props: TextEditorProps) { () => props.preSet, async (newContent) => { const instance = editor(); - if (instance && newContent && instance.getHTML() !== newContent) { - console.log("[History] Initial content load, postId:", props.postId); - instance.commands.setContent(newContent, { emitUpdate: false }); - // Reset the load attempt flag when content changes - hasAttemptedHistoryLoad = false; + if (instance && newContent) { + const currentHTML = instance.getHTML(); + const contentMatches = currentHTML === newContent; - // Load history from database if postId is provided - if (props.postId) { - await loadHistoryFromDB(); + if (!contentMatches) { console.log( - "[History] After load, history length:", - history().length + "[History] Initial content load, postId:", + props.postId ); - } + instance.commands.setContent(newContent, { emitUpdate: false }); - // Migrate legacy superscript references to Reference marks - setTimeout(() => migrateLegacyReferences(instance), 50); + // Reset the load attempt flag when content changes + hasAttemptedHistoryLoad = false; - // Capture initial state in history only if no history was loaded - setTimeout(() => { - if (history().length === 0) { + // Load history from database if postId is provided + if (props.postId) { + await loadHistoryFromDB(); console.log( - "[History] No history found, capturing initial state" - ); - captureHistory(instance); - } else { - console.log( - "[History] Skipping initial capture, have", - history().length, - "entries" + "[History] After load, history length:", + history().length ); } - // Mark initial load as complete - now edits will be captured - isInitialLoad = false; - }, 200); + + // Migrate legacy superscript references to Reference marks + setTimeout(() => migrateLegacyReferences(instance), 50); + + // Capture initial state in history only if no history was loaded + setTimeout(() => { + if (history().length === 0) { + console.log( + "[History] No history found, capturing initial state" + ); + captureHistory(instance); + } else { + console.log( + "[History] Skipping initial capture, have", + history().length, + "entries" + ); + } + isInitialLoad = false; + }, 200); + } else { + // Content already matches - this is the initial load case + setTimeout(() => { + isInitialLoad = false; + }, 500); + } } }, { defer: true } @@ -3626,6 +3885,13 @@ export default function TextEditor(props: TextEditorProps) { + + {/* Infill Loading Indicator */} + +
+ AI thinking... +
+
); }