@atlaskit/editor-plugin-autocomplete 3.2.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.
@@ -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: 300
242
+ debounceMs: LOCAL_SLOW_LANE_DEBOUNCE_MS
223
243
  }) : createSlowLaneClient({
224
244
  baseUrl: '',
225
- debounceMs: 300
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
- 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
- }
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
  })
@@ -109,13 +109,37 @@ export const BE_PARITY = {
109
109
  */
110
110
  MAX_CONTEXT_WORDS: 100
111
111
  };
112
+ const splitOnWhitespace = text => {
113
+ const trimmed = text.trim();
114
+ if (trimmed === '') {
115
+ return [];
116
+ }
117
+ const words = [];
118
+ let wordStart = -1;
119
+ for (let i = 0; i < trimmed.length; i++) {
120
+ if (trimmed[i].trim() === '') {
121
+ if (wordStart !== -1) {
122
+ words.push(trimmed.slice(wordStart, i));
123
+ wordStart = -1;
124
+ }
125
+ continue;
126
+ }
127
+ if (wordStart === -1) {
128
+ wordStart = i;
129
+ }
130
+ }
131
+ if (wordStart !== -1) {
132
+ words.push(trimmed.slice(wordStart));
133
+ }
134
+ return words;
135
+ };
112
136
 
113
137
  /**
114
138
  * Return the last `n` whitespace-separated words of `text`, joined by spaces.
115
139
  * Mirrors the BE rolling-window truncation applied before both encoders.
116
140
  */
117
141
  const truncateToLastNWords = (text, n) => {
118
- const words = text.trim().split(/\s+/u);
142
+ const words = splitOnWhitespace(text);
119
143
  return words.length <= n ? text : words.slice(-n).join(' ');
120
144
  };
121
145
 
