@atlaskit/editor-plugin-autocomplete 0.1.0 ā 0.3.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 +16 -0
- package/afm-cc/tsconfig.json +2 -1
- package/afm-jira/tsconfig.json +2 -1
- package/afm-products/tsconfig.json +2 -1
- package/build/tsconfig.json +20 -0
- package/build/url-module.d.ts +8 -0
- package/dist/cjs/autocompletePlugin.js +18 -5
- package/dist/cjs/autocompletePluginType.js +5 -1
- package/dist/cjs/pm-plugins/autocomplete-plugin.js +368 -0
- package/dist/cjs/pm-plugins/ghost-text-decoration.js +39 -0
- package/dist/cjs/pm-plugins/scoring-pipeline.js +256 -0
- package/dist/cjs/pm-plugins/slow-lane-client.js +199 -0
- package/dist/cjs/pm-plugins/text-predictor.js +796 -0
- package/dist/es2019/autocompletePlugin.js +20 -6
- package/dist/es2019/autocompletePluginType.js +1 -0
- package/dist/es2019/pm-plugins/autocomplete-plugin.js +368 -0
- package/dist/es2019/pm-plugins/ghost-text-decoration.js +33 -0
- package/dist/es2019/pm-plugins/scoring-pipeline.js +221 -0
- package/dist/es2019/pm-plugins/slow-lane-client.js +157 -0
- package/dist/es2019/pm-plugins/text-predictor.js +631 -0
- package/dist/esm/autocompletePlugin.js +18 -5
- package/dist/esm/autocompletePluginType.js +1 -0
- package/dist/esm/pm-plugins/autocomplete-plugin.js +362 -0
- package/dist/esm/pm-plugins/ghost-text-decoration.js +33 -0
- package/dist/esm/pm-plugins/scoring-pipeline.js +252 -0
- package/dist/esm/pm-plugins/slow-lane-client.js +192 -0
- package/dist/esm/pm-plugins/text-predictor.js +793 -0
- package/dist/types/autocompletePluginType.d.ts +6 -3
- package/dist/types/pm-plugins/autocomplete-plugin.d.ts +36 -0
- package/dist/types/pm-plugins/ghost-text-decoration.d.ts +7 -0
- package/dist/types/pm-plugins/scoring-pipeline.d.ts +33 -0
- package/dist/types/pm-plugins/slow-lane-client.d.ts +46 -0
- package/dist/types/pm-plugins/text-predictor.d.ts +90 -0
- package/dist/types-ts4.5/autocompletePluginType.d.ts +6 -3
- package/dist/types-ts4.5/pm-plugins/autocomplete-plugin.d.ts +36 -0
- package/dist/types-ts4.5/pm-plugins/ghost-text-decoration.d.ts +7 -0
- package/dist/types-ts4.5/pm-plugins/scoring-pipeline.d.ts +33 -0
- package/dist/types-ts4.5/pm-plugins/slow-lane-client.d.ts +46 -0
- package/dist/types-ts4.5/pm-plugins/text-predictor.d.ts +90 -0
- package/package.json +2 -2
- package/src/autocompletePlugin.tsx +25 -5
- package/src/autocompletePluginType.ts +14 -3
- package/src/pm-plugins/autocomplete-plugin/package.json +15 -0
- package/src/pm-plugins/autocomplete-plugin.ts +443 -0
- package/src/pm-plugins/data/combined_l2_l3_pos_tags.json +73571 -3
- package/src/pm-plugins/data/ghost_pos_tags.json +43 -3
- package/src/pm-plugins/data/grammar_transitions_10k.json +46 -3
- package/src/pm-plugins/data/l3_vocabulary.json +20002 -3
- package/src/pm-plugins/data/vocabulary_10k.json +38794 -3
- package/src/pm-plugins/data/word_index_10k.json +7760 -3
- package/src/pm-plugins/ghost-text-decoration.ts +44 -0
- package/src/pm-plugins/scoring-pipeline.ts +294 -0
- package/src/pm-plugins/slow-lane-client/package.json +15 -0
- package/src/pm-plugins/slow-lane-client.ts +222 -0
- package/src/pm-plugins/text-predictor/package.json +15 -0
- package/src/pm-plugins/text-predictor.ts +780 -0
- package/tsconfig.app.json +12 -3
- package/tsconfig.json +4 -1
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { EditorState } from '@atlaskit/editor-prosemirror/state';
|
|
2
|
+
import { Decoration, DecorationSet } from '@atlaskit/editor-prosemirror/view';
|
|
3
|
+
|
|
4
|
+
const GHOST_TEXT_CLASS = 'autocomplete-ghost-text';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Creates a DecorationSet containing a ghost text widget at the given position.
|
|
8
|
+
* The ghost text is rendered as a styled <span> that appears after the cursor.
|
|
9
|
+
*/
|
|
10
|
+
export const createGhostTextDecorationSet = (
|
|
11
|
+
state: EditorState,
|
|
12
|
+
position: number,
|
|
13
|
+
text: string,
|
|
14
|
+
): DecorationSet => {
|
|
15
|
+
if (!text) {
|
|
16
|
+
return DecorationSet.empty;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const decoration = Decoration.widget(
|
|
20
|
+
position,
|
|
21
|
+
() => {
|
|
22
|
+
const container = document.createElement('span');
|
|
23
|
+
container.className = GHOST_TEXT_CLASS;
|
|
24
|
+
container.setAttribute('data-autocomplete-ghost', 'true');
|
|
25
|
+
container.style.color = '#999';
|
|
26
|
+
container.style.opacity = '0.6';
|
|
27
|
+
container.style.pointerEvents = 'none';
|
|
28
|
+
container.style.userSelect = 'none';
|
|
29
|
+
container.style.fontStyle = 'italic';
|
|
30
|
+
// U+200B (Zero Width Space) gives the browser a line-break opportunity
|
|
31
|
+
// immediately before the ghost text. This ensures the typed text before
|
|
32
|
+
// the span is never pushed to the next line by the ghost text's width ā
|
|
33
|
+
// only the ghost text itself will wrap if it doesn't fit.
|
|
34
|
+
container.textContent = '\u200b' + text;
|
|
35
|
+
return container;
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
side: 1, // Render after content at this position
|
|
39
|
+
key: 'autocomplete-ghost-text',
|
|
40
|
+
},
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
return DecorationSet.create(state.doc, [decoration]);
|
|
44
|
+
};
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scoring Pipeline: Stage 1 (Semantic + Frequency), Grammar Filter, Stage 2 (LM Re-ranking).
|
|
3
|
+
*
|
|
4
|
+
* Operates synchronously on pre-loaded data. Each stage gracefully degrades
|
|
5
|
+
* when its required data isn't available (cold ā warm ā full warm).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import posTagsData from './data/combined_l2_l3_pos_tags.json';
|
|
9
|
+
import ghostPosTagsData from './data/ghost_pos_tags.json';
|
|
10
|
+
import grammarTransitionsData from './data/grammar_transitions_10k.json';
|
|
11
|
+
|
|
12
|
+
// āāā Types āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
13
|
+
|
|
14
|
+
export interface ScoringCandidate {
|
|
15
|
+
authorFreq: number;
|
|
16
|
+
docFreq: number;
|
|
17
|
+
sessionFreq: number;
|
|
18
|
+
tenantFreq: number;
|
|
19
|
+
word: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ScoredCandidate {
|
|
23
|
+
finalScore: number;
|
|
24
|
+
freqScore: number;
|
|
25
|
+
lmScore: number;
|
|
26
|
+
semanticScore: number;
|
|
27
|
+
word: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Metadata returned by the grammar filter for debug logging in the caller. */
|
|
31
|
+
export interface GrammarFilterMeta {
|
|
32
|
+
after: number;
|
|
33
|
+
before: number;
|
|
34
|
+
dropped: string[];
|
|
35
|
+
prevTags: string[];
|
|
36
|
+
prevWord: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface PosTransitionRule {
|
|
40
|
+
allowed: string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface GrammarTransitions {
|
|
44
|
+
transitions: Record<string, PosTransitionRule>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// āāā Scoring Constants āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
48
|
+
|
|
49
|
+
const ALPHA = 0.5;
|
|
50
|
+
const BETA = 0.5;
|
|
51
|
+
const NEUTRAL_SCORE = 0.5;
|
|
52
|
+
const STAGE1_WEIGHT = 0.6;
|
|
53
|
+
const STAGE2_WEIGHT = 0.4;
|
|
54
|
+
const MIN_STAGE1_SCORE = 0.35;
|
|
55
|
+
const L1_SESSION_CAP = 1.2;
|
|
56
|
+
|
|
57
|
+
// āāā Grammar Data (loaded once on import) āāāāāāāāāāāāāāāāāāā
|
|
58
|
+
|
|
59
|
+
const posTags: Map<string, string[]> = new Map(
|
|
60
|
+
Object.entries(posTagsData as Record<string, string[]>),
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const grammarTransitions = grammarTransitionsData as GrammarTransitions;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Precomputed map from each POS tag to the set of allowed next POS tags.
|
|
67
|
+
* Built once at module load from grammarTransitions so applyGrammarFilter
|
|
68
|
+
* never re-iterates the transition rules per call.
|
|
69
|
+
*/
|
|
70
|
+
const precomputedAllowedByPos: Map<string, Set<string>> = new Map(
|
|
71
|
+
Object.entries(grammarTransitions.transitions).map(([pos, rule]) => [
|
|
72
|
+
pos,
|
|
73
|
+
new Set(rule.allowed),
|
|
74
|
+
]),
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
// āāā Math āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
78
|
+
|
|
79
|
+
function cosineSimilarity(a: Float32Array, b: Float32Array): number {
|
|
80
|
+
let dot = 0;
|
|
81
|
+
let normA = 0;
|
|
82
|
+
let normB = 0;
|
|
83
|
+
for (let i = 0; i < a.length; i++) {
|
|
84
|
+
dot += a[i] * b[i];
|
|
85
|
+
normA += a[i] * a[i];
|
|
86
|
+
normB += b[i] * b[i];
|
|
87
|
+
}
|
|
88
|
+
const dNormA = Math.sqrt(normA);
|
|
89
|
+
const dNormB = Math.sqrt(normB);
|
|
90
|
+
if (dNormA === 0 || dNormB === 0) {
|
|
91
|
+
return NEUTRAL_SCORE;
|
|
92
|
+
}
|
|
93
|
+
return (1 + dot / (dNormA * dNormB)) / 2;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// āāā Stage 1: Semantic + Frequency āāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
97
|
+
|
|
98
|
+
function scoreStage1(
|
|
99
|
+
candidate: ScoringCandidate,
|
|
100
|
+
contextVector: Float32Array | null,
|
|
101
|
+
getWordVector: (word: string) => Float32Array | null,
|
|
102
|
+
maxTenantFreq: number,
|
|
103
|
+
): { freqScore: number; semanticScore: number; stage1Score: number } {
|
|
104
|
+
|
|
105
|
+
// 1. Calculate Base Global Score (Normalized Log)
|
|
106
|
+
const maxPossibleLog = Math.log10(maxTenantFreq + 1);
|
|
107
|
+
|
|
108
|
+
// Diversity Adjustment
|
|
109
|
+
const diversityRaw = (
|
|
110
|
+
Math.log10(candidate.tenantFreq + 1) * 0.50 +
|
|
111
|
+
Math.log10(candidate.docFreq + 1) * 0.25 +
|
|
112
|
+
Math.log10(candidate.authorFreq + 1) * 0.25
|
|
113
|
+
) / maxPossibleLog;
|
|
114
|
+
|
|
115
|
+
const sessionMultiplier = candidate.sessionFreq > 0
|
|
116
|
+
? 1 + (Math.log10(candidate.sessionFreq + 1) * 2.5)
|
|
117
|
+
: 1;
|
|
118
|
+
|
|
119
|
+
// Apply multiplier; capped at L1_SESSION_CAP (default 1.2) to prevent excessive over-indexing
|
|
120
|
+
const freqScore = Math.min(diversityRaw * sessionMultiplier, L1_SESSION_CAP);
|
|
121
|
+
|
|
122
|
+
// 3. Semantic Scoring
|
|
123
|
+
let semanticScore = NEUTRAL_SCORE;
|
|
124
|
+
if (contextVector) {
|
|
125
|
+
const wordVec = getWordVector(candidate.word);
|
|
126
|
+
semanticScore = wordVec ? cosineSimilarity(contextVector, wordVec) : NEUTRAL_SCORE;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
semanticScore,
|
|
131
|
+
freqScore,
|
|
132
|
+
stage1Score: (ALPHA * semanticScore) + (BETA * freqScore),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// āāā Grammar Filter āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* GHOST POS DICTIONARY
|
|
140
|
+
* A hardcoded mapping of common structural English words that were stripped
|
|
141
|
+
* from the main domain vocabulary. This allows the grammar filter to understand
|
|
142
|
+
* context without suggesting these words to the user.
|
|
143
|
+
*/
|
|
144
|
+
const ghostPosTags: Record<string, string[]> = ghostPosTagsData as Record<string, string[]>;
|
|
145
|
+
|
|
146
|
+
type FilterEntry = { candidate: ScoringCandidate; freqScore: number; semanticScore: number; stage1Score: number };
|
|
147
|
+
|
|
148
|
+
function applyGrammarFilter(
|
|
149
|
+
candidates: FilterEntry[],
|
|
150
|
+
previousWord: string,
|
|
151
|
+
): { filtered: FilterEntry[]; grammarMeta: GrammarFilterMeta | null } {
|
|
152
|
+
if (!previousWord) return { filtered: candidates, grammarMeta: null };
|
|
153
|
+
|
|
154
|
+
const lowerPrev = previousWord.toLowerCase();
|
|
155
|
+
const prevTags = ghostPosTags[lowerPrev] || posTags.get(lowerPrev);
|
|
156
|
+
|
|
157
|
+
if (!prevTags || prevTags.length === 0) {
|
|
158
|
+
return { filtered: candidates, grammarMeta: null };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
let allowedNextTags: Set<string>;
|
|
162
|
+
if (prevTags.length === 1) {
|
|
163
|
+
// Common case: single POS tag ā reuse the precomputed Set directly (no allocation)
|
|
164
|
+
allowedNextTags = precomputedAllowedByPos.get(prevTags[0]) ?? new Set();
|
|
165
|
+
} else {
|
|
166
|
+
allowedNextTags = new Set<string>();
|
|
167
|
+
for (const pt of prevTags) {
|
|
168
|
+
const allowed = precomputedAllowedByPos.get(pt);
|
|
169
|
+
if (allowed) allowed.forEach(tag => allowedNextTags.add(tag));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const filtered: FilterEntry[] = [];
|
|
174
|
+
const dropped: string[] = [];
|
|
175
|
+
|
|
176
|
+
for (const entry of candidates) {
|
|
177
|
+
const candidateTags = posTags.get(entry.candidate.word.toLowerCase());
|
|
178
|
+
|
|
179
|
+
// If candidate has no tags (unknown word), let it pass to be safe
|
|
180
|
+
if (!candidateTags || candidateTags.length === 0) {
|
|
181
|
+
filtered.push(entry);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (candidateTags.some((ct) => allowedNextTags.has(ct))) {
|
|
186
|
+
filtered.push(entry);
|
|
187
|
+
} else {
|
|
188
|
+
dropped.push(entry.candidate.word);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const finalFiltered = filtered.length > 0 ? filtered : candidates;
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
filtered: finalFiltered,
|
|
196
|
+
grammarMeta: {
|
|
197
|
+
prevWord: lowerPrev,
|
|
198
|
+
prevTags,
|
|
199
|
+
before: candidates.length,
|
|
200
|
+
after: finalFiltered.length,
|
|
201
|
+
dropped: filtered.length > 0 ? dropped : [],
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// āāā Stage 2: LM Re-ranking āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
207
|
+
function getLmScore(
|
|
208
|
+
word: string,
|
|
209
|
+
lmLogits: Record<string, number> | null
|
|
210
|
+
): number {
|
|
211
|
+
if (!lmLogits) return 0;
|
|
212
|
+
|
|
213
|
+
// Look up the word directly! No more tokens.
|
|
214
|
+
const val = lmLogits[word.toLowerCase()];
|
|
215
|
+
if (typeof val === 'number') {
|
|
216
|
+
return val;
|
|
217
|
+
}
|
|
218
|
+
return 0;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// āāā Public API āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
222
|
+
|
|
223
|
+
export interface RankCandidatesResult {
|
|
224
|
+
candidates: ScoredCandidate[];
|
|
225
|
+
grammarMeta: GrammarFilterMeta | null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function rankCandidates(
|
|
229
|
+
candidates: ScoringCandidate[],
|
|
230
|
+
contextVector: Float32Array | null,
|
|
231
|
+
getWordVector: (word: string) => Float32Array | null,
|
|
232
|
+
lmLogits: Record<string, number> | null,
|
|
233
|
+
maxTenantFreq: number,
|
|
234
|
+
previousWord: string,
|
|
235
|
+
): RankCandidatesResult {
|
|
236
|
+
// Stage 1
|
|
237
|
+
const stage1Results = candidates.map((candidate) => {
|
|
238
|
+
const { semanticScore, freqScore, stage1Score } = scoreStage1(
|
|
239
|
+
candidate,
|
|
240
|
+
contextVector,
|
|
241
|
+
getWordVector,
|
|
242
|
+
maxTenantFreq,
|
|
243
|
+
);
|
|
244
|
+
return { candidate, semanticScore, freqScore, stage1Score };
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
const stage1Survivors = stage1Results.filter(entry => entry.stage1Score >= MIN_STAGE1_SCORE);
|
|
248
|
+
|
|
249
|
+
// Grammar Filter
|
|
250
|
+
const { filtered, grammarMeta } = applyGrammarFilter(stage1Survivors, previousWord);
|
|
251
|
+
|
|
252
|
+
// Stage 2 + final assembly
|
|
253
|
+
let lmMax = 0;
|
|
254
|
+
if (lmLogits && Object.keys(lmLogits).length > 0) {
|
|
255
|
+
const values = Object.values(lmLogits);
|
|
256
|
+
lmMax = Math.max(...values);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const scored: ScoredCandidate[] = filtered.map((entry) => {
|
|
260
|
+
let lmScore = 0;
|
|
261
|
+
let finalScore = entry.stage1Score;
|
|
262
|
+
|
|
263
|
+
if (lmLogits && Object.keys(lmLogits).length > 0) {
|
|
264
|
+
const rawLm = getLmScore(entry.candidate.word, lmLogits);
|
|
265
|
+
|
|
266
|
+
if (rawLm !== 0) {
|
|
267
|
+
// The word was in the top_k! Score it normally.
|
|
268
|
+
const logitDiff = Math.log(rawLm) - Math.log(lmMax);
|
|
269
|
+
lmScore = Math.exp(logitDiff);
|
|
270
|
+
} else {
|
|
271
|
+
lmScore = 0.05;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
finalScore = STAGE1_WEIGHT * entry.stage1Score + STAGE2_WEIGHT * lmScore;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
word: entry.candidate.word,
|
|
279
|
+
freqScore: entry.freqScore,
|
|
280
|
+
semanticScore: entry.semanticScore,
|
|
281
|
+
lmScore,
|
|
282
|
+
finalScore,
|
|
283
|
+
};
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
scored.sort((a, b) => {
|
|
287
|
+
if (b.finalScore !== a.finalScore) {
|
|
288
|
+
return b.finalScore - a.finalScore;
|
|
289
|
+
}
|
|
290
|
+
return a.word.length - b.word.length;
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
return { candidates: scored, grammarMeta };
|
|
294
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@atlaskit/editor-plugin-autocomplete/src/pm-plugins/slow-lane-client",
|
|
3
|
+
"main": "../../../dist/cjs/pm-plugins/slow-lane-client.js",
|
|
4
|
+
"module": "../../../dist/esm/pm-plugins/slow-lane-client.js",
|
|
5
|
+
"module:es2019": "../../../dist/es2019/pm-plugins/slow-lane-client.js",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"types": "../../../dist/types/pm-plugins/slow-lane-client.d.ts",
|
|
8
|
+
"typesVersions": {
|
|
9
|
+
">=4.5 <5.9": {
|
|
10
|
+
"*": [
|
|
11
|
+
"../../../dist/types-ts4.5/pm-plugins/slow-lane-client.d.ts"
|
|
12
|
+
]
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slow Lane Client: Backend context encoding for autocomplete.
|
|
3
|
+
*
|
|
4
|
+
* Fires a BE request on word boundaries to encode document context.
|
|
5
|
+
* Expects the typeahead-encodings API format:
|
|
6
|
+
* Request: { text, session_id }
|
|
7
|
+
* Response: { semantic_vector: number[], lm_logits: Record<string, number> }
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// āāā Types āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
11
|
+
|
|
12
|
+
/** Request payload for typeahead-encodings endpoint. */
|
|
13
|
+
export interface TypeaheadEncodingsRequest {
|
|
14
|
+
session_id: string;
|
|
15
|
+
text: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Response from typeahead-encodings endpoint. */
|
|
19
|
+
export interface TypeaheadEncodingsResponse {
|
|
20
|
+
lm_logits: Record<string, number>;
|
|
21
|
+
semantic_vector: number[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// āāā Constants āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
25
|
+
|
|
26
|
+
// eslint-disable-next-line require-unicode-regexp
|
|
27
|
+
const WORD_BOUNDARY_CHARS = /[\s.,;:!?]/;
|
|
28
|
+
const DEFAULT_DEBOUNCE_MS = 300;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Check if text ends with a word boundary character (space or punctuation).
|
|
32
|
+
*/
|
|
33
|
+
export const isWordBoundary = (text: string): boolean => {
|
|
34
|
+
if (!text || text.length === 0) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
const lastChar = text[text.length - 1];
|
|
38
|
+
return WORD_BOUNDARY_CHARS.test(lastChar);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// āāā Slow Lane Client āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
42
|
+
|
|
43
|
+
export interface SlowLaneClientConfig {
|
|
44
|
+
baseUrl: string;
|
|
45
|
+
debounceMs?: number;
|
|
46
|
+
endpoint?: string;
|
|
47
|
+
fetchFn?: typeof fetch;
|
|
48
|
+
onUpdate?: (opts: { hasLmLogits: boolean; hasVector: boolean; textLength: number }) => void;
|
|
49
|
+
productKey?: string;
|
|
50
|
+
sessionId?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const createSlowLaneClient = (
|
|
54
|
+
config: SlowLaneClientConfig,
|
|
55
|
+
): {
|
|
56
|
+
getContextVector: () => Float32Array | null;
|
|
57
|
+
getLmLogits: () => Record<string, number> | null;
|
|
58
|
+
isWordBoundary: (text: string) => boolean;
|
|
59
|
+
setContextVector: (vector: Float32Array | null) => void;
|
|
60
|
+
setLmLogits: (logits: Record<string, number> | null) => void;
|
|
61
|
+
updateContext: (text: string) => void;
|
|
62
|
+
} => {
|
|
63
|
+
const {
|
|
64
|
+
baseUrl,
|
|
65
|
+
sessionId: configSessionId,
|
|
66
|
+
productKey = 'confluence',
|
|
67
|
+
endpoint = '/gateway/api/v1/autocomplete/typeahead-encodings',
|
|
68
|
+
debounceMs = DEFAULT_DEBOUNCE_MS,
|
|
69
|
+
fetchFn = fetch,
|
|
70
|
+
onUpdate,
|
|
71
|
+
} = config;
|
|
72
|
+
|
|
73
|
+
const sessionId = configSessionId ?? crypto.randomUUID();
|
|
74
|
+
|
|
75
|
+
const headers: Record<string, string> = {
|
|
76
|
+
'Content-Type': 'application/json',
|
|
77
|
+
'x-experience-id': 'confluence-smart-typeahead-encodings',
|
|
78
|
+
'x-product': productKey,
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
82
|
+
let lastRequestedText = '';
|
|
83
|
+
let storedContextVector: Float32Array | null = null;
|
|
84
|
+
let storedLmLogits: Record<string, number> | null = null;
|
|
85
|
+
|
|
86
|
+
const doUpdateContext = async (text: string): Promise<void> => {
|
|
87
|
+
if (!text || text.trim().length === 0) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// eslint-disable-next-line require-unicode-regexp
|
|
92
|
+
const url = `${baseUrl.replace(/\/$/, '')}${endpoint}`;
|
|
93
|
+
const payload: TypeaheadEncodingsRequest = {
|
|
94
|
+
text,
|
|
95
|
+
session_id: sessionId,
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// Log the exact payload being sent so the comment/reply structure is visible
|
|
99
|
+
// eslint-disable-next-line no-console
|
|
100
|
+
console.groupCollapsed(
|
|
101
|
+
`%c[SlowLane] %cš¤ Sending context | ${text.length} chars`,
|
|
102
|
+
'color: #9c27b0; font-weight: bold;',
|
|
103
|
+
'color: inherit;',
|
|
104
|
+
);
|
|
105
|
+
text.split('\n').forEach((line, i, arr) => {
|
|
106
|
+
// eslint-disable-next-line no-console
|
|
107
|
+
console.log(` ${i === arr.length - 1 ? 'ā¶' : ' '} ${line}`);
|
|
108
|
+
});
|
|
109
|
+
// eslint-disable-next-line no-console
|
|
110
|
+
console.groupEnd();
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
const res = await fetchFn(url, {
|
|
114
|
+
method: 'POST',
|
|
115
|
+
headers,
|
|
116
|
+
body: JSON.stringify(payload),
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
if (!res.ok) {
|
|
120
|
+
storedContextVector = null;
|
|
121
|
+
storedLmLogits = null;
|
|
122
|
+
// eslint-disable-next-line no-console
|
|
123
|
+
console.log(
|
|
124
|
+
`%c[SlowLane] %cā Request failed (${res.status})`,
|
|
125
|
+
'color: #9c27b0; font-weight: bold;',
|
|
126
|
+
'color: #f44336;',
|
|
127
|
+
);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const data = (await res.json()) as TypeaheadEncodingsResponse;
|
|
132
|
+
|
|
133
|
+
if (data.semantic_vector && Array.isArray(data.semantic_vector)) {
|
|
134
|
+
storedContextVector = new Float32Array(data.semantic_vector);
|
|
135
|
+
} else {
|
|
136
|
+
storedContextVector = null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (data.lm_logits && typeof data.lm_logits === 'object') {
|
|
140
|
+
storedLmLogits = data.lm_logits;
|
|
141
|
+
} else {
|
|
142
|
+
storedLmLogits = null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Log what came back so it can be correlated with the next prediction group
|
|
146
|
+
// eslint-disable-next-line no-console
|
|
147
|
+
console.groupCollapsed(
|
|
148
|
+
`%c[SlowLane] %cš„ Response received`,
|
|
149
|
+
'color: #9c27b0; font-weight: bold;',
|
|
150
|
+
'color: inherit;',
|
|
151
|
+
);
|
|
152
|
+
// eslint-disable-next-line no-console
|
|
153
|
+
console.log(
|
|
154
|
+
storedContextVector
|
|
155
|
+
? `ā
semantic_vector: ${storedContextVector.length} dims`
|
|
156
|
+
: 'ā No semantic_vector',
|
|
157
|
+
);
|
|
158
|
+
// eslint-disable-next-line no-console
|
|
159
|
+
console.log(
|
|
160
|
+
storedLmLogits
|
|
161
|
+
? `ā
lm_logits: ${Object.keys(storedLmLogits).length} tokens`
|
|
162
|
+
: 'ā No lm_logits',
|
|
163
|
+
);
|
|
164
|
+
// eslint-disable-next-line no-console
|
|
165
|
+
console.groupEnd();
|
|
166
|
+
|
|
167
|
+
onUpdate?.({
|
|
168
|
+
textLength: text.length,
|
|
169
|
+
hasVector: storedContextVector !== null,
|
|
170
|
+
hasLmLogits: storedLmLogits !== null,
|
|
171
|
+
});
|
|
172
|
+
// eslint-disable-next-line no-unused-vars
|
|
173
|
+
} catch (e) {
|
|
174
|
+
storedContextVector = null;
|
|
175
|
+
storedLmLogits = null;
|
|
176
|
+
// eslint-disable-next-line no-console
|
|
177
|
+
console.log(
|
|
178
|
+
'%c[SlowLane] %cā Network error ā context cleared',
|
|
179
|
+
'color: #9c27b0; font-weight: bold;',
|
|
180
|
+
'color: #f44336;',
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const updateContextDebounced = (text: string): void => {
|
|
186
|
+
if (debounceTimer) {
|
|
187
|
+
clearTimeout(debounceTimer);
|
|
188
|
+
}
|
|
189
|
+
lastRequestedText = text;
|
|
190
|
+
debounceTimer = setTimeout(() => {
|
|
191
|
+
debounceTimer = null;
|
|
192
|
+
doUpdateContext(lastRequestedText);
|
|
193
|
+
}, debounceMs);
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
updateContext: updateContextDebounced,
|
|
198
|
+
getContextVector: () => storedContextVector,
|
|
199
|
+
getLmLogits: () => storedLmLogits,
|
|
200
|
+
setContextVector: (vector) => {
|
|
201
|
+
storedContextVector = vector;
|
|
202
|
+
},
|
|
203
|
+
setLmLogits: (logits) => {
|
|
204
|
+
storedLmLogits = logits;
|
|
205
|
+
},
|
|
206
|
+
isWordBoundary,
|
|
207
|
+
};
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
let defaultSlowLaneClient: ReturnType<typeof createSlowLaneClient> | null = null;
|
|
211
|
+
|
|
212
|
+
export const setDefaultSlowLaneClient = (
|
|
213
|
+
client: ReturnType<typeof createSlowLaneClient> | null,
|
|
214
|
+
): void => {
|
|
215
|
+
defaultSlowLaneClient = client;
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
export const getStoredContextVector = (): Float32Array | null =>
|
|
219
|
+
defaultSlowLaneClient?.getContextVector() ?? null;
|
|
220
|
+
|
|
221
|
+
export const getStoredLmLogits = (): Record<string, number> | null =>
|
|
222
|
+
defaultSlowLaneClient?.getLmLogits() ?? null;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@atlaskit/editor-plugin-autocomplete/src/pm-plugins/text-predictor",
|
|
3
|
+
"main": "../../../dist/cjs/pm-plugins/text-predictor.js",
|
|
4
|
+
"module": "../../../dist/esm/pm-plugins/text-predictor.js",
|
|
5
|
+
"module:es2019": "../../../dist/es2019/pm-plugins/text-predictor.js",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"types": "../../../dist/types/pm-plugins/text-predictor.d.ts",
|
|
8
|
+
"typesVersions": {
|
|
9
|
+
">=4.5 <5.9": {
|
|
10
|
+
"*": [
|
|
11
|
+
"../../../dist/types-ts4.5/pm-plugins/text-predictor.d.ts"
|
|
12
|
+
]
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|