snapshot of https://github.com/ueberdosis/tiptap.git (main, 2026-09-15; blobs > 131072B omitted)
Some checks failed
Verify / Install node dependencies (push) Failing after 2s
Verify / Build packages (push) Has been skipped
Verify / Check linting & formatting (push) Has been skipped
Verify / Run unit tests (push) Has been skipped
Verify / Run e2e tests (shard 1/4) (push) Has been skipped
Verify / Run e2e tests (shard 2/4) (push) Has been skipped
Verify / Run e2e tests (shard 3/4) (push) Has been skipped
Verify / Run e2e tests (shard 4/4) (push) Has been skipped
Publish / Resolve publish config (push) Failing after 2s
Publish / Send notification to Slack (push) Has been skipped
Publish / Send failure notification to Slack (push) Failing after 8s
Verify / Merge e2e reports (push) Has been skipped
Publish / Build (push) Has been skipped
Publish / Bump Versions OR Release to Registry (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-15 21:11:02 +05:00
commit 083a46e93b
2115 changed files with 245958 additions and 0 deletions

View File

@@ -0,0 +1,13 @@
# @tiptap/ai-toolkit
## 0.4.0
### Minor Changes
- 32b7d87: Add the `AiInsertReveal` extension (`@tiptap/ai-toolkit/streaming-reveal`) to fade in text as the AI streams it into a collaborative document.
## 0.3.0
### Minor Changes
- c8c174e: Rename `@tiptap/server-ai-toolkit` to `@tiptap/ai-toolkit`. The former package remains available as a deprecated alias; update imports to use `@tiptap/ai-toolkit`.

View File

@@ -0,0 +1,18 @@
# @tiptap/ai-toolkit
[![Version](https://img.shields.io/npm/v/@tiptap/ai-toolkit.svg?label=version)](https://www.npmjs.com/package/@tiptap/ai-toolkit)
[![Downloads](https://img.shields.io/npm/dm/@tiptap/ai-toolkit.svg)](https://npmcharts.com/compare/tiptap?minimal=true)
[![License](https://img.shields.io/npm/l/@tiptap/ai-toolkit.svg)](https://www.npmjs.com/package/@tiptap/ai-toolkit)
[![Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub)](https://github.com/sponsors/ueberdosis)
## Introduction
Tiptap is a headless wrapper around [ProseMirror](https://ProseMirror.net) – a toolkit for building rich text WYSIWYG editors, which is already in use at many well-known companies such as _New York Times_, _The Guardian_ or _Atlassian_.
## Official Documentation
Documentation can be found on the [Tiptap website](https://tiptap.dev).
## License
Tiptap is open sourced software licensed under the [MIT license](https://github.com/ueberdosis/tiptap/blob/main/LICENSE.md).

View File

@@ -0,0 +1,80 @@
{
"name": "@tiptap/ai-toolkit",
"version": "0.4.0",
"description": "Integrate AI capabilities into the Tiptap editor. This package contains the SDK for connecting to the Server AI Toolkit service.",
"keywords": [
"ai toolkit",
"server",
"server ai toolkit",
"tiptap",
"tiptap ai"
],
"homepage": "https://tiptap.dev/docs/editor/extensions/functionality/ai-toolkit",
"bugs": {
"url": "https://github.com/ueberdosis/tiptap/issues"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/ueberdosis/tiptap",
"directory": "packages/ai-toolkit"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"files": [
"src",
"dist"
],
"type": "module",
"main": "dist/index.cjs",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": {
"import": "./dist/index.d.ts",
"require": "./dist/index.d.cts"
},
"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": {
"build": "vp pack",
"dev": "vp pack --watch"
},
"dependencies": {
"es-toolkit": "^1.41.0",
"zod": "^4.1.8"
},
"devDependencies": {
"@tiptap/core": "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/y-tiptap": "^3.0.0",
"yjs": "^13.6.23"
},
"peerDependenciesMeta": {
"@tiptap/y-tiptap": {
"optional": true
},
"yjs": {
"optional": true
}
}
}

View File

@@ -0,0 +1,77 @@
import { Extension } from '@tiptap/core'
/**
* The attribute name used to store the hash on nodes.
*/
const ATTRIBUTE_NAME = '_hash'
/**
* Checks whether the editor already includes the full {@link AiToolkit} extension.
*
* @param extensionNames - Extension names available in the current editor setup.
* @return `true` when the AI Toolkit extension is installed.
*/
function hasAiToolkitExtension(extensionNames: string[]): boolean {
return extensionNames.includes('aiToolkit')
}
/**
* A Tiptap extension that registers `_hash` on non-inline nodes.
*
* The server package only contributes the attribute definition when AI Toolkit
* itself is not already installed.
*/
export const ServerAiToolkitHashExtension = Extension.create({
name: 'serverAiToolkitHash',
/**
* Registers the hash attribute on all non-inline, non-text, non-doc node types.
*
* @return The global attribute configuration.
*/
addGlobalAttributes() {
if (hasAiToolkitExtension(this.extensions.map(extension => extension.name))) {
return []
}
const types = this.extensions
.filter(ext => {
if (
// Only add hashes to nodes, not to marks
ext.type !== 'node' ||
// Exclude certain node types
ext.name === 'text' ||
ext.name === 'doc' ||
ext.name === 'tableHeader' ||
ext.name === 'tableCell' ||
// Exclude inline nodes
(typeof ext.config?.group === 'string' && ext.config.group.includes('inline'))
) {
return false
}
return true
})
.map(ext => ext.name)
return [
{
types,
attributes: {
[ATTRIBUTE_NAME]: {
default: null,
parseHTML: (element: HTMLElement) => element.getAttribute(ATTRIBUTE_NAME),
renderHTML: (attributes: Record<string, unknown>) => {
if (!attributes[ATTRIBUTE_NAME]) {
return {}
}
return {
[ATTRIBUTE_NAME]: attributes[ATTRIBUTE_NAME],
}
},
},
},
},
]
},
})

View File

@@ -0,0 +1,2 @@
export * from './schema-awareness/index.js'
export * from './server-ai-toolkit-extension.js'

View File

@@ -0,0 +1,86 @@
import { type Editor, getExtensionField, Mark } from '@tiptap/core'
import { z } from 'zod'
import type { EditorContext } from './types/editor-context.js'
import type { AddJsonSchemaAwareness, JsonItem } from './types/json-item.js'
import { defaultJsonItems } from './utils/default-json-items.js'
import { mergeJsonItems } from './utils/merge-json-items.js'
import { serializeSchema } from './utils/serialize-schema.js'
/**
* Options for {@link getEditorContext}.
*/
export interface GetEditorContextOptions {
/**
* Custom schema awareness items to include in addition to the default ones.
*
* This lets callers extend the serialized editor context with custom node
* definitions or overrides.
* @default []
*/
customNodes?: JsonItem[]
}
/**
* Returns editor context data including a serialized schema and merged custom nodes.
*
* This function collects schema information from extensions and converts it to a format
* suitable for AI model consumption. Zod schemas in attributes are converted to JSON schemas.
*
* @param editor - The Tiptap editor instance
* @param options - Configuration options for the editor context
* @return An object containing the serialized schema and merged custom nodes with JSON schema attributes
*/
export function getEditorContext(
editor: Editor,
options: GetEditorContextOptions = {},
): EditorContext {
const extensionNamesSet = new Set(editor.extensionManager.extensions.map(ext => ext.name))
const customNodesFromExtensions = editor.extensionManager.extensions.flatMap(extension => {
const context = {
name: extension.name,
options: extension.options,
storage: extension.storage,
editor,
}
const addJsonSchemaAwareness = getExtensionField<AddJsonSchemaAwareness>(
extension,
'addJsonSchemaAwareness',
context,
)
if (addJsonSchemaAwareness) {
return {
...addJsonSchemaAwareness(),
isMark: extension instanceof Mark,
extensionName: extension.name,
}
}
return []
})
const items = [defaultJsonItems, customNodesFromExtensions, options.customNodes ?? []]
.reduce(mergeJsonItems, [])
.filter((item: JsonItem) => extensionNamesSet.has(item.extensionName))
// Convert Zod schemas to JSON schemas
const mergedCustomNodes = items.map((item: JsonItem) => {
const { attributes, ...rest } = item
if (!attributes) {
return rest
}
const attributesSchema = z.object(attributes)
const jsonSchema = z.toJSONSchema(attributesSchema)
return {
...rest,
attributes: jsonSchema,
}
})
return {
serializedSchema: serializeSchema(editor.schema),
items: mergedCustomNodes,
}
}

View File

@@ -0,0 +1,2 @@
export * from './get-editor-context.js'
export * from './types/index.js'

View File

@@ -0,0 +1,17 @@
import type { SerializedSchema } from '../utils/serialize-schema.js'
import type { SerializedJsonItem } from './json-item.js'
/**
* Result of {@link getEditorContext} containing information about the
* editor's extensions that the Server AI Toolkit needs to work.
*/
export interface EditorContext {
/**
* The serialized ProseMirror schema
*/
serializedSchema: SerializedSchema
/**
* The merged custom nodes with Zod schemas converted to JSON schemas
*/
items: SerializedJsonItem[]
}

View File

@@ -0,0 +1,2 @@
export * from './editor-context.js'
export * from './json-item.js'

View File

@@ -0,0 +1,51 @@
import type { Editor } from '@tiptap/core'
import type { z } from 'zod'
/**
* Represents a JSON schema awareness item for AI model understanding
*
* This interface defines the structure of schema information that is provided
* to AI models to help them understand what JSON elements and attributes are
* available in the editor's schema.
*/
export interface JsonItem {
/**
* The name of the extension that provides this element
*/
extensionName: string
/**
* If `true`, the item is a mark instead of a node.
*/
isMark?: boolean
/**
* The human-readable name of the element in English
*/
name: string
/**
* Explanation of the element in English for the AI model
*/
description?: string | null
/**
* Possible attributes of the JSON element. If `undefined`, there are no attributes.
* The keys are attribute names and the values are Zod schemas that define the attribute structure.
*/
attributes?: Record<string, z.ZodTypeAny>
}
export type SerializedJsonItem = Omit<JsonItem, 'attributes'> & { attributes?: unknown }
export type AddJsonSchemaAwareness<Options = any, Storage = any> = (this: {
name: string
options: Options
storage: Storage
editor?: Editor
}) => Omit<JsonItem, 'extensionName' | 'isMark'>
declare module '@tiptap/core' {
interface NodeConfig<Options, Storage> {
addJsonSchemaAwareness?: AddJsonSchemaAwareness<Options, Storage>
}
interface MarkConfig<Options, Storage> {
addJsonSchemaAwareness?: AddJsonSchemaAwareness<Options, Storage>
}
}

View File

@@ -0,0 +1,283 @@
import { z } from 'zod'
import type { JsonItem } from '../types/json-item.js'
export const defaultJsonItems: JsonItem[] = [
// Core nodes
// Omit the root 'doc' node because the AI does not read it or generate it.
{
extensionName: 'paragraph',
name: 'Paragraph',
},
{
extensionName: 'text',
name: 'Text',
},
{
extensionName: 'heading',
name: 'Heading',
description: 'h1 to h6',
attributes: {
level: z.number().min(1).max(6),
},
},
{
extensionName: 'blockquote',
name: 'Blockquote',
description: 'for quoted content',
},
{
extensionName: 'codeBlock',
name: 'Code Block',
attributes: {
language: z.string().nullable().optional(),
},
},
{
extensionName: 'hardBreak',
name: 'Line Break',
},
{
extensionName: 'horizontalRule',
name: 'Horizontal Rule',
description: 'horizontal divider line',
},
{
extensionName: 'image',
name: 'Image',
attributes: {
src: z.string(),
alt: z.string().nullable().optional(),
title: z.string().nullable().optional(),
width: z.number().nullable().optional(),
height: z.number().nullable().optional(),
},
},
// List nodes
{
extensionName: 'bulletList',
name: 'Bullet List',
description: 'Unordered list with bullet points',
},
{
extensionName: 'orderedList',
name: 'Ordered List',
description: 'Numbered list',
attributes: {
start: z.number().optional(),
type: z
.string()
.nullable()
.optional()
.describe(
'Numbering style: "1" for numbers, "a" for lowercase letters, "A" for uppercase letters, "i" for lowercase roman numerals, "I" for uppercase roman numerals',
),
},
},
{
extensionName: 'listItem',
name: 'List Item',
description: 'Item within a bulletList or orderedList',
},
{
extensionName: 'taskList',
name: 'Task List',
description: 'Checklist with task items',
},
{
extensionName: 'taskItem',
name: 'Task Item',
description: 'Item within a taskList',
attributes: {
checked: z.boolean(),
},
},
// Table nodes
{
extensionName: 'table',
name: 'Table',
description: 'Table container node',
},
{
extensionName: 'tableRow',
name: 'Table Row',
description: 'Row within a table',
},
{
extensionName: 'tableCell',
name: 'Table Cell',
description: 'Cell within a tableRow',
},
{
extensionName: 'tableHeader',
name: 'Table Header',
description: 'Header cell within a tableRow',
},
// Special nodes
{
extensionName: 'mention',
name: 'Mention',
description: 'Mention node for @mentions and similar',
attributes: {
id: z.string(),
label: z.string().nullable().optional(),
mentionSuggestionChar: z
.string()
.optional()
.describe(
'The character that triggers this mention type (e.g., "@" for users, "#" for tags). Used to distinguish between multiple mention types',
),
},
},
{
extensionName: 'emoji',
name: 'Emoji',
attributes: {
name: z
.string()
.describe(
'The unique name/shortcode identifier for the emoji (e.g., "smile", "heart", "thumbs_up")',
),
},
},
{
extensionName: 'youtube',
name: 'YouTube',
attributes: {
src: z.string(),
start: z
.number()
.optional()
.describe('Start time in seconds for when the video should begin playing'),
width: z.number().optional(),
height: z.number().optional(),
},
},
{
extensionName: 'details',
name: 'Details',
description: 'Collapsible details/summary',
attributes: {
open: z.boolean().optional(),
},
},
{
extensionName: 'detailsSummary',
name: 'Details Summary',
description: 'Summary text for a details node',
},
{
extensionName: 'detailsContent',
name: 'Details Content',
description: 'Collapsible content within a details node',
},
{
extensionName: 'blockMath',
name: 'Block Math',
attributes: {
latex: z.string(),
},
},
{
extensionName: 'inlineMath',
name: 'Inline Math',
attributes: {
latex: z.string(),
},
},
// Marks
{
extensionName: 'bold',
name: 'Bold',
isMark: true,
},
{
extensionName: 'italic',
name: 'Italic',
isMark: true,
},
{
extensionName: 'code',
name: 'Code',
isMark: true,
},
{
extensionName: 'link',
name: 'Link',
isMark: true,
attributes: {
href: z.string(),
target: z.string().nullable().optional(),
rel: z.string().nullable().optional(),
class: z.string().nullable().optional(),
},
},
{
extensionName: 'strike',
name: 'Strike',
isMark: true,
},
{
extensionName: 'underline',
name: 'Underline',
isMark: true,
},
{
extensionName: 'highlight',
name: 'Highlight',
isMark: true,
attributes: {
color: z.string().nullable().optional(),
},
},
{
extensionName: 'subscript',
name: 'Subscript',
isMark: true,
},
{
extensionName: 'superscript',
name: 'Superscript',
isMark: true,
},
{
extensionName: 'textStyle',
name: 'Text Style',
isMark: true,
attributes: {
fontFamily: z.string(),
fontSize: z.string(),
lineHeight: z.string(),
color: z.string(),
backgroundColor: z.string(),
},
},
// tiptap-pro nodes
{
extensionName: 'iframely',
name: 'Iframely',
description: 'Embed content from URLs using Iframely service',
attributes: {
url: z.string().nullable(),
data: z.any().nullable().optional(),
},
},
{
extensionName: 'blockThread',
name: 'Block Thread',
description: 'Block-level comment thread',
attributes: {
'data-thread-id': z.string(),
},
},
// tiptap-pro marks
{
extensionName: 'inlineThread',
name: 'Inline Thread',
isMark: true,
description: 'Inline comment thread',
attributes: {
'data-thread-id': z.string(),
},
},
]

View File

@@ -0,0 +1,14 @@
import type { JsonItem } from '../types/json-item.js'
/**
* When new JSON items are added, we need to remove from `items` the items that
* are duplicated in `newItems`
* @param items - The original array of JSON items
* @param newItems - The new array of JSON items to add
* @returns A new array of JSON items where the items from `newItems` are added
* and the items from `items` that are duplicated in `newItems` are removed
*/
export function mergeJsonItems(items: JsonItem[], newItems: JsonItem[]): JsonItem[] {
const newItemsSet = new Set(newItems.map(item => item.extensionName))
return [...items.filter(item => !newItemsSet.has(item.extensionName)), ...newItems]
}

View File

@@ -0,0 +1,123 @@
import type { AttributeSpec, MarkSpec, NodeSpec, Schema } from '@tiptap/pm/model'
import { omit } from 'es-toolkit'
/**
* A serialized version of AttributeSpec where the validate function is converted to a string.
* This allows the attribute specification to be JSON-serializable.
*/
export type SerializedAttributeSpec = Omit<AttributeSpec, 'validate'> & {
validate?: string
}
/**
* A type representing serialized attributes as a record of attribute names to their serialized specs.
*/
type SerializedAttributes = { attrs?: Record<string, SerializedAttributeSpec> }
/**
* A serialized version of NodeSpec with non-serializable properties
* (toDOM, parseDOM, toDebugString, leafText, toText) removed and attrs converted to
* SerializedAttributeSpec format.
*/
export type SerializedNodeSpec = Omit<
NodeSpec,
'toDOM' | 'parseDOM' | 'toDebugString' | 'leafText' | 'toText' | 'attrs'
> &
SerializedAttributes
/**
* A serialized version of MarkSpec with non-serializable properties (toDOM, parseDOM) removed
* and attrs converted to SerializedAttributeSpec format.
*/
export type SerializedMarkSpec = Omit<MarkSpec, 'toDOM' | 'parseDOM' | 'attrs'> &
SerializedAttributes
/**
* A JSON-serializable representation of a ProseMirror Schema.
* Contains the top node name, and serialized node and mark specifications.
*/
export interface SerializedSchema {
topNode?: string
nodes: Record<string, SerializedNodeSpec>
marks: Record<string, SerializedMarkSpec>
}
/**
* Serializes an AttributeSpec by converting the validate function to a string if it exists.
* This makes the attribute specification JSON-serializable.
*
* @param attributeSpec - The AttributeSpec to serialize
* @returns A SerializedAttributeSpec with validate as a string or undefined
*/
function serializeAttributeSpec(attributeSpec: AttributeSpec): SerializedAttributeSpec {
return {
...omit(attributeSpec, ['validate']),
validate: typeof attributeSpec.validate === 'string' ? attributeSpec.validate : undefined,
}
}
/**
* Serializes a record of AttributeSpec objects by converting each one using serializeAttributeSpec.
*
* @param attributeSpecs - A record of attribute names to their AttributeSpec definitions
* @returns A record of attribute names to their SerializedAttributeSpec definitions
*/
function serializeAttributeSpecs(
attributeSpecs: Record<string, AttributeSpec>,
): Record<string, SerializedAttributeSpec> {
return Object.fromEntries(
Object.entries(attributeSpecs).map(([key, value]) => [key, serializeAttributeSpec(value)]),
)
}
/**
* Serializes a NodeSpec by removing non-serializable properties
* (toDOM, parseDOM, toDebugString, leafText, toText) and converting the attrs to
* SerializedAttributeSpec format.
*
* @param nodeSpec - The NodeSpec to serialize
* @returns A SerializedNodeSpec with only JSON-serializable properties
*/
function serializeNodeSpec(nodeSpec: NodeSpec): SerializedNodeSpec {
return {
...omit(nodeSpec, ['toDOM', 'parseDOM', 'toDebugString', 'leafText', 'toText', 'attrs']),
attrs: nodeSpec.attrs ? serializeAttributeSpecs(nodeSpec.attrs) : undefined,
}
}
/**
* Serializes a MarkSpec by removing non-serializable properties (toDOM, parseDOM)
* and converting the attrs to SerializedAttributeSpec format.
*
* @param markSpec - The MarkSpec to serialize
* @returns A SerializedMarkSpec with only JSON-serializable properties
*/
function serializeMarkSpec(markSpec: MarkSpec): SerializedMarkSpec {
return {
...omit(markSpec, ['toDOM', 'parseDOM', 'attrs']),
attrs: markSpec.attrs ? serializeAttributeSpecs(markSpec.attrs) : undefined,
}
}
/**
* Serializes a ProseMirror Schema to a JSON-serializable format.
* Converts all node and mark specifications by removing non-serializable properties
* and converting attribute validation functions to strings.
*
* Non-serializable properties are properties related to DOM serialization and parsing,
* and functions that validate attribute values. These are just removed from the schema.
*
* @param schema - The ProseMirror Schema to serialize
* @returns A SerializedSchema containing the top node name and serialized node/mark specs
*/
export function serializeSchema(schema: Schema): SerializedSchema {
return {
topNode: schema.spec.topNode,
nodes: Object.fromEntries(
Object.values(schema.nodes).map(node => [node.name, serializeNodeSpec(node.spec)]),
),
marks: Object.fromEntries(
Object.values(schema.marks).map(mark => [mark.name, serializeMarkSpec(mark.spec)]),
),
}
}

View File

@@ -0,0 +1,23 @@
import { Extension } from '@tiptap/core'
import { ServerAiToolkitHashExtension } from './hash-extension/server-ai-toolkit-hash-extension.js'
/**
* Server AI Toolkit extension.
*
* This package-level extension registers the internal hash extension used by
* the Server AI Toolkit so server-side read and edit flows can rely on stable
* `_hash` attributes already stored in the document.
*/
export const ServerAiToolkit = Extension.create({
name: 'serverAiToolkit',
/**
* Registers internal extensions required by the Server AI Toolkit.
*
* @return The list of internal extensions.
*/
addExtensions() {
return [ServerAiToolkitHashExtension]
},
})

View File

@@ -0,0 +1,509 @@
// @vitest-environment happy-dom
import { Editor } from '@tiptap/core'
import { Collaboration } from '@tiptap/extension-collaboration'
import StarterKit from '@tiptap/starter-kit'
import { afterEach, describe, expect, it, vi } from 'vite-plus/test'
import * as Y from 'yjs'
import { AiInsertReveal } from './streaming-reveal.js'
const AI_CLIENT_ID = 111111
const HUMAN_CLIENT_ID = 222222
/** The awareness payload the Tiptap AI server publishes while it streams. */
const AI_USER = { name: 'AI', color: '#8B5CF6', aiInstanceId: 'ai-instance-1' }
/** Stands in for a collab provider, exposing only the awareness surface we read. */
function createProvider() {
const states = new Map<number, Record<string, any>>()
const listeners = new Set<() => void>()
return {
awareness: {
getStates: () => states,
on: (_event: string, listener: () => void) => listeners.add(listener),
off: (_event: string, listener: () => void) => listeners.delete(listener),
},
announce(clientId: number, user: Record<string, any>) {
states.set(clientId, { user })
listeners.forEach(listener => listener())
},
}
}
/** Creates an editor with {@link AiInsertReveal} but no collaboration (no y-sync plugin). */
function createEditor(): Promise<Editor> {
return new Promise(resolve => {
const editor = new Editor({
element: document.createElement('div'),
extensions: [StarterKit, AiInsertReveal.configure({ provider: createProvider() })],
content: {
type: 'doc',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Hello' }] }],
},
onCreate: () => {
resolve(editor)
},
})
})
}
/** A remote peer that keeps its clientID across writes. */
function createPeer(ydoc: Y.Doc, clientID: number): Y.Doc {
const peer = new Y.Doc()
peer.clientID = clientID
Y.applyUpdate(peer, Y.encodeStateAsUpdate(ydoc))
return peer
}
/** Creates a collaborative editor seeded with one `Hello` paragraph, plus two remote peers. */
function createCollabEditor(options?: {
durationMs?: number
}): Promise<{ editor: Editor; ydoc: Y.Doc; ai: Y.Doc; human: Y.Doc }> {
const ydoc = new Y.Doc()
const provider = createProvider()
provider.announce(AI_CLIENT_ID, AI_USER)
provider.announce(HUMAN_CLIENT_ID, { name: 'Someone else', color: '#0EA5E9' })
return new Promise(resolve => {
new Editor({
element: document.createElement('div'),
extensions: [
StarterKit.configure({ undoRedo: false }),
Collaboration.configure({ document: ydoc }),
AiInsertReveal.configure({
provider,
...(options?.durationMs === undefined ? {} : { 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,
ai: createPeer(ydoc, AI_CLIENT_ID),
human: createPeer(ydoc, HUMAN_CLIENT_ID),
})
},
})
})
}
/** Applies an insert from `peer`, so it lands as a remote transaction authored by it. */
function remoteInsert(peer: Y.Doc, ydoc: Y.Doc, index: number, text: string): void {
Y.applyUpdate(peer, Y.encodeStateAsUpdate(ydoc, Y.encodeStateVector(peer)))
const paragraph = peer.getXmlFragment('default').get(0) as Y.XmlElement
const xmlText = paragraph.get(0) as Y.XmlText
xmlText.insert(index, text)
Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(peer, Y.encodeStateVector(ydoc)))
}
/** Applies a whole new block from `peer`, the way the AI writes a fresh paragraph. */
function remoteInsertBlock(peer: Y.Doc, ydoc: Y.Doc, text: string): void {
Y.applyUpdate(peer, Y.encodeStateAsUpdate(ydoc, Y.encodeStateVector(peer)))
const fragment = peer.getXmlFragment('default')
const block = new Y.XmlElement('paragraph')
const blockText = new Y.XmlText()
block.insert(0, [blockText])
fragment.insert(0, [block])
if (text.length > 0) blockText.insert(0, text)
Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(peer, Y.encodeStateVector(ydoc)))
}
/** Collects the extension's current reveal decorations, resolved against `state`. */
function revealDecorations(
editor: Editor,
state: unknown = editor.state,
): Array<{ from: number; to: number; style: string }> {
for (const plugin of editor.state.plugins) {
const set = (plugin as any).props?.decorations?.call(plugin, 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', () => {
afterEach(() => {
vi.useRealTimers()
})
it('is a named Tiptap extension', () => {
expect(AiInsertReveal.name).toBe('aiInsertReveal')
})
it('warns and degrades to a no-op when no collaboration y-sync plugin is present', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
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')
expect(warn).toHaveBeenCalledWith(expect.stringContaining('Collaboration extension'))
warn.mockRestore()
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,
provider: createProvider(),
}),
],
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, ai } = await createCollabEditor()
remoteInsert(ai, 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('reveals a block that arrives as a whole new node', async () => {
const { editor, ydoc, ai } = await createCollabEditor()
remoteInsertBlock(ai, ydoc, 'A brand new title')
const decorations = revealDecorations(editor)
expect(decorations).toHaveLength(1)
expect(decorations[0].to - decorations[0].from).toBe('A brand new title'.length)
editor.destroy()
})
it('reveals text streamed into a block that was created empty', async () => {
const { editor, ydoc, ai } = await createCollabEditor()
remoteInsertBlock(ai, ydoc, '')
remoteInsert(ai, ydoc, 0, 'Streamed title')
const decorations = revealDecorations(editor)
expect(decorations).toHaveLength(1)
expect(decorations[0].to - decorations[0].from).toBe('Streamed title'.length)
editor.destroy()
})
it('merges touching runs that share an animation offset into one decoration', async () => {
const { editor, ydoc, ai } = await createCollabEditor()
// Only the clock is faked: the editor is created through a real `setTimeout`.
vi.useFakeTimers({ toFake: ['Date'] })
remoteInsert(ai, ydoc, 5, ' one')
remoteInsert(ai, ydoc, 9, ' two')
expect(editor.getText()).toBe('Hello one two')
const decorations = revealDecorations(editor)
expect(decorations).toHaveLength(1)
expect(decorations[0].to - decorations[0].from).toBe(' one two'.length)
editor.destroy()
})
it('reveals an insert at the very end of the document', async () => {
const { editor, ydoc, ai } = await createCollabEditor()
remoteInsert(ai, ydoc, 5, '!')
expect(editor.getText()).toBe('Hello!')
const decorations = revealDecorations(editor)
expect(decorations).toHaveLength(1)
expect(decorations[0].to - decorations[0].from).toBe(1)
expect(decorations[0].to).toBeLessThanOrEqual(editor.state.doc.content.size)
editor.destroy()
})
it('clamps a decoration that resolves past the end of the document', async () => {
const { editor, ydoc, ai } = await createCollabEditor()
remoteInsert(ai, ydoc, 5, ' WORLD')
expect(revealDecorations(editor)).toHaveLength(1)
// Y.Doc can be ahead of the PM doc mid-sync, so resolve against a shorter doc.
const shortDoc = editor.schema.nodeFromJSON({
type: 'doc',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Hello!' }] }],
})
const decorations = revealDecorations(editor, { ...editor.state, doc: shortDoc })
expect(decorations).toHaveLength(1)
expect(decorations[0].to).toBe(shortDoc.content.size)
editor.destroy()
})
it("does not reveal another collaborator's remote insert", async () => {
const { editor, ydoc, human } = await createCollabEditor()
remoteInsert(human, ydoc, 5, ' WORLD')
// The insert lands like any remote edit, it just must not fade.
expect(editor.getText()).toBe('Hello WORLD')
expect(revealDecorations(editor)).toHaveLength(0)
editor.destroy()
})
it('reveals the AI while a collaborator types alongside it', async () => {
const { editor, ydoc, ai, human } = await createCollabEditor()
remoteInsert(human, ydoc, 5, ' HUMAN')
remoteInsert(ai, ydoc, 11, ' AI')
expect(editor.getText()).toBe('Hello HUMAN AI')
const decorations = revealDecorations(editor)
expect(decorations).toHaveLength(1)
expect(decorations[0].to - decorations[0].from).toBe(' AI'.length)
editor.destroy()
})
it('reveals a run that arrived before awareness identified the AI', async () => {
const ydoc = new Y.Doc()
const provider = createProvider()
const editor = await new Promise<Editor>(resolve => {
new Editor({
element: document.createElement('div'),
extensions: [
StarterKit.configure({ undoRedo: false }),
Collaboration.configure({ document: ydoc }),
AiInsertReveal.configure({ provider }),
],
onCreate: ({ editor: created }) => {
created.commands.setContent('<p>Hello</p>')
resolve(created)
},
})
})
// The AI's first tokens can land before its awareness entry does.
remoteInsert(createPeer(ydoc, AI_CLIENT_ID), ydoc, 5, ' WORLD')
expect(revealDecorations(editor)).toHaveLength(0)
provider.announce(AI_CLIENT_ID, AI_USER)
expect(revealDecorations(editor)).toHaveLength(1)
editor.destroy()
})
it('reveals a multi-block structure the AI writes as one node', async () => {
const { editor, ydoc, ai } = await createCollabEditor()
Y.applyUpdate(ai, Y.encodeStateAsUpdate(ydoc, Y.encodeStateVector(ai)))
const list = new Y.XmlElement('bulletList')
for (let item = 0; item < 3; item++) {
const listItem = new Y.XmlElement('listItem')
const paragraph = new Y.XmlElement('paragraph')
const text = new Y.XmlText()
paragraph.insert(0, [text])
listItem.insert(0, [paragraph])
list.insert(list.length, [listItem])
text.insert(0, `Item ${item}`)
}
ai.getXmlFragment('default').insert(0, [list])
Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(ai, Y.encodeStateVector(ydoc)))
// One structure, so the block cap must not mistake it for a document sync.
expect(revealDecorations(editor)).toHaveLength(3)
editor.destroy()
})
it('reveals nothing when a whole AI-authored document arrives at once', async () => {
const ydoc = new Y.Doc()
const provider = createProvider()
provider.announce(AI_CLIENT_ID, AI_USER)
const editor = await new Promise<Editor>(resolve => {
const created = new Editor({
element: document.createElement('div'),
extensions: [
StarterKit.configure({ undoRedo: false }),
Collaboration.configure({ document: ydoc }),
AiInsertReveal.configure({ provider }),
],
onCreate: () => resolve(created),
})
})
// Joining a room hands over everything the AI wrote before, in one update.
const server = new Y.Doc()
server.clientID = AI_CLIENT_ID
const fragment = server.getXmlFragment('default')
for (let block = 0; block < 5; block++) {
const element = new Y.XmlElement('paragraph')
const text = new Y.XmlText()
element.insert(0, [text])
fragment.insert(fragment.length, [element])
text.insert(0, `Paragraph ${block} written earlier by the AI.`)
}
Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(server))
expect(editor.getText().length).toBeGreaterThan(0)
expect(revealDecorations(editor)).toHaveLength(0)
editor.destroy()
})
it('warns when the provider has no awareness', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const editor = await new Promise<Editor>(resolve => {
const created = new Editor({
element: document.createElement('div'),
extensions: [StarterKit, AiInsertReveal.configure({ provider: {} })],
onCreate: () => resolve(created),
})
})
expect(warn).toHaveBeenCalledWith(expect.stringContaining('no awareness'))
warn.mockRestore()
editor.destroy()
})
it('warns when durationMs cannot hold a reveal', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const editor = await new Promise<Editor>(resolve => {
const created = new Editor({
element: document.createElement('div'),
extensions: [
StarterKit,
AiInsertReveal.configure({ provider: createProvider(), durationMs: 0 }),
],
onCreate: () => resolve(created),
})
})
expect(warn).toHaveBeenCalledWith(expect.stringContaining('durationMs'))
warn.mockRestore()
editor.destroy()
})
it('warns and reveals nothing when no provider is configured', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const ydoc = new Y.Doc()
const editor = await new Promise<Editor>(resolve => {
new Editor({
element: document.createElement('div'),
extensions: [
StarterKit.configure({ undoRedo: false }),
Collaboration.configure({ document: ydoc }),
AiInsertReveal,
],
onCreate: ({ editor: created }) => {
created.commands.setContent('<p>Hello</p>')
resolve(created)
},
})
})
remoteInsert(createPeer(ydoc, AI_CLIENT_ID), ydoc, 5, ' WORLD')
// Without awareness the AI cannot be identified, so it fails closed.
expect(editor.getText()).toBe('Hello WORLD')
expect(revealDecorations(editor)).toHaveLength(0)
expect(warn).toHaveBeenCalledWith(expect.stringContaining('"provider" option is required'))
warn.mockRestore()
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, ai } = await createCollabEditor({ durationMs: 30 })
vi.useFakeTimers({ toFake: ['Date'] })
remoteInsert(ai, ydoc, 5, ' WORLD')
expect(revealDecorations(editor)).toHaveLength(1)
vi.setSystemTime(Date.now() + 60)
expect(revealDecorations(editor)).toHaveLength(0)
editor.destroy()
})
it('clamps a run longer than the max reveal range to that many characters', async () => {
const { editor, ydoc, ai } = await createCollabEditor()
remoteInsert(ai, ydoc, 5, 'x'.repeat(401))
expect(editor.getText()).toBe(`Hello${'x'.repeat(401)}`)
const decorations = revealDecorations(editor)
expect(decorations).toHaveLength(1)
// The whole run inserts; only the fade is capped (MAX_REVEAL_RANGE = 400).
expect(decorations[0].to - decorations[0].from).toBe(400)
editor.destroy()
})
it('drops a run whose resolved span no longer matches its inserted length', async () => {
const { editor, ydoc, ai } = await createCollabEditor()
remoteInsert(ai, ydoc, 5, ' WORLD')
// 'XYZ' lands inside the first run, so its span drifts from 6 to 9 and drops as stale.
remoteInsert(ai, ydoc, 8, 'XYZ')
const decorations = revealDecorations(editor)
expect(decorations).toHaveLength(1)
expect(decorations[0].to - decorations[0].from).toBe(3)
editor.destroy()
})
it('tears down cleanly after a reveal without throwing', async () => {
const { editor, ydoc, ai } = await createCollabEditor()
remoteInsert(ai, ydoc, 5, ' WORLD')
expect(revealDecorations(editor)).toHaveLength(1)
expect(() => editor.destroy()).not.toThrow()
})
})

View File

@@ -0,0 +1,392 @@
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
/**
* The collaboration provider, e.g. `HocuspocusProvider` or `TiptapCloudProvider`.
* Its awareness is what identifies the AI, so nothing is revealed without it.
*/
provider: RevealProvider | null
}
/** The awareness surface this extension reads from the provider. */
export type RevealProvider = {
awareness?: {
getStates: () => Map<number, Record<string, any> | undefined>
on: (event: 'change', listener: () => void) => void
off: (event: 'change', listener: () => void) => void
}
}
/** Caps the fade to a run's first N chars; the rest reveals instantly. */
const MAX_REVEAL_RANGE = 400
/** ProseMirror compares decoration attrs by value, so an unrounded offset rebuilds the DOM. */
const REVEAL_AGE_STEP = 100
/** Above this, an update is a document sync, such as joining a room, not live streaming. */
const MAX_BLOCKS_PER_UPDATE = 2
/** Yjs relative positions so each run survives y-tiptap doc rebuilds. */
type RevealEntry = {
start: Y.RelativePosition
end: Y.RelativePosition
at: number
/** Inserted char count; a resolved span that drifts from it is stale. */
length: number
/** Author, resolved against awareness at render time rather than on arrival. */
client: number
}
/** Minimal shape of the y-sync plugin state we read. */
type YSyncState = {
doc: Y.Doc
type: Y.XmlFragment
binding: { mapping: Map<Y.AbstractType<unknown>, PMNode | PMNode[]> } | null
}
const aiInsertRevealKey = new PluginKey('aiInsertReveal')
function resolveRevealRange(
ystate: YSyncState,
entry: RevealEntry,
now: number,
durationMs: number,
docSize: number,
): { from: number; to: number; age: number } | null {
const age = now - entry.at
if (age >= durationMs) return null
const span = resolveSpan(ystate, entry, docSize)
return span === null ? null : { ...span, age }
}
function resolveSpan(
ystate: YSyncState,
entry: RevealEntry,
docSize: number,
): { 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 : clampSpan(from, to, entry.length, docSize)
}
/** Drops a span whose width drifted from the insert, so stale mappings never paint. */
function clampSpan(
from: number,
to: number,
length: number,
docSize: number,
): { from: number; to: number } | null {
const lo = Math.min(from, to)
const hi = Math.max(from, to)
if (hi - lo !== length) return null
const start = Math.max(0, lo)
const end = Math.min(docSize, hi, start + MAX_REVEAL_RANGE)
return start >= end ? null : { from: start, to: end }
}
/** Merges touching runs with the same offset, so a stream paints a few spans, not one per token. */
function mergeRuns(
ranges: Array<{ from: number; to: number; age: number }>,
): Array<{ from: number; to: number; delay: number }> {
const merged: Array<{ from: number; to: number; delay: number }> = []
for (const range of ranges) {
const delay = Math.round(range.age / REVEAL_AGE_STEP) * REVEAL_AGE_STEP
const last = merged[merged.length - 1]
if (last !== undefined && last.delay === delay && last.to === range.from) {
last.to = range.to
} else {
merged.push({ from: range.from, to: range.to, delay })
}
}
return merged
}
/** The Tiptap AI server tags the identity it publishes with an instance id. */
function isAiUser(user: Record<string, any> | undefined): boolean {
return Boolean(user?.aiInstanceId)
}
/** Clients whose clock advanced in this transaction, i.e. who authored it. */
function transactionAuthors(transaction: Y.Transaction): number[] {
const authors: number[] = []
transaction.afterState.forEach((clock, client) => {
if ((transaction.beforeState.get(client) ?? 0) < clock) authors.push(client)
})
return authors
}
function collectInsertedRuns(
event: Y.YEvent<Y.AbstractType<unknown>>,
now: number,
client: number,
): RevealEntry[] {
const target = event.target
const runs: RevealEntry[] = []
if (target instanceof Y.XmlText) {
let index = 0
for (const op of event.delta) {
const { advance, inserted } = scanDeltaOp(op)
if (inserted > 0) runs.push(makeRun(target, index, inserted, now, client))
index += advance
}
return runs
}
// Whole nodes arrive when the AI writes a heading or a fresh block, so their
// text never shows up as an insert into existing content.
for (const op of event.delta) {
if (!Array.isArray(op.insert)) continue
if (op.insert.length > MAX_BLOCKS_PER_UPDATE) continue
for (const node of op.insert) collectNodeText(node, now, client, runs)
}
return runs
}
function collectNodeText(node: unknown, now: number, client: number, runs: RevealEntry[]): void {
if (node instanceof Y.XmlText) {
if (node.length > 0) runs.push(makeRun(node, 0, node.length, now, client))
return
}
if (node instanceof Y.XmlElement) {
for (const child of node.toArray()) collectNodeText(child, now, client, 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 assoc < 0 so an appended token starts a new run, not extends it.
* At index 0 the start needs the same assoc, or it resolves to nothing.
*/
function makeRun(
target: Y.XmlText,
index: number,
length: number,
now: number,
client: number,
): RevealEntry {
return {
start: Y.createRelativePositionFromTypeIndex(target, index, index === 0 ? -1 : 0),
end: Y.createRelativePositionFromTypeIndex(target, index + length, -1),
at: now,
length,
client,
}
}
/**
* Fades in text the AI streams into the document, using view-only decorations.
* It never mutates the document, so it is inert to accept/reject and persistence.
* Requires Collaboration, a provider, and CSS for `className` (default `ai-insert-reveal`).
*/
export const AiInsertReveal = Extension.create<AiInsertRevealOptions>({
name: 'aiInsertReveal',
addOptions() {
return {
className: 'ai-insert-reveal',
durationMs: 550,
provider: null,
}
},
onCreate() {
const { provider, durationMs } = this.options
if (!provider) {
console.warn(
'[tiptap warn]: The "provider" option is required for "AiInsertReveal" to tell the AI apart from other collaborators. Nothing is revealed without it.',
)
} else if (!provider.awareness) {
console.warn(
'[tiptap warn]: The provider passed to "AiInsertReveal" has no awareness, so the AI cannot be identified. Nothing is revealed.',
)
}
if (durationMs <= 0) {
console.warn(
`[tiptap warn]: "AiInsertReveal" needs a positive "durationMs" to hold a reveal, got ${durationMs}. Nothing is revealed.`,
)
}
},
addProseMirrorPlugins() {
const { className, durationMs, provider } = this.options
// Runs in insertion (time) order, so expired entries are always a prefix.
const entries: RevealEntry[] = []
// Never pruned: the AI clears its awareness entry when it stops, and its first
// tokens can arrive before that entry lands.
const aiClients = new Set<number>()
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 ranges = entries
.filter(entry => aiClients.has(entry.client))
.map(entry =>
resolveRevealRange(ystate, entry, now, durationMs, state.doc.content.size),
)
.filter((range): range is NonNullable<typeof range> => 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.
const decorations = mergeRuns(ranges).map(range =>
Decoration.inline(range.from, range.to, {
class: className,
style: `animation-delay: -${range.delay}ms`,
}),
)
return DecorationSet.create(state.doc, decorations)
},
},
view: view => {
const initialState = ySyncPluginKey.getState(view.state) as YSyncState | undefined
const fragment = initialState?.type ?? null
const awareness = provider?.awareness ?? null
if (fragment === null) {
console.warn(
'[tiptap warn]: "AiInsertReveal" needs the Collaboration extension to see incoming text. Nothing is revealed.',
)
}
const trackAiClients = () => {
const known = aiClients.size
awareness
?.getStates()
.forEach((state: Record<string, any> | undefined, clientId: number) => {
if (isAiUser(state?.user)) aiClients.add(clientId)
})
// Runs that arrived before the AI was identified can now be revealed.
if (aiClients.size > known) scheduleRerender()
}
let raf: number | null = null
let pruneTimer: ReturnType<typeof setTimeout> | null = null
const rerender = () => {
if (view.isDestroyed) return
// Empty transaction: re-runs `decorations` so new entries paint and expired ones drop.
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<Y.YEvent<Y.AbstractType<unknown>>>,
transaction: Y.Transaction,
) => {
if (transaction.local) return
// A batch mixing several authors cannot be attributed run by run.
const authors = transactionAuthors(transaction)
if (authors.length !== 1) return
const now = Date.now()
dropExpired(now)
const runs = events.flatMap(event => collectInsertedRuns(event, now, authors[0]))
if (runs.length === 0) return
entries.push(...runs)
scheduleRerender()
schedulePrune()
}
trackAiClients()
awareness?.on('change', trackAiClients)
fragment?.observeDeep(onChange)
return {
destroy: () => {
awareness?.off('change', trackAiClients)
fragment?.unobserveDeep(onChange)
if (raf !== null) cancelAnimationFrame(raf)
if (pruneTimer !== null) clearTimeout(pruneTimer)
entries.length = 0
},
}
},
}),
]
},
})

View File

@@ -0,0 +1,140 @@
// @vitest-environment happy-dom
import { Editor, Extension } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import { describe, expect, it } from 'vite-plus/test'
import { ServerAiToolkit } from '@tiptap/ai-toolkit'
/**
* Options for creating a test editor.
*/
interface CreateEditorOptions {
/**
* Initial document JSON.
*/
content?: Record<string, unknown>
/**
* Additional extensions to include alongside the default ones.
*/
extensions?: Extension[]
}
/**
* Creates an editor configured with the {@link ServerAiToolkit} extension.
*
* @param options - Editor configuration options.
* @return Promise resolving once the editor create lifecycle has finished.
*/
function createEditor(options: CreateEditorOptions): Promise<Editor> {
return new Promise(resolve => {
const editor = new Editor({
element: document.createElement('div'),
content: options.content,
extensions: [StarterKit, ServerAiToolkit, ...(options.extensions ?? [])],
onCreate: () => {
resolve(editor)
},
})
})
}
/**
* Creates an editor with an explicit extension order.
*
* @param extensions - Extensions to register in the desired order.
* @param content - Initial document JSON.
* @return Promise resolving once the editor create lifecycle has finished.
*/
function createEditorWithExplicitExtensions(
extensions: Extension[],
content?: Record<string, unknown>,
): Promise<Editor> {
return new Promise(resolve => {
const editor = new Editor({
element: document.createElement('div'),
content,
extensions,
onCreate: () => {
resolve(editor)
},
})
})
}
const MockAiToolkit = Extension.create({
name: 'aiToolkit',
})
describe('ServerAiToolkit', () => {
it('registers _hash attributes without generating values on creation', async () => {
const editor = await createEditor({
content: {
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'First' }] },
{ type: 'paragraph', content: [{ type: 'text', text: 'Second' }] },
],
},
})
const paragraphs = editor.getJSON().content ?? []
expect(paragraphs).toHaveLength(2)
// oxlint-disable-next-line no-underscore-dangle
expect(paragraphs[0]?.attrs?._hash ?? null).toBeNull()
// oxlint-disable-next-line no-underscore-dangle
expect(paragraphs[1]?.attrs?._hash ?? null).toBeNull()
editor.destroy()
})
it('preserves provided hashes through a JSON round-trip', async () => {
const firstEditor = await createEditor({
content: {
type: 'doc',
content: [
{
type: 'paragraph',
attrs: { _hash: 'ABC123' },
content: [{ type: 'text', text: 'Hello' }],
},
],
},
})
const firstJson = firstEditor.getJSON()
// oxlint-disable-next-line no-underscore-dangle
const firstHash = firstJson.content?.[0]?.attrs?._hash
firstEditor.destroy()
const secondEditor = await createEditor({
content: firstJson,
})
const secondJson = secondEditor.getJSON()
// oxlint-disable-next-line no-underscore-dangle
const secondHash = secondJson.content?.[0]?.attrs?._hash
expect(secondHash).toBe(firstHash)
secondEditor.destroy()
})
it('does not synthesize hashes when the AI Toolkit extension is present', async () => {
const editor = await createEditorWithExplicitExtensions(
[StarterKit, ServerAiToolkit, MockAiToolkit],
{
type: 'doc',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'First' }] }],
},
)
// oxlint-disable-next-line no-underscore-dangle
expect(editor.getJSON().content?.[0]?.attrs?._hash ?? null).toBeNull()
editor.destroy()
})
})

View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vite-plus'
import { basePackConfig } from '../../pack.config.mjs'
export default defineConfig({
pack: {
entry: ['src/index.ts', 'src/streaming-reveal.ts'],
...basePackConfig(),
},
})