@@ -457,6 +481,14 @@ export const createLocalSlowLaneClient = (config = {}) => {
457
481
  let lastRequestedText = '';
458
482
  let requestCounter = 0;
459
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;
460
492
  let ready = false;
461
493
  let destroyed = false;
462
494
  let initFailed = false;
@@ -600,32 +632,34 @@ export const createLocalSlowLaneClient = (config = {}) => {
600
632
  const lmText = truncateToLastNWords(text, BE_PARITY.MAX_CONTEXT_TOKENS);
601
633
  const semanticText = truncateToLastNWords(text, BE_PARITY.MAX_CONTEXT_WORDS);
602
634
  const arcticInput = wrapForArctic(semanticText);
635
+ const captureCompletionTime = (promise, onResolved) => promise.then(value => {
636
+ onResolved(performance.now());
637
+ return value;
638
+ });
603
639
  if (isAutocompleteDebugEnabled()) {
604
640
  // eslint-disable-next-line no-console
605
- console.log(`%c[LocalSlowLane] %c🔢 Arctic input (${arcticInput.length} chars, ${semanticText.split(/\s+/u).length} words): "${arcticInput.length > 100 ? `${arcticInput.slice(0, 100)}…` : arcticInput}"`, 'color: #9c27b0; font-weight: bold;', 'color: #009688;');
641
+ console.log(`%c[LocalSlowLane] %c🔢 Arctic input (${arcticInput.length} chars, ${splitOnWhitespace(semanticText).length} words): "${arcticInput.length > 100 ? `${arcticInput.slice(0, 100)}…` : arcticInput}"`, 'color: #9c27b0; font-weight: bold;', 'color: #009688;');
606
642
  // eslint-disable-next-line no-console
607
- console.log(`%c[LocalSlowLane] %c🧠 LM input (${lmText.length} chars, ${lmText.split(/\s+/u).length} words): "${lmText.length > 100 ? `${lmText.slice(0, 100)}…` : lmText}"`, 'color: #9c27b0; font-weight: bold;', 'color: #2196f3;');
643
+ console.log(`%c[LocalSlowLane] %c🧠 LM input (${lmText.length} chars, ${splitOnWhitespace(lmText).length} words): "${lmText.length > 100 ? `${lmText.slice(0, 100)}…` : lmText}"`, 'color: #9c27b0; font-weight: bold;', 'color: #2196f3;');
608
644
  }
609
645
  try {
610
- var _embeddingResponse$da, _embeddingResponse$da2;
646
+ var _data, _data$;
611
647
  const tStart = performance.now();
612
648
  let tLmDone = 0;
613
649
  let tEmbDone = 0;
614
- const [, embeddingResponse] = await Promise.all([engine.completions.create({
650
+ const [, embeddingResponse] = await Promise.all([captureCompletionTime(engine.completions.create({
615
651
  model: modelId,
616
652
  prompt: lmText,
617
653
  max_tokens: 1,
618
654
  temperature: 0,
619
655
  logprobs: false
620
- }).then(r => {
621
- tLmDone = performance.now();
622
- return r;
623
- }), engine.embeddings.create({
656
+ }), resolvedAt => {
657
+ tLmDone = resolvedAt;
658
+ }), captureCompletionTime(engine.embeddings.create({
624
659
  model: LOCAL_MLC_EMBEDDING_MODEL_ID,
625
660
  input: arcticInput
626
- }).then(r => {
627
- tEmbDone = performance.now();
628
- return r;
661
+ }), resolvedAt => {
662
+ tEmbDone = resolvedAt;
629
663
  })]);
630
664
  if (isAutocompleteDebugEnabled()) {
631
665
  // eslint-disable-next-line no-console
@@ -650,7 +684,7 @@ export const createLocalSlowLaneClient = (config = {}) => {
650
684
  // Guard against base64-encoded responses (encoding_format: 'base64' would
651
685
  // yield a string, and new Float32Array(string) silently produces an empty
652
686
  // array, corrupting downstream cosine-similarity scoring).
653
- const embedding = (_embeddingResponse$da = embeddingResponse.data) === null || _embeddingResponse$da === void 0 ? void 0 : (_embeddingResponse$da2 = _embeddingResponse$da[0]) === null || _embeddingResponse$da2 === void 0 ? void 0 : _embeddingResponse$da2.embedding;
687
+ const embedding = (_data = embeddingResponse.data) === null || _data === void 0 ? void 0 : (_data$ = _data[0]) === null || _data$ === void 0 ? void 0 : _data$.embedding;
654
688
  storedContextVector = Array.isArray(embedding) && embedding.length > 0 ? new Float32Array(embedding) : null;
655
689
  if (isAutocompleteDebugEnabled()) {
656
690
  // eslint-disable-next-line no-console
@@ -682,8 +716,8 @@ export const createLocalSlowLaneClient = (config = {}) => {
682
716
  hasLmLogits: storedLmLogits !== null
683
717
  });
684
718
  } catch (err) {
685
- // Discard errors for stale requests
686
- if (requestId < latestRequestId) {
719
+ // Discard errors for stale requests or after teardown
720
+ if (requestId < latestRequestId || destroyed) {
687
721
  return;
688
722
  }
689
723
  storedContextVector = null;
@@ -703,10 +737,46 @@ export const createLocalSlowLaneClient = (config = {}) => {
703
737
 
704
738
  // ── Context update (debounced) ─────────────────────────────────────────
705
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
+ };
706
760
  const doUpdateContext = text => {
761
+ var _pendingInference;
707
762
  if (destroyed || !text || text.trim().length === 0) {
708
763
  return;
709
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
+ }
710
780
  const requestId = ++requestCounter;
711
781
  latestRequestId = requestId;
712
782
  if (isAutocompleteDebugEnabled()) {
@@ -720,12 +790,27 @@ export const createLocalSlowLaneClient = (config = {}) => {
720
790
  // eslint-disable-next-line no-console
721
791
  console.groupEnd();
722
792
  }
723
- void ensureEngineInitialized().then(() => runInference(text, requestId)).catch(() => {});
793
+ if (inferenceInFlight) {
794
+ pendingInference = {
795
+ text,
796
+ requestId
797
+ };
798
+ return;
799
+ }
800
+ startInference(text, requestId);
724
801
  };
725
802
  const updateContextDebounced = text => {
726
803
  if (debounceTimer) {
727
804
  clearTimeout(debounceTimer);
728
805
  }
806
+ if (inferenceInFlight) {
807
+ pendingInference = null;
808
+ if (text === activeInferenceText) {
809
+ latestRequestId = activeInferenceRequestId;
810
+ lastRequestedText = text;
811
+ return;
812
+ }
813
+ }
729
814
  lastRequestedText = text;
730
815
  debounceTimer = setTimeout(() => {
731
816
  debounceTimer = null;
@@ -758,6 +843,10 @@ export const createLocalSlowLaneClient = (config = {}) => {
758
843
  unloadEngine(engineToUnload);
759
844
  }
760
845
  engineInitPromise = null;
846
+ inferenceInFlight = false;
847
+ activeInferenceText = null;
848
+ activeInferenceRequestId = -1;
849
+ pendingInference = null;
761
850
  storedContextVector = null;
762
851
  storedLmLogits = null;
763
852
  }