@atlaskit/editor-plugin-autocomplete 2.4.0 → 2.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/afm-cc/tsconfig.json +3 -0
  3. package/afm-products/tsconfig.json +3 -0
  4. package/dist/cjs/analytics/ufo.js +111 -0
  5. package/dist/cjs/pm-plugins/autocomplete-plugin.js +126 -64
  6. package/dist/cjs/pm-plugins/local-slow-lane-client.js +452 -0
  7. package/dist/cjs/pm-plugins/slow-lane-client.js +45 -16
  8. package/dist/cjs/pm-plugins/text-predictor.js +72 -40
  9. package/dist/es2019/analytics/ufo.js +110 -0
  10. package/dist/es2019/pm-plugins/autocomplete-plugin.js +132 -72
  11. package/dist/es2019/pm-plugins/local-slow-lane-client.js +361 -0
  12. package/dist/es2019/pm-plugins/slow-lane-client.js +29 -2
  13. package/dist/es2019/pm-plugins/text-predictor.js +48 -15
  14. package/dist/esm/analytics/ufo.js +105 -0
  15. package/dist/esm/pm-plugins/autocomplete-plugin.js +126 -64
  16. package/dist/esm/pm-plugins/local-slow-lane-client.js +443 -0
  17. package/dist/esm/pm-plugins/slow-lane-client.js +45 -16
  18. package/dist/esm/pm-plugins/text-predictor.js +73 -40
  19. package/dist/types/analytics/ufo.d.ts +38 -0
  20. package/dist/types/pm-plugins/autocomplete-plugin.d.ts +5 -0
  21. package/dist/types/pm-plugins/local-slow-lane-client.d.ts +102 -0
  22. package/dist/types-ts4.5/analytics/ufo.d.ts +38 -0
  23. package/dist/types-ts4.5/pm-plugins/autocomplete-plugin.d.ts +5 -0
  24. package/dist/types-ts4.5/pm-plugins/local-slow-lane-client.d.ts +102 -0
  25. package/package.json +7 -2
  26. package/src/analytics/ufo.ts +132 -0
  27. package/src/pm-plugins/autocomplete-plugin.ts +125 -64
  28. package/src/pm-plugins/local-slow-lane-client.ts +487 -0
  29. package/src/pm-plugins/slow-lane-client.ts +28 -2
  30. package/src/pm-plugins/text-predictor.ts +42 -12
  31. package/tsconfig.app.json +3 -0
