diff --git a/.changeset/new-pots-admire.md b/.changeset/new-pots-admire.md new file mode 100644 index 0000000000..f8776959fd --- /dev/null +++ b/.changeset/new-pots-admire.md @@ -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. diff --git a/packages/ai-toolkit/package.json b/packages/ai-toolkit/package.json index 107c4947c6..2bc6ca18c0 100644 --- a/packages/ai-toolkit/package.json +++ b/packages/ai-toolkit/package.json @@ -39,6 +39,14 @@ }, "import": "./dist/index.js", "require": "./dist/index.cjs" + }, + "./streaming-reveal": { + "types": { + "import": "./dist/streaming-reveal.d.ts", + "require": "./dist/streaming-reveal.d.cts" + }, + "import": "./dist/streaming-reveal.js", + "require": "./dist/streaming-reveal.cjs" } }, "scripts": { @@ -51,10 +59,22 @@ }, "devDependencies": { "@tiptap/core": "workspace:^", - "@tiptap/pm": "workspace:^" + "@tiptap/pm": "workspace:^", + "@tiptap/y-tiptap": "^3.0.7", + "yjs": "^13.6.23" }, "peerDependencies": { "@tiptap/core": "^3.0.1", - "@tiptap/pm": "^3.0.1" + "@tiptap/pm": "^3.0.1", + "@tiptap/y-tiptap": "^3.0.0", + "yjs": "^13.6.23" + }, + "peerDependenciesMeta": { + "@tiptap/y-tiptap": { + "optional": true + }, + "yjs": { + "optional": true + } } } diff --git a/packages/ai-toolkit/src/streaming-reveal.spec.ts b/packages/ai-toolkit/src/streaming-reveal.spec.ts new file mode 100644 index 0000000000..678cae0d2d --- /dev/null +++ b/packages/ai-toolkit/src/streaming-reveal.spec.ts @@ -0,0 +1,168 @@ +// @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 {@link AiInsertReveal} but no collaboration (no y-sync plugin). */ +function createEditor(): Promise { + 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 (fresh Y.Doc, {@link AiInsertReveal}) seeded with one `Hello` paragraph. */ +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('

Hello

