diff --git a/packages/ai/src/ai-actions/__tests__/editor/editor-adapter.test.ts b/packages/ai/src/ai-actions/__tests__/editor/editor-adapter.test.ts index 1255cf4bdc..7d6c4bf109 100644 --- a/packages/ai/src/ai-actions/__tests__/editor/editor-adapter.test.ts +++ b/packages/ai/src/ai-actions/__tests__/editor/editor-adapter.test.ts @@ -656,6 +656,98 @@ describe('EditorAdapter', () => { }); }); + describe('insertFormattedContent', () => { + it('calls editor.commands.insertContent with contentType for html', () => { + updateEditorState(defaultSegments, { from: 1, to: 5 }); + + mockAdapter.insertFormattedContent('
Hello link
', { + contentType: 'html', + position: 'replace', + }); + + expect(mockEditor.commands.setTextSelection).toHaveBeenCalledWith({ from: 1, to: 5 }); + expect(mockEditor.commands.insertContent).toHaveBeenCalledWith( + 'Hello link
', + { contentType: 'html' }, + ); + }); + + it('calls editor.commands.insertContent with contentType for markdown', () => { + updateEditorState(defaultSegments, { from: 1, to: 5 }); + + mockAdapter.insertFormattedContent('# Heading\n\n[link](https://example.com)', { + contentType: 'markdown', + position: 'replace', + }); + + expect(mockEditor.commands.insertContent).toHaveBeenCalledWith('# Heading\n\n[link](https://example.com)', { + contentType: 'markdown', + }); + }); + + it('falls back to insertText for contentType: text', () => { + updateEditorState(defaultSegments, { from: 1, to: 5 }); + + const insertSpy = vi.spyOn(mockAdapter, 'insertText'); + mockAdapter.insertFormattedContent('plain text', { contentType: 'text', position: 'replace' }); + + expect(insertSpy).toHaveBeenCalledWith('plain text', { contentType: 'text', position: 'replace' }); + expect(mockEditor.commands.insertContent).not.toHaveBeenCalledWith(expect.anything(), { + contentType: 'text', + }); + + insertSpy.mockRestore(); + }); + + it('falls back to insertText when no contentType is provided', () => { + updateEditorState(defaultSegments, { from: 1, to: 5 }); + + const insertSpy = vi.spyOn(mockAdapter, 'insertText'); + mockAdapter.insertFormattedContent('just text', { position: 'replace' }); + + expect(insertSpy).toHaveBeenCalledWith('just text', { position: 'replace' }); + + insertSpy.mockRestore(); + }); + + it('sets selection to collapsed range at from for position: before', () => { + updateEditorState(defaultSegments, { from: 3, to: 8 }); + + mockAdapter.insertFormattedContent('Before
', { + contentType: 'html', + position: 'before', + }); + + // 'before' collapses to from: to = from + expect(mockEditor.commands.setTextSelection).toHaveBeenCalledWith({ from: 3, to: 3 }); + expect(mockEditor.commands.insertContent).toHaveBeenCalledWith('Before
', { contentType: 'html' }); + }); + + it('sets selection to collapsed range at to for position: after', () => { + updateEditorState(defaultSegments, { from: 3, to: 8 }); + + mockAdapter.insertFormattedContent('After
', { + contentType: 'html', + position: 'after', + }); + + // 'after' collapses to to: from = to + expect(mockEditor.commands.setTextSelection).toHaveBeenCalledWith({ from: 8, to: 8 }); + expect(mockEditor.commands.insertContent).toHaveBeenCalledWith('After
', { contentType: 'html' }); + }); + + it('uses full selection range for position: replace (default)', () => { + updateEditorState(defaultSegments, { from: 3, to: 8 }); + + mockAdapter.insertFormattedContent('Replace
', { + contentType: 'html', + }); + + expect(mockEditor.commands.setTextSelection).toHaveBeenCalledWith({ from: 3, to: 8 }); + expect(mockEditor.commands.insertContent).toHaveBeenCalledWith('Replace
', { contentType: 'html' }); + }); + }); + describe('replaceText with appended content', () => { it('handles appended text correctly (prefix equals original, suffix is 0)', () => { const boldMark = schema.marks.bold.create(); diff --git a/packages/ai/src/ai-actions/__tests__/services/ai-actions-service.test.ts b/packages/ai/src/ai-actions/__tests__/services/ai-actions-service.test.ts index 07d30df74f..27f0fe22ad 100644 --- a/packages/ai/src/ai-actions/__tests__/services/ai-actions-service.test.ts +++ b/packages/ai/src/ai-actions/__tests__/services/ai-actions-service.test.ts @@ -457,6 +457,22 @@ describe('AIActionsService', () => { expect(result.success).toBe(true); expect(result.results).toHaveLength(1); }); + + it('should throw when trackChanges and contentType: html are both set', async () => { + const actions = new AIActionsService(mockProvider, mockEditor, () => mockEditor.state.doc.textContent, false); + + await expect( + actions.literalReplace('A', 'B', { trackChanges: true, contentType: 'html' }), + ).rejects.toThrow('trackChanges and contentType'); + }); + + it('should throw when trackChanges and contentType: markdown are both set', async () => { + const actions = new AIActionsService(mockProvider, mockEditor, () => mockEditor.state.doc.textContent, false); + + await expect( + actions.literalReplace('A', '**B**', { trackChanges: true, contentType: 'markdown' }), + ).rejects.toThrow('trackChanges and contentType'); + }); }); describe('insertTrackedChange', () => { @@ -805,6 +821,200 @@ describe('AIActionsService', () => { }); }); + describe('insertContent with contentType', () => { + it('should disable streaming when contentType is html', async () => { + const response = JSON.stringify({ + success: true, + results: [{ suggestedText: 'Hello link
' }], + }); + + const streamSpy = vi.fn().mockImplementation(async function* () { + yield response; + }); + const completionSpy = vi.fn().mockResolvedValue(response); + + mockProvider.streamCompletion = streamSpy as typeof mockProvider.streamCompletion; + mockProvider.getCompletion = completionSpy; + + const insertFormattedSpy = vi + .spyOn(EditorAdapter.prototype, 'insertFormattedContent') + .mockImplementation(() => {}); + + const actions = new AIActionsService( + mockProvider, + mockEditor, + () => mockEditor.state.doc.textContent, + false, + undefined, + true, // streaming preference enabled + ); + + const result = await actions.insertContent('generate html', { contentType: 'html' }); + + expect(result.success).toBe(true); + // Streaming must be disabled for HTML content + expect(streamSpy).not.toHaveBeenCalled(); + expect(completionSpy).toHaveBeenCalled(); + // Should use insertFormattedContent, not insertText + expect(insertFormattedSpy).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ contentType: 'html' }), + ); + + insertFormattedSpy.mockRestore(); + }); + + it('should disable streaming when contentType is markdown', async () => { + const response = JSON.stringify({ + success: true, + results: [{ suggestedText: '# Title\n\n[link](https://example.com)' }], + }); + + const streamSpy = vi.fn().mockImplementation(async function* () { + yield response; + }); + const completionSpy = vi.fn().mockResolvedValue(response); + + mockProvider.streamCompletion = streamSpy as typeof mockProvider.streamCompletion; + mockProvider.getCompletion = completionSpy; + + const insertFormattedSpy = vi + .spyOn(EditorAdapter.prototype, 'insertFormattedContent') + .mockImplementation(() => {}); + + const actions = new AIActionsService( + mockProvider, + mockEditor, + () => mockEditor.state.doc.textContent, + false, + undefined, + true, + ); + + const result = await actions.insertContent('generate markdown', { contentType: 'markdown' }); + + expect(result.success).toBe(true); + expect(streamSpy).not.toHaveBeenCalled(); + expect(completionSpy).toHaveBeenCalled(); + expect(insertFormattedSpy).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ contentType: 'markdown' }), + ); + + insertFormattedSpy.mockRestore(); + }); + + it('should route through insertFormattedContent with position: before for html', async () => { + const response = JSON.stringify({ + success: true, + results: [{ suggestedText: 'Before content
' }], + }); + + mockProvider.getCompletion = vi.fn().mockResolvedValue(response); + + const insertFormattedSpy = vi + .spyOn(EditorAdapter.prototype, 'insertFormattedContent') + .mockImplementation(() => {}); + + const actions = new AIActionsService(mockProvider, mockEditor, () => mockEditor.state.doc.textContent, false); + const result = await actions.insertContent('add content', { + position: 'before', + contentType: 'html', + }); + + expect(result.success).toBe(true); + expect(insertFormattedSpy).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ position: 'before', contentType: 'html' }), + ); + + insertFormattedSpy.mockRestore(); + }); + + it('should route through insertFormattedContent with position: after for html', async () => { + const response = JSON.stringify({ + success: true, + results: [{ suggestedText: 'After content
' }], + }); + + mockProvider.getCompletion = vi.fn().mockResolvedValue(response); + + const insertFormattedSpy = vi + .spyOn(EditorAdapter.prototype, 'insertFormattedContent') + .mockImplementation(() => {}); + + const actions = new AIActionsService(mockProvider, mockEditor, () => mockEditor.state.doc.textContent, false); + const result = await actions.insertContent('add content', { + position: 'after', + contentType: 'html', + }); + + expect(result.success).toBe(true); + expect(insertFormattedSpy).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ position: 'after', contentType: 'html' }), + ); + + insertFormattedSpy.mockRestore(); + }); + + it('should use plain insertText path when contentType is text', async () => { + const response = JSON.stringify({ + success: true, + results: [{ suggestedText: 'Plain text content' }], + }); + + mockProvider.getCompletion = vi.fn().mockResolvedValue(response); + + const insertTextSpy = vi.spyOn(EditorAdapter.prototype, 'insertText').mockImplementation(() => {}); + const insertFormattedSpy = vi + .spyOn(EditorAdapter.prototype, 'insertFormattedContent') + .mockImplementation(() => {}); + + const actions = new AIActionsService(mockProvider, mockEditor, () => mockEditor.state.doc.textContent, false); + const result = await actions.insertContent('add content', { contentType: 'text' }); + + expect(result.success).toBe(true); + expect(insertFormattedSpy).not.toHaveBeenCalled(); + expect(insertTextSpy).toHaveBeenCalled(); + + insertTextSpy.mockRestore(); + insertFormattedSpy.mockRestore(); + }); + }); + + describe('literalReplace with contentType', () => { + let literalSpy: ReturnTypeHi
', { + contentType: 'html', + }); + + expect(result.success).toBe(true); + expect(mockEditor.commands.setTextSelection).toHaveBeenCalled(); + expect(insertFormattedSpy).toHaveBeenCalledWith( + 'Hi
', + expect.objectContaining({ contentType: 'html', position: 'replace' }), + ); + }); + }); + describe('error handling', () => { it('should respect enableLogging flag', async () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { diff --git a/packages/ai/src/ai-actions/editor/editor-adapter.ts b/packages/ai/src/ai-actions/editor/editor-adapter.ts index 25c7fcbfb7..55a53c4929 100644 --- a/packages/ai/src/ai-actions/editor/editor-adapter.ts +++ b/packages/ai/src/ai-actions/editor/editor-adapter.ts @@ -744,4 +744,49 @@ export class EditorAdapter { this.applyPatch(from, to, suggestedText); } + + /** + * Inserts content with optional format parsing (HTML, markdown). + * When contentType is 'html' or 'markdown', delegates to the editor's + * insertContent command which parses the content through ProseMirror's DOMParser, + * creating proper marks (e.g., link marks for tags). + * When contentType is 'text' or omitted, falls back to plain-text insertText. + * + * @param content - The content to insert + * @param options + */ + insertFormattedContent( + content: string, + options?: { position?: 'before' | 'after' | 'replace'; contentType?: 'html' | 'markdown' | 'text' }, + ): void { + const contentType = options?.contentType; + + if (contentType && contentType !== 'text') { + const position = this.getSelectionRange(); + if (!position) return; + + const mode = options?.position ?? 'replace'; + let from = position.from; + let to = position.to; + + if (mode === 'before') { + to = from; + } else if (mode === 'after') { + from = to; + } + + // Set selection to the target range before inserting + this.editor.commands?.setTextSelection?.({ from, to }); + const commands = this.editor.commands as + | { + insertContent?: (value: string, config?: { contentType?: 'html' | 'markdown' | 'text' }) => unknown; + } + | undefined; + commands?.insertContent?.(content, { contentType }); + return; + } + + // Fall back to plain-text path + this.insertText(content, options); + } } diff --git a/packages/ai/src/ai-actions/index.ts b/packages/ai/src/ai-actions/index.ts index f096863a71..32008582e6 100644 --- a/packages/ai/src/ai-actions/index.ts +++ b/packages/ai/src/ai-actions/index.ts @@ -74,7 +74,7 @@ export class AIActions { literalReplace: async ( findText: string, replaceText: string, - options?: { caseSensitive?: boolean; trackChanges?: boolean }, + options?: { caseSensitive?: boolean; trackChanges?: boolean; contentType?: 'html' | 'markdown' | 'text' }, ) => { return this.executeActionWithCallbacks(() => this.commands.literalReplace(findText, replaceText, options)); }, @@ -97,7 +97,10 @@ export class AIActions { summarize: async (instruction: string) => { return this.executeActionWithCallbacks(() => this.commands.summarize(instruction)); }, - insertContent: async (instruction: string, options?: { position?: 'before' | 'after' | 'replace' }) => { + insertContent: async ( + instruction: string, + options?: { position?: 'before' | 'after' | 'replace'; contentType?: 'html' | 'markdown' | 'text' }, + ) => { return this.executeActionWithCallbacks(() => this.commands.insertContent(instruction, options)); }, }; diff --git a/packages/ai/src/ai-actions/services/ai-actions-service.ts b/packages/ai/src/ai-actions/services/ai-actions-service.ts index 5f0be706a7..78574a16ff 100644 --- a/packages/ai/src/ai-actions/services/ai-actions-service.ts +++ b/packages/ai/src/ai-actions/services/ai-actions-service.ts @@ -460,6 +460,7 @@ export class AIActionsService { options?: { caseSensitive?: boolean; trackChanges?: boolean; + contentType?: 'html' | 'markdown' | 'text'; }, ): PromiseSee [27] today
'; + + editor.commands.insertContent(input, { contentType: 'html' }); + await Promise.resolve(); + + const { linkMarks, literalHtml } = inspectEditorState(editor); + expect(linkMarks.length).toBeGreaterThanOrEqual(1); + expect(linkMarks[0].href).toBe('https://example.com'); + expect(linkMarks[0].text).toBe('[27]'); + expect(literalHtml).toHaveLength(0); + }); + + it('exportsSee [27] today
', { + contentType: 'html', + }); + await Promise.resolve(); + + const result = await exportFromEditor(editor); + const hyperlinks = findHyperlinks(result); + expect(hyperlinks.length).toBeGreaterThanOrEqual(1); + expect(hyperlinks[0].attributes?.['r:id']).toBeTruthy(); + expect(getHyperlinkText(hyperlinks[0])).toBe('[27]'); + }); + }); + + describe('multi-paragraph with multiple links', () => { + it('all links produce link marks', async () => { + const editor = await setupEditor(); + const input = ` +First link one text
+Second link two text
+Third link three text
+ `; + + editor.commands.insertContent(input, { contentType: 'html' }); + await Promise.resolve(); + + const { linkMarks, literalHtml } = inspectEditorState(editor); + expect(linkMarks).toHaveLength(3); + expect(linkMarks.map((m) => m.href)).toEqual(['https://one.com', 'https://two.com', 'https://three.com']); + expect(literalHtml).toHaveLength(0); + }); + + it('all links export asFirst link one text
+Second link two text
+Third link three text
+ `; + + editor.commands.insertContent(input, { contentType: 'html' }); + await Promise.resolve(); + + const result = await exportFromEditor(editor); + const hyperlinks = findHyperlinks(result); + expect(hyperlinks).toHaveLength(3); + hyperlinks.forEach((hl) => { + expect(hl.attributes?.['r:id']).toBeTruthy(); + }); + }); + }); +});