Skip to content

Commit decb3d2

Browse files
authored
fix(ai): operations on blocks containing comments (#2953)
1 parent 824abce commit decb3d2

4 files changed

Lines changed: 233 additions & 8 deletions

File tree

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import {
5+
relativePositionToAbsolutePosition,
6+
ySyncPluginKey,
7+
} from "y-prosemirror";
8+
import { afterEach, describe, expect, it, vi } from "vite-plus/test";
9+
import * as Y from "yjs";
10+
import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
11+
import { DefaultThreadStoreAuth } from "../../comments/threadstore/DefaultThreadStoreAuth.js";
12+
import { withCollaboration } from "../extensions/index.js";
13+
import { RESTYjsThreadStore } from "./RESTYjsThreadStore.js";
14+
15+
function createCollabEditor() {
16+
const doc = new Y.Doc();
17+
const fragment = doc.getXmlFragment("doc");
18+
const editor = BlockNoteEditor.create(
19+
withCollaboration({
20+
collaboration: {
21+
fragment,
22+
user: { name: "Test User", color: "#FF0000" },
23+
provider: undefined,
24+
},
25+
trailingBlock: false,
26+
}),
27+
);
28+
editor.mount(document.createElement("div"));
29+
editor.replaceBlocks(editor.document, [
30+
{ type: "paragraph", content: "Hello World" },
31+
]);
32+
return { editor, doc, fragment };
33+
}
34+
35+
afterEach(() => {
36+
vi.restoreAllMocks();
37+
});
38+
39+
describe("RESTYjsThreadStore", () => {
40+
it("sends resolvable yjs positions along with the thread", async () => {
41+
const { editor, doc, fragment } = createCollabEditor();
42+
43+
const requests: any[] = [];
44+
vi.spyOn(globalThis, "fetch").mockImplementation((async (
45+
_url: any,
46+
init: any,
47+
) => {
48+
requests.push(JSON.parse(init.body));
49+
return new Response("{}", {
50+
status: 200,
51+
headers: { "Content-Type": "application/json" },
52+
});
53+
}) as any);
54+
55+
const store = new RESTYjsThreadStore(
56+
"https://example.com/threads",
57+
{},
58+
doc.getMap("threads"),
59+
new DefaultThreadStoreAuth("user-1", "editor"),
60+
);
61+
62+
await store.addThreadToDocument({
63+
threadId: "thread-1",
64+
selection: { anchor: 3, head: 8 },
65+
editor,
66+
});
67+
68+
expect(requests).toHaveLength(1);
69+
const { yjs } = requests[0].selection;
70+
expect(yjs).toBeDefined();
71+
72+
// the relative positions must resolve back to the positions we passed in
73+
const state = ySyncPluginKey.getState(editor.prosemirrorState) as any;
74+
const resolve = (relPos: any) =>
75+
relativePositionToAbsolutePosition(
76+
fragment.doc!,
77+
state.binding.type,
78+
Y.createRelativePositionFromJSON(relPos),
79+
state.binding.mapping,
80+
);
81+
82+
expect(resolve(yjs.anchor)).toBe(3);
83+
expect(resolve(yjs.head)).toBe(8);
84+
});
85+
});

packages/core/src/yjs/comments/RESTYjsThreadStore.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,13 @@ export class RESTYjsThreadStore extends YjsThreadStoreBase {
5959
}) => {
6060
const { threadId, selection } = options;
6161

62-
const binding = ySyncPluginKey.getState(options.editor.prosemirrorState);
62+
// Note: the positions have to be resolved against the *binding's* type and
63+
// mapping. The plugin state has a `type` of its own, but no `mapping`, and
64+
// its `type` can go stale (e.g. while the doc is forked, see
65+
// `ForkYDocExtension`).
66+
const binding = ySyncPluginKey.getState(
67+
options.editor.prosemirrorState,
68+
)?.binding;
6369
const yjsSelection = binding
6470
? {
6571
head: absolutePositionToRelativePosition(
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
/**
2+
* Regression test for https://github.com/TypeCellOS/BlockNote/issues/2947
3+
*
4+
* Comment marks are `blocknoteIgnore` marks: they're not part of the BlockNote
5+
* document model and don't survive an HTML round-trip. The HTML rebase tool
6+
* used to treat that as a fatal "html diff", which made every AI operation on a
7+
* block containing a comment fail.
8+
*
9+
* Runs fully offline (no LLM call): tool calls are fed straight into the
10+
* executor.
11+
*/
12+
import { BlockNoteEditor, createExtension } from "@blocknote/core";
13+
import { CommentMark } from "@blocknote/core/comments";
14+
import { describe, expect, it } from "vite-plus/test";
15+
16+
import { AIExtension } from "../../../AIExtension.js";
17+
import { StreamToolExecutor } from "../../../streamTool/StreamToolExecutor.js";
18+
import { StreamTool } from "../../../streamTool/streamTool.js";
19+
import { tools } from "./tools/index.js";
20+
import { createHTMLRebaseTool } from "./tools/rebaseTool.js";
21+
22+
/**
23+
* Registers just the comment mark. The full `CommentsExtension` needs a thread
24+
* store and user resolver, neither of which affects the document model.
25+
*/
26+
const CommentMarkExtension = createExtension(() => ({
27+
key: "commentMarkOnly",
28+
tiptapExtensions: [CommentMark],
29+
}));
30+
31+
function createEditorWithComment() {
32+
const editor = BlockNoteEditor.create({
33+
initialContent: [
34+
{ id: "ref1", type: "paragraph", content: "Hello, world!" },
35+
{ id: "ref2", type: "paragraph", content: "How are you?" },
36+
],
37+
trailingBlock: false,
38+
extensions: [AIExtension(), CommentMarkExtension()],
39+
});
40+
editor.mount(document.createElement("div"));
41+
42+
// comment on "Hello" in the first block
43+
editor.transact((tr) =>
44+
tr.addMark(
45+
3,
46+
8,
47+
editor.pmSchema.marks.comment.create({
48+
threadId: "thread-1",
49+
orphan: false,
50+
}),
51+
),
52+
);
53+
54+
return editor;
55+
}
56+
57+
function commentedRanges(editor: BlockNoteEditor<any, any, any>) {
58+
const ranges: { text: string; threadId: string }[] = [];
59+
editor.prosemirrorState.doc.descendants((node) => {
60+
const mark = node.marks.find((m) => m.type.name === "comment");
61+
if (mark) {
62+
ranges.push({ text: node.text!, threadId: mark.attrs.threadId });
63+
}
64+
});
65+
return ranges;
66+
}
67+
68+
async function runUpdate(
69+
editor: BlockNoteEditor<any, any, any>,
70+
id: string,
71+
html: string,
72+
) {
73+
const streamTools = [
74+
tools.update(editor, { idsSuffixed: false, withDelays: false }),
75+
] as StreamTool<any>[];
76+
77+
await new StreamToolExecutor(streamTools).execute(
78+
(async function* () {
79+
yield {
80+
operation: { type: "update" as const, id, block: html },
81+
isUpdateToPreviousOperation: false,
82+
isPossiblyPartial: false,
83+
metadata: undefined,
84+
};
85+
})(),
86+
);
87+
}
88+
89+
describe("blocks containing comments", () => {
90+
it("can build a rebase tool for a commented block", () => {
91+
const editor = createEditorWithComment();
92+
93+
expect(() => createHTMLRebaseTool("ref1", editor)).not.toThrow();
94+
});
95+
96+
it("updates a block that contains a comment", async () => {
97+
const editor = createEditorWithComment();
98+
99+
await runUpdate(editor, "ref1", "<p>Hello, universe!</p>");
100+
101+
editor.getExtension(AIExtension)?.acceptChanges();
102+
expect((editor.document[0] as any).content[0].text).toBe(
103+
"Hello, universe!",
104+
);
105+
// the untouched part of the comment is still anchored
106+
expect(commentedRanges(editor)).toEqual([
107+
{ text: "Hello", threadId: "thread-1" },
108+
]);
109+
});
110+
111+
it("updates a sibling block while another block has a comment", async () => {
112+
const editor = createEditorWithComment();
113+
114+
await runUpdate(editor, "ref2", "<p>How do you do?</p>");
115+
116+
editor.getExtension(AIExtension)?.acceptChanges();
117+
expect((editor.document[1] as any).content[0].text).toBe("How do you do?");
118+
expect(commentedRanges(editor)).toEqual([
119+
{ text: "Hello", threadId: "thread-1" },
120+
]);
121+
});
122+
});

packages/xl-ai/src/api/formats/html-blocks/tools/rebaseTool.ts

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { BlockNoteEditor, getBlock } from "@blocknote/core";
2+
import { Mapping } from "prosemirror-transform";
23
import { updateToReplaceSteps } from "../../../../prosemirror/changeset.js";
34
import {
45
getApplySuggestionsTr,
@@ -47,13 +48,24 @@ export function createHTMLRebaseTool(
4748
tr.doc,
4849
);
4950

50-
if (steps.length) {
51-
throw new Error("html diff", {
52-
cause: {
53-
html,
54-
htmlBlock,
55-
},
56-
});
51+
// The HTML round-trip isn't always lossless: marks that aren't part of the
52+
// BlockNote document model (`blocknoteIgnore`, e.g. comments) don't survive
53+
// it. Apply the difference to the projection, so operations are applied to
54+
// the document as the HTML format sees it, and are then rebased onto the
55+
// actual document (same as `createMDRebaseTool` does for markdown).
56+
const stepMapping = new Mapping();
57+
for (const step of steps) {
58+
const mapped = step.map(stepMapping);
59+
if (!mapped) {
60+
throw new Error("html diff", {
61+
cause: {
62+
html,
63+
htmlBlock,
64+
},
65+
});
66+
}
67+
tr.step(mapped);
68+
stepMapping.appendMap(mapped.getMap());
5769
}
5870

5971
return rebaseTool(editor, tr);

0 commit comments

Comments
 (0)