@atlaskit/editor-plugin-autocomplete 8.0.0 → 9.1.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 +108 -0
- package/dist/cjs/analytics/ufo.js +2 -1
- package/dist/cjs/pm-plugins/artifact-loader.js +135 -0
- package/dist/cjs/pm-plugins/artifacts-manifest.js +3 -0
- package/dist/cjs/pm-plugins/autocomplete-plugin.js +498 -160
- package/dist/cjs/pm-plugins/canonical-lm-scoring.js +371 -0
- package/dist/cjs/pm-plugins/debug-mode.js +105 -7
- package/dist/cjs/pm-plugins/ghost-text-decoration.js +12 -3
- package/dist/cjs/pm-plugins/local-slow-lane-client.js +1202 -453
- package/dist/cjs/pm-plugins/scoring-pipeline.js +164 -19
- package/dist/cjs/pm-plugins/slow-lane-client.js +100 -37
- package/dist/cjs/pm-plugins/text-predictor.js +2099 -309
- package/dist/es2019/analytics/ufo.js +2 -0
- package/dist/es2019/pm-plugins/artifact-loader.js +70 -0
- package/dist/es2019/pm-plugins/artifacts-manifest.js +3 -0
- package/dist/es2019/pm-plugins/autocomplete-plugin.js +489 -152
- package/dist/es2019/pm-plugins/canonical-lm-scoring.js +270 -0
- package/dist/es2019/pm-plugins/debug-mode.js +102 -7
- package/dist/es2019/pm-plugins/ghost-text-decoration.js +12 -3
- package/dist/es2019/pm-plugins/local-slow-lane-client.js +940 -326
- package/dist/es2019/pm-plugins/scoring-pipeline.js +137 -13
- package/dist/es2019/pm-plugins/slow-lane-client.js +94 -37
- package/dist/es2019/pm-plugins/text-predictor.js +1692 -200
- package/dist/esm/analytics/ufo.js +2 -1
- package/dist/esm/pm-plugins/artifact-loader.js +128 -0
- package/dist/esm/pm-plugins/artifacts-manifest.js +3 -0
- package/dist/esm/pm-plugins/autocomplete-plugin.js +501 -163
- package/dist/esm/pm-plugins/canonical-lm-scoring.js +364 -0
- package/dist/esm/pm-plugins/debug-mode.js +104 -6
- package/dist/esm/pm-plugins/ghost-text-decoration.js +12 -3
- package/dist/esm/pm-plugins/local-slow-lane-client.js +1200 -451
- package/dist/esm/pm-plugins/scoring-pipeline.js +163 -18
- package/dist/esm/pm-plugins/slow-lane-client.js +100 -37
- package/dist/esm/pm-plugins/text-predictor.js +2101 -313
- package/dist/types/analytics/ufo.d.ts +1 -0
- package/dist/types/pm-plugins/artifact-loader.d.ts +44 -0
- package/dist/types/pm-plugins/artifacts-manifest.d.ts +3 -0
- package/dist/types/pm-plugins/autocomplete-plugin.d.ts +39 -0
- package/dist/types/pm-plugins/canonical-lm-scoring.d.ts +158 -0
- package/dist/types/pm-plugins/debug-mode.d.ts +55 -1
- package/dist/types/pm-plugins/ghost-text-decoration.d.ts +5 -2
- package/dist/types/pm-plugins/local-slow-lane-client.d.ts +87 -54
- package/dist/types/pm-plugins/scoring-pipeline.d.ts +33 -1
- package/dist/types/pm-plugins/slow-lane-client.d.ts +51 -3
- package/dist/types/pm-plugins/text-predictor.d.ts +156 -3
- package/package.json +2 -2
- package/src/analytics/ufo.ts +5 -0
- package/src/pm-plugins/artifact-loader.ts +92 -0
- package/src/pm-plugins/artifacts-manifest.ts +3 -0
- package/src/pm-plugins/autocomplete-plugin.ts +584 -154
- package/src/pm-plugins/canonical-lm-scoring.ts +412 -0
- package/src/pm-plugins/debug-mode.ts +134 -7
- package/src/pm-plugins/ghost-text-decoration.ts +13 -4
- package/src/pm-plugins/local-slow-lane-client.ts +1302 -386
- package/src/pm-plugins/scoring-pipeline.ts +167 -17
- package/src/pm-plugins/slow-lane-client.ts +147 -62
- package/src/pm-plugins/text-predictor.ts +2119 -254
- package/CANONICAL_FIX__DO_NOT_USE_ME_A/package.json +0 -8
- package/CANONICAL_FIX__DO_NOT_USE_ME_B/package.json +0 -8
- package/CANONICAL_FIX__DO_NOT_USE_ME_C/package.json +0 -8
- package/dist/cjs/entry-points/src-pm-plugins-autocomplete-plugin.js +0 -18
- package/dist/cjs/entry-points/src-pm-plugins-slow-lane-client.js +0 -36
- package/dist/cjs/entry-points/src-pm-plugins-text-predictor.js +0 -66
- package/dist/es2019/entry-points/src-pm-plugins-autocomplete-plugin.js +0 -2
- package/dist/es2019/entry-points/src-pm-plugins-slow-lane-client.js +0 -2
- package/dist/es2019/entry-points/src-pm-plugins-text-predictor.js +0 -2
- package/dist/esm/entry-points/src-pm-plugins-autocomplete-plugin.js +0 -2
- package/dist/esm/entry-points/src-pm-plugins-slow-lane-client.js +0 -2
- package/dist/esm/entry-points/src-pm-plugins-text-predictor.js +0 -2
- package/dist/types/entry-points/src-pm-plugins-autocomplete-plugin.d.ts +0 -2
- package/dist/types/entry-points/src-pm-plugins-slow-lane-client.d.ts +0 -2
- package/dist/types/entry-points/src-pm-plugins-text-predictor.d.ts +0 -2
- package/scripts/gen_first_token_to_words.py +0 -170
- package/src/entry-points/src-pm-plugins-autocomplete-plugin.ts +0 -7
- package/src/entry-points/src-pm-plugins-slow-lane-client.ts +0 -13
- package/src/entry-points/src-pm-plugins-text-predictor.ts +0 -14
- package/src/pm-plugins/autocomplete-plugin/package.json +0 -8
- package/src/pm-plugins/slow-lane-client/package.json +0 -8
- package/src/pm-plugins/text-predictor/package.json +0 -8
|
@@ -14,7 +14,12 @@ import type { EditorView } from '@atlaskit/editor-prosemirror/view';
|
|
|
14
14
|
|
|
15
15
|
import type { AutocompletePlugin } from '../autocompletePluginType';
|
|
16
16
|
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
CTC_STYLES,
|
|
19
|
+
ctcTag,
|
|
20
|
+
isAutocompleteDebugEnabled,
|
|
21
|
+
isAutocompleteDebugVerbose,
|
|
22
|
+
} from './debug-mode';
|
|
18
23
|
import { createGhostTextDecorationSet } from './ghost-text-decoration';
|
|
19
24
|
import {
|
|
20
25
|
createLocalSlowLaneClient,
|
|
@@ -23,18 +28,29 @@ import {
|
|
|
23
28
|
type LocalSlowLaneLoadSuccess,
|
|
24
29
|
} from './local-slow-lane-client';
|
|
25
30
|
import { loadGrammarDataAsync } from './scoring-pipeline';
|
|
26
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
clearDefaultSlowLaneClient,
|
|
33
|
+
createSlowLaneClient,
|
|
34
|
+
getDefaultSlowLaneClientStatus,
|
|
35
|
+
isWordBoundary,
|
|
36
|
+
setDefaultSlowLaneClient,
|
|
37
|
+
type SlowLaneClientKind,
|
|
38
|
+
} from './slow-lane-client';
|
|
27
39
|
import {
|
|
28
40
|
predict,
|
|
41
|
+
type PredictionResult,
|
|
29
42
|
loadDefaultVocabulary,
|
|
30
43
|
loadVectorsAsync,
|
|
31
44
|
incrementSessionFreq,
|
|
32
45
|
ingestDocumentPage,
|
|
46
|
+
getLastPredictionDebug,
|
|
47
|
+
noteSuggestionAccepted,
|
|
33
48
|
} from './text-predictor';
|
|
34
49
|
|
|
35
50
|
export const autocompletePluginKey: PluginKey = new PluginKey('autocomplete');
|
|
36
51
|
|
|
37
|
-
const
|
|
52
|
+
const PREDICTION_COALESCE_MS = 0;
|
|
53
|
+
const GHOST_DECISION_BUDGET_MS = 100;
|
|
38
54
|
const NETWORK_SLOW_LANE_DEBOUNCE_MS = 300;
|
|
39
55
|
const LOCAL_SLOW_LANE_DEBOUNCE_MS = 100;
|
|
40
56
|
const CONTEXT_REFRESH_THROTTLE_MS = 1000;
|
|
@@ -49,16 +65,70 @@ const MAX_INGESTED_CONTEXT_TEXTS = 50;
|
|
|
49
65
|
// on every word boundary for the plugin's lifetime.
|
|
50
66
|
const MAX_CONTEXT_REFRESH_ATTEMPTS = 5;
|
|
51
67
|
const INLINE_LEAF_TEXT = ' ';
|
|
68
|
+
let slowLaneClientInstanceSequence = 0;
|
|
52
69
|
|
|
53
|
-
// eslint-disable-next-line require-unicode-regexp
|
|
54
|
-
const TRAILING_NON_BOUNDARY_TOKEN_REGEX = /([^\s.,;:!?]*)$/;
|
|
55
70
|
// eslint-disable-next-line require-unicode-regexp
|
|
56
71
|
const WORD_BOUNDARY_CHARS_REGEX = /[\s.,;:!?]/;
|
|
72
|
+
const TRAILING_SURFACE_PREFIX_REGEX = /[\p{L}\p{N}_'-]+$/u;
|
|
73
|
+
|
|
74
|
+
type AsyncPredictionSignal = {
|
|
75
|
+
contextKey?: string;
|
|
76
|
+
familyKey?: string;
|
|
77
|
+
kind: 'boundary' | 'semantic' | 'surface';
|
|
78
|
+
latencyMs?: number;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
type PredictionDecision = {
|
|
82
|
+
asyncSignals: number;
|
|
83
|
+
deadlineAt: number;
|
|
84
|
+
evaluations: number;
|
|
85
|
+
position: number;
|
|
86
|
+
revision: number;
|
|
87
|
+
startedAt: number;
|
|
88
|
+
textBefore: string;
|
|
89
|
+
};
|
|
57
90
|
|
|
58
91
|
const hasDestroy = (
|
|
59
92
|
client: ReturnType<typeof createSlowLaneClient> | LocalSlowLaneClient,
|
|
60
93
|
): client is LocalSlowLaneClient => 'destroy' in client && typeof client.destroy === 'function';
|
|
61
94
|
|
|
95
|
+
export interface CommittedSuggestion {
|
|
96
|
+
/** Wall-clock latency from the triggering editor update to the committed ghost. */
|
|
97
|
+
decisionLatencyMs: number;
|
|
98
|
+
/** Evidence tier that authorized this suggestion for display. */
|
|
99
|
+
evidenceTier: PredictionResult['evidenceTier'];
|
|
100
|
+
/** Ghost tail actually rendered and inserted. */
|
|
101
|
+
ghostText: string;
|
|
102
|
+
/**
|
|
103
|
+
* Mean per-token log-probability of the verified prefix, carried so an
|
|
104
|
+
* acceptance can be attributed to the evidence that authorized the display
|
|
105
|
+
* rather than to whatever the scorer holds by the time Tab arrives.
|
|
106
|
+
*/
|
|
107
|
+
meanTokenLogProbability: number;
|
|
108
|
+
/**
|
|
109
|
+
* Whether a longer candidate in the same pool extended this surface, which
|
|
110
|
+
* hands it that pool's mass undivided under the chain rule and puts it near
|
|
111
|
+
* the top of the posterior range for a structural reason.
|
|
112
|
+
*/
|
|
113
|
+
poolHeldExtension: boolean;
|
|
114
|
+
/** ProseMirror position at which the ghost is anchored. */
|
|
115
|
+
position: number;
|
|
116
|
+
/** Share of its shortlist's mass the model put on this surface. */
|
|
117
|
+
posterior: number;
|
|
118
|
+
/** Final confidence-v2 ranking score. */
|
|
119
|
+
rankScore: number;
|
|
120
|
+
/** Monotonic editor decision revision that owns this suggestion. */
|
|
121
|
+
revision: number;
|
|
122
|
+
/** How many scored candidates this surface's normaliser divided between. */
|
|
123
|
+
shortlistSize: number;
|
|
124
|
+
/** Full candidate surface, used by analytics and cooldown. */
|
|
125
|
+
surface: string;
|
|
126
|
+
/** Whether the surface is a single word, a bigram, or a phrase. */
|
|
127
|
+
termType: PredictionResult['termType'];
|
|
128
|
+
/** Difference between the selected rank score and its closest eligible rival. */
|
|
129
|
+
winnerMargin: number;
|
|
130
|
+
}
|
|
131
|
+
|
|
62
132
|
export interface AutocompletePluginState {
|
|
63
133
|
/** The decoration set containing the ghost text widget */
|
|
64
134
|
decorationSet: DecorationSet;
|
|
@@ -66,14 +136,77 @@ export interface AutocompletePluginState {
|
|
|
66
136
|
ghostPosition: number;
|
|
67
137
|
/** The predicted ghost text to display */
|
|
68
138
|
ghostText: string;
|
|
139
|
+
/** Single authoritative snapshot for rendering, insertion, analytics, and cooldown. */
|
|
140
|
+
suggestion: CommittedSuggestion | null;
|
|
69
141
|
}
|
|
70
142
|
|
|
71
143
|
const createInitialState = (): AutocompletePluginState => ({
|
|
72
144
|
ghostText: '',
|
|
73
145
|
ghostPosition: -1,
|
|
74
146
|
decorationSet: DecorationSet.empty,
|
|
147
|
+
suggestion: null,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const clearGhostState = (pluginState: AutocompletePluginState): AutocompletePluginState => ({
|
|
151
|
+
...pluginState,
|
|
152
|
+
ghostText: '',
|
|
153
|
+
ghostPosition: -1,
|
|
154
|
+
decorationSet: DecorationSet.empty,
|
|
155
|
+
suggestion: null,
|
|
75
156
|
});
|
|
76
157
|
|
|
158
|
+
/**
|
|
159
|
+
* Keep a displayed ghost alive when the user types the character it already
|
|
160
|
+
* predicted.
|
|
161
|
+
*
|
|
162
|
+
* Clearing on every document change throws away a suggestion the keystroke just
|
|
163
|
+
* confirmed, and the replacement decision has to re-earn it from an empty state
|
|
164
|
+
* inside the budget — which it often misses, so the ghost blinks out mid-word.
|
|
165
|
+
* It is worse for multi-word surfaces: typing the space in `machine learning`
|
|
166
|
+
* empties the current word, and the display gate refuses to show anything again
|
|
167
|
+
* until three characters of `learning` exist.
|
|
168
|
+
*
|
|
169
|
+
* A confirming keystroke is not new information about the prediction, so the
|
|
170
|
+
* ghost simply advances by one character and the still-running decision is free
|
|
171
|
+
* to replace it with something better. Anything else — a different character,
|
|
172
|
+
* a paste, a deletion, an edit elsewhere — clears as before.
|
|
173
|
+
*/
|
|
174
|
+
const advanceGhostThroughTypedCharacter = (
|
|
175
|
+
tr: ReadonlyTransaction,
|
|
176
|
+
pluginState: AutocompletePluginState,
|
|
177
|
+
): AutocompletePluginState | null => {
|
|
178
|
+
const { ghostPosition, ghostText, suggestion } = pluginState;
|
|
179
|
+
if (ghostText.length < 2 || ghostPosition < 0 || !suggestion) {
|
|
180
|
+
// A one-character tail leaves nothing to show after advancing.
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Exactly one character longer, with the cursor sitting just past the ghost
|
|
185
|
+
// anchor: together these identify a single insertion made at the anchor
|
|
186
|
+
// rather than a paste or an edit elsewhere in the document.
|
|
187
|
+
if (tr.doc.content.size - tr.before.content.size !== 1) {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
if (!tr.selection.empty || tr.selection.from !== ghostPosition + 1) {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
if (tr.doc.textBetween(ghostPosition, ghostPosition + 1) !== ghostText[0]) {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const remaining = ghostText.slice(1);
|
|
198
|
+
const nextPosition = ghostPosition + 1;
|
|
199
|
+
return {
|
|
200
|
+
...pluginState,
|
|
201
|
+
ghostText: remaining,
|
|
202
|
+
ghostPosition: nextPosition,
|
|
203
|
+
decorationSet: createGhostTextDecorationSet(tr.doc, nextPosition, remaining),
|
|
204
|
+
// `surface` stays the full candidate so cooldown and analytics still
|
|
205
|
+
// describe the suggestion that was originally committed.
|
|
206
|
+
suggestion: { ...suggestion, ghostText: remaining, position: nextPosition },
|
|
207
|
+
};
|
|
208
|
+
};
|
|
209
|
+
|
|
77
210
|
/**
|
|
78
211
|
* Extract text content before the cursor from the current document.
|
|
79
212
|
* Returns the last ~200 characters for context.
|
|
@@ -111,6 +244,9 @@ const getTextBeforeCursor = (state: EditorState): string => {
|
|
|
111
244
|
return fullText.slice(-maxChars);
|
|
112
245
|
};
|
|
113
246
|
|
|
247
|
+
const getTrailingSurfacePrefixLength = (text: string): number =>
|
|
248
|
+
text.trimEnd().match(TRAILING_SURFACE_PREFIX_REGEX)?.[0].length ?? 0;
|
|
249
|
+
|
|
114
250
|
/**
|
|
115
251
|
* Set the autocomplete state via a transaction metadata.
|
|
116
252
|
*/
|
|
@@ -126,18 +262,42 @@ const setAutocompleteMeta = (
|
|
|
126
262
|
*/
|
|
127
263
|
let lastShownGhostText = '';
|
|
128
264
|
|
|
129
|
-
const showGhostText = (
|
|
265
|
+
const showGhostText = (
|
|
266
|
+
view: EditorView,
|
|
267
|
+
prediction: PredictionResult,
|
|
268
|
+
position: number,
|
|
269
|
+
revision: number,
|
|
270
|
+
decisionStartedAt: number,
|
|
271
|
+
): CommittedSuggestion | null => {
|
|
130
272
|
try {
|
|
131
273
|
const { state, dispatch } = view;
|
|
132
|
-
const
|
|
274
|
+
const suggestion: CommittedSuggestion = {
|
|
275
|
+
decisionLatencyMs: performance.now() - decisionStartedAt,
|
|
276
|
+
evidenceTier: prediction.evidenceTier,
|
|
277
|
+
ghostText: prediction.text,
|
|
278
|
+
meanTokenLogProbability: prediction.meanTokenLogProbability,
|
|
279
|
+
poolHeldExtension: prediction.poolHeldExtension,
|
|
280
|
+
position,
|
|
281
|
+
posterior: prediction.posterior,
|
|
282
|
+
rankScore: prediction.rankScore,
|
|
283
|
+
revision,
|
|
284
|
+
shortlistSize: prediction.shortlistSize,
|
|
285
|
+
surface: prediction.surface,
|
|
286
|
+
termType: prediction.termType,
|
|
287
|
+
winnerMargin: prediction.winnerMargin,
|
|
288
|
+
};
|
|
289
|
+
const decorationSet = createGhostTextDecorationSet(state.doc, position, prediction.text);
|
|
133
290
|
const tr = setAutocompleteMeta(state.tr, {
|
|
134
|
-
ghostText: text,
|
|
291
|
+
ghostText: prediction.text,
|
|
135
292
|
ghostPosition: position,
|
|
136
293
|
decorationSet,
|
|
294
|
+
suggestion,
|
|
137
295
|
});
|
|
138
296
|
dispatch(tr);
|
|
297
|
+
return suggestion;
|
|
139
298
|
} catch (error) {
|
|
140
299
|
logException(error as Error, { location: 'editor-plugin-autocomplete/showGhostText' });
|
|
300
|
+
return null;
|
|
141
301
|
}
|
|
142
302
|
};
|
|
143
303
|
|
|
@@ -156,6 +316,7 @@ const clearGhostText = (state: EditorState, dispatch?: (tr: Transaction) => void
|
|
|
156
316
|
ghostText: '',
|
|
157
317
|
ghostPosition: -1,
|
|
158
318
|
decorationSet: DecorationSet.empty,
|
|
319
|
+
suggestion: null,
|
|
159
320
|
});
|
|
160
321
|
dispatch(tr);
|
|
161
322
|
} catch (error) {
|
|
@@ -169,42 +330,48 @@ const clearGhostText = (state: EditorState, dispatch?: (tr: Transaction) => void
|
|
|
169
330
|
/**
|
|
170
331
|
* Accept the current ghost text suggestion and insert it into the document.
|
|
171
332
|
*/
|
|
172
|
-
const acceptGhostText = (
|
|
333
|
+
const acceptGhostText = (
|
|
334
|
+
state: EditorState,
|
|
335
|
+
dispatch?: (tr: Transaction) => void,
|
|
336
|
+
): CommittedSuggestion | null => {
|
|
173
337
|
const pluginState = autocompletePluginKey.getState(state) as AutocompletePluginState | undefined;
|
|
174
|
-
if (!pluginState || !pluginState.ghostText) {
|
|
175
|
-
return
|
|
338
|
+
if (!pluginState?.suggestion || !pluginState.ghostText) {
|
|
339
|
+
return null;
|
|
176
340
|
}
|
|
177
341
|
|
|
178
342
|
if (dispatch) {
|
|
179
343
|
try {
|
|
180
|
-
const { ghostText, ghostPosition } = pluginState;
|
|
344
|
+
const { ghostText, ghostPosition, suggestion } = pluginState;
|
|
181
345
|
let tr = state.tr.insertText(ghostText, ghostPosition);
|
|
182
346
|
tr = setAutocompleteMeta(tr, {
|
|
183
347
|
ghostText: '',
|
|
184
348
|
ghostPosition: -1,
|
|
185
349
|
decorationSet: DecorationSet.empty,
|
|
350
|
+
suggestion: null,
|
|
186
351
|
});
|
|
187
352
|
dispatch(tr);
|
|
353
|
+
return suggestion;
|
|
188
354
|
} catch (error) {
|
|
189
355
|
logException(error as Error, { location: 'editor-plugin-autocomplete/acceptGhostText' });
|
|
190
|
-
return
|
|
356
|
+
return null;
|
|
191
357
|
}
|
|
192
358
|
}
|
|
193
|
-
return
|
|
359
|
+
return pluginState.suggestion;
|
|
194
360
|
};
|
|
195
361
|
|
|
196
362
|
const acceptGhostTextWithAnalytics = (
|
|
197
363
|
state: EditorState,
|
|
198
364
|
dispatch: ((tr: Transaction) => void) | undefined,
|
|
199
|
-
onAccepted: (
|
|
365
|
+
onAccepted: (suggestion: CommittedSuggestion) => void,
|
|
200
366
|
): boolean => {
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
367
|
+
const acceptedSuggestion = acceptGhostText(state, dispatch);
|
|
368
|
+
if (acceptedSuggestion) {
|
|
369
|
+
// Start the post-accept cooldown so the just-accepted unit isn't immediately
|
|
370
|
+
// re-offered (QI-2 echo, e.g. `end to end` → `end to end to end`).
|
|
371
|
+
noteSuggestionAccepted(acceptedSuggestion.surface);
|
|
372
|
+
onAccepted(acceptedSuggestion);
|
|
206
373
|
}
|
|
207
|
-
return
|
|
374
|
+
return acceptedSuggestion !== null;
|
|
208
375
|
};
|
|
209
376
|
|
|
210
377
|
/**
|
|
@@ -323,15 +490,6 @@ const getLeadingTextCharacter = (text?: string): string | undefined => {
|
|
|
323
490
|
return String.fromCodePoint(firstCodePoint);
|
|
324
491
|
};
|
|
325
492
|
|
|
326
|
-
const getTypedLengthForPrediction = (textBefore: string): number => {
|
|
327
|
-
if (isWordBoundary(textBefore)) {
|
|
328
|
-
return 0;
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
const trailingToken = textBefore.match(TRAILING_NON_BOUNDARY_TOKEN_REGEX)?.[1] ?? '';
|
|
332
|
-
return trailingToken.length;
|
|
333
|
-
};
|
|
334
|
-
|
|
335
493
|
const isEnglishLocale = (locale?: string): boolean => {
|
|
336
494
|
if (!locale) {
|
|
337
495
|
return false;
|
|
@@ -350,6 +508,7 @@ export const createAutocompletePlugin = (
|
|
|
350
508
|
const isAutocompleteEnabled = isEnglishLocale(locale);
|
|
351
509
|
|
|
352
510
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
511
|
+
let decisionDeadlineTimer: ReturnType<typeof setTimeout> | null = null;
|
|
353
512
|
let hasIngestedPage = false;
|
|
354
513
|
let resolvedContext: AutocompleteContext | undefined;
|
|
355
514
|
/**
|
|
@@ -359,6 +518,10 @@ export const createAutocompletePlugin = (
|
|
|
359
518
|
*/
|
|
360
519
|
let currentView: EditorView | null = null;
|
|
361
520
|
let unsubscribeFromContextUpdates: (() => void) | undefined;
|
|
521
|
+
// "Prime, don't ask-and-read": semantic, Tier-A, and Tier-B results land
|
|
522
|
+
// asynchronously, often with no following keystroke. Re-run prediction when
|
|
523
|
+
// they arrive so the caches are consumed immediately.
|
|
524
|
+
let reschedulePredictionOnSlowLaneResult: ((signal: AsyncPredictionSignal) => void) | null = null;
|
|
362
525
|
/**
|
|
363
526
|
* Set after accepting a suggestion so the next doc-change update
|
|
364
527
|
* skips scheduling a new prediction for the just-inserted text.
|
|
@@ -372,8 +535,8 @@ export const createAutocompletePlugin = (
|
|
|
372
535
|
* re-showing the same suggestion. Resets to null as soon as the text changes.
|
|
373
536
|
*/
|
|
374
537
|
let dismissedContext: string | null = null;
|
|
375
|
-
let
|
|
376
|
-
let
|
|
538
|
+
let decisionRevision = 0;
|
|
539
|
+
let activeDecision: PredictionDecision | undefined;
|
|
377
540
|
|
|
378
541
|
/**
|
|
379
542
|
* cold → no slow-lane vector received yet; frequency-only trie scoring
|
|
@@ -435,9 +598,9 @@ export const createAutocompletePlugin = (
|
|
|
435
598
|
});
|
|
436
599
|
};
|
|
437
600
|
|
|
438
|
-
const fireSuggestionInsertedAnalytics = (
|
|
439
|
-
const typedLength =
|
|
440
|
-
const suggestionLength =
|
|
601
|
+
const fireSuggestionInsertedAnalytics = (suggestion: CommittedSuggestion): void => {
|
|
602
|
+
const typedLength = Math.max(0, suggestion.surface.length - suggestion.ghostText.length);
|
|
603
|
+
const suggestionLength = suggestion.surface.length;
|
|
441
604
|
const kssDelta = suggestionLength - typedLength;
|
|
442
605
|
|
|
443
606
|
api?.analytics?.actions.fireAnalyticsEvent({
|
|
@@ -459,14 +622,44 @@ export const createAutocompletePlugin = (
|
|
|
459
622
|
debounceMs: LOCAL_SLOW_LANE_DEBOUNCE_MS,
|
|
460
623
|
onLoadSuccess: fireLocalModelLoadedAnalytics,
|
|
461
624
|
onLoadError: fireLocalModelLoadFailedAnalytics,
|
|
625
|
+
// Re-run prediction for each independently scheduled model result.
|
|
626
|
+
onUpdate: () => reschedulePredictionOnSlowLaneResult?.({ kind: 'semantic' }),
|
|
627
|
+
onBoundaryLmUpdate: ({ contextKey, familyKey, latencyMs }) =>
|
|
628
|
+
reschedulePredictionOnSlowLaneResult?.({
|
|
629
|
+
contextKey,
|
|
630
|
+
familyKey,
|
|
631
|
+
kind: 'boundary',
|
|
632
|
+
latencyMs,
|
|
633
|
+
}),
|
|
634
|
+
onSurfaceScoreUpdate: ({ contextKey, familyKey, latencyMs }) =>
|
|
635
|
+
reschedulePredictionOnSlowLaneResult?.({
|
|
636
|
+
contextKey,
|
|
637
|
+
familyKey,
|
|
638
|
+
kind: 'surface',
|
|
639
|
+
latencyMs,
|
|
640
|
+
}),
|
|
462
641
|
surface,
|
|
463
642
|
})
|
|
464
643
|
: createSlowLaneClient({
|
|
465
644
|
baseUrl: '',
|
|
466
645
|
debounceMs: NETWORK_SLOW_LANE_DEBOUNCE_MS,
|
|
646
|
+
// Re-run prediction when the BE semantic vector / LM logits land so
|
|
647
|
+
// warm-mode unigram ranking applies without waiting for a keystroke.
|
|
648
|
+
onUpdate: () => reschedulePredictionOnSlowLaneResult?.({ kind: 'semantic' }),
|
|
467
649
|
surface,
|
|
468
650
|
});
|
|
469
|
-
|
|
651
|
+
const slowLaneClientKind: SlowLaneClientKind = options?.useLocalModel ? 'localLlm' : 'server';
|
|
652
|
+
const slowLaneClientId = `${surface}:${slowLaneClientKind}:${++slowLaneClientInstanceSequence}`;
|
|
653
|
+
setDefaultSlowLaneClient(slowLaneClient, {
|
|
654
|
+
clientId: slowLaneClientId,
|
|
655
|
+
kind: slowLaneClientKind,
|
|
656
|
+
});
|
|
657
|
+
const registeredSlowLaneStatus = getDefaultSlowLaneClientStatus();
|
|
658
|
+
ctcTag(
|
|
659
|
+
'init',
|
|
660
|
+
`slow lane registered · id=${slowLaneClientId} · selected=${slowLaneClientKind} · canonical=${registeredSlowLaneStatus.canonicalScoringSupported ? 'yes' : 'no'} · ready=${registeredSlowLaneStatus.localModelReady ?? 'n/a'}`,
|
|
661
|
+
slowLaneClientKind === 'localLlm' ? CTC_STYLES.good : CTC_STYLES.warn,
|
|
662
|
+
);
|
|
470
663
|
|
|
471
664
|
let contextRequestInFlight = false;
|
|
472
665
|
let lastContextRefreshAt = 0;
|
|
@@ -476,7 +669,67 @@ export const createAutocompletePlugin = (
|
|
|
476
669
|
// Set when the plugin is torn down so in-flight getContext() resolutions don't
|
|
477
670
|
// mutate the global text-predictor state after destruction.
|
|
478
671
|
let destroyed = false;
|
|
479
|
-
|
|
672
|
+
// L1 ingestion cannot run before the vocabulary has loaded: `incrementSessionFreq`
|
|
673
|
+
// only touches trie nodes that already exist, so an empty trie silently rejects
|
|
674
|
+
// every word. Text that arrives first — which a page usually does, since the
|
|
675
|
+
// chat resolves it on focus in the same tick as the load starts — is held here
|
|
676
|
+
// and applied once the load has settled.
|
|
677
|
+
const pendingSessionContextTexts = new Set<string>();
|
|
678
|
+
const sessionIngestedContextTexts = new Set<string>();
|
|
679
|
+
let isVocabularyReady = false;
|
|
680
|
+
let vocabularyLoadPromise: Promise<void> | undefined;
|
|
681
|
+
|
|
682
|
+
const addBoundedContextText = (texts: Set<string>, text: string): void => {
|
|
683
|
+
texts.add(text);
|
|
684
|
+
// Evict oldest entries (Set preserves insertion order) to bound memory.
|
|
685
|
+
while (texts.size > MAX_INGESTED_CONTEXT_TEXTS) {
|
|
686
|
+
const oldest = texts.values().next().value;
|
|
687
|
+
if (oldest === undefined) {
|
|
688
|
+
break;
|
|
689
|
+
}
|
|
690
|
+
texts.delete(oldest);
|
|
691
|
+
}
|
|
692
|
+
};
|
|
693
|
+
|
|
694
|
+
const flushPendingSessionContext = (): void => {
|
|
695
|
+
if (!isVocabularyReady || destroyed) {
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
for (const text of pendingSessionContextTexts) {
|
|
699
|
+
if (!sessionIngestedContextTexts.has(text)) {
|
|
700
|
+
ingestDocumentPage(text);
|
|
701
|
+
addBoundedContextText(sessionIngestedContextTexts, text);
|
|
702
|
+
}
|
|
703
|
+
pendingSessionContextTexts.delete(text);
|
|
704
|
+
}
|
|
705
|
+
};
|
|
706
|
+
|
|
707
|
+
const ensureVocabularyReady = (): Promise<void> => {
|
|
708
|
+
if (isVocabularyReady) {
|
|
709
|
+
flushPendingSessionContext();
|
|
710
|
+
return Promise.resolve();
|
|
711
|
+
}
|
|
712
|
+
vocabularyLoadPromise ??= loadDefaultVocabulary({
|
|
713
|
+
isLocalLLM: options?.useLocalModel ?? false,
|
|
714
|
+
surface,
|
|
715
|
+
})
|
|
716
|
+
.then(() => {
|
|
717
|
+
if (destroyed) {
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
isVocabularyReady = true;
|
|
721
|
+
flushPendingSessionContext();
|
|
722
|
+
})
|
|
723
|
+
.catch((error) => {
|
|
724
|
+
// Do not consume the pending text on failure. A later focus retries the
|
|
725
|
+
// load and can still apply the original context exactly once.
|
|
726
|
+
vocabularyLoadPromise = undefined;
|
|
727
|
+
logException(error as Error, {
|
|
728
|
+
location: 'editor-plugin-autocomplete/loadDefaultVocabulary',
|
|
729
|
+
});
|
|
730
|
+
});
|
|
731
|
+
return vocabularyLoadPromise;
|
|
732
|
+
};
|
|
480
733
|
|
|
481
734
|
const logContextResolved = (source: string, context?: AutocompleteContext): void => {
|
|
482
735
|
if (!isAutocompleteDebugEnabled()) {
|
|
@@ -484,18 +737,13 @@ export const createAutocompletePlugin = (
|
|
|
484
737
|
}
|
|
485
738
|
|
|
486
739
|
// eslint-disable-next-line no-console
|
|
487
|
-
console.log(
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
parentCommentPreview: context?.parentCommentContent?.slice(0, 80),
|
|
495
|
-
siblingCount: context?.siblingCommentsContents?.length ?? 0,
|
|
496
|
-
hasFullPage: !!context?.fullPageContent,
|
|
497
|
-
},
|
|
498
|
-
);
|
|
740
|
+
console.log('%c[CTC:signal]%c getContext resolved', CTC_STYLES.brand, CTC_STYLES.body, {
|
|
741
|
+
source,
|
|
742
|
+
hasParentComment: !!context?.parentCommentContent,
|
|
743
|
+
parentCommentPreview: context?.parentCommentContent?.slice(0, 80),
|
|
744
|
+
siblingCount: context?.siblingCommentsContents?.length ?? 0,
|
|
745
|
+
hasFullPage: !!context?.fullPageContent,
|
|
746
|
+
});
|
|
499
747
|
};
|
|
500
748
|
|
|
501
749
|
const applyContext = (context: AutocompleteContext): void => {
|
|
@@ -510,20 +758,11 @@ export const createAutocompletePlugin = (
|
|
|
510
758
|
resolvedContext = { ...resolvedContext, ...definedContext };
|
|
511
759
|
|
|
512
760
|
const ingestContextText = (text?: string): void => {
|
|
513
|
-
if (!text ||
|
|
761
|
+
if (!text || sessionIngestedContextTexts.has(text)) {
|
|
514
762
|
return;
|
|
515
763
|
}
|
|
516
764
|
|
|
517
|
-
|
|
518
|
-
// Evict oldest entries (Set preserves insertion order) to bound memory.
|
|
519
|
-
while (ingestedContextTexts.size > MAX_INGESTED_CONTEXT_TEXTS) {
|
|
520
|
-
const oldest = ingestedContextTexts.values().next().value;
|
|
521
|
-
if (oldest === undefined) {
|
|
522
|
-
break;
|
|
523
|
-
}
|
|
524
|
-
ingestedContextTexts.delete(oldest);
|
|
525
|
-
}
|
|
526
|
-
ingestDocumentPage(text);
|
|
765
|
+
addBoundedContextText(pendingSessionContextTexts, text);
|
|
527
766
|
};
|
|
528
767
|
|
|
529
768
|
ingestContextText(context.fullPageContent);
|
|
@@ -531,6 +770,7 @@ export const createAutocompletePlugin = (
|
|
|
531
770
|
for (const siblingCommentContent of context.siblingCommentsContents ?? []) {
|
|
532
771
|
ingestContextText(siblingCommentContent);
|
|
533
772
|
}
|
|
773
|
+
flushPendingSessionContext();
|
|
534
774
|
|
|
535
775
|
// Context arrived after word boundaries may already have fired. Re-send
|
|
536
776
|
// slow-lane context immediately so the next inference includes the thread.
|
|
@@ -593,77 +833,273 @@ export const createAutocompletePlugin = (
|
|
|
593
833
|
return true;
|
|
594
834
|
};
|
|
595
835
|
|
|
596
|
-
|
|
597
|
-
* Schedule a prediction after a short debounce.
|
|
598
|
-
* Tier 1 predictions are synchronous (<0.1ms) but we still debounce
|
|
599
|
-
* to avoid unnecessary work on rapid keystrokes.
|
|
600
|
-
*/
|
|
601
|
-
const schedulePrediction = (view: EditorView): void => {
|
|
836
|
+
const clearDecisionTimers = (): void => {
|
|
602
837
|
if (debounceTimer) {
|
|
603
838
|
clearTimeout(debounceTimer);
|
|
839
|
+
debounceTimer = null;
|
|
604
840
|
}
|
|
841
|
+
if (decisionDeadlineTimer) {
|
|
842
|
+
clearTimeout(decisionDeadlineTimer);
|
|
843
|
+
decisionDeadlineTimer = null;
|
|
844
|
+
}
|
|
845
|
+
};
|
|
605
846
|
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
847
|
+
const cancelActiveDecision = (): void => {
|
|
848
|
+
clearDecisionTimers();
|
|
849
|
+
activeDecision = undefined;
|
|
850
|
+
};
|
|
610
851
|
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
852
|
+
const isDecisionSnapshotCurrent = (view: EditorView, decision: PredictionDecision): boolean => {
|
|
853
|
+
const { state } = view;
|
|
854
|
+
return (
|
|
855
|
+
state.selection.empty &&
|
|
856
|
+
state.selection.from === decision.position &&
|
|
857
|
+
getTextBeforeCursor(state) === decision.textBefore
|
|
858
|
+
);
|
|
859
|
+
};
|
|
614
860
|
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
// nodeAfter correctly handles inline atoms (mentions, emojis) where
|
|
618
|
-
// parentOffset and textContent indices diverge.
|
|
619
|
-
const nodeAfter = selection.$from.nodeAfter;
|
|
620
|
-
const charAfterCursor = nodeAfter?.isText
|
|
621
|
-
? getLeadingTextCharacter(nodeAfter.text)
|
|
622
|
-
: undefined;
|
|
623
|
-
// Only suppress for Unicode letters, digits, and underscore — hyphens,
|
|
624
|
-
// apostrophes, and similar punctuation are valid left-edge boundaries
|
|
625
|
-
// and should not block suggestions (e.g. cursor before '-' in compound-word).
|
|
626
|
-
if (charAfterCursor && isMidWordCharacter(charAfterCursor)) {
|
|
627
|
-
return;
|
|
628
|
-
}
|
|
861
|
+
const isDecisionCurrent = (view: EditorView, decision: PredictionDecision): boolean =>
|
|
862
|
+
activeDecision?.revision === decision.revision && isDecisionSnapshotCurrent(view, decision);
|
|
629
863
|
|
|
630
|
-
|
|
864
|
+
const expireActiveDecision = (decision: PredictionDecision): void => {
|
|
865
|
+
if (activeDecision?.revision !== decision.revision) {
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
631
868
|
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
if (textBefore === dismissedContext) {
|
|
635
|
-
return;
|
|
636
|
-
}
|
|
637
|
-
dismissedContext = null;
|
|
869
|
+
cancelActiveDecision();
|
|
870
|
+
};
|
|
638
871
|
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
872
|
+
const evaluateActiveDecision = (view: EditorView, revision: number): void => {
|
|
873
|
+
const decision = activeDecision;
|
|
874
|
+
if (!decision || decision.revision !== revision || !isDecisionCurrent(view, decision)) {
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
642
877
|
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
878
|
+
if (performance.now() > decision.deadlineAt) {
|
|
879
|
+
expireActiveDecision(decision);
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
decision.evaluations++;
|
|
883
|
+
|
|
884
|
+
try {
|
|
885
|
+
const { state } = view;
|
|
886
|
+
const { selection } = state;
|
|
887
|
+
|
|
888
|
+
// Suppress suggestions when the cursor is mid-word — only offer
|
|
889
|
+
// completions when the cursor is at the trailing edge of a token.
|
|
890
|
+
const nodeAfter = selection.$from.nodeAfter;
|
|
891
|
+
const charAfterCursor = nodeAfter?.isText
|
|
892
|
+
? getLeadingTextCharacter(nodeAfter.text)
|
|
893
|
+
: undefined;
|
|
894
|
+
if (charAfterCursor && isMidWordCharacter(charAfterCursor)) {
|
|
895
|
+
cancelActiveDecision();
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
if (decision.textBefore === dismissedContext) {
|
|
900
|
+
cancelActiveDecision();
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
dismissedContext = null;
|
|
904
|
+
|
|
905
|
+
// Two characters are enough to prefetch the canonical shortlist. The
|
|
906
|
+
// predictor itself withholds display until the three-character gate.
|
|
907
|
+
if (decision.textBefore.trim().length < 2) {
|
|
908
|
+
cancelActiveDecision();
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
const prediction = predict(decision.textBefore);
|
|
913
|
+
if (!prediction || prediction.text.length === 0) {
|
|
914
|
+
// Remain in `collecting` until an async signal arrives or the hard
|
|
915
|
+
// deadline expires. We never display a provisional fallback.
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
const readyAtMs = performance.now() - decision.startedAt;
|
|
920
|
+
if (readyAtMs > GHOST_DECISION_BUDGET_MS) {
|
|
921
|
+
cancelActiveDecision();
|
|
922
|
+
if (isAutocompleteDebugEnabled()) {
|
|
923
|
+
// eslint-disable-next-line no-console
|
|
924
|
+
console.log(
|
|
925
|
+
'%c[CTC:budget]%c fully gated after render deadline (render suppressed)',
|
|
926
|
+
CTC_STYLES.section,
|
|
927
|
+
CTC_STYLES.body,
|
|
928
|
+
{
|
|
929
|
+
revision: decision.revision,
|
|
930
|
+
readyAtMs,
|
|
931
|
+
surface: prediction.surface,
|
|
932
|
+
},
|
|
933
|
+
);
|
|
660
934
|
}
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
const suggestion = showGhostText(
|
|
939
|
+
view,
|
|
940
|
+
prediction,
|
|
941
|
+
decision.position,
|
|
942
|
+
decision.revision,
|
|
943
|
+
decision.startedAt,
|
|
944
|
+
);
|
|
945
|
+
if (!suggestion) {
|
|
946
|
+
cancelActiveDecision();
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
cancelActiveDecision();
|
|
951
|
+
if (isAutocompleteDebugEnabled()) {
|
|
952
|
+
// eslint-disable-next-line no-console
|
|
953
|
+
console.log(
|
|
954
|
+
'%c[CTC:decision]%c committed immutable ghost',
|
|
955
|
+
CTC_STYLES.brand,
|
|
956
|
+
CTC_STYLES.body,
|
|
957
|
+
{
|
|
958
|
+
revision: suggestion.revision,
|
|
959
|
+
surface: suggestion.surface,
|
|
960
|
+
ghostText: suggestion.ghostText,
|
|
961
|
+
evidenceTier: suggestion.evidenceTier,
|
|
962
|
+
rankScore: suggestion.rankScore,
|
|
963
|
+
winnerMargin: suggestion.winnerMargin,
|
|
964
|
+
decisionLatencyMs: suggestion.decisionLatencyMs,
|
|
965
|
+
budgetMs: GHOST_DECISION_BUDGET_MS,
|
|
966
|
+
typedPrefixLength: getTrailingSurfacePrefixLength(decision.textBefore),
|
|
967
|
+
evaluations: decision.evaluations,
|
|
968
|
+
asyncSignals: decision.asyncSignals,
|
|
969
|
+
readyWithinBudget: suggestion.decisionLatencyMs <= GHOST_DECISION_BUDGET_MS,
|
|
970
|
+
},
|
|
971
|
+
);
|
|
972
|
+
}
|
|
973
|
+
if (suggestion.ghostText !== lastShownGhostText) {
|
|
974
|
+
lastShownGhostText = suggestion.ghostText;
|
|
975
|
+
api?.analytics?.actions.fireAnalyticsEvent({
|
|
976
|
+
action: ACTION.SUGGESTION_VIEWED,
|
|
977
|
+
actionSubject: ACTION_SUBJECT.CONTEXTUAL_TYPEAHEAD,
|
|
978
|
+
eventType: EVENT_TYPE.TRACK,
|
|
979
|
+
attributes: { completionSource: getCompletionSource(), surface },
|
|
664
980
|
});
|
|
665
981
|
}
|
|
666
|
-
}
|
|
982
|
+
} catch (error) {
|
|
983
|
+
cancelActiveDecision();
|
|
984
|
+
logException(error as Error, {
|
|
985
|
+
location: 'editor-plugin-autocomplete/evaluateActiveDecision',
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
};
|
|
989
|
+
|
|
990
|
+
const beginPredictionDecision = (view: EditorView): void => {
|
|
991
|
+
cancelActiveDecision();
|
|
992
|
+
const { state } = view;
|
|
993
|
+
if (!state.selection.empty) {
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
const startedAt = performance.now();
|
|
998
|
+
const textBefore = getTextBeforeCursor(state);
|
|
999
|
+
const revision = ++decisionRevision;
|
|
1000
|
+
activeDecision = {
|
|
1001
|
+
asyncSignals: 0,
|
|
1002
|
+
deadlineAt: startedAt + GHOST_DECISION_BUDGET_MS,
|
|
1003
|
+
evaluations: 0,
|
|
1004
|
+
position: state.selection.from,
|
|
1005
|
+
revision,
|
|
1006
|
+
startedAt,
|
|
1007
|
+
textBefore,
|
|
1008
|
+
};
|
|
1009
|
+
if (isAutocompleteDebugEnabled()) {
|
|
1010
|
+
// eslint-disable-next-line no-console
|
|
1011
|
+
console.log('%c[CTC:decision]%c collecting', CTC_STYLES.brand, CTC_STYLES.body, {
|
|
1012
|
+
revision,
|
|
1013
|
+
budgetMs: GHOST_DECISION_BUDGET_MS,
|
|
1014
|
+
coalesceMs: PREDICTION_COALESCE_MS,
|
|
1015
|
+
typedPrefixLength: getTrailingSurfacePrefixLength(activeDecision.textBefore),
|
|
1016
|
+
textBefore: activeDecision.textBefore.slice(-80),
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
debounceTimer = setTimeout(() => {
|
|
1021
|
+
debounceTimer = null;
|
|
1022
|
+
evaluateActiveDecision(view, revision);
|
|
1023
|
+
}, PREDICTION_COALESCE_MS);
|
|
1024
|
+
decisionDeadlineTimer = setTimeout(() => {
|
|
1025
|
+
decisionDeadlineTimer = null;
|
|
1026
|
+
if (activeDecision?.revision === revision) {
|
|
1027
|
+
const expiredDecision = activeDecision;
|
|
1028
|
+
if (isAutocompleteDebugEnabled()) {
|
|
1029
|
+
const elapsedMs = performance.now() - expiredDecision.startedAt;
|
|
1030
|
+
const predictionDebug = getLastPredictionDebug();
|
|
1031
|
+
const matchingPredictionDebug =
|
|
1032
|
+
predictionDebug?.textBefore === expiredDecision.textBefore ? predictionDebug : null;
|
|
1033
|
+
const abstentionPrefix = '— abstain: ';
|
|
1034
|
+
const predictorDecision = matchingPredictionDebug?.decision;
|
|
1035
|
+
const predictorReason = predictorDecision?.startsWith(abstentionPrefix)
|
|
1036
|
+
? predictorDecision.slice(abstentionPrefix.length)
|
|
1037
|
+
: predictorDecision;
|
|
1038
|
+
const decisionMessage =
|
|
1039
|
+
predictorReason && !matchingPredictionDebug?.awaitingAsyncEvidence
|
|
1040
|
+
? `abstain: ${predictorReason}`
|
|
1041
|
+
: `abstain: ${GHOST_DECISION_BUDGET_MS}ms decision budget expired${predictorReason ? ` · ${predictorReason}` : ''}`;
|
|
1042
|
+
// eslint-disable-next-line no-console
|
|
1043
|
+
console.log(`%c[CTC:decision]%c ${decisionMessage}`, CTC_STYLES.brand, CTC_STYLES.body, {
|
|
1044
|
+
revision,
|
|
1045
|
+
budgetMs: GHOST_DECISION_BUDGET_MS,
|
|
1046
|
+
elapsedMs,
|
|
1047
|
+
typedPrefixLength: getTrailingSurfacePrefixLength(expiredDecision.textBefore),
|
|
1048
|
+
evaluations: expiredDecision.evaluations,
|
|
1049
|
+
asyncSignals: expiredDecision.asyncSignals,
|
|
1050
|
+
textBefore: expiredDecision.textBefore.slice(-80),
|
|
1051
|
+
predictorDecision: predictorReason ?? null,
|
|
1052
|
+
});
|
|
1053
|
+
}
|
|
1054
|
+
expireActiveDecision(expiredDecision);
|
|
1055
|
+
}
|
|
1056
|
+
}, GHOST_DECISION_BUDGET_MS);
|
|
1057
|
+
};
|
|
1058
|
+
|
|
1059
|
+
// Async semantic/canonical results may complete the active decision, but they
|
|
1060
|
+
// never create a new decision or replace an already committed ghost.
|
|
1061
|
+
reschedulePredictionOnSlowLaneResult = (signal) => {
|
|
1062
|
+
if (destroyed || !currentView) {
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
if (!activeDecision) {
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
const revision = activeDecision.revision;
|
|
1069
|
+
const elapsedMs = performance.now() - activeDecision.startedAt;
|
|
1070
|
+
const remainingMs = activeDecision.deadlineAt - performance.now();
|
|
1071
|
+
if (remainingMs < 0) {
|
|
1072
|
+
const expiredDecision = activeDecision;
|
|
1073
|
+
expireActiveDecision(expiredDecision);
|
|
1074
|
+
return;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
activeDecision.asyncSignals++;
|
|
1078
|
+
if (isAutocompleteDebugVerbose()) {
|
|
1079
|
+
// eslint-disable-next-line no-console
|
|
1080
|
+
console.log(
|
|
1081
|
+
'%c[CTC:readiness]%c async evidence signal',
|
|
1082
|
+
CTC_STYLES.section,
|
|
1083
|
+
CTC_STYLES.body,
|
|
1084
|
+
{
|
|
1085
|
+
...signal,
|
|
1086
|
+
revision,
|
|
1087
|
+
elapsedMs,
|
|
1088
|
+
remainingMs: Math.max(0, remainingMs),
|
|
1089
|
+
typedPrefixLength: getTrailingSurfacePrefixLength(activeDecision.textBefore),
|
|
1090
|
+
withinBudget: remainingMs >= 0,
|
|
1091
|
+
},
|
|
1092
|
+
);
|
|
1093
|
+
}
|
|
1094
|
+
if (debounceTimer) {
|
|
1095
|
+
clearTimeout(debounceTimer);
|
|
1096
|
+
}
|
|
1097
|
+
debounceTimer = setTimeout(() => {
|
|
1098
|
+
debounceTimer = null;
|
|
1099
|
+
if (currentView) {
|
|
1100
|
+
evaluateActiveDecision(currentView, revision);
|
|
1101
|
+
}
|
|
1102
|
+
}, 0);
|
|
667
1103
|
};
|
|
668
1104
|
|
|
669
1105
|
const maybeUpdateSessionFrequency = (view: EditorView, prevState: EditorState): void => {
|
|
@@ -712,23 +1148,14 @@ export const createAutocompletePlugin = (
|
|
|
712
1148
|
return { ...pluginState, ...meta };
|
|
713
1149
|
}
|
|
714
1150
|
|
|
715
|
-
// A new prediction is scheduled from view.update
|
|
1151
|
+
// A new prediction is scheduled from view.update either way; the
|
|
1152
|
+
// surviving ghost only covers the gap until it commits.
|
|
716
1153
|
if (tr.docChanged) {
|
|
717
|
-
return
|
|
718
|
-
...pluginState,
|
|
719
|
-
ghostText: '',
|
|
720
|
-
ghostPosition: -1,
|
|
721
|
-
decorationSet: DecorationSet.empty,
|
|
722
|
-
};
|
|
1154
|
+
return advanceGhostThroughTypedCharacter(tr, pluginState) ?? clearGhostState(pluginState);
|
|
723
1155
|
}
|
|
724
1156
|
|
|
725
1157
|
if (tr.selectionSet && pluginState.ghostText) {
|
|
726
|
-
return
|
|
727
|
-
...pluginState,
|
|
728
|
-
ghostText: '',
|
|
729
|
-
ghostPosition: -1,
|
|
730
|
-
decorationSet: DecorationSet.empty,
|
|
731
|
-
};
|
|
1158
|
+
return clearGhostState(pluginState);
|
|
732
1159
|
}
|
|
733
1160
|
|
|
734
1161
|
return pluginState;
|
|
@@ -745,22 +1172,25 @@ export const createAutocompletePlugin = (
|
|
|
745
1172
|
|
|
746
1173
|
handleKeyDown: keydownHandler({
|
|
747
1174
|
Tab: (state: EditorState, dispatch?: (tr: Transaction) => void) => {
|
|
748
|
-
return acceptGhostTextWithAnalytics(state, dispatch, (
|
|
1175
|
+
return acceptGhostTextWithAnalytics(state, dispatch, (suggestion) => {
|
|
749
1176
|
justAccepted = true;
|
|
750
1177
|
lastShownGhostText = '';
|
|
751
|
-
|
|
1178
|
+
cancelActiveDecision();
|
|
1179
|
+
fireSuggestionInsertedAnalytics(suggestion);
|
|
752
1180
|
});
|
|
753
1181
|
},
|
|
754
1182
|
ArrowRight: (state: EditorState, dispatch?: (tr: Transaction) => void) => {
|
|
755
|
-
return acceptGhostTextWithAnalytics(state, dispatch, (
|
|
1183
|
+
return acceptGhostTextWithAnalytics(state, dispatch, (suggestion) => {
|
|
756
1184
|
justAccepted = true;
|
|
757
1185
|
lastShownGhostText = '';
|
|
758
|
-
|
|
1186
|
+
cancelActiveDecision();
|
|
1187
|
+
fireSuggestionInsertedAnalytics(suggestion);
|
|
759
1188
|
});
|
|
760
1189
|
},
|
|
761
1190
|
Escape: (state: EditorState, dispatch?: (tr: Transaction) => void) => {
|
|
762
1191
|
const didClear = clearGhostText(state, dispatch);
|
|
763
1192
|
if (didClear) {
|
|
1193
|
+
cancelActiveDecision();
|
|
764
1194
|
dismissedContext = getTextBeforeCursor(state);
|
|
765
1195
|
fireSuggestionDismissedAnalytics('escape');
|
|
766
1196
|
}
|
|
@@ -779,6 +1209,7 @@ export const createAutocompletePlugin = (
|
|
|
779
1209
|
| undefined;
|
|
780
1210
|
if (pluginState?.ghostText) {
|
|
781
1211
|
clearGhostText(view.state, view.dispatch);
|
|
1212
|
+
cancelActiveDecision();
|
|
782
1213
|
fireSuggestionDismissedAnalytics('blur');
|
|
783
1214
|
}
|
|
784
1215
|
return false;
|
|
@@ -791,6 +1222,7 @@ export const createAutocompletePlugin = (
|
|
|
791
1222
|
if (!event.target.closest('[data-autocomplete-ghost="true"]')) {
|
|
792
1223
|
const didClear = clearGhostText(view.state, view.dispatch);
|
|
793
1224
|
if (didClear) {
|
|
1225
|
+
cancelActiveDecision();
|
|
794
1226
|
dismissedContext = getTextBeforeCursor(view.state);
|
|
795
1227
|
lastShownGhostText = '';
|
|
796
1228
|
fireSuggestionDismissedAnalytics('click');
|
|
@@ -800,10 +1232,11 @@ export const createAutocompletePlugin = (
|
|
|
800
1232
|
|
|
801
1233
|
event.preventDefault();
|
|
802
1234
|
|
|
803
|
-
const accepted = acceptGhostTextWithAnalytics(view.state, view.dispatch, (
|
|
1235
|
+
const accepted = acceptGhostTextWithAnalytics(view.state, view.dispatch, (suggestion) => {
|
|
804
1236
|
justAccepted = true;
|
|
805
1237
|
lastShownGhostText = '';
|
|
806
|
-
|
|
1238
|
+
cancelActiveDecision();
|
|
1239
|
+
fireSuggestionInsertedAnalytics(suggestion);
|
|
807
1240
|
});
|
|
808
1241
|
|
|
809
1242
|
if (accepted) {
|
|
@@ -817,14 +1250,7 @@ export const createAutocompletePlugin = (
|
|
|
817
1250
|
return false;
|
|
818
1251
|
}
|
|
819
1252
|
|
|
820
|
-
|
|
821
|
-
isLocalLLM: options?.useLocalModel ?? false,
|
|
822
|
-
surface,
|
|
823
|
-
}).catch((error) => {
|
|
824
|
-
logException(error as Error, {
|
|
825
|
-
location: 'editor-plugin-autocomplete/loadDefaultVocabulary',
|
|
826
|
-
});
|
|
827
|
-
});
|
|
1253
|
+
void ensureVocabularyReady();
|
|
828
1254
|
loadVectorsAsync({
|
|
829
1255
|
isLocalLLM: options?.useLocalModel ?? false,
|
|
830
1256
|
surface,
|
|
@@ -881,9 +1307,7 @@ export const createAutocompletePlugin = (
|
|
|
881
1307
|
// the dismissedContext guard and abort until the user types again.
|
|
882
1308
|
dismissedContext = getTextBeforeCursor(view.state);
|
|
883
1309
|
|
|
884
|
-
|
|
885
|
-
clearTimeout(debounceTimer);
|
|
886
|
-
}
|
|
1310
|
+
cancelActiveDecision();
|
|
887
1311
|
return;
|
|
888
1312
|
}
|
|
889
1313
|
|
|
@@ -914,7 +1338,9 @@ export const createAutocompletePlugin = (
|
|
|
914
1338
|
}
|
|
915
1339
|
}
|
|
916
1340
|
|
|
917
|
-
|
|
1341
|
+
beginPredictionDecision(view);
|
|
1342
|
+
} else if (!prevState.selection.eq(view.state.selection)) {
|
|
1343
|
+
cancelActiveDecision();
|
|
918
1344
|
}
|
|
919
1345
|
},
|
|
920
1346
|
destroy: () => {
|
|
@@ -922,14 +1348,18 @@ export const createAutocompletePlugin = (
|
|
|
922
1348
|
currentView = null;
|
|
923
1349
|
unsubscribeFromContextUpdates?.();
|
|
924
1350
|
unsubscribeFromContextUpdates = undefined;
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
1351
|
+
cancelActiveDecision();
|
|
1352
|
+
const releasedDefaultClient = clearDefaultSlowLaneClient(slowLaneClient);
|
|
1353
|
+
ctcTag(
|
|
1354
|
+
'init',
|
|
1355
|
+
`slow lane teardown · id=${slowLaneClientId} · default=${releasedDefaultClient ? 'released' : 'retained newer owner'}`,
|
|
1356
|
+
releasedDefaultClient ? CTC_STYLES.dim : CTC_STYLES.warn,
|
|
1357
|
+
);
|
|
928
1358
|
if (hasDestroy(slowLaneClient)) {
|
|
929
1359
|
slowLaneClient.destroy();
|
|
930
1360
|
}
|
|
931
|
-
|
|
932
|
-
|
|
1361
|
+
pendingSessionContextTexts.clear();
|
|
1362
|
+
sessionIngestedContextTexts.clear();
|
|
933
1363
|
},
|
|
934
1364
|
};
|
|
935
1365
|
},
|