Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 214 lines 6.3 kB view raw
1/** 2 * AI Doc Action Executor — applies AI actions to TipTap documents. 3 * 4 * Supports inserting, replacing, and suggesting changes (track changes). 5 * All operations go through TipTap's transaction system, so they are 6 * Yjs-aware and will sync to collaborators. 7 */ 8 9import type { Editor } from '@tiptap/core'; 10import { createSuggestionAttrs } from '../lib/suggesting.js'; 11import type { DocAction, DocInsertAction, DocReplaceAction, DocSuggestInsertAction, DocSuggestReplaceAction } from '../lib/ai-actions.js'; 12 13export interface ActionResult { 14 success: boolean; 15 error?: string; 16} 17 18/** 19 * Execute a doc action on the TipTap editor. 20 */ 21export function executeDocAction( 22 editor: Editor, 23 action: DocAction, 24): ActionResult { 25 switch (action.type) { 26 case 'doc_insert': 27 return executeInsert(editor, action); 28 case 'doc_replace': 29 return executeReplace(editor, action); 30 case 'doc_suggest_insert': 31 return executeSuggestInsert(editor, action); 32 case 'doc_suggest_replace': 33 return executeSuggestReplace(editor, action); 34 } 35} 36 37function resolvePosition(editor: Editor, position: 'cursor' | 'start' | 'end'): number { 38 switch (position) { 39 case 'cursor': 40 return editor.state.selection.head; 41 case 'start': 42 return 1; 43 case 'end': 44 return editor.state.doc.content.size - 1; 45 } 46} 47 48function executeInsert(editor: Editor, action: DocInsertAction): ActionResult { 49 const pos = resolvePosition(editor, action.position); 50 const success = editor.chain() 51 .focus() 52 .insertContentAt(pos, action.content) 53 .run(); 54 return { success }; 55} 56 57function executeReplace(editor: Editor, action: DocReplaceAction): ActionResult { 58 const { state } = editor; 59 const docText = state.doc.textContent; 60 const searchIdx = docText.indexOf(action.search); 61 62 if (searchIdx === -1) { 63 return { success: false, error: `Text not found: "${action.search.slice(0, 50)}"` }; 64 } 65 66 // Map text offset to ProseMirror position 67 // Walk the document to find the actual position 68 let found = false; 69 const searchLen = action.search.length; 70 71 state.doc.descendants((node, pos) => { 72 if (found || !node.isText) return; 73 const nodeText = node.text || ''; 74 const idx = nodeText.indexOf(action.search); 75 if (idx !== -1) { 76 const from = pos + idx; 77 const to = from + searchLen; 78 editor.chain() 79 .focus() 80 .command(({ tr }) => { 81 tr.replaceWith(from, to, action.replace 82 ? state.schema.text(action.replace) 83 : state.schema.text('')); 84 return true; 85 }) 86 .run(); 87 found = true; 88 } 89 }); 90 91 if (!found) { 92 // Fallback: try across node boundaries using the full-doc text 93 // This handles cases where the search text spans multiple nodes 94 let textOffset = 0; 95 let fromPos = -1; 96 97 state.doc.descendants((node, pos) => { 98 if (fromPos !== -1) return; 99 if (node.isText) { 100 const nodeText = node.text || ''; 101 // Check if search starts in this node 102 const remaining = action.search.slice(0); 103 const localStart = docText.indexOf(remaining, textOffset) - textOffset; 104 if (localStart >= 0 && localStart < nodeText.length) { 105 fromPos = pos + localStart; 106 } 107 textOffset += nodeText.length; 108 } 109 }); 110 111 if (fromPos !== -1) { 112 const toPos = fromPos + searchLen; 113 editor.chain() 114 .focus() 115 .command(({ tr }) => { 116 if (action.replace) { 117 tr.replaceWith(fromPos, toPos, state.schema.text(action.replace)); 118 } else { 119 tr.delete(fromPos, toPos); 120 } 121 return true; 122 }) 123 .run(); 124 found = true; 125 } 126 } 127 128 return found 129 ? { success: true } 130 : { success: false, error: `Could not locate text in document structure` }; 131} 132 133function executeSuggestInsert(editor: Editor, action: DocSuggestInsertAction): ActionResult { 134 const pos = resolvePosition(editor, action.position); 135 const attrs = createSuggestionAttrs({ type: 'insert', author: 'AI' }); 136 const markType = editor.state.schema.marks['suggestion-insert']; 137 138 if (!markType) { 139 return { success: false, error: 'Suggestion marks not available in this editor' }; 140 } 141 142 const success = editor.chain() 143 .focus() 144 .command(({ tr }) => { 145 const mark = markType.create(attrs); 146 const textNode = editor.state.schema.text(action.content, [mark]); 147 tr.insert(pos, textNode); 148 return true; 149 }) 150 .run(); 151 152 return { success }; 153} 154 155function executeSuggestReplace(editor: Editor, action: DocSuggestReplaceAction): ActionResult { 156 const { state } = editor; 157 const suggestionId = createSuggestionAttrs({ type: 'delete', author: 'AI' }).suggestionId; 158 const deleteMarkType = state.schema.marks['suggestion-delete']; 159 const insertMarkType = state.schema.marks['suggestion-insert']; 160 161 if (!deleteMarkType || !insertMarkType) { 162 return { success: false, error: 'Suggestion marks not available in this editor' }; 163 } 164 165 // Find the search text in the document 166 let fromPos = -1; 167 let toPos = -1; 168 169 state.doc.descendants((node, pos) => { 170 if (fromPos !== -1) return; 171 if (node.isText) { 172 const nodeText = node.text || ''; 173 const idx = nodeText.indexOf(action.search); 174 if (idx !== -1) { 175 fromPos = pos + idx; 176 toPos = fromPos + action.search.length; 177 } 178 } 179 }); 180 181 if (fromPos === -1) { 182 return { success: false, error: `Text not found: "${action.search.slice(0, 50)}"` }; 183 } 184 185 const timestamp = new Date().toISOString(); 186 187 const success = editor.chain() 188 .focus() 189 .command(({ tr }) => { 190 // Mark the old text as suggestion-delete 191 const deleteMark = deleteMarkType.create({ 192 suggestionId, 193 author: 'AI', 194 type: 'delete', 195 timestamp, 196 }); 197 tr.addMark(fromPos, toPos, deleteMark); 198 199 // Insert replacement text with suggestion-insert mark right after 200 const insertMark = insertMarkType.create({ 201 suggestionId, 202 author: 'AI', 203 type: 'insert', 204 timestamp, 205 }); 206 const textNode = state.schema.text(action.replace, [insertMark]); 207 tr.insert(toPos, textNode); 208 209 return true; 210 }) 211 .run(); 212 213 return { success }; 214}