@atlaskit/editor-plugin-autocomplete 9.0.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 +133 -0
- package/dist/cjs/pm-plugins/autocomplete-plugin.js +564 -67
- package/dist/cjs/pm-plugins/debug-mode.js +28 -2
- package/dist/cjs/pm-plugins/inline-code-harvester.js +576 -0
- package/dist/cjs/pm-plugins/text-predictor.js +437 -178
- package/dist/es2019/pm-plugins/autocomplete-plugin.js +515 -52
- package/dist/es2019/pm-plugins/debug-mode.js +27 -1
- package/dist/es2019/pm-plugins/inline-code-harvester.js +455 -0
- package/dist/es2019/pm-plugins/text-predictor.js +223 -3
- package/dist/esm/pm-plugins/autocomplete-plugin.js +562 -67
- package/dist/esm/pm-plugins/debug-mode.js +27 -1
- package/dist/esm/pm-plugins/inline-code-harvester.js +572 -0
- package/dist/esm/pm-plugins/text-predictor.js +437 -178
- package/dist/types/pm-plugins/autocomplete-plugin.d.ts +37 -0
- package/dist/types/pm-plugins/debug-mode.d.ts +34 -0
- package/dist/types/pm-plugins/inline-code-harvester.d.ts +146 -0
- package/dist/types/pm-plugins/text-predictor.d.ts +105 -3
- package/package.json +2 -2
- package/src/pm-plugins/autocomplete-plugin.ts +589 -48
- package/src/pm-plugins/debug-mode.ts +47 -1
- package/src/pm-plugins/inline-code-harvester.ts +561 -0
- package/src/pm-plugins/text-predictor.ts +274 -5
|
@@ -6,10 +6,13 @@ import { PluginKey } from '@atlaskit/editor-prosemirror/state';
|
|
|
6
6
|
import { DecorationSet } from '@atlaskit/editor-prosemirror/view';
|
|
7
7
|
import { CTC_STYLES, ctcTag, isAutocompleteDebugEnabled, isAutocompleteDebugVerbose } from './debug-mode';
|
|
8
8
|
import { createGhostTextDecorationSet } from './ghost-text-decoration';
|
|
9
|
+
// Type-only, so the harvester chunk is still reached exclusively through the
|
|
10
|
+
// dynamic import in `ensureInlineCodeHarvester`.
|
|
11
|
+
|
|
9
12
|
import { createLocalSlowLaneClient } from './local-slow-lane-client';
|
|
10
13
|
import { loadGrammarDataAsync } from './scoring-pipeline';
|
|
11
14
|
import { clearDefaultSlowLaneClient, createSlowLaneClient, getDefaultSlowLaneClientStatus, isWordBoundary, setDefaultSlowLaneClient } from './slow-lane-client';
|
|
12
|
-
import { predict, loadDefaultVocabulary, loadVectorsAsync, incrementSessionFreq, ingestDocumentPage, getLastPredictionDebug, noteSuggestionAccepted } from './text-predictor';
|
|
15
|
+
import { predict, loadDefaultVocabulary, loadVectorsAsync, incrementSessionFreq, ingestDocumentPage, getLastPredictionDebug, getLastPredictionOutcome, isSurfaceInAcceptCooldown, noteSuggestionAccepted, resetSessionBoosts } from './text-predictor';
|
|
13
16
|
export const autocompletePluginKey = new PluginKey('autocomplete');
|
|
14
17
|
const PREDICTION_COALESCE_MS = 0;
|
|
15
18
|
const GHOST_DECISION_BUDGET_MS = 100;
|
|
@@ -20,6 +23,32 @@ const CONTEXT_REFRESH_THROTTLE_MS = 1000;
|
|
|
20
23
|
// version of the (potentially hundreds-of-KB) page content for the plugin's
|
|
21
24
|
// lifetime. Eviction is FIFO; a re-ingest of an evicted text is harmless.
|
|
22
25
|
const MAX_INGESTED_CONTEXT_TEXTS = 50;
|
|
26
|
+
// Re-prime callback of every mounted editor. The harvested set and the L1
|
|
27
|
+
// boosts are shared by all of them, and more than one is mounted routinely — a
|
|
28
|
+
// comment box alongside its replies, or a comment box alongside the chat input.
|
|
29
|
+
// Membership doubles as the mount count, so a teardown only clears the shared
|
|
30
|
+
// stores once the last editor has gone.
|
|
31
|
+
//
|
|
32
|
+
// Decision: one pot, shared across hosts. Terms harvested in a comment box can
|
|
33
|
+
// be offered in the chat input and the other way round. Both are content from
|
|
34
|
+
// the page the reader is looking at, so the mixing is between things they can
|
|
35
|
+
// already see; partitioning the boosts would mean a per-host overlay on every
|
|
36
|
+
// trie node, which the scoring path reads on every keystroke.
|
|
37
|
+
const mountedEditors = new Set();
|
|
38
|
+
// The scope that shared learning belongs to, as last reported by a host that
|
|
39
|
+
// scopes its context. Held here rather than per editor because the data it
|
|
40
|
+
// guards is shared and some hosts replace one editor with another across a
|
|
41
|
+
// navigation: an instance-local key would read as unset on exactly the change it
|
|
42
|
+
// exists to catch, while the previous scope's terms carried on in these globals.
|
|
43
|
+
// Undefined until a scoping host says otherwise. An editor that sends no key
|
|
44
|
+
// still shares the pot being emptied, so it is re-primed from the context it
|
|
45
|
+
// already holds rather than left with learning it can no longer see.
|
|
46
|
+
let contextScopeKey;
|
|
47
|
+
// Set by whichever editor first finished importing the harvester chunk. The
|
|
48
|
+
// reset paths go through this rather than their own copy, because the editor
|
|
49
|
+
// that has to clear the set is not always one that imported it — the set is one
|
|
50
|
+
// per module, and nothing can be in it unless some editor got this far.
|
|
51
|
+
let loadedInlineCodeHarvester = null;
|
|
23
52
|
// Caps how many times the word-boundary path will retry getContext() while the
|
|
24
53
|
// parent comment is still missing. Combined with the 1s throttle this gives a
|
|
25
54
|
// ~5s window to cover a still-loading comment thread, then stops permanently so
|
|
@@ -94,11 +123,16 @@ const advanceGhostThroughTypedCharacter = (tr, pluginState) => {
|
|
|
94
123
|
ghostPosition: nextPosition,
|
|
95
124
|
decorationSet: createGhostTextDecorationSet(tr.doc, nextPosition, remaining),
|
|
96
125
|
// `surface` stays the full candidate so cooldown and analytics still
|
|
97
|
-
// describe the suggestion that was originally committed.
|
|
126
|
+
// describe the suggestion that was originally committed. The replaced
|
|
127
|
+
// prefix grows with the keystroke: it is the run the insertion overwrites,
|
|
128
|
+
// and the user has just typed one more character of it.
|
|
98
129
|
suggestion: {
|
|
99
130
|
...suggestion,
|
|
100
131
|
ghostText: remaining,
|
|
101
|
-
position: nextPosition
|
|
132
|
+
position: nextPosition,
|
|
133
|
+
...(suggestion.replacesTypedPrefixLength === undefined ? {} : {
|
|
134
|
+
replacesTypedPrefixLength: suggestion.replacesTypedPrefixLength + 1
|
|
135
|
+
})
|
|
102
136
|
}
|
|
103
137
|
};
|
|
104
138
|
};
|
|
@@ -135,10 +169,11 @@ const getTextBeforeCursor = state => {
|
|
|
135
169
|
}
|
|
136
170
|
return fullText.slice(-maxChars);
|
|
137
171
|
};
|
|
138
|
-
const
|
|
172
|
+
const getTrailingSurfaceToken = text => {
|
|
139
173
|
var _text$trimEnd$match$, _text$trimEnd$match;
|
|
140
|
-
return (_text$trimEnd$match$ = (_text$trimEnd$match = text.trimEnd().match(TRAILING_SURFACE_PREFIX_REGEX)) === null || _text$trimEnd$match === void 0 ? void 0 : _text$trimEnd$match[0]
|
|
174
|
+
return (_text$trimEnd$match$ = (_text$trimEnd$match = text.trimEnd().match(TRAILING_SURFACE_PREFIX_REGEX)) === null || _text$trimEnd$match === void 0 ? void 0 : _text$trimEnd$match[0]) !== null && _text$trimEnd$match$ !== void 0 ? _text$trimEnd$match$ : '';
|
|
141
175
|
};
|
|
176
|
+
const getTrailingSurfacePrefixLength = text => getTrailingSurfaceToken(text).length;
|
|
142
177
|
|
|
143
178
|
/**
|
|
144
179
|
* Set the autocomplete state via a transaction metadata.
|
|
@@ -151,13 +186,16 @@ const setAutocompleteMeta = (tr, meta) => {
|
|
|
151
186
|
* Apply a ghost text suggestion to the editor state.
|
|
152
187
|
*/
|
|
153
188
|
let lastShownGhostText = '';
|
|
154
|
-
const showGhostText = (view, prediction, position, revision, decisionStartedAt) => {
|
|
189
|
+
const showGhostText = (view, prediction, position, revision, decisionStartedAt, replacesTypedPrefixLength) => {
|
|
155
190
|
try {
|
|
156
191
|
const {
|
|
157
192
|
state,
|
|
158
193
|
dispatch
|
|
159
194
|
} = view;
|
|
160
195
|
const suggestion = {
|
|
196
|
+
...(replacesTypedPrefixLength === undefined ? {} : {
|
|
197
|
+
replacesTypedPrefixLength
|
|
198
|
+
}),
|
|
161
199
|
decisionLatencyMs: performance.now() - decisionStartedAt,
|
|
162
200
|
evidenceTier: prediction.evidenceTier,
|
|
163
201
|
ghostText: prediction.text,
|
|
@@ -226,12 +264,33 @@ const acceptGhostText = (state, dispatch) => {
|
|
|
226
264
|
}
|
|
227
265
|
if (dispatch) {
|
|
228
266
|
try {
|
|
267
|
+
var _suggestion$replacesT;
|
|
229
268
|
const {
|
|
230
269
|
ghostText,
|
|
231
270
|
ghostPosition,
|
|
232
271
|
suggestion
|
|
233
272
|
} = pluginState;
|
|
234
|
-
|
|
273
|
+
// Replacing back over the typed prefix rewrites it in the surface's own
|
|
274
|
+
// casing; every other path appends and leaves the prefix untouched.
|
|
275
|
+
const replaceFrom = Math.max(0, ghostPosition - Math.min((_suggestion$replacesT = suggestion.replacesTypedPrefixLength) !== null && _suggestion$replacesT !== void 0 ? _suggestion$replacesT : 0, ghostPosition));
|
|
276
|
+
let tr = state.tr;
|
|
277
|
+
if (replaceFrom === ghostPosition) {
|
|
278
|
+
tr = tr.insertText(ghostText, ghostPosition);
|
|
279
|
+
} else {
|
|
280
|
+
tr = tr.insertText(suggestion.surface, replaceFrom, ghostPosition);
|
|
281
|
+
// Only the harvested path replaces, and what authorized it was the
|
|
282
|
+
// surface being marked as code somewhere in the session. Inserting it
|
|
283
|
+
// as plain text loses that, so the identifier the user accepted reads
|
|
284
|
+
// as prose while the same identifier they typed by hand does not.
|
|
285
|
+
const codeMark = state.schema.marks.code;
|
|
286
|
+
if (codeMark) {
|
|
287
|
+
tr = tr.addMark(replaceFrom, replaceFrom + suggestion.surface.length, codeMark.create());
|
|
288
|
+
// The backtick input rule never ran, so nothing else will close this
|
|
289
|
+
// mark. Without dropping it from the stored set the next character
|
|
290
|
+
// the user types continues the code span.
|
|
291
|
+
tr = tr.removeStoredMark(codeMark);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
235
294
|
tr = setAutocompleteMeta(tr, {
|
|
236
295
|
ghostText: '',
|
|
237
296
|
ghostPosition: -1,
|
|
@@ -319,6 +378,69 @@ const getLeadingTextCharacter = text => {
|
|
|
319
378
|
}
|
|
320
379
|
return String.fromCodePoint(firstCodePoint);
|
|
321
380
|
};
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* The one verdict a harvested surface may speak on.
|
|
384
|
+
*
|
|
385
|
+
* `no-candidate` is the only abstention that means no vocabulary reaches the
|
|
386
|
+
* prefix at all. Every other one — a margin the model cannot clear, a rival
|
|
387
|
+
* still being read — describes known words in contention, where a session
|
|
388
|
+
* surface with no score behind it would be overruling the ranker rather than
|
|
389
|
+
* filling a gap it left.
|
|
390
|
+
*/
|
|
391
|
+
const HARVEST_ELIGIBLE_ABSTAIN_REASON = 'no-candidate';
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Whether the scored path has finished with this exact prefix and found nothing.
|
|
395
|
+
*
|
|
396
|
+
* The 100ms deadline expiring is not the same answer: it means the ranker was
|
|
397
|
+
* still working, and displaying then would race a suggestion that is about to
|
|
398
|
+
* arrive. So the harvest path reads the recorded verdict rather than the clock.
|
|
399
|
+
*/
|
|
400
|
+
const scoredPathFinishedEmpty = textBefore => {
|
|
401
|
+
const outcome = getLastPredictionOutcome();
|
|
402
|
+
return outcome !== null && outcome.textBefore === textBefore && !outcome.awaitingAsyncEvidence && outcome.abstainReason === HARVEST_ELIGIBLE_ABSTAIN_REASON;
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Whether the surface is already sitting immediately before the typed prefix.
|
|
407
|
+
*
|
|
408
|
+
* Completing `ml-s` to `ml-studio` right after `ml-studio` produces the echo the
|
|
409
|
+
* scored path's repetition guard exists to stop, and this path does not go
|
|
410
|
+
* through arbitration to inherit it.
|
|
411
|
+
*/
|
|
412
|
+
const repeatsPrecedingText = (match, textBefore) => {
|
|
413
|
+
const trimmed = textBefore.trimEnd();
|
|
414
|
+
const preceding = trimmed.slice(0, trimmed.length - match.typedPrefixLength).trimEnd();
|
|
415
|
+
return preceding.toLowerCase().endsWith(match.surface.toLowerCase());
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Dress a harvested match as a prediction so it commits through the same path as
|
|
420
|
+
* a scored one.
|
|
421
|
+
*
|
|
422
|
+
* The scoring fields are zeroed rather than invented: there is no posterior, no
|
|
423
|
+
* margin and no shortlist behind this surface, and `session-harvest` on the
|
|
424
|
+
* evidence tier is what says so wherever the snapshot is read.
|
|
425
|
+
*/
|
|
426
|
+
const buildHarvestPrediction = match => ({
|
|
427
|
+
evidenceDepth: {
|
|
428
|
+
totalChars: 0,
|
|
429
|
+
totalTokens: 0,
|
|
430
|
+
verifiedChars: 0,
|
|
431
|
+
verifiedTokens: 0
|
|
432
|
+
},
|
|
433
|
+
evidenceTier: 'session-harvest',
|
|
434
|
+
meanTokenLogProbability: 0,
|
|
435
|
+
poolHeldExtension: false,
|
|
436
|
+
posterior: 0,
|
|
437
|
+
rankScore: 0,
|
|
438
|
+
shortlistSize: 0,
|
|
439
|
+
surface: match.surface,
|
|
440
|
+
termType: 'word',
|
|
441
|
+
text: match.ghostText,
|
|
442
|
+
winnerMargin: 0
|
|
443
|
+
});
|
|
322
444
|
const isEnglishLocale = locale => {
|
|
323
445
|
if (!locale) {
|
|
324
446
|
return false;
|
|
@@ -330,6 +452,7 @@ export const createAutocompletePlugin = (options, api) => {
|
|
|
330
452
|
const surface = (_options$surface = options === null || options === void 0 ? void 0 : options.surface) !== null && _options$surface !== void 0 ? _options$surface : 'editor';
|
|
331
453
|
const locale = (_options$locale = options === null || options === void 0 ? void 0 : options.locale) !== null && _options$locale !== void 0 ? _options$locale : typeof navigator !== 'undefined' ? navigator.language : undefined;
|
|
332
454
|
const isAutocompleteEnabled = isEnglishLocale(locale);
|
|
455
|
+
const isInlineCodeHarvestEnabled = isAutocompleteEnabled && (options === null || options === void 0 ? void 0 : options.harvestInlineCode) === true;
|
|
333
456
|
let debounceTimer = null;
|
|
334
457
|
let decisionDeadlineTimer = null;
|
|
335
458
|
let hasIngestedPage = false;
|
|
@@ -372,14 +495,24 @@ export const createAutocompletePlugin = (options, api) => {
|
|
|
372
495
|
}
|
|
373
496
|
return options !== null && options !== void 0 && options.useLocalModel ? 'localLlm' : 'server';
|
|
374
497
|
};
|
|
375
|
-
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Which path produced a given suggestion.
|
|
501
|
+
*
|
|
502
|
+
* A harvested surface is reported as `harvest` rather than by the slow-lane
|
|
503
|
+
* state it happened to be shown under, so its views and acceptances stay
|
|
504
|
+
* separable from the scored path's and cannot quietly move the headline
|
|
505
|
+
* acceptance rate.
|
|
506
|
+
*/
|
|
507
|
+
const completionSourceFor = suggestion => (suggestion === null || suggestion === void 0 ? void 0 : suggestion.evidenceTier) === 'session-harvest' ? 'harvest' : getCompletionSource();
|
|
508
|
+
const fireSuggestionDismissedAnalytics = (reason, suggestion) => {
|
|
376
509
|
var _api$analytics;
|
|
377
510
|
api === null || api === void 0 ? void 0 : (_api$analytics = api.analytics) === null || _api$analytics === void 0 ? void 0 : _api$analytics.actions.fireAnalyticsEvent({
|
|
378
511
|
action: ACTION.SUGGESTION_DISMISSED,
|
|
379
512
|
actionSubject: ACTION_SUBJECT.CONTEXTUAL_TYPEAHEAD,
|
|
380
513
|
eventType: EVENT_TYPE.TRACK,
|
|
381
514
|
attributes: {
|
|
382
|
-
completionSource:
|
|
515
|
+
completionSource: completionSourceFor(suggestion),
|
|
383
516
|
reason,
|
|
384
517
|
surface
|
|
385
518
|
}
|
|
@@ -436,7 +569,7 @@ export const createAutocompletePlugin = (options, api) => {
|
|
|
436
569
|
actionSubject: ACTION_SUBJECT.CONTEXTUAL_TYPEAHEAD,
|
|
437
570
|
eventType: EVENT_TYPE.TRACK,
|
|
438
571
|
attributes: {
|
|
439
|
-
completionSource:
|
|
572
|
+
completionSource: completionSourceFor(suggestion),
|
|
440
573
|
suggestionLength,
|
|
441
574
|
typedLength,
|
|
442
575
|
kssDelta,
|
|
@@ -504,6 +637,10 @@ export const createAutocompletePlugin = (options, api) => {
|
|
|
504
637
|
const registeredSlowLaneStatus = getDefaultSlowLaneClientStatus();
|
|
505
638
|
ctcTag('init', `slow lane registered · id=${slowLaneClientId} · selected=${slowLaneClientKind} · canonical=${registeredSlowLaneStatus.canonicalScoringSupported ? 'yes' : 'no'} · ready=${(_registeredSlowLaneSt = registeredSlowLaneStatus.localModelReady) !== null && _registeredSlowLaneSt !== void 0 ? _registeredSlowLaneSt : 'n/a'}`, slowLaneClientKind === 'localLlm' ? CTC_STYLES.good : CTC_STYLES.warn);
|
|
506
639
|
let contextRequestInFlight = false;
|
|
640
|
+
// A read the host asked for while another was open, kept so it can be made
|
|
641
|
+
// once that one settles. One slot, not a queue: a burst of notifications only
|
|
642
|
+
// ever means "read again", and the last of them describes the current state.
|
|
643
|
+
let queuedContextRefreshSource;
|
|
507
644
|
let lastContextRefreshAt = 0;
|
|
508
645
|
// Bounds the word-boundary retry loop so it terminates even when the editor is
|
|
509
646
|
// not in a comment thread (parentCommentContent never resolves).
|
|
@@ -511,7 +648,145 @@ export const createAutocompletePlugin = (options, api) => {
|
|
|
511
648
|
// Set when the plugin is torn down so in-flight getContext() resolutions don't
|
|
512
649
|
// mutate the global text-predictor state after destruction.
|
|
513
650
|
let destroyed = false;
|
|
514
|
-
|
|
651
|
+
// L1 ingestion cannot run before the vocabulary has loaded: `incrementSessionFreq`
|
|
652
|
+
// only touches trie nodes that already exist, so an empty trie silently rejects
|
|
653
|
+
// every word. Text that arrives first — which a page usually does, since the
|
|
654
|
+
// chat resolves it on focus in the same tick as the load starts — is held here
|
|
655
|
+
// and applied once the load has settled.
|
|
656
|
+
const pendingSessionContextTexts = new Set();
|
|
657
|
+
const sessionIngestedContextTexts = new Set();
|
|
658
|
+
let isVocabularyReady = false;
|
|
659
|
+
let vocabularyLoadPromise;
|
|
660
|
+
|
|
661
|
+
// Context text waiting on the harvester chunk, the vocabulary, or both. The
|
|
662
|
+
// harvester drops surfaces the vocabulary already holds, so intake before the
|
|
663
|
+
// load has settled would admit ordinary words no one needs completed.
|
|
664
|
+
const pendingHarvestTexts = new Set();
|
|
665
|
+
// Separate from `sessionIngestedContextTexts` because the two consumers settle
|
|
666
|
+
// at different times: reply occurrences accumulate, so text fed twice would
|
|
667
|
+
// count twice.
|
|
668
|
+
const harvestedContextTexts = new Set();
|
|
669
|
+
let inlineCodeHarvester = null;
|
|
670
|
+
let inlineCodeHarvesterPromise;
|
|
671
|
+
const addBoundedContextText = (texts, text) => {
|
|
672
|
+
texts.add(text);
|
|
673
|
+
// Evict oldest entries (Set preserves insertion order) to bound memory.
|
|
674
|
+
while (texts.size > MAX_INGESTED_CONTEXT_TEXTS) {
|
|
675
|
+
const oldest = texts.values().next().value;
|
|
676
|
+
if (oldest === undefined) {
|
|
677
|
+
break;
|
|
678
|
+
}
|
|
679
|
+
texts.delete(oldest);
|
|
680
|
+
}
|
|
681
|
+
};
|
|
682
|
+
const flushPendingSessionContext = () => {
|
|
683
|
+
if (!isVocabularyReady || destroyed) {
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
for (const text of pendingSessionContextTexts) {
|
|
687
|
+
if (!sessionIngestedContextTexts.has(text)) {
|
|
688
|
+
ingestDocumentPage(text);
|
|
689
|
+
addBoundedContextText(sessionIngestedContextTexts, text);
|
|
690
|
+
}
|
|
691
|
+
pendingSessionContextTexts.delete(text);
|
|
692
|
+
}
|
|
693
|
+
};
|
|
694
|
+
const flushPendingHarvestTexts = () => {
|
|
695
|
+
const harvester = inlineCodeHarvester;
|
|
696
|
+
if (!harvester || !isVocabularyReady || destroyed || pendingHarvestTexts.size === 0) {
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
for (const text of pendingHarvestTexts) {
|
|
700
|
+
if (!harvestedContextTexts.has(text)) {
|
|
701
|
+
harvester.harvestInlineCodeFromText(text);
|
|
702
|
+
addBoundedContextText(harvestedContextTexts, text);
|
|
703
|
+
}
|
|
704
|
+
pendingHarvestTexts.delete(text);
|
|
705
|
+
}
|
|
706
|
+
harvester.logInlineCodeHarvest('context');
|
|
707
|
+
};
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Load the harvester chunk, once, and only for a host that asked for it.
|
|
711
|
+
*
|
|
712
|
+
* Kept off the critical path in the same way the vocabulary and vector loads
|
|
713
|
+
* are: nothing is requested until the editor is focused, so a session that
|
|
714
|
+
* never types in the chat pays nothing for the feature.
|
|
715
|
+
*/
|
|
716
|
+
const ensureInlineCodeHarvester = () => {
|
|
717
|
+
if (!isInlineCodeHarvestEnabled || destroyed) {
|
|
718
|
+
return Promise.resolve(null);
|
|
719
|
+
}
|
|
720
|
+
inlineCodeHarvesterPromise ??= import( /* webpackChunkName: "@atlaskit-internal_editor-plugin-autocomplete-inline-code-harvester" */
|
|
721
|
+
'./inline-code-harvester').then(module => {
|
|
722
|
+
if (destroyed) {
|
|
723
|
+
return null;
|
|
724
|
+
}
|
|
725
|
+
inlineCodeHarvester = module;
|
|
726
|
+
loadedInlineCodeHarvester = module;
|
|
727
|
+
flushPendingHarvestTexts();
|
|
728
|
+
return module;
|
|
729
|
+
}).catch(error => {
|
|
730
|
+
// Clear the promise so a later focus can retry; the queued text is
|
|
731
|
+
// still held and is fed by whichever attempt succeeds.
|
|
732
|
+
inlineCodeHarvesterPromise = undefined;
|
|
733
|
+
logException(error, {
|
|
734
|
+
location: 'editor-plugin-autocomplete/loadInlineCodeHarvester'
|
|
735
|
+
});
|
|
736
|
+
return null;
|
|
737
|
+
});
|
|
738
|
+
return inlineCodeHarvesterPromise;
|
|
739
|
+
};
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* Re-read the document's code-marked spans.
|
|
743
|
+
*
|
|
744
|
+
* The backtick input rule consumes both delimiters on the closing tick, so a
|
|
745
|
+
* finished span in the live document is only findable through its mark — and
|
|
746
|
+
* only by walking, since a mark carries no notification.
|
|
747
|
+
*/
|
|
748
|
+
const harvestDocument = (doc, trigger) => {
|
|
749
|
+
if (!isInlineCodeHarvestEnabled || destroyed) {
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
const harvester = inlineCodeHarvester;
|
|
753
|
+
if (!harvester) {
|
|
754
|
+
void ensureInlineCodeHarvester();
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
if (!isVocabularyReady) {
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
harvester.harvestInlineCodeFromDoc(doc);
|
|
761
|
+
harvester.logInlineCodeHarvest(trigger);
|
|
762
|
+
};
|
|
763
|
+
const ensureVocabularyReady = () => {
|
|
764
|
+
var _options$useLocalMode;
|
|
765
|
+
if (isVocabularyReady) {
|
|
766
|
+
flushPendingSessionContext();
|
|
767
|
+
flushPendingHarvestTexts();
|
|
768
|
+
return Promise.resolve();
|
|
769
|
+
}
|
|
770
|
+
vocabularyLoadPromise ??= loadDefaultVocabulary({
|
|
771
|
+
isLocalLLM: (_options$useLocalMode = options === null || options === void 0 ? void 0 : options.useLocalModel) !== null && _options$useLocalMode !== void 0 ? _options$useLocalMode : false,
|
|
772
|
+
surface
|
|
773
|
+
}).then(() => {
|
|
774
|
+
if (destroyed) {
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
isVocabularyReady = true;
|
|
778
|
+
flushPendingSessionContext();
|
|
779
|
+
flushPendingHarvestTexts();
|
|
780
|
+
}).catch(error => {
|
|
781
|
+
// Do not consume the pending text on failure. A later focus retries the
|
|
782
|
+
// load and can still apply the original context exactly once.
|
|
783
|
+
vocabularyLoadPromise = undefined;
|
|
784
|
+
logException(error, {
|
|
785
|
+
location: 'editor-plugin-autocomplete/loadDefaultVocabulary'
|
|
786
|
+
});
|
|
787
|
+
});
|
|
788
|
+
return vocabularyLoadPromise;
|
|
789
|
+
};
|
|
515
790
|
const logContextResolved = (source, context) => {
|
|
516
791
|
var _context$parentCommen, _context$siblingComme4, _context$siblingComme5;
|
|
517
792
|
if (!isAutocompleteDebugEnabled()) {
|
|
@@ -521,13 +796,117 @@ export const createAutocompletePlugin = (options, api) => {
|
|
|
521
796
|
// eslint-disable-next-line no-console
|
|
522
797
|
console.log('%c[CTC:signal]%c getContext resolved', CTC_STYLES.brand, CTC_STYLES.body, {
|
|
523
798
|
source,
|
|
799
|
+
scope: context === null || context === void 0 ? void 0 : context.contextScopeKey,
|
|
524
800
|
hasParentComment: !!(context !== null && context !== void 0 && context.parentCommentContent),
|
|
525
801
|
parentCommentPreview: context === null || context === void 0 ? void 0 : (_context$parentCommen = context.parentCommentContent) === null || _context$parentCommen === void 0 ? void 0 : _context$parentCommen.slice(0, 80),
|
|
526
802
|
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,
|
|
527
803
|
hasFullPage: !!(context !== null && context !== void 0 && context.fullPageContent)
|
|
528
804
|
});
|
|
529
805
|
};
|
|
806
|
+
const ingestContextText = text => {
|
|
807
|
+
if (!text) {
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
if (!sessionIngestedContextTexts.has(text)) {
|
|
811
|
+
addBoundedContextText(pendingSessionContextTexts, text);
|
|
812
|
+
}
|
|
813
|
+
// Queued unconditionally rather than only once the chunk is present:
|
|
814
|
+
// context usually resolves in the same tick the load starts, and text
|
|
815
|
+
// dropped for arriving early is a reply that can never be harvested.
|
|
816
|
+
if (isInlineCodeHarvestEnabled && !harvestedContextTexts.has(text)) {
|
|
817
|
+
addBoundedContextText(pendingHarvestTexts, text);
|
|
818
|
+
}
|
|
819
|
+
};
|
|
820
|
+
const ingestResolvedContext = () => {
|
|
821
|
+
var _resolvedContext, _resolvedContext2;
|
|
822
|
+
ingestContextText((_resolvedContext = resolvedContext) === null || _resolvedContext === void 0 ? void 0 : _resolvedContext.fullPageContent);
|
|
823
|
+
ingestContextText((_resolvedContext2 = resolvedContext) === null || _resolvedContext2 === void 0 ? void 0 : _resolvedContext2.parentCommentContent);
|
|
824
|
+
for (const siblingCommentContent of (_resolvedContext$sibl = (_resolvedContext3 = resolvedContext) === null || _resolvedContext3 === void 0 ? void 0 : _resolvedContext3.siblingCommentsContents) !== null && _resolvedContext$sibl !== void 0 ? _resolvedContext$sibl : []) {
|
|
825
|
+
var _resolvedContext$sibl, _resolvedContext3;
|
|
826
|
+
ingestContextText(siblingCommentContent);
|
|
827
|
+
}
|
|
828
|
+
flushPendingSessionContext();
|
|
829
|
+
flushPendingHarvestTexts();
|
|
830
|
+
};
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* Put back what this editor had contributed to the pot another editor just
|
|
834
|
+
* emptied.
|
|
835
|
+
*
|
|
836
|
+
* The scope that ended belongs to the host that reported it, not to everyone
|
|
837
|
+
* sharing these globals. Without this an editor alongside it — a comment box
|
|
838
|
+
* under a chat panel that switched conversation — is left with none of its
|
|
839
|
+
* learning and no way back to it: the dedupe sets below are per editor, so
|
|
840
|
+
* its own page reads as already ingested and is skipped from then on.
|
|
841
|
+
*
|
|
842
|
+
* Nothing is re-fetched. The context this editor resolved is still held, and
|
|
843
|
+
* what it describes has not changed just because another host moved on.
|
|
844
|
+
*
|
|
845
|
+
* Only for an editor that reported no scope. One that did is party to the
|
|
846
|
+
* same scope system and the scope just dropped may well be its own: a
|
|
847
|
+
* Confluence page transition mounts the incoming editor before tearing down
|
|
848
|
+
* the outgoing one, and the outgoing one is still holding the page that was
|
|
849
|
+
* left. Putting that back is exactly what the eviction was for.
|
|
850
|
+
*/
|
|
851
|
+
const reprimeSessionLearning = () => {
|
|
852
|
+
var _resolvedContext4;
|
|
853
|
+
if (destroyed || ((_resolvedContext4 = resolvedContext) === null || _resolvedContext4 === void 0 ? void 0 : _resolvedContext4.contextScopeKey) !== undefined) {
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
pendingSessionContextTexts.clear();
|
|
857
|
+
sessionIngestedContextTexts.clear();
|
|
858
|
+
pendingHarvestTexts.clear();
|
|
859
|
+
harvestedContextTexts.clear();
|
|
860
|
+
ingestResolvedContext();
|
|
861
|
+
if (currentView) {
|
|
862
|
+
harvestDocument(currentView.state.doc, 'reprime');
|
|
863
|
+
}
|
|
864
|
+
};
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* Drop everything learned for the scope that just ended.
|
|
868
|
+
*
|
|
869
|
+
* Both stores are claims about what is being discussed, and neither survives
|
|
870
|
+
* the discussion changing: L1 boosts would keep a page's words ranked above
|
|
871
|
+
* the next page's, and the harvested set holds one conversation's content.
|
|
872
|
+
* The dedupe sets go too, so the context that arrives next is treated as
|
|
873
|
+
* unseen and re-primes both — which is what makes this safe to do on a page
|
|
874
|
+
* change, since the transcript is re-ingested along with the new page.
|
|
875
|
+
*
|
|
876
|
+
* The stores are shared, so every other mounted editor is put back in the
|
|
877
|
+
* same tick from context it already holds. Only the scope that ended is
|
|
878
|
+
* actually dropped.
|
|
879
|
+
*/
|
|
880
|
+
const resetSessionScopedLearning = reason => {
|
|
881
|
+
var _loadedInlineCodeHarv;
|
|
882
|
+
resetSessionBoosts();
|
|
883
|
+
(_loadedInlineCodeHarv = loadedInlineCodeHarvester) === null || _loadedInlineCodeHarv === void 0 ? void 0 : _loadedInlineCodeHarv.resetInlineCodeHarvest();
|
|
884
|
+
resolvedContext = undefined;
|
|
885
|
+
pendingSessionContextTexts.clear();
|
|
886
|
+
sessionIngestedContextTexts.clear();
|
|
887
|
+
pendingHarvestTexts.clear();
|
|
888
|
+
harvestedContextTexts.clear();
|
|
889
|
+
ctcTag('init', `session-scoped learning reset · ${reason}`, CTC_STYLES.dim);
|
|
890
|
+
for (const reprimeOther of mountedEditors) {
|
|
891
|
+
if (reprimeOther !== reprimeSessionLearning) {
|
|
892
|
+
reprimeOther();
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
};
|
|
530
896
|
const applyContext = context => {
|
|
897
|
+
// A host that scopes its context tells us which scope each read belongs to.
|
|
898
|
+
// A read with no recorded key to compare against only records: that is the
|
|
899
|
+
// first since the last editor went away, so there is nothing left to throw
|
|
900
|
+
// away and resetting would drop the priming focus just started. A read from
|
|
901
|
+
// a newly mounted editor is not that case — the key outlives the editor
|
|
902
|
+
// precisely so a host that remounts across a navigation still evicts.
|
|
903
|
+
if (context.contextScopeKey !== undefined && context.contextScopeKey !== contextScopeKey) {
|
|
904
|
+
if (contextScopeKey !== undefined) {
|
|
905
|
+
resetSessionScopedLearning(`scope ${contextScopeKey} → ${context.contextScopeKey}`);
|
|
906
|
+
}
|
|
907
|
+
contextScopeKey = context.contextScopeKey;
|
|
908
|
+
}
|
|
909
|
+
|
|
531
910
|
// Merge rather than replace: the word-boundary retry may resolve only a
|
|
532
911
|
// late-arriving field (e.g. parentCommentContent) without re-sending
|
|
533
912
|
// fullPageContent, so replacing would drop previously resolved context.
|
|
@@ -538,27 +917,7 @@ export const createAutocompletePlugin = (options, api) => {
|
|
|
538
917
|
...resolvedContext,
|
|
539
918
|
...definedContext
|
|
540
919
|
};
|
|
541
|
-
|
|
542
|
-
if (!text || ingestedContextTexts.has(text)) {
|
|
543
|
-
return;
|
|
544
|
-
}
|
|
545
|
-
ingestedContextTexts.add(text);
|
|
546
|
-
// Evict oldest entries (Set preserves insertion order) to bound memory.
|
|
547
|
-
while (ingestedContextTexts.size > MAX_INGESTED_CONTEXT_TEXTS) {
|
|
548
|
-
const oldest = ingestedContextTexts.values().next().value;
|
|
549
|
-
if (oldest === undefined) {
|
|
550
|
-
break;
|
|
551
|
-
}
|
|
552
|
-
ingestedContextTexts.delete(oldest);
|
|
553
|
-
}
|
|
554
|
-
ingestDocumentPage(text);
|
|
555
|
-
};
|
|
556
|
-
ingestContextText(context.fullPageContent);
|
|
557
|
-
ingestContextText(context.parentCommentContent);
|
|
558
|
-
for (const siblingCommentContent of (_context$siblingComme6 = context.siblingCommentsContents) !== null && _context$siblingComme6 !== void 0 ? _context$siblingComme6 : []) {
|
|
559
|
-
var _context$siblingComme6;
|
|
560
|
-
ingestContextText(siblingCommentContent);
|
|
561
|
-
}
|
|
920
|
+
ingestResolvedContext();
|
|
562
921
|
|
|
563
922
|
// Context arrived after word boundaries may already have fired. Re-send
|
|
564
923
|
// slow-lane context immediately so the next inference includes the thread.
|
|
@@ -576,7 +935,19 @@ export const createAutocompletePlugin = (options, api) => {
|
|
|
576
935
|
source,
|
|
577
936
|
allowThrottle = true
|
|
578
937
|
}) => {
|
|
579
|
-
if (!(options !== null && options !== void 0 && options.getContext)
|
|
938
|
+
if (!(options !== null && options !== void 0 && options.getContext)) {
|
|
939
|
+
return false;
|
|
940
|
+
}
|
|
941
|
+
if (contextRequestInFlight) {
|
|
942
|
+
// Only the reads that bypass the throttle are worth keeping: those are
|
|
943
|
+
// the ones the host asked for by name, and a scope change travels among
|
|
944
|
+
// them with no second channel to arrive on, so losing one would strand
|
|
945
|
+
// the eviction until something else happened to move. A word-boundary
|
|
946
|
+
// poll is a retry loop for context that has not landed yet, and the read
|
|
947
|
+
// already open will bring it.
|
|
948
|
+
if (!allowThrottle) {
|
|
949
|
+
queuedContextRefreshSource = source;
|
|
950
|
+
}
|
|
580
951
|
return false;
|
|
581
952
|
}
|
|
582
953
|
const now = Date.now();
|
|
@@ -603,6 +974,14 @@ export const createAutocompletePlugin = (options, api) => {
|
|
|
603
974
|
});
|
|
604
975
|
}).finally(() => {
|
|
605
976
|
contextRequestInFlight = false;
|
|
977
|
+
const queuedSource = queuedContextRefreshSource;
|
|
978
|
+
queuedContextRefreshSource = undefined;
|
|
979
|
+
if (queuedSource !== undefined && !destroyed) {
|
|
980
|
+
refreshContext({
|
|
981
|
+
source: queuedSource,
|
|
982
|
+
allowThrottle: false
|
|
983
|
+
});
|
|
984
|
+
}
|
|
606
985
|
});
|
|
607
986
|
return true;
|
|
608
987
|
};
|
|
@@ -637,6 +1016,48 @@ export const createAutocompletePlugin = (options, api) => {
|
|
|
637
1016
|
}
|
|
638
1017
|
cancelActiveDecision();
|
|
639
1018
|
};
|
|
1019
|
+
|
|
1020
|
+
/**
|
|
1021
|
+
* Offer a harvested inline-code surface on a prefix the scored path left
|
|
1022
|
+
* empty. Returns whether one was committed.
|
|
1023
|
+
*/
|
|
1024
|
+
const commitHarvestedSuggestion = (view, decision) => {
|
|
1025
|
+
const harvester = inlineCodeHarvester;
|
|
1026
|
+
if (!harvester || !isInlineCodeHarvestEnabled) {
|
|
1027
|
+
return false;
|
|
1028
|
+
}
|
|
1029
|
+
if (!scoredPathFinishedEmpty(decision.textBefore)) {
|
|
1030
|
+
return false;
|
|
1031
|
+
}
|
|
1032
|
+
const typedPrefix = getTrailingSurfaceToken(decision.textBefore);
|
|
1033
|
+
const match = harvester.findHarvestedCompletion(typedPrefix);
|
|
1034
|
+
if (!match) {
|
|
1035
|
+
return false;
|
|
1036
|
+
}
|
|
1037
|
+
if (isSurfaceInAcceptCooldown(match.surface) || repeatsPrecedingText(match, decision.textBefore)) {
|
|
1038
|
+
return false;
|
|
1039
|
+
}
|
|
1040
|
+
const suggestion = showGhostText(view, buildHarvestPrediction(match), decision.position, decision.revision, decision.startedAt, match.typedPrefixLength);
|
|
1041
|
+
if (!suggestion) {
|
|
1042
|
+
return false;
|
|
1043
|
+
}
|
|
1044
|
+
cancelActiveDecision();
|
|
1045
|
+
ctcTag('harvest', `offered "${match.surface}" for "${typedPrefix}" · ${match.collapsedBy} · ${match.replyOccurrences} reply / ${match.documentSpans} doc sightings${match.rivalSurfaces.length > 0 ? ` · over ${match.rivalSurfaces.join(', ')}` : ''}`, CTC_STYLES.lm);
|
|
1046
|
+
if (suggestion.ghostText !== lastShownGhostText) {
|
|
1047
|
+
var _api$analytics5;
|
|
1048
|
+
lastShownGhostText = suggestion.ghostText;
|
|
1049
|
+
api === null || api === void 0 ? void 0 : (_api$analytics5 = api.analytics) === null || _api$analytics5 === void 0 ? void 0 : _api$analytics5.actions.fireAnalyticsEvent({
|
|
1050
|
+
action: ACTION.SUGGESTION_VIEWED,
|
|
1051
|
+
actionSubject: ACTION_SUBJECT.CONTEXTUAL_TYPEAHEAD,
|
|
1052
|
+
eventType: EVENT_TYPE.TRACK,
|
|
1053
|
+
attributes: {
|
|
1054
|
+
completionSource: completionSourceFor(suggestion),
|
|
1055
|
+
surface
|
|
1056
|
+
}
|
|
1057
|
+
});
|
|
1058
|
+
}
|
|
1059
|
+
return true;
|
|
1060
|
+
};
|
|
640
1061
|
const evaluateActiveDecision = (view, revision) => {
|
|
641
1062
|
const decision = activeDecision;
|
|
642
1063
|
if (!decision || decision.revision !== revision || !isDecisionCurrent(view, decision)) {
|
|
@@ -677,8 +1098,11 @@ export const createAutocompletePlugin = (options, api) => {
|
|
|
677
1098
|
}
|
|
678
1099
|
const prediction = predict(decision.textBefore);
|
|
679
1100
|
if (!prediction || prediction.text.length === 0) {
|
|
680
|
-
//
|
|
681
|
-
//
|
|
1101
|
+
// Only where the scored path has finished and come away with nothing
|
|
1102
|
+
// does a harvested surface get to answer; otherwise remain in
|
|
1103
|
+
// `collecting` until an async signal arrives or the hard deadline
|
|
1104
|
+
// expires. We never display a provisional fallback.
|
|
1105
|
+
commitHarvestedSuggestion(view, decision);
|
|
682
1106
|
return;
|
|
683
1107
|
}
|
|
684
1108
|
const readyAtMs = performance.now() - decision.startedAt;
|
|
@@ -718,14 +1142,14 @@ export const createAutocompletePlugin = (options, api) => {
|
|
|
718
1142
|
});
|
|
719
1143
|
}
|
|
720
1144
|
if (suggestion.ghostText !== lastShownGhostText) {
|
|
721
|
-
var _api$
|
|
1145
|
+
var _api$analytics6;
|
|
722
1146
|
lastShownGhostText = suggestion.ghostText;
|
|
723
|
-
api === null || api === void 0 ? void 0 : (_api$
|
|
1147
|
+
api === null || api === void 0 ? void 0 : (_api$analytics6 = api.analytics) === null || _api$analytics6 === void 0 ? void 0 : _api$analytics6.actions.fireAnalyticsEvent({
|
|
724
1148
|
action: ACTION.SUGGESTION_VIEWED,
|
|
725
1149
|
actionSubject: ACTION_SUBJECT.CONTEXTUAL_TYPEAHEAD,
|
|
726
1150
|
eventType: EVENT_TYPE.TRACK,
|
|
727
1151
|
attributes: {
|
|
728
|
-
completionSource:
|
|
1152
|
+
completionSource: completionSourceFor(suggestion),
|
|
729
1153
|
surface
|
|
730
1154
|
}
|
|
731
1155
|
});
|
|
@@ -913,11 +1337,13 @@ export const createAutocompletePlugin = (options, api) => {
|
|
|
913
1337
|
});
|
|
914
1338
|
},
|
|
915
1339
|
Escape: (state, dispatch) => {
|
|
1340
|
+
var _autocompletePluginKe;
|
|
1341
|
+
const dismissed = (_autocompletePluginKe = autocompletePluginKey.getState(state)) === null || _autocompletePluginKe === void 0 ? void 0 : _autocompletePluginKe.suggestion;
|
|
916
1342
|
const didClear = clearGhostText(state, dispatch);
|
|
917
1343
|
if (didClear) {
|
|
918
1344
|
cancelActiveDecision();
|
|
919
1345
|
dismissedContext = getTextBeforeCursor(state);
|
|
920
|
-
fireSuggestionDismissedAnalytics('escape');
|
|
1346
|
+
fireSuggestionDismissedAnalytics('escape', dismissed !== null && dismissed !== void 0 ? dismissed : null);
|
|
921
1347
|
}
|
|
922
1348
|
return didClear;
|
|
923
1349
|
}
|
|
@@ -931,7 +1357,7 @@ export const createAutocompletePlugin = (options, api) => {
|
|
|
931
1357
|
if (pluginState !== null && pluginState !== void 0 && pluginState.ghostText) {
|
|
932
1358
|
clearGhostText(view.state, view.dispatch);
|
|
933
1359
|
cancelActiveDecision();
|
|
934
|
-
fireSuggestionDismissedAnalytics('blur');
|
|
1360
|
+
fireSuggestionDismissedAnalytics('blur', pluginState.suggestion);
|
|
935
1361
|
}
|
|
936
1362
|
return false;
|
|
937
1363
|
},
|
|
@@ -940,12 +1366,14 @@ export const createAutocompletePlugin = (options, api) => {
|
|
|
940
1366
|
return false;
|
|
941
1367
|
}
|
|
942
1368
|
if (!event.target.closest('[data-autocomplete-ghost="true"]')) {
|
|
1369
|
+
var _autocompletePluginKe2;
|
|
1370
|
+
const dismissed = (_autocompletePluginKe2 = autocompletePluginKey.getState(view.state)) === null || _autocompletePluginKe2 === void 0 ? void 0 : _autocompletePluginKe2.suggestion;
|
|
943
1371
|
const didClear = clearGhostText(view.state, view.dispatch);
|
|
944
1372
|
if (didClear) {
|
|
945
1373
|
cancelActiveDecision();
|
|
946
1374
|
dismissedContext = getTextBeforeCursor(view.state);
|
|
947
1375
|
lastShownGhostText = '';
|
|
948
|
-
fireSuggestionDismissedAnalytics('click');
|
|
1376
|
+
fireSuggestionDismissedAnalytics('click', dismissed !== null && dismissed !== void 0 ? dismissed : null);
|
|
949
1377
|
}
|
|
950
1378
|
return false;
|
|
951
1379
|
}
|
|
@@ -961,17 +1389,17 @@ export const createAutocompletePlugin = (options, api) => {
|
|
|
961
1389
|
}
|
|
962
1390
|
return accepted;
|
|
963
1391
|
},
|
|
964
|
-
focus:
|
|
965
|
-
var _options$
|
|
1392
|
+
focus: view => {
|
|
1393
|
+
var _options$useLocalMode2, _options$useLocalMode3;
|
|
966
1394
|
if (!isAutocompleteEnabled) {
|
|
967
1395
|
return false;
|
|
968
1396
|
}
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
1397
|
+
void ensureVocabularyReady();
|
|
1398
|
+
// First point at which the session is known to be using the input,
|
|
1399
|
+
// which is where the rest of the artifacts are requested too.
|
|
1400
|
+
void ensureInlineCodeHarvester().then(() => harvestDocument(view.state.doc, 'focus')).catch(error => {
|
|
973
1401
|
logException(error, {
|
|
974
|
-
location: 'editor-plugin-autocomplete/
|
|
1402
|
+
location: 'editor-plugin-autocomplete/harvestDocument'
|
|
975
1403
|
});
|
|
976
1404
|
});
|
|
977
1405
|
loadVectorsAsync({
|
|
@@ -1005,11 +1433,13 @@ export const createAutocompletePlugin = (options, api) => {
|
|
|
1005
1433
|
// Capture up front so a subscription notification before the first PM
|
|
1006
1434
|
// transaction can still drive slowLaneClient.updateContext (gated on currentView).
|
|
1007
1435
|
currentView = editorView;
|
|
1436
|
+
mountedEditors.add(reprimeSessionLearning);
|
|
1008
1437
|
|
|
1009
1438
|
// Push channel for hosts that keep producing context after mount (e.g. Rovo chat).
|
|
1010
1439
|
if (isAutocompleteEnabled && options !== null && options !== void 0 && options.subscribeToContextUpdates) {
|
|
1011
1440
|
unsubscribeFromContextUpdates = options.subscribeToContextUpdates(() => {
|
|
1012
|
-
// Bypass throttle for freshness;
|
|
1441
|
+
// Bypass throttle for freshness; a read still open defers this one
|
|
1442
|
+
// rather than overlapping it.
|
|
1013
1443
|
refreshContext({
|
|
1014
1444
|
source: 'subscription',
|
|
1015
1445
|
allowThrottle: false
|
|
@@ -1035,16 +1465,21 @@ export const createAutocompletePlugin = (options, api) => {
|
|
|
1035
1465
|
maybeUpdateSessionFrequency(view, prevState);
|
|
1036
1466
|
const textBefore = getTextBeforeCursor(view.state);
|
|
1037
1467
|
if (isWordBoundary(textBefore)) {
|
|
1038
|
-
var
|
|
1468
|
+
var _resolvedContext5;
|
|
1039
1469
|
slowLaneClient.updateContext(buildSlowLaneText(view.state.doc.textContent, resolvedContext));
|
|
1040
1470
|
|
|
1471
|
+
// The backtick input rule has fired by the time a span is
|
|
1472
|
+
// finished, so a word boundary is the earliest point the mark
|
|
1473
|
+
// exists to be found.
|
|
1474
|
+
harvestDocument(view.state.doc, 'word-boundary');
|
|
1475
|
+
|
|
1041
1476
|
// Context may not have resolved on first focus (e.g. comment
|
|
1042
1477
|
// thread still loading). Retry on word boundaries until we have
|
|
1043
1478
|
// the parent comment, throttled so we don't refetch constantly
|
|
1044
1479
|
// and capped so non-comment editors stop retrying entirely.
|
|
1045
1480
|
// Skipped for push-channel hosts (subscribeToContextUpdates): they
|
|
1046
1481
|
// never grow parentCommentContent, so polling only burns the budget.
|
|
1047
|
-
if (!(options !== null && options !== void 0 && options.subscribeToContextUpdates) && !((
|
|
1482
|
+
if (!(options !== null && options !== void 0 && options.subscribeToContextUpdates) && !((_resolvedContext5 = resolvedContext) !== null && _resolvedContext5 !== void 0 && _resolvedContext5.parentCommentContent) && wordBoundaryRefreshAttempts < MAX_CONTEXT_REFRESH_ATTEMPTS) {
|
|
1048
1483
|
// Only count the attempt when a fetch actually started, so an
|
|
1049
1484
|
// in-flight or throttled no-op doesn't burn the retry budget.
|
|
1050
1485
|
if (refreshContext({
|
|
@@ -1071,7 +1506,35 @@ export const createAutocompletePlugin = (options, api) => {
|
|
|
1071
1506
|
if (hasDestroy(slowLaneClient)) {
|
|
1072
1507
|
slowLaneClient.destroy();
|
|
1073
1508
|
}
|
|
1074
|
-
|
|
1509
|
+
queuedContextRefreshSource = undefined;
|
|
1510
|
+
pendingSessionContextTexts.clear();
|
|
1511
|
+
sessionIngestedContextTexts.clear();
|
|
1512
|
+
pendingHarvestTexts.clear();
|
|
1513
|
+
harvestedContextTexts.clear();
|
|
1514
|
+
// The harvested set holds one conversation's user and assistant
|
|
1515
|
+
// content, and the L1 boosts describe what that conversation was
|
|
1516
|
+
// about. Both live at module scope, so they have to be emptied here
|
|
1517
|
+
// or they leak into whichever editor mounts next — but only once no
|
|
1518
|
+
// editor is left to be using them. The scope key goes with them, and
|
|
1519
|
+
// only with them: clearing it while another editor is still mounted
|
|
1520
|
+
// would leave that editor's next read looking like a first one, with
|
|
1521
|
+
// nothing to compare against and everything still loaded.
|
|
1522
|
+
mountedEditors.delete(reprimeSessionLearning);
|
|
1523
|
+
if (mountedEditors.size === 0) {
|
|
1524
|
+
var _loadedInlineCodeHarv2;
|
|
1525
|
+
(_loadedInlineCodeHarv2 = loadedInlineCodeHarvester) === null || _loadedInlineCodeHarv2 === void 0 ? void 0 : _loadedInlineCodeHarv2.resetInlineCodeHarvest();
|
|
1526
|
+
// Only for a host that scopes its context, which is the one asking
|
|
1527
|
+
// for its learning to end with the conversation. A host that sends
|
|
1528
|
+
// no scope has boosts belonging to the page it is on, and that page
|
|
1529
|
+
// outlives an editor being unmounted — a Confluence comment box is
|
|
1530
|
+
// closed far more often than the page under it changes.
|
|
1531
|
+
if (contextScopeKey !== undefined) {
|
|
1532
|
+
resetSessionBoosts();
|
|
1533
|
+
contextScopeKey = undefined;
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
inlineCodeHarvester = null;
|
|
1537
|
+
inlineCodeHarvesterPromise = undefined;
|
|
1075
1538
|
}
|
|
1076
1539
|
};
|
|
1077
1540
|
}
|