Skip to content

Commit 7b23b74

Browse files
authored
feat(ai): Abort requests (#2213)
* initial checkin * fix * misc fixes * fixes * fix test * docs * fix test * fix lockfile * fix error handling * fix lint * fix example * wip * extract appendablestream * fix * small fixes * fix lock * fix build * small improvements * update docs
1 parent 75339cf commit 7b23b74

File tree

16 files changed

+609
-97
lines changed

16 files changed

+609
-97
lines changed

docs/content/docs/features/ai/backend-integration.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ const model = createOpenAICompatible({
7070
})('model-id');
7171

7272
// ...
73-
createAIExtension({
73+
AIExtension({
7474
transport: new ClientSideTransport({
7575
model,
7676
}),

docs/content/docs/features/ai/getting-started.mdx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import { createBlockNoteEditor } from "@blocknote/core";
2525
import { BlockNoteAIExtension } from "@blocknote/xl-ai";
2626
import { en } from "@blocknote/core/locales";
2727
import { en as aiEn } from "@blocknote/xl-ai/locales";
28-
import { createAIExtension } from "@blocknote/xl-ai";
28+
import { AIExtension } from "@blocknote/xl-ai";
2929
import "@blocknote/xl-ai/style.css"; // add the AI stylesheet
3030

3131
const editor = createBlockNoteEditor({
@@ -34,7 +34,7 @@ const editor = createBlockNoteEditor({
3434
ai: aiEn, // add default translations for the AI extension
3535
},
3636
extensions: [
37-
createAIExtension({
37+
AIExtension({
3838
transport: new DefaultChatTransport({
3939
api: `/api/chat`,
4040
}),
@@ -44,7 +44,7 @@ const editor = createBlockNoteEditor({
4444
});
4545
```
4646

47-
See the [API Reference](/docs/features/ai/reference) for more information on the `createAIExtension` method.
47+
See the [API Reference](/docs/features/ai/reference) for more information on the `AIExtension` options.
4848

4949
## Adding AI UI elements
5050

docs/content/docs/features/ai/reference.mdx

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,17 @@ description: Reference documentation for the BlockNote AI extension
44
imageTitle: BlockNote AI
55
---
66

7-
## `createAIExtension`
7+
## `AIExtension`
88

9-
Use `createAIExtension` to create a new AI Extension that can be registered to an editor when calling `useCreateBlockNote`.
9+
Use `AIExtension` to create a new AI Extension that can be registered to an editor when calling `useCreateBlockNote`.
1010

1111
```typescript
1212
// Usage:
13-
const aiExtension = createAIExtension(opts: AIExtensionOptions);
14-
15-
// Definitions:
16-
function createAIExtension(options: AIExtensionOptions): (editor: BlockNoteEditor) => AIExtension;
13+
useCreateBlockNote({
14+
// Register the AI extension
15+
extensions: [AIExtension(options)],
16+
// other editor options
17+
});
1718

1819
type AIExtensionOptions = AIRequestHelpers & {
1920
/**
@@ -42,7 +43,7 @@ type AIRequestHelpers = {
4243
* Customize which stream tools are available to the LLM.
4344
*/
4445
streamToolsProvider?: StreamToolsProvider<any, any>;
45-
// Provide `streamToolsProvider` in createAIExtension(options) or override per call via InvokeAIOptions.
46+
// Provide `streamToolsProvider` in AIExtension(options) or override per call via InvokeAIOptions.
4647
// If omitted, defaults to using `aiDocumentFormats.html.getStreamToolsProvider()`.
4748

4849
/**
@@ -59,12 +60,12 @@ type AIRequestHelpers = {
5960
};
6061
```
6162

62-
## `AIExtension`
63+
## `AIExtension` extension instance
6364

64-
The `AIExtension` class is the main class for the AI extension. It exposes state and methods to interact with BlockNote's AI features.
65+
The `AIExtension` extension instance returned by `editor.getExtension(AIExtension)` exposes state and methods to interact with BlockNote's AI features.
6566

6667
```typescript
67-
class AIExtension {
68+
type AIExtensionInstance = {
6869
/**
6970
* Execute a call to an LLM and apply the result to the editor
7071
*/
@@ -113,6 +114,8 @@ class AIExtension {
113114
rejectChanges(): void;
114115
/** Retry the previous LLM call (only valid when status is "error") */
115116
retry(): Promise<void>;
117+
/** Abort the current LLM request */
118+
abort(reason?: any): Promise<void>;
116119
/** Advanced: manually update the status shown by the AI menu */
117120
setAIResponseStatus(
118121
status:
@@ -122,12 +125,12 @@ class AIExtension {
122125
| "user-reviewing"
123126
| { status: "error"; error: any },
124127
): void;
125-
}
128+
};
126129
```
127130

128131
### `InvokeAI`
129132

130-
Requests to an LLM are made by calling `invokeAI` on the `AIExtension` object. This takes an `InvokeAIOptions` object as an argument.
133+
Requests to an LLM are made by calling `invokeAI` on the `AIExtension` instance. This takes an `InvokeAIOptions` object as an argument.
131134

132135
```typescript
133136
type InvokeAIOptions = {

examples/09-ai/01-minimal/src/App.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { en as aiEn } from "@blocknote/xl-ai/locales";
2222
import "@blocknote/xl-ai/style.css";
2323

2424
import { DefaultChatTransport } from "ai";
25+
import { useEffect } from "react";
2526
import { getEnv } from "./getEnv";
2627

2728
const BASE_URL =

packages/xl-ai/src/AIExtension.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ export const AIExtension = createExtension(
7070
| {
7171
previousRequestOptions: InvokeAIOptions;
7272
chat: Chat<UIMessage>;
73+
abortController: AbortController;
7374
}
7475
| undefined;
7576
let autoScroll = false;
@@ -233,6 +234,36 @@ export const AIExtension = createExtension(
233234
this.closeAIMenu();
234235
},
235236

237+
/**
238+
* Abort the current LLM request.
239+
*
240+
* This will stop the ongoing request and revert any changes made by the AI.
241+
* Only valid when there is an active AI request in progress.
242+
*/
243+
async abort(reason?: any) {
244+
const { aiMenuState } = store.state;
245+
if (aiMenuState === "closed" || !chatSession) {
246+
return;
247+
}
248+
249+
// Only abort if the request is in progress (thinking or ai-writing)
250+
if (
251+
aiMenuState.status !== "thinking" &&
252+
aiMenuState.status !== "ai-writing"
253+
) {
254+
return;
255+
}
256+
257+
const chat = chatSession.chat;
258+
const abortController = chatSession.abortController;
259+
260+
// Abort the tool call operations
261+
abortController.abort(reason);
262+
263+
// Stop the chat request
264+
await chat.stop();
265+
},
266+
236267
/**
237268
* Retry the previous LLM call.
238269
*
@@ -341,6 +372,9 @@ export const AIExtension = createExtension(
341372
editor.getExtension(ForkYDocExtension)?.fork();
342373

343374
try {
375+
// Create a new AbortController for this request
376+
const abortController = new AbortController();
377+
344378
if (!chatSession) {
345379
// note: in the current implementation opts.transport is only used when creating a new chat
346380
// (so changing transport for a subsequent call in the same chat-session is not supported)
@@ -353,9 +387,11 @@ export const AIExtension = createExtension(
353387
sendAutomaticallyWhen: () => false,
354388
transport: opts.transport || this.options.state.transport,
355389
}),
390+
abortController,
356391
};
357392
} else {
358393
chatSession.previousRequestOptions = opts;
394+
chatSession.abortController = abortController;
359395
}
360396
const chat = chatSession.chat;
361397

@@ -439,9 +475,13 @@ export const AIExtension = createExtension(
439475
],
440476
},
441477
opts.chatRequestOptions || this.options.state.chatRequestOptions,
478+
chatSession.abortController.signal,
442479
);
443480

444-
if (result.ok && chat.status !== "error") {
481+
if (
482+
(result.ok && chat.status !== "error") ||
483+
abortController.signal.aborted
484+
) {
445485
this.setAIResponseStatus("user-reviewing");
446486
} else {
447487
// eslint-disable-next-line no-console

packages/xl-ai/src/api/aiRequest/sendMessageWithAIRequest.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { AIRequest } from "./types.js";
1919
* @param aiRequest - the AI request (create using {@link buildAIRequest})
2020
* @param message - the message to send to the LLM (optional, defaults to the last message)
2121
* @param options - the `ChatRequestOptions` to pass to the `chat.sendMessage` method (custom metadata, body, etc)
22+
* @param abortSignal - Optional AbortSignal to cancel ongoing tool call operations
2223
*
2324
* @returns the result of the tool call processing. Consumer should check both `chat.status` and `result.ok`;
2425
* - `chat.status` indicates if the LLM request succeeeded
@@ -29,6 +30,7 @@ export async function sendMessageWithAIRequest(
2930
aiRequest: AIRequest,
3031
message?: Parameters<Chat<UIMessage>["sendMessage"]>[0],
3132
options?: Parameters<Chat<UIMessage>["sendMessage"]>[1],
33+
abortSignal?: AbortSignal,
3234
) {
3335
const sendingMessage = message ?? chat.lastMessage;
3436

@@ -44,6 +46,7 @@ export async function sendMessageWithAIRequest(
4446
aiRequest.streamTools,
4547
chat,
4648
aiRequest.onStart,
49+
abortSignal,
4750
);
4851
options = merge(options, {
4952
metadata: {

packages/xl-ai/src/api/formats/base-tools/createAddBlocksTool.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
import { updateToReplaceSteps } from "../../../prosemirror/changeset.js";
1010
import { RebaseTool } from "../../../prosemirror/rebaseTool.js";
1111
import { Result, streamTool } from "../../../streamTool/streamTool.js";
12+
import { AbortError } from "../../../util/AbortError.js";
1213
import { isEmptyParagraph } from "../../../util/emptyBlock.js";
1314
import { validateBlockArray } from "./util/validateBlockArray.js";
1415

@@ -182,7 +183,7 @@ export function createAddBlocksTool<T>(config: {
182183
const referenceIdMap: Record<string, string> = {}; // TODO: unit test
183184

184185
return {
185-
execute: async (chunk) => {
186+
execute: async (chunk, abortSignal?: AbortSignal) => {
186187
if (!chunk.isUpdateToPreviousOperation) {
187188
// we have a new operation, reset the added block ids
188189
addedBlockIds = [];
@@ -268,6 +269,9 @@ export function createAddBlocksTool<T>(config: {
268269
// }
269270

270271
for (const step of agentSteps) {
272+
if (abortSignal?.aborted) {
273+
throw new AbortError("Operation was aborted");
274+
}
271275
if (options.withDelays) {
272276
await delayAgentStep(step);
273277
}

packages/xl-ai/src/api/formats/base-tools/createUpdateBlockTool.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
import { updateToReplaceSteps } from "../../../prosemirror/changeset.js";
1010
import { RebaseTool } from "../../../prosemirror/rebaseTool.js";
1111
import { Result, streamTool } from "../../../streamTool/streamTool.js";
12+
import { AbortError } from "../../../util/AbortError.js";
1213

1314
export type UpdateBlockToolCall<T> = {
1415
type: "update";
@@ -177,7 +178,7 @@ export function createUpdateBlockTool<T>(config: {
177178
}
178179
: undefined;
179180
return {
180-
execute: async (chunk) => {
181+
execute: async (chunk, abortSignal?: AbortSignal) => {
181182
if (chunk.operation.type !== "update") {
182183
// pass through non-update operations
183184
return false;
@@ -244,6 +245,9 @@ export function createUpdateBlockTool<T>(config: {
244245
const agentSteps = getStepsAsAgent(tr);
245246

246247
for (const step of agentSteps) {
248+
if (abortSignal?.aborted) {
249+
throw new AbortError("Operation was aborted");
250+
}
247251
if (options.withDelays) {
248252
await delayAgentStep(step);
249253
}
Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
export class ChunkExecutionError extends Error {
2+
public readonly aborted: boolean;
3+
24
constructor(
35
message: string,
46
public readonly chunk: any,
5-
options?: { cause?: unknown },
7+
options?: { cause?: unknown; aborted?: boolean },
68
) {
79
super(message, options);
810
this.name = "ChunkExecutionError";
11+
this.aborted = options?.aborted ?? false;
912
}
1013
}

packages/xl-ai/src/streamTool/StreamToolExecutor.ts

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,12 @@ export class StreamToolExecutor<T extends StreamTool<any>[]> {
5151

5252
/**
5353
* @param streamTools - The StreamTools to use to apply the StreamToolCalls
54+
* @param abortSignal - Optional AbortSignal to cancel ongoing operations
5455
*/
55-
constructor(private streamTools: T) {
56+
constructor(
57+
private streamTools: T,
58+
private abortSignal?: AbortSignal,
59+
) {
5660
this.stream = this.createStream();
5761
}
5862

@@ -115,27 +119,35 @@ export class StreamToolExecutor<T extends StreamTool<any>[]> {
115119
let handled = false;
116120
for (const executor of executors) {
117121
try {
118-
const result = await executor.execute(chunk);
122+
// Pass the signal to executor - it should handle abort internally
123+
const result = await executor.execute(chunk, this.abortSignal);
119124
if (result) {
120125
controller.enqueue({ status: "ok", chunk });
121126
handled = true;
122127
break;
123128
}
124129
} catch (error) {
125-
throw new ChunkExecutionError(
126-
`Tool execution failed: ${getErrorMessage(error)}`,
127-
chunk,
128-
{
129-
cause: error,
130-
},
130+
controller.error(
131+
new ChunkExecutionError(
132+
`Tool execution failed: ${getErrorMessage(error)}`,
133+
chunk,
134+
{
135+
cause: error,
136+
aborted: this.abortSignal?.aborted ?? false,
137+
},
138+
),
131139
);
140+
return;
132141
}
133142
}
134143
if (!handled) {
135144
const operationType = (chunk.operation as any)?.type || "unknown";
136-
throw new Error(
137-
`No tool could handle operation of type: ${operationType}`,
145+
controller.error(
146+
new Error(
147+
`No tool could handle operation of type: ${operationType}`,
148+
),
138149
);
150+
return;
139151
}
140152
},
141153
});

0 commit comments

Comments
 (0)