@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.
- package/CHANGELOG.md +16 -0
- package/dist/cjs/pm-plugins/autocomplete-plugin.js +180 -23
- package/dist/cjs/pm-plugins/local-slow-lane-client.js +67 -4
- package/dist/es2019/pm-plugins/autocomplete-plugin.js +166 -23
- package/dist/es2019/pm-plugins/local-slow-lane-client.js +66 -3
- package/dist/esm/pm-plugins/autocomplete-plugin.js +180 -23
- package/dist/esm/pm-plugins/local-slow-lane-client.js +67 -4
- package/package.json +2 -2
- package/src/pm-plugins/autocomplete-plugin.ts +185 -26
- package/src/pm-plugins/local-slow-lane-client.ts +72 -5
|
@@ -14,6 +14,7 @@ import type { EditorView } from '@atlaskit/editor-prosemirror/view';
|
|
|
14
14
|
|
|
15
15
|
import type { AutocompletePlugin } from '../autocompletePluginType';
|
|
16
16
|
|
|
17
|
+
import { isAutocompleteDebugEnabled } from './debug-mode';
|
|
17
18
|
import { createGhostTextDecorationSet } from './ghost-text-decoration';
|
|
18
19
|
import { createLocalSlowLaneClient, type LocalSlowLaneClient } from './local-slow-lane-client';
|
|
19
20
|
import { createSlowLaneClient, setDefaultSlowLaneClient, isWordBoundary } from './slow-lane-client';
|
|
@@ -28,6 +29,19 @@ import {
|
|
|
28
29
|
export const autocompletePluginKey: PluginKey = new PluginKey('autocomplete');
|
|
29
30
|
|
|
30
31
|
const DEBOUNCE_MS = 150;
|
|
32
|
+
const NETWORK_SLOW_LANE_DEBOUNCE_MS = 300;
|
|
33
|
+
const LOCAL_SLOW_LANE_DEBOUNCE_MS = 100;
|
|
34
|
+
const CONTEXT_REFRESH_THROTTLE_MS = 1000;
|
|
35
|
+
// Caps the dedup Set so long editing sessions don't retain every distinct
|
|
36
|
+
// version of the (potentially hundreds-of-KB) page content for the plugin's
|
|
37
|
+
// lifetime. Eviction is FIFO; a re-ingest of an evicted text is harmless.
|
|
38
|
+
const MAX_INGESTED_CONTEXT_TEXTS = 50;
|
|
39
|
+
// Caps how many times the word-boundary path will retry getContext() while the
|
|
40
|
+
// parent comment is still missing. Combined with the 1s throttle this gives a
|
|
41
|
+
// ~5s window to cover a still-loading comment thread, then stops permanently so
|
|
42
|
+
// non-comment editors (where parentCommentContent never arrives) don't refetch
|
|
43
|
+
// on every word boundary for the plugin's lifetime.
|
|
44
|
+
const MAX_CONTEXT_REFRESH_ATTEMPTS = 5;
|
|
31
45
|
|
|
32
46
|
const hasDestroy = (
|
|
33
47
|
client: ReturnType<typeof createSlowLaneClient> | LocalSlowLaneClient,
|
|
@@ -241,6 +255,12 @@ export const createAutocompletePlugin = (
|
|
|
241
255
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
242
256
|
let hasIngestedPage = false;
|
|
243
257
|
let resolvedContext: AutocompleteContext | undefined;
|
|
258
|
+
/**
|
|
259
|
+
* Kept in sync with the live EditorView so the async getContext() promise
|
|
260
|
+
* can re-trigger a slow-lane update the moment context arrives, even if the
|
|
261
|
+
* user has already typed several words before the promise resolved.
|
|
262
|
+
*/
|
|
263
|
+
let currentView: EditorView | null = null;
|
|
244
264
|
/**
|
|
245
265
|
* Set after accepting a suggestion so the next doc-change update
|
|
246
266
|
* skips scheduling a new prediction for the just-inserted text.
|
|
@@ -257,12 +277,24 @@ export const createAutocompletePlugin = (
|
|
|
257
277
|
let lastSuggestionTypedLength = 0;
|
|
258
278
|
let lastSuggestionLength = 0;
|
|
259
279
|
|
|
280
|
+
/**
|
|
281
|
+
* cold → no slow-lane vector received yet; frequency-only trie scoring
|
|
282
|
+
* server → server slow-lane API returned the context vector
|
|
283
|
+
* localLlm → on-device WebGPU/MLC model returned the context vector
|
|
284
|
+
*/
|
|
285
|
+
const getCompletionSource = (): 'cold' | 'server' | 'localLlm' => {
|
|
286
|
+
if (!slowLaneClient.getContextVector()) {
|
|
287
|
+
return 'cold';
|
|
288
|
+
}
|
|
289
|
+
return options?.useLocalModel ? 'localLlm' : 'server';
|
|
290
|
+
};
|
|
291
|
+
|
|
260
292
|
const fireSuggestionDismissedAnalytics = (reason: 'escape' | 'blur'): void => {
|
|
261
293
|
api?.analytics?.actions.fireAnalyticsEvent({
|
|
262
294
|
action: ACTION.SUGGESTION_DISMISSED,
|
|
263
295
|
actionSubject: ACTION_SUBJECT.CONTEXTUAL_TYPEAHEAD,
|
|
264
296
|
eventType: EVENT_TYPE.TRACK,
|
|
265
|
-
attributes: { reason },
|
|
297
|
+
attributes: { completionSource: getCompletionSource(), reason },
|
|
266
298
|
});
|
|
267
299
|
};
|
|
268
300
|
|
|
@@ -276,6 +308,7 @@ export const createAutocompletePlugin = (
|
|
|
276
308
|
actionSubject: ACTION_SUBJECT.CONTEXTUAL_TYPEAHEAD,
|
|
277
309
|
eventType: EVENT_TYPE.TRACK,
|
|
278
310
|
attributes: {
|
|
311
|
+
completionSource: getCompletionSource(),
|
|
279
312
|
suggestionLength,
|
|
280
313
|
typedLength,
|
|
281
314
|
kssDelta,
|
|
@@ -285,14 +318,139 @@ export const createAutocompletePlugin = (
|
|
|
285
318
|
|
|
286
319
|
const slowLaneClient = options?.useLocalModel
|
|
287
320
|
? createLocalSlowLaneClient({
|
|
288
|
-
debounceMs:
|
|
321
|
+
debounceMs: LOCAL_SLOW_LANE_DEBOUNCE_MS,
|
|
289
322
|
})
|
|
290
323
|
: createSlowLaneClient({
|
|
291
324
|
baseUrl: '',
|
|
292
|
-
debounceMs:
|
|
325
|
+
debounceMs: NETWORK_SLOW_LANE_DEBOUNCE_MS,
|
|
293
326
|
});
|
|
294
327
|
setDefaultSlowLaneClient(slowLaneClient);
|
|
295
328
|
|
|
329
|
+
let contextRequestInFlight = false;
|
|
330
|
+
let lastContextRefreshAt = 0;
|
|
331
|
+
// Bounds the word-boundary retry loop so it terminates even when the editor is
|
|
332
|
+
// not in a comment thread (parentCommentContent never resolves).
|
|
333
|
+
let wordBoundaryRefreshAttempts = 0;
|
|
334
|
+
// Set when the plugin is torn down so in-flight getContext() resolutions don't
|
|
335
|
+
// mutate the global text-predictor state after destruction.
|
|
336
|
+
let destroyed = false;
|
|
337
|
+
const ingestedContextTexts = new Set<string>();
|
|
338
|
+
|
|
339
|
+
const logContextResolved = (source: string, context?: AutocompleteContext): void => {
|
|
340
|
+
if (!isAutocompleteDebugEnabled()) {
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// eslint-disable-next-line no-console
|
|
345
|
+
console.log(
|
|
346
|
+
'%c[Autocomplete] %cgetContext resolved',
|
|
347
|
+
'color: #00b8d9; font-weight: bold;',
|
|
348
|
+
'color: inherit;',
|
|
349
|
+
{
|
|
350
|
+
source,
|
|
351
|
+
hasParentComment: !!context?.parentCommentContent,
|
|
352
|
+
parentCommentPreview: context?.parentCommentContent?.slice(0, 80),
|
|
353
|
+
siblingCount: context?.siblingCommentsContents?.length ?? 0,
|
|
354
|
+
hasFullPage: !!context?.fullPageContent,
|
|
355
|
+
},
|
|
356
|
+
);
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
const applyContext = (context: AutocompleteContext): void => {
|
|
360
|
+
// Merge rather than replace: the word-boundary retry may resolve only a
|
|
361
|
+
// late-arriving field (e.g. parentCommentContent) without re-sending
|
|
362
|
+
// fullPageContent, so replacing would drop previously resolved context.
|
|
363
|
+
// Strip undefined values first so a field explicitly set to undefined by
|
|
364
|
+
// getContext doesn't overwrite a previously resolved value.
|
|
365
|
+
const definedContext = Object.fromEntries(
|
|
366
|
+
Object.entries(context).filter(([, value]) => value !== undefined),
|
|
367
|
+
);
|
|
368
|
+
resolvedContext = { ...resolvedContext, ...definedContext };
|
|
369
|
+
|
|
370
|
+
const ingestContextText = (text?: string): void => {
|
|
371
|
+
if (!text || ingestedContextTexts.has(text)) {
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
ingestedContextTexts.add(text);
|
|
376
|
+
// Evict oldest entries (Set preserves insertion order) to bound memory.
|
|
377
|
+
while (ingestedContextTexts.size > MAX_INGESTED_CONTEXT_TEXTS) {
|
|
378
|
+
const oldest = ingestedContextTexts.values().next().value;
|
|
379
|
+
if (oldest === undefined) {
|
|
380
|
+
break;
|
|
381
|
+
}
|
|
382
|
+
ingestedContextTexts.delete(oldest);
|
|
383
|
+
}
|
|
384
|
+
ingestDocumentPage(text);
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
ingestContextText(context.fullPageContent);
|
|
388
|
+
ingestContextText(context.parentCommentContent);
|
|
389
|
+
for (const siblingCommentContent of context.siblingCommentsContents ?? []) {
|
|
390
|
+
ingestContextText(siblingCommentContent);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Context arrived after word boundaries may already have fired. Re-send
|
|
394
|
+
// slow-lane context immediately so the next inference includes the thread.
|
|
395
|
+
if (currentView) {
|
|
396
|
+
slowLaneClient.updateContext(
|
|
397
|
+
buildSlowLaneText(currentView.state.doc.textContent, resolvedContext),
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Returns true when a fetch was actually started, false when it was skipped
|
|
404
|
+
* (no getContext, a request already in flight, or throttled). Callers that
|
|
405
|
+
* track a retry budget should only count attempts where this returns true.
|
|
406
|
+
*/
|
|
407
|
+
const refreshContext = ({
|
|
408
|
+
source,
|
|
409
|
+
allowThrottle = true,
|
|
410
|
+
}: {
|
|
411
|
+
allowThrottle?: boolean;
|
|
412
|
+
source: string;
|
|
413
|
+
}): boolean => {
|
|
414
|
+
if (!options?.getContext || contextRequestInFlight) {
|
|
415
|
+
return false;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const now = Date.now();
|
|
419
|
+
if (allowThrottle && now - lastContextRefreshAt < CONTEXT_REFRESH_THROTTLE_MS) {
|
|
420
|
+
return false;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
contextRequestInFlight = true;
|
|
424
|
+
lastContextRefreshAt = now;
|
|
425
|
+
|
|
426
|
+
options
|
|
427
|
+
.getContext()
|
|
428
|
+
.then((context) => {
|
|
429
|
+
// Bail if the plugin was destroyed while the fetch was in flight —
|
|
430
|
+
// applyContext mutates global text-predictor state we must not touch
|
|
431
|
+
// after teardown.
|
|
432
|
+
if (destroyed) {
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
logContextResolved(source, context);
|
|
436
|
+
if (!context) {
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
applyContext(context);
|
|
441
|
+
})
|
|
442
|
+
.catch((error) => {
|
|
443
|
+
logException(error as Error, {
|
|
444
|
+
location: 'editor-plugin-autocomplete/getContext',
|
|
445
|
+
});
|
|
446
|
+
})
|
|
447
|
+
.finally(() => {
|
|
448
|
+
contextRequestInFlight = false;
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
return true;
|
|
452
|
+
};
|
|
453
|
+
|
|
296
454
|
/**
|
|
297
455
|
* Schedule a prediction after a short debounce.
|
|
298
456
|
* Tier 1 predictions are synchronous (<0.1ms) but we still debounce
|
|
@@ -339,6 +497,7 @@ export const createAutocompletePlugin = (
|
|
|
339
497
|
action: ACTION.SUGGESTION_VIEWED,
|
|
340
498
|
actionSubject: ACTION_SUBJECT.CONTEXTUAL_TYPEAHEAD,
|
|
341
499
|
eventType: EVENT_TYPE.TRACK,
|
|
500
|
+
attributes: { completionSource: getCompletionSource() },
|
|
342
501
|
});
|
|
343
502
|
}
|
|
344
503
|
}
|
|
@@ -490,29 +649,10 @@ export const createAutocompletePlugin = (
|
|
|
490
649
|
});
|
|
491
650
|
if (!hasIngestedPage) {
|
|
492
651
|
hasIngestedPage = true;
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
if (!context) {
|
|
498
|
-
return;
|
|
499
|
-
}
|
|
500
|
-
resolvedContext = context;
|
|
501
|
-
|
|
502
|
-
if (context.fullPageContent) {
|
|
503
|
-
ingestDocumentPage(context.fullPageContent);
|
|
504
|
-
}
|
|
505
|
-
if (context.parentCommentContent) {
|
|
506
|
-
ingestDocumentPage(context.parentCommentContent);
|
|
507
|
-
}
|
|
508
|
-
context.siblingCommentsContents?.forEach(ingestDocumentPage);
|
|
509
|
-
})
|
|
510
|
-
.catch((error) => {
|
|
511
|
-
logException(error as Error, {
|
|
512
|
-
location: 'editor-plugin-autocomplete/getContext',
|
|
513
|
-
});
|
|
514
|
-
});
|
|
515
|
-
}
|
|
652
|
+
refreshContext({
|
|
653
|
+
source: 'focus',
|
|
654
|
+
allowThrottle: false,
|
|
655
|
+
});
|
|
516
656
|
}
|
|
517
657
|
return false;
|
|
518
658
|
},
|
|
@@ -521,6 +661,7 @@ export const createAutocompletePlugin = (
|
|
|
521
661
|
|
|
522
662
|
view: () => ({
|
|
523
663
|
update: (view: EditorView, prevState: EditorState) => {
|
|
664
|
+
currentView = view;
|
|
524
665
|
if (!prevState.doc.eq(view.state.doc)) {
|
|
525
666
|
if (justAccepted) {
|
|
526
667
|
justAccepted = false;
|
|
@@ -542,18 +683,36 @@ export const createAutocompletePlugin = (
|
|
|
542
683
|
slowLaneClient.updateContext(
|
|
543
684
|
buildSlowLaneText(view.state.doc.textContent, resolvedContext),
|
|
544
685
|
);
|
|
686
|
+
|
|
687
|
+
// Context may not have resolved on first focus (e.g. comment
|
|
688
|
+
// thread still loading). Retry on word boundaries until we have
|
|
689
|
+
// the parent comment, throttled so we don't refetch constantly
|
|
690
|
+
// and capped so non-comment editors stop retrying entirely.
|
|
691
|
+
if (
|
|
692
|
+
!resolvedContext?.parentCommentContent &&
|
|
693
|
+
wordBoundaryRefreshAttempts < MAX_CONTEXT_REFRESH_ATTEMPTS
|
|
694
|
+
) {
|
|
695
|
+
// Only count the attempt when a fetch actually started, so an
|
|
696
|
+
// in-flight or throttled no-op doesn't burn the retry budget.
|
|
697
|
+
if (refreshContext({ source: 'word-boundary' })) {
|
|
698
|
+
wordBoundaryRefreshAttempts++;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
545
701
|
}
|
|
546
702
|
|
|
547
703
|
schedulePrediction(view);
|
|
548
704
|
}
|
|
549
705
|
},
|
|
550
706
|
destroy: () => {
|
|
707
|
+
destroyed = true;
|
|
708
|
+
currentView = null;
|
|
551
709
|
if (debounceTimer) {
|
|
552
710
|
clearTimeout(debounceTimer);
|
|
553
711
|
}
|
|
554
712
|
if (hasDestroy(slowLaneClient)) {
|
|
555
713
|
slowLaneClient.destroy();
|
|
556
714
|
}
|
|
715
|
+
ingestedContextTexts.clear();
|
|
557
716
|
setDefaultSlowLaneClient(null);
|
|
558
717
|
},
|
|
559
718
|
}),
|
|
@@ -595,6 +595,14 @@ export const createLocalSlowLaneClient = (
|
|
|
595
595
|
let lastRequestedText = '';
|
|
596
596
|
let requestCounter = 0;
|
|
597
597
|
let latestRequestId = -1;
|
|
598
|
+
let inferenceInFlight = false;
|
|
599
|
+
let activeInferenceText: string | null = null;
|
|
600
|
+
// The requestId of the inference currently in flight. Tracked so the
|
|
601
|
+
// in-flight dedup path can restore `latestRequestId` to it — otherwise an
|
|
602
|
+
// intermediate keystroke that bumped `latestRequestId` would cause the
|
|
603
|
+
// in-flight (still-current) result to be discarded as stale.
|
|
604
|
+
let activeInferenceRequestId = -1;
|
|
605
|
+
let pendingInference: { requestId: number; text: string } | null = null;
|
|
598
606
|
let ready = false;
|
|
599
607
|
let destroyed = false;
|
|
600
608
|
let initFailed = false;
|
|
@@ -920,8 +928,8 @@ export const createLocalSlowLaneClient = (
|
|
|
920
928
|
hasLmLogits: storedLmLogits !== null,
|
|
921
929
|
});
|
|
922
930
|
} catch (err) {
|
|
923
|
-
// Discard errors for stale requests
|
|
924
|
-
if (requestId < latestRequestId) {
|
|
931
|
+
// Discard errors for stale requests or after teardown
|
|
932
|
+
if (requestId < latestRequestId || destroyed) {
|
|
925
933
|
return;
|
|
926
934
|
}
|
|
927
935
|
|
|
@@ -943,11 +951,55 @@ export const createLocalSlowLaneClient = (
|
|
|
943
951
|
|
|
944
952
|
// ── Context update (debounced) ─────────────────────────────────────────
|
|
945
953
|
|
|
954
|
+
const startInference = (text: string, requestId: number): void => {
|
|
955
|
+
// Self-contained guard: never start a new inference cycle after teardown,
|
|
956
|
+
// regardless of caller discipline.
|
|
957
|
+
if (destroyed) {
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
inferenceInFlight = true;
|
|
962
|
+
activeInferenceText = text;
|
|
963
|
+
activeInferenceRequestId = requestId;
|
|
964
|
+
|
|
965
|
+
void ensureEngineInitialized()
|
|
966
|
+
.then(() => runInference(text, requestId))
|
|
967
|
+
.catch(() => {})
|
|
968
|
+
.finally(() => {
|
|
969
|
+
inferenceInFlight = false;
|
|
970
|
+
activeInferenceText = null;
|
|
971
|
+
activeInferenceRequestId = -1;
|
|
972
|
+
|
|
973
|
+
const next = pendingInference;
|
|
974
|
+
pendingInference = null;
|
|
975
|
+
if (next && !destroyed) {
|
|
976
|
+
startInference(next.text, next.requestId);
|
|
977
|
+
}
|
|
978
|
+
});
|
|
979
|
+
};
|
|
980
|
+
|
|
946
981
|
const doUpdateContext = (text: string): void => {
|
|
947
982
|
if (destroyed || !text || text.trim().length === 0) {
|
|
948
983
|
return;
|
|
949
984
|
}
|
|
950
985
|
|
|
986
|
+
if (inferenceInFlight && text === activeInferenceText) {
|
|
987
|
+
// The latest desired text already matches the in-flight inference, so
|
|
988
|
+
// re-running it would be wasted work. But an intermediate keystroke may
|
|
989
|
+
// have bumped `latestRequestId` past the in-flight request (and then been
|
|
990
|
+
// coalesced away), which would cause runInference to discard the
|
|
991
|
+
// still-current result as stale. Pin `latestRequestId` back to the active
|
|
992
|
+
// request so its result is accepted, and drop any now-superseded pending
|
|
993
|
+
// request.
|
|
994
|
+
latestRequestId = activeInferenceRequestId;
|
|
995
|
+
pendingInference = null;
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
if (inferenceInFlight && pendingInference?.text === text) {
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
951
1003
|
const requestId = ++requestCounter;
|
|
952
1004
|
latestRequestId = requestId;
|
|
953
1005
|
|
|
@@ -967,15 +1019,26 @@ export const createLocalSlowLaneClient = (
|
|
|
967
1019
|
console.groupEnd();
|
|
968
1020
|
}
|
|
969
1021
|
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
1022
|
+
if (inferenceInFlight) {
|
|
1023
|
+
pendingInference = { text, requestId };
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
startInference(text, requestId);
|
|
973
1028
|
};
|
|
974
1029
|
|
|
975
1030
|
const updateContextDebounced = (text: string): void => {
|
|
976
1031
|
if (debounceTimer) {
|
|
977
1032
|
clearTimeout(debounceTimer);
|
|
978
1033
|
}
|
|
1034
|
+
if (inferenceInFlight) {
|
|
1035
|
+
pendingInference = null;
|
|
1036
|
+
if (text === activeInferenceText) {
|
|
1037
|
+
latestRequestId = activeInferenceRequestId;
|
|
1038
|
+
lastRequestedText = text;
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
979
1042
|
lastRequestedText = text;
|
|
980
1043
|
debounceTimer = setTimeout(() => {
|
|
981
1044
|
debounceTimer = null;
|
|
@@ -1008,6 +1071,10 @@ export const createLocalSlowLaneClient = (
|
|
|
1008
1071
|
unloadEngine(engineToUnload);
|
|
1009
1072
|
}
|
|
1010
1073
|
engineInitPromise = null;
|
|
1074
|
+
inferenceInFlight = false;
|
|
1075
|
+
activeInferenceText = null;
|
|
1076
|
+
activeInferenceRequestId = -1;
|
|
1077
|
+
pendingInference = null;
|
|
1011
1078
|
storedContextVector = null;
|
|
1012
1079
|
storedLmLogits = null;
|
|
1013
1080
|
},
|