From bc0690574c59aa9c27b263e76830329a9ba3d836 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Thu, 24 Sep 2026 14:35:35 +0200 Subject: [PATCH 1/2] refactor: remove AI Lodash dependencies and simplify workspace code --- .../commands/updateBlock/updateBlock.test.ts | 2 +- .../blockManipulation/tables/tables.test.ts | 25 ++-- packages/core/src/api/getBlockInfoFromPos.ts | 11 +- .../src/api/getBlocksChangedByTransaction.ts | 2 +- .../src/api/nodeConversions/nodeToBlock.ts | 30 +++-- .../api/parsers/markdown/markdownToHtml.ts | 4 +- .../NumberedListItem/IndexingPlugin.ts | 2 +- .../comments/threadstore/TipTapThreadStore.ts | 10 +- .../core/src/editor/managers/BlockManager.ts | 4 +- .../editor/managers/ExtensionManager/index.ts | 6 +- packages/core/src/editor/transformPasted.ts | 7 +- .../extensions/SideMenu/blockDropHandling.ts | 2 +- .../Link/helpers/autolink.ts | 3 +- .../Link/helpers/linkDetector.ts | 2 +- .../tiptap-extensions/UniqueID/UniqueID.ts | 4 +- packages/core/src/util/string.ts | 4 +- .../core/src/y/extensions/YCursorPlugin.ts | 2 +- .../core/src/yjs/extensions/YCursorPlugin.ts | 2 +- .../components/Comments/ThreadsSidebar.tsx | 4 +- packages/react/src/hooks/useStore.ts | 6 +- packages/xl-ai-server/src/routes/mock.ts | 3 +- .../src/routes/serverPersistence.ts | 5 +- packages/xl-ai/package.json | 4 - .../sendMessageWithAIRequest.test.ts | 110 ++++++++++++++++++ .../api/aiRequest/sendMessageWithAIRequest.ts | 17 ++- .../formats/base-tools/createAddBlocksTool.ts | 15 ++- .../FormattingToolbar/AIToolbarButton.tsx | 6 +- packages/xl-ai/src/prosemirror/changeset.ts | 5 +- .../xl-ai/src/streamTool/jsonSchema.test.ts | 77 ++++++++++++ packages/xl-ai/src/streamTool/jsonSchema.ts | 35 +++++- pnpm-lock.yaml | 32 ----- 31 files changed, 317 insertions(+), 124 deletions(-) create mode 100644 packages/xl-ai/src/api/aiRequest/sendMessageWithAIRequest.test.ts create mode 100644 packages/xl-ai/src/streamTool/jsonSchema.test.ts diff --git a/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.test.ts b/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.test.ts index 2044df111b..c947bbbc00 100644 --- a/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.test.ts +++ b/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.test.ts @@ -1097,7 +1097,7 @@ describe("Test updateBlock minimal steps", () => { expect(block.props.level).toBe(3); // The styled text on either side of the replaced range is preserved. expect(block.content[0].text).toBe("Paragraph"); - expect(block.content[block.content.length - 1].text).toBe("content"); + expect(block.content.at(-1)?.text).toBe("content"); expect(() => editor._tiptapEditor.state.doc.check()).not.toThrow(); }); }); diff --git a/packages/core/src/api/blockManipulation/tables/tables.test.ts b/packages/core/src/api/blockManipulation/tables/tables.test.ts index 520a65327d..802a014a6d 100644 --- a/packages/core/src/api/blockManipulation/tables/tables.test.ts +++ b/packages/core/src/api/blockManipulation/tables/tables.test.ts @@ -1815,11 +1815,12 @@ describe("Test cropEmptyRowsOrColumns", () => { ...simpleTable, content: { ...simpleTable.content, - rows: simpleTable.content.rows.concat([ + rows: [ + ...simpleTable.content.rows, { cells: [emptyCell, emptyCell], }, - ]), + ], }, }, "rows", @@ -1847,11 +1848,12 @@ describe("Test cropEmptyRowsOrColumns", () => { ...tableWithColspan, content: { ...tableWithColspan.content, - rows: tableWithColspan.content.rows.concat([ + rows: [ + ...tableWithColspan.content.rows, { cells: [emptyCell, emptyCell, emptyCell], }, - ]), + ], }, }, "rows", @@ -1888,11 +1890,12 @@ describe("Test cropEmptyRowsOrColumns", () => { ...tableWithRowspan, content: { ...tableWithRowspan.content, - rows: tableWithRowspan.content.rows.concat([ + rows: [ + ...tableWithRowspan.content.rows, { cells: [emptyCell, emptyCell, emptyCell], }, - ]), + ], }, }, "rows", @@ -1932,11 +1935,12 @@ describe("Test cropEmptyRowsOrColumns", () => { ...tableWithColspanAndRowspan, content: { ...tableWithColspanAndRowspan.content, - rows: tableWithColspanAndRowspan.content.rows.concat([ + rows: [ + ...tableWithColspanAndRowspan.content.rows, { cells: [emptyCell, emptyCell, emptyCell], }, - ]), + ], }, }, "rows", @@ -1985,11 +1989,12 @@ describe("Test cropEmptyRowsOrColumns", () => { ...tableWithComplexRowspansAndColspans, content: { ...tableWithComplexRowspansAndColspans.content, - rows: tableWithComplexRowspansAndColspans.content.rows.concat([ + rows: [ + ...tableWithComplexRowspansAndColspans.content.rows, { cells: [emptyCell, emptyCell, emptyCell, emptyCell], }, - ]), + ], }, }, "rows", diff --git a/packages/core/src/api/getBlockInfoFromPos.ts b/packages/core/src/api/getBlockInfoFromPos.ts index 693cc23d65..36c8856f0f 100644 --- a/packages/core/src/api/getBlockInfoFromPos.ts +++ b/packages/core/src/api/getBlockInfoFromPos.ts @@ -306,10 +306,13 @@ export function getNearestBlockPos(doc: Node, pos: number) { // eslint-disable-next-line no-console console.warn(`Position ${pos} is not within a blockContainer node.`); - const resolvedPos = doc.resolve( - allBlockContainerPositions.find((position) => position >= pos) || - allBlockContainerPositions[allBlockContainerPositions.length - 1], - ); + const blockPosition = + allBlockContainerPositions.find((position) => position >= pos) ?? + allBlockContainerPositions.at(-1); + if (blockPosition === undefined) { + throw new Error("Document has no block containers"); + } + const resolvedPos = doc.resolve(blockPosition); return { posBeforeNode: resolvedPos.pos, node: resolvedPos.nodeAfter!, diff --git a/packages/core/src/api/getBlocksChangedByTransaction.ts b/packages/core/src/api/getBlocksChangedByTransaction.ts index c374ccc9fe..21f162ee3a 100644 --- a/packages/core/src/api/getBlocksChangedByTransaction.ts +++ b/packages/core/src/api/getBlocksChangedByTransaction.ts @@ -270,7 +270,7 @@ function detectReorderedChildren( } const lisIndexSet = new Set(); - let k = tailsEndsAtIndex[tailsEndsAtIndex.length - 1] ?? -1; + let k = tailsEndsAtIndex.at(-1) ?? -1; while (k !== -1) { lisIndexSet.add(k); k = previousIndexInLis[k]; diff --git a/packages/core/src/api/nodeConversions/nodeToBlock.ts b/packages/core/src/api/nodeConversions/nodeToBlock.ts index 7f378075a4..90b7f98873 100644 --- a/packages/core/src/api/nodeConversions/nodeToBlock.ts +++ b/packages/core/src/api/nodeConversions/nodeToBlock.ts @@ -86,9 +86,7 @@ export function contentNodeToTableContent< if (rowIndex === 0) { rowNode.content.forEach((cellNode) => { let colWidth = cellNode.attrs.colwidth as null | undefined | number[]; - if (colWidth === undefined || colWidth === null) { - colWidth = new Array(cellNode.attrs.colspan ?? 1).fill(undefined); - } + colWidth ??= new Array(cellNode.attrs.colspan ?? 1).fill(undefined); ret.columnWidths.push(...colWidth); }); } @@ -109,11 +107,10 @@ export function contentNodeToTableContent< // If the schema only allowed a single tableParagraph node, then the merging would not work and cause prosemirror to fit the content into a new cell .reduce( (acc, contentPartial) => { - if (!acc.length) { + const last = acc.at(-1); + if (!last) { return contentPartial; } - - const last = acc[acc.length - 1]; const first = contentPartial[0]; // Only merge if the last and first content are both styled text nodes and have the same styles @@ -194,8 +191,11 @@ export function contentNodeToInlineContent< currentContent.text += "\n"; } else if (isLinkInlineContent(currentContent)) { // Current content is a link. - currentContent.content[currentContent.content.length - 1].text += - "\n"; + const lastContent = currentContent.content.at(-1); + if (!lastContent) { + throw new Error("Link content is unexpectedly empty"); + } + lastContent.text += "\n"; } else { throw new Error("unexpected"); } @@ -297,15 +297,13 @@ export function contentNodeToInlineContent< // Node is a link (same type as current content). // Link URLs are the same. if (currentContent.href === linkMark.attrs.href) { + const lastContent = currentContent.content.at(-1); + if (!lastContent) { + throw new Error("Link content is unexpectedly empty"); + } // Styles are the same. - if ( - JSON.stringify( - currentContent.content[currentContent.content.length - 1] - .styles, - ) === JSON.stringify(styles) - ) { - currentContent.content[currentContent.content.length - 1].text += - node.textContent; + if (JSON.stringify(lastContent.styles) === JSON.stringify(styles)) { + lastContent.text += node.textContent; } else { // Styles are different. currentContent.content.push({ diff --git a/packages/core/src/api/parsers/markdown/markdownToHtml.ts b/packages/core/src/api/parsers/markdown/markdownToHtml.ts index 01296dd3f1..0a5c0c0699 100644 --- a/packages/core/src/api/parsers/markdown/markdownToHtml.ts +++ b/packages/core/src/api/parsers/markdown/markdownToHtml.ts @@ -396,7 +396,7 @@ function parseInlineCode( if ( code.length >= 2 && code[0] === " " && - code[code.length - 1] === " " && + code.at(-1) === " " && /[^ ]/.test(code) ) { code = code.substring(1, code.length - 1); @@ -866,7 +866,7 @@ function tokenize(markdown: string): Token[] { // Horizontal rule: ---, ***, ___ (3+ chars, optionally with spaces) if (/^(\s{0,3})([-*_])\s*(\2\s*){2,}$/.test(line)) { // Setext H2: --- immediately after a paragraph (no blank line between) - const prevToken = tokens[tokens.length - 1]; + const prevToken = tokens.at(-1); if ( !prevLineWasBlank && line.trim().match(/^-+$/) && diff --git a/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.ts b/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.ts index 0222fff46a..fa110bfc16 100644 --- a/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.ts +++ b/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.ts @@ -75,7 +75,7 @@ function calculateListItemIndex( let isFirst: boolean; // Determine starting index from the block just before the chain - const lastInChain = chain[chain.length - 1]; + const lastInChain = chain.at(-1) ?? { node, pos }; const lastInfo = getBlockInfoFromNode(lastInChain.node, lastInChain.pos); if (!lastInfo.hasContent) { throw new Error("impossible"); diff --git a/packages/core/src/comments/threadstore/TipTapThreadStore.ts b/packages/core/src/comments/threadstore/TipTapThreadStore.ts index b1f7908814..428b1426a7 100644 --- a/packages/core/src/comments/threadstore/TipTapThreadStore.ts +++ b/packages/core/src/comments/threadstore/TipTapThreadStore.ts @@ -79,9 +79,13 @@ export class TiptapThreadStore extends ThreadStore { }, }); - return this.tiptapCommentToCommentData( - thread.comments[thread.comments.length - 1], - ); + const lastComment = thread.comments.at(-1); + if (!lastComment) { + throw new Error( + "Comment provider returned an empty thread after adding a comment", + ); + } + return this.tiptapCommentToCommentData(lastComment); } /** diff --git a/packages/core/src/editor/managers/BlockManager.ts b/packages/core/src/editor/managers/BlockManager.ts index dbae8b1281..21607abdde 100644 --- a/packages/core/src/editor/managers/BlockManager.ts +++ b/packages/core/src/editor/managers/BlockManager.ts @@ -131,9 +131,7 @@ export class BlockManager< return false; } - const children = reverse - ? block.children.slice().reverse() - : block.children; + const children = reverse ? block.children.toReversed() : block.children; if (!traverseBlockArray(children)) { return false; diff --git a/packages/core/src/editor/managers/ExtensionManager/index.ts b/packages/core/src/editor/managers/ExtensionManager/index.ts index 76a8674cc4..b45b435d5d 100644 --- a/packages/core/src/editor/managers/ExtensionManager/index.ts +++ b/packages/core/src/editor/managers/ExtensionManager/index.ts @@ -354,9 +354,9 @@ export class ExtensionManager { } // ---- Add phase (no updatePlugins call) ---- - const newExtensions = ([] as (Extension | ExtensionFactoryInstance)[]) - .concat(toRegister) - .filter(Boolean) as (Extension | ExtensionFactoryInstance)[]; + const newExtensions = ( + Array.isArray(toRegister) ? toRegister : [toRegister] + ).filter(Boolean) as (Extension | ExtensionFactoryInstance)[]; const registeredExtensions = newExtensions .map((ext) => this.addExtension(ext)) diff --git a/packages/core/src/editor/transformPasted.ts b/packages/core/src/editor/transformPasted.ts index 935e2b5bd2..9976216c1c 100644 --- a/packages/core/src/editor/transformPasted.ts +++ b/packages/core/src/editor/transformPasted.ts @@ -72,12 +72,9 @@ export function wrapTableRows(f: Fragment, schema: Schema) { const newItems: any[] = []; for (let i = 0; i < f.childCount; i++) { if (f.child(i).type.name === "tableRow") { - if ( - newItems.length > 0 && - newItems[newItems.length - 1].type.name === "table" - ) { + const prevTable = newItems.at(-1); + if (prevTable?.type.name === "table") { // append to existing table - const prevTable = newItems[newItems.length - 1]; const newTable = prevTable.copy(prevTable.content.addToEnd(f.child(i))); newItems[newItems.length - 1] = newTable; } else { diff --git a/packages/core/src/extensions/SideMenu/blockDropHandling.ts b/packages/core/src/extensions/SideMenu/blockDropHandling.ts index fc3293a51e..08b76a19da 100644 --- a/packages/core/src/extensions/SideMenu/blockDropHandling.ts +++ b/packages/core/src/extensions/SideMenu/blockDropHandling.ts @@ -66,7 +66,7 @@ function findDropEditor( ? coords.clientY - rect.bottom : 0; - const distance = Math.sqrt(Math.pow(distanceX, 2) + Math.pow(distanceY, 2)); + const distance = Math.sqrt(distanceX ** 2 + distanceY ** 2); if (distance < minDistance) { minDistance = distance; diff --git a/packages/core/src/extensions/tiptap-extensions/Link/helpers/autolink.ts b/packages/core/src/extensions/tiptap-extensions/Link/helpers/autolink.ts index 4a215034a2..afee4b82e6 100644 --- a/packages/core/src/extensions/tiptap-extensions/Link/helpers/autolink.ts +++ b/packages/core/src/extensions/tiptap-extensions/Link/helpers/autolink.ts @@ -104,8 +104,7 @@ export function autolink(options: AutolinkOptions): Plugin { return; } - const lastWordBeforeSpace = - wordsBeforeWhitespace[wordsBeforeWhitespace.length - 1]; + const lastWordBeforeSpace = wordsBeforeWhitespace.at(-1) ?? ""; const lastWordAndBlockOffset = textBlock.pos + textBeforeWhitespace.lastIndexOf(lastWordBeforeSpace); diff --git a/packages/core/src/extensions/tiptap-extensions/Link/helpers/linkDetector.ts b/packages/core/src/extensions/tiptap-extensions/Link/helpers/linkDetector.ts index 8fd67354b3..05fbdd1f50 100644 --- a/packages/core/src/extensions/tiptap-extensions/Link/helpers/linkDetector.ts +++ b/packages/core/src/extensions/tiptap-extensions/Link/helpers/linkDetector.ts @@ -137,7 +137,7 @@ function countChar(str: string, ch: string): number { */ function extractTld(hostname: string): string { const parts = hostname.split("."); - return parts[parts.length - 1].toLowerCase(); + return (parts.at(-1) ?? "").toLowerCase(); } function isValidTld(hostname: string): boolean { diff --git a/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts b/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts index a654823f9c..36e2f42151 100644 --- a/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts +++ b/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts @@ -21,9 +21,7 @@ function removeDuplicates(array: any, by = JSON.stringify) { const seen: any = {}; return array.filter((item: any) => { const key = by(item); - return Object.prototype.hasOwnProperty.call(seen, key) - ? false - : (seen[key] = true); + return Object.hasOwn(seen, key) ? false : (seen[key] = true); }); } diff --git a/packages/core/src/util/string.ts b/packages/core/src/util/string.ts index 48f0e9bf94..71f2ff0ef6 100644 --- a/packages/core/src/util/string.ts +++ b/packages/core/src/util/string.ts @@ -6,12 +6,12 @@ export function filenameFromURL(url: string): string { const parts = url.split("/"); if ( !parts.length || // invalid? - parts[parts.length - 1] === "" // for example, URL ends in a directory-like trailing slash + parts.at(-1) === "" // for example, URL ends in a directory-like trailing slash ) { // in this case just return the original url return url; } - return parts[parts.length - 1]; + return parts.at(-1) ?? url; } export function isVideoUrl(url: string) { diff --git a/packages/core/src/y/extensions/YCursorPlugin.ts b/packages/core/src/y/extensions/YCursorPlugin.ts index 4b120b5958..604a4e586d 100644 --- a/packages/core/src/y/extensions/YCursorPlugin.ts +++ b/packages/core/src/y/extensions/YCursorPlugin.ts @@ -26,7 +26,7 @@ function isDarkColor(bgColor: string): boolean { if (col <= 0.03928) { return col / 12.92; } - return Math.pow((col + 0.055) / 1.055, 2.4); + return ((col + 0.055) / 1.055) ** 2.4; }); const L = 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; return L <= 0.179; diff --git a/packages/core/src/yjs/extensions/YCursorPlugin.ts b/packages/core/src/yjs/extensions/YCursorPlugin.ts index f31c1b2da1..9eab0eec56 100644 --- a/packages/core/src/yjs/extensions/YCursorPlugin.ts +++ b/packages/core/src/yjs/extensions/YCursorPlugin.ts @@ -26,7 +26,7 @@ function isDarkColor(bgColor: string): boolean { if (col <= 0.03928) { return col / 12.92; } - return Math.pow((col + 0.055) / 1.055, 2.4); + return ((col + 0.055) / 1.055) ** 2.4; }); const L = 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; return L <= 0.179; diff --git a/packages/react/src/components/Comments/ThreadsSidebar.tsx b/packages/react/src/components/Comments/ThreadsSidebar.tsx index f62cb59152..72b77a44b5 100644 --- a/packages/react/src/components/Comments/ThreadsSidebar.tsx +++ b/packages/react/src/components/Comments/ThreadsSidebar.tsx @@ -98,8 +98,8 @@ function sortThreads( // sort by latest comment in thread first return threads.sort( (a, b) => - b.comments[b.comments.length - 1].createdAt.getTime() - - a.comments[a.comments.length - 1].createdAt.getTime(), + (b.comments.at(-1)?.createdAt ?? b.createdAt).getTime() - + (a.comments.at(-1)?.createdAt ?? a.createdAt).getTime(), ); } diff --git a/packages/react/src/hooks/useStore.ts b/packages/react/src/hooks/useStore.ts index 394836a440..b3081a73e7 100644 --- a/packages/react/src/hooks/useStore.ts +++ b/packages/react/src/hooks/useStore.ts @@ -80,13 +80,11 @@ export function shallow(objA: T, objB: T): boolean { return keysA.every( (key) => - Object.prototype.hasOwnProperty.call(objB, key) && + Object.hasOwn(objB, key) && Object.is(objA[key as keyof T], objB[key as keyof T]), ); } function getOwnKeys(obj: T): Array { - return (Object.keys(obj) as Array).concat( - Object.getOwnPropertySymbols(obj), - ); + return [...Object.keys(obj), ...Object.getOwnPropertySymbols(obj)]; } diff --git a/packages/xl-ai-server/src/routes/mock.ts b/packages/xl-ai-server/src/routes/mock.ts index 7487252db1..53bbfa1e45 100644 --- a/packages/xl-ai-server/src/routes/mock.ts +++ b/packages/xl-ai-server/src/routes/mock.ts @@ -35,8 +35,7 @@ const MANUAL_TOOL_CALLS = [ // Use `streamText` to stream text responses from the LLM regularRoute.post("/streamText", async (c) => { const { messages } = await c.req.json(); - const blockId = - messages[messages.length - 1].metadata.documentState.blocks[0].id; + const blockId = messages.at(-1).metadata.documentState.blocks[0].id; MANUAL_TOOL_CALLS[0].args.operations[0].referenceId = blockId; const stream = createUIMessageStream({ diff --git a/packages/xl-ai-server/src/routes/serverPersistence.ts b/packages/xl-ai-server/src/routes/serverPersistence.ts index a888f14828..2049182125 100644 --- a/packages/xl-ai-server/src/routes/serverPersistence.ts +++ b/packages/xl-ai-server/src/routes/serverPersistence.ts @@ -61,9 +61,8 @@ serverPersistenceRoute.post("/streamText", async (c) => { ); // get results from tool parts to update the last message - if (messages.length > 0 && toolParts.length > 0) { - const lastMessage = messages[messages.length - 1]; - + const lastMessage = messages.at(-1); + if (lastMessage && toolParts.length > 0) { messages[messages.length - 1] = { ...lastMessage, parts: lastMessage.parts.map((part) => { diff --git a/packages/xl-ai/package.json b/packages/xl-ai/package.json index 572ef01075..44e6891599 100644 --- a/packages/xl-ai/package.json +++ b/packages/xl-ai/package.json @@ -92,8 +92,6 @@ "@handlewithcare/prosemirror-suggest-changes": "^0.1.8", "@tiptap/core": "^3.31.3", "ai": "^6.0.5", - "lodash.isequal": "^4.5.0", - "lodash.merge": "^4.6.2", "prosemirror-changeset": "^2.4.2", "prosemirror-model": "^1.25.11", "prosemirror-state": "^1.4.4", @@ -114,8 +112,6 @@ "@types/diff": "^6.0.0", "@types/json-diff": "^1.0.3", "@types/json-schema": "^7.0.15", - "@types/lodash.isequal": "^4.5.8", - "@types/lodash.merge": "^4.6.9", "@types/node": "22.13.13", "@types/react": "^19.2.3", "@types/react-dom": "^19.2.3", diff --git a/packages/xl-ai/src/api/aiRequest/sendMessageWithAIRequest.test.ts b/packages/xl-ai/src/api/aiRequest/sendMessageWithAIRequest.test.ts new file mode 100644 index 0000000000..2bc01432d4 --- /dev/null +++ b/packages/xl-ai/src/api/aiRequest/sendMessageWithAIRequest.test.ts @@ -0,0 +1,110 @@ +import { Chat } from "@ai-sdk/react"; +import { BlockNoteEditor } from "@blocknote/core"; +import { UIMessage } from "ai"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { sendMessageWithAIRequest } from "./sendMessageWithAIRequest.js"; +import { AIRequest } from "./types.js"; + +vi.mock("../../streamTool/index.js", async (importOriginal) => ({ + ...(await importOriginal()), + setupToolCallStreaming: vi.fn(async () => ({ ok: true, value: undefined })), +})); + +function createRequest(): AIRequest { + return { + editor: BlockNoteEditor.create(), + streamTools: [], + onStart: vi.fn(), + documentState: { selection: false, blocks: [], isEmptyDocument: true }, + }; +} + +afterEach(() => vi.restoreAllMocks()); + +describe("sendMessageWithAIRequest", () => { + it("replaces stale document state and tool definitions while preserving custom options", async () => { + const chat = new Chat({}); + const send = vi.spyOn(chat, "sendMessage").mockResolvedValue(undefined); + const request = createRequest(); + const oldMetadata = { + custom: { nested: [1, 2] }, + documentState: { + selection: true, + blocks: [{ block: "deleted" }], + selectedBlocks: [{ id: "deleted", block: "deleted" }], + isEmptyDocument: false, + }, + }; + const message = { + text: "Update the document", + metadata: oldMetadata, + }; + const options = { + headers: { "X-Custom": "value" }, + metadata: { source: "custom", custom: { nested: [1, 2] } }, + body: { custom: { nested: [3, 4] }, toolDefinitions: { staleTool: {} } }, + }; + const originalOptions = structuredClone(options); + + await expect( + sendMessageWithAIRequest(chat, request, message, options), + ).resolves.toEqual({ ok: true, value: undefined }); + + expect(message.metadata).toEqual({ + custom: oldMetadata.custom, + documentState: request.documentState, + }); + expect(oldMetadata.documentState.selectedBlocks).toHaveLength(1); + expect(send).toHaveBeenCalledWith(message, { + headers: options.headers, + metadata: { ...options.metadata, source: "blocknote-ai" }, + body: { + custom: options.body.custom, + toolDefinitions: { applyDocumentOperations: expect.any(Object) }, + }, + }); + expect(options).toEqual(originalOptions); + }); + + it.each([undefined, null, "custom", 42])( + "accepts absent or non-object metadata (%s)", + async (metadata) => { + const chat = new Chat({}); + const send = vi.spyOn(chat, "sendMessage").mockResolvedValue(undefined); + const request = createRequest(); + const message = { text: "Update", metadata }; + + await sendMessageWithAIRequest(chat, request, message, { metadata }); + + expect(message.metadata).toEqual({ + documentState: request.documentState, + }); + expect(send).toHaveBeenCalledWith(message, { + metadata: { source: "blocknote-ai" }, + body: { + toolDefinitions: { applyDocumentOperations: expect.any(Object) }, + }, + }); + }, + ); + + it("updates the last message when no message or options are supplied", async () => { + const chat = new Chat({ + messages: [{ id: "last", role: "user", parts: [] }], + }); + const send = vi.spyOn(chat, "sendMessage").mockResolvedValue(undefined); + const request = createRequest(); + + await sendMessageWithAIRequest(chat, request); + + expect(chat.lastMessage?.metadata).toEqual({ + documentState: request.documentState, + }); + expect(send).toHaveBeenCalledWith(undefined, { + metadata: { source: "blocknote-ai" }, + body: { + toolDefinitions: { applyDocumentOperations: expect.any(Object) }, + }, + }); + }); +}); diff --git a/packages/xl-ai/src/api/aiRequest/sendMessageWithAIRequest.ts b/packages/xl-ai/src/api/aiRequest/sendMessageWithAIRequest.ts index 3250329df7..2c6ebbb5ab 100644 --- a/packages/xl-ai/src/api/aiRequest/sendMessageWithAIRequest.ts +++ b/packages/xl-ai/src/api/aiRequest/sendMessageWithAIRequest.ts @@ -1,6 +1,5 @@ import { Chat } from "@ai-sdk/react"; import { UIMessage } from "ai"; -import merge from "lodash.merge"; import { setupToolCallStreaming, streamToolsToToolSet, @@ -38,9 +37,14 @@ export async function sendMessageWithAIRequest( throw new Error("No message to send"); } - sendingMessage.metadata = merge(sendingMessage.metadata, { + // Replace the document snapshot as a whole: merging arrays by index can + // retain deleted blocks or a selection from the previous request. + sendingMessage.metadata = { + ...(typeof sendingMessage.metadata === "object" + ? sendingMessage.metadata + : undefined), documentState: aiRequest.documentState, - }); + }; const toolCallProcessing = setupToolCallStreaming( aiRequest.streamTools, @@ -48,16 +52,19 @@ export async function sendMessageWithAIRequest( aiRequest.onStart, abortSignal, ); - options = merge(options, { + options = { + ...options, metadata: { + ...(typeof options?.metadata === "object" ? options.metadata : undefined), source: "blocknote-ai", }, body: { + ...options?.body, toolDefinitions: await toolSetToToolDefinitions( streamToolsToToolSet(aiRequest.streamTools), ), }, - }); + }; await chat.sendMessage(message, options); diff --git a/packages/xl-ai/src/api/formats/base-tools/createAddBlocksTool.ts b/packages/xl-ai/src/api/formats/base-tools/createAddBlocksTool.ts index e0d6de224f..eb7b6326e6 100644 --- a/packages/xl-ai/src/api/formats/base-tools/createAddBlocksTool.ts +++ b/packages/xl-ai/src/api/formats/base-tools/createAddBlocksTool.ts @@ -205,11 +205,11 @@ export function createAddBlocksTool(config: { return true; } + const lastBlock = jsonToolCall.blocks.at(-1); if ( chunk.isPossiblyPartial && - isEmptyParagraph( - jsonToolCall.blocks[jsonToolCall.blocks.length - 1], - ) + lastBlock && + isEmptyParagraph(lastBlock) ) { // for example, a parsing just "
    " would first result in an empty paragraph, // wait for more content before adding the block @@ -283,9 +283,12 @@ export function createAddBlocksTool(config: { } if (!chunk.isPossiblyPartial) { - if (operation.position === "after") { - referenceIdMap[operation.referenceId] = - addedBlockIds[addedBlockIds.length - 1]; + const lastAddedBlockId = addedBlockIds.at(-1); + if ( + operation.position === "after" && + lastAddedBlockId !== undefined + ) { + referenceIdMap[operation.referenceId] = lastAddedBlockId; } } diff --git a/packages/xl-ai/src/components/FormattingToolbar/AIToolbarButton.tsx b/packages/xl-ai/src/components/FormattingToolbar/AIToolbarButton.tsx index c2ce5b5745..6693f4b0e5 100644 --- a/packages/xl-ai/src/components/FormattingToolbar/AIToolbarButton.tsx +++ b/packages/xl-ai/src/components/FormattingToolbar/AIToolbarButton.tsx @@ -28,7 +28,11 @@ export const AIToolbarButton = () => { throw new Error("No selection"); } - const position = selection.blocks[selection.blocks.length - 1].id; + const lastBlock = selection.blocks.at(-1); + if (!lastBlock) { + throw new Error("No selected block"); + } + const position = lastBlock.id; ai.openAIMenuAtBlock(position); formattingToolbar.store.setState(false); diff --git a/packages/xl-ai/src/prosemirror/changeset.ts b/packages/xl-ai/src/prosemirror/changeset.ts index 7f40964016..3a299d1e4a 100644 --- a/packages/xl-ai/src/prosemirror/changeset.ts +++ b/packages/xl-ai/src/prosemirror/changeset.ts @@ -233,9 +233,8 @@ export function updateToReplaceSteps( // // at this point, the changeset would drop "How are you doing?" // but we should ignore this, as maybe this will still be in the LLMs yet-to-be-streamed response - if (dontReplaceContentAtEnd && changeset.changes.length > 0) { - const lastChange = changeset.changes[changeset.changes.length - 1]; - + const lastChange = changeset.changes.at(-1); + if (dontReplaceContentAtEnd && lastChange) { const lengthA = lastChange.toA - lastChange.fromA; const lengthB = lastChange.toB - lastChange.fromB; diff --git a/packages/xl-ai/src/streamTool/jsonSchema.test.ts b/packages/xl-ai/src/streamTool/jsonSchema.test.ts new file mode 100644 index 0000000000..0906afc4a2 --- /dev/null +++ b/packages/xl-ai/src/streamTool/jsonSchema.test.ts @@ -0,0 +1,77 @@ +import type { JSONSchema7Definition } from "json-schema"; +import { describe, expect, it } from "vite-plus/test"; + +import { createStreamToolsArraySchema } from "./jsonSchema.js"; +import type { StreamTool } from "./streamTool.js"; + +function tool( + name: string, + definitions: Record, +): StreamTool<{ type: string }> { + return { + name, + inputSchema: { type: "object", $defs: definitions }, + validate: () => ({ ok: true, value: { type: name } }), + executor: () => ({ execute: async () => false }), + }; +} + +describe("createStreamToolsArraySchema definitions", () => { + it("shares equal nested definitions regardless of object key order", () => { + const first: JSONSchema7Definition = { + type: "object", + properties: { + value: { type: "string", enum: ["one", "two"] }, + constructor: { type: "string" } satisfies JSONSchema7Definition, + enabled: true, + forbidden: false, + }, + required: ["value"], + }; + const second: JSONSchema7Definition = { + required: ["value"], + properties: { + forbidden: false, + constructor: { type: "string" } satisfies JSONSchema7Definition, + enabled: true, + value: { enum: ["one", "two"], type: "string" }, + }, + type: "object", + }; + expect( + createStreamToolsArraySchema([ + tool("add", { shared: first }), + tool("update", { shared: second }), + ]).$defs, + ).toEqual({ shared: second }); + }); + + it.each([ + { type: "number" }, + { type: "string", enum: ["two", "one"] }, + { type: "string", enum: ["one", "two", "three"] }, + false, + ] satisfies JSONSchema7Definition[])( + "rejects a different definition: %j", + (conflicting) => { + expect(() => + createStreamToolsArraySchema([ + tool("add", { shared: { type: "string", enum: ["one", "two"] } }), + tool("update", { shared: conflicting }), + ]), + ).toThrow("Duplicate, but different definition for shared"); + }, + ); + + it("retains independent definitions and omits empty definitions", () => { + expect( + createStreamToolsArraySchema([ + tool("add", { first: { type: "string" } }), + tool("update", { second: { type: "number" } }), + ]).$defs, + ).toEqual({ first: { type: "string" }, second: { type: "number" } }); + expect( + createStreamToolsArraySchema([tool("add", {})]).$defs, + ).toBeUndefined(); + }); +}); diff --git a/packages/xl-ai/src/streamTool/jsonSchema.ts b/packages/xl-ai/src/streamTool/jsonSchema.ts index 0637a8c976..5642b614ba 100644 --- a/packages/xl-ai/src/streamTool/jsonSchema.ts +++ b/packages/xl-ai/src/streamTool/jsonSchema.ts @@ -1,8 +1,39 @@ import { jsonSchema, ToolSet } from "ai"; import type { JSONSchema7, JSONSchema7Definition } from "json-schema"; -import isEqual from "lodash.isequal"; import { StreamTool } from "./streamTool.js"; +// Schemas contain JSON values: compare object keys without depending on their +// order or treating a schema property named "constructor" as a JS prototype. +function isSchemaObject(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function equalSchemaValue(left: unknown, right: unknown): boolean { + if (left === right) { + return true; + } + if (Array.isArray(left)) { + return ( + Array.isArray(right) && + left.length === right.length && + left.every((value: unknown, index) => + equalSchemaValue(value, right[index]), + ) + ); + } + if (Array.isArray(right) || !isSchemaObject(left) || !isSchemaObject(right)) { + return false; + } + const keys = Object.keys(left); + return ( + keys.length === Object.keys(right).length && + keys.every( + (key) => + Object.hasOwn(right, key) && equalSchemaValue(left[key], right[key]), + ) + ); +} + function streamToolToJSONSchema(tool: StreamTool): { schema: JSONSchema7; $defs?: Record; @@ -55,7 +86,7 @@ export function createStreamToolsArraySchema( const $defs: Record = {}; for (const schema of schemas) { for (const key in schema.$defs) { - if ($defs[key] && !isEqual($defs[key], schema.$defs[key])) { + if ($defs[key] && !equalSchemaValue($defs[key], schema.$defs[key])) { throw new Error(`Duplicate, but different definition for ${key}`); } $defs[key] = schema.$defs[key]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d6e5dededa..1c50b9ed84 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5989,12 +5989,6 @@ importers: ai: specifier: ^6.0.5 version: 6.0.5(zod@4.6.7) - lodash.isequal: - specifier: ^4.5.0 - version: 4.5.0 - lodash.merge: - specifier: ^4.6.2 - version: 4.6.2 prosemirror-changeset: specifier: ^2.4.2 version: 2.4.2 @@ -6053,12 +6047,6 @@ importers: '@types/json-schema': specifier: ^7.0.15 version: 7.0.15 - '@types/lodash.isequal': - specifier: ^4.5.8 - version: 4.5.8 - '@types/lodash.merge': - specifier: ^4.6.9 - version: 4.6.9 '@types/node': specifier: 25.9.5 version: 25.9.5 @@ -11248,12 +11236,6 @@ packages: '@types/lodash.groupby@4.6.9': resolution: {integrity: sha512-z2xtCX2ko7GrqORnnYea4+ksT7jZNAvaOcLd6mP9M7J09RHvJs06W8BGdQQAX8ARef09VQLdeRilSOcfHlDQJQ==} - '@types/lodash.isequal@4.5.8': - resolution: {integrity: sha512-uput6pg4E/tj2LGxCZo9+y27JNyB2OZuuI/T5F+ylVDYuqICLG2/ktjxx0v6GvVntAf8TvEzeQLcV0ffRirXuA==} - - '@types/lodash.merge@4.6.9': - resolution: {integrity: sha512-23sHDPmzd59kUgWyKGiOMO2Qb9YtqRO/x4IhkgNUiPQ1+5MUVqi6bCZeq9nBJ17msjIMbEIO5u+XW4Kz6aGUhQ==} - '@types/lodash@4.17.24': resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==} @@ -14363,10 +14345,6 @@ packages: lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} - lodash.isequal@4.5.0: - resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} - deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. - lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} @@ -21514,14 +21492,6 @@ snapshots: dependencies: '@types/lodash': 4.17.24 - '@types/lodash.isequal@4.5.8': - dependencies: - '@types/lodash': 4.17.24 - - '@types/lodash.merge@4.6.9': - dependencies: - '@types/lodash': 4.17.24 - '@types/lodash@4.17.24': {} '@types/mdast@4.0.4': @@ -24770,8 +24740,6 @@ snapshots: lodash.debounce@4.0.8: {} - lodash.isequal@4.5.0: {} - lodash.merge@4.6.2: {} lodash@4.18.1: {} From 4a4a16577b1b0fb390fa41b8af025dd41b9d70ab Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Mon, 26 Oct 2026 11:01:30 +0100 Subject: [PATCH 2/2] refactor: minimize cleanup and cover special schema keys --- .../src/blocks/ListItem/NumberedListItem/IndexingPlugin.ts | 2 +- packages/xl-ai/src/streamTool/jsonSchema.test.ts | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.ts b/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.ts index fa110bfc16..0222fff46a 100644 --- a/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.ts +++ b/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.ts @@ -75,7 +75,7 @@ function calculateListItemIndex( let isFirst: boolean; // Determine starting index from the block just before the chain - const lastInChain = chain.at(-1) ?? { node, pos }; + const lastInChain = chain[chain.length - 1]; const lastInfo = getBlockInfoFromNode(lastInChain.node, lastInChain.pos); if (!lastInfo.hasContent) { throw new Error("impossible"); diff --git a/packages/xl-ai/src/streamTool/jsonSchema.test.ts b/packages/xl-ai/src/streamTool/jsonSchema.test.ts index 0906afc4a2..3e70183b78 100644 --- a/packages/xl-ai/src/streamTool/jsonSchema.test.ts +++ b/packages/xl-ai/src/streamTool/jsonSchema.test.ts @@ -23,6 +23,8 @@ describe("createStreamToolsArraySchema definitions", () => { properties: { value: { type: "string", enum: ["one", "two"] }, constructor: { type: "string" } satisfies JSONSchema7Definition, + valueOf: { type: "number" } satisfies JSONSchema7Definition, + toString: { type: "boolean" } satisfies JSONSchema7Definition, enabled: true, forbidden: false, }, @@ -33,6 +35,8 @@ describe("createStreamToolsArraySchema definitions", () => { properties: { forbidden: false, constructor: { type: "string" } satisfies JSONSchema7Definition, + toString: { type: "boolean" } satisfies JSONSchema7Definition, + valueOf: { type: "number" } satisfies JSONSchema7Definition, enabled: true, value: { enum: ["one", "two"], type: "string" }, },