') + resolve({ editor, ydoc }) + }, + }) + }) +} + +/** Applies a remote insert via a synced second Y.Doc. */ +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 extension's current reveal decorations from the editor's live state. */ +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(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() + }) +}) diff --git a/packages/ai-toolkit/src/streaming-reveal.ts b/packages/ai-toolkit/src/streaming-reveal.ts new file mode 100644 index 0000000000..4d0244e471 --- /dev/null +++ b/packages/ai-toolkit/src/streaming-reveal.ts @@ -0,0 +1,233 @@ +import { Extension } from '@tiptap/core' +import type { Node as PMNode } from '@tiptap/pm/model' +import { Plugin, PluginKey } from '@tiptap/pm/state' +import { Decoration, DecorationSet } from '@tiptap/pm/view' +import { relativePositionToAbsolutePosition, ySyncPluginKey } from '@tiptap/y-tiptap' +import * as Y from 'yjs' + +/** + * Configuration for {@link AiInsertReveal}. + */ +export type AiInsertRevealOptions = { + /** CSS class on each revealed run; style it in your app to define the fade. */ + className: string + /** + * How long (ms) each run keeps its reveal decoration before it is dropped. + * Keep it at or above your CSS animation duration so the fade can finish. + */ + durationMs: number +} + +/** Limits each revealed streamed insert to guard against mis-resolved positions spanning the document. */ +const MAX_REVEAL_RANGE = 400 + +/** Anchors each inserted run with Yjs relative positions so it survives y-tiptap document rebuilds. */ +type RevealEntry = { + start: Y.RelativePosition + end: Y.RelativePosition + at: number +} + +/** Minimal shape of the y-sync plugin state we read. */ +type YSyncState = { + doc: Y.Doc + type: Y.XmlFragment + binding: { mapping: Map, PMNode | PMNode[]> } | null +} + +const aiInsertRevealKey = new PluginKey('aiInsertReveal') + +function resolveRevealRange( + ystate: YSyncState, + entry: RevealEntry, + now: number, + durationMs: number, +): { from: number; to: number; age: number } | null { + const age = now - entry.at + if (age >= durationMs) return null + + const span = resolveSpan(ystate, entry) + return span === null ? null : { ...span, age } +} + +function resolveSpan(ystate: YSyncState, entry: RevealEntry): { from: number; to: number } | null { + if (!ystate.binding) return null + + const from = relativePositionToAbsolutePosition( + ystate.doc, + ystate.type, + entry.start, + ystate.binding.mapping, + ) + const to = relativePositionToAbsolutePosition( + ystate.doc, + ystate.type, + entry.end, + ystate.binding.mapping, + ) + return from === null || to === null ? null : orderedSpan(from, to) +} + +function orderedSpan(from: number, to: number): { from: number; to: number } | null { + const a = Math.min(from, to) + const b = Math.max(from, to) + return a >= b || b - a > MAX_REVEAL_RANGE ? null : { from: a, to: b } +} + +function collectInsertedRuns(event: Y.YEvent>, now: number): RevealEntry[] { + const target = event.target + if (!(target instanceof Y.XmlText)) return [] + + const runs: RevealEntry[] = [] + let index = 0 + for (const op of event.delta) { + const { advance, inserted } = scanDeltaOp(op) + if (inserted > 0) runs.push(makeRun(target, index, inserted, now)) + index += advance + } + return runs +} + +function scanDeltaOp(op: { retain?: number; insert?: unknown }): { + advance: number + inserted: number +} { + if (typeof op.retain === 'number') return { advance: op.retain, inserted: 0 } + if (typeof op.insert === 'string') + return { advance: op.insert.length, inserted: op.insert.length } + // A non-string insert (embed) advances one position but is not a revealed run. + return op.insert === undefined ? { advance: 0, inserted: 0 } : { advance: 1, inserted: 0 } +} + +/** End anchor uses assoc < 0 so an appended token starts a new run, not extends this one. */ +function makeRun(target: Y.XmlText, index: number, length: number, now: number): RevealEntry { + return { + start: Y.createRelativePositionFromTypeIndex(target, index), + end: Y.createRelativePositionFromTypeIndex(target, index + length, -1), + at: now, + } +} + +/** + * Fades in remote Yjs inserts with view-only decorations. It never mutates the + * document, so it is inert to accept/reject and persistence, and ignores local edits. + * Requires Collaboration and CSS for `className` (default `ai-insert-reveal`). + */ +export const AiInsertReveal = Extension.create({ + name: 'aiInsertReveal', + + addOptions() { + return { + className: 'ai-insert-reveal', + durationMs: 550, + } + }, + + addProseMirrorPlugins() { + const { className, durationMs } = this.options + + // Runs in insertion (time) order, so expired entries are always a prefix. + const entries: RevealEntry[] = [] + + const dropExpired = (now: number) => { + let firstActive = 0 + while (firstActive < entries.length && now - entries[firstActive].at >= durationMs) { + firstActive += 1 + } + if (firstActive > 0) entries.splice(0, firstActive) + } + + return [ + new Plugin({ + key: aiInsertRevealKey, + + props: { + decorations: state => { + const ystate = ySyncPluginKey.getState(state) as YSyncState | undefined + if (entries.length === 0 || !ystate?.binding) return null + + const now = Date.now() + const decorations = entries + .map(entry => resolveRevealRange(ystate, entry, now, durationMs)) + .filter((range): range is NonNullable => range !== null) + // y-tiptap rebuilds the whole doc per token, restarting the CSS + // animation; offset by the run's age to resume the fade instead. + .map(range => + Decoration.inline(range.from, range.to, { + class: className, + style: `animation-delay: -${Math.round(range.age)}ms`, + }), + ) + + return DecorationSet.create(state.doc, decorations) + }, + }, + + view: view => { + const initialState = ySyncPluginKey.getState(view.state) as YSyncState | undefined + const fragment = initialState?.type ?? null + + let raf: number | null = null + let pruneTimer: ReturnType | null = null + + const rerender = () => { + if (view.isDestroyed) return + // Empty transaction: re-runs `decorations` so new entries paint (and + // expired ones are dropped). Kept out of the undo history. + view.dispatch(view.state.tr.setMeta('addToHistory', false)) + } + + // Coalesce bursts of tokens landing in the same frame into one render. + const scheduleRerender = () => { + if (raf !== null) return + raf = requestAnimationFrame(() => { + raf = null + rerender() + }) + } + + // After the stream pauses, one delayed render removes the last runs' + // decorations once their fades have completed. + const schedulePrune = () => { + if (pruneTimer !== null) clearTimeout(pruneTimer) + pruneTimer = setTimeout(() => { + pruneTimer = null + dropExpired(Date.now()) + rerender() + }, durationMs + 80) + } + + const onChange = ( + events: Array>>, + transaction: Y.Transaction, + ) => { + // Only remote edits, i.e. the AI. The local user's own typing is a + // local transaction and must not fade. + if (transaction.local) return + + const now = Date.now() + dropExpired(now) + + const runs = events.flatMap(event => collectInsertedRuns(event, now)) + if (runs.length === 0) return + + entries.push(...runs) + scheduleRerender() + schedulePrune() + } + + fragment?.observeDeep(onChange) + + return { + destroy: () => { + fragment?.unobserveDeep(onChange) + if (raf !== null) cancelAnimationFrame(raf) + if (pruneTimer !== null) clearTimeout(pruneTimer) + entries.length = 0 + }, + } + }, + }), + ] + }, +}) diff --git a/packages/ai-toolkit/tsup.config.ts b/packages/ai-toolkit/tsup.config.ts index 03b7c8d0b6..c3269074b0 100644 --- a/packages/ai-toolkit/tsup.config.ts +++ b/packages/ai-toolkit/tsup.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from 'tsup' export default defineConfig({ - entry: ['src/index.ts'], + entry: { index: 'src/index.ts', 'streaming-reveal': 'src/streaming-reveal.ts' }, tsconfig: '../../tsconfig.build.json', outDir: 'dist', dts: true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9bc607c836..c2be625c4d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -412,6 +412,12 @@ importers: '@tiptap/pm': specifier: workspace:^ version: link:../pm + '@tiptap/y-tiptap': + specifier: ^3.0.7 + version: 3.0.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.41.9)(y-protocols@1.0.6(yjs@13.6.23))(yjs@13.6.23) + yjs: + specifier: ^13.6.23 + version: 13.6.23 packages/core: devDependencies: