@alaikis/translation-sdk 1.0.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.
@@ -0,0 +1,1247 @@
1
+ /**
2
+ * Translation Service gRPC-Web TypeScript/JavaScript Client
3
+ *
4
+ * Auto-generated for api.laker.dev Translation Service
5
+ * Service: TranslationService
6
+ * Source: proto/translation.proto
7
+ */
8
+ const defaultCrossTabOptions = {
9
+ enabled: false,
10
+ channelName: 'laker-translation-cache',
11
+ storageKeyPrefix: 'laker_translation_'
12
+ };
13
+ /**
14
+ * Automatic template extraction from text containing numeric variables
15
+ * @param text Original text that may contain numeric variables
16
+ * @returns Template extraction result
17
+ */
18
+ export function extractTemplate(text) {
19
+ // Regex to find numbers in text
20
+ const numberRegex = /\d+(?:\.\d+)?/g;
21
+ const matches = text.match(numberRegex);
22
+ if (!matches || matches.length === 0) {
23
+ return {
24
+ isTemplated: false,
25
+ srcTemplate: text,
26
+ dstTemplate: '',
27
+ variables: []
28
+ };
29
+ }
30
+ let template = text;
31
+ const variables = [];
32
+ matches.forEach((match, index) => {
33
+ const varName = `{var${index + 1}}`;
34
+ template = template.replace(match, varName);
35
+ variables.push(match);
36
+ });
37
+ return {
38
+ isTemplated: variables.length > 0,
39
+ srcTemplate: template,
40
+ dstTemplate: '',
41
+ variables
42
+ };
43
+ }
44
+ /**
45
+ * TranslationPool - Single-level translation cache (simplified for new API)
46
+ *
47
+ * Architecture:
48
+ * - pool: stores translations (from backend, unified result field)
49
+ * - currentFingerprint: current active fingerprint for special translations
50
+ * - Optional cross-tab synchronization via Broadcast Channel and localStorage
51
+ *
52
+ * Rules:
53
+ * - If fingerprint exists, load special translations
54
+ * - If no fingerprint, load common translations
55
+ * - All translations stored in single pool with unified access
56
+ */
57
+ class TranslationPool {
58
+ client;
59
+ senseId;
60
+ pool = new Map();
61
+ currentFingerprint = null;
62
+ crossTabOptions;
63
+ broadcastChannel = null;
64
+ loading = false;
65
+ loadedFingerprints = new Set();
66
+ /**
67
+ * Create a new TranslationPool for a specific sense
68
+ * @param client TranslationClient instance
69
+ * @param senseId The semantic sense ID
70
+ * @param crossTabOptions Cross-tab synchronization options
71
+ */
72
+ constructor(client, senseId, crossTabOptions) {
73
+ this.client = client;
74
+ this.senseId = senseId;
75
+ this.crossTabOptions = { ...defaultCrossTabOptions, ...crossTabOptions };
76
+ if (this.crossTabOptions.enabled && typeof BroadcastChannel !== 'undefined') {
77
+ this.initCrossTabSync();
78
+ }
79
+ // Load from localStorage if cross-tab enabled and storage available
80
+ this.loadFromStorage();
81
+ }
82
+ /**
83
+ * Initialize cross-tab synchronization
84
+ */
85
+ initCrossTabSync() {
86
+ this.broadcastChannel = new BroadcastChannel(this.crossTabOptions.channelName);
87
+ this.broadcastChannel.onmessage = (event) => {
88
+ const message = event.data;
89
+ // Ignore messages for other senses
90
+ if (message.senseId !== this.senseId) {
91
+ return;
92
+ }
93
+ switch (message.type) {
94
+ case 'cache_update':
95
+ this.handleCacheUpdate(message);
96
+ break;
97
+ case 'cache_clear':
98
+ this.handleCacheClear(message);
99
+ break;
100
+ case 'request_initial_sync':
101
+ this.handleInitialSyncRequest();
102
+ break;
103
+ }
104
+ };
105
+ // Request other tabs to share their cache
106
+ this.broadcastChannel.postMessage({
107
+ type: 'request_initial_sync',
108
+ senseId: this.senseId
109
+ });
110
+ }
111
+ /**
112
+ * Load cache from localStorage
113
+ */
114
+ loadFromStorage() {
115
+ if (typeof localStorage === 'undefined' || !this.crossTabOptions.enabled) {
116
+ return;
117
+ }
118
+ const storageKey = this.getStorageKey();
119
+ try {
120
+ const stored = localStorage.getItem(storageKey);
121
+ if (stored) {
122
+ const data = JSON.parse(stored);
123
+ data.forEach(({ text, translation }) => {
124
+ this.pool.set(text, translation);
125
+ });
126
+ }
127
+ }
128
+ catch (e) {
129
+ console.warn('Failed to load translation cache from localStorage:', e);
130
+ }
131
+ }
132
+ /**
133
+ * Get storage key based on current fingerprint
134
+ */
135
+ getStorageKey() {
136
+ const fp = this.currentFingerprint || 'common';
137
+ return `${this.crossTabOptions.storageKeyPrefix}${this.senseId}_${fp}`;
138
+ }
139
+ /**
140
+ * Save cache to localStorage
141
+ */
142
+ saveToStorage() {
143
+ if (typeof localStorage === 'undefined' || !this.crossTabOptions.enabled) {
144
+ return;
145
+ }
146
+ const storageKey = this.getStorageKey();
147
+ try {
148
+ const data = this.getAll();
149
+ localStorage.setItem(storageKey, JSON.stringify(data));
150
+ }
151
+ catch (e) {
152
+ console.warn('Failed to save translation cache to localStorage:', e);
153
+ }
154
+ }
155
+ /**
156
+ * Broadcast cache update to all other tabs
157
+ */
158
+ broadcastUpdate(text, translation) {
159
+ if (!this.broadcastChannel || !this.crossTabOptions.enabled) {
160
+ return;
161
+ }
162
+ const message = {
163
+ type: 'cache_update',
164
+ senseId: this.senseId,
165
+ fingerprint: this.currentFingerprint || undefined,
166
+ data: {
167
+ result: this.getAll(),
168
+ ...(text && translation && { text, translation })
169
+ }
170
+ };
171
+ this.broadcastChannel.postMessage(message);
172
+ this.saveToStorage();
173
+ }
174
+ /**
175
+ * Handle incoming cache update from another tab
176
+ */
177
+ handleCacheUpdate(message) {
178
+ if (message.data?.result) {
179
+ // Update full cache
180
+ this.pool.clear();
181
+ message.data.result.forEach(({ text, translation }) => {
182
+ this.pool.set(text, translation);
183
+ });
184
+ }
185
+ // Update specific entry
186
+ if (message.data?.text && message.data?.translation) {
187
+ this.pool.set(message.data.text, message.data.translation);
188
+ this.saveToStorage();
189
+ }
190
+ }
191
+ /**
192
+ * Handle incoming cache clear from another tab
193
+ */
194
+ handleCacheClear(message) {
195
+ this.clearAll();
196
+ }
197
+ /**
198
+ * Handle initial sync request from a new tab
199
+ */
200
+ handleInitialSyncRequest() {
201
+ // Send our current cache to the new tab
202
+ this.broadcastUpdate();
203
+ }
204
+ /**
205
+ * Check if currently loading
206
+ */
207
+ isLoading() {
208
+ return this.loading;
209
+ }
210
+ /**
211
+ * Initialize the pool - loads translations from backend using streaming
212
+ * If fingerprint is set, loads special translations; otherwise loads common
213
+ */
214
+ async initialize() {
215
+ if (this.loading) {
216
+ return;
217
+ }
218
+ // Check if already loaded for this fingerprint
219
+ const fpKey = this.currentFingerprint || 'common';
220
+ if (this.loadedFingerprints.has(fpKey) && this.pool.size > 0) {
221
+ return;
222
+ }
223
+ this.loading = true;
224
+ try {
225
+ // Use streaming for batch loading
226
+ await this.client.translateStream({
227
+ senseId: this.senseId,
228
+ fingerprint: this.currentFingerprint || undefined,
229
+ batchSize: 500
230
+ }, (response) => {
231
+ // Add all translations from this batch
232
+ response.translations.forEach(record => {
233
+ this.pool.set(record.text, record.translate);
234
+ });
235
+ return true; // Continue streaming
236
+ });
237
+ // Mark as loaded
238
+ this.loadedFingerprints.add(fpKey);
239
+ // Broadcast to other tabs after full initialization
240
+ this.broadcastUpdate();
241
+ }
242
+ finally {
243
+ this.loading = false;
244
+ }
245
+ }
246
+ /**
247
+ * Switch to a different fingerprint, loads its translations
248
+ * @param fingerprint The fingerprint to switch to
249
+ */
250
+ async switchFingerprint(fingerprint) {
251
+ // Clear current pool
252
+ this.pool.clear();
253
+ this.currentFingerprint = fingerprint;
254
+ // Load from localStorage first
255
+ this.loadFromStorage();
256
+ // Check if we need to load from backend
257
+ if (!this.loadedFingerprints.has(fingerprint)) {
258
+ await this.initialize();
259
+ }
260
+ }
261
+ /**
262
+ * Clear the current fingerprint to free memory
263
+ */
264
+ clearCurrentFingerprint() {
265
+ this.currentFingerprint = null;
266
+ this.pool.clear();
267
+ }
268
+ /**
269
+ * Lookup translation
270
+ * @param text Original text to lookup
271
+ * @returns Lookup result
272
+ */
273
+ lookup(text) {
274
+ if (this.pool.has(text)) {
275
+ return {
276
+ found: true,
277
+ translation: this.pool.get(text),
278
+ source: this.currentFingerprint ? 'special' : 'common'
279
+ };
280
+ }
281
+ // Not found
282
+ return {
283
+ found: false,
284
+ translation: '',
285
+ source: null
286
+ };
287
+ }
288
+ /**
289
+ * Add a translation to the pool
290
+ * @param text Original text
291
+ * @param translation Translated text
292
+ */
293
+ addTranslation(text, translation) {
294
+ this.pool.set(text, translation);
295
+ this.broadcastUpdate(text, translation);
296
+ }
297
+ /**
298
+ * Request translation from backend, automatically adds to pool if found
299
+ *
300
+ * @param text Text to translate
301
+ * @param fromLang Source language
302
+ * @param toLang Target language
303
+ * @returns Translation response
304
+ */
305
+ async requestTranslation(text, fromLang, toLang) {
306
+ const response = await this.client.llmTranslate({
307
+ text,
308
+ fromLang,
309
+ toLang,
310
+ senseId: this.senseId
311
+ });
312
+ // Add to pool automatically
313
+ if (response.translatedText) {
314
+ this.addTranslation(text, response.translatedText);
315
+ }
316
+ return response;
317
+ }
318
+ /**
319
+ * Get all translations
320
+ * @returns Array of {text, translation}
321
+ */
322
+ getAll() {
323
+ const result = [];
324
+ this.pool.forEach((translation, text) => {
325
+ result.push({ text, translation });
326
+ });
327
+ return result;
328
+ }
329
+ /**
330
+ * Clear all cached data to free memory
331
+ */
332
+ clearAll() {
333
+ this.pool.clear();
334
+ this.loadedFingerprints.clear();
335
+ this.currentFingerprint = null;
336
+ // Clear localStorage
337
+ if (typeof localStorage !== 'undefined' && this.crossTabOptions.enabled) {
338
+ const storageKey = this.getStorageKey();
339
+ localStorage.removeItem(storageKey);
340
+ }
341
+ // Broadcast clear to other tabs
342
+ if (this.broadcastChannel && this.crossTabOptions.enabled) {
343
+ this.broadcastChannel.postMessage({
344
+ type: 'cache_clear',
345
+ senseId: this.senseId
346
+ });
347
+ }
348
+ }
349
+ /**
350
+ * Close the broadcast channel to free resources
351
+ * Should be called when the pool is no longer needed
352
+ */
353
+ destroy() {
354
+ if (this.broadcastChannel) {
355
+ this.broadcastChannel.close();
356
+ this.broadcastChannel = null;
357
+ }
358
+ }
359
+ /**
360
+ * Check if cross-tab synchronization is enabled
361
+ */
362
+ isCrossTabEnabled() {
363
+ return this.crossTabOptions.enabled;
364
+ }
365
+ }
366
+ /**
367
+ * TranslationService - Main entry point for translation operations
368
+ * Provides a clean API with automatic caching and cross-tab synchronization
369
+ *
370
+ * Features:
371
+ * - Lazy initialization: automatically initializes on first translate() call
372
+ * - Streaming batch load: uses streaming API for efficient initialization
373
+ * - On-demand loading: automatically loads when fingerprint changes
374
+ * - Cross-tab sync: optional Broadcast Channel + localStorage synchronization
375
+ *
376
+ * Usage:
377
+ * - Simple: Just create and call translate() - initialization is automatic
378
+ * - Advanced: Call initialize() explicitly to preload all translations upfront
379
+ */
380
+ export class TranslationService {
381
+ client;
382
+ pool;
383
+ initialized = false;
384
+ initPromise = null;
385
+ options;
386
+ /**
387
+ * Create a new TranslationService instance
388
+ * @param client TranslationClient instance (or base URL string to create one automatically)
389
+ * @param options Translation service options including senseId and optional fingerprint/cross-tab settings
390
+ */
391
+ constructor(client, options) {
392
+ if (typeof client === 'string') {
393
+ this.client = new TranslationClient(client);
394
+ }
395
+ else {
396
+ this.client = client;
397
+ }
398
+ this.options = options;
399
+ // Configure cross-tab options
400
+ const crossTabOptions = {
401
+ enabled: options.crossTab === true,
402
+ };
403
+ if (options.crossTabChannelName) {
404
+ crossTabOptions.channelName = options.crossTabChannelName;
405
+ }
406
+ if (options.crossTabStorageKeyPrefix) {
407
+ crossTabOptions.storageKeyPrefix = options.crossTabStorageKeyPrefix;
408
+ }
409
+ // Create internal translation pool
410
+ this.pool = new TranslationPool(this.client, options.senseId, crossTabOptions);
411
+ // Set initial fingerprint if provided
412
+ if (options.fingerprint) {
413
+ this.pool['currentFingerprint'] = options.fingerprint;
414
+ }
415
+ }
416
+ /**
417
+ * Ensure the service is initialized (lazy initialization)
418
+ * Called automatically by translate() if needed
419
+ */
420
+ async ensureInitialized() {
421
+ if (this.initialized && !this.pool.isLoading()) {
422
+ return;
423
+ }
424
+ // Use singleton pattern for initialization promise
425
+ if (!this.initPromise) {
426
+ this.initPromise = this.doInitialize();
427
+ }
428
+ await this.initPromise;
429
+ }
430
+ /**
431
+ * Actual initialization logic
432
+ */
433
+ async doInitialize() {
434
+ // Initialize the pool (loads translations via streaming)
435
+ await this.pool.initialize();
436
+ this.initialized = true;
437
+ }
438
+ /**
439
+ * Initialize the translation service - loads all translations from backend
440
+ *
441
+ * This is optional - translate() will automatically initialize if needed.
442
+ * Call this explicitly if you want to preload all translations upfront.
443
+ *
444
+ * Automatically handles cross-tab synchronization if enabled.
445
+ */
446
+ async initialize() {
447
+ await this.ensureInitialized();
448
+ }
449
+ /**
450
+ * Check if service has been initialized
451
+ */
452
+ isInitialized() {
453
+ return this.initialized;
454
+ }
455
+ /**
456
+ * Switch to a different fingerprint (for personalized/custom translations)
457
+ * Automatically loads all translations for this fingerprint
458
+ * @param fingerprint - The fingerprint to switch to
459
+ */
460
+ async switchFingerprint(fingerprint) {
461
+ // Reset initialization state
462
+ this.initialized = false;
463
+ this.initPromise = null;
464
+ await this.pool.switchFingerprint(fingerprint);
465
+ this.initialized = true;
466
+ }
467
+ /**
468
+ * Clear the current fingerprint - frees memory
469
+ */
470
+ clearCurrentFingerprint() {
471
+ this.pool.clearCurrentFingerprint();
472
+ }
473
+ /**
474
+ * Add a custom translation to the current pool
475
+ * @param text - Original text
476
+ * @param translation - Translated text
477
+ */
478
+ addCustomTranslation(text, translation) {
479
+ this.pool.addTranslation(text, translation);
480
+ }
481
+ /**
482
+ * Lookup translation in cache
483
+ * Note: If not initialized, will return not found
484
+ * @param text - Text to lookup
485
+ * @returns Lookup result with found flag and translation if available
486
+ */
487
+ lookup(text) {
488
+ return this.pool.lookup(text);
489
+ }
490
+ /**
491
+ * Translate text with automatic caching
492
+ *
493
+ * Features:
494
+ * - Lazy initialization: automatically initializes on first call
495
+ * - Checks cache first
496
+ * - If not found in cache, requests from backend and caches result
497
+ *
498
+ * @param text - Text to translate
499
+ * @param toLang - Target language (2-letter code)
500
+ * @param fromLang - Optional source language (2-letter code)
501
+ * @param provider - Optional translation provider name
502
+ * @returns Translation response
503
+ */
504
+ async translate(text, toLang, fromLang, provider) {
505
+ // Lazy initialization - ensure initialized before checking cache
506
+ await this.ensureInitialized();
507
+ // Check cache first
508
+ const lookupResult = this.lookup(text);
509
+ if (lookupResult.found) {
510
+ return {
511
+ originalText: text,
512
+ translatedText: lookupResult.translation,
513
+ provider: 'cache',
514
+ timestamp: Date.now(),
515
+ finished: true,
516
+ cached: true,
517
+ fromLang: fromLang,
518
+ toLang: toLang,
519
+ };
520
+ }
521
+ // Not in cache, request from backend
522
+ const senseId = this.getSenseId();
523
+ const response = await this.client.llmTranslate({
524
+ text,
525
+ toLang,
526
+ fromLang,
527
+ provider,
528
+ senseId,
529
+ });
530
+ // Add to cache automatically
531
+ if (response.translatedText) {
532
+ this.pool.addTranslation(text, response.translatedText);
533
+ }
534
+ return response;
535
+ }
536
+ /**
537
+ * Direct translation - bypasses cache and always requests from backend
538
+ * Use this for one-off translations when you don't need caching
539
+ *
540
+ * @param text - Text to translate
541
+ * @param toLang - Target language (2-letter code)
542
+ * @param fromLang - Optional source language (2-letter code)
543
+ * @param provider - Optional translation provider name
544
+ * @returns Translation response
545
+ */
546
+ async translateDirect(text, toLang, fromLang, provider) {
547
+ const senseId = this.getSenseId();
548
+ return this.client.llmTranslate({
549
+ text,
550
+ toLang,
551
+ fromLang,
552
+ provider,
553
+ senseId,
554
+ });
555
+ }
556
+ /**
557
+ * Stream translations from backend in batches
558
+ * Used for bulk loading all translations in a sense
559
+ *
560
+ * @param request - Stream request parameters
561
+ * @param onBatch - Callback for each batch received
562
+ */
563
+ async streamTranslate(request, onBatch) {
564
+ await this.client.translateStream(request, onBatch);
565
+ }
566
+ /**
567
+ * Get the sense ID this service is connected to
568
+ */
569
+ getSenseId() {
570
+ return this.pool.senseId;
571
+ }
572
+ /**
573
+ * Clear all cached data to free memory
574
+ */
575
+ clearAll() {
576
+ this.pool.clearAll();
577
+ }
578
+ /**
579
+ * Destroy the service, close connections and free resources
580
+ * Should be called when the service is no longer needed
581
+ */
582
+ destroy() {
583
+ this.pool.destroy();
584
+ this.client.destroy();
585
+ this.initialized = false;
586
+ this.initPromise = null;
587
+ }
588
+ }
589
+ /**
590
+ * Simple LRU Cache implementation for TranslationClient
591
+ * Uses Map with access order for O(1) get/set operations
592
+ */
593
+ class LRUCache {
594
+ capacity;
595
+ cache;
596
+ constructor(capacity = 1000) {
597
+ this.capacity = capacity;
598
+ this.cache = new Map();
599
+ }
600
+ /**
601
+ * Get value from cache, moves to end (most recently used)
602
+ */
603
+ get(key) {
604
+ if (!this.cache.has(key)) {
605
+ return undefined;
606
+ }
607
+ // Move to end (most recently used)
608
+ const value = this.cache.get(key);
609
+ this.cache.delete(key);
610
+ this.cache.set(key, value);
611
+ return value;
612
+ }
613
+ /**
614
+ * Set value in cache, evicts oldest if over capacity
615
+ */
616
+ set(key, value) {
617
+ // Delete if exists (to move to end)
618
+ if (this.cache.has(key)) {
619
+ this.cache.delete(key);
620
+ }
621
+ // Evict oldest if at capacity
622
+ else if (this.cache.size >= this.capacity) {
623
+ // First key is the oldest (least recently used)
624
+ const oldestKey = this.cache.keys().next().value;
625
+ this.cache.delete(oldestKey);
626
+ }
627
+ this.cache.set(key, value);
628
+ }
629
+ /**
630
+ * Check if key exists in cache
631
+ */
632
+ has(key) {
633
+ return this.cache.has(key);
634
+ }
635
+ /**
636
+ * Delete key from cache
637
+ */
638
+ delete(key) {
639
+ return this.cache.delete(key);
640
+ }
641
+ /**
642
+ * Clear all entries
643
+ */
644
+ clear() {
645
+ this.cache.clear();
646
+ }
647
+ /**
648
+ * Get current cache size
649
+ */
650
+ get size() {
651
+ return this.cache.size;
652
+ }
653
+ }
654
+ /**
655
+ * Cache key generator for translation requests
656
+ */
657
+ function generateCacheKey(request) {
658
+ const parts = [
659
+ request.text,
660
+ request.toLang || '',
661
+ request.fromLang || '',
662
+ request.senseId || '',
663
+ request.provider || ''
664
+ ];
665
+ return parts.join('|');
666
+ }
667
+ const defaultClientCrossTabOptions = {
668
+ enabled: false,
669
+ channelName: 'laker-translation-client-cache',
670
+ storageKey: 'laker_translation_client_cache'
671
+ };
672
+ /**
673
+ * TranslationClient - Low-level gRPC-Web compatible client for TranslationService
674
+ * Uses JSON over HTTP transport for compatibility
675
+ * Includes LRU cache with optional Broadcast Channel + localStorage synchronization
676
+ */
677
+ export class TranslationClient {
678
+ baseUrl;
679
+ token;
680
+ timeout;
681
+ cache;
682
+ cacheEnabled;
683
+ crossTabOptions;
684
+ broadcastChannel = null;
685
+ storageKey;
686
+ /**
687
+ * Create a new TranslationClient
688
+ * @param baseUrl Base URL of the gRPC-Web endpoint, defaults to https://api.hottol.com/laker/
689
+ * @param token JWT authentication token (optional)
690
+ * @param timeout Request timeout in milliseconds (default: 30000)
691
+ * @param cacheSize LRU cache size (default: 1000, set to 0 to disable cache)
692
+ * @param crossTabOptions Cross-tab synchronization options (default: disabled)
693
+ */
694
+ constructor(baseUrl = 'https://api.hottol.com/laker/', token = '', timeout = 30000, cacheSize = 1000, crossTabOptions) {
695
+ this.baseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
696
+ this.token = token;
697
+ this.timeout = timeout;
698
+ this.cacheEnabled = cacheSize > 0;
699
+ this.cache = new LRUCache(cacheSize);
700
+ this.crossTabOptions = { ...defaultClientCrossTabOptions, ...crossTabOptions };
701
+ this.storageKey = this.crossTabOptions.storageKey;
702
+ // Initialize cross-tab synchronization if enabled
703
+ if (this.crossTabOptions.enabled && typeof BroadcastChannel !== 'undefined') {
704
+ this.initCrossTabSync();
705
+ }
706
+ // Load from localStorage if cross-tab enabled
707
+ this.loadFromStorage();
708
+ }
709
+ /**
710
+ * Initialize cross-tab synchronization via Broadcast Channel
711
+ */
712
+ initCrossTabSync() {
713
+ this.broadcastChannel = new BroadcastChannel(this.crossTabOptions.channelName);
714
+ this.broadcastChannel.onmessage = (event) => {
715
+ const message = event.data;
716
+ switch (message.type) {
717
+ case 'cache_update':
718
+ if (message.key && message.data) {
719
+ // Update local cache from another tab's update
720
+ this.cache.set(message.key, message.data);
721
+ }
722
+ break;
723
+ case 'cache_clear':
724
+ this.cache.clear();
725
+ break;
726
+ case 'request_sync':
727
+ // Another tab is requesting our cache, send our data
728
+ this.broadcastFullCache();
729
+ break;
730
+ }
731
+ };
732
+ // Request other tabs to share their cache
733
+ this.broadcastChannel.postMessage({ type: 'request_sync' });
734
+ }
735
+ /**
736
+ * Load cache from localStorage
737
+ */
738
+ loadFromStorage() {
739
+ if (typeof localStorage === 'undefined' || !this.crossTabOptions.enabled) {
740
+ return;
741
+ }
742
+ try {
743
+ const stored = localStorage.getItem(this.storageKey);
744
+ if (stored) {
745
+ const data = JSON.parse(stored);
746
+ data.forEach(({ key, value }) => {
747
+ this.cache.set(key, value);
748
+ });
749
+ }
750
+ }
751
+ catch (e) {
752
+ console.warn('Failed to load translation cache from localStorage:', e);
753
+ }
754
+ }
755
+ /**
756
+ * Save cache to localStorage
757
+ */
758
+ saveToStorage() {
759
+ if (typeof localStorage === 'undefined' || !this.crossTabOptions.enabled) {
760
+ return;
761
+ }
762
+ try {
763
+ const data = this.getAllCacheEntries();
764
+ localStorage.setItem(this.storageKey, JSON.stringify(data));
765
+ }
766
+ catch (e) {
767
+ console.warn('Failed to save translation cache to localStorage:', e);
768
+ }
769
+ }
770
+ /**
771
+ * Get all cache entries for storage/broadcast
772
+ */
773
+ getAllCacheEntries() {
774
+ const result = [];
775
+ // Access internal cache map for iteration
776
+ const cacheMap = this.cache.cache;
777
+ cacheMap.forEach((value, key) => {
778
+ result.push({ key, value });
779
+ });
780
+ return result;
781
+ }
782
+ /**
783
+ * Broadcast full cache to other tabs
784
+ */
785
+ broadcastFullCache() {
786
+ if (!this.broadcastChannel || !this.crossTabOptions.enabled) {
787
+ return;
788
+ }
789
+ const entries = this.getAllCacheEntries();
790
+ entries.forEach(({ key, value }) => {
791
+ this.broadcastChannel.postMessage({
792
+ type: 'cache_update',
793
+ key,
794
+ data: value
795
+ });
796
+ });
797
+ }
798
+ /**
799
+ * Broadcast a single cache update to other tabs
800
+ */
801
+ broadcastCacheUpdate(key, value) {
802
+ if (!this.broadcastChannel || !this.crossTabOptions.enabled) {
803
+ return;
804
+ }
805
+ this.broadcastChannel.postMessage({
806
+ type: 'cache_update',
807
+ key,
808
+ data: value
809
+ });
810
+ this.saveToStorage();
811
+ }
812
+ /**
813
+ * Set or update the JWT authentication token
814
+ * @param token JWT token
815
+ */
816
+ setToken(token) {
817
+ this.token = token;
818
+ }
819
+ /**
820
+ * Enable or disable cache
821
+ * @param enabled Whether to enable cache
822
+ */
823
+ setCacheEnabled(enabled) {
824
+ this.cacheEnabled = enabled;
825
+ }
826
+ /**
827
+ * Clear the translation cache (also clears localStorage and broadcasts to other tabs)
828
+ */
829
+ clearCache() {
830
+ this.cache.clear();
831
+ // Clear localStorage
832
+ if (typeof localStorage !== 'undefined' && this.crossTabOptions.enabled) {
833
+ localStorage.removeItem(this.storageKey);
834
+ }
835
+ // Broadcast clear to other tabs
836
+ if (this.broadcastChannel && this.crossTabOptions.enabled) {
837
+ this.broadcastChannel.postMessage({ type: 'cache_clear' });
838
+ }
839
+ }
840
+ /**
841
+ * Get current cache size
842
+ */
843
+ getCacheSize() {
844
+ return this.cache.size;
845
+ }
846
+ /**
847
+ * Check if cross-tab synchronization is enabled
848
+ */
849
+ isCrossTabEnabled() {
850
+ return this.crossTabOptions.enabled;
851
+ }
852
+ /**
853
+ * Destroy the client, close broadcast channel and free resources
854
+ */
855
+ destroy() {
856
+ if (this.broadcastChannel) {
857
+ this.broadcastChannel.close();
858
+ this.broadcastChannel = null;
859
+ }
860
+ }
861
+ /**
862
+ 凤 * GetSenseTranslate - One-shot unary request with pagination
863
+ * @param request Request parameters
864
+ */
865
+ async getSenseTranslate(request) {
866
+ const url = `${this.baseUrl}/TranslationService/GetSenseTranslate`;
867
+ const response = await this.fetchJson(url, request);
868
+ return response;
869
+ }
870
+ /**
871
+ * TranslateStream - Server streaming, receives multiple batches progressively
872
+ * @param request Request parameters
873
+ * @param onBatch Callback for each batch received. Return false to stop streaming early.
874
+ */
875
+ async translateStream(request, onBatch) {
876
+ const url = `${this.baseUrl}/TranslationService/TranslateStream`;
877
+ // For gRPC-Web streaming over HTTP, we use POST with streaming response
878
+ const response = await this.fetchWithTimeout(url, {
879
+ method: 'POST',
880
+ body: JSON.stringify(request),
881
+ headers: this.getHeaders()
882
+ });
883
+ if (!response.body) {
884
+ throw new Error('No response body for streaming request');
885
+ }
886
+ const reader = response.body.getReader();
887
+ const decoder = new TextDecoder();
888
+ while (true) {
889
+ const { done, value } = await reader.read();
890
+ if (done) {
891
+ break;
892
+ }
893
+ const chunk = decoder.decode(value);
894
+ // Parse each line as a JSON message
895
+ const lines = chunk.split('\n').filter(line => line.trim().length > 0);
896
+ for (const line of lines) {
897
+ try {
898
+ const data = JSON.parse(line);
899
+ const shouldContinue = onBatch(data);
900
+ if (shouldContinue === false) {
901
+ reader.cancel();
902
+ return;
903
+ }
904
+ }
905
+ catch (e) {
906
+ console.warn('Failed to parse streaming chunk:', line, e);
907
+ }
908
+ }
909
+ }
910
+ }
911
+ /**
912
+ * Collect all streaming responses into an array
913
+ * @param request Request parameters
914
+ */
915
+ async translateStreamCollect(request) {
916
+ const result = [];
917
+ await this.translateStream(request, (response) => {
918
+ result.push(response);
919
+ return true;
920
+ });
921
+ return result;
922
+ }
923
+ /**
924
+ * LLMTranslate - One-shot large language model translation
925
+ * Uses LRU cache to avoid repeated requests for the same text
926
+ * With cross-tab enabled, automatically syncs cache across browser tabs
927
+ * @param request Translation request
928
+ * @param skipCache If true, bypass cache and always request from backend
929
+ */
930
+ async llmTranslate(request, skipCache = false) {
931
+ const cacheKey = generateCacheKey(request);
932
+ // Check cache first
933
+ if (this.cacheEnabled && !skipCache) {
934
+ const cached = this.cache.get(cacheKey);
935
+ if (cached) {
936
+ // Return cached response with cached flag set
937
+ return { ...cached, cached: true };
938
+ }
939
+ }
940
+ // Request from backend
941
+ const url = `${this.baseUrl}/TranslationService/LLMTranslate`;
942
+ const response = await this.fetchJson(url, request);
943
+ // Cache the response
944
+ if (this.cacheEnabled && response.translatedText) {
945
+ const cachedResponse = { ...response, cached: true };
946
+ this.cache.set(cacheKey, cachedResponse);
947
+ // Broadcast to other tabs and save to localStorage
948
+ this.broadcastCacheUpdate(cacheKey, cachedResponse);
949
+ }
950
+ return { ...response, cached: false };
951
+ }
952
+ /**
953
+ * LLMTranslateStream - Streaming large language model translation
954
+ * Note: Streaming requests are not cached
955
+ * @param request Translation request
956
+ * @param onResponse Callback for each response chunk
957
+ */
958
+ async llmTranslateStream(request, onResponse) {
959
+ const url = `${this.baseUrl}/TranslationService/LLMTranslateStream`;
960
+ const response = await this.fetchWithTimeout(url, {
961
+ method: 'POST',
962
+ body: JSON.stringify(request),
963
+ headers: this.getHeaders()
964
+ });
965
+ if (!response.body) {
966
+ throw new Error('No response body for streaming request');
967
+ }
968
+ const reader = response.body.getReader();
969
+ const decoder = new TextDecoder();
970
+ while (true) {
971
+ const { done, value } = await reader.read();
972
+ if (done) {
973
+ break;
974
+ }
975
+ const chunk = decoder.decode(value);
976
+ const lines = chunk.split('\n').filter(line => line.trim().length > 0);
977
+ for (const line of lines) {
978
+ try {
979
+ const data = JSON.parse(line);
980
+ const shouldContinue = onResponse(data);
981
+ if (shouldContinue === false) {
982
+ reader.cancel();
983
+ return;
984
+ }
985
+ }
986
+ catch (e) {
987
+ console.warn('Failed to parse streaming chunk:', line, e);
988
+ }
989
+ }
990
+ }
991
+ }
992
+ getHeaders() {
993
+ const headers = {
994
+ 'Content-Type': 'application/grpc-web+json',
995
+ 'X-Grpc-Web': '1'
996
+ };
997
+ if (this.token) {
998
+ headers['Authorization'] = `Bearer ${this.token}`;
999
+ }
1000
+ return headers;
1001
+ }
1002
+ async fetchJson(url, body) {
1003
+ const response = await this.fetchWithTimeout(url, {
1004
+ method: 'POST',
1005
+ body: JSON.stringify(body),
1006
+ headers: this.getHeaders()
1007
+ });
1008
+ if (!response.ok) {
1009
+ throw new Error(`HTTP error! status: ${response.status}`);
1010
+ }
1011
+ return await response.json();
1012
+ }
1013
+ async fetchWithTimeout(url, options) {
1014
+ const controller = new AbortController();
1015
+ const id = setTimeout(() => controller.abort(), this.timeout);
1016
+ const response = await fetch(url, {
1017
+ ...options,
1018
+ signal: controller.signal
1019
+ });
1020
+ clearTimeout(id);
1021
+ return response;
1022
+ }
1023
+ }
1024
+ /**
1025
+ * AppTranslation - Application-level translation interface
1026
+ * Single entry point for all translation needs with automatic initialization,
1027
+ * intelligent caching, and on-demand loading.
1028
+ *
1029
+ * Features:
1030
+ * - Single entry point: just provide token and senseId
1031
+ * - Automatic streaming batch initialization
1032
+ * - On-demand loading when fingerprint changes
1033
+ * - Intelligent translation with cache-first strategy
1034
+ * - Cross-tab synchronization support
1035
+ *
1036
+ * Usage:
1037
+ * ```typescript
1038
+ * // Create instance
1039
+ * const appTranslate = new AppTranslation({
1040
+ * token: 'your-jwt-token',
1041
+ * senseId: 'your-sense-id',
1042
+ * baseUrl: 'https://api.laker.dev'
1043
+ * });
1044
+ *
1045
+ * // Simple translation - auto-initializes
1046
+ * const result = await appTranslate.translate('Hello', 'zh');
1047
+ *
1048
+ * // Switch fingerprint - auto-loads special translations
1049
+ * await appTranslate.setFingerprint('user-123');
1050
+ *
1051
+ * // Destroy when done
1052
+ * appTranslate.destroy();
1053
+ * ```
1054
+ */
1055
+ export class AppTranslation {
1056
+ client;
1057
+ service;
1058
+ config;
1059
+ currentFingerprint = null;
1060
+ useCache;
1061
+ /**
1062
+ * Create a new AppTranslation instance
1063
+ * @param config Configuration options
1064
+ */
1065
+ constructor(config) {
1066
+ this.config = config;
1067
+ // Default to using cache unless explicitly disabled
1068
+ this.useCache = config.useCache !== false;
1069
+ // If cache is disabled, set cacheSize to 0
1070
+ const cacheSize = this.useCache ? (config.cacheSize || 1000) : 0;
1071
+ this.client = new TranslationClient(config.baseUrl || 'https://api.hottol.com/laker/', config.token, config.timeout || 30000, cacheSize, { enabled: config.crossTab ?? false });
1072
+ this.service = new TranslationService(this.client, {
1073
+ senseId: config.senseId,
1074
+ fingerprint: config.fingerprint,
1075
+ crossTab: config.crossTab ?? false
1076
+ });
1077
+ if (config.fingerprint) {
1078
+ this.currentFingerprint = config.fingerprint;
1079
+ }
1080
+ }
1081
+ /**
1082
+ * Update the authentication token
1083
+ * @param token New JWT token
1084
+ */
1085
+ setToken(token) {
1086
+ this.client.setToken(token);
1087
+ }
1088
+ /**
1089
+ * Set or change the current fingerprint
1090
+ * Automatically loads special translations for this fingerprint
1091
+ * @param fingerprint The fingerprint to use
1092
+ */
1093
+ async setFingerprint(fingerprint) {
1094
+ if (this.currentFingerprint === fingerprint) {
1095
+ return;
1096
+ }
1097
+ this.currentFingerprint = fingerprint;
1098
+ await this.service.switchFingerprint(fingerprint);
1099
+ }
1100
+ /**
1101
+ * Clear the current fingerprint
1102
+ * Falls back to common translations
1103
+ */
1104
+ clearFingerprint() {
1105
+ this.currentFingerprint = null;
1106
+ this.service.clearCurrentFingerprint();
1107
+ }
1108
+ /**
1109
+ * Get the current fingerprint
1110
+ */
1111
+ getFingerprint() {
1112
+ return this.currentFingerprint;
1113
+ }
1114
+ /**
1115
+ * Translate text with automatic caching and initialization
1116
+ *
1117
+ * - First call initializes the service automatically
1118
+ * - Checks cache first (fingerprint-specific → common)
1119
+ * - Falls back to LLM translation if not found
1120
+ * - Caches result automatically (unless useCache: false)
1121
+ *
1122
+ * @param text Text to translate
1123
+ * @param toLang Target language code (e.g., 'zh', 'en')
1124
+ * @param fromLang Optional source language code (auto-detected if not provided)
1125
+ * @returns Translation result
1126
+ */
1127
+ async translate(text, toLang, fromLang) {
1128
+ const response = await this.service.translate(text, toLang, fromLang);
1129
+ return response.translatedText;
1130
+ }
1131
+ /**
1132
+ * Translate text with full response details
1133
+ * @param text Text to translate
1134
+ * @param toLang Target language code
1135
+ * @param fromLang Optional source language code
1136
+ * @returns Full translation response
1137
+ */
1138
+ async translateWithDetails(text, toLang, fromLang) {
1139
+ return this.service.translate(text, toLang, fromLang);
1140
+ }
1141
+ /**
1142
+ * Translate text without using cache (always request from backend)
1143
+ * @param text Text to translate
1144
+ * @param toLang Target language code
1145
+ * @param fromLang Optional source language code
1146
+ * @returns Translation result
1147
+ */
1148
+ async translateNoCache(text, toLang, fromLang) {
1149
+ const response = await this.service.translateDirect(text, toLang, fromLang);
1150
+ return response.translatedText;
1151
+ }
1152
+ /**
1153
+ * Batch translate multiple texts
1154
+ * @param texts Array of texts to translate
1155
+ * @param toLang Target language code
1156
+ * @param fromLang Optional source language code
1157
+ * @returns Array of translated texts in same order
1158
+ */
1159
+ async translateBatch(texts, toLang, fromLang) {
1160
+ const results = await Promise.all(texts.map(text => this.translate(text, toLang, fromLang)));
1161
+ return results;
1162
+ }
1163
+ /**
1164
+ * Check if cache is enabled
1165
+ * @returns true if cache is enabled
1166
+ */
1167
+ isCacheEnabled() {
1168
+ return this.useCache;
1169
+ }
1170
+ /**
1171
+ * Check if a translation exists in cache
1172
+ * @param text Text to check
1173
+ * @returns true if translation exists in cache
1174
+ */
1175
+ hasTranslation(text) {
1176
+ if (!this.useCache) {
1177
+ return false;
1178
+ }
1179
+ return this.service.lookup(text).found;
1180
+ }
1181
+ /**
1182
+ * Get translation from cache without requesting from backend
1183
+ * @param text Text to look up
1184
+ * @returns Translation if found, null otherwise
1185
+ */
1186
+ getCached(text) {
1187
+ if (!this.useCache) {
1188
+ return null;
1189
+ }
1190
+ const result = this.service.lookup(text);
1191
+ return result.found ? result.translation : null;
1192
+ }
1193
+ /**
1194
+ * Add a custom translation to the cache
1195
+ * @param text Original text
1196
+ * @param translation Translated text
1197
+ */
1198
+ addTranslation(text, translation) {
1199
+ if (this.useCache) {
1200
+ this.service.addCustomTranslation(text, translation);
1201
+ }
1202
+ }
1203
+ /**
1204
+ * Preload all translations for current context
1205
+ * Call this to warm up the cache before translating
1206
+ */
1207
+ async preload() {
1208
+ if (this.useCache) {
1209
+ await this.service.initialize();
1210
+ }
1211
+ }
1212
+ /**
1213
+ * Check if the service is initialized
1214
+ */
1215
+ isInitialized() {
1216
+ return this.service.isInitialized();
1217
+ }
1218
+ /**
1219
+ * Clear all cached translations
1220
+ */
1221
+ clearCache() {
1222
+ this.service.clearAll();
1223
+ this.client.clearCache();
1224
+ }
1225
+ /**
1226
+ * Destroy the instance and free resources
1227
+ * Call this when the instance is no longer needed
1228
+ */
1229
+ destroy() {
1230
+ this.service.destroy();
1231
+ }
1232
+ }
1233
+ /**
1234
+ * Create an AppTranslation instance with simplified configuration
1235
+ * @param token JWT authentication token
1236
+ * @param senseId Translation sense ID
1237
+ * @param options Additional options
1238
+ * @returns AppTranslation instance
1239
+ */
1240
+ export function createTranslation(token, senseId, options) {
1241
+ return new AppTranslation({
1242
+ token,
1243
+ senseId,
1244
+ ...options
1245
+ });
1246
+ }
1247
+ export default TranslationClient;