@@ -0,0 +1,487 @@
1
+ /**
2
+ * Local Slow Lane Client: On-device inference via @mlc-ai/web-llm.
3
+ *
4
+ * Drop-in replacement for the network-based slow-lane-client. Instead of
5
+ * calling a backend API, this client uses MLC WebLLM to run a small language
6
+ * model (SmolLM 135M) directly in the browser via WebGPU.
7
+ *
8
+ * ── Why main thread (no Web Worker)? ─────────────────────────────────────
9
+ * SmolLM 135M is small enough (~270 MB weights, 350-400 MB VRAM) that
10
+ * WebGPU inference on the main thread is production-viable:
11
+ *
12
+ * - WebGPU GPU compute is inherently async (doesn't block the main thread)
13
+ * - CPU overhead (tokenization + post-processing) is only 5-10 ms
14
+ * - Single forward pass latency is 50-150 ms — well within autocomplete
15
+ * expectations (~250 ms between word boundaries)
16
+ *
17
+ * This avoids all the complexity of Web Workers:
18
+ * - No CSP workarounds (blob URLs, inline scripts)
19
+ * - No bundler configuration (worker-plugin, import.meta.url)
20
+ * - No message passing protocol
21
+ * - Standard npm import — just works
22
+ *
23
+ * ── Interface ────────────────────────────────────────────────────────────
24
+ * Same shape as createSlowLaneClient so text-predictor.ts needs zero changes.
25
+ * The client exposes getContextVector() and getLmLogits() which are populated
26
+ * asynchronously after each updateContext() call.
27
+ */
28
+
29
+ import type { MLCEngine, InitProgressReport, AppConfig } from '@mlc-ai/web-llm';
30
+
31
+ import { isAutocompleteDebugEnabled } from './debug-mode';
32
+ import { isWordBoundary } from './slow-lane-client';
33
+
34
+ type WebLlmModelRecord = NonNullable<AppConfig['model_list']>[number];
35
+
36
+ const startsWithAsciiLetter = (value: string): boolean => {
37
+ const firstChar = value.charCodeAt(0);
38
+
39
+ return (firstChar >= 65 && firstChar <= 90) || (firstChar >= 97 && firstChar <= 122);
40
+ };
41
+
42
+ // ─── Types ───────────────────────────────────────────────────────────────────
43
+
44
+ export interface LocalSlowLaneClientConfig {
45
+ /**
46
+ * Optional custom model registration for models not in web-llm's
47
+ * built-in list. When provided, the model is appended to the app
48
+ * config before engine creation.
49
+ */
50
+ customModelConfig?: {
51
+ /** Context window size override (optional) */
52
+ contextWindowSize?: number;
53
+ /** HuggingFace URL to the model weights (e.g. "https://huggingface.co/HuggingFaceTB/smollm-135M-instruct-add-basics-q0f16-MLC") */
54
+ model: string;
55
+ /** URL to the compiled WASM library for this model architecture */
56
+ modelLib: string;
57
+ /** VRAM required in MB (optional, for resource planning) */
58
+ vramRequiredMB?: number;
59
+ };
60
+ /** Debounce interval in ms before sending context for inference. */
61
+ debounceMs?: number;
62
+ /**
63
+ * MLC model identifier.
64
+ * Defaults to the built-in SmolLM2-135M-Instruct-q0f16-MLC.
65
+ *
66
+ * To use a custom HuggingFace model, provide both `modelId` and
67
+ * `customModelConfig` with the model URL and WASM library URL.
68
+ */
69
+ modelId?: string;
70
+ /** Callback fired with status messages (model loading progress, etc.). */
71
+ onStatus?: (message: string) => void;
72
+ /** Callback fired when inference returns new results. */
73
+ onUpdate?: (opts: { hasLmLogits: boolean; hasVector: boolean; textLength: number }) => void;
74
+ }
75
+
76
+ // Same return type as createSlowLaneClient for drop-in compatibility
77
+ export interface LocalSlowLaneClient {
78
+ /** Clean up resources. */
79
+ destroy: () => void;
80
+ getContextVector: () => Float32Array | null;
81
+ getLmLogits: () => Record<string, number> | null;
82
+ /** Whether the model is loaded and ready for inference. */
83
+ isReady: () => boolean;
84
+ isWordBoundary: (text: string) => boolean;
85
+ setContextVector: (vector: Float32Array | null) => void;
86
+ setLmLogits: (logits: Record<string, number> | null) => void;
87
+ updateContext: (text: string) => void;
88
+ }
89
+
90
+ // ─── Constants ───────────────────────────────────────────────────────────────
91
+
92
+ const DEFAULT_DEBOUNCE_MS = 300;
93
+
94
+ export const LOCAL_MLC_MODEL_ID = 'SmolLM2-135M-Instruct-q0f16-MLC';
95
+
96
+ /** HF root for the default weights (includes `tensor-cache.json` for WebLLM 0.2+). */
97
+ export const LOCAL_MLC_HF_MODEL_REPO =
98
+ 'https://huggingface.co/mlc-ai/SmolLM2-135M-Instruct-q0f16-MLC';
99
+
100
+ export const LOCAL_MLC_MODEL_LIB_WASM_NAME = 'SmolLM2-135M-Instruct-q0f16-ctx4k_cs1k-webgpu.wasm';
101
+
102
+ /**
103
+ * Original target repo (add-basics fine-tune). **Not compatible with WebLLM 0.2.x** (no `tensor-cache.json`).
104
+ * @see module doc above
105
+ */
106
+ export const HUGGINGFACE_TB_SMOLLM_ADD_BASICS_REPO =
107
+ 'https://huggingface.co/HuggingFaceTB/smollm-135M-instruct-add-basics-q0f16-MLC';
108
+
109
+ // ─── Factory ─────────────────────────────────────────────────────────────────
110
+
111
+ /**
112
+ * Create a local slow-lane client powered by MLC WebLLM.
113
+ *
114
+ * The engine is initialised lazily — model weights are downloaded (and cached
115
+ * in IndexedDB) on first use. Subsequent page loads skip the download.
116
+ *
117
+ * Usage:
118
+ * ```ts
119
+ * const client = createLocalSlowLaneClient({ debounceMs: 300 });
120
+ * // On word boundaries:
121
+ * client.updateContext(docText);
122
+ * // In scoring pipeline:
123
+ * const vec = client.getContextVector();
124
+ * const logits = client.getLmLogits();
125
+ * // On plugin teardown:
126
+ * client.destroy();
127
+ * ```
128
+ */
129
+ export const createLocalSlowLaneClient = (
130
+ config: LocalSlowLaneClientConfig = {},
131
+ ): LocalSlowLaneClient => {
132
+ const {
133
+ debounceMs = DEFAULT_DEBOUNCE_MS,
134
+ onUpdate,
135
+ onStatus,
136
+ modelId = LOCAL_MLC_MODEL_ID,
137
+ customModelConfig,
138
+ } = config;
139
+
140
+ // ── State ──────────────────────────────────────────────────────────────
141
+ let storedContextVector: Float32Array | null = null;
142
+ let storedLmLogits: Record<string, number> | null = null;
143
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null;
144
+ let lastRequestedText = '';
145
+ let requestCounter = 0;
146
+ let latestRequestId = -1;
147
+ let ready = false;
148
+ let destroyed = false;
149
+ let initFailed = false;
150
+ let engine: MLCEngine | null = null;
151
+ let engineInitPromise: Promise<void> | null = null;
152
+
153
+ const unloadEngine = (engineToUnload: MLCEngine): void => {
154
+ engineToUnload.unload().catch((error: unknown) => {
155
+ if (isAutocompleteDebugEnabled()) {
156
+ // eslint-disable-next-line no-console
157
+ console.log(
158
+ '%c[LocalSlowLane] %cFailed to unload engine',
159
+ 'color: #9c27b0; font-weight: bold;',
160
+ 'color: inherit;',
161
+ error,
162
+ );
163
+ }
164
+ });
165
+ };
166
+
167
+ // ── Engine initialisation ──────────────────────────────────────────────
168
+
169
+ const initProgressCallback = (progress: InitProgressReport): void => {
170
+ const message = `[${(progress.progress * 100).toFixed(0)}%] ${progress.text}`;
171
+ if (isAutocompleteDebugEnabled()) {
172
+ // eslint-disable-next-line no-console
173
+ console.log(
174
+ `%c[LocalSlowLane] %c🔄 ${message}`,
175
+ 'color: #9c27b0; font-weight: bold;',
176
+ 'color: inherit;',
177
+ );
178
+ }
179
+ onStatus?.(message);
180
+ };
181
+
182
+ const initEngine = async (): Promise<void> => {
183
+ try {
184
+ if (isAutocompleteDebugEnabled()) {
185
+ // eslint-disable-next-line no-console
186
+ console.log(
187
+ `%c[LocalSlowLane] %c🚀 Initialising MLC engine with model: ${modelId}`,
188
+ 'color: #9c27b0; font-weight: bold;',
189
+ 'color: inherit;',
190
+ );
191
+ }
192
+ onStatus?.(`Initialising model: ${modelId}…`);
193
+
194
+ if (!('gpu' in navigator)) {
195
+ throw new Error('WebGPU not supported');
196
+ }
197
+
198
+ // eslint-disable-next-line @repo/internal/import/no-unresolved, import/dynamic-import-chunkname -- runtime dependency declared in package.json and lazily loaded for webgpu support
199
+ const { CreateMLCEngine, prebuiltAppConfig } = await import(
200
+ /* webpackChunkName: "@atlaskit-internal_editor-plugin-autocomplete-mlc-web-llm" */ '@mlc-ai/web-llm'
201
+ );
202
+
203
+ const customModelRecord: WebLlmModelRecord | undefined = customModelConfig
204
+ ? {
205
+ model: customModelConfig.model,
206
+ model_id: modelId,
207
+ model_lib: customModelConfig.modelLib,
208
+ low_resource_required: true,
209
+ required_features: ['shader-f16'],
210
+ ...(customModelConfig.vramRequiredMB !== undefined
211
+ ? {
212
+ vram_required_MB: customModelConfig.vramRequiredMB,
213
+ }
214
+ : {}),
215
+ ...(customModelConfig.contextWindowSize !== undefined
216
+ ? {
217
+ overrides: {
218
+ context_window_size: customModelConfig.contextWindowSize,
219
+ },
220
+ }
221
+ : {}),
222
+ }
223
+ : undefined;
224
+
225
+ const appConfig: AppConfig = {
226
+ model_list: [
227
+ ...prebuiltAppConfig.model_list,
228
+ ...(customModelRecord ? [customModelRecord] : []),
229
+ ],
230
+ };
231
+
232
+ engine = await CreateMLCEngine(modelId, {
233
+ appConfig,
234
+ initProgressCallback,
235
+ });
236
+
237
+ if (destroyed) {
238
+ // destroy() was called while we were loading — clean up
239
+ unloadEngine(engine);
240
+ engine = null;
241
+ return;
242
+ }
243
+
244
+ ready = true;
245
+
246
+ if (isAutocompleteDebugEnabled()) {
247
+ // eslint-disable-next-line no-console
248
+ console.log(
249
+ '%c[LocalSlowLane] %c✅ MLC engine loaded and ready',
250
+ 'color: #9c27b0; font-weight: bold;',
251
+ 'color: #4caf50;',
252
+ );
253
+ }
254
+ onStatus?.('Model loaded and ready.');
255
+ } catch (err) {
256
+ const errorMsg = err instanceof Error ? err.message : String(err);
257
+ ready = false;
258
+ if (isAutocompleteDebugEnabled()) {
259
+ // eslint-disable-next-line no-console
260
+ console.log(`[LocalSlowLane] Engine initialisation failed: ${errorMsg}`);
261
+ }
262
+ onStatus?.(`Engine initialisation failed: ${errorMsg}`);
263
+ engineInitPromise = null;
264
+ initFailed = true;
265
+ }
266
+ };
267
+
268
+ const ensureEngineInitialized = (): Promise<void> => {
269
+ if (initFailed) {
270
+ return Promise.resolve();
271
+ }
272
+ if (!engineInitPromise) {
273
+ engineInitPromise = initEngine();
274
+ }
275
+ return engineInitPromise;
276
+ };
277
+
278
+ // ── Inference ──────────────────────────────────────────────────────────
279
+
280
+ /**
281
+ * Run a single forward pass to extract next-token logit probabilities.
282
+ *
283
+ * We use the chat completions API with `max_tokens: 1` and `logprobs: true`
284
+ * to get the model's next-token distribution without generating text.
285
+ * This is the cheapest possible inference call — a single forward pass.
286
+ */
287
+ const runInference = async (text: string, requestId: number): Promise<void> => {
288
+ if (!engine || destroyed) {
289
+ return;
290
+ }
291
+
292
+ try {
293
+ // Use chat completion with logprobs to get next-token distribution
294
+ const response = await engine.chat.completions.create({
295
+ messages: [
296
+ {
297
+ role: 'user',
298
+ content: text,
299
+ },
300
+ ],
301
+ max_tokens: 1,
302
+ logprobs: true,
303
+ top_logprobs: 5,
304
+ temperature: 0,
305
+ });
306
+
307
+ // Discard stale results
308
+ if (requestId < latestRequestId || destroyed) {
309
+ return;
310
+ }
311
+
312
+ // ── Extract LM logits ───────────────────────────────────────
313
+ const lmLogits: Record<string, number> = {};
314
+
315
+ const logprobsContent = response.choices?.[0]?.logprobs?.content;
316
+ if (logprobsContent && logprobsContent.length > 0) {
317
+ const tokenLogprobs = logprobsContent[0];
318
+
319
+ // Add the top token
320
+ if (tokenLogprobs.token) {
321
+ const token = tokenLogprobs.token.trim().toLowerCase();
322
+ if (token.length > 0 && startsWithAsciiLetter(token)) {
323
+ lmLogits[token] = Math.exp(tokenLogprobs.logprob);
324
+ }
325
+ }
326
+
327
+ // Add alternative tokens from top_logprobs
328
+ if (tokenLogprobs.top_logprobs) {
329
+ for (const alt of tokenLogprobs.top_logprobs) {
330
+ const token = alt.token.trim().toLowerCase();
331
+ if (token.length > 0 && startsWithAsciiLetter(token)) {
332
+ lmLogits[token] = Math.exp(alt.logprob);
333
+ }
334
+ }
335
+ }
336
+ }
337
+
338
+ storedLmLogits = Object.keys(lmLogits).length > 0 ? lmLogits : null;
339
+
340
+ // ── Semantic vector ─────────────────────────────────────────
341
+ // SmolLM is a generative model, not an embedding model, so we
342
+ // don't get a true semantic vector. We generate a lightweight
343
+ // pseudo-embedding from the logit distribution for compatibility
344
+ // with the existing scoring pipeline.
345
+ //
346
+ // For a production implementation, you would use a dedicated
347
+ // embedding model (e.g. via web-llm's embeddings API with an
348
+ // embedding-specific model).
349
+ if (storedLmLogits) {
350
+ const logitValues = Object.values(storedLmLogits);
351
+ storedContextVector = new Float32Array(logitValues);
352
+ } else {
353
+ storedContextVector = null;
354
+ }
355
+
356
+ if (isAutocompleteDebugEnabled()) {
357
+ // eslint-disable-next-line no-console
358
+ console.groupCollapsed(
359
+ `%c[LocalSlowLane] %c📥 Inference result (request #${requestId})`,
360
+ 'color: #9c27b0; font-weight: bold;',
361
+ 'color: inherit;',
362
+ );
363
+ // eslint-disable-next-line no-console
364
+ console.log(
365
+ storedContextVector
366
+ ? `✅ pseudo-vector: ${storedContextVector.length} dims`
367
+ : '❌ No vector',
368
+ );
369
+ // eslint-disable-next-line no-console
370
+ console.log(
371
+ storedLmLogits
372
+ ? `✅ lm_logits: ${Object.keys(storedLmLogits).length} tokens`
373
+ : '❌ No lm_logits',
374
+ );
375
+ if (storedLmLogits) {
376
+ const topTokens = Object.entries(storedLmLogits)
377
+ .sort(([, a], [, b]) => b - a)
378
+ .slice(0, 10);
379
+ // eslint-disable-next-line no-console
380
+ console.log(
381
+ 'Top 10 predictions:',
382
+ topTokens.map(([t, p]) => `${t}: ${(p * 100).toFixed(1)}%`).join(', '),
383
+ );
384
+ }
385
+ // eslint-disable-next-line no-console
386
+ console.groupEnd();
387
+ }
388
+
389
+ onUpdate?.({
390
+ textLength: text.length,
391
+ hasVector: storedContextVector !== null,
392
+ hasLmLogits: storedLmLogits !== null,
393
+ });
394
+ } catch (err) {
395
+ // Discard errors for stale requests
396
+ if (requestId < latestRequestId) {
397
+ return;
398
+ }
399
+
400
+ storedContextVector = null;
401
+ storedLmLogits = null;
402
+ onUpdate?.({ textLength: text.length, hasVector: false, hasLmLogits: false });
403
+
404
+ const errorMsg = err instanceof Error ? err.message : String(err);
405
+ if (isAutocompleteDebugEnabled()) {
406
+ // eslint-disable-next-line no-console
407
+ console.log(
408
+ `%c[LocalSlowLane] %c❌ Inference error (request #${requestId}): ${errorMsg}`,
409
+ 'color: #9c27b0; font-weight: bold;',
410
+ 'color: #f44336;',
411
+ );
412
+ }
413
+ }
414
+ };
415
+
416
+ // ── Context update (debounced) ─────────────────────────────────────────
417
+
418
+ const doUpdateContext = (text: string): void => {
419
+ if (destroyed || !text || text.trim().length === 0) {
420
+ return;
421
+ }
422
+
423
+ const requestId = ++requestCounter;
424
+ latestRequestId = requestId;
425
+
426
+ if (isAutocompleteDebugEnabled()) {
427
+ // eslint-disable-next-line no-console
428
+ console.groupCollapsed(
429
+ `%c[LocalSlowLane] %c📤 Context update (request #${requestId}) | ${text.length} chars`,
430
+ 'color: #9c27b0; font-weight: bold;',
431
+ 'color: inherit;',
432
+ );
433
+ const lines = text.split('\n');
434
+ lines.forEach((line, i) => {
435
+ // eslint-disable-next-line no-console
436
+ console.log(` ${i === lines.length - 1 ? '▶' : ' '} ${line}`);
437
+ });
438
+ // eslint-disable-next-line no-console
439
+ console.groupEnd();
440
+ }
441
+
442
+ void ensureEngineInitialized()
443
+ .then(() => runInference(text, requestId))
444
+ .catch(() => {});
445
+ };
446
+
447
+ const updateContextDebounced = (text: string): void => {
448
+ if (debounceTimer) {
449
+ clearTimeout(debounceTimer);
450
+ }
451
+ lastRequestedText = text;
452
+ debounceTimer = setTimeout(() => {
453
+ debounceTimer = null;
454
+ doUpdateContext(lastRequestedText);
455
+ }, debounceMs);
456
+ };
457
+
458
+ // ── Public API (same shape as createSlowLaneClient) ────────────────────
459
+ return {
460
+ updateContext: updateContextDebounced,
461
+ getContextVector: () => storedContextVector,
462
+ getLmLogits: () => storedLmLogits,
463
+ setContextVector: (vector) => {
464
+ storedContextVector = vector;
465
+ },
466
+ setLmLogits: (logits) => {
467
+ storedLmLogits = logits;
468
+ },
469
+ isWordBoundary,
470
+ isReady: () => ready,
471
+ destroy: () => {
472
+ destroyed = true;
473
+ ready = false;
474
+ if (debounceTimer) {
475
+ clearTimeout(debounceTimer);
476
+ }
477
+ if (engine) {
478
+ const engineToUnload = engine;
479
+ engine = null;
480
+ unloadEngine(engineToUnload);
481
+ }
482
+ engineInitPromise = null;
483
+ storedContextVector = null;
484
+ storedLmLogits = null;
485
+ },
486
+ };
487
+ };
@@ -7,6 +7,8 @@
7
7
  * Response: { semantic_vector: number[], lm_logits: Record<string, number> }
