@atlaskit/editor-plugin-autocomplete 3.3.0 → 3.4.0
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/CHANGELOG.md +7 -0
- package/dist/cjs/pm-plugins/autocomplete-plugin.js +162 -22
- package/dist/cjs/pm-plugins/local-slow-lane-client.js +67 -4
- package/dist/es2019/pm-plugins/autocomplete-plugin.js +148 -22
- package/dist/es2019/pm-plugins/local-slow-lane-client.js +66 -3
- package/dist/esm/pm-plugins/autocomplete-plugin.js +162 -22
- package/dist/esm/pm-plugins/local-slow-lane-client.js +67 -4
- package/package.json +2 -2
- package/src/pm-plugins/autocomplete-plugin.ts +170 -25
- package/src/pm-plugins/local-slow-lane-client.ts +72 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# @atlaskit/editor-plugin-autocomplete
|
|
2
2
|
|
|
3
|
+
## 3.4.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [`b6fb60030b3b9`](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/commits/b6fb60030b3b9) -
|
|
8
|
+
Improve local autocomplete scheduling latency
|
|
9
|
+
|
|
3
10
|
## 3.3.0
|
|
4
11
|
|
|
5
12
|
### Minor Changes
|
|
@@ -6,20 +6,38 @@ Object.defineProperty(exports, "__esModule", {
|
|
|
6
6
|
});
|
|
7
7
|
exports.createAutocompletePlugin = exports.autocompletePluginKey = void 0;
|
|
8
8
|
var _defineProperty2 = _interopRequireDefault(require("@babel/runtime/helpers/defineProperty"));
|
|
9
|
+
var _slicedToArray2 = _interopRequireDefault(require("@babel/runtime/helpers/slicedToArray"));
|
|
9
10
|
var _analytics = require("@atlaskit/editor-common/analytics");
|
|
10
11
|
var _monitoring = require("@atlaskit/editor-common/monitoring");
|
|
11
12
|
var _safePlugin = require("@atlaskit/editor-common/safe-plugin");
|
|
12
13
|
var _keymap = require("@atlaskit/editor-prosemirror/keymap");
|
|
13
14
|
var _state = require("@atlaskit/editor-prosemirror/state");
|
|
14
15
|
var _view = require("@atlaskit/editor-prosemirror/view");
|
|
16
|
+
var _debugMode = require("./debug-mode");
|
|
15
17
|
var _ghostTextDecoration = require("./ghost-text-decoration");
|
|
16
18
|
var _localSlowLaneClient = require("./local-slow-lane-client");
|
|
17
19
|
var _slowLaneClient = require("./slow-lane-client");
|
|
18
20
|
var _textPredictor = require("./text-predictor");
|
|
21
|
+
function _createForOfIteratorHelper(r, e) { var t = "undefined" != typeof Symbol && r[Symbol.iterator] || r["@@iterator"]; if (!t) { if (Array.isArray(r) || (t = _unsupportedIterableToArray(r)) || e && r && "number" == typeof r.length) { t && (r = t); var _n = 0, F = function F() {}; return { s: F, n: function n() { return _n >= r.length ? { done: !0 } : { done: !1, value: r[_n++] }; }, e: function e(r) { throw r; }, f: F }; } throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } var o, a = !0, u = !1; return { s: function s() { t = t.call(r); }, n: function n() { var r = t.next(); return a = r.done, r; }, e: function e(r) { u = !0, o = r; }, f: function f() { try { a || null == t.return || t.return(); } finally { if (u) throw o; } } }; }
|
|
22
|
+
function _unsupportedIterableToArray(r, a) { if (r) { if ("string" == typeof r) return _arrayLikeToArray(r, a); var t = {}.toString.call(r).slice(8, -1); return "Object" === t && r.constructor && (t = r.constructor.name), "Map" === t || "Set" === t ? Array.from(r) : "Arguments" === t || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(t) ? _arrayLikeToArray(r, a) : void 0; } }
|
|
23
|
+
function _arrayLikeToArray(r, a) { (null == a || a > r.length) && (a = r.length); for (var e = 0, n = Array(a); e < a; e++) n[e] = r[e]; return n; }
|
|
19
24
|
function ownKeys(e, r) { var t = Object.keys(e); if (Object.getOwnPropertySymbols) { var o = Object.getOwnPropertySymbols(e); r && (o = o.filter(function (r) { return Object.getOwnPropertyDescriptor(e, r).enumerable; })), t.push.apply(t, o); } return t; }
|
|
20
25
|
function _objectSpread(e) { for (var r = 1; r < arguments.length; r++) { var t = null != arguments[r] ? arguments[r] : {}; r % 2 ? ownKeys(Object(t), !0).forEach(function (r) { (0, _defineProperty2.default)(e, r, t[r]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) { Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r)); }); } return e; }
|
|
21
26
|
var autocompletePluginKey = exports.autocompletePluginKey = new _state.PluginKey('autocomplete');
|
|
22
27
|
var DEBOUNCE_MS = 150;
|
|
28
|
+
var NETWORK_SLOW_LANE_DEBOUNCE_MS = 300;
|
|
29
|
+
var LOCAL_SLOW_LANE_DEBOUNCE_MS = 100;
|
|
30
|
+
var CONTEXT_REFRESH_THROTTLE_MS = 1000;
|
|
31
|
+
// Caps the dedup Set so long editing sessions don't retain every distinct
|
|
32
|
+
// version of the (potentially hundreds-of-KB) page content for the plugin's
|
|
33
|
+
// lifetime. Eviction is FIFO; a re-ingest of an evicted text is harmless.
|
|
34
|
+
var MAX_INGESTED_CONTEXT_TEXTS = 50;
|
|
35
|
+
// Caps how many times the word-boundary path will retry getContext() while the
|
|
36
|
+
// parent comment is still missing. Combined with the 1s throttle this gives a
|
|
37
|
+
// ~5s window to cover a still-loading comment thread, then stops permanently so
|
|
38
|
+
// non-comment editors (where parentCommentContent never arrives) don't refetch
|
|
39
|
+
// on every word boundary for the plugin's lifetime.
|
|
40
|
+
var MAX_CONTEXT_REFRESH_ATTEMPTS = 5;
|
|
23
41
|
var hasDestroy = function hasDestroy(client) {
|
|
24
42
|
return 'destroy' in client && typeof client.destroy === 'function';
|
|
25
43
|
};
|
|
@@ -184,6 +202,12 @@ var createAutocompletePlugin = exports.createAutocompletePlugin = function creat
|
|
|
184
202
|
var debounceTimer = null;
|
|
185
203
|
var hasIngestedPage = false;
|
|
186
204
|
var resolvedContext;
|
|
205
|
+
/**
|
|
206
|
+
* Kept in sync with the live EditorView so the async getContext() promise
|
|
207
|
+
* can re-trigger a slow-lane update the moment context arrives, even if the
|
|
208
|
+
* user has already typed several words before the promise resolved.
|
|
209
|
+
*/
|
|
210
|
+
var currentView = null;
|
|
187
211
|
/**
|
|
188
212
|
* Set after accepting a suggestion so the next doc-change update
|
|
189
213
|
* skips scheduling a new prediction for the just-inserted text.
|
|
@@ -227,12 +251,125 @@ var createAutocompletePlugin = exports.createAutocompletePlugin = function creat
|
|
|
227
251
|
});
|
|
228
252
|
};
|
|
229
253
|
var slowLaneClient = options !== null && options !== void 0 && options.useLocalModel ? (0, _localSlowLaneClient.createLocalSlowLaneClient)({
|
|
230
|
-
debounceMs:
|
|
254
|
+
debounceMs: LOCAL_SLOW_LANE_DEBOUNCE_MS
|
|
231
255
|
}) : (0, _slowLaneClient.createSlowLaneClient)({
|
|
232
256
|
baseUrl: '',
|
|
233
|
-
debounceMs:
|
|
257
|
+
debounceMs: NETWORK_SLOW_LANE_DEBOUNCE_MS
|
|
234
258
|
});
|
|
235
259
|
(0, _slowLaneClient.setDefaultSlowLaneClient)(slowLaneClient);
|
|
260
|
+
var contextRequestInFlight = false;
|
|
261
|
+
var lastContextRefreshAt = 0;
|
|
262
|
+
// Bounds the word-boundary retry loop so it terminates even when the editor is
|
|
263
|
+
// not in a comment thread (parentCommentContent never resolves).
|
|
264
|
+
var wordBoundaryRefreshAttempts = 0;
|
|
265
|
+
// Set when the plugin is torn down so in-flight getContext() resolutions don't
|
|
266
|
+
// mutate the global text-predictor state after destruction.
|
|
267
|
+
var destroyed = false;
|
|
268
|
+
var ingestedContextTexts = new Set();
|
|
269
|
+
var logContextResolved = function logContextResolved(source, context) {
|
|
270
|
+
var _context$parentCommen, _context$siblingComme4, _context$siblingComme5;
|
|
271
|
+
if (!(0, _debugMode.isAutocompleteDebugEnabled)()) {
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// eslint-disable-next-line no-console
|
|
276
|
+
console.log('%c[Autocomplete] %cgetContext resolved', 'color: #00b8d9; font-weight: bold;', 'color: inherit;', {
|
|
277
|
+
source: source,
|
|
278
|
+
hasParentComment: !!(context !== null && context !== void 0 && context.parentCommentContent),
|
|
279
|
+
parentCommentPreview: context === null || context === void 0 || (_context$parentCommen = context.parentCommentContent) === null || _context$parentCommen === void 0 ? void 0 : _context$parentCommen.slice(0, 80),
|
|
280
|
+
siblingCount: (_context$siblingComme4 = context === null || context === void 0 || (_context$siblingComme5 = context.siblingCommentsContents) === null || _context$siblingComme5 === void 0 ? void 0 : _context$siblingComme5.length) !== null && _context$siblingComme4 !== void 0 ? _context$siblingComme4 : 0,
|
|
281
|
+
hasFullPage: !!(context !== null && context !== void 0 && context.fullPageContent)
|
|
282
|
+
});
|
|
283
|
+
};
|
|
284
|
+
var applyContext = function applyContext(context) {
|
|
285
|
+
var _context$siblingComme6;
|
|
286
|
+
// Merge rather than replace: the word-boundary retry may resolve only a
|
|
287
|
+
// late-arriving field (e.g. parentCommentContent) without re-sending
|
|
288
|
+
// fullPageContent, so replacing would drop previously resolved context.
|
|
289
|
+
// Strip undefined values first so a field explicitly set to undefined by
|
|
290
|
+
// getContext doesn't overwrite a previously resolved value.
|
|
291
|
+
var definedContext = Object.fromEntries(Object.entries(context).filter(function (_ref) {
|
|
292
|
+
var _ref2 = (0, _slicedToArray2.default)(_ref, 2),
|
|
293
|
+
value = _ref2[1];
|
|
294
|
+
return value !== undefined;
|
|
295
|
+
}));
|
|
296
|
+
resolvedContext = _objectSpread(_objectSpread({}, resolvedContext), definedContext);
|
|
297
|
+
var ingestContextText = function ingestContextText(text) {
|
|
298
|
+
if (!text || ingestedContextTexts.has(text)) {
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
ingestedContextTexts.add(text);
|
|
302
|
+
// Evict oldest entries (Set preserves insertion order) to bound memory.
|
|
303
|
+
while (ingestedContextTexts.size > MAX_INGESTED_CONTEXT_TEXTS) {
|
|
304
|
+
var oldest = ingestedContextTexts.values().next().value;
|
|
305
|
+
if (oldest === undefined) {
|
|
306
|
+
break;
|
|
307
|
+
}
|
|
308
|
+
ingestedContextTexts.delete(oldest);
|
|
309
|
+
}
|
|
310
|
+
(0, _textPredictor.ingestDocumentPage)(text);
|
|
311
|
+
};
|
|
312
|
+
ingestContextText(context.fullPageContent);
|
|
313
|
+
ingestContextText(context.parentCommentContent);
|
|
314
|
+
var _iterator = _createForOfIteratorHelper((_context$siblingComme6 = context.siblingCommentsContents) !== null && _context$siblingComme6 !== void 0 ? _context$siblingComme6 : []),
|
|
315
|
+
_step;
|
|
316
|
+
try {
|
|
317
|
+
for (_iterator.s(); !(_step = _iterator.n()).done;) {
|
|
318
|
+
var siblingCommentContent = _step.value;
|
|
319
|
+
ingestContextText(siblingCommentContent);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Context arrived after word boundaries may already have fired. Re-send
|
|
323
|
+
// slow-lane context immediately so the next inference includes the thread.
|
|
324
|
+
} catch (err) {
|
|
325
|
+
_iterator.e(err);
|
|
326
|
+
} finally {
|
|
327
|
+
_iterator.f();
|
|
328
|
+
}
|
|
329
|
+
if (currentView) {
|
|
330
|
+
slowLaneClient.updateContext(buildSlowLaneText(currentView.state.doc.textContent, resolvedContext));
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Returns true when a fetch was actually started, false when it was skipped
|
|
336
|
+
* (no getContext, a request already in flight, or throttled). Callers that
|
|
337
|
+
* track a retry budget should only count attempts where this returns true.
|
|
338
|
+
*/
|
|
339
|
+
var refreshContext = function refreshContext(_ref3) {
|
|
340
|
+
var source = _ref3.source,
|
|
341
|
+
_ref3$allowThrottle = _ref3.allowThrottle,
|
|
342
|
+
allowThrottle = _ref3$allowThrottle === void 0 ? true : _ref3$allowThrottle;
|
|
343
|
+
if (!(options !== null && options !== void 0 && options.getContext) || contextRequestInFlight) {
|
|
344
|
+
return false;
|
|
345
|
+
}
|
|
346
|
+
var now = Date.now();
|
|
347
|
+
if (allowThrottle && now - lastContextRefreshAt < CONTEXT_REFRESH_THROTTLE_MS) {
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
contextRequestInFlight = true;
|
|
351
|
+
lastContextRefreshAt = now;
|
|
352
|
+
options.getContext().then(function (context) {
|
|
353
|
+
// Bail if the plugin was destroyed while the fetch was in flight —
|
|
354
|
+
// applyContext mutates global text-predictor state we must not touch
|
|
355
|
+
// after teardown.
|
|
356
|
+
if (destroyed) {
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
logContextResolved(source, context);
|
|
360
|
+
if (!context) {
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
applyContext(context);
|
|
364
|
+
}).catch(function (error) {
|
|
365
|
+
(0, _monitoring.logException)(error, {
|
|
366
|
+
location: 'editor-plugin-autocomplete/getContext'
|
|
367
|
+
});
|
|
368
|
+
}).finally(function () {
|
|
369
|
+
contextRequestInFlight = false;
|
|
370
|
+
});
|
|
371
|
+
return true;
|
|
372
|
+
};
|
|
236
373
|
|
|
237
374
|
/**
|
|
238
375
|
* Schedule a prediction after a short debounce.
|
|
@@ -404,26 +541,10 @@ var createAutocompletePlugin = exports.createAutocompletePlugin = function creat
|
|
|
404
541
|
});
|
|
405
542
|
if (!hasIngestedPage) {
|
|
406
543
|
hasIngestedPage = true;
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
return;
|
|
412
|
-
}
|
|
413
|
-
resolvedContext = context;
|
|
414
|
-
if (context.fullPageContent) {
|
|
415
|
-
(0, _textPredictor.ingestDocumentPage)(context.fullPageContent);
|
|
416
|
-
}
|
|
417
|
-
if (context.parentCommentContent) {
|
|
418
|
-
(0, _textPredictor.ingestDocumentPage)(context.parentCommentContent);
|
|
419
|
-
}
|
|
420
|
-
(_context$siblingComme4 = context.siblingCommentsContents) === null || _context$siblingComme4 === void 0 || _context$siblingComme4.forEach(_textPredictor.ingestDocumentPage);
|
|
421
|
-
}).catch(function (error) {
|
|
422
|
-
(0, _monitoring.logException)(error, {
|
|
423
|
-
location: 'editor-plugin-autocomplete/getContext'
|
|
424
|
-
});
|
|
425
|
-
});
|
|
426
|
-
}
|
|
544
|
+
refreshContext({
|
|
545
|
+
source: 'focus',
|
|
546
|
+
allowThrottle: false
|
|
547
|
+
});
|
|
427
548
|
}
|
|
428
549
|
return false;
|
|
429
550
|
}
|
|
@@ -432,6 +553,7 @@ var createAutocompletePlugin = exports.createAutocompletePlugin = function creat
|
|
|
432
553
|
view: function view() {
|
|
433
554
|
return {
|
|
434
555
|
update: function update(view, prevState) {
|
|
556
|
+
currentView = view;
|
|
435
557
|
if (!prevState.doc.eq(view.state.doc)) {
|
|
436
558
|
if (justAccepted) {
|
|
437
559
|
justAccepted = false;
|
|
@@ -447,18 +569,36 @@ var createAutocompletePlugin = exports.createAutocompletePlugin = function creat
|
|
|
447
569
|
maybeUpdateSessionFrequency(view, prevState);
|
|
448
570
|
var textBefore = getTextBeforeCursor(view.state);
|
|
449
571
|
if ((0, _slowLaneClient.isWordBoundary)(textBefore)) {
|
|
572
|
+
var _resolvedContext;
|
|
450
573
|
slowLaneClient.updateContext(buildSlowLaneText(view.state.doc.textContent, resolvedContext));
|
|
574
|
+
|
|
575
|
+
// Context may not have resolved on first focus (e.g. comment
|
|
576
|
+
// thread still loading). Retry on word boundaries until we have
|
|
577
|
+
// the parent comment, throttled so we don't refetch constantly
|
|
578
|
+
// and capped so non-comment editors stop retrying entirely.
|
|
579
|
+
if (!((_resolvedContext = resolvedContext) !== null && _resolvedContext !== void 0 && _resolvedContext.parentCommentContent) && wordBoundaryRefreshAttempts < MAX_CONTEXT_REFRESH_ATTEMPTS) {
|
|
580
|
+
// Only count the attempt when a fetch actually started, so an
|
|
581
|
+
// in-flight or throttled no-op doesn't burn the retry budget.
|
|
582
|
+
if (refreshContext({
|
|
583
|
+
source: 'word-boundary'
|
|
584
|
+
})) {
|
|
585
|
+
wordBoundaryRefreshAttempts++;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
451
588
|
}
|
|
452
589
|
schedulePrediction(view);
|
|
453
590
|
}
|
|
454
591
|
},
|
|
455
592
|
destroy: function destroy() {
|
|
593
|
+
destroyed = true;
|
|
594
|
+
currentView = null;
|
|
456
595
|
if (debounceTimer) {
|
|
457
596
|
clearTimeout(debounceTimer);
|
|
458
597
|
}
|
|
459
598
|
if (hasDestroy(slowLaneClient)) {
|
|
460
599
|
slowLaneClient.destroy();
|
|
461
600
|
}
|
|
601
|
+
ingestedContextTexts.clear();
|
|
462
602
|
(0, _slowLaneClient.setDefaultSlowLaneClient)(null);
|
|
463
603
|
}
|
|
464
604
|
};
|
|
@@ -622,6 +622,14 @@ var createLocalSlowLaneClient = exports.createLocalSlowLaneClient = function cre
|
|
|
622
622
|
var lastRequestedText = '';
|
|
623
623
|
var requestCounter = 0;
|
|
624
624
|
var latestRequestId = -1;
|
|
625
|
+
var inferenceInFlight = false;
|
|
626
|
+
var activeInferenceText = null;
|
|
627
|
+
// The requestId of the inference currently in flight. Tracked so the
|
|
628
|
+
// in-flight dedup path can restore `latestRequestId` to it — otherwise an
|
|
629
|
+
// intermediate keystroke that bumped `latestRequestId` would cause the
|
|
630
|
+
// in-flight (still-current) result to be discarded as stale.
|
|
631
|
+
var activeInferenceRequestId = -1;
|
|
632
|
+
var pendingInference = null;
|
|
625
633
|
var ready = false;
|
|
626
634
|
var destroyed = false;
|
|
627
635
|
var initFailed = false;
|
|
@@ -902,7 +910,7 @@ var createLocalSlowLaneClient = exports.createLocalSlowLaneClient = function cre
|
|
|
902
910
|
case 5:
|
|
903
911
|
_context3.prev = 5;
|
|
904
912
|
_t2 = _context3["catch"](2);
|
|
905
|
-
if (!(requestId < latestRequestId)) {
|
|
913
|
+
if (!(requestId < latestRequestId || destroyed)) {
|
|
906
914
|
_context3.next = 6;
|
|
907
915
|
break;
|
|
908
916
|
}
|
|
@@ -933,10 +941,48 @@ var createLocalSlowLaneClient = exports.createLocalSlowLaneClient = function cre
|
|
|
933
941
|
|
|
934
942
|
// ── Context update (debounced) ─────────────────────────────────────────
|
|
935
943
|
|
|
944
|
+
var _startInference = function startInference(text, requestId) {
|
|
945
|
+
// Self-contained guard: never start a new inference cycle after teardown,
|
|
946
|
+
// regardless of caller discipline.
|
|
947
|
+
if (destroyed) {
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
inferenceInFlight = true;
|
|
951
|
+
activeInferenceText = text;
|
|
952
|
+
activeInferenceRequestId = requestId;
|
|
953
|
+
void ensureEngineInitialized().then(function () {
|
|
954
|
+
return runInference(text, requestId);
|
|
955
|
+
}).catch(function () {}).finally(function () {
|
|
956
|
+
inferenceInFlight = false;
|
|
957
|
+
activeInferenceText = null;
|
|
958
|
+
activeInferenceRequestId = -1;
|
|
959
|
+
var next = pendingInference;
|
|
960
|
+
pendingInference = null;
|
|
961
|
+
if (next && !destroyed) {
|
|
962
|
+
_startInference(next.text, next.requestId);
|
|
963
|
+
}
|
|
964
|
+
});
|
|
965
|
+
};
|
|
936
966
|
var doUpdateContext = function doUpdateContext(text) {
|
|
967
|
+
var _pendingInference;
|
|
937
968
|
if (destroyed || !text || text.trim().length === 0) {
|
|
938
969
|
return;
|
|
939
970
|
}
|
|
971
|
+
if (inferenceInFlight && text === activeInferenceText) {
|
|
972
|
+
// The latest desired text already matches the in-flight inference, so
|
|
973
|
+
// re-running it would be wasted work. But an intermediate keystroke may
|
|
974
|
+
// have bumped `latestRequestId` past the in-flight request (and then been
|
|
975
|
+
// coalesced away), which would cause runInference to discard the
|
|
976
|
+
// still-current result as stale. Pin `latestRequestId` back to the active
|
|
977
|
+
// request so its result is accepted, and drop any now-superseded pending
|
|
978
|
+
// request.
|
|
979
|
+
latestRequestId = activeInferenceRequestId;
|
|
980
|
+
pendingInference = null;
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
if (inferenceInFlight && ((_pendingInference = pendingInference) === null || _pendingInference === void 0 ? void 0 : _pendingInference.text) === text) {
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
940
986
|
var requestId = ++requestCounter;
|
|
941
987
|
latestRequestId = requestId;
|
|
942
988
|
if ((0, _debugMode.isAutocompleteDebugEnabled)()) {
|
|
@@ -950,14 +996,27 @@ var createLocalSlowLaneClient = exports.createLocalSlowLaneClient = function cre
|
|
|
950
996
|
// eslint-disable-next-line no-console
|
|
951
997
|
console.groupEnd();
|
|
952
998
|
}
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
999
|
+
if (inferenceInFlight) {
|
|
1000
|
+
pendingInference = {
|
|
1001
|
+
text: text,
|
|
1002
|
+
requestId: requestId
|
|
1003
|
+
};
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
1006
|
+
_startInference(text, requestId);
|
|
956
1007
|
};
|
|
957
1008
|
var updateContextDebounced = function updateContextDebounced(text) {
|
|
958
1009
|
if (debounceTimer) {
|
|
959
1010
|
clearTimeout(debounceTimer);
|
|
960
1011
|
}
|
|
1012
|
+
if (inferenceInFlight) {
|
|
1013
|
+
pendingInference = null;
|
|
1014
|
+
if (text === activeInferenceText) {
|
|
1015
|
+
latestRequestId = activeInferenceRequestId;
|
|
1016
|
+
lastRequestedText = text;
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
961
1020
|
lastRequestedText = text;
|
|
962
1021
|
debounceTimer = setTimeout(function () {
|
|
963
1022
|
debounceTimer = null;
|
|
@@ -996,6 +1055,10 @@ var createLocalSlowLaneClient = exports.createLocalSlowLaneClient = function cre
|
|
|
996
1055
|
unloadEngine(engineToUnload);
|
|
997
1056
|
}
|
|
998
1057
|
engineInitPromise = null;
|
|
1058
|
+
inferenceInFlight = false;
|
|
1059
|
+
activeInferenceText = null;
|
|
1060
|
+
activeInferenceRequestId = -1;
|
|
1061
|
+
pendingInference = null;
|
|
999
1062
|
storedContextVector = null;
|
|
1000
1063
|
storedLmLogits = null;
|
|
1001
1064
|
}
|
|
@@ -4,12 +4,26 @@ import { SafePlugin } from '@atlaskit/editor-common/safe-plugin';
|
|
|
4
4
|
import { keydownHandler } from '@atlaskit/editor-prosemirror/keymap';
|
|
5
5
|
import { PluginKey } from '@atlaskit/editor-prosemirror/state';
|
|
6
6
|
import { DecorationSet } from '@atlaskit/editor-prosemirror/view';
|
|
7
|
+
import { isAutocompleteDebugEnabled } from './debug-mode';
|
|
7
8
|
import { createGhostTextDecorationSet } from './ghost-text-decoration';
|
|
8
9
|
import { createLocalSlowLaneClient } from './local-slow-lane-client';
|
|
9
10
|
import { createSlowLaneClient, setDefaultSlowLaneClient, isWordBoundary } from './slow-lane-client';
|
|
10
11
|
import { predict, loadDefaultVocabulary, loadVectorsAsync, incrementSessionFreq, ingestDocumentPage } from './text-predictor';
|
|
11
12
|
export const autocompletePluginKey = new PluginKey('autocomplete');
|
|
12
13
|
const DEBOUNCE_MS = 150;
|
|
14
|
+
const NETWORK_SLOW_LANE_DEBOUNCE_MS = 300;
|
|
15
|
+
const LOCAL_SLOW_LANE_DEBOUNCE_MS = 100;
|
|
16
|
+
const CONTEXT_REFRESH_THROTTLE_MS = 1000;
|
|
17
|
+
// Caps the dedup Set so long editing sessions don't retain every distinct
|
|
18
|
+
// version of the (potentially hundreds-of-KB) page content for the plugin's
|
|
19
|
+
// lifetime. Eviction is FIFO; a re-ingest of an evicted text is harmless.
|
|
20
|
+
const MAX_INGESTED_CONTEXT_TEXTS = 50;
|
|
21
|
+
// Caps how many times the word-boundary path will retry getContext() while the
|
|
22
|
+
// parent comment is still missing. Combined with the 1s throttle this gives a
|
|
23
|
+
// ~5s window to cover a still-loading comment thread, then stops permanently so
|
|
24
|
+
// non-comment editors (where parentCommentContent never arrives) don't refetch
|
|
25
|
+
// on every word boundary for the plugin's lifetime.
|
|
26
|
+
const MAX_CONTEXT_REFRESH_ATTEMPTS = 5;
|
|
13
27
|
const hasDestroy = client => 'destroy' in client && typeof client.destroy === 'function';
|
|
14
28
|
const createInitialState = () => ({
|
|
15
29
|
ghostText: '',
|
|
@@ -176,6 +190,12 @@ export const createAutocompletePlugin = (options, api) => {
|
|
|
176
190
|
let debounceTimer = null;
|
|
177
191
|
let hasIngestedPage = false;
|
|
178
192
|
let resolvedContext;
|
|
193
|
+
/**
|
|
194
|
+
* Kept in sync with the live EditorView so the async getContext() promise
|
|
195
|
+
* can re-trigger a slow-lane update the moment context arrives, even if the
|
|
196
|
+
* user has already typed several words before the promise resolved.
|
|
197
|
+
*/
|
|
198
|
+
let currentView = null;
|
|
179
199
|
/**
|
|
180
200
|
* Set after accepting a suggestion so the next doc-change update
|
|
181
201
|
* skips scheduling a new prediction for the just-inserted text.
|
|
@@ -219,12 +239,115 @@ export const createAutocompletePlugin = (options, api) => {
|
|
|
219
239
|
});
|
|
220
240
|
};
|
|
221
241
|
const slowLaneClient = options !== null && options !== void 0 && options.useLocalModel ? createLocalSlowLaneClient({
|
|
222
|
-
debounceMs:
|
|
242
|
+
debounceMs: LOCAL_SLOW_LANE_DEBOUNCE_MS
|
|
223
243
|
}) : createSlowLaneClient({
|
|
224
244
|
baseUrl: '',
|
|
225
|
-
debounceMs:
|
|
245
|
+
debounceMs: NETWORK_SLOW_LANE_DEBOUNCE_MS
|
|
226
246
|
});
|
|
227
247
|
setDefaultSlowLaneClient(slowLaneClient);
|
|
248
|
+
let contextRequestInFlight = false;
|
|
249
|
+
let lastContextRefreshAt = 0;
|
|
250
|
+
// Bounds the word-boundary retry loop so it terminates even when the editor is
|
|
251
|
+
// not in a comment thread (parentCommentContent never resolves).
|
|
252
|
+
let wordBoundaryRefreshAttempts = 0;
|
|
253
|
+
// Set when the plugin is torn down so in-flight getContext() resolutions don't
|
|
254
|
+
// mutate the global text-predictor state after destruction.
|
|
255
|
+
let destroyed = false;
|
|
256
|
+
const ingestedContextTexts = new Set();
|
|
257
|
+
const logContextResolved = (source, context) => {
|
|
258
|
+
var _context$parentCommen, _context$siblingComme4, _context$siblingComme5;
|
|
259
|
+
if (!isAutocompleteDebugEnabled()) {
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// eslint-disable-next-line no-console
|
|
264
|
+
console.log('%c[Autocomplete] %cgetContext resolved', 'color: #00b8d9; font-weight: bold;', 'color: inherit;', {
|
|
265
|
+
source,
|
|
266
|
+
hasParentComment: !!(context !== null && context !== void 0 && context.parentCommentContent),
|
|
267
|
+
parentCommentPreview: context === null || context === void 0 ? void 0 : (_context$parentCommen = context.parentCommentContent) === null || _context$parentCommen === void 0 ? void 0 : _context$parentCommen.slice(0, 80),
|
|
268
|
+
siblingCount: (_context$siblingComme4 = context === null || context === void 0 ? void 0 : (_context$siblingComme5 = context.siblingCommentsContents) === null || _context$siblingComme5 === void 0 ? void 0 : _context$siblingComme5.length) !== null && _context$siblingComme4 !== void 0 ? _context$siblingComme4 : 0,
|
|
269
|
+
hasFullPage: !!(context !== null && context !== void 0 && context.fullPageContent)
|
|
270
|
+
});
|
|
271
|
+
};
|
|
272
|
+
const applyContext = context => {
|
|
273
|
+
// Merge rather than replace: the word-boundary retry may resolve only a
|
|
274
|
+
// late-arriving field (e.g. parentCommentContent) without re-sending
|
|
275
|
+
// fullPageContent, so replacing would drop previously resolved context.
|
|
276
|
+
// Strip undefined values first so a field explicitly set to undefined by
|
|
277
|
+
// getContext doesn't overwrite a previously resolved value.
|
|
278
|
+
const definedContext = Object.fromEntries(Object.entries(context).filter(([, value]) => value !== undefined));
|
|
279
|
+
resolvedContext = {
|
|
280
|
+
...resolvedContext,
|
|
281
|
+
...definedContext
|
|
282
|
+
};
|
|
283
|
+
const ingestContextText = text => {
|
|
284
|
+
if (!text || ingestedContextTexts.has(text)) {
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
ingestedContextTexts.add(text);
|
|
288
|
+
// Evict oldest entries (Set preserves insertion order) to bound memory.
|
|
289
|
+
while (ingestedContextTexts.size > MAX_INGESTED_CONTEXT_TEXTS) {
|
|
290
|
+
const oldest = ingestedContextTexts.values().next().value;
|
|
291
|
+
if (oldest === undefined) {
|
|
292
|
+
break;
|
|
293
|
+
}
|
|
294
|
+
ingestedContextTexts.delete(oldest);
|
|
295
|
+
}
|
|
296
|
+
ingestDocumentPage(text);
|
|
297
|
+
};
|
|
298
|
+
ingestContextText(context.fullPageContent);
|
|
299
|
+
ingestContextText(context.parentCommentContent);
|
|
300
|
+
for (const siblingCommentContent of (_context$siblingComme6 = context.siblingCommentsContents) !== null && _context$siblingComme6 !== void 0 ? _context$siblingComme6 : []) {
|
|
301
|
+
var _context$siblingComme6;
|
|
302
|
+
ingestContextText(siblingCommentContent);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Context arrived after word boundaries may already have fired. Re-send
|
|
306
|
+
// slow-lane context immediately so the next inference includes the thread.
|
|
307
|
+
if (currentView) {
|
|
308
|
+
slowLaneClient.updateContext(buildSlowLaneText(currentView.state.doc.textContent, resolvedContext));
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Returns true when a fetch was actually started, false when it was skipped
|
|
314
|
+
* (no getContext, a request already in flight, or throttled). Callers that
|
|
315
|
+
* track a retry budget should only count attempts where this returns true.
|
|
316
|
+
*/
|
|
317
|
+
const refreshContext = ({
|
|
318
|
+
source,
|
|
319
|
+
allowThrottle = true
|
|
320
|
+
}) => {
|
|
321
|
+
if (!(options !== null && options !== void 0 && options.getContext) || contextRequestInFlight) {
|
|
322
|
+
return false;
|
|
323
|
+
}
|
|
324
|
+
const now = Date.now();
|
|
325
|
+
if (allowThrottle && now - lastContextRefreshAt < CONTEXT_REFRESH_THROTTLE_MS) {
|
|
326
|
+
return false;
|
|
327
|
+
}
|
|
328
|
+
contextRequestInFlight = true;
|
|
329
|
+
lastContextRefreshAt = now;
|
|
330
|
+
options.getContext().then(context => {
|
|
331
|
+
// Bail if the plugin was destroyed while the fetch was in flight —
|
|
332
|
+
// applyContext mutates global text-predictor state we must not touch
|
|
333
|
+
// after teardown.
|
|
334
|
+
if (destroyed) {
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
logContextResolved(source, context);
|
|
338
|
+
if (!context) {
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
applyContext(context);
|
|
342
|
+
}).catch(error => {
|
|
343
|
+
logException(error, {
|
|
344
|
+
location: 'editor-plugin-autocomplete/getContext'
|
|
345
|
+
});
|
|
346
|
+
}).finally(() => {
|
|
347
|
+
contextRequestInFlight = false;
|
|
348
|
+
});
|
|
349
|
+
return true;
|
|
350
|
+
};
|
|
228
351
|
|
|
229
352
|
/**
|
|
230
353
|
* Schedule a prediction after a short debounce.
|
|
@@ -403,26 +526,10 @@ export const createAutocompletePlugin = (options, api) => {
|
|
|
403
526
|
});
|
|
404
527
|
if (!hasIngestedPage) {
|
|
405
528
|
hasIngestedPage = true;
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
return;
|
|
411
|
-
}
|
|
412
|
-
resolvedContext = context;
|
|
413
|
-
if (context.fullPageContent) {
|
|
414
|
-
ingestDocumentPage(context.fullPageContent);
|
|
415
|
-
}
|
|
416
|
-
if (context.parentCommentContent) {
|
|
417
|
-
ingestDocumentPage(context.parentCommentContent);
|
|
418
|
-
}
|
|
419
|
-
(_context$siblingComme4 = context.siblingCommentsContents) === null || _context$siblingComme4 === void 0 ? void 0 : _context$siblingComme4.forEach(ingestDocumentPage);
|
|
420
|
-
}).catch(error => {
|
|
421
|
-
logException(error, {
|
|
422
|
-
location: 'editor-plugin-autocomplete/getContext'
|
|
423
|
-
});
|
|
424
|
-
});
|
|
425
|
-
}
|
|
529
|
+
refreshContext({
|
|
530
|
+
source: 'focus',
|
|
531
|
+
allowThrottle: false
|
|
532
|
+
});
|
|
426
533
|
}
|
|
427
534
|
return false;
|
|
428
535
|
}
|
|
@@ -430,6 +537,7 @@ export const createAutocompletePlugin = (options, api) => {
|
|
|
430
537
|
},
|
|
431
538
|
view: () => ({
|
|
432
539
|
update: (view, prevState) => {
|
|
540
|
+
currentView = view;
|
|
433
541
|
if (!prevState.doc.eq(view.state.doc)) {
|
|
434
542
|
if (justAccepted) {
|
|
435
543
|
justAccepted = false;
|
|
@@ -445,18 +553,36 @@ export const createAutocompletePlugin = (options, api) => {
|
|
|
445
553
|
maybeUpdateSessionFrequency(view, prevState);
|
|
446
554
|
const textBefore = getTextBeforeCursor(view.state);
|
|
447
555
|
if (isWordBoundary(textBefore)) {
|
|
556
|
+
var _resolvedContext;
|
|
448
557
|
slowLaneClient.updateContext(buildSlowLaneText(view.state.doc.textContent, resolvedContext));
|
|
558
|
+
|
|
559
|
+
// Context may not have resolved on first focus (e.g. comment
|
|
560
|
+
// thread still loading). Retry on word boundaries until we have
|
|
561
|
+
// the parent comment, throttled so we don't refetch constantly
|
|
562
|
+
// and capped so non-comment editors stop retrying entirely.
|
|
563
|
+
if (!((_resolvedContext = resolvedContext) !== null && _resolvedContext !== void 0 && _resolvedContext.parentCommentContent) && wordBoundaryRefreshAttempts < MAX_CONTEXT_REFRESH_ATTEMPTS) {
|
|
564
|
+
// Only count the attempt when a fetch actually started, so an
|
|
565
|
+
// in-flight or throttled no-op doesn't burn the retry budget.
|
|
566
|
+
if (refreshContext({
|
|
567
|
+
source: 'word-boundary'
|
|
568
|
+
})) {
|
|
569
|
+
wordBoundaryRefreshAttempts++;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
449
572
|
}
|
|
450
573
|
schedulePrediction(view);
|
|
451
574
|
}
|
|
452
575
|
},
|
|
453
576
|
destroy: () => {
|
|
577
|
+
destroyed = true;
|
|
578
|
+
currentView = null;
|
|
454
579
|
if (debounceTimer) {
|
|
455
580
|
clearTimeout(debounceTimer);
|
|
456
581
|
}
|
|
457
582
|
if (hasDestroy(slowLaneClient)) {
|
|
458
583
|
slowLaneClient.destroy();
|
|
459
584
|
}
|
|
585
|
+
ingestedContextTexts.clear();
|
|
460
586
|
setDefaultSlowLaneClient(null);
|
|
461
587
|
}
|
|
462
588
|
})
|