@atlaskit/editor-plugin-autocomplete 9.1.0 → 9.2.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 +105 -0
- package/dist/cjs/pm-plugins/autocomplete-plugin.js +496 -50
- package/dist/cjs/pm-plugins/debug-mode.js +14 -3
- package/dist/cjs/pm-plugins/inline-code-harvester.js +576 -0
- package/dist/cjs/pm-plugins/text-predictor.js +298 -151
- package/dist/es2019/pm-plugins/autocomplete-plugin.js +457 -36
- package/dist/es2019/pm-plugins/debug-mode.js +13 -2
- package/dist/es2019/pm-plugins/inline-code-harvester.js +455 -0
- package/dist/es2019/pm-plugins/text-predictor.js +136 -4
- package/dist/esm/pm-plugins/autocomplete-plugin.js +494 -50
- package/dist/esm/pm-plugins/debug-mode.js +13 -2
- package/dist/esm/pm-plugins/inline-code-harvester.js +572 -0
- package/dist/esm/pm-plugins/text-predictor.js +297 -150
- package/dist/types/pm-plugins/autocomplete-plugin.d.ts +37 -0
- package/dist/types/pm-plugins/debug-mode.d.ts +19 -4
- package/dist/types/pm-plugins/inline-code-harvester.d.ts +146 -0
- package/dist/types/pm-plugins/text-predictor.d.ts +70 -3
- package/package.json +2 -2
- package/src/pm-plugins/autocomplete-plugin.ts +526 -31
- package/src/pm-plugins/debug-mode.ts +26 -5
- package/src/pm-plugins/inline-code-harvester.ts +561 -0
- package/src/pm-plugins/text-predictor.ts +162 -7
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { ACTION, ACTION_SUBJECT, EVENT_TYPE } from '@atlaskit/editor-common/analytics';
|
|
2
|
+
import type { CompletionSource } from '@atlaskit/editor-common/analytics';
|
|
2
3
|
import { logException } from '@atlaskit/editor-common/monitoring';
|
|
3
4
|
import { SafePlugin } from '@atlaskit/editor-common/safe-plugin';
|
|
4
5
|
import type { ExtractInjectionAPI } from '@atlaskit/editor-common/types';
|
|
5
6
|
import { keydownHandler } from '@atlaskit/editor-prosemirror/keymap';
|
|
7
|
+
import type { Node as PMNode } from '@atlaskit/editor-prosemirror/model';
|
|
6
8
|
import { PluginKey } from '@atlaskit/editor-prosemirror/state';
|
|
7
9
|
import type {
|
|
8
10
|
EditorState,
|
|
@@ -21,6 +23,9 @@ import {
|
|
|
21
23
|
isAutocompleteDebugVerbose,
|
|
22
24
|
} from './debug-mode';
|
|
23
25
|
import { createGhostTextDecorationSet } from './ghost-text-decoration';
|
|
26
|
+
// Type-only, so the harvester chunk is still reached exclusively through the
|
|
27
|
+
// dynamic import in `ensureInlineCodeHarvester`.
|
|
28
|
+
import type { HarvestMatch } from './inline-code-harvester';
|
|
24
29
|
import {
|
|
25
30
|
createLocalSlowLaneClient,
|
|
26
31
|
type LocalSlowLaneClient,
|
|
@@ -38,13 +43,17 @@ import {
|
|
|
38
43
|
} from './slow-lane-client';
|
|
39
44
|
import {
|
|
40
45
|
predict,
|
|
46
|
+
type CtcAbstainReason,
|
|
41
47
|
type PredictionResult,
|
|
42
48
|
loadDefaultVocabulary,
|
|
43
49
|
loadVectorsAsync,
|
|
44
50
|
incrementSessionFreq,
|
|
45
51
|
ingestDocumentPage,
|
|
46
52
|
getLastPredictionDebug,
|
|
53
|
+
getLastPredictionOutcome,
|
|
54
|
+
isSurfaceInAcceptCooldown,
|
|
47
55
|
noteSuggestionAccepted,
|
|
56
|
+
resetSessionBoosts,
|
|
48
57
|
} from './text-predictor';
|
|
49
58
|
|
|
50
59
|
export const autocompletePluginKey: PluginKey = new PluginKey('autocomplete');
|
|
@@ -58,6 +67,33 @@ const CONTEXT_REFRESH_THROTTLE_MS = 1000;
|
|
|
58
67
|
// version of the (potentially hundreds-of-KB) page content for the plugin's
|
|
59
68
|
// lifetime. Eviction is FIFO; a re-ingest of an evicted text is harmless.
|
|
60
69
|
const MAX_INGESTED_CONTEXT_TEXTS = 50;
|
|
70
|
+
// Re-prime callback of every mounted editor. The harvested set and the L1
|
|
71
|
+
// boosts are shared by all of them, and more than one is mounted routinely — a
|
|
72
|
+
// comment box alongside its replies, or a comment box alongside the chat input.
|
|
73
|
+
// Membership doubles as the mount count, so a teardown only clears the shared
|
|
74
|
+
// stores once the last editor has gone.
|
|
75
|
+
//
|
|
76
|
+
// Decision: one pot, shared across hosts. Terms harvested in a comment box can
|
|
77
|
+
// be offered in the chat input and the other way round. Both are content from
|
|
78
|
+
// the page the reader is looking at, so the mixing is between things they can
|
|
79
|
+
// already see; partitioning the boosts would mean a per-host overlay on every
|
|
80
|
+
// trie node, which the scoring path reads on every keystroke.
|
|
81
|
+
const mountedEditors = new Set<() => void>();
|
|
82
|
+
// The scope that shared learning belongs to, as last reported by a host that
|
|
83
|
+
// scopes its context. Held here rather than per editor because the data it
|
|
84
|
+
// guards is shared and some hosts replace one editor with another across a
|
|
85
|
+
// navigation: an instance-local key would read as unset on exactly the change it
|
|
86
|
+
// exists to catch, while the previous scope's terms carried on in these globals.
|
|
87
|
+
// Undefined until a scoping host says otherwise. An editor that sends no key
|
|
88
|
+
// still shares the pot being emptied, so it is re-primed from the context it
|
|
89
|
+
// already holds rather than left with learning it can no longer see.
|
|
90
|
+
let contextScopeKey: string | undefined;
|
|
91
|
+
type InlineCodeHarvesterModule = typeof import('./inline-code-harvester');
|
|
92
|
+
// Set by whichever editor first finished importing the harvester chunk. The
|
|
93
|
+
// reset paths go through this rather than their own copy, because the editor
|
|
94
|
+
// that has to clear the set is not always one that imported it — the set is one
|
|
95
|
+
// per module, and nothing can be in it unless some editor got this far.
|
|
96
|
+
let loadedInlineCodeHarvester: InlineCodeHarvesterModule | null = null;
|
|
61
97
|
// Caps how many times the word-boundary path will retry getContext() while the
|
|
62
98
|
// parent comment is still missing. Combined with the 1s throttle this gives a
|
|
63
99
|
// ~5s window to cover a still-loading comment thread, then stops permanently so
|
|
@@ -117,6 +153,17 @@ export interface CommittedSuggestion {
|
|
|
117
153
|
posterior: number;
|
|
118
154
|
/** Final confidence-v2 ranking score. */
|
|
119
155
|
rankScore: number;
|
|
156
|
+
/**
|
|
157
|
+
* Characters of already-typed prefix the insertion overwrites, for surfaces
|
|
158
|
+
* that carry their own casing.
|
|
159
|
+
*
|
|
160
|
+
* The scored path appends its tail and leaves what the user typed alone,
|
|
161
|
+
* which is right for prose. A harvested identifier is matched
|
|
162
|
+
* case-insensitively but is only correct in the casing it was written in, so
|
|
163
|
+
* accepting `ml-s` against `ML-Studio` has to replace rather than append.
|
|
164
|
+
* Absent on every other path.
|
|
165
|
+
*/
|
|
166
|
+
replacesTypedPrefixLength?: number;
|
|
120
167
|
/** Monotonic editor decision revision that owns this suggestion. */
|
|
121
168
|
revision: number;
|
|
122
169
|
/** How many scored candidates this surface's normaliser divided between. */
|
|
@@ -202,8 +249,17 @@ const advanceGhostThroughTypedCharacter = (
|
|
|
202
249
|
ghostPosition: nextPosition,
|
|
203
250
|
decorationSet: createGhostTextDecorationSet(tr.doc, nextPosition, remaining),
|
|
204
251
|
// `surface` stays the full candidate so cooldown and analytics still
|
|
205
|
-
// describe the suggestion that was originally committed.
|
|
206
|
-
|
|
252
|
+
// describe the suggestion that was originally committed. The replaced
|
|
253
|
+
// prefix grows with the keystroke: it is the run the insertion overwrites,
|
|
254
|
+
// and the user has just typed one more character of it.
|
|
255
|
+
suggestion: {
|
|
256
|
+
...suggestion,
|
|
257
|
+
ghostText: remaining,
|
|
258
|
+
position: nextPosition,
|
|
259
|
+
...(suggestion.replacesTypedPrefixLength === undefined
|
|
260
|
+
? {}
|
|
261
|
+
: { replacesTypedPrefixLength: suggestion.replacesTypedPrefixLength + 1 }),
|
|
262
|
+
},
|
|
207
263
|
};
|
|
208
264
|
};
|
|
209
265
|
|
|
@@ -244,8 +300,11 @@ const getTextBeforeCursor = (state: EditorState): string => {
|
|
|
244
300
|
return fullText.slice(-maxChars);
|
|
245
301
|
};
|
|
246
302
|
|
|
303
|
+
const getTrailingSurfaceToken = (text: string): string =>
|
|
304
|
+
text.trimEnd().match(TRAILING_SURFACE_PREFIX_REGEX)?.[0] ?? '';
|
|
305
|
+
|
|
247
306
|
const getTrailingSurfacePrefixLength = (text: string): number =>
|
|
248
|
-
text
|
|
307
|
+
getTrailingSurfaceToken(text).length;
|
|
249
308
|
|
|
250
309
|
/**
|
|
251
310
|
* Set the autocomplete state via a transaction metadata.
|
|
@@ -268,10 +327,12 @@ const showGhostText = (
|
|
|
268
327
|
position: number,
|
|
269
328
|
revision: number,
|
|
270
329
|
decisionStartedAt: number,
|
|
330
|
+
replacesTypedPrefixLength?: number,
|
|
271
331
|
): CommittedSuggestion | null => {
|
|
272
332
|
try {
|
|
273
333
|
const { state, dispatch } = view;
|
|
274
334
|
const suggestion: CommittedSuggestion = {
|
|
335
|
+
...(replacesTypedPrefixLength === undefined ? {} : { replacesTypedPrefixLength }),
|
|
275
336
|
decisionLatencyMs: performance.now() - decisionStartedAt,
|
|
276
337
|
evidenceTier: prediction.evidenceTier,
|
|
277
338
|
ghostText: prediction.text,
|
|
@@ -342,7 +403,30 @@ const acceptGhostText = (
|
|
|
342
403
|
if (dispatch) {
|
|
343
404
|
try {
|
|
344
405
|
const { ghostText, ghostPosition, suggestion } = pluginState;
|
|
345
|
-
|
|
406
|
+
// Replacing back over the typed prefix rewrites it in the surface's own
|
|
407
|
+
// casing; every other path appends and leaves the prefix untouched.
|
|
408
|
+
const replaceFrom = Math.max(
|
|
409
|
+
0,
|
|
410
|
+
ghostPosition - Math.min(suggestion.replacesTypedPrefixLength ?? 0, ghostPosition),
|
|
411
|
+
);
|
|
412
|
+
let tr = state.tr;
|
|
413
|
+
if (replaceFrom === ghostPosition) {
|
|
414
|
+
tr = tr.insertText(ghostText, ghostPosition);
|
|
415
|
+
} else {
|
|
416
|
+
tr = tr.insertText(suggestion.surface, replaceFrom, ghostPosition);
|
|
417
|
+
// Only the harvested path replaces, and what authorized it was the
|
|
418
|
+
// surface being marked as code somewhere in the session. Inserting it
|
|
419
|
+
// as plain text loses that, so the identifier the user accepted reads
|
|
420
|
+
// as prose while the same identifier they typed by hand does not.
|
|
421
|
+
const codeMark = state.schema.marks.code;
|
|
422
|
+
if (codeMark) {
|
|
423
|
+
tr = tr.addMark(replaceFrom, replaceFrom + suggestion.surface.length, codeMark.create());
|
|
424
|
+
// The backtick input rule never ran, so nothing else will close this
|
|
425
|
+
// mark. Without dropping it from the stored set the next character
|
|
426
|
+
// the user types continues the code span.
|
|
427
|
+
tr = tr.removeStoredMark(codeMark);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
346
430
|
tr = setAutocompleteMeta(tr, {
|
|
347
431
|
ghostText: '',
|
|
348
432
|
ghostPosition: -1,
|
|
@@ -381,6 +465,21 @@ const acceptGhostTextWithAnalytics = (
|
|
|
381
465
|
* priority boost.
|
|
382
466
|
*/
|
|
383
467
|
export interface AutocompleteContext {
|
|
468
|
+
/**
|
|
469
|
+
* Identifies what this context belongs to, for hosts whose editor outlives
|
|
470
|
+
* the thing it is writing about — a chat input that stays mounted across
|
|
471
|
+
* conversations and pages, say.
|
|
472
|
+
*
|
|
473
|
+
* When it changes, everything learned for the previous scope is dropped:
|
|
474
|
+
* both the L1 boosts and the harvested inline-code set are claims about what
|
|
475
|
+
* is being discussed, and neither transfers. The context reported alongside
|
|
476
|
+
* the new key is then ingested as if it were the first, so anything still
|
|
477
|
+
* current — an ongoing transcript, for instance — is primed again.
|
|
478
|
+
*
|
|
479
|
+
* Omit it and nothing resets; the editor learns for its own lifetime, which
|
|
480
|
+
* is right for a host mounted per comment or per page.
|
|
481
|
+
*/
|
|
482
|
+
contextScopeKey?: string;
|
|
384
483
|
/** Full page content as a string (e.g. markdown). */
|
|
385
484
|
fullPageContent?: string;
|
|
386
485
|
/** The currently selected text on the page, if any. */
|
|
@@ -397,6 +496,17 @@ export interface AutocompletePluginOptions {
|
|
|
397
496
|
* word-frequency boosting. Called lazily so the preset can remain synchronous.
|
|
398
497
|
*/
|
|
399
498
|
getContext?: () => Promise<AutocompleteContext | undefined>;
|
|
499
|
+
/**
|
|
500
|
+
* Opt in to suggesting inline-code identifiers seen in this session
|
|
501
|
+
* (`ml-studio` and friends), which no vocabulary holds and the scored path
|
|
502
|
+
* therefore cannot reach.
|
|
503
|
+
*
|
|
504
|
+
* Off by default and passed only by hosts whose own experiment enrolment
|
|
505
|
+
* covers it, so a surface that shares this plugin under a different gate is
|
|
506
|
+
* unaffected until it opts in too. The harvester is a separate chunk and is
|
|
507
|
+
* not requested at all while this is false.
|
|
508
|
+
*/
|
|
509
|
+
harvestInlineCode?: boolean;
|
|
400
510
|
/**
|
|
401
511
|
* User locale used to determine whether autocomplete should run.
|
|
402
512
|
* Defaults to browser locale when omitted.
|
|
@@ -490,6 +600,69 @@ const getLeadingTextCharacter = (text?: string): string | undefined => {
|
|
|
490
600
|
return String.fromCodePoint(firstCodePoint);
|
|
491
601
|
};
|
|
492
602
|
|
|
603
|
+
/**
|
|
604
|
+
* The one verdict a harvested surface may speak on.
|
|
605
|
+
*
|
|
606
|
+
* `no-candidate` is the only abstention that means no vocabulary reaches the
|
|
607
|
+
* prefix at all. Every other one — a margin the model cannot clear, a rival
|
|
608
|
+
* still being read — describes known words in contention, where a session
|
|
609
|
+
* surface with no score behind it would be overruling the ranker rather than
|
|
610
|
+
* filling a gap it left.
|
|
611
|
+
*/
|
|
612
|
+
const HARVEST_ELIGIBLE_ABSTAIN_REASON: CtcAbstainReason = 'no-candidate';
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Whether the scored path has finished with this exact prefix and found nothing.
|
|
616
|
+
*
|
|
617
|
+
* The 100ms deadline expiring is not the same answer: it means the ranker was
|
|
618
|
+
* still working, and displaying then would race a suggestion that is about to
|
|
619
|
+
* arrive. So the harvest path reads the recorded verdict rather than the clock.
|
|
620
|
+
*/
|
|
621
|
+
const scoredPathFinishedEmpty = (textBefore: string): boolean => {
|
|
622
|
+
const outcome = getLastPredictionOutcome();
|
|
623
|
+
return (
|
|
624
|
+
outcome !== null &&
|
|
625
|
+
outcome.textBefore === textBefore &&
|
|
626
|
+
!outcome.awaitingAsyncEvidence &&
|
|
627
|
+
outcome.abstainReason === HARVEST_ELIGIBLE_ABSTAIN_REASON
|
|
628
|
+
);
|
|
629
|
+
};
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Whether the surface is already sitting immediately before the typed prefix.
|
|
633
|
+
*
|
|
634
|
+
* Completing `ml-s` to `ml-studio` right after `ml-studio` produces the echo the
|
|
635
|
+
* scored path's repetition guard exists to stop, and this path does not go
|
|
636
|
+
* through arbitration to inherit it.
|
|
637
|
+
*/
|
|
638
|
+
const repeatsPrecedingText = (match: HarvestMatch, textBefore: string): boolean => {
|
|
639
|
+
const trimmed = textBefore.trimEnd();
|
|
640
|
+
const preceding = trimmed.slice(0, trimmed.length - match.typedPrefixLength).trimEnd();
|
|
641
|
+
return preceding.toLowerCase().endsWith(match.surface.toLowerCase());
|
|
642
|
+
};
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Dress a harvested match as a prediction so it commits through the same path as
|
|
646
|
+
* a scored one.
|
|
647
|
+
*
|
|
648
|
+
* The scoring fields are zeroed rather than invented: there is no posterior, no
|
|
649
|
+
* margin and no shortlist behind this surface, and `session-harvest` on the
|
|
650
|
+
* evidence tier is what says so wherever the snapshot is read.
|
|
651
|
+
*/
|
|
652
|
+
const buildHarvestPrediction = (match: HarvestMatch): PredictionResult => ({
|
|
653
|
+
evidenceDepth: { totalChars: 0, totalTokens: 0, verifiedChars: 0, verifiedTokens: 0 },
|
|
654
|
+
evidenceTier: 'session-harvest',
|
|
655
|
+
meanTokenLogProbability: 0,
|
|
656
|
+
poolHeldExtension: false,
|
|
657
|
+
posterior: 0,
|
|
658
|
+
rankScore: 0,
|
|
659
|
+
shortlistSize: 0,
|
|
660
|
+
surface: match.surface,
|
|
661
|
+
termType: 'word',
|
|
662
|
+
text: match.ghostText,
|
|
663
|
+
winnerMargin: 0,
|
|
664
|
+
});
|
|
665
|
+
|
|
493
666
|
const isEnglishLocale = (locale?: string): boolean => {
|
|
494
667
|
if (!locale) {
|
|
495
668
|
return false;
|
|
@@ -506,6 +679,7 @@ export const createAutocompletePlugin = (
|
|
|
506
679
|
const locale =
|
|
507
680
|
options?.locale ?? (typeof navigator !== 'undefined' ? navigator.language : undefined);
|
|
508
681
|
const isAutocompleteEnabled = isEnglishLocale(locale);
|
|
682
|
+
const isInlineCodeHarvestEnabled = isAutocompleteEnabled && options?.harvestInlineCode === true;
|
|
509
683
|
|
|
510
684
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
511
685
|
let decisionDeadlineTimer: ReturnType<typeof setTimeout> | null = null;
|
|
@@ -543,19 +717,33 @@ export const createAutocompletePlugin = (
|
|
|
543
717
|
* server → server slow-lane API returned the context vector
|
|
544
718
|
* localLlm → on-device WebGPU/MLC model returned the context vector
|
|
545
719
|
*/
|
|
546
|
-
const getCompletionSource = ():
|
|
720
|
+
const getCompletionSource = (): CompletionSource => {
|
|
547
721
|
if (!slowLaneClient.getContextVector()) {
|
|
548
722
|
return 'cold';
|
|
549
723
|
}
|
|
550
724
|
return options?.useLocalModel ? 'localLlm' : 'server';
|
|
551
725
|
};
|
|
552
726
|
|
|
553
|
-
|
|
727
|
+
/**
|
|
728
|
+
* Which path produced a given suggestion.
|
|
729
|
+
*
|
|
730
|
+
* A harvested surface is reported as `harvest` rather than by the slow-lane
|
|
731
|
+
* state it happened to be shown under, so its views and acceptances stay
|
|
732
|
+
* separable from the scored path's and cannot quietly move the headline
|
|
733
|
+
* acceptance rate.
|
|
734
|
+
*/
|
|
735
|
+
const completionSourceFor = (suggestion: CommittedSuggestion | null): CompletionSource =>
|
|
736
|
+
suggestion?.evidenceTier === 'session-harvest' ? 'harvest' : getCompletionSource();
|
|
737
|
+
|
|
738
|
+
const fireSuggestionDismissedAnalytics = (
|
|
739
|
+
reason: 'escape' | 'blur' | 'click',
|
|
740
|
+
suggestion: CommittedSuggestion | null,
|
|
741
|
+
): void => {
|
|
554
742
|
api?.analytics?.actions.fireAnalyticsEvent({
|
|
555
743
|
action: ACTION.SUGGESTION_DISMISSED,
|
|
556
744
|
actionSubject: ACTION_SUBJECT.CONTEXTUAL_TYPEAHEAD,
|
|
557
745
|
eventType: EVENT_TYPE.TRACK,
|
|
558
|
-
attributes: { completionSource:
|
|
746
|
+
attributes: { completionSource: completionSourceFor(suggestion), reason, surface },
|
|
559
747
|
});
|
|
560
748
|
};
|
|
561
749
|
|
|
@@ -608,7 +796,7 @@ export const createAutocompletePlugin = (
|
|
|
608
796
|
actionSubject: ACTION_SUBJECT.CONTEXTUAL_TYPEAHEAD,
|
|
609
797
|
eventType: EVENT_TYPE.TRACK,
|
|
610
798
|
attributes: {
|
|
611
|
-
completionSource:
|
|
799
|
+
completionSource: completionSourceFor(suggestion),
|
|
612
800
|
suggestionLength,
|
|
613
801
|
typedLength,
|
|
614
802
|
kssDelta,
|
|
@@ -662,6 +850,10 @@ export const createAutocompletePlugin = (
|
|
|
662
850
|
);
|
|
663
851
|
|
|
664
852
|
let contextRequestInFlight = false;
|
|
853
|
+
// A read the host asked for while another was open, kept so it can be made
|
|
854
|
+
// once that one settles. One slot, not a queue: a burst of notifications only
|
|
855
|
+
// ever means "read again", and the last of them describes the current state.
|
|
856
|
+
let queuedContextRefreshSource: string | undefined;
|
|
665
857
|
let lastContextRefreshAt = 0;
|
|
666
858
|
// Bounds the word-boundary retry loop so it terminates even when the editor is
|
|
667
859
|
// not in a comment thread (parentCommentContent never resolves).
|
|
@@ -679,6 +871,17 @@ export const createAutocompletePlugin = (
|
|
|
679
871
|
let isVocabularyReady = false;
|
|
680
872
|
let vocabularyLoadPromise: Promise<void> | undefined;
|
|
681
873
|
|
|
874
|
+
// Context text waiting on the harvester chunk, the vocabulary, or both. The
|
|
875
|
+
// harvester drops surfaces the vocabulary already holds, so intake before the
|
|
876
|
+
// load has settled would admit ordinary words no one needs completed.
|
|
877
|
+
const pendingHarvestTexts = new Set<string>();
|
|
878
|
+
// Separate from `sessionIngestedContextTexts` because the two consumers settle
|
|
879
|
+
// at different times: reply occurrences accumulate, so text fed twice would
|
|
880
|
+
// count twice.
|
|
881
|
+
const harvestedContextTexts = new Set<string>();
|
|
882
|
+
let inlineCodeHarvester: InlineCodeHarvesterModule | null = null;
|
|
883
|
+
let inlineCodeHarvesterPromise: Promise<InlineCodeHarvesterModule | null> | undefined;
|
|
884
|
+
|
|
682
885
|
const addBoundedContextText = (texts: Set<string>, text: string): void => {
|
|
683
886
|
texts.add(text);
|
|
684
887
|
// Evict oldest entries (Set preserves insertion order) to bound memory.
|
|
@@ -704,9 +907,84 @@ export const createAutocompletePlugin = (
|
|
|
704
907
|
}
|
|
705
908
|
};
|
|
706
909
|
|
|
910
|
+
const flushPendingHarvestTexts = (): void => {
|
|
911
|
+
const harvester = inlineCodeHarvester;
|
|
912
|
+
if (!harvester || !isVocabularyReady || destroyed || pendingHarvestTexts.size === 0) {
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
for (const text of pendingHarvestTexts) {
|
|
916
|
+
if (!harvestedContextTexts.has(text)) {
|
|
917
|
+
harvester.harvestInlineCodeFromText(text);
|
|
918
|
+
addBoundedContextText(harvestedContextTexts, text);
|
|
919
|
+
}
|
|
920
|
+
pendingHarvestTexts.delete(text);
|
|
921
|
+
}
|
|
922
|
+
harvester.logInlineCodeHarvest('context');
|
|
923
|
+
};
|
|
924
|
+
|
|
925
|
+
/**
|
|
926
|
+
* Load the harvester chunk, once, and only for a host that asked for it.
|
|
927
|
+
*
|
|
928
|
+
* Kept off the critical path in the same way the vocabulary and vector loads
|
|
929
|
+
* are: nothing is requested until the editor is focused, so a session that
|
|
930
|
+
* never types in the chat pays nothing for the feature.
|
|
931
|
+
*/
|
|
932
|
+
const ensureInlineCodeHarvester = (): Promise<InlineCodeHarvesterModule | null> => {
|
|
933
|
+
if (!isInlineCodeHarvestEnabled || destroyed) {
|
|
934
|
+
return Promise.resolve(null);
|
|
935
|
+
}
|
|
936
|
+
inlineCodeHarvesterPromise ??= import(
|
|
937
|
+
/* webpackChunkName: "@atlaskit-internal_editor-plugin-autocomplete-inline-code-harvester" */
|
|
938
|
+
'./inline-code-harvester'
|
|
939
|
+
)
|
|
940
|
+
.then((module) => {
|
|
941
|
+
if (destroyed) {
|
|
942
|
+
return null;
|
|
943
|
+
}
|
|
944
|
+
inlineCodeHarvester = module;
|
|
945
|
+
loadedInlineCodeHarvester = module;
|
|
946
|
+
flushPendingHarvestTexts();
|
|
947
|
+
return module;
|
|
948
|
+
})
|
|
949
|
+
.catch((error) => {
|
|
950
|
+
// Clear the promise so a later focus can retry; the queued text is
|
|
951
|
+
// still held and is fed by whichever attempt succeeds.
|
|
952
|
+
inlineCodeHarvesterPromise = undefined;
|
|
953
|
+
logException(error as Error, {
|
|
954
|
+
location: 'editor-plugin-autocomplete/loadInlineCodeHarvester',
|
|
955
|
+
});
|
|
956
|
+
return null;
|
|
957
|
+
});
|
|
958
|
+
return inlineCodeHarvesterPromise;
|
|
959
|
+
};
|
|
960
|
+
|
|
961
|
+
/**
|
|
962
|
+
* Re-read the document's code-marked spans.
|
|
963
|
+
*
|
|
964
|
+
* The backtick input rule consumes both delimiters on the closing tick, so a
|
|
965
|
+
* finished span in the live document is only findable through its mark — and
|
|
966
|
+
* only by walking, since a mark carries no notification.
|
|
967
|
+
*/
|
|
968
|
+
const harvestDocument = (doc: PMNode, trigger: string): void => {
|
|
969
|
+
if (!isInlineCodeHarvestEnabled || destroyed) {
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
const harvester = inlineCodeHarvester;
|
|
973
|
+
if (!harvester) {
|
|
974
|
+
void ensureInlineCodeHarvester();
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
if (!isVocabularyReady) {
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
harvester.harvestInlineCodeFromDoc(doc);
|
|
981
|
+
harvester.logInlineCodeHarvest(trigger);
|
|
982
|
+
};
|
|
983
|
+
|
|
707
984
|
const ensureVocabularyReady = (): Promise<void> => {
|
|
708
985
|
if (isVocabularyReady) {
|
|
709
986
|
flushPendingSessionContext();
|
|
987
|
+
flushPendingHarvestTexts();
|
|
710
988
|
return Promise.resolve();
|
|
711
989
|
}
|
|
712
990
|
vocabularyLoadPromise ??= loadDefaultVocabulary({
|
|
@@ -719,6 +997,7 @@ export const createAutocompletePlugin = (
|
|
|
719
997
|
}
|
|
720
998
|
isVocabularyReady = true;
|
|
721
999
|
flushPendingSessionContext();
|
|
1000
|
+
flushPendingHarvestTexts();
|
|
722
1001
|
})
|
|
723
1002
|
.catch((error) => {
|
|
724
1003
|
// Do not consume the pending text on failure. A later focus retries the
|
|
@@ -739,6 +1018,7 @@ export const createAutocompletePlugin = (
|
|
|
739
1018
|
// eslint-disable-next-line no-console
|
|
740
1019
|
console.log('%c[CTC:signal]%c getContext resolved', CTC_STYLES.brand, CTC_STYLES.body, {
|
|
741
1020
|
source,
|
|
1021
|
+
scope: context?.contextScopeKey,
|
|
742
1022
|
hasParentComment: !!context?.parentCommentContent,
|
|
743
1023
|
parentCommentPreview: context?.parentCommentContent?.slice(0, 80),
|
|
744
1024
|
siblingCount: context?.siblingCommentsContents?.length ?? 0,
|
|
@@ -746,7 +1026,109 @@ export const createAutocompletePlugin = (
|
|
|
746
1026
|
});
|
|
747
1027
|
};
|
|
748
1028
|
|
|
1029
|
+
const ingestContextText = (text?: string): void => {
|
|
1030
|
+
if (!text) {
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
if (!sessionIngestedContextTexts.has(text)) {
|
|
1035
|
+
addBoundedContextText(pendingSessionContextTexts, text);
|
|
1036
|
+
}
|
|
1037
|
+
// Queued unconditionally rather than only once the chunk is present:
|
|
1038
|
+
// context usually resolves in the same tick the load starts, and text
|
|
1039
|
+
// dropped for arriving early is a reply that can never be harvested.
|
|
1040
|
+
if (isInlineCodeHarvestEnabled && !harvestedContextTexts.has(text)) {
|
|
1041
|
+
addBoundedContextText(pendingHarvestTexts, text);
|
|
1042
|
+
}
|
|
1043
|
+
};
|
|
1044
|
+
|
|
1045
|
+
const ingestResolvedContext = (): void => {
|
|
1046
|
+
ingestContextText(resolvedContext?.fullPageContent);
|
|
1047
|
+
ingestContextText(resolvedContext?.parentCommentContent);
|
|
1048
|
+
for (const siblingCommentContent of resolvedContext?.siblingCommentsContents ?? []) {
|
|
1049
|
+
ingestContextText(siblingCommentContent);
|
|
1050
|
+
}
|
|
1051
|
+
flushPendingSessionContext();
|
|
1052
|
+
flushPendingHarvestTexts();
|
|
1053
|
+
};
|
|
1054
|
+
|
|
1055
|
+
/**
|
|
1056
|
+
* Put back what this editor had contributed to the pot another editor just
|
|
1057
|
+
* emptied.
|
|
1058
|
+
*
|
|
1059
|
+
* The scope that ended belongs to the host that reported it, not to everyone
|
|
1060
|
+
* sharing these globals. Without this an editor alongside it — a comment box
|
|
1061
|
+
* under a chat panel that switched conversation — is left with none of its
|
|
1062
|
+
* learning and no way back to it: the dedupe sets below are per editor, so
|
|
1063
|
+
* its own page reads as already ingested and is skipped from then on.
|
|
1064
|
+
*
|
|
1065
|
+
* Nothing is re-fetched. The context this editor resolved is still held, and
|
|
1066
|
+
* what it describes has not changed just because another host moved on.
|
|
1067
|
+
*
|
|
1068
|
+
* Only for an editor that reported no scope. One that did is party to the
|
|
1069
|
+
* same scope system and the scope just dropped may well be its own: a
|
|
1070
|
+
* Confluence page transition mounts the incoming editor before tearing down
|
|
1071
|
+
* the outgoing one, and the outgoing one is still holding the page that was
|
|
1072
|
+
* left. Putting that back is exactly what the eviction was for.
|
|
1073
|
+
*/
|
|
1074
|
+
const reprimeSessionLearning = (): void => {
|
|
1075
|
+
if (destroyed || resolvedContext?.contextScopeKey !== undefined) {
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
pendingSessionContextTexts.clear();
|
|
1079
|
+
sessionIngestedContextTexts.clear();
|
|
1080
|
+
pendingHarvestTexts.clear();
|
|
1081
|
+
harvestedContextTexts.clear();
|
|
1082
|
+
ingestResolvedContext();
|
|
1083
|
+
if (currentView) {
|
|
1084
|
+
harvestDocument(currentView.state.doc, 'reprime');
|
|
1085
|
+
}
|
|
1086
|
+
};
|
|
1087
|
+
|
|
1088
|
+
/**
|
|
1089
|
+
* Drop everything learned for the scope that just ended.
|
|
1090
|
+
*
|
|
1091
|
+
* Both stores are claims about what is being discussed, and neither survives
|
|
1092
|
+
* the discussion changing: L1 boosts would keep a page's words ranked above
|
|
1093
|
+
* the next page's, and the harvested set holds one conversation's content.
|
|
1094
|
+
* The dedupe sets go too, so the context that arrives next is treated as
|
|
1095
|
+
* unseen and re-primes both — which is what makes this safe to do on a page
|
|
1096
|
+
* change, since the transcript is re-ingested along with the new page.
|
|
1097
|
+
*
|
|
1098
|
+
* The stores are shared, so every other mounted editor is put back in the
|
|
1099
|
+
* same tick from context it already holds. Only the scope that ended is
|
|
1100
|
+
* actually dropped.
|
|
1101
|
+
*/
|
|
1102
|
+
const resetSessionScopedLearning = (reason: string): void => {
|
|
1103
|
+
resetSessionBoosts();
|
|
1104
|
+
loadedInlineCodeHarvester?.resetInlineCodeHarvest();
|
|
1105
|
+
resolvedContext = undefined;
|
|
1106
|
+
pendingSessionContextTexts.clear();
|
|
1107
|
+
sessionIngestedContextTexts.clear();
|
|
1108
|
+
pendingHarvestTexts.clear();
|
|
1109
|
+
harvestedContextTexts.clear();
|
|
1110
|
+
ctcTag('init', `session-scoped learning reset · ${reason}`, CTC_STYLES.dim);
|
|
1111
|
+
for (const reprimeOther of mountedEditors) {
|
|
1112
|
+
if (reprimeOther !== reprimeSessionLearning) {
|
|
1113
|
+
reprimeOther();
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
};
|
|
1117
|
+
|
|
749
1118
|
const applyContext = (context: AutocompleteContext): void => {
|
|
1119
|
+
// A host that scopes its context tells us which scope each read belongs to.
|
|
1120
|
+
// A read with no recorded key to compare against only records: that is the
|
|
1121
|
+
// first since the last editor went away, so there is nothing left to throw
|
|
1122
|
+
// away and resetting would drop the priming focus just started. A read from
|
|
1123
|
+
// a newly mounted editor is not that case — the key outlives the editor
|
|
1124
|
+
// precisely so a host that remounts across a navigation still evicts.
|
|
1125
|
+
if (context.contextScopeKey !== undefined && context.contextScopeKey !== contextScopeKey) {
|
|
1126
|
+
if (contextScopeKey !== undefined) {
|
|
1127
|
+
resetSessionScopedLearning(`scope ${contextScopeKey} → ${context.contextScopeKey}`);
|
|
1128
|
+
}
|
|
1129
|
+
contextScopeKey = context.contextScopeKey;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
750
1132
|
// Merge rather than replace: the word-boundary retry may resolve only a
|
|
751
1133
|
// late-arriving field (e.g. parentCommentContent) without re-sending
|
|
752
1134
|
// fullPageContent, so replacing would drop previously resolved context.
|
|
@@ -757,20 +1139,7 @@ export const createAutocompletePlugin = (
|
|
|
757
1139
|
);
|
|
758
1140
|
resolvedContext = { ...resolvedContext, ...definedContext };
|
|
759
1141
|
|
|
760
|
-
|
|
761
|
-
if (!text || sessionIngestedContextTexts.has(text)) {
|
|
762
|
-
return;
|
|
763
|
-
}
|
|
764
|
-
|
|
765
|
-
addBoundedContextText(pendingSessionContextTexts, text);
|
|
766
|
-
};
|
|
767
|
-
|
|
768
|
-
ingestContextText(context.fullPageContent);
|
|
769
|
-
ingestContextText(context.parentCommentContent);
|
|
770
|
-
for (const siblingCommentContent of context.siblingCommentsContents ?? []) {
|
|
771
|
-
ingestContextText(siblingCommentContent);
|
|
772
|
-
}
|
|
773
|
-
flushPendingSessionContext();
|
|
1142
|
+
ingestResolvedContext();
|
|
774
1143
|
|
|
775
1144
|
// Context arrived after word boundaries may already have fired. Re-send
|
|
776
1145
|
// slow-lane context immediately so the next inference includes the thread.
|
|
@@ -793,7 +1162,20 @@ export const createAutocompletePlugin = (
|
|
|
793
1162
|
allowThrottle?: boolean;
|
|
794
1163
|
source: string;
|
|
795
1164
|
}): boolean => {
|
|
796
|
-
if (!options?.getContext
|
|
1165
|
+
if (!options?.getContext) {
|
|
1166
|
+
return false;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
if (contextRequestInFlight) {
|
|
1170
|
+
// Only the reads that bypass the throttle are worth keeping: those are
|
|
1171
|
+
// the ones the host asked for by name, and a scope change travels among
|
|
1172
|
+
// them with no second channel to arrive on, so losing one would strand
|
|
1173
|
+
// the eviction until something else happened to move. A word-boundary
|
|
1174
|
+
// poll is a retry loop for context that has not landed yet, and the read
|
|
1175
|
+
// already open will bring it.
|
|
1176
|
+
if (!allowThrottle) {
|
|
1177
|
+
queuedContextRefreshSource = source;
|
|
1178
|
+
}
|
|
797
1179
|
return false;
|
|
798
1180
|
}
|
|
799
1181
|
|
|
@@ -828,6 +1210,11 @@ export const createAutocompletePlugin = (
|
|
|
828
1210
|
})
|
|
829
1211
|
.finally(() => {
|
|
830
1212
|
contextRequestInFlight = false;
|
|
1213
|
+
const queuedSource = queuedContextRefreshSource;
|
|
1214
|
+
queuedContextRefreshSource = undefined;
|
|
1215
|
+
if (queuedSource !== undefined && !destroyed) {
|
|
1216
|
+
refreshContext({ source: queuedSource, allowThrottle: false });
|
|
1217
|
+
}
|
|
831
1218
|
});
|
|
832
1219
|
|
|
833
1220
|
return true;
|
|
@@ -869,6 +1256,63 @@ export const createAutocompletePlugin = (
|
|
|
869
1256
|
cancelActiveDecision();
|
|
870
1257
|
};
|
|
871
1258
|
|
|
1259
|
+
/**
|
|
1260
|
+
* Offer a harvested inline-code surface on a prefix the scored path left
|
|
1261
|
+
* empty. Returns whether one was committed.
|
|
1262
|
+
*/
|
|
1263
|
+
const commitHarvestedSuggestion = (view: EditorView, decision: PredictionDecision): boolean => {
|
|
1264
|
+
const harvester = inlineCodeHarvester;
|
|
1265
|
+
if (!harvester || !isInlineCodeHarvestEnabled) {
|
|
1266
|
+
return false;
|
|
1267
|
+
}
|
|
1268
|
+
if (!scoredPathFinishedEmpty(decision.textBefore)) {
|
|
1269
|
+
return false;
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
const typedPrefix = getTrailingSurfaceToken(decision.textBefore);
|
|
1273
|
+
const match = harvester.findHarvestedCompletion(typedPrefix);
|
|
1274
|
+
if (!match) {
|
|
1275
|
+
return false;
|
|
1276
|
+
}
|
|
1277
|
+
if (
|
|
1278
|
+
isSurfaceInAcceptCooldown(match.surface) ||
|
|
1279
|
+
repeatsPrecedingText(match, decision.textBefore)
|
|
1280
|
+
) {
|
|
1281
|
+
return false;
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
const suggestion = showGhostText(
|
|
1285
|
+
view,
|
|
1286
|
+
buildHarvestPrediction(match),
|
|
1287
|
+
decision.position,
|
|
1288
|
+
decision.revision,
|
|
1289
|
+
decision.startedAt,
|
|
1290
|
+
match.typedPrefixLength,
|
|
1291
|
+
);
|
|
1292
|
+
if (!suggestion) {
|
|
1293
|
+
return false;
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
cancelActiveDecision();
|
|
1297
|
+
ctcTag(
|
|
1298
|
+
'harvest',
|
|
1299
|
+
`offered "${match.surface}" for "${typedPrefix}" · ${match.collapsedBy} · ${match.replyOccurrences} reply / ${match.documentSpans} doc sightings${
|
|
1300
|
+
match.rivalSurfaces.length > 0 ? ` · over ${match.rivalSurfaces.join(', ')}` : ''
|
|
1301
|
+
}`,
|
|
1302
|
+
CTC_STYLES.lm,
|
|
1303
|
+
);
|
|
1304
|
+
if (suggestion.ghostText !== lastShownGhostText) {
|
|
1305
|
+
lastShownGhostText = suggestion.ghostText;
|
|
1306
|
+
api?.analytics?.actions.fireAnalyticsEvent({
|
|
1307
|
+
action: ACTION.SUGGESTION_VIEWED,
|
|
1308
|
+
actionSubject: ACTION_SUBJECT.CONTEXTUAL_TYPEAHEAD,
|
|
1309
|
+
eventType: EVENT_TYPE.TRACK,
|
|
1310
|
+
attributes: { completionSource: completionSourceFor(suggestion), surface },
|
|
1311
|
+
});
|
|
1312
|
+
}
|
|
1313
|
+
return true;
|
|
1314
|
+
};
|
|
1315
|
+
|
|
872
1316
|
const evaluateActiveDecision = (view: EditorView, revision: number): void => {
|
|
873
1317
|
const decision = activeDecision;
|
|
874
1318
|
if (!decision || decision.revision !== revision || !isDecisionCurrent(view, decision)) {
|
|
@@ -911,8 +1355,11 @@ export const createAutocompletePlugin = (
|
|
|
911
1355
|
|
|
912
1356
|
const prediction = predict(decision.textBefore);
|
|
913
1357
|
if (!prediction || prediction.text.length === 0) {
|
|
914
|
-
//
|
|
915
|
-
//
|
|
1358
|
+
// Only where the scored path has finished and come away with nothing
|
|
1359
|
+
// does a harvested surface get to answer; otherwise remain in
|
|
1360
|
+
// `collecting` until an async signal arrives or the hard deadline
|
|
1361
|
+
// expires. We never display a provisional fallback.
|
|
1362
|
+
commitHarvestedSuggestion(view, decision);
|
|
916
1363
|
return;
|
|
917
1364
|
}
|
|
918
1365
|
|
|
@@ -976,7 +1423,7 @@ export const createAutocompletePlugin = (
|
|
|
976
1423
|
action: ACTION.SUGGESTION_VIEWED,
|
|
977
1424
|
actionSubject: ACTION_SUBJECT.CONTEXTUAL_TYPEAHEAD,
|
|
978
1425
|
eventType: EVENT_TYPE.TRACK,
|
|
979
|
-
attributes: { completionSource:
|
|
1426
|
+
attributes: { completionSource: completionSourceFor(suggestion), surface },
|
|
980
1427
|
});
|
|
981
1428
|
}
|
|
982
1429
|
} catch (error) {
|
|
@@ -1188,11 +1635,14 @@ export const createAutocompletePlugin = (
|
|
|
1188
1635
|
});
|
|
1189
1636
|
},
|
|
1190
1637
|
Escape: (state: EditorState, dispatch?: (tr: Transaction) => void) => {
|
|
1638
|
+
const dismissed = (
|
|
1639
|
+
autocompletePluginKey.getState(state) as AutocompletePluginState | undefined
|
|
1640
|
+
)?.suggestion;
|
|
1191
1641
|
const didClear = clearGhostText(state, dispatch);
|
|
1192
1642
|
if (didClear) {
|
|
1193
1643
|
cancelActiveDecision();
|
|
1194
1644
|
dismissedContext = getTextBeforeCursor(state);
|
|
1195
|
-
fireSuggestionDismissedAnalytics('escape');
|
|
1645
|
+
fireSuggestionDismissedAnalytics('escape', dismissed ?? null);
|
|
1196
1646
|
}
|
|
1197
1647
|
return didClear;
|
|
1198
1648
|
},
|
|
@@ -1210,7 +1660,7 @@ export const createAutocompletePlugin = (
|
|
|
1210
1660
|
if (pluginState?.ghostText) {
|
|
1211
1661
|
clearGhostText(view.state, view.dispatch);
|
|
1212
1662
|
cancelActiveDecision();
|
|
1213
|
-
fireSuggestionDismissedAnalytics('blur');
|
|
1663
|
+
fireSuggestionDismissedAnalytics('blur', pluginState.suggestion);
|
|
1214
1664
|
}
|
|
1215
1665
|
return false;
|
|
1216
1666
|
},
|
|
@@ -1220,12 +1670,15 @@ export const createAutocompletePlugin = (
|
|
|
1220
1670
|
}
|
|
1221
1671
|
|
|
1222
1672
|
if (!event.target.closest('[data-autocomplete-ghost="true"]')) {
|
|
1673
|
+
const dismissed = (
|
|
1674
|
+
autocompletePluginKey.getState(view.state) as AutocompletePluginState | undefined
|
|
1675
|
+
)?.suggestion;
|
|
1223
1676
|
const didClear = clearGhostText(view.state, view.dispatch);
|
|
1224
1677
|
if (didClear) {
|
|
1225
1678
|
cancelActiveDecision();
|
|
1226
1679
|
dismissedContext = getTextBeforeCursor(view.state);
|
|
1227
1680
|
lastShownGhostText = '';
|
|
1228
|
-
fireSuggestionDismissedAnalytics('click');
|
|
1681
|
+
fireSuggestionDismissedAnalytics('click', dismissed ?? null);
|
|
1229
1682
|
}
|
|
1230
1683
|
return false;
|
|
1231
1684
|
}
|
|
@@ -1245,12 +1698,21 @@ export const createAutocompletePlugin = (
|
|
|
1245
1698
|
|
|
1246
1699
|
return accepted;
|
|
1247
1700
|
},
|
|
1248
|
-
focus: () => {
|
|
1701
|
+
focus: (view: EditorView) => {
|
|
1249
1702
|
if (!isAutocompleteEnabled) {
|
|
1250
1703
|
return false;
|
|
1251
1704
|
}
|
|
1252
1705
|
|
|
1253
1706
|
void ensureVocabularyReady();
|
|
1707
|
+
// First point at which the session is known to be using the input,
|
|
1708
|
+
// which is where the rest of the artifacts are requested too.
|
|
1709
|
+
void ensureInlineCodeHarvester()
|
|
1710
|
+
.then(() => harvestDocument(view.state.doc, 'focus'))
|
|
1711
|
+
.catch((error) => {
|
|
1712
|
+
logException(error as Error, {
|
|
1713
|
+
location: 'editor-plugin-autocomplete/harvestDocument',
|
|
1714
|
+
});
|
|
1715
|
+
});
|
|
1254
1716
|
loadVectorsAsync({
|
|
1255
1717
|
isLocalLLM: options?.useLocalModel ?? false,
|
|
1256
1718
|
surface,
|
|
@@ -1283,11 +1745,13 @@ export const createAutocompletePlugin = (
|
|
|
1283
1745
|
// Capture up front so a subscription notification before the first PM
|
|
1284
1746
|
// transaction can still drive slowLaneClient.updateContext (gated on currentView).
|
|
1285
1747
|
currentView = editorView;
|
|
1748
|
+
mountedEditors.add(reprimeSessionLearning);
|
|
1286
1749
|
|
|
1287
1750
|
// Push channel for hosts that keep producing context after mount (e.g. Rovo chat).
|
|
1288
1751
|
if (isAutocompleteEnabled && options?.subscribeToContextUpdates) {
|
|
1289
1752
|
unsubscribeFromContextUpdates = options.subscribeToContextUpdates(() => {
|
|
1290
|
-
// Bypass throttle for freshness;
|
|
1753
|
+
// Bypass throttle for freshness; a read still open defers this one
|
|
1754
|
+
// rather than overlapping it.
|
|
1291
1755
|
refreshContext({ source: 'subscription', allowThrottle: false });
|
|
1292
1756
|
});
|
|
1293
1757
|
}
|
|
@@ -1319,6 +1783,11 @@ export const createAutocompletePlugin = (
|
|
|
1319
1783
|
buildSlowLaneText(view.state.doc.textContent, resolvedContext),
|
|
1320
1784
|
);
|
|
1321
1785
|
|
|
1786
|
+
// The backtick input rule has fired by the time a span is
|
|
1787
|
+
// finished, so a word boundary is the earliest point the mark
|
|
1788
|
+
// exists to be found.
|
|
1789
|
+
harvestDocument(view.state.doc, 'word-boundary');
|
|
1790
|
+
|
|
1322
1791
|
// Context may not have resolved on first focus (e.g. comment
|
|
1323
1792
|
// thread still loading). Retry on word boundaries until we have
|
|
1324
1793
|
// the parent comment, throttled so we don't refetch constantly
|
|
@@ -1358,8 +1827,34 @@ export const createAutocompletePlugin = (
|
|
|
1358
1827
|
if (hasDestroy(slowLaneClient)) {
|
|
1359
1828
|
slowLaneClient.destroy();
|
|
1360
1829
|
}
|
|
1830
|
+
queuedContextRefreshSource = undefined;
|
|
1361
1831
|
pendingSessionContextTexts.clear();
|
|
1362
1832
|
sessionIngestedContextTexts.clear();
|
|
1833
|
+
pendingHarvestTexts.clear();
|
|
1834
|
+
harvestedContextTexts.clear();
|
|
1835
|
+
// The harvested set holds one conversation's user and assistant
|
|
1836
|
+
// content, and the L1 boosts describe what that conversation was
|
|
1837
|
+
// about. Both live at module scope, so they have to be emptied here
|
|
1838
|
+
// or they leak into whichever editor mounts next — but only once no
|
|
1839
|
+
// editor is left to be using them. The scope key goes with them, and
|
|
1840
|
+
// only with them: clearing it while another editor is still mounted
|
|
1841
|
+
// would leave that editor's next read looking like a first one, with
|
|
1842
|
+
// nothing to compare against and everything still loaded.
|
|
1843
|
+
mountedEditors.delete(reprimeSessionLearning);
|
|
1844
|
+
if (mountedEditors.size === 0) {
|
|
1845
|
+
loadedInlineCodeHarvester?.resetInlineCodeHarvest();
|
|
1846
|
+
// Only for a host that scopes its context, which is the one asking
|
|
1847
|
+
// for its learning to end with the conversation. A host that sends
|
|
1848
|
+
// no scope has boosts belonging to the page it is on, and that page
|
|
1849
|
+
// outlives an editor being unmounted — a Confluence comment box is
|
|
1850
|
+
// closed far more often than the page under it changes.
|
|
1851
|
+
if (contextScopeKey !== undefined) {
|
|
1852
|
+
resetSessionBoosts();
|
|
1853
|
+
contextScopeKey = undefined;
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
inlineCodeHarvester = null;
|
|
1857
|
+
inlineCodeHarvesterPromise = undefined;
|
|
1363
1858
|
},
|
|
1364
1859
|
};
|
|
1365
1860
|
},
|