@lofcz/platejs-ai 52.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +24 -0
- package/README.md +7 -0
- package/dist/getEditorPrompt-BnVrgUl3.js +103 -0
- package/dist/getEditorPrompt-BnVrgUl3.js.map +1 -0
- package/dist/index-B_bqJoKL.d.ts +85 -0
- package/dist/index-B_bqJoKL.d.ts.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +125 -0
- package/dist/index.js.map +1 -0
- package/dist/react/index.d.ts +409 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +1409 -0
- package/dist/react/index.js.map +1 -0
- package/package.json +66 -0
|
@@ -0,0 +1,1409 @@
|
|
|
1
|
+
import { n as BaseAIPlugin, r as withAIBatch, t as getEditorPrompt } from "../getEditorPrompt-BnVrgUl3.js";
|
|
2
|
+
import { ElementApi, KEYS, NodeApi, PathApi, RangeApi, TextApi, bindFirst, getPluginKey, getPluginType, isDefined, nanoid } from "platejs";
|
|
3
|
+
import { SkipSuggestionDeletes, acceptSuggestion, diffToSuggestions, getSuggestionKey, getTransientSuggestionKey, rejectSuggestion } from "@platejs/suggestion";
|
|
4
|
+
import { isSelecting } from "@platejs/selection";
|
|
5
|
+
import { MarkdownPlugin, deserializeInlineMd, deserializeMd, serializeInlineMd, serializeMd } from "@platejs/markdown";
|
|
6
|
+
import { createTPlatePlugin, getEditorPlugin, toPlatePlugin, useEditorPlugin, usePluginOption } from "platejs/react";
|
|
7
|
+
import { distance } from "fastest-levenshtein";
|
|
8
|
+
import { BlockSelectionPlugin, removeBlockSelectionNodes } from "@platejs/selection/react";
|
|
9
|
+
import { SuggestionPlugin } from "@platejs/suggestion/react";
|
|
10
|
+
import cloneDeep from "lodash/cloneDeep.js";
|
|
11
|
+
import { c } from "react-compiler-runtime";
|
|
12
|
+
import React, { useEffect, useMemo, useRef } from "react";
|
|
13
|
+
import debounce from "lodash/debounce.js";
|
|
14
|
+
|
|
15
|
+
//#region src/react/ai/AIPlugin.ts
|
|
16
|
+
const AIPlugin = toPlatePlugin(BaseAIPlugin);
|
|
17
|
+
|
|
18
|
+
//#endregion
|
|
19
|
+
//#region src/react/ai/utils/findTextRangeInBlock.tsx
|
|
20
|
+
function maxAllowedDistance(len) {
|
|
21
|
+
if (len <= 2) return 0;
|
|
22
|
+
if (len <= 5) return 1;
|
|
23
|
+
if (len <= 10) return 2;
|
|
24
|
+
if (len <= 20) return 3;
|
|
25
|
+
return 5;
|
|
26
|
+
}
|
|
27
|
+
/** Find text within a block, supporting fuzzy matching. */
|
|
28
|
+
function findTextRangeInBlock({ block, findText }) {
|
|
29
|
+
const [blockNode, blockPath] = block;
|
|
30
|
+
const textSegments = [];
|
|
31
|
+
let fullText = "";
|
|
32
|
+
for (const [textNode, textPath] of NodeApi.texts(blockNode)) {
|
|
33
|
+
const startOffset = fullText.length;
|
|
34
|
+
const absolutePath = [...blockPath, ...textPath];
|
|
35
|
+
textSegments.push({
|
|
36
|
+
offset: startOffset,
|
|
37
|
+
path: absolutePath,
|
|
38
|
+
text: textNode.text
|
|
39
|
+
});
|
|
40
|
+
fullText += textNode.text;
|
|
41
|
+
}
|
|
42
|
+
if (!fullText) return null;
|
|
43
|
+
let matchStart = fullText.indexOf(findText);
|
|
44
|
+
let matchEnd = matchStart >= 0 ? matchStart + findText.length : -1;
|
|
45
|
+
if (matchStart === -1) {
|
|
46
|
+
const maxDist = maxAllowedDistance(findText.length);
|
|
47
|
+
let bestMatch = {
|
|
48
|
+
distance: Number.POSITIVE_INFINITY,
|
|
49
|
+
end: -1,
|
|
50
|
+
start: -1
|
|
51
|
+
};
|
|
52
|
+
for (let i = 0; i <= fullText.length - findText.length; i++) for (let lenOffset = -maxDist; lenOffset <= maxDist; lenOffset++) {
|
|
53
|
+
const len = findText.length + lenOffset;
|
|
54
|
+
if (len <= 0 || i + len > fullText.length) continue;
|
|
55
|
+
const dist = distance(fullText.slice(i, i + len), findText);
|
|
56
|
+
if (dist <= maxDist && dist < bestMatch.distance) bestMatch = {
|
|
57
|
+
distance: dist,
|
|
58
|
+
end: i + len,
|
|
59
|
+
start: i
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
if (bestMatch.start !== -1) {
|
|
63
|
+
matchStart = bestMatch.start;
|
|
64
|
+
matchEnd = bestMatch.end;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (matchStart === -1) for (let prefixLen = findText.length - 1; prefixLen > 0; prefixLen--) {
|
|
68
|
+
const prefix = findText.slice(0, Math.max(0, prefixLen));
|
|
69
|
+
const idx = fullText.indexOf(prefix);
|
|
70
|
+
if (idx !== -1) {
|
|
71
|
+
matchStart = idx;
|
|
72
|
+
matchEnd = idx + prefixLen;
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (matchStart === -1) return null;
|
|
77
|
+
const findPoint = (charOffset, isEnd = false) => {
|
|
78
|
+
if (!isEnd) {
|
|
79
|
+
for (const segment of textSegments) if (charOffset === segment.offset) return {
|
|
80
|
+
offset: 0,
|
|
81
|
+
path: segment.path
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
for (const segment of textSegments) {
|
|
85
|
+
const segmentEnd = segment.offset + segment.text.length;
|
|
86
|
+
if (charOffset >= segment.offset && charOffset <= segmentEnd) return {
|
|
87
|
+
offset: charOffset - segment.offset,
|
|
88
|
+
path: segment.path
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
const lastSegment = textSegments.at(-1);
|
|
92
|
+
if (!lastSegment) return {
|
|
93
|
+
offset: 0,
|
|
94
|
+
path: blockPath
|
|
95
|
+
};
|
|
96
|
+
return {
|
|
97
|
+
offset: lastSegment.text.length,
|
|
98
|
+
path: lastSegment.path
|
|
99
|
+
};
|
|
100
|
+
};
|
|
101
|
+
return {
|
|
102
|
+
anchor: findPoint(matchStart, false),
|
|
103
|
+
focus: findPoint(matchEnd, true)
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
//#endregion
|
|
108
|
+
//#region src/react/ai/utils/aiCommentToRange.ts
|
|
109
|
+
const aiCommentToRange = (editor, aiComment) => {
|
|
110
|
+
const { blockId, content } = aiComment;
|
|
111
|
+
const contentNodes = deserializeMd(editor, content);
|
|
112
|
+
let firstBlock;
|
|
113
|
+
const ranges = [];
|
|
114
|
+
contentNodes.forEach((node, index) => {
|
|
115
|
+
let currentBlock;
|
|
116
|
+
if (index === 0) {
|
|
117
|
+
firstBlock = editor.api.node({
|
|
118
|
+
id: blockId,
|
|
119
|
+
at: []
|
|
120
|
+
});
|
|
121
|
+
currentBlock = firstBlock;
|
|
122
|
+
} else {
|
|
123
|
+
if (!firstBlock) return;
|
|
124
|
+
const [_, firstBlockPath] = firstBlock;
|
|
125
|
+
const blockPath = [firstBlockPath[0] + index];
|
|
126
|
+
currentBlock = editor.api.node(blockPath);
|
|
127
|
+
}
|
|
128
|
+
if (!currentBlock) return;
|
|
129
|
+
const range = findTextRangeInBlock({
|
|
130
|
+
block: currentBlock,
|
|
131
|
+
findText: NodeApi.string(node)
|
|
132
|
+
});
|
|
133
|
+
if (!range) return;
|
|
134
|
+
ranges.push(range);
|
|
135
|
+
});
|
|
136
|
+
if (ranges.length === 0) return;
|
|
137
|
+
if (ranges.length > 1) {
|
|
138
|
+
const startRange = ranges[0];
|
|
139
|
+
const endRange = ranges.at(-1);
|
|
140
|
+
return {
|
|
141
|
+
anchor: startRange.anchor,
|
|
142
|
+
focus: endRange.focus
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
if (ranges.length === 1) return ranges[0];
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
//#endregion
|
|
149
|
+
//#region src/react/ai-chat/utils/acceptAISuggestions.ts
|
|
150
|
+
const acceptAISuggestions = (editor) => {
|
|
151
|
+
editor.getApi(SuggestionPlugin).suggestion.nodes({ transient: true }).forEach(([suggestionNode]) => {
|
|
152
|
+
const suggestionData = editor.getApi(SuggestionPlugin).suggestion.suggestionData(suggestionNode);
|
|
153
|
+
if (!suggestionData) return;
|
|
154
|
+
acceptSuggestion(editor, {
|
|
155
|
+
createdAt: new Date(suggestionData.createdAt),
|
|
156
|
+
keyId: getSuggestionKey(suggestionData.id),
|
|
157
|
+
suggestionId: suggestionData.id,
|
|
158
|
+
type: suggestionData.type,
|
|
159
|
+
userId: suggestionData.userId
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
editor.tf.unsetNodes([getTransientSuggestionKey()], {
|
|
163
|
+
at: [],
|
|
164
|
+
mode: "all",
|
|
165
|
+
match: (n) => !!n[getTransientSuggestionKey()]
|
|
166
|
+
});
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
//#endregion
|
|
170
|
+
//#region src/react/ai-chat/transforms/acceptAIChat.ts
|
|
171
|
+
const acceptAIChat = (editor) => {
|
|
172
|
+
const mode = editor.getOption(AIChatPlugin, "mode");
|
|
173
|
+
if (mode === "insert") {
|
|
174
|
+
const { tf } = getEditorPlugin(editor, AIPlugin);
|
|
175
|
+
const api = editor.getApi({ key: KEYS.ai });
|
|
176
|
+
const lastAINodePath = api.aiChat.node({
|
|
177
|
+
at: [],
|
|
178
|
+
reverse: true
|
|
179
|
+
})[1];
|
|
180
|
+
withAIBatch(editor, () => {
|
|
181
|
+
tf.ai.removeMarks();
|
|
182
|
+
editor.getTransforms(AIChatPlugin).aiChat.removeAnchor();
|
|
183
|
+
});
|
|
184
|
+
api.aiChat.hide();
|
|
185
|
+
editor.tf.focus();
|
|
186
|
+
const focusPoint = editor.api.end(lastAINodePath);
|
|
187
|
+
editor.tf.setSelection({
|
|
188
|
+
anchor: focusPoint,
|
|
189
|
+
focus: focusPoint
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
if (mode === "chat") {
|
|
193
|
+
withAIBatch(editor, () => {
|
|
194
|
+
acceptAISuggestions(editor);
|
|
195
|
+
});
|
|
196
|
+
editor.getApi(AIChatPlugin).aiChat.hide();
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
//#endregion
|
|
201
|
+
//#region src/react/ai-chat/utils/nestedContainerUtils.ts
|
|
202
|
+
/** Check if nodes is a single table with single cell */
|
|
203
|
+
const isSingleCellTable = (nodes) => {
|
|
204
|
+
if (nodes.length !== 1) return false;
|
|
205
|
+
const table = nodes[0];
|
|
206
|
+
if (table.type !== KEYS.table) return false;
|
|
207
|
+
const rows = table.children;
|
|
208
|
+
if (rows.length !== 1) return false;
|
|
209
|
+
const row = rows[0];
|
|
210
|
+
if (row.type !== KEYS.tr) return false;
|
|
211
|
+
const cells = row.children;
|
|
212
|
+
if (cells.length !== 1) return false;
|
|
213
|
+
return cells[0].type === KEYS.td;
|
|
214
|
+
};
|
|
215
|
+
/** Extract td children from single-cell table */
|
|
216
|
+
const getTableCellChildren = (table) => {
|
|
217
|
+
return table.children[0].children[0].children;
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
//#endregion
|
|
221
|
+
//#region src/react/ai-chat/utils/applyAISuggestions.ts
|
|
222
|
+
const applyAISuggestions = (editor, content) => {
|
|
223
|
+
/** Conflict with block selection */
|
|
224
|
+
editor.getApi({ key: KEYS.cursorOverlay })?.cursorOverlay?.removeCursor("selection");
|
|
225
|
+
const { chatNodes } = editor.getOptions(AIChatPlugin);
|
|
226
|
+
if (chatNodes.length > 1) {
|
|
227
|
+
const setReplaceIds = (ids) => {
|
|
228
|
+
editor.setOption(AIChatPlugin, "_replaceIds", ids);
|
|
229
|
+
};
|
|
230
|
+
if (editor.getOption(AIChatPlugin, "_replaceIds").length === 0) setReplaceIds(chatNodes.map((node) => node.id));
|
|
231
|
+
const diffNodes = getDiffNodes(editor, content);
|
|
232
|
+
const replaceNodes = Array.from(editor.api.nodes({
|
|
233
|
+
at: [],
|
|
234
|
+
match: (n) => ElementApi.isElement(n) && editor.getOption(AIChatPlugin, "_replaceIds").includes(n.id)
|
|
235
|
+
}));
|
|
236
|
+
replaceNodes.forEach(([node, path], index) => {
|
|
237
|
+
const diffNode = diffNodes[index];
|
|
238
|
+
if (!diffNode) return;
|
|
239
|
+
if (index === replaceNodes.length - 1 && diffNodes.length > replaceNodes.length) {
|
|
240
|
+
editor.tf.replaceNodes(diffNodes.slice(index), { at: path });
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
const replaceNode = node;
|
|
244
|
+
const diffSuggestion = diffNode;
|
|
245
|
+
const isSameString = SkipSuggestionDeletes(editor, replaceNode) === SkipSuggestionDeletes(editor, diffSuggestion);
|
|
246
|
+
const isSameSuggestion = replaceNode.suggestion?.type === diffSuggestion.suggestion?.type;
|
|
247
|
+
if (isSameString && isSameSuggestion && node.id === diffNode.id) return;
|
|
248
|
+
editor.tf.replaceNodes(diffNode, { at: path });
|
|
249
|
+
});
|
|
250
|
+
editor.getApi(BlockSelectionPlugin).blockSelection.set(diffNodes.map((node) => node.id));
|
|
251
|
+
setReplaceIds(diffNodes.map((node) => node.id));
|
|
252
|
+
} else {
|
|
253
|
+
const diffNodes = getDiffNodes(editor, content);
|
|
254
|
+
editor.tf.insertFragment(diffNodes);
|
|
255
|
+
const nodes = Array.from(editor.api.nodes({
|
|
256
|
+
at: [],
|
|
257
|
+
mode: "lowest",
|
|
258
|
+
match: (n) => TextApi.isText(n) && !!n[getTransientSuggestionKey()]
|
|
259
|
+
}));
|
|
260
|
+
const range = editor.api.nodesRange(nodes);
|
|
261
|
+
editor.tf.setSelection(range);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
const withProps = (diffNodes, chatNodes) => diffNodes.map((node, index) => {
|
|
266
|
+
if (!ElementApi.isElement(node)) return node;
|
|
267
|
+
const originalNode = chatNodes?.[index];
|
|
268
|
+
return {
|
|
269
|
+
...node,
|
|
270
|
+
...originalNode ?? { id: nanoid() },
|
|
271
|
+
children: node.children
|
|
272
|
+
};
|
|
273
|
+
});
|
|
274
|
+
const withTransient = (diffNodes) => diffNodes.map((node) => {
|
|
275
|
+
if (TextApi.isText(node)) return {
|
|
276
|
+
...node,
|
|
277
|
+
[getTransientSuggestionKey()]: true
|
|
278
|
+
};
|
|
279
|
+
return {
|
|
280
|
+
...node,
|
|
281
|
+
children: withTransient(node.children),
|
|
282
|
+
[getTransientSuggestionKey()]: true
|
|
283
|
+
};
|
|
284
|
+
});
|
|
285
|
+
const withoutSuggestionAndComments = (nodes) => nodes.map((node) => {
|
|
286
|
+
if (TextApi.isText(node)) {
|
|
287
|
+
if (node[KEYS.suggestion] || node[KEYS.comment]) return { text: node.text };
|
|
288
|
+
return node;
|
|
289
|
+
}
|
|
290
|
+
if (ElementApi.isElement(node)) {
|
|
291
|
+
if (node[KEYS.suggestion]) {
|
|
292
|
+
const nodeWithoutSuggestion = {};
|
|
293
|
+
Object.keys(node).forEach((key) => {
|
|
294
|
+
if (key !== KEYS.suggestion && !key.startsWith(KEYS.suggestion)) nodeWithoutSuggestion[key] = node[key];
|
|
295
|
+
});
|
|
296
|
+
return {
|
|
297
|
+
...nodeWithoutSuggestion,
|
|
298
|
+
children: withoutSuggestionAndComments(node.children)
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
return {
|
|
302
|
+
...node,
|
|
303
|
+
children: withoutSuggestionAndComments(node.children)
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
return node;
|
|
307
|
+
});
|
|
308
|
+
const getDiffNodes = (editor, aiContent) => {
|
|
309
|
+
let chatNodes = withoutSuggestionAndComments(editor.getOption(AIChatPlugin, "chatNodes"));
|
|
310
|
+
/**If selecting one single cell table, we just need to compare it's children to get diff nodes */
|
|
311
|
+
if (isSingleCellTable(chatNodes)) chatNodes = getTableCellChildren(chatNodes[0]);
|
|
312
|
+
const aiNodes = withProps(deserializeMd(editor, aiContent), chatNodes);
|
|
313
|
+
return withTransient(diffToSuggestions(editor, chatNodes, aiNodes, { ignoreProps: ["id", "listStart"] }));
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
//#endregion
|
|
317
|
+
//#region src/react/ai-chat/utils/applyTableCellSuggestion.ts
|
|
318
|
+
/**
|
|
319
|
+
* Apply AI-generated content to a table cell as diff suggestions. Finds the
|
|
320
|
+
* cell by ID, deserializes the markdown content, computes diff, and replaces
|
|
321
|
+
* the cell's children with suggestion-marked nodes.
|
|
322
|
+
*/
|
|
323
|
+
const applyTableCellSuggestion = (editor, cellUpdate) => {
|
|
324
|
+
const { content, id } = cellUpdate;
|
|
325
|
+
const cellEntry = editor.api.node({
|
|
326
|
+
at: [],
|
|
327
|
+
match: (n) => ElementApi.isElement(n) && n.id === id
|
|
328
|
+
});
|
|
329
|
+
if (!cellEntry) {
|
|
330
|
+
console.warn(`Table cell with id "${id}" not found`);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
const [cell, cellPath] = cellEntry;
|
|
334
|
+
const transientDiffNodes = withTransient(diffToSuggestions(editor, withoutSuggestionAndComments(cell.children), deserializeMd(editor, content), { ignoreProps: ["id"] }));
|
|
335
|
+
editor.tf.replaceNodes(transientDiffNodes, {
|
|
336
|
+
at: cellPath,
|
|
337
|
+
children: true
|
|
338
|
+
});
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
//#endregion
|
|
342
|
+
//#region src/react/ai-chat/utils/getLastAssistantMessage.ts
|
|
343
|
+
function getLastAssistantMessage(editor) {
|
|
344
|
+
return editor.getOptions(AIChatPlugin).chat.messages?.findLast((message) => message.role === "assistant");
|
|
345
|
+
}
|
|
346
|
+
function useLastAssistantMessage() {
|
|
347
|
+
const $ = c(2);
|
|
348
|
+
const toolName = usePluginOption(AIChatPlugin, "toolName");
|
|
349
|
+
const chat = usePluginOption(AIChatPlugin, "chat");
|
|
350
|
+
if (toolName === "comment") return;
|
|
351
|
+
let t0;
|
|
352
|
+
if ($[0] !== chat.messages) {
|
|
353
|
+
t0 = chat.messages?.findLast(_temp);
|
|
354
|
+
$[0] = chat.messages;
|
|
355
|
+
$[1] = t0;
|
|
356
|
+
} else t0 = $[1];
|
|
357
|
+
return t0;
|
|
358
|
+
}
|
|
359
|
+
function _temp(message) {
|
|
360
|
+
return message.role === "assistant";
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
//#endregion
|
|
364
|
+
//#region src/react/ai-chat/utils/rejectAISuggestions.ts
|
|
365
|
+
const rejectAISuggestions = (editor) => {
|
|
366
|
+
editor.getApi(SuggestionPlugin).suggestion.nodes({ transient: true }).forEach(([suggestionNode]) => {
|
|
367
|
+
const suggestionData = editor.getApi(SuggestionPlugin).suggestion.suggestionData(suggestionNode);
|
|
368
|
+
if (!suggestionData) return;
|
|
369
|
+
rejectSuggestion(editor, {
|
|
370
|
+
createdAt: new Date(suggestionData.createdAt),
|
|
371
|
+
keyId: getSuggestionKey(suggestionData.id),
|
|
372
|
+
suggestionId: suggestionData.id,
|
|
373
|
+
type: suggestionData.type,
|
|
374
|
+
userId: suggestionData.userId
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
editor.tf.unsetNodes([getTransientSuggestionKey()], {
|
|
378
|
+
at: [],
|
|
379
|
+
mode: "all",
|
|
380
|
+
match: (n) => !!n[getTransientSuggestionKey()]
|
|
381
|
+
});
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
//#endregion
|
|
385
|
+
//#region src/react/ai-chat/utils/resetAIChat.ts
|
|
386
|
+
const resetAIChat = (editor, { undo = true } = {}) => {
|
|
387
|
+
const { api, getOptions, setOptions } = getEditorPlugin(editor, { key: KEYS.aiChat });
|
|
388
|
+
api.aiChat.stop();
|
|
389
|
+
const chat = getOptions().chat;
|
|
390
|
+
if (chat.messages && chat.messages.length > 0) chat.setMessages?.([]);
|
|
391
|
+
setOptions({
|
|
392
|
+
_replaceIds: [],
|
|
393
|
+
chatNodes: [],
|
|
394
|
+
mode: "insert",
|
|
395
|
+
toolName: null
|
|
396
|
+
});
|
|
397
|
+
if (undo) editor.getTransforms(AIPlugin).ai.undo();
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
//#endregion
|
|
401
|
+
//#region src/react/ai-chat/utils/submitAIChat.ts
|
|
402
|
+
const submitAIChat = (editor, input, { mode, options, prompt, toolName: toolNameProps } = {}) => {
|
|
403
|
+
const { getOptions, setOption } = getEditorPlugin(editor, { key: KEYS.aiChat });
|
|
404
|
+
const { chat, toolName: toolNameOption } = getOptions();
|
|
405
|
+
const toolName = toolNameProps ?? toolNameOption ?? null;
|
|
406
|
+
if (!prompt && input?.length === 0) return;
|
|
407
|
+
if (!prompt) prompt = input;
|
|
408
|
+
if (!mode) mode = isSelecting(editor) ? "chat" : "insert";
|
|
409
|
+
if (mode === "insert") editor.getTransforms(AIPlugin).ai.undo();
|
|
410
|
+
setOption("mode", mode);
|
|
411
|
+
setOption("toolName", toolName);
|
|
412
|
+
const blocks = editor.getApi(BlockSelectionPlugin).blockSelection.getNodes();
|
|
413
|
+
const blocksRange = editor.api.nodesRange(blocks);
|
|
414
|
+
const promptText = getEditorPrompt(editor, { prompt });
|
|
415
|
+
const selection = blocks.length > 0 ? blocksRange : editor.selection;
|
|
416
|
+
let chatNodes;
|
|
417
|
+
if (blocks.length > 0) chatNodes = blocks.map((block) => block[0]);
|
|
418
|
+
else {
|
|
419
|
+
const selectionBlocks = editor.api.blocks({ mode: "highest" });
|
|
420
|
+
if (selectionBlocks.length > 1) chatNodes = selectionBlocks.map((block) => block[0]);
|
|
421
|
+
else chatNodes = editor.api.fragment();
|
|
422
|
+
}
|
|
423
|
+
setOption("chatNodes", chatNodes);
|
|
424
|
+
setOption("chatSelection", blocks.length > 0 ? null : editor.selection);
|
|
425
|
+
const ctx = {
|
|
426
|
+
children: editor.children,
|
|
427
|
+
selection: selection ?? null,
|
|
428
|
+
toolName
|
|
429
|
+
};
|
|
430
|
+
chat.sendMessage?.({ text: promptText }, {
|
|
431
|
+
body: { ctx },
|
|
432
|
+
...options
|
|
433
|
+
});
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
//#endregion
|
|
437
|
+
//#region src/react/ai-chat/transforms/replaceSelectionAIChat.ts
|
|
438
|
+
const createFormattedBlocks = ({ blocks, format, sourceBlock }) => {
|
|
439
|
+
if (format === "none") return cloneDeep(blocks);
|
|
440
|
+
const [sourceNode] = sourceBlock;
|
|
441
|
+
const firstTextEntry = NodeApi.firstText(sourceNode);
|
|
442
|
+
if (!firstTextEntry) return null;
|
|
443
|
+
const blockProps = NodeApi.extractProps(sourceNode);
|
|
444
|
+
const textProps = NodeApi.extractProps(firstTextEntry[0]);
|
|
445
|
+
const applyTextFormatting = (node) => {
|
|
446
|
+
if (TextApi.isText(node)) return {
|
|
447
|
+
...textProps,
|
|
448
|
+
...node
|
|
449
|
+
};
|
|
450
|
+
if (node.children) return {
|
|
451
|
+
...node,
|
|
452
|
+
children: node.children.map(applyTextFormatting)
|
|
453
|
+
};
|
|
454
|
+
return node;
|
|
455
|
+
};
|
|
456
|
+
return blocks.map((block, index) => {
|
|
457
|
+
if (format === "single" && index > 0) return block;
|
|
458
|
+
return applyTextFormatting({
|
|
459
|
+
...block,
|
|
460
|
+
...blockProps
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
};
|
|
464
|
+
const replaceSelectionAIChat = (editor, sourceEditor, { format = "single" } = {}) => {
|
|
465
|
+
if (!sourceEditor || sourceEditor.api.isEmpty()) return;
|
|
466
|
+
const isBlockSelecting = editor.getOption(BlockSelectionPlugin, "isSelectingSome");
|
|
467
|
+
editor.getApi({ key: KEYS.ai }).aiChat.hide();
|
|
468
|
+
if (!isBlockSelecting) {
|
|
469
|
+
const firstBlock = editor.api.node({
|
|
470
|
+
block: true,
|
|
471
|
+
mode: "lowest"
|
|
472
|
+
});
|
|
473
|
+
if (firstBlock && editor.api.isSelected(firstBlock[1], { contains: true }) && format !== "none") {
|
|
474
|
+
const formattedBlocks$1 = createFormattedBlocks({
|
|
475
|
+
blocks: cloneDeep(sourceEditor.children),
|
|
476
|
+
format,
|
|
477
|
+
sourceBlock: firstBlock
|
|
478
|
+
});
|
|
479
|
+
if (!formattedBlocks$1) return;
|
|
480
|
+
/** When user selection is cover the whole code block */
|
|
481
|
+
if (firstBlock[0].type === KEYS.codeLine && sourceEditor.children[0].type === KEYS.codeBlock && sourceEditor.children.length === 1) editor.tf.insertFragment(formattedBlocks$1[0].children);
|
|
482
|
+
else editor.tf.insertFragment(formattedBlocks$1);
|
|
483
|
+
editor.tf.focus();
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
editor.tf.insertFragment(sourceEditor.children);
|
|
487
|
+
editor.tf.focus();
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
const selectedBlocks = editor.getApi(BlockSelectionPlugin).blockSelection.getNodes();
|
|
491
|
+
if (selectedBlocks.length === 0) return;
|
|
492
|
+
if (format === "none" || format === "single" && selectedBlocks.length > 1) {
|
|
493
|
+
editor.tf.withoutNormalizing(() => {
|
|
494
|
+
removeBlockSelectionNodes(editor);
|
|
495
|
+
editor.tf.withNewBatch(() => {
|
|
496
|
+
editor.getTransforms(BlockSelectionPlugin).blockSelection.insertBlocksAndSelect(cloneDeep(sourceEditor.children), { at: selectedBlocks[0][1] });
|
|
497
|
+
});
|
|
498
|
+
});
|
|
499
|
+
editor.getApi(BlockSelectionPlugin).blockSelection.focus();
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
const [, firstBlockPath] = selectedBlocks[0];
|
|
503
|
+
const formattedBlocks = createFormattedBlocks({
|
|
504
|
+
blocks: cloneDeep(sourceEditor.children),
|
|
505
|
+
format,
|
|
506
|
+
sourceBlock: selectedBlocks[0]
|
|
507
|
+
});
|
|
508
|
+
if (!formattedBlocks) return;
|
|
509
|
+
editor.tf.withoutNormalizing(() => {
|
|
510
|
+
removeBlockSelectionNodes(editor);
|
|
511
|
+
editor.tf.withNewBatch(() => {
|
|
512
|
+
editor.getTransforms(BlockSelectionPlugin).blockSelection.insertBlocksAndSelect(formattedBlocks, { at: firstBlockPath });
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
editor.getApi(BlockSelectionPlugin).blockSelection.focus();
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
//#endregion
|
|
519
|
+
//#region src/react/ai-chat/transforms/insertBelowAIChat.ts
|
|
520
|
+
const insertBelowAIChat = (editor, sourceEditor, { format = "single" } = {}) => {
|
|
521
|
+
const { toolName } = editor.getOptions(AIChatPlugin);
|
|
522
|
+
if (toolName === "generate") return insertBelowGenerate(editor, sourceEditor, { format });
|
|
523
|
+
const selectedBlocks = editor.getApi(BlockSelectionPlugin).blockSelection.getNodes();
|
|
524
|
+
const selectedIds = editor.getOptions(BlockSelectionPlugin).selectedIds;
|
|
525
|
+
editor.getTransforms(AIPlugin).ai.undo();
|
|
526
|
+
const insertBlocksAndSelect = editor.getTransforms(BlockSelectionPlugin).blockSelection.insertBlocksAndSelect;
|
|
527
|
+
if (!selectedIds || selectedIds.size === 0) return;
|
|
528
|
+
const lastBlock = selectedBlocks.at(-1);
|
|
529
|
+
if (!lastBlock) return;
|
|
530
|
+
const nextPath = PathApi.next(lastBlock[1]);
|
|
531
|
+
insertBlocksAndSelect(selectedBlocks.map((block) => block[0]), {
|
|
532
|
+
at: nextPath,
|
|
533
|
+
insertedCallback: () => {
|
|
534
|
+
withAIBatch(editor, () => {
|
|
535
|
+
acceptAISuggestions(editor);
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
});
|
|
539
|
+
editor.getApi(AIChatPlugin).aiChat.hide({ focus: false });
|
|
540
|
+
};
|
|
541
|
+
const insertBelowGenerate = (editor, sourceEditor, { format = "single" } = {}) => {
|
|
542
|
+
if (!sourceEditor || sourceEditor.api.isEmpty()) return;
|
|
543
|
+
const isBlockSelecting = editor.getOption(BlockSelectionPlugin, "isSelectingSome");
|
|
544
|
+
editor.getApi({ key: KEYS.ai }).aiChat.hide();
|
|
545
|
+
const insertBlocksAndSelect = editor.getTransforms(BlockSelectionPlugin).blockSelection.insertBlocksAndSelect;
|
|
546
|
+
if (isBlockSelecting) {
|
|
547
|
+
const selectedBlocks = editor.getApi(BlockSelectionPlugin).blockSelection.getNodes();
|
|
548
|
+
const selectedIds = editor.getOptions(BlockSelectionPlugin).selectedIds;
|
|
549
|
+
if (!selectedIds || selectedIds.size === 0) return;
|
|
550
|
+
const lastBlock = selectedBlocks.at(-1);
|
|
551
|
+
if (!lastBlock) return;
|
|
552
|
+
const nextPath = PathApi.next(lastBlock[1]);
|
|
553
|
+
if (format === "none") {
|
|
554
|
+
insertBlocksAndSelect(cloneDeep(sourceEditor.children), { at: nextPath });
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
const formattedBlocks = createFormattedBlocks({
|
|
558
|
+
blocks: cloneDeep(sourceEditor.children),
|
|
559
|
+
format,
|
|
560
|
+
sourceBlock: lastBlock
|
|
561
|
+
});
|
|
562
|
+
if (!formattedBlocks) return;
|
|
563
|
+
insertBlocksAndSelect(formattedBlocks, { at: nextPath });
|
|
564
|
+
} else {
|
|
565
|
+
const [, end] = RangeApi.edges(editor.selection);
|
|
566
|
+
const endPath = [end.path[0]];
|
|
567
|
+
const currentBlock = editor.api.node({
|
|
568
|
+
at: endPath,
|
|
569
|
+
block: true,
|
|
570
|
+
mode: "lowest"
|
|
571
|
+
});
|
|
572
|
+
if (!currentBlock) return;
|
|
573
|
+
if (format === "none") {
|
|
574
|
+
insertBlocksAndSelect(cloneDeep(sourceEditor.children), { at: PathApi.next(endPath) });
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
const formattedBlocks = createFormattedBlocks({
|
|
578
|
+
blocks: cloneDeep(sourceEditor.children),
|
|
579
|
+
format,
|
|
580
|
+
sourceBlock: currentBlock
|
|
581
|
+
});
|
|
582
|
+
if (!formattedBlocks) return;
|
|
583
|
+
insertBlocksAndSelect(formattedBlocks, { at: PathApi.next(endPath) });
|
|
584
|
+
}
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
//#endregion
|
|
588
|
+
//#region src/react/ai-chat/transforms/removeAnchorAIChat.ts
|
|
589
|
+
const removeAnchorAIChat = (editor, options) => {
|
|
590
|
+
editor.tf.withoutSaving(() => {
|
|
591
|
+
editor.tf.removeNodes({
|
|
592
|
+
at: [],
|
|
593
|
+
match: (n) => ElementApi.isElement(n) && n.type === getPluginType(editor, KEYS.aiChat),
|
|
594
|
+
...options
|
|
595
|
+
});
|
|
596
|
+
});
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
//#endregion
|
|
600
|
+
//#region src/react/ai-chat/withAIChat.ts
|
|
601
|
+
const withAIChat = ({ api, editor, getOptions, tf: { insertText, normalizeNode }, type }) => {
|
|
602
|
+
const tf = editor.getTransforms(AIPlugin);
|
|
603
|
+
const matchesTrigger = (text) => {
|
|
604
|
+
const { trigger } = getOptions();
|
|
605
|
+
if (trigger instanceof RegExp) return trigger.test(text);
|
|
606
|
+
if (Array.isArray(trigger)) return trigger.includes(text);
|
|
607
|
+
return text === trigger;
|
|
608
|
+
};
|
|
609
|
+
return { transforms: {
|
|
610
|
+
insertText(text, options) {
|
|
611
|
+
const { triggerPreviousCharPattern, triggerQuery } = getOptions();
|
|
612
|
+
const fn = () => {
|
|
613
|
+
if (!editor.selection || !matchesTrigger(text) || triggerQuery && !triggerQuery(editor)) return;
|
|
614
|
+
const previousChar = editor.api.string(editor.api.range("before", editor.selection));
|
|
615
|
+
if (!triggerPreviousCharPattern?.test(previousChar)) return;
|
|
616
|
+
const nodeEntry = editor.api.block({ highest: true });
|
|
617
|
+
if (!nodeEntry || !editor.api.isEmpty(nodeEntry[0])) return;
|
|
618
|
+
api.aiChat.show();
|
|
619
|
+
return true;
|
|
620
|
+
};
|
|
621
|
+
if (fn()) return;
|
|
622
|
+
return insertText(text, options);
|
|
623
|
+
},
|
|
624
|
+
normalizeNode(entry) {
|
|
625
|
+
const [node, path] = entry;
|
|
626
|
+
if (node[KEYS.ai] && !getOptions().open) {
|
|
627
|
+
tf.ai.removeMarks({ at: path });
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
if (ElementApi.isElement(node) && node.type === type && !getOptions().open) {
|
|
631
|
+
editor.getTransforms(AIChatPlugin).aiChat.removeAnchor({ at: path });
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
return normalizeNode(entry);
|
|
635
|
+
}
|
|
636
|
+
} };
|
|
637
|
+
};
|
|
638
|
+
|
|
639
|
+
//#endregion
|
|
640
|
+
//#region src/react/ai-chat/AIChatPlugin.ts
|
|
641
|
+
const AIChatPlugin = createTPlatePlugin({
|
|
642
|
+
key: KEYS.aiChat,
|
|
643
|
+
dependencies: ["ai"],
|
|
644
|
+
node: { isElement: true },
|
|
645
|
+
options: {
|
|
646
|
+
_blockChunks: "",
|
|
647
|
+
_blockPath: null,
|
|
648
|
+
_mdxName: null,
|
|
649
|
+
_replaceIds: [],
|
|
650
|
+
aiEditor: null,
|
|
651
|
+
chat: { messages: [] },
|
|
652
|
+
chatNodes: [],
|
|
653
|
+
chatSelection: null,
|
|
654
|
+
experimental_lastTextId: null,
|
|
655
|
+
mode: "insert",
|
|
656
|
+
open: false,
|
|
657
|
+
streaming: false,
|
|
658
|
+
toolName: null,
|
|
659
|
+
trigger: " ",
|
|
660
|
+
triggerPreviousCharPattern: /^\s?$/
|
|
661
|
+
}
|
|
662
|
+
}).overrideEditor(withAIChat).extendApi(({ editor, getOption, getOptions, setOption, type }) => ({
|
|
663
|
+
reset: bindFirst(resetAIChat, editor),
|
|
664
|
+
submit: bindFirst(submitAIChat, editor),
|
|
665
|
+
node: (options = {}) => {
|
|
666
|
+
const { anchor = false, streaming = false, ...rest } = options;
|
|
667
|
+
if (anchor) return editor.api.node({
|
|
668
|
+
at: [],
|
|
669
|
+
match: (n) => ElementApi.isElement(n) && n.type === type,
|
|
670
|
+
...rest
|
|
671
|
+
});
|
|
672
|
+
if (streaming) {
|
|
673
|
+
if (!getOption("streaming")) return;
|
|
674
|
+
const path = getOption("_blockPath");
|
|
675
|
+
if (!path) return;
|
|
676
|
+
return editor.api.node({
|
|
677
|
+
at: path,
|
|
678
|
+
mode: "lowest",
|
|
679
|
+
reverse: true,
|
|
680
|
+
match: (t) => !!t[getPluginType(editor, KEYS.ai)],
|
|
681
|
+
...rest
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
return editor.api.node({
|
|
685
|
+
match: (n) => n[getPluginType(editor, KEYS.ai)],
|
|
686
|
+
...rest
|
|
687
|
+
});
|
|
688
|
+
},
|
|
689
|
+
reload: () => {
|
|
690
|
+
const { chat, chatNodes, chatSelection } = getOptions();
|
|
691
|
+
editor.getTransforms(AIPlugin).ai.undo();
|
|
692
|
+
if (chatSelection) editor.tf.setSelection(chatSelection);
|
|
693
|
+
else editor.getApi(BlockSelectionPlugin).blockSelection.set(chatNodes.map((node) => node.id));
|
|
694
|
+
const blocks = editor.getApi(BlockSelectionPlugin).blockSelection.getNodes();
|
|
695
|
+
const selection = blocks.length > 0 ? editor.api.nodesRange(blocks) : editor.selection;
|
|
696
|
+
const ctx = {
|
|
697
|
+
children: editor.children,
|
|
698
|
+
selection: selection ?? null,
|
|
699
|
+
toolName: getOption("toolName")
|
|
700
|
+
};
|
|
701
|
+
chat.regenerate?.({ body: { ctx } });
|
|
702
|
+
},
|
|
703
|
+
stop: () => {
|
|
704
|
+
setOption("streaming", false);
|
|
705
|
+
getOptions().chat.stop?.();
|
|
706
|
+
}
|
|
707
|
+
})).extendApi(({ api, editor, getOptions, setOption, tf }) => ({
|
|
708
|
+
hide: ({ focus = true, undo = true } = {}) => {
|
|
709
|
+
api.aiChat.reset({ undo });
|
|
710
|
+
setOption("open", false);
|
|
711
|
+
if (focus) if (editor.getOption(BlockSelectionPlugin, "isSelectingSome")) editor.getApi(BlockSelectionPlugin).blockSelection.focus();
|
|
712
|
+
else editor.tf.focus();
|
|
713
|
+
const lastBatch = editor.history.undos.at(-1);
|
|
714
|
+
if (lastBatch?.ai) lastBatch.ai = void 0;
|
|
715
|
+
tf.aiChat.removeAnchor();
|
|
716
|
+
},
|
|
717
|
+
show: () => {
|
|
718
|
+
api.aiChat.reset();
|
|
719
|
+
setOption("toolName", null);
|
|
720
|
+
getOptions().chat.setMessages?.([]);
|
|
721
|
+
setOption("open", true);
|
|
722
|
+
}
|
|
723
|
+
})).extendTransforms(({ editor }) => ({
|
|
724
|
+
accept: bindFirst(acceptAIChat, editor),
|
|
725
|
+
insertBelow: bindFirst(insertBelowAIChat, editor),
|
|
726
|
+
removeAnchor: bindFirst(removeAnchorAIChat, editor),
|
|
727
|
+
replaceSelection: bindFirst(replaceSelectionAIChat, editor)
|
|
728
|
+
}));
|
|
729
|
+
|
|
730
|
+
//#endregion
|
|
731
|
+
//#region src/react/ai-chat/hooks/useAIChatEditor.ts
|
|
732
|
+
/**
|
|
733
|
+
* Register an editor in the AI chat plugin, and deserializes the content into
|
|
734
|
+
* `editor.children` with block-level memoization.
|
|
735
|
+
*
|
|
736
|
+
* @returns Deserialized children to pass as `value` prop to PlateStatic
|
|
737
|
+
*/
|
|
738
|
+
const useAIChatEditor = (editor, content, { parser } = {}) => {
|
|
739
|
+
const { setOption } = useEditorPlugin(AIChatPlugin);
|
|
740
|
+
const children = useMemo(() => {
|
|
741
|
+
return editor.getApi(MarkdownPlugin).markdown.deserialize(content, {
|
|
742
|
+
memoize: true,
|
|
743
|
+
parser
|
|
744
|
+
});
|
|
745
|
+
}, [content]);
|
|
746
|
+
editor.children = children;
|
|
747
|
+
useEffect(() => {
|
|
748
|
+
setOption("aiEditor", editor);
|
|
749
|
+
}, [editor, setOption]);
|
|
750
|
+
return children;
|
|
751
|
+
};
|
|
752
|
+
|
|
753
|
+
//#endregion
|
|
754
|
+
//#region src/react/ai-chat/hooks/useChatChunk.ts
|
|
755
|
+
const useChatChunk = ({ onChunk, onFinish }) => {
|
|
756
|
+
const { status } = usePluginOption({ key: KEYS.aiChat }, "chat");
|
|
757
|
+
const isLoading = status === "streaming" || status === "submitted";
|
|
758
|
+
const content = useLastAssistantMessage()?.parts.find((part) => part.type === "text")?.text;
|
|
759
|
+
const insertedTextRef = useRef("");
|
|
760
|
+
const prevIsLoadingRef = useRef(isLoading);
|
|
761
|
+
useEffect(() => {
|
|
762
|
+
if (!isLoading) insertedTextRef.current = "";
|
|
763
|
+
if (prevIsLoadingRef.current && !isLoading) onFinish?.({ content: content ?? "" });
|
|
764
|
+
prevIsLoadingRef.current = isLoading;
|
|
765
|
+
}, [isLoading]);
|
|
766
|
+
useEffect(() => {
|
|
767
|
+
if (!content) return;
|
|
768
|
+
const chunk = content.slice(insertedTextRef.current.length);
|
|
769
|
+
const nodes = [];
|
|
770
|
+
if (chunk) {
|
|
771
|
+
const isFirst = insertedTextRef.current === "";
|
|
772
|
+
nodes.push({ text: chunk });
|
|
773
|
+
onChunk({
|
|
774
|
+
chunk,
|
|
775
|
+
isFirst,
|
|
776
|
+
nodes,
|
|
777
|
+
text: content
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
insertedTextRef.current = content;
|
|
781
|
+
}, [content]);
|
|
782
|
+
};
|
|
783
|
+
|
|
784
|
+
//#endregion
|
|
785
|
+
//#region src/react/ai-chat/hooks/useEditorChat.ts
|
|
786
|
+
const useEditorChat = ({ onOpenBlockSelection, onOpenChange, onOpenCursor, onOpenSelection }) => {
|
|
787
|
+
const { editor } = useEditorPlugin(AIChatPlugin);
|
|
788
|
+
const open = usePluginOption(AIChatPlugin, "open");
|
|
789
|
+
useEffect(() => {
|
|
790
|
+
onOpenChange?.(open);
|
|
791
|
+
if (open) {
|
|
792
|
+
if (onOpenBlockSelection) {
|
|
793
|
+
const blockSelectionApi = editor.getApi(BlockSelectionPlugin).blockSelection;
|
|
794
|
+
if (editor.getOption(BlockSelectionPlugin, "isSelectingSome")) {
|
|
795
|
+
onOpenBlockSelection(blockSelectionApi.getNodes());
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
if (onOpenCursor && editor.api.isCollapsed()) {
|
|
800
|
+
onOpenCursor();
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
if (onOpenSelection && editor.api.isExpanded()) {
|
|
804
|
+
onOpenSelection();
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
}, [open]);
|
|
809
|
+
};
|
|
810
|
+
|
|
811
|
+
//#endregion
|
|
812
|
+
//#region src/react/ai-chat/streaming/streamDeserializeInlineMd.ts
|
|
813
|
+
const streamDeserializeInlineMd = (editor, text, options) => editor.getApi(MarkdownPlugin).markdown.deserializeInline(text, options);
|
|
814
|
+
|
|
815
|
+
//#endregion
|
|
816
|
+
//#region src/react/ai-chat/streaming/utils/utils.ts
|
|
817
|
+
const getChunkTrimmed = (chunk, { direction = "right" } = {}) => {
|
|
818
|
+
const str = direction === "right" ? chunk.trimEnd() : chunk.trimStart();
|
|
819
|
+
if (direction === "right") return chunk.slice(str.length);
|
|
820
|
+
return chunk.slice(0, chunk.length - str.length);
|
|
821
|
+
};
|
|
822
|
+
function isCompleteCodeBlock(str) {
|
|
823
|
+
const trimmed = str.trim();
|
|
824
|
+
const startsWithCodeBlock = trimmed.startsWith("```");
|
|
825
|
+
const endsWithCodeBlock = trimmed.endsWith("```");
|
|
826
|
+
return startsWithCodeBlock && endsWithCodeBlock;
|
|
827
|
+
}
|
|
828
|
+
function isCompleteMath(str) {
|
|
829
|
+
const trimmed = str.trim();
|
|
830
|
+
const startsWithMath = trimmed.startsWith("$$");
|
|
831
|
+
const endsWithMath = trimmed.endsWith("$$");
|
|
832
|
+
return startsWithMath && endsWithMath;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
//#endregion
|
|
836
|
+
//#region src/react/ai-chat/streaming/utils/escapeInput.ts
|
|
837
|
+
const escapeInput = (data) => {
|
|
838
|
+
let res = data;
|
|
839
|
+
if (data.startsWith("$$") && !data.startsWith("$$\n") && !isCompleteMath(data)) res = data.replace("$$", String.raw`\$\$`);
|
|
840
|
+
return res;
|
|
841
|
+
};
|
|
842
|
+
|
|
843
|
+
//#endregion
|
|
844
|
+
//#region src/react/ai-chat/streaming/utils/getListNode.ts
|
|
845
|
+
const getListNode = (editor, node) => {
|
|
846
|
+
if (node.listStyleType && node.listStart) {
|
|
847
|
+
const previousNode = editor.api.previous({ at: editor.selection?.focus })?.[0];
|
|
848
|
+
if (previousNode?.listStyleType && previousNode?.listStart) return node;
|
|
849
|
+
if (node.listStart === 1) return node;
|
|
850
|
+
return {
|
|
851
|
+
...node,
|
|
852
|
+
listRestartPolite: node.listStart
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
return node;
|
|
856
|
+
};
|
|
857
|
+
|
|
858
|
+
//#endregion
|
|
859
|
+
//#region src/react/ai-chat/streaming/utils/isSameNode.ts
|
|
860
|
+
const LIST_STYLE_TYPE = "listStyleType";
|
|
861
|
+
const isSameNode = (editor, node1, node2) => {
|
|
862
|
+
if (node1.type !== editor.getType(KEYS.p) || node2.type !== editor.getType(KEYS.p)) return node1.type === node2.type;
|
|
863
|
+
if (isDefined(node1[LIST_STYLE_TYPE]) || isDefined(node2[LIST_STYLE_TYPE])) return node1[LIST_STYLE_TYPE] === node2[LIST_STYLE_TYPE];
|
|
864
|
+
return node1.type === node2.type;
|
|
865
|
+
};
|
|
866
|
+
|
|
867
|
+
//#endregion
|
|
868
|
+
//#region src/react/ai-chat/streaming/utils/nodesWithProps.ts
|
|
869
|
+
const nodesWithProps = (editor, nodes, options) => nodes.map((node) => {
|
|
870
|
+
if (ElementApi.isElement(node)) return {
|
|
871
|
+
...getListNode(editor, node),
|
|
872
|
+
...options.elementProps,
|
|
873
|
+
children: nodesWithProps(editor, node.children, options)
|
|
874
|
+
};
|
|
875
|
+
return {
|
|
876
|
+
...options.textProps,
|
|
877
|
+
...node,
|
|
878
|
+
text: node.text
|
|
879
|
+
};
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
//#endregion
|
|
883
|
+
//#region src/react/ai-chat/streaming/streamDeserializeMd.ts
|
|
884
|
+
const statMdxTagRegex = /<([A-Za-z][A-Za-z0-9._:-]*)(?:\s[^>]*?)?(?<!\/)>/;
|
|
885
|
+
const streamDeserializeMd = (editor, data, options) => {
|
|
886
|
+
const input = escapeInput(data);
|
|
887
|
+
const value = withoutDeserializeInMdx(editor, input);
|
|
888
|
+
if (Array.isArray(value)) return value;
|
|
889
|
+
let blocks = [];
|
|
890
|
+
blocks = editor.getApi(MarkdownPlugin).markdown.deserialize(input, {
|
|
891
|
+
...options,
|
|
892
|
+
preserveEmptyParagraphs: false
|
|
893
|
+
});
|
|
894
|
+
const trimmedData = getChunkTrimmed(data);
|
|
895
|
+
const lastBlock = blocks.at(-1);
|
|
896
|
+
const addNewLine = trimmedData === "\n\n";
|
|
897
|
+
const unshiftNewLine = getChunkTrimmed(data, { direction: "left" }) === "\n\n";
|
|
898
|
+
const isCodeBlockOrTable = lastBlock?.type === "code_block" || lastBlock?.type === "table";
|
|
899
|
+
let result = blocks;
|
|
900
|
+
/**
|
|
901
|
+
* Deserialize the sting like `123\n\n` will be `123` base on markdown spec
|
|
902
|
+
* but we want to keep the `\n\n`
|
|
903
|
+
*/
|
|
904
|
+
if (lastBlock && !isCodeBlockOrTable && trimmedData.length > 0 && !addNewLine) {
|
|
905
|
+
const textNode = [{ text: trimmedData }];
|
|
906
|
+
const lastChild = lastBlock.children.at(-1);
|
|
907
|
+
/** It’s like normalizing and merging the text nodes. */
|
|
908
|
+
if (lastChild && TextApi.isText(lastChild) && Object.keys(lastChild).length === 1) {
|
|
909
|
+
lastBlock.children.pop();
|
|
910
|
+
const textNode$1 = [{ text: lastChild.text + trimmedData }];
|
|
911
|
+
lastBlock.children.push(...textNode$1);
|
|
912
|
+
} else lastBlock.children.push(...textNode);
|
|
913
|
+
result = [...blocks.slice(0, -1), lastBlock];
|
|
914
|
+
}
|
|
915
|
+
if (addNewLine && !isCodeBlockOrTable) result.push({
|
|
916
|
+
children: [{ text: "" }],
|
|
917
|
+
type: KEYS.p
|
|
918
|
+
});
|
|
919
|
+
if (unshiftNewLine && !isCodeBlockOrTable) result.unshift({
|
|
920
|
+
children: [{ text: "" }],
|
|
921
|
+
type: KEYS.p
|
|
922
|
+
});
|
|
923
|
+
return result;
|
|
924
|
+
};
|
|
925
|
+
const withoutDeserializeInMdx = (editor, input) => {
|
|
926
|
+
const mdxName = editor.getOption(AIChatPlugin, "_mdxName");
|
|
927
|
+
if (mdxName) {
|
|
928
|
+
if (input.includes(`</${mdxName}>`)) {
|
|
929
|
+
editor.setOption(AIChatPlugin, "_mdxName", null);
|
|
930
|
+
return false;
|
|
931
|
+
}
|
|
932
|
+
return [{
|
|
933
|
+
children: [{ text: input }],
|
|
934
|
+
type: getPluginType(editor, KEYS.p)
|
|
935
|
+
}];
|
|
936
|
+
}
|
|
937
|
+
const newMdxName = statMdxTagRegex.exec(input)?.[1];
|
|
938
|
+
if (input.startsWith(`<${newMdxName}`)) editor.setOption(AIChatPlugin, "_mdxName", newMdxName ?? null);
|
|
939
|
+
};
|
|
940
|
+
|
|
941
|
+
//#endregion
|
|
942
|
+
//#region src/react/ai-chat/streaming/streamSerializeMd.ts
|
|
943
|
+
const trimEndHeading = (editor, value) => {
|
|
944
|
+
const headingKeys = new Set([
|
|
945
|
+
KEYS.h1,
|
|
946
|
+
KEYS.h2,
|
|
947
|
+
KEYS.h3,
|
|
948
|
+
KEYS.h4,
|
|
949
|
+
KEYS.h5,
|
|
950
|
+
KEYS.h6
|
|
951
|
+
]);
|
|
952
|
+
const lastBlock = value.at(-1);
|
|
953
|
+
if (lastBlock && headingKeys.has(getPluginKey(editor, lastBlock.type) ?? lastBlock.type) && ElementApi.isElement(lastBlock)) {
|
|
954
|
+
const lastTextNode = lastBlock.children.at(-1);
|
|
955
|
+
if (TextApi.isText(lastTextNode)) {
|
|
956
|
+
const trimmedText = getChunkTrimmed(lastTextNode?.text);
|
|
957
|
+
const newChildren = [...lastBlock.children.slice(0, -1), { text: lastTextNode.text.trimEnd() }];
|
|
958
|
+
const newLastBlock = {
|
|
959
|
+
...lastBlock,
|
|
960
|
+
children: newChildren
|
|
961
|
+
};
|
|
962
|
+
return {
|
|
963
|
+
trimmedText,
|
|
964
|
+
value: [...value.slice(0, -1), newLastBlock]
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
return {
|
|
969
|
+
trimmedText: "",
|
|
970
|
+
value
|
|
971
|
+
};
|
|
972
|
+
};
|
|
973
|
+
const streamSerializeMd = (editor, options, chunk) => {
|
|
974
|
+
const { value: optionsValue, ...restOptions } = options;
|
|
975
|
+
const { value } = trimEndHeading(editor, optionsValue ?? editor.children);
|
|
976
|
+
let result = "";
|
|
977
|
+
result = editor.getApi(MarkdownPlugin).markdown.serialize({
|
|
978
|
+
value,
|
|
979
|
+
...restOptions
|
|
980
|
+
});
|
|
981
|
+
const trimmedChunk = getChunkTrimmed(chunk);
|
|
982
|
+
if (isCompleteCodeBlock(result) && !chunk.endsWith("```")) result = result.trimEnd().slice(0, -3) + trimmedChunk;
|
|
983
|
+
if (isCompleteMath(result) && !chunk.endsWith("$$")) result = result.trimEnd().slice(0, -3) + trimmedChunk;
|
|
984
|
+
result = result.replace(/ /g, " ");
|
|
985
|
+
result = result.replace(/​/g, " ");
|
|
986
|
+
result = result.replace(/\u200B/g, "");
|
|
987
|
+
if (trimmedChunk !== "\n\n") result = result.trimEnd() + trimmedChunk;
|
|
988
|
+
if (chunk.endsWith("\n\n")) {
|
|
989
|
+
if (result === "\n") result = "";
|
|
990
|
+
else if (result.endsWith("\n\n")) result = result.slice(0, -1);
|
|
991
|
+
}
|
|
992
|
+
result = result.replace(/\\([\\`*_{}\\[\]()#+\-\\.!~<>|$])/g, "$1");
|
|
993
|
+
return result;
|
|
994
|
+
};
|
|
995
|
+
|
|
996
|
+
//#endregion
|
|
997
|
+
//#region src/react/ai-chat/streaming/streamInsertChunk.ts
|
|
998
|
+
const getNextPath = (path, length) => {
|
|
999
|
+
let result = path;
|
|
1000
|
+
for (let i = 0; i < length; i++) result = PathApi.next(result);
|
|
1001
|
+
return result;
|
|
1002
|
+
};
|
|
1003
|
+
/** @experimental */
|
|
1004
|
+
function streamInsertChunk(editor, chunk, options = {}) {
|
|
1005
|
+
const { _blockChunks, _blockPath } = editor.getOptions(AIChatPlugin);
|
|
1006
|
+
if (_blockPath === null) {
|
|
1007
|
+
const blocks = streamDeserializeMd(editor, chunk);
|
|
1008
|
+
const path = getCurrentBlockPath(editor);
|
|
1009
|
+
const startBlock = editor.api.node(path)[0];
|
|
1010
|
+
const startInEmptyParagraph = NodeApi.string(startBlock).length === 0 && startBlock.type === getPluginType(editor, KEYS.p);
|
|
1011
|
+
if (startInEmptyParagraph) editor.tf.removeNodes({ at: path });
|
|
1012
|
+
if (blocks.length > 0) {
|
|
1013
|
+
editor.tf.insertNodes(nodesWithProps(editor, [blocks[0]], options), {
|
|
1014
|
+
at: path,
|
|
1015
|
+
nextBlock: !startInEmptyParagraph,
|
|
1016
|
+
select: true
|
|
1017
|
+
});
|
|
1018
|
+
editor.setOption(AIChatPlugin, "_blockPath", getCurrentBlockPath(editor));
|
|
1019
|
+
editor.setOption(AIChatPlugin, "_blockChunks", chunk);
|
|
1020
|
+
if (blocks.length > 1) {
|
|
1021
|
+
const nextBlocks = blocks.slice(1);
|
|
1022
|
+
const nextPath = getCurrentBlockPath(editor);
|
|
1023
|
+
editor.tf.insertNodes(nodesWithProps(editor, nextBlocks, options), {
|
|
1024
|
+
at: nextPath,
|
|
1025
|
+
nextBlock: true,
|
|
1026
|
+
select: true
|
|
1027
|
+
});
|
|
1028
|
+
const lastBlock = editor.api.node(getNextPath(nextPath, nextBlocks.length));
|
|
1029
|
+
editor.setOption(AIChatPlugin, "_blockPath", lastBlock[1]);
|
|
1030
|
+
const lastBlockChunks = streamSerializeMd(editor, { value: [lastBlock[0]] }, chunk);
|
|
1031
|
+
editor.setOption(AIChatPlugin, "_blockChunks", lastBlockChunks);
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
} else {
|
|
1035
|
+
const tempBlockChunks = _blockChunks + chunk;
|
|
1036
|
+
const tempBlocks = streamDeserializeMd(editor, tempBlockChunks);
|
|
1037
|
+
if (tempBlocks.length === 0) return console.warn(`unsupport md nodes: ${JSON.stringify(tempBlockChunks)}`);
|
|
1038
|
+
if (tempBlocks.length === 1) {
|
|
1039
|
+
const currentBlock = editor.api.node(_blockPath)[0];
|
|
1040
|
+
if (isSameNode(editor, currentBlock, tempBlocks[0])) {
|
|
1041
|
+
const chunkNodes = streamDeserializeInlineMd(editor, chunk);
|
|
1042
|
+
editor.tf.insertNodes(nodesWithProps(editor, chunkNodes, options), {
|
|
1043
|
+
at: editor.api.end(_blockPath),
|
|
1044
|
+
select: true
|
|
1045
|
+
});
|
|
1046
|
+
const serializedBlock = streamSerializeMd(editor, { value: [editor.api.node(_blockPath)[0]] }, tempBlockChunks);
|
|
1047
|
+
const blockText = NodeApi.string(tempBlocks[0]);
|
|
1048
|
+
if (serializedBlock === tempBlockChunks && blockText === serializedBlock) editor.setOption(AIChatPlugin, "_blockChunks", tempBlockChunks);
|
|
1049
|
+
else {
|
|
1050
|
+
editor.tf.replaceNodes(nodesWithProps(editor, [tempBlocks[0]], options), {
|
|
1051
|
+
at: _blockPath,
|
|
1052
|
+
select: true
|
|
1053
|
+
});
|
|
1054
|
+
const serializedBlock$1 = streamSerializeMd(editor, { value: [tempBlocks[0]] }, tempBlockChunks);
|
|
1055
|
+
editor.setOption(AIChatPlugin, "_blockChunks", tempBlocks[0].type === getPluginType(editor, KEYS.codeBlock) || tempBlocks[0].type === getPluginType(editor, KEYS.table) || tempBlocks[0].type === getPluginType(editor, KEYS.equation) ? tempBlockChunks : serializedBlock$1);
|
|
1056
|
+
}
|
|
1057
|
+
} else {
|
|
1058
|
+
const serializedBlock = streamSerializeMd(editor, { value: [tempBlocks[0]] }, tempBlockChunks);
|
|
1059
|
+
editor.tf.replaceNodes(nodesWithProps(editor, [tempBlocks[0]], options), {
|
|
1060
|
+
at: _blockPath,
|
|
1061
|
+
select: true
|
|
1062
|
+
});
|
|
1063
|
+
editor.setOption(AIChatPlugin, "_blockChunks", serializedBlock);
|
|
1064
|
+
}
|
|
1065
|
+
} else {
|
|
1066
|
+
editor.tf.replaceNodes(nodesWithProps(editor, [tempBlocks[0]], options), {
|
|
1067
|
+
at: _blockPath,
|
|
1068
|
+
select: true
|
|
1069
|
+
});
|
|
1070
|
+
if (tempBlocks.length > 1) {
|
|
1071
|
+
const newEndBlockPath = getNextPath(_blockPath, tempBlocks.length - 1);
|
|
1072
|
+
editor.tf.insertNodes(nodesWithProps(editor, tempBlocks.slice(1), options), {
|
|
1073
|
+
at: PathApi.next(_blockPath),
|
|
1074
|
+
select: true
|
|
1075
|
+
});
|
|
1076
|
+
editor.setOption(AIChatPlugin, "_blockPath", newEndBlockPath);
|
|
1077
|
+
const endBlock = editor.api.node(newEndBlockPath)[0];
|
|
1078
|
+
const serializedBlock = streamSerializeMd(editor, { value: [endBlock] }, tempBlockChunks);
|
|
1079
|
+
editor.setOption(AIChatPlugin, "_blockChunks", serializedBlock);
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
const getCurrentBlockPath = (editor) => {
|
|
1085
|
+
const getAnchorPreviousPath = (editor$1) => {
|
|
1086
|
+
const anchorNode = editor$1.getApi(AIChatPlugin).aiChat.node({ anchor: true });
|
|
1087
|
+
if (anchorNode) return PathApi.previous(anchorNode[1]);
|
|
1088
|
+
};
|
|
1089
|
+
const getFocusPath = (editor$1) => editor$1.selection?.focus.path.slice(0, 1);
|
|
1090
|
+
const path = getAnchorPreviousPath(editor) ?? getFocusPath(editor) ?? [0];
|
|
1091
|
+
const entry = editor.api.node(path);
|
|
1092
|
+
if (entry && (entry[0].type === getPluginType(editor, KEYS.columnGroup) || entry[0].type === getPluginType(editor, KEYS.table))) return editor.api.above()?.[1] ?? path;
|
|
1093
|
+
return path;
|
|
1094
|
+
};
|
|
1095
|
+
|
|
1096
|
+
//#endregion
|
|
1097
|
+
//#region src/react/copilot/renderCopilotBelowNodes.tsx
|
|
1098
|
+
const renderCopilotBelowNodes = ({ editor }) => {
|
|
1099
|
+
const { renderGhostText: GhostText } = getEditorPlugin(editor, { key: KEYS.copilot }).getOptions();
|
|
1100
|
+
if (!GhostText) return;
|
|
1101
|
+
return ({ children }) => /* @__PURE__ */ React.createElement(React.Fragment, null, children, /* @__PURE__ */ React.createElement(GhostText, null));
|
|
1102
|
+
};
|
|
1103
|
+
|
|
1104
|
+
//#endregion
|
|
1105
|
+
//#region src/react/copilot/transforms/acceptCopilot.ts
|
|
1106
|
+
const acceptCopilot = (editor) => {
|
|
1107
|
+
const { suggestionText } = editor.getOptions({ key: KEYS.copilot });
|
|
1108
|
+
if (!suggestionText?.length) return false;
|
|
1109
|
+
editor.tf.insertFragment(deserializeInlineMd(editor, suggestionText));
|
|
1110
|
+
};
|
|
1111
|
+
|
|
1112
|
+
//#endregion
|
|
1113
|
+
//#region src/react/copilot/utils/callCompletionApi.ts
|
|
1114
|
+
const getOriginalFetch = () => fetch;
|
|
1115
|
+
async function callCompletionApi({ api = "/api/completion", body, credentials, fetch: fetch$1 = getOriginalFetch(), headers, prompt, setAbortController = () => {}, setCompletion = () => {}, setError = () => {}, setLoading = () => {}, onError, onFinish, onResponse }) {
|
|
1116
|
+
try {
|
|
1117
|
+
setLoading(true);
|
|
1118
|
+
setError(null);
|
|
1119
|
+
const abortController = new AbortController();
|
|
1120
|
+
setAbortController(abortController);
|
|
1121
|
+
setCompletion("");
|
|
1122
|
+
const res = await fetch$1(api, {
|
|
1123
|
+
body: JSON.stringify({
|
|
1124
|
+
prompt,
|
|
1125
|
+
...body
|
|
1126
|
+
}),
|
|
1127
|
+
credentials,
|
|
1128
|
+
headers: {
|
|
1129
|
+
"Content-Type": "application/json",
|
|
1130
|
+
...headers
|
|
1131
|
+
},
|
|
1132
|
+
method: "POST",
|
|
1133
|
+
signal: abortController.signal
|
|
1134
|
+
}).catch((error) => {
|
|
1135
|
+
throw error;
|
|
1136
|
+
});
|
|
1137
|
+
if (onResponse) await onResponse(res);
|
|
1138
|
+
if (!res.ok) throw new Error(await res.text() || "Failed to fetch the chat response.");
|
|
1139
|
+
if (!res.body) throw new Error("The response body is empty.");
|
|
1140
|
+
const { text } = await res.json();
|
|
1141
|
+
if (!text) throw new Error("The response does not contain a text field.");
|
|
1142
|
+
setCompletion(text);
|
|
1143
|
+
if (onFinish) onFinish(prompt, text);
|
|
1144
|
+
setAbortController(null);
|
|
1145
|
+
return text;
|
|
1146
|
+
} catch (error) {
|
|
1147
|
+
if (error.name === "AbortError") {
|
|
1148
|
+
setAbortController(null);
|
|
1149
|
+
return null;
|
|
1150
|
+
}
|
|
1151
|
+
if (error instanceof Error && onError) onError(error);
|
|
1152
|
+
setError(error);
|
|
1153
|
+
} finally {
|
|
1154
|
+
setLoading(false);
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
//#endregion
|
|
1159
|
+
//#region src/react/copilot/utils/getNextWord.ts
|
|
1160
|
+
const nonSpaceRegex = /^\s*(\S)/;
|
|
1161
|
+
const cjkCharRegex = /[\u1100-\u11FF\u3040-\u30FF\u3400-\u4DBF\u4E00-\u9FFF\uAC00-\uD7AF\uF900-\uFAFF]/;
|
|
1162
|
+
const cjkMatchRegex = /^(\s*)([\u1100-\u11FF\u3040-\u30FF\u3400-\u4DBF\u4E00-\u9FFF\uAC00-\uD7AF\uF900-\uFAFF])([\u3000-\u303F\uFF00-\uFFEF])?/;
|
|
1163
|
+
const nonCjkMatchRegex = /^(\s*\S+?)(?=[\s\u1100-\u11FF\u3040-\u30FF\u3400-\u4DBF\u4E00-\u9FFF\uAC00-\uD7AF\uF900-\uFAFF]|$)/;
|
|
1164
|
+
const getNextWord = ({ text }) => {
|
|
1165
|
+
if (!text) return {
|
|
1166
|
+
firstWord: "",
|
|
1167
|
+
remainingText: ""
|
|
1168
|
+
};
|
|
1169
|
+
const nonSpaceMatch = nonSpaceRegex.exec(text);
|
|
1170
|
+
if (!nonSpaceMatch) return {
|
|
1171
|
+
firstWord: "",
|
|
1172
|
+
remainingText: ""
|
|
1173
|
+
};
|
|
1174
|
+
const firstNonSpaceChar = nonSpaceMatch[1];
|
|
1175
|
+
const isCJKChar = cjkCharRegex.test(firstNonSpaceChar);
|
|
1176
|
+
let firstWord;
|
|
1177
|
+
let remainingText;
|
|
1178
|
+
if (isCJKChar) {
|
|
1179
|
+
const match = cjkMatchRegex.exec(text);
|
|
1180
|
+
if (match) {
|
|
1181
|
+
const [, spaces = "", char = "", punctuation = ""] = match;
|
|
1182
|
+
firstWord = spaces + char + punctuation;
|
|
1183
|
+
remainingText = text.slice(firstWord.length);
|
|
1184
|
+
} else {
|
|
1185
|
+
firstWord = "";
|
|
1186
|
+
remainingText = text;
|
|
1187
|
+
}
|
|
1188
|
+
} else {
|
|
1189
|
+
const match = nonCjkMatchRegex.exec(text);
|
|
1190
|
+
if (match) {
|
|
1191
|
+
firstWord = match[0];
|
|
1192
|
+
remainingText = text.slice(firstWord.length);
|
|
1193
|
+
} else {
|
|
1194
|
+
firstWord = text;
|
|
1195
|
+
remainingText = "";
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
return {
|
|
1199
|
+
firstWord,
|
|
1200
|
+
remainingText
|
|
1201
|
+
};
|
|
1202
|
+
};
|
|
1203
|
+
|
|
1204
|
+
//#endregion
|
|
1205
|
+
//#region src/react/copilot/utils/triggerCopilotSuggestion.ts
|
|
1206
|
+
const triggerCopilotSuggestion = async (editor) => {
|
|
1207
|
+
const { api, getOptions, setOption } = getEditorPlugin(editor, { key: KEYS.copilot });
|
|
1208
|
+
const { completeOptions, getPrompt, isLoading, triggerQuery } = getOptions();
|
|
1209
|
+
if (isLoading || editor.getOptions({ key: KEYS.aiChat }).chat?.isLoading) return false;
|
|
1210
|
+
if (!triggerQuery({ editor })) return false;
|
|
1211
|
+
const prompt = getPrompt({ editor });
|
|
1212
|
+
if (prompt.length === 0) return false;
|
|
1213
|
+
api.copilot.stop();
|
|
1214
|
+
await callCompletionApi({
|
|
1215
|
+
prompt,
|
|
1216
|
+
onFinish: (_, completion) => {
|
|
1217
|
+
api.copilot.setBlockSuggestion({ text: completion });
|
|
1218
|
+
},
|
|
1219
|
+
...completeOptions,
|
|
1220
|
+
setAbortController: (controller) => setOption("abortController", controller),
|
|
1221
|
+
setCompletion: (completion) => setOption("completion", completion),
|
|
1222
|
+
setError: (error) => setOption("error", error),
|
|
1223
|
+
setLoading: (loading) => setOption("isLoading", loading),
|
|
1224
|
+
onError: (error) => {
|
|
1225
|
+
setOption("error", error);
|
|
1226
|
+
completeOptions?.onError?.(error);
|
|
1227
|
+
}
|
|
1228
|
+
});
|
|
1229
|
+
};
|
|
1230
|
+
|
|
1231
|
+
//#endregion
|
|
1232
|
+
//#region src/react/copilot/utils/withoutAbort.ts
|
|
1233
|
+
const withoutAbort = (editor, fn) => {
|
|
1234
|
+
editor.setOption(CopilotPlugin, "shouldAbort", false);
|
|
1235
|
+
fn();
|
|
1236
|
+
editor.setOption(CopilotPlugin, "shouldAbort", true);
|
|
1237
|
+
};
|
|
1238
|
+
|
|
1239
|
+
//#endregion
|
|
1240
|
+
//#region src/react/copilot/transforms/acceptCopilotNextWord.ts
|
|
1241
|
+
const acceptCopilotNextWord = (editor) => {
|
|
1242
|
+
const { api, getOptions } = getEditorPlugin(editor, { key: KEYS.copilot });
|
|
1243
|
+
const { getNextWord: getNextWord$1, suggestionText } = getOptions();
|
|
1244
|
+
if (!suggestionText?.length) return false;
|
|
1245
|
+
const { firstWord, remainingText } = getNextWord$1({ text: suggestionText });
|
|
1246
|
+
api.copilot.setBlockSuggestion({ text: remainingText });
|
|
1247
|
+
withoutAbort(editor, () => {
|
|
1248
|
+
editor.tf.insertFragment(deserializeInlineMd(editor, firstWord));
|
|
1249
|
+
});
|
|
1250
|
+
};
|
|
1251
|
+
|
|
1252
|
+
//#endregion
|
|
1253
|
+
//#region src/react/copilot/withCopilot.ts
|
|
1254
|
+
const getPatchString = (editor, operations) => {
|
|
1255
|
+
let string = "";
|
|
1256
|
+
for (const operation of operations) if (operation.type === "insert_node") {
|
|
1257
|
+
const node = operation.node;
|
|
1258
|
+
const text = serializeInlineMd(editor, { value: [node] });
|
|
1259
|
+
string += text;
|
|
1260
|
+
} else if (operation.type === "insert_text") string += operation.text;
|
|
1261
|
+
return string;
|
|
1262
|
+
};
|
|
1263
|
+
const withCopilot = ({ api, editor, getOptions, setOption, tf: { apply, insertText, redo, setSelection, undo, writeHistory } }) => {
|
|
1264
|
+
let prevSelection = null;
|
|
1265
|
+
return { transforms: {
|
|
1266
|
+
apply(operation) {
|
|
1267
|
+
const { shouldAbort } = getOptions();
|
|
1268
|
+
if (shouldAbort) api.copilot.reject();
|
|
1269
|
+
apply(operation);
|
|
1270
|
+
},
|
|
1271
|
+
insertText(text, options) {
|
|
1272
|
+
const suggestionText = getOptions().suggestionText;
|
|
1273
|
+
if (suggestionText?.startsWith(text)) {
|
|
1274
|
+
withoutAbort(editor, () => {
|
|
1275
|
+
editor.tf.withoutMerging(() => {
|
|
1276
|
+
const newText = suggestionText?.slice(text.length);
|
|
1277
|
+
setOption("suggestionText", newText);
|
|
1278
|
+
insertText(text);
|
|
1279
|
+
});
|
|
1280
|
+
});
|
|
1281
|
+
return;
|
|
1282
|
+
}
|
|
1283
|
+
insertText(text, options);
|
|
1284
|
+
},
|
|
1285
|
+
redo() {
|
|
1286
|
+
if (!getOptions().suggestionText) return redo();
|
|
1287
|
+
const topRedo = editor.history.redos.at(-1);
|
|
1288
|
+
const prevSuggestion = getOptions().suggestionText;
|
|
1289
|
+
if (topRedo && topRedo.shouldAbort === false && prevSuggestion) {
|
|
1290
|
+
withoutAbort(editor, () => {
|
|
1291
|
+
const shouldRemoveText = getPatchString(editor, topRedo.operations);
|
|
1292
|
+
setOption("suggestionText", prevSuggestion.slice(shouldRemoveText.length));
|
|
1293
|
+
redo();
|
|
1294
|
+
});
|
|
1295
|
+
return;
|
|
1296
|
+
}
|
|
1297
|
+
return redo();
|
|
1298
|
+
},
|
|
1299
|
+
setSelection(props) {
|
|
1300
|
+
setSelection(props);
|
|
1301
|
+
if (editor.selection && (!prevSelection || !RangeApi.equals(prevSelection, editor.selection)) && getOptions().autoTriggerQuery({ editor }) && editor.api.isFocused()) api.copilot.triggerSuggestion();
|
|
1302
|
+
prevSelection = editor.selection;
|
|
1303
|
+
},
|
|
1304
|
+
undo() {
|
|
1305
|
+
if (!getOptions().suggestionText) return undo();
|
|
1306
|
+
const lastUndos = editor.history.undos.at(-1);
|
|
1307
|
+
const oldText = getOptions().suggestionText;
|
|
1308
|
+
if (lastUndos && lastUndos.shouldAbort === false && oldText) {
|
|
1309
|
+
withoutAbort(editor, () => {
|
|
1310
|
+
setOption("suggestionText", getPatchString(editor, lastUndos.operations) + oldText);
|
|
1311
|
+
undo();
|
|
1312
|
+
});
|
|
1313
|
+
return;
|
|
1314
|
+
}
|
|
1315
|
+
return undo();
|
|
1316
|
+
},
|
|
1317
|
+
writeHistory(stacks, batch) {
|
|
1318
|
+
if (!getOptions().isLoading) batch.shouldAbort = getOptions().shouldAbort;
|
|
1319
|
+
return writeHistory(stacks, batch);
|
|
1320
|
+
}
|
|
1321
|
+
} };
|
|
1322
|
+
};
|
|
1323
|
+
|
|
1324
|
+
//#endregion
|
|
1325
|
+
//#region src/react/copilot/CopilotPlugin.tsx
|
|
1326
|
+
const CopilotPlugin = createTPlatePlugin({
|
|
1327
|
+
key: KEYS.copilot,
|
|
1328
|
+
handlers: {
|
|
1329
|
+
onBlur: ({ api }) => {
|
|
1330
|
+
api.copilot.reject();
|
|
1331
|
+
},
|
|
1332
|
+
onMouseDown: ({ api }) => {
|
|
1333
|
+
api.copilot.reject();
|
|
1334
|
+
}
|
|
1335
|
+
},
|
|
1336
|
+
options: {
|
|
1337
|
+
abortController: null,
|
|
1338
|
+
completeOptions: {},
|
|
1339
|
+
completion: "",
|
|
1340
|
+
debounceDelay: 0,
|
|
1341
|
+
error: null,
|
|
1342
|
+
getNextWord,
|
|
1343
|
+
isLoading: false,
|
|
1344
|
+
renderGhostText: null,
|
|
1345
|
+
shouldAbort: true,
|
|
1346
|
+
suggestionNodeId: null,
|
|
1347
|
+
suggestionText: null,
|
|
1348
|
+
autoTriggerQuery: ({ editor }) => {
|
|
1349
|
+
if (editor.getOptions({ key: KEYS.copilot }).suggestionText) return false;
|
|
1350
|
+
if (editor.api.isEmpty(editor.selection, { block: true })) return false;
|
|
1351
|
+
const blockAbove = editor.api.block();
|
|
1352
|
+
if (!blockAbove) return false;
|
|
1353
|
+
return NodeApi.string(blockAbove[0]).at(-1) === " ";
|
|
1354
|
+
},
|
|
1355
|
+
getPrompt: ({ editor }) => {
|
|
1356
|
+
const contextEntry = editor.api.block({ highest: true });
|
|
1357
|
+
if (!contextEntry) return "";
|
|
1358
|
+
return serializeMd(editor, { value: [contextEntry[0]] });
|
|
1359
|
+
},
|
|
1360
|
+
triggerQuery: ({ editor }) => {
|
|
1361
|
+
if (editor.api.isExpanded()) return false;
|
|
1362
|
+
if (!editor.api.isAt({ end: true })) return false;
|
|
1363
|
+
return true;
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
}).overrideEditor(withCopilot).extendSelectors(({ getOptions }) => ({ isSuggested: (id) => getOptions().suggestionNodeId === id })).extendTransforms(({ editor }) => ({
|
|
1367
|
+
accept: bindFirst(acceptCopilot, editor),
|
|
1368
|
+
acceptNextWord: bindFirst(acceptCopilotNextWord, editor)
|
|
1369
|
+
})).extendApi(({ api, editor, getOptions, setOption, setOptions }) => {
|
|
1370
|
+
const debounceDelay = getOptions().debounceDelay;
|
|
1371
|
+
let triggerSuggestion = bindFirst(triggerCopilotSuggestion, editor);
|
|
1372
|
+
if (debounceDelay) triggerSuggestion = debounce(bindFirst(triggerCopilotSuggestion, editor), debounceDelay);
|
|
1373
|
+
return {
|
|
1374
|
+
triggerSuggestion,
|
|
1375
|
+
setBlockSuggestion: ({ id = getOptions().suggestionNodeId, text }) => {
|
|
1376
|
+
if (!id) id = editor.api.block()[0].id;
|
|
1377
|
+
setOptions({
|
|
1378
|
+
suggestionNodeId: id,
|
|
1379
|
+
suggestionText: text
|
|
1380
|
+
});
|
|
1381
|
+
},
|
|
1382
|
+
stop: () => {
|
|
1383
|
+
const { abortController } = getOptions();
|
|
1384
|
+
api.copilot.triggerSuggestion?.cancel();
|
|
1385
|
+
if (abortController) {
|
|
1386
|
+
abortController.abort();
|
|
1387
|
+
setOption("abortController", null);
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
};
|
|
1391
|
+
}).extendApi(({ api, getOptions, setOptions }) => ({ reject: () => {
|
|
1392
|
+
if (!getOptions().suggestionText?.length) return false;
|
|
1393
|
+
api.copilot.stop();
|
|
1394
|
+
setOptions({
|
|
1395
|
+
completion: null,
|
|
1396
|
+
suggestionNodeId: null,
|
|
1397
|
+
suggestionText: null
|
|
1398
|
+
});
|
|
1399
|
+
} })).extend({
|
|
1400
|
+
render: { belowNodes: renderCopilotBelowNodes },
|
|
1401
|
+
shortcuts: {
|
|
1402
|
+
accept: { keys: "tab" },
|
|
1403
|
+
reject: { keys: "escape" }
|
|
1404
|
+
}
|
|
1405
|
+
});
|
|
1406
|
+
|
|
1407
|
+
//#endregion
|
|
1408
|
+
export { AIChatPlugin, AIPlugin, CopilotPlugin, acceptAIChat, acceptAISuggestions, acceptCopilot, acceptCopilotNextWord, aiCommentToRange, applyAISuggestions, applyTableCellSuggestion, callCompletionApi, createFormattedBlocks, escapeInput, findTextRangeInBlock, getChunkTrimmed, getCurrentBlockPath, getLastAssistantMessage, getListNode, getNextWord, getTableCellChildren, insertBelowAIChat, insertBelowGenerate, isCompleteCodeBlock, isCompleteMath, isSameNode, isSingleCellTable, nodesWithProps, rejectAISuggestions, removeAnchorAIChat, renderCopilotBelowNodes, replaceSelectionAIChat, resetAIChat, streamDeserializeInlineMd, streamDeserializeMd, streamInsertChunk, streamSerializeMd, submitAIChat, triggerCopilotSuggestion, useAIChatEditor, useChatChunk, useEditorChat, useLastAssistantMessage, withAIChat, withCopilot, withTransient, withoutAbort, withoutSuggestionAndComments };
|
|
1409
|
+
//# sourceMappingURL=index.js.map
|