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
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:
13
packages/ai-toolkit/CHANGELOG.md
Normal file
13
packages/ai-toolkit/CHANGELOG.md
Normal 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`.
|
||||
18
packages/ai-toolkit/README.md
Normal file
18
packages/ai-toolkit/README.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# @tiptap/ai-toolkit
|
||||
|
||||
[](https://www.npmjs.com/package/@tiptap/ai-toolkit)
|
||||
[](https://npmcharts.com/compare/tiptap?minimal=true)
|
||||
[](https://www.npmjs.com/package/@tiptap/ai-toolkit)
|
||||
[](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).
|
||||
80
packages/ai-toolkit/package.json
Normal file
80
packages/ai-toolkit/package.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
})
|
||||
2
packages/ai-toolkit/src/index.ts
Normal file
2
packages/ai-toolkit/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './schema-awareness/index.js'
|
||||
export * from './server-ai-toolkit-extension.js'
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
2
packages/ai-toolkit/src/schema-awareness/index.ts
Normal file
2
packages/ai-toolkit/src/schema-awareness/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './get-editor-context.js'
|
||||
export * from './types/index.js'
|
||||
@@ -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[]
|
||||
}
|
||||
2
packages/ai-toolkit/src/schema-awareness/types/index.ts
Normal file
2
packages/ai-toolkit/src/schema-awareness/types/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './editor-context.js'
|
||||
export * from './json-item.js'
|
||||
51
packages/ai-toolkit/src/schema-awareness/types/json-item.ts
Normal file
51
packages/ai-toolkit/src/schema-awareness/types/json-item.ts
Normal 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>
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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)]),
|
||||
),
|
||||
}
|
||||
}
|
||||
23
packages/ai-toolkit/src/server-ai-toolkit-extension.ts
Normal file
23
packages/ai-toolkit/src/server-ai-toolkit-extension.ts
Normal 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]
|
||||
},
|
||||
})
|
||||
509
packages/ai-toolkit/src/streaming-reveal.test.ts
Normal file
509
packages/ai-toolkit/src/streaming-reveal.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
392
packages/ai-toolkit/src/streaming-reveal.ts
Normal file
392
packages/ai-toolkit/src/streaming-reveal.ts
Normal 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
|
||||
},
|
||||
}
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
140
packages/ai-toolkit/tests/integration/serverAiToolkit.test.ts
Normal file
140
packages/ai-toolkit/tests/integration/serverAiToolkit.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
9
packages/ai-toolkit/vite.config.ts
Normal file
9
packages/ai-toolkit/vite.config.ts
Normal 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(),
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user