@atlaskit/editor-plugin-autocomplete 3.3.0 → 3.4.1

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.
@@ -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.
@@ -191,6 +211,18 @@ export const createAutocompletePlugin = (options, api) => {
191
211
  let dismissedContext = null;
192
212
  let lastSuggestionTypedLength = 0;
193
213
  let lastSuggestionLength = 0;
214
+
215
+ /**
216
+ * cold → no slow-lane vector received yet; frequency-only trie scoring
217
+ * server → server slow-lane API returned the context vector
218
+ * localLlm → on-device WebGPU/MLC model returned the context vector
219
+ */
220
+ const getCompletionSource = () => {
221
+ if (!slowLaneClient.getContextVector()) {
222
+ return 'cold';
223
+ }
224
+ return options !== null && options !== void 0 && options.useLocalModel ? 'localLlm' : 'server';
225
+ };
194
226
  const fireSuggestionDismissedAnalytics = reason => {
195
227
  var _api$analytics;
196
228
  api === null || api === void 0 ? void 0 : (_api$analytics = api.analytics) === null || _api$analytics === void 0 ? void 0 : _api$analytics.actions.fireAnalyticsEvent({
@@ -198,6 +230,7 @@ export const createAutocompletePlugin = (options, api) => {
198
230
  actionSubject: ACTION_SUBJECT.CONTEXTUAL_TYPEAHEAD,
199
231
  eventType: EVENT_TYPE.TRACK,
200
232
  attributes: {
233
+ completionSource: getCompletionSource(),
201
234
  reason
202
235
  }
203
236
  });
@@ -212,6 +245,7 @@ export const createAutocompletePlugin = (options, api) => {
212
245
  actionSubject: ACTION_SUBJECT.CONTEXTUAL_TYPEAHEAD,
213
246
  eventType: EVENT_TYPE.TRACK,
214
247
  attributes: {
248
+ completionSource: getCompletionSource(),
215
249
  suggestionLength,
216
250
  typedLength,
217
251
  kssDelta
@@ -219,12 +253,115 @@ export const createAutocompletePlugin = (options, api) => {
219
253
  });
220
254
  };
221
255
  const slowLaneClient = options !== null && options !== void 0 && options.useLocalModel ? createLocalSlowLaneClient({
222
- debounceMs: 300
256
+ debounceMs: LOCAL_SLOW_LANE_DEBOUNCE_MS
223
257
  }) : createSlowLaneClient({
224
258
  baseUrl: '',
225
- debounceMs: 300
259
+ debounceMs: NETWORK_SLOW_LANE_DEBOUNCE_MS
226
260
  });
227
261
  setDefaultSlowLaneClient(slowLaneClient);
262
+ let contextRequestInFlight = false;
263
+ let lastContextRefreshAt = 0;
264
+ // Bounds the word-boundary retry loop so it terminates even when the editor is
265
+ // not in a comment thread (parentCommentContent never resolves).
266
+ let wordBoundaryRefreshAttempts = 0;
267
+ // Set when the plugin is torn down so in-flight getContext() resolutions don't
268
+ // mutate the global text-predictor state after destruction.
269
+ let destroyed = false;
270
+ const ingestedContextTexts = new Set();
271
+ const logContextResolved = (source, context) => {
272
+ var _context$parentCommen, _context$siblingComme4, _context$siblingComme5;
273
+ if (!isAutocompleteDebugEnabled()) {
274
+ return;
275
+ }
276
+
277
+ // eslint-disable-next-line no-console
278
+ console.log('%c[Autocomplete] %cgetContext resolved', 'color: #00b8d9; font-weight: bold;', 'color: inherit;', {
279
+ source,
280
+ hasParentComment: !!(context !== null && context !== void 0 && context.parentCommentContent),
281
+ parentCommentPreview: context === null || context === void 0 ? void 0 : (_context$parentCommen = context.parentCommentContent) === null || _context$parentCommen === void 0 ? void 0 : _context$parentCommen.slice(0, 80),
282
+ 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,
283
+ hasFullPage: !!(context !== null && context !== void 0 && context.fullPageContent)
284
+ });
285
+ };
286
+ const applyContext = context => {
287
+ // Merge rather than replace: the word-boundary retry may resolve only a
288
+ // late-arriving field (e.g. parentCommentContent) without re-sending
289
+ // fullPageContent, so replacing would drop previously resolved context.
290
+ // Strip undefined values first so a field explicitly set to undefined by
291
+ // getContext doesn't overwrite a previously resolved value.
292
+ const definedContext = Object.fromEntries(Object.entries(context).filter(([, value]) => value !== undefined));
293
+ resolvedContext = {
294
+ ...resolvedContext,
295
+ ...definedContext
296
+ };
297
+ const 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
+ const oldest = ingestedContextTexts.values().next().value;
305
+ if (oldest === undefined) {
306
+ break;
307
+ }
308
+ ingestedContextTexts.delete(oldest);
309
+ }
310
+ ingestDocumentPage(text);
311
+ };
312
+ ingestContextText(context.fullPageContent);
313
+ ingestContextText(context.parentCommentContent);
314
+ for (const siblingCommentContent of (_context$siblingComme6 = context.siblingCommentsContents) !== null && _context$siblingComme6 !== void 0 ? _context$siblingComme6 : []) {
315
+ var _context$siblingComme6;
316
+ ingestContextText(siblingCommentContent);
317
+ }
318
+
319
+ // Context arrived after word boundaries may already have fired. Re-send
320
+ // slow-lane context immediately so the next inference includes the thread.
321
+ if (currentView) {
322
+ slowLaneClient.updateContext(buildSlowLaneText(currentView.state.doc.textContent, resolvedContext));
323
+ }
324
+ };
325
+
326
+ /**
327
+ * Returns true when a fetch was actually started, false when it was skipped
328
+ * (no getContext, a request already in flight, or throttled). Callers that
329
+ * track a retry budget should only count attempts where this returns true.
330
+ */
331
+ const refreshContext = ({
332
+ source,
333
+ allowThrottle = true
334
+ }) => {
335
+ if (!(options !== null && options !== void 0 && options.getContext) || contextRequestInFlight) {
336
+ return false;
337
+ }
338
+ const now = Date.now();
339
+ if (allowThrottle && now - lastContextRefreshAt < CONTEXT_REFRESH_THROTTLE_MS) {
340
+ return false;
341
+ }
342
+ contextRequestInFlight = true;
343
+ lastContextRefreshAt = now;
344
+ options.getContext().then(context => {
345
+ // Bail if the plugin was destroyed while the fetch was in flight —
346
+ // applyContext mutates global text-predictor state we must not touch
347
+ // after teardown.
348
+ if (destroyed) {
349
+ return;
350
+ }
351
+ logContextResolved(source, context);
352
+ if (!context) {
353
+ return;
354
+ }
355
+ applyContext(context);
356
+ }).catch(error => {
357
+ logException(error, {
358
+ location: 'editor-plugin-autocomplete/getContext'
359
+ });
360
+ }).finally(() => {
361
+ contextRequestInFlight = false;
362
+ });
363
+ return true;
364
+ };
228
365
 
229
366
  /**
230
367
  * Schedule a prediction after a short debounce.
@@ -269,7 +406,10 @@ export const createAutocompletePlugin = (options, api) => {
269
406
  api === null || api === void 0 ? void 0 : (_api$analytics3 = api.analytics) === null || _api$analytics3 === void 0 ? void 0 : _api$analytics3.actions.fireAnalyticsEvent({
270
407
  action: ACTION.SUGGESTION_VIEWED,
271
408
  actionSubject: ACTION_SUBJECT.CONTEXTUAL_TYPEAHEAD,
272
- eventType: EVENT_TYPE.TRACK
409
+ eventType: EVENT_TYPE.TRACK,
410
+ attributes: {
411
+ completionSource: getCompletionSource()
412
+ }
273
413
  });
274
414
  }
275
415
  }
@@ -403,26 +543,10 @@ export const createAutocompletePlugin = (options, api) => {
403
543
  });
404
544
  if (!hasIngestedPage) {
405
545
  hasIngestedPage = true;
406
- if (options !== null && options !== void 0 && options.getContext) {
407
- options.getContext().then(context => {
408
- var _context$siblingComme4;
409
- if (!context) {
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
- }
546
+ refreshContext({
547
+ source: 'focus',
548
+ allowThrottle: false
549
+ });
426
550
  }
427
551
  return false;
428
552
  }
@@ -430,6 +554,7 @@ export const createAutocompletePlugin = (options, api) => {
430
554
  },
431
555
  view: () => ({
432
556
  update: (view, prevState) => {
557
+ currentView = view;
433
558
  if (!prevState.doc.eq(view.state.doc)) {
434
559
  if (justAccepted) {
435
560
  justAccepted = false;
@@ -445,18 +570,36 @@ export const createAutocompletePlugin = (options, api) => {
445
570
  maybeUpdateSessionFrequency(view, prevState);
446
571
  const textBefore = getTextBeforeCursor(view.state);
447
572
  if (isWordBoundary(textBefore)) {
573
+ var _resolvedContext;
448
574
  slowLaneClient.updateContext(buildSlowLaneText(view.state.doc.textContent, resolvedContext));
575
+
576
+ // Context may not have resolved on first focus (e.g. comment
577
+ // thread still loading). Retry on word boundaries until we have
578
+ // the parent comment, throttled so we don't refetch constantly
579
+ // and capped so non-comment editors stop retrying entirely.
580
+ if (!((_resolvedContext = resolvedContext) !== null && _resolvedContext !== void 0 && _resolvedContext.parentCommentContent) && wordBoundaryRefreshAttempts < MAX_CONTEXT_REFRESH_ATTEMPTS) {
581
+ // Only count the attempt when a fetch actually started, so an
582
+ // in-flight or throttled no-op doesn't burn the retry budget.
583
+ if (refreshContext({
584
+ source: 'word-boundary'
585
+ })) {
586
+ wordBoundaryRefreshAttempts++;
587
+ }
588
+ }
449
589
  }
450
590
  schedulePrediction(view);
451
591
  }
452
592
  },
453
593
  destroy: () => {
594
+ destroyed = true;
595
+ currentView = null;
454
596
  if (debounceTimer) {
455
597
  clearTimeout(debounceTimer);
456
598
  }
457
599
  if (hasDestroy(slowLaneClient)) {
458
600
  slowLaneClient.destroy();
459
601
  }
602
+ ingestedContextTexts.clear();
460
603
  setDefaultSlowLaneClient(null);
461
604
  }
462
605
  })
@@ -481,6 +481,14 @@ export const createLocalSlowLaneClient = (config = {}) => {
481
481
  let lastRequestedText = '';
482
482
  let requestCounter = 0;
483
483
  let latestRequestId = -1;
484
+ let inferenceInFlight = false;
485
+ let activeInferenceText = null;
486
+ // The requestId of the inference currently in flight. Tracked so the
487
+ // in-flight dedup path can restore `latestRequestId` to it — otherwise an
488
+ // intermediate keystroke that bumped `latestRequestId` would cause the
489
+ // in-flight (still-current) result to be discarded as stale.
490
+ let activeInferenceRequestId = -1;
491
+ let pendingInference = null;
484
492
  let ready = false;
485
493
  let destroyed = false;
486
494
  let initFailed = false;
@@ -708,8 +716,8 @@ export const createLocalSlowLaneClient = (config = {}) => {
708
716
  hasLmLogits: storedLmLogits !== null
709
717
  });
710
718
  } catch (err) {
711
- // Discard errors for stale requests
712
- if (requestId < latestRequestId) {
719
+ // Discard errors for stale requests or after teardown
720
+ if (requestId < latestRequestId || destroyed) {
713
721
  return;
714
722
  }
715
723
  storedContextVector = null;
@@ -729,10 +737,46 @@ export const createLocalSlowLaneClient = (config = {}) => {
729
737
 
730
738
  // ── Context update (debounced) ─────────────────────────────────────────
731
739
 
740
+ const startInference = (text, requestId) => {
741
+ // Self-contained guard: never start a new inference cycle after teardown,
742
+ // regardless of caller discipline.
743
+ if (destroyed) {
744
+ return;
745
+ }
746
+ inferenceInFlight = true;
747
+ activeInferenceText = text;
748
+ activeInferenceRequestId = requestId;
749
+ void ensureEngineInitialized().then(() => runInference(text, requestId)).catch(() => {}).finally(() => {
750
+ inferenceInFlight = false;
751
+ activeInferenceText = null;
752
+ activeInferenceRequestId = -1;
753
+ const next = pendingInference;
754
+ pendingInference = null;
755
+ if (next && !destroyed) {
756
+ startInference(next.text, next.requestId);
757
+ }
758
+ });
759
+ };
732
760
  const doUpdateContext = text => {
761
+ var _pendingInference;
733
762
  if (destroyed || !text || text.trim().length === 0) {
734
763
  return;
735
764
  }
765
+ if (inferenceInFlight && text === activeInferenceText) {
766
+ // The latest desired text already matches the in-flight inference, so
767
+ // re-running it would be wasted work. But an intermediate keystroke may
768
+ // have bumped `latestRequestId` past the in-flight request (and then been
769
+ // coalesced away), which would cause runInference to discard the
770
+ // still-current result as stale. Pin `latestRequestId` back to the active
771
+ // request so its result is accepted, and drop any now-superseded pending
772
+ // request.
773
+ latestRequestId = activeInferenceRequestId;
774
+ pendingInference = null;
775
+ return;
776
+ }
777
+ if (inferenceInFlight && ((_pendingInference = pendingInference) === null || _pendingInference === void 0 ? void 0 : _pendingInference.text) === text) {
778
+ return;
779
+ }
736
780
  const requestId = ++requestCounter;
737
781
  latestRequestId = requestId;
738
782
  if (isAutocompleteDebugEnabled()) {
@@ -746,12 +790,27 @@ export const createLocalSlowLaneClient = (config = {}) => {
746
790
  // eslint-disable-next-line no-console
747
791
  console.groupEnd();
748
792
  }
749
- void ensureEngineInitialized().then(() => runInference(text, requestId)).catch(() => {});
793
+ if (inferenceInFlight) {
794
+ pendingInference = {
795
+ text,
796
+ requestId
797
+ };
798
+ return;
799
+ }
800
+ startInference(text, requestId);
750
801
  };
751
802
  const updateContextDebounced = text => {
752
803
  if (debounceTimer) {
753
804
  clearTimeout(debounceTimer);
754
805
  }
806
+ if (inferenceInFlight) {
807
+ pendingInference = null;
808
+ if (text === activeInferenceText) {
809
+ latestRequestId = activeInferenceRequestId;
810
+ lastRequestedText = text;
811
+ return;
812
+ }
813
+ }
755
814
  lastRequestedText = text;
756
815
  debounceTimer = setTimeout(() => {
757
816
  debounceTimer = null;
@@ -784,6 +843,10 @@ export const createLocalSlowLaneClient = (config = {}) => {
784
843
  unloadEngine(engineToUnload);
785
844
  }
786
845
  engineInitPromise = null;
846
+ inferenceInFlight = false;
847
+ activeInferenceText = null;
848
+ activeInferenceRequestId = -1;
849
+ pendingInference = null;
787
850
  storedContextVector = null;
788
851
  storedLmLogits = null;
789
852
  }