-
-
Notifications
You must be signed in to change notification settings - Fork 3.1k
feat: add the streaming-reveal extension to @tiptap/ai-toolkit #8109
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
ozdemircibaris
wants to merge
12
commits into
main
Choose a base branch
from
feat/streaming-reveal-extension
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 10 commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
32b7d87
feat: add streaming-reveal extension to @tiptap/ai-toolkit
ozdemircibaris 4b1c45e
cover AiInsertReveal behavior and simplify its plugin functions
ozdemircibaris 37c55ca
refactor: name the reveal source after its streaming-reveal entry
ozdemircibaris e1faf23
Merge remote-tracking branch 'origin/main' into feat/streaming-reveal…
ozdemircibaris c3a99c6
chore: update lockfile for the streaming-reveal deps
ozdemircibaris 3577551
refactor: split streaming-reveal into single-purpose helpers
ozdemircibaris d1b3421
docs: remove inert biome-ignore directives and trim redundant comments
ozdemircibaris 326505e
chore: drop the unrelated core/pm bump from the lockfile
ozdemircibaris 101716b
Merge remote-tracking branch 'origin/main' into feat/streaming-reveal…
ozdemircibaris 14db45b
pnpm lock
ozdemircibaris d6da91e
docs: tighten streaming-reveal comments per review
ozdemircibaris 696d2cf
docs: shorten remaining streaming-reveal comments
ozdemircibaris File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@tiptap/ai-toolkit": minor | ||
| --- | ||
|
|
||
| Add the `AiInsertReveal` extension, exported from `@tiptap/ai-toolkit/streaming-reveal`, to fade in text as the AI streams it into a collaborative document. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,187 @@ | ||
| // @vitest-environment happy-dom | ||
|
|
||
| import { Editor } from '@tiptap/core' | ||
| import { Collaboration } from '@tiptap/extension-collaboration' | ||
| import StarterKit from '@tiptap/starter-kit' | ||
| import { describe, expect, it } from 'vitest' | ||
| import * as Y from 'yjs' | ||
|
|
||
| import { AiInsertReveal } from './streaming-reveal.js' | ||
|
|
||
| /** | ||
| * Creates an editor with the {@link AiInsertReveal} extension and no | ||
| * collaboration, to prove the extension loads and degrades gracefully when the | ||
| * y-sync plugin it reads is absent. | ||
| * | ||
| * @return Promise resolving once the editor create lifecycle has finished. | ||
| */ | ||
| function createEditor(): Promise<Editor> { | ||
| return new Promise(resolve => { | ||
| const editor = new Editor({ | ||
| element: document.createElement('div'), | ||
| extensions: [StarterKit, AiInsertReveal], | ||
| content: { | ||
| type: 'doc', | ||
| content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Hello' }] }], | ||
| }, | ||
| onCreate: () => { | ||
| resolve(editor) | ||
| }, | ||
| }) | ||
| }) | ||
| } | ||
|
|
||
| /** | ||
| * Creates a collaborative editor bound to a fresh Y.Doc with {@link AiInsertReveal}, | ||
| * seeded with a single `Hello` paragraph. | ||
| * | ||
| * @param options - Optional reveal configuration forwarded to `configure`. | ||
| * @return Promise resolving to the editor and its backing Y.Doc. | ||
| */ | ||
| function createCollabEditor(options?: { | ||
| durationMs?: number | ||
| }): Promise<{ editor: Editor; ydoc: Y.Doc }> { | ||
| const ydoc = new Y.Doc() | ||
| return new Promise(resolve => { | ||
| new Editor({ | ||
| element: document.createElement('div'), | ||
| extensions: [ | ||
| StarterKit.configure({ undoRedo: false }), | ||
| Collaboration.configure({ document: ydoc }), | ||
| options?.durationMs === undefined | ||
| ? AiInsertReveal | ||
| : AiInsertReveal.configure({ durationMs: options.durationMs }), | ||
| ], | ||
| onCreate: ({ editor }) => { | ||
| // Collaboration ignores the `content` prop (the empty Y.Doc wins when the | ||
| // y-sync plugin binds), so seed the shared doc with a local edit instead. | ||
| editor.commands.setContent('<p>Hello</p>') | ||
| resolve({ editor, ydoc }) | ||
| }, | ||
| }) | ||
| }) | ||
| } | ||
|
|
||
| /** | ||
| * Applies a remote (non-local) insert into the first paragraph's text, mimicking | ||
| * an AI streaming into the shared document from another peer. Uses a second Y.Doc | ||
| * synced from `ydoc` so the resulting transaction has `local === false`. | ||
| */ | ||
| function remoteInsert(ydoc: Y.Doc, index: number, text: string): void { | ||
| const remote = new Y.Doc() | ||
| Y.applyUpdate(remote, Y.encodeStateAsUpdate(ydoc)) | ||
| const paragraph = remote.getXmlFragment('default').get(0) as Y.XmlElement | ||
| const xmlText = paragraph.get(0) as Y.XmlText | ||
| xmlText.insert(index, text) | ||
| Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(remote, Y.encodeStateVector(ydoc))) | ||
| } | ||
|
|
||
| /** | ||
| * Collects the reveal decorations currently produced by the extension, resolved | ||
| * against the editor's live state (mirrors what the view renders). | ||
| */ | ||
| function revealDecorations(editor: Editor): Array<{ from: number; to: number; style: string }> { | ||
| for (const plugin of editor.state.plugins) { | ||
| const set = (plugin as any).props?.decorations?.call(plugin, editor.state) | ||
| const found = (set?.find?.() ?? []).filter( | ||
| (d: any) => d.type?.attrs?.class === 'ai-insert-reveal', | ||
| ) | ||
| if (found.length > 0) { | ||
| return found.map((d: any) => ({ from: d.from, to: d.to, style: d.type.attrs.style ?? '' })) | ||
| } | ||
| } | ||
| return [] | ||
| } | ||
|
|
||
| describe('AiInsertReveal', () => { | ||
| it('is a named Tiptap extension', () => { | ||
| expect(AiInsertReveal.name).toBe('aiInsertReveal') | ||
| }) | ||
|
|
||
| it('registers and degrades to a no-op when no collaboration y-sync plugin is present', async () => { | ||
| const editor = await createEditor() | ||
|
|
||
| expect(editor.extensionManager.extensions.some(e => e.name === 'aiInsertReveal')).toBe(true) | ||
| // Without a y-sync plugin the decorations source resolves to nothing, so the | ||
| // editor renders normally rather than throwing. | ||
| expect(editor.getText()).toBe('Hello') | ||
|
|
||
| editor.destroy() | ||
| }) | ||
|
|
||
| it('applies configured className and durationMs', async () => { | ||
| const editor = await new Promise<Editor>(resolve => { | ||
| const created = new Editor({ | ||
| element: document.createElement('div'), | ||
| extensions: [ | ||
| StarterKit, | ||
| AiInsertReveal.configure({ className: 'custom-reveal', durationMs: 300 }), | ||
| ], | ||
| onCreate: () => resolve(created), | ||
| }) | ||
| }) | ||
|
|
||
| const reveal = editor.extensionManager.extensions.find(e => e.name === 'aiInsertReveal') | ||
| expect(reveal?.options).toMatchObject({ className: 'custom-reveal', durationMs: 300 }) | ||
|
|
||
| editor.destroy() | ||
| }) | ||
|
|
||
| it('reveals a remote insert as a decoration over exactly the inserted run', async () => { | ||
| const { editor, ydoc } = await createCollabEditor() | ||
|
|
||
| remoteInsert(ydoc, 5, ' WORLD') | ||
|
|
||
| expect(editor.getText()).toBe('Hello WORLD') | ||
| const decorations = revealDecorations(editor) | ||
| expect(decorations).toHaveLength(1) | ||
| expect(decorations[0].to - decorations[0].from).toBe(' WORLD'.length) | ||
| // The age-seeded animation-delay is present so the fade survives re-renders. | ||
| expect(decorations[0].style).toMatch(/animation-delay: -\d+ms/) | ||
|
|
||
| editor.destroy() | ||
| }) | ||
|
|
||
| it("does not reveal the local user's own typing", async () => { | ||
| const { editor } = await createCollabEditor() | ||
|
|
||
| // A local transaction (transaction.local === true) must be ignored. | ||
| editor.commands.insertContentAt(6, 'X') | ||
|
|
||
| expect(editor.getText()).toBe('HelloX') | ||
| expect(revealDecorations(editor)).toHaveLength(0) | ||
|
|
||
| editor.destroy() | ||
| }) | ||
|
|
||
| it('drops the reveal once its duration has elapsed', async () => { | ||
| const { editor, ydoc } = await createCollabEditor({ durationMs: 30 }) | ||
|
|
||
| remoteInsert(ydoc, 5, ' WORLD') | ||
| expect(revealDecorations(editor)).toHaveLength(1) | ||
|
|
||
| await new Promise(resolve => setTimeout(resolve, 60)) | ||
| expect(revealDecorations(editor)).toHaveLength(0) | ||
|
|
||
| editor.destroy() | ||
| }) | ||
|
|
||
| it('ignores an insert larger than the max reveal range', async () => { | ||
| const { editor, ydoc } = await createCollabEditor() | ||
|
|
||
| remoteInsert(ydoc, 5, 'x'.repeat(401)) | ||
|
|
||
| expect(editor.getText()).toBe(`Hello${'x'.repeat(401)}`) | ||
| expect(revealDecorations(editor)).toHaveLength(0) | ||
|
|
||
| editor.destroy() | ||
| }) | ||
|
|
||
| it('tears down cleanly after a reveal without throwing', async () => { | ||
| const { editor, ydoc } = await createCollabEditor() | ||
| remoteInsert(ydoc, 5, ' WORLD') | ||
| expect(revealDecorations(editor)).toHaveLength(1) | ||
|
|
||
| expect(() => editor.destroy()).not.toThrow() | ||
| }) | ||
| }) | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.