8
8
  */
9
9
 
10
+ import { abortExp, EXPERIENCE_NAME, failExp, startExp, succeedExp } from '../analytics/ufo';
11
+
10
12
  import { isAutocompleteDebugEnabled } from './debug-mode';
11
13
 
12
14
  // ─── Types ───────────────────────────────────────────────────────────────────
@@ -84,8 +86,10 @@ export const createSlowLaneClient = (
84
86
  let lastRequestedText = '';
85
87
  let storedContextVector: Float32Array | null = null;
86
88
  let storedLmLogits: Record<string, number> | null = null;
89
+ let requestSeq = 0;
90
+ let inflightRequestId: string | null = null;
87
91
 
88
- const doUpdateContext = async (text: string): Promise<void> => {
92
+ const doUpdateContext = async (text: string, requestId: string): Promise<void> => {
89
93
  if (!text || text.trim().length === 0) {
90
94
  return;
91
95
  }
@@ -97,6 +101,8 @@ export const createSlowLaneClient = (
97
101
  session_id: sessionId,
98
102
  };
99
103
 
104
+ startExp(EXPERIENCE_NAME.SLOW_LANE_FETCH, requestId, { textLength: text.length });
105
+
100
106
  if (isAutocompleteDebugEnabled()) {
101
107
  // eslint-disable-next-line no-console
102
108
  console.groupCollapsed(
@@ -122,6 +128,10 @@ export const createSlowLaneClient = (
122
128
  if (!res.ok) {
123
129
  storedContextVector = null;
124
130
  storedLmLogits = null;
131
+ failExp(EXPERIENCE_NAME.SLOW_LANE_FETCH, requestId, {
132
+ status: res.status,
133
+ errorType: 'http_error',
134
+ });
125
135
  if (isAutocompleteDebugEnabled()) {
126
136
  // eslint-disable-next-line no-console
127
137
  console.log(
@@ -170,6 +180,12 @@ export const createSlowLaneClient = (
170
180
  console.groupEnd();
171
181
  }
172
182
 
183
+ succeedExp(EXPERIENCE_NAME.SLOW_LANE_FETCH, requestId, {
184
+ textLength: text.length,
185
+ hasVector: storedContextVector !== null,
186
+ hasLmLogits: storedLmLogits !== null,
187
+ });
188
+
173
189
  onUpdate?.({
174
190
  textLength: text.length,
175
191
  hasVector: storedContextVector !== null,
@@ -179,6 +195,7 @@ export const createSlowLaneClient = (
179
195
  } catch (e) {
180
196
  storedContextVector = null;
181
197
  storedLmLogits = null;
198
+ failExp(EXPERIENCE_NAME.SLOW_LANE_FETCH, requestId, { errorType: 'network' });
182
199
  if (isAutocompleteDebugEnabled()) {
183
200
  // eslint-disable-next-line no-console
184
201
  console.log(
@@ -187,6 +204,10 @@ export const createSlowLaneClient = (
187
204
  'color: #f44336;',
188
205
  );
189
206
  }
207
+ } finally {
208
+ if (inflightRequestId === requestId) {
209
+ inflightRequestId = null;
210
+ }
190
211
  }
191
212
  };
192
213
 
@@ -197,7 +218,12 @@ export const createSlowLaneClient = (
197
218
  lastRequestedText = text;
198
219
  debounceTimer = setTimeout(() => {
199
220
  debounceTimer = null;
200
- doUpdateContext(lastRequestedText);
221
+ if (inflightRequestId !== null) {
222
+ abortExp(EXPERIENCE_NAME.SLOW_LANE_FETCH, inflightRequestId, 'superseded');
223
+ }
224
+ const requestId = String(++requestSeq);
225
+ inflightRequestId = requestId;
226
+ doUpdateContext(lastRequestedText, requestId);
201
227
  }, debounceMs);
202
228
  };
203
229
 
@@ -15,6 +15,8 @@
15
15
  * via incrementSessionFreq(), called on word boundaries from the plugin.
16
16
  */
17
17
 
18
+ import { EXPERIENCE_NAME, failExp, startExp, succeedExp } from '../analytics/ufo';
19
+
18
20
  // import bigramsData from './data/bigrams.json';
19
21
  import l3VocabularyData from './data/l3_vocabulary.json';
20
22
  import vocabularyData from './data/vocabulary_10k.json';
@@ -723,12 +725,14 @@ export const loadVectorsAsync = async (options?: {
723
725
  return;
724
726
  }
725
727
  vectorsLoadStarted = true;
728
+ startExp(EXPERIENCE_NAME.LOAD_VECTORS, 'singleton');
726
729
 
727
730
  let url: string;
728
731
  try {
729
732
  url = await options.getBinaryUrl();
730
733
  } catch (e) {
731
734
  vectorsLoadStarted = false;
735
+ failExp(EXPERIENCE_NAME.LOAD_VECTORS, 'singleton', { errorType: 'resolve_url' });
732
736
  // eslint-disable-next-line no-console
733
737
  console.warn('[text-predictor] Failed to resolve vectors URL:', e);
734
738
  return;
@@ -738,6 +742,10 @@ export const loadVectorsAsync = async (options?: {
738
742
  const res = await fetch(url);
739
743
  if (!res.ok) {
740
744
  vectorsLoadStarted = false;
745
+ failExp(EXPERIENCE_NAME.LOAD_VECTORS, 'singleton', {
746
+ status: res.status,
747
+ errorType: 'http_error',
748
+ });
741
749
  // eslint-disable-next-line no-console
742
750
  console.warn(`[text-predictor] Failed to load vectors: ${res.status}`);
743
751
  return;
@@ -749,6 +757,11 @@ export const loadVectorsAsync = async (options?: {
749
757
  const dim = float32.length / nWords;
750
758
 
751
759
  vectorStore = { float32, wordIndex, dim };
760
+ succeedExp(EXPERIENCE_NAME.LOAD_VECTORS, 'singleton', {
761
+ wordCount: nWords,
762
+ dim,
763
+ sizeBytes: float32.byteLength,
764
+ });
752
765
  if (isAutocompleteDebugEnabled()) {
753
766
  // eslint-disable-next-line no-console
754
767
  console.log('[text-predictor] Vectors loaded:', {
@@ -759,6 +772,7 @@ export const loadVectorsAsync = async (options?: {
759
772
  }
760
773
  } catch (e) {
761
774
  vectorsLoadStarted = false;
775
+ failExp(EXPERIENCE_NAME.LOAD_VECTORS, 'singleton', { errorType: 'network' });
762
776
  // eslint-disable-next-line no-console
763
777
  console.warn('[text-predictor] Failed to load vectors:', e);
764
778
  }
@@ -769,17 +783,33 @@ export const initVectors = (store: VectorStore): void => {
769
783
  };
770
784
 
771
785
  export const loadDefaultVocabulary = (): void => {
772
- // 1. Load the Atlassian Domain (L2)
773
- const data = vocabularyData as VocabularyJson;
774
- const terms = Object.entries(data.words).map(([word, stats]) => ({
775
- word,
776
- freq: stats.freq,
777
- docFreq: stats.doc_freq,
778
- authorFreq: stats.author_freq,
779
- }));
780
- initVocabulary({ terms });
786
+ if (isInitialized) {
787
+ return;
788
+ }
789
+
790
+ startExp(EXPERIENCE_NAME.LOAD_VOCABULARY, 'singleton');
781
791
 
782
- // 2. Load General English (L3)
783
- const l3Words = l3VocabularyData as string[];
784
- initL3Vocabulary(l3Words);
792
+ try {
793
+ // 1. Load the Atlassian Domain (L2)
794
+ const data = vocabularyData as VocabularyJson;
795
+ const terms = Object.entries(data.words).map(([word, stats]) => ({
796
+ word,
797
+ freq: stats.freq,
798
+ docFreq: stats.doc_freq,
799
+ authorFreq: stats.author_freq,
800
+ }));
801
+ initVocabulary({ terms });
802
+
803
+ // 2. Load General English (L3)
804
+ const l3Words = l3VocabularyData as string[];
805
+ initL3Vocabulary(l3Words);
806
+
807
+ succeedExp(EXPERIENCE_NAME.LOAD_VOCABULARY, 'singleton', {
808
+ l2WordCount: terms.length,
809
+ l3WordCount: l3Words.length,
810
+ });
811
+ } catch (e) {
812
+ failExp(EXPERIENCE_NAME.LOAD_VOCABULARY, 'singleton', { errorType: 'parse_error' });
813
+ throw e;
814
+ }
785
815
  };
package/tsconfig.app.json CHANGED
@@ -42,6 +42,9 @@
42
42
  },
43
43
  {
44
44
  "path": "../editor-prosemirror/tsconfig.app.json"
45
+ },
46
+ {
47
+ "path": "../../data/ufo-external/tsconfig.app.json"
45
48
  }
46
49
  ]
47
50
  }