Skip to content

Commit 023893e

Browse files
authored
fix: handle undo/redo and collaboration with AI (#1697)
1 parent 0c2d682 commit 023893e

File tree

14 files changed

+617
-7
lines changed

14 files changed

+617
-7
lines changed
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"playground": true,
3+
"docs": false,
4+
"author": "nperez0111",
5+
"tags": ["AI", "llm"],
6+
"dependencies": {
7+
"@blocknote/xl-ai": "latest",
8+
"@mantine/core": "^7.10.1",
9+
"ai": "^4.3.15",
10+
"@ai-sdk/groq": "^1.2.9",
11+
"y-partykit": "^0.0.25",
12+
"yjs": "^13.6.15",
13+
"zustand": "^5.0.3"
14+
}
15+
}
Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
import { createGroq } from "@ai-sdk/groq";
2+
import { BlockNoteEditor, filterSuggestionItems } from "@blocknote/core";
3+
import "@blocknote/core/fonts/inter.css";
4+
import { en } from "@blocknote/core/locales";
5+
import { BlockNoteView } from "@blocknote/mantine";
6+
import "@blocknote/mantine/style.css";
7+
import {
8+
FormattingToolbar,
9+
FormattingToolbarController,
10+
SuggestionMenuController,
11+
getDefaultReactSlashMenuItems,
12+
getFormattingToolbarItems,
13+
useCreateBlockNote,
14+
} from "@blocknote/react";
15+
import {
16+
AIMenuController,
17+
AIToolbarButton,
18+
locales as aiLocales,
19+
createAIExtension,
20+
createBlockNoteAIClient,
21+
getAISlashMenuItems,
22+
} from "@blocknote/xl-ai";
23+
import "@blocknote/xl-ai/style.css";
24+
import { useEffect, useState } from "react";
25+
import YPartyKitProvider from "y-partykit/provider";
26+
import * as Y from "yjs";
27+
import { getEnv } from "./getEnv.js";
28+
29+
import "./styles.css";
30+
// eslint-disable-next-line import/no-extraneous-dependencies
31+
import { EditorView } from "prosemirror-view";
32+
33+
const params = new URLSearchParams(window.location.search);
34+
const ghostWritingRoom = params.get("room");
35+
const ghostWriterIndex = parseInt(params.get("index") || "1");
36+
const isGhostWriting = Boolean(ghostWritingRoom);
37+
const roomName = ghostWritingRoom || `ghost-writer-${Date.now()}`;
38+
// Sets up Yjs document and PartyKit Yjs provider.
39+
const doc = new Y.Doc();
40+
const provider = new YPartyKitProvider(
41+
"blocknote-dev.yousefed.partykit.dev",
42+
// Use a unique name as a "room" for your application.
43+
roomName,
44+
doc,
45+
);
46+
47+
/**
48+
* Y-prosemirror has an optimization, where it doesn't send awareness updates unless the editor is currently focused.
49+
* So, for the ghost writers, we override the hasFocus method to always return true.
50+
*/
51+
if (isGhostWriting) {
52+
EditorView.prototype.hasFocus = () => true;
53+
}
54+
55+
const ghostContent =
56+
"This demo shows a two-way sync of documents. It allows you to test collaboration features, and see how stable the editor is. ";
57+
58+
// Optional: proxy requests through the `@blocknote/xl-ai-server` proxy server
59+
// so that we don't have to expose our API keys to the client
60+
const client = createBlockNoteAIClient({
61+
apiKey: getEnv("BLOCKNOTE_AI_SERVER_API_KEY") || "PLACEHOLDER",
62+
baseURL:
63+
getEnv("BLOCKNOTE_AI_SERVER_BASE_URL") || "https://localhost:3000/ai",
64+
});
65+
66+
// Use an "open" model such as llama, in this case via groq.com
67+
const model = createGroq({
68+
// call via our proxy client
69+
...client.getProviderSettings("groq"),
70+
})("llama-3.3-70b-versatile");
71+
72+
/*
73+
ALTERNATIVES:
74+
75+
Call a model directly (without the proxy):
76+
77+
const model = createGroq({
78+
apiKey: "<YOUR_GROQ_API_KEY>",
79+
})("llama-3.3-70b-versatile");
80+
81+
Or, use a different provider like OpenAI:
82+
83+
const model = createOpenAI({
84+
...client.getProviderSettings("openai"),
85+
})("gpt-4", {});
86+
*/
87+
88+
export default function App() {
89+
const [numGhostWriters, setNumGhostWriters] = useState(1);
90+
const [isPaused, setIsPaused] = useState(false);
91+
const editor = useCreateBlockNote({
92+
collaboration: {
93+
// The Yjs Provider responsible for transporting updates:
94+
provider,
95+
// Where to store BlockNote data in the Y.Doc:
96+
fragment: doc.getXmlFragment("document-store"),
97+
// Information (name and color) for this user:
98+
user: {
99+
name: isGhostWriting
100+
? `Ghost Writer #${ghostWriterIndex}`
101+
: "My Username",
102+
color: isGhostWriting ? "#CCCCCC" : "#00ff00",
103+
},
104+
},
105+
dictionary: {
106+
...en,
107+
ai: aiLocales.en, // add default translations for the AI extension
108+
},
109+
// Register the AI extension
110+
extensions: [
111+
createAIExtension({
112+
model,
113+
}),
114+
],
115+
// We set some initial content for demo purposes
116+
initialContent: [
117+
{
118+
type: "heading",
119+
props: {
120+
level: 1,
121+
},
122+
content: "I love cats",
123+
},
124+
{
125+
type: "paragraph",
126+
content:
127+
"Cats are one of the most beloved and fascinating animals in the world. Known for their agility, independence, and charm, cats have been companions to humans for thousands of years. Domesticated cats, scientifically named Felis catus, come in various breeds, colors, and personalities, making them a popular choice for pet owners everywhere. Their mysterious behavior, sharp reflexes, and quiet affection have earned them a special place in countless households.",
128+
},
129+
{
130+
type: "paragraph",
131+
content:
132+
"Beyond their role as pets, cats have a rich history and cultural significance. In ancient Egypt, they were revered and even worshipped as symbols of protection and grace. Throughout history, they’ve appeared in folklore, art, and literature, often associated with curiosity, luck, and mystery. Despite superstitions surrounding black cats in some cultures, many societies around the world admire and cherish these sleek and graceful animals.",
133+
},
134+
{
135+
type: "paragraph",
136+
content:
137+
"Cats also offer emotional and physical benefits to their owners. Studies have shown that interacting with cats can reduce stress, lower blood pressure, and improve mental well-being. Their gentle purring, playful antics, and warm companionship provide comfort to people of all ages. Whether lounging in the sun, chasing a toy, or curling up on a lap, cats bring joy, peace, and a bit of magic to the lives of those who welcome them into their homes.",
138+
},
139+
],
140+
});
141+
142+
useEffect(() => {
143+
if (!isGhostWriting || isPaused) {
144+
return;
145+
}
146+
let index = 0;
147+
let timeout: NodeJS.Timeout;
148+
149+
const scheduleNextChar = () => {
150+
const jitter = Math.random() * 200; // Random delay between 0-200ms
151+
timeout = setTimeout(() => {
152+
const firstBlock = editor.document?.[0];
153+
if (firstBlock) {
154+
editor.insertInlineContent(ghostContent[index], {
155+
updateSelection: true,
156+
});
157+
index = (index + 1) % ghostContent.length;
158+
}
159+
scheduleNextChar();
160+
}, 50 + jitter);
161+
};
162+
163+
scheduleNextChar();
164+
165+
return () => clearTimeout(timeout);
166+
}, [editor, isPaused]);
167+
168+
// Renders the editor instance.
169+
return (
170+
<>
171+
{isGhostWriting ? (
172+
<button onClick={() => setIsPaused((a) => !a)}>
173+
{isPaused ? "Resume Ghost Writer" : "Pause Ghost Writer"}
174+
</button>
175+
) : (
176+
<>
177+
<button onClick={() => setNumGhostWriters((a) => a + 1)}>
178+
Add a Ghost Writer
179+
</button>
180+
<button onClick={() => setNumGhostWriters((a) => a - 1)}>
181+
Remove a Ghost Writer
182+
</button>
183+
<button
184+
onClick={() => {
185+
window.open(
186+
`${window.location.origin}${window.location.pathname}?room=${roomName}&index=-1`,
187+
"_blank",
188+
);
189+
}}
190+
>
191+
Ghost Writer in a new window
192+
</button>
193+
</>
194+
)}
195+
<BlockNoteView
196+
editor={editor}
197+
// We're disabling some default UI elements
198+
formattingToolbar={false}
199+
slashMenu={false}
200+
>
201+
{/* Add the AI Command menu to the editor */}
202+
<AIMenuController />
203+
204+
{/* We disabled the default formatting toolbar with `formattingToolbar=false`
205+
and replace it for one with an "AI button" (defined below).
206+
(See "Formatting Toolbar" in docs)
207+
*/}
208+
<FormattingToolbarWithAI />
209+
210+
{/* We disabled the default SlashMenu with `slashMenu=false`
211+
and replace it for one with an AI option (defined below).
212+
(See "Suggestion Menus" in docs)
213+
*/}
214+
<SuggestionMenuWithAI editor={editor} />
215+
</BlockNoteView>
216+
217+
{!isGhostWriting && (
218+
<div className="two-way-sync">
219+
{Array.from({ length: numGhostWriters }).map((_, index) => (
220+
<iframe
221+
src={`${window.location.origin}${
222+
window.location.pathname
223+
}?room=${roomName}&index=${index + 1}&hideMenu=true`}
224+
title="ghost writer"
225+
className="ghost-writer"
226+
/>
227+
))}
228+
</div>
229+
)}
230+
</>
231+
);
232+
}
233+
234+
// Formatting toolbar with the `AIToolbarButton` added
235+
function FormattingToolbarWithAI() {
236+
return (
237+
<FormattingToolbarController
238+
formattingToolbar={() => (
239+
<FormattingToolbar>
240+
{...getFormattingToolbarItems()}
241+
{/* Add the AI button */}
242+
<AIToolbarButton />
243+
</FormattingToolbar>
244+
)}
245+
/>
246+
);
247+
}
248+
249+
// Slash menu with the AI option added
250+
function SuggestionMenuWithAI(props: {
251+
editor: BlockNoteEditor<any, any, any>;
252+
}) {
253+
return (
254+
<SuggestionMenuController
255+
triggerCharacter="/"
256+
getItems={async (query) =>
257+
filterSuggestionItems(
258+
[
259+
...getDefaultReactSlashMenuItems(props.editor),
260+
// add the default AI slash menu items, or define your own
261+
...getAISlashMenuItems(props.editor),
262+
],
263+
query,
264+
)
265+
}
266+
/>
267+
);
268+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# AI + Ghost Writer
2+
3+
This example combines the AI extension with the ghost writer example to show how to use the AI extension in a collaborative environment.
4+
5+
**Relevant Docs:**
6+
7+
- [Editor Setup](/docs/editor-basics/setup)
8+
- [Changing the Formatting Toolbar](/docs/ui-components/formatting-toolbar#changing-the-formatting-toolbar)
9+
- [Changing Slash Menu Items](/docs/ui-components/suggestion-menus#changing-slash-menu-items)
10+
- [Getting Stared with BlockNote AI](/docs/ai/setup)
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// helper function to get env variables across next / vite
2+
// only needed so this example works in BlockNote demos and docs
3+
export function getEnv(key: string) {
4+
const env = (import.meta as any).env
5+
? {
6+
BLOCKNOTE_AI_SERVER_API_KEY: (import.meta as any).env
7+
.VITE_BLOCKNOTE_AI_SERVER_API_KEY,
8+
BLOCKNOTE_AI_SERVER_BASE_URL: (import.meta as any).env
9+
.VITE_BLOCKNOTE_AI_SERVER_BASE_URL,
10+
}
11+
: {
12+
BLOCKNOTE_AI_SERVER_API_KEY:
13+
process.env.NEXT_PUBLIC_BLOCKNOTE_AI_SERVER_API_KEY,
14+
BLOCKNOTE_AI_SERVER_BASE_URL:
15+
process.env.NEXT_PUBLIC_BLOCKNOTE_AI_SERVER_BASE_URL,
16+
};
17+
18+
const value = env[key as keyof typeof env];
19+
return value;
20+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<html lang="en">
2+
<head>
3+
<script>
4+
<!-- AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY -->
5+
</script>
6+
<meta charset="UTF-8" />
7+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
8+
<title>AI + Ghost Writer</title>
9+
</head>
10+
<body>
11+
<div id="root"></div>
12+
<script type="module" src="./main.tsx"></script>
13+
</body>
14+
</html>
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY
2+
import React from "react";
3+
import { createRoot } from "react-dom/client";
4+
import App from "./App.jsx";
5+
6+
const root = createRoot(document.getElementById("root")!);
7+
root.render(
8+
<React.StrictMode>
9+
<App />
10+
</React.StrictMode>
11+
);
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
{
2+
"name": "@blocknote/example-ai-with-collaboration",
3+
"description": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY",
4+
"private": true,
5+
"version": "0.12.4",
6+
"scripts": {
7+
"start": "vite",
8+
"dev": "vite",
9+
"build:prod": "tsc && vite build",
10+
"preview": "vite preview"
11+
},
12+
"dependencies": {
13+
"@blocknote/core": "latest",
14+
"@blocknote/react": "latest",
15+
"@blocknote/ariakit": "latest",
16+
"@blocknote/mantine": "latest",
17+
"@blocknote/shadcn": "latest",
18+
"react": "^18.3.1",
19+
"react-dom": "^18.3.1",
20+
"@blocknote/xl-ai": "latest",
21+
"@mantine/core": "^7.10.1",
22+
"ai": "^4.3.15",
23+
"@ai-sdk/groq": "^1.2.9",
24+
"y-partykit": "^0.0.25",
25+
"yjs": "^13.6.15",
26+
"zustand": "^5.0.3"
27+
},
28+
"devDependencies": {
29+
"@types/react": "^18.0.25",
30+
"@types/react-dom": "^18.0.9",
31+
"@vitejs/plugin-react": "^4.3.1",
32+
"vite": "^5.3.4"
33+
}
34+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
.two-way-sync {
2+
display: flex;
3+
flex-direction: row;
4+
height: 100%;
5+
margin-top: 10px;
6+
gap: 8px;
7+
}
8+
9+
.ghost-writer {
10+
flex: 1;
11+
border: 1px solid #ccc;
12+
}

0 commit comments

Comments
 (0)