@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.
- package/package.json +28 -0
- package/translation-client.d.ts +502 -0
- package/translation-client.js +1247 -0
|
@@ -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;
|