@alaikis/translation-sdk 1.2.2 → 1.2.3
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/README.md +195 -0
- package/package.json +4 -1
- package/translation-client.d.ts +13 -4
- package/translation-client.js +1424 -1163
package/translation-client.js
CHANGED
|
@@ -1,903 +1,1016 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
function __awaiter(thisArg, _arguments, P, generator) {
|
|
25
|
-
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
26
|
-
return new (P || (P = Promise))(function (resolve, reject) {
|
|
27
|
-
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
28
|
-
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
29
|
-
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
30
|
-
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
31
|
-
});
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
|
|
35
|
-
var e = new Error(message);
|
|
36
|
-
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Translation Service gRPC-Web TypeScript/JavaScript Client
|
|
41
|
-
*
|
|
42
|
-
* Auto-generated for api.laker.dev Translation Service
|
|
43
|
-
* Service: TranslationService
|
|
44
|
-
* Source: proto/translation.proto
|
|
45
|
-
*/
|
|
46
|
-
const defaultCrossTabOptions = {
|
|
47
|
-
enabled: false,
|
|
48
|
-
channelName: 'laker-translation-cache',
|
|
49
|
-
storageKeyPrefix: 'laker_translation_'
|
|
50
|
-
};
|
|
51
|
-
/**
|
|
52
|
-
* Automatic template extraction from text containing numeric variables
|
|
53
|
-
* @param text Original text that may contain numeric variables
|
|
54
|
-
* @returns Template extraction result
|
|
55
|
-
*/
|
|
56
|
-
function extractTemplate(text) {
|
|
57
|
-
// Regex to find numbers in text
|
|
58
|
-
const numberRegex = /\d+(?:\.\d+)?/g;
|
|
59
|
-
const matches = text.match(numberRegex);
|
|
60
|
-
if (!matches || matches.length === 0) {
|
|
61
|
-
return {
|
|
62
|
-
isTemplated: false,
|
|
63
|
-
srcTemplate: text,
|
|
64
|
-
dstTemplate: '',
|
|
65
|
-
variables: []
|
|
66
|
-
};
|
|
67
|
-
}
|
|
68
|
-
let template = text;
|
|
69
|
-
const variables = [];
|
|
70
|
-
matches.forEach((match, index) => {
|
|
71
|
-
const varName = `{var${index + 1}}`;
|
|
72
|
-
template = template.replace(match, varName);
|
|
73
|
-
variables.push(match);
|
|
74
|
-
});
|
|
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) {
|
|
75
23
|
return {
|
|
76
|
-
isTemplated:
|
|
77
|
-
srcTemplate:
|
|
24
|
+
isTemplated: false,
|
|
25
|
+
srcTemplate: text,
|
|
78
26
|
dstTemplate: '',
|
|
79
|
-
variables
|
|
27
|
+
variables: []
|
|
80
28
|
};
|
|
81
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 - Multi-fingerprint translation cache with automatic common preloading
|
|
46
|
+
*
|
|
47
|
+
* Architecture:
|
|
48
|
+
* - pools: Map of fingerprint -> Map<text, translation> (each fingerprint has independent cache)
|
|
49
|
+
* - currentFingerprint: current active fingerprint for special translations
|
|
50
|
+
* - common is always loaded and cached forever, never cleared unless full clear happens
|
|
51
|
+
* - Optional cross-tab synchronization via Broadcast Channel and localStorage
|
|
52
|
+
*
|
|
53
|
+
* Rules:
|
|
54
|
+
* - common translations are always loaded on initialization and cached forever
|
|
55
|
+
* - If fingerprint exists, load special translations for that fingerprint
|
|
56
|
+
* - Switching fingerprints doesn't clear cached data for other fingerprints
|
|
57
|
+
* - Lookup priority: current fingerprint first, common second
|
|
58
|
+
* - All translations are cached independently by fingerprint
|
|
59
|
+
*/
|
|
60
|
+
class TranslationPool {
|
|
82
61
|
/**
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
if (
|
|
118
|
-
this.initCrossTabSync();
|
|
119
|
-
}
|
|
120
|
-
// Load from localStorage if cross-tab enabled and storage available
|
|
121
|
-
this.loadFromStorage();
|
|
122
|
-
}
|
|
123
|
-
/**
|
|
124
|
-
* Initialize cross-tab synchronization
|
|
125
|
-
*/
|
|
126
|
-
initCrossTabSync() {
|
|
127
|
-
this.broadcastChannel = new BroadcastChannel(this.crossTabOptions.channelName);
|
|
128
|
-
this.broadcastChannel.onmessage = (event) => {
|
|
129
|
-
const message = event.data;
|
|
130
|
-
// Ignore messages for other senses
|
|
131
|
-
if (message.senseId !== this.senseId) {
|
|
132
|
-
return;
|
|
133
|
-
}
|
|
134
|
-
switch (message.type) {
|
|
135
|
-
case 'cache_update':
|
|
136
|
-
this.handleCacheUpdate(message);
|
|
137
|
-
break;
|
|
138
|
-
case 'cache_clear':
|
|
139
|
-
this.handleCacheClear(message);
|
|
140
|
-
break;
|
|
141
|
-
case 'request_initial_sync':
|
|
142
|
-
this.handleInitialSyncRequest();
|
|
143
|
-
break;
|
|
144
|
-
}
|
|
145
|
-
};
|
|
146
|
-
// Request other tabs to share their cache
|
|
147
|
-
this.broadcastChannel.postMessage({
|
|
148
|
-
type: 'request_initial_sync',
|
|
149
|
-
senseId: this.senseId
|
|
150
|
-
});
|
|
151
|
-
}
|
|
152
|
-
/**
|
|
153
|
-
* Load cache from localStorage
|
|
154
|
-
*/
|
|
155
|
-
loadFromStorage() {
|
|
156
|
-
if (typeof localStorage === 'undefined' || !this.crossTabOptions.enabled) {
|
|
157
|
-
return;
|
|
158
|
-
}
|
|
159
|
-
// Always load common first
|
|
160
|
-
this.loadFingerprintFromStorage('common');
|
|
161
|
-
// Load current fingerprint if exists
|
|
162
|
-
if (this.currentFingerprint) {
|
|
163
|
-
this.loadFingerprintFromStorage(this.currentFingerprint);
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
/**
|
|
167
|
-
* Load a specific fingerprint's cache from localStorage
|
|
168
|
-
*/
|
|
169
|
-
loadFingerprintFromStorage(fp) {
|
|
170
|
-
const storageKey = this.getStorageKey(fp);
|
|
171
|
-
try {
|
|
172
|
-
const stored = localStorage.getItem(storageKey);
|
|
173
|
-
if (stored) {
|
|
174
|
-
const data = JSON.parse(stored);
|
|
175
|
-
let pool = this.pools.get(fp);
|
|
176
|
-
if (!pool) {
|
|
177
|
-
pool = new Map();
|
|
178
|
-
this.pools.set(fp, pool);
|
|
179
|
-
}
|
|
180
|
-
data.forEach(({ text, translation }) => {
|
|
181
|
-
pool.set(text, translation);
|
|
182
|
-
});
|
|
183
|
-
this.loadedFingerprints.add(fp);
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
catch (e) {
|
|
187
|
-
console.warn('Failed to load translation cache from localStorage:', e);
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
/**
|
|
191
|
-
* Get storage key for a specific fingerprint
|
|
192
|
-
*/
|
|
193
|
-
getStorageKey(fp) {
|
|
194
|
-
return `${this.crossTabOptions.storageKeyPrefix}${this.senseId}_${fp}`;
|
|
195
|
-
}
|
|
196
|
-
/**
|
|
197
|
-
* Save cache to localStorage
|
|
198
|
-
*/
|
|
199
|
-
saveToStorage(fp) {
|
|
200
|
-
if (typeof localStorage === 'undefined' || !this.crossTabOptions.enabled) {
|
|
62
|
+
* Create a new TranslationPool for a specific sense
|
|
63
|
+
* @param client TranslationClient instance
|
|
64
|
+
* @param senseId The semantic sense ID
|
|
65
|
+
* @param crossTabOptions Cross-tab synchronization options
|
|
66
|
+
*/
|
|
67
|
+
constructor(client, senseId, crossTabOptions) {
|
|
68
|
+
// Separate cache for each fingerprint: fingerprint -> Map<text, translation>
|
|
69
|
+
this.pools = new Map();
|
|
70
|
+
this.currentFingerprint = null;
|
|
71
|
+
this.broadcastChannel = null;
|
|
72
|
+
this.loading = false;
|
|
73
|
+
this.loadedFingerprints = new Set();
|
|
74
|
+
// Observer pattern for queueing translation requests during load
|
|
75
|
+
this.queuedRequests = [];
|
|
76
|
+
this.pendingResolutions = {};
|
|
77
|
+
this.client = client;
|
|
78
|
+
this.senseId = senseId;
|
|
79
|
+
this.crossTabOptions = Object.assign(Object.assign({}, defaultCrossTabOptions), crossTabOptions);
|
|
80
|
+
// Initialize common pool always
|
|
81
|
+
this.pools.set('common', new Map());
|
|
82
|
+
if (this.crossTabOptions.enabled && typeof BroadcastChannel !== 'undefined') {
|
|
83
|
+
this.initCrossTabSync();
|
|
84
|
+
}
|
|
85
|
+
// Load from localStorage if cross-tab enabled and storage available
|
|
86
|
+
this.loadFromStorage();
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Initialize cross-tab synchronization
|
|
90
|
+
*/
|
|
91
|
+
initCrossTabSync() {
|
|
92
|
+
this.broadcastChannel = new BroadcastChannel(this.crossTabOptions.channelName);
|
|
93
|
+
this.broadcastChannel.onmessage = (event) => {
|
|
94
|
+
const message = event.data;
|
|
95
|
+
// Ignore messages for other senses
|
|
96
|
+
if (message.senseId !== this.senseId) {
|
|
201
97
|
return;
|
|
202
98
|
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
}
|
|
214
|
-
catch (e) {
|
|
215
|
-
console.warn('Failed to save translation cache to localStorage:', e);
|
|
99
|
+
switch (message.type) {
|
|
100
|
+
case 'cache_update':
|
|
101
|
+
this.handleCacheUpdate(message);
|
|
102
|
+
break;
|
|
103
|
+
case 'cache_clear':
|
|
104
|
+
this.handleCacheClear(message);
|
|
105
|
+
break;
|
|
106
|
+
case 'request_initial_sync':
|
|
107
|
+
this.handleInitialSyncRequest();
|
|
108
|
+
break;
|
|
216
109
|
}
|
|
110
|
+
};
|
|
111
|
+
// Request other tabs to share their cache
|
|
112
|
+
this.broadcastChannel.postMessage({
|
|
113
|
+
type: 'request_initial_sync',
|
|
114
|
+
senseId: this.senseId
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Load cache from localStorage
|
|
119
|
+
*/
|
|
120
|
+
loadFromStorage() {
|
|
121
|
+
if (typeof localStorage === 'undefined' || !this.crossTabOptions.enabled) {
|
|
122
|
+
return;
|
|
217
123
|
}
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
return;
|
|
224
|
-
}
|
|
225
|
-
const fp = this.currentFingerprint || 'common';
|
|
226
|
-
const message = {
|
|
227
|
-
type: 'cache_update',
|
|
228
|
-
senseId: this.senseId,
|
|
229
|
-
fingerprint: this.currentFingerprint || undefined,
|
|
230
|
-
data: Object.assign({ result: this.getAllForFingerprint(fp) }, (text && translation && { text, translation }))
|
|
231
|
-
};
|
|
232
|
-
this.broadcastChannel.postMessage(message);
|
|
233
|
-
this.saveToStorage(fp);
|
|
124
|
+
// Always load common first
|
|
125
|
+
this.loadFingerprintFromStorage('common');
|
|
126
|
+
// Load current fingerprint if exists
|
|
127
|
+
if (this.currentFingerprint) {
|
|
128
|
+
this.loadFingerprintFromStorage(this.currentFingerprint);
|
|
234
129
|
}
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Load a specific fingerprint's cache from localStorage
|
|
133
|
+
*/
|
|
134
|
+
loadFingerprintFromStorage(fp) {
|
|
135
|
+
const storageKey = this.getStorageKey(fp);
|
|
136
|
+
try {
|
|
137
|
+
const stored = localStorage.getItem(storageKey);
|
|
138
|
+
if (stored) {
|
|
139
|
+
const data = JSON.parse(stored);
|
|
243
140
|
let pool = this.pools.get(fp);
|
|
244
141
|
if (!pool) {
|
|
245
142
|
pool = new Map();
|
|
246
143
|
this.pools.set(fp, pool);
|
|
247
144
|
}
|
|
248
|
-
|
|
249
|
-
message.data.result.forEach(({ text, translation }) => {
|
|
145
|
+
data.forEach(({ text, translation }) => {
|
|
250
146
|
pool.set(text, translation);
|
|
251
147
|
});
|
|
252
148
|
this.loadedFingerprints.add(fp);
|
|
253
149
|
}
|
|
254
|
-
// Update specific entry
|
|
255
|
-
if (((_b = message.data) === null || _b === void 0 ? void 0 : _b.text) && ((_c = message.data) === null || _c === void 0 ? void 0 : _c.translation)) {
|
|
256
|
-
const pool = this.pools.get(fp) || new Map();
|
|
257
|
-
pool.set(message.data.text, message.data.translation);
|
|
258
|
-
this.pools.set(fp, pool);
|
|
259
|
-
this.saveToStorage(fp);
|
|
260
|
-
}
|
|
261
150
|
}
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
151
|
+
catch (e) {
|
|
152
|
+
console.warn('Failed to load translation cache from localStorage:', e);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Get storage key for a specific fingerprint
|
|
157
|
+
*/
|
|
158
|
+
getStorageKey(fp) {
|
|
159
|
+
return `${this.crossTabOptions.storageKeyPrefix}${this.senseId}_${fp}`;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Save cache to localStorage
|
|
163
|
+
*/
|
|
164
|
+
saveToStorage(fp) {
|
|
165
|
+
if (typeof localStorage === 'undefined' || !this.crossTabOptions.enabled) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const storageKey = this.getStorageKey(fp);
|
|
169
|
+
try {
|
|
170
|
+
const pool = this.pools.get(fp);
|
|
171
|
+
const data = [];
|
|
172
|
+
if (pool) {
|
|
173
|
+
pool.forEach((translation, text) => {
|
|
174
|
+
data.push({ text, translation });
|
|
175
|
+
});
|
|
269
176
|
}
|
|
177
|
+
localStorage.setItem(storageKey, JSON.stringify(data));
|
|
270
178
|
}
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
*/
|
|
274
|
-
handleInitialSyncRequest() {
|
|
275
|
-
// Send our current cache to the new tab
|
|
276
|
-
this.broadcastUpdate();
|
|
179
|
+
catch (e) {
|
|
180
|
+
console.warn('Failed to save translation cache to localStorage:', e);
|
|
277
181
|
}
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Broadcast cache update to all other tabs
|
|
185
|
+
*/
|
|
186
|
+
broadcastUpdate(text, translation) {
|
|
187
|
+
if (!this.broadcastChannel || !this.crossTabOptions.enabled) {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const fp = this.currentFingerprint || 'common';
|
|
191
|
+
const message = {
|
|
192
|
+
type: 'cache_update',
|
|
193
|
+
senseId: this.senseId,
|
|
194
|
+
fingerprint: this.currentFingerprint || undefined,
|
|
195
|
+
data: Object.assign({ result: this.getAllForFingerprint(fp) }, (text && translation && { text, translation }))
|
|
196
|
+
};
|
|
197
|
+
this.broadcastChannel.postMessage(message);
|
|
198
|
+
this.saveToStorage(fp);
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Handle incoming cache update from another tab
|
|
202
|
+
*/
|
|
203
|
+
handleCacheUpdate(message) {
|
|
204
|
+
var _a, _b, _c;
|
|
205
|
+
const fp = message.fingerprint || 'common';
|
|
206
|
+
if ((_a = message.data) === null || _a === void 0 ? void 0 : _a.result) {
|
|
207
|
+
// Update full cache for this fingerprint
|
|
208
|
+
let pool = this.pools.get(fp);
|
|
209
|
+
if (!pool) {
|
|
210
|
+
pool = new Map();
|
|
211
|
+
this.pools.set(fp, pool);
|
|
212
|
+
}
|
|
213
|
+
pool.clear();
|
|
214
|
+
message.data.result.forEach(({ text, translation }) => {
|
|
215
|
+
pool.set(text, translation);
|
|
309
216
|
});
|
|
217
|
+
this.loadedFingerprints.add(fp);
|
|
310
218
|
}
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
if (this.loadedFingerprints.has(fp)) {
|
|
318
|
-
return;
|
|
319
|
-
}
|
|
320
|
-
// Ensure pool exists for this fingerprint
|
|
321
|
-
let pool = this.pools.get(fp);
|
|
322
|
-
if (!pool) {
|
|
323
|
-
pool = new Map();
|
|
324
|
-
this.pools.set(fp, pool);
|
|
325
|
-
}
|
|
326
|
-
// Use streaming for batch loading
|
|
327
|
-
yield this.client.translateStream({
|
|
328
|
-
senseId: this.senseId,
|
|
329
|
-
fingerprint,
|
|
330
|
-
batchSize: 500
|
|
331
|
-
}, (response) => {
|
|
332
|
-
// Add all translations from this batch to the fingerprint's pool
|
|
333
|
-
response.translations.forEach(record => {
|
|
334
|
-
pool.set(record.text, record.translate);
|
|
335
|
-
});
|
|
336
|
-
return true; // Continue streaming
|
|
337
|
-
});
|
|
338
|
-
// Mark as loaded
|
|
339
|
-
this.loadedFingerprints.add(fp);
|
|
340
|
-
});
|
|
219
|
+
// Update specific entry
|
|
220
|
+
if (((_b = message.data) === null || _b === void 0 ? void 0 : _b.text) && ((_c = message.data) === null || _c === void 0 ? void 0 : _c.translation)) {
|
|
221
|
+
const pool = this.pools.get(fp) || new Map();
|
|
222
|
+
pool.set(message.data.text, message.data.translation);
|
|
223
|
+
this.pools.set(fp, pool);
|
|
224
|
+
this.saveToStorage(fp);
|
|
341
225
|
}
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Handle incoming cache clear from another tab
|
|
229
|
+
*/
|
|
230
|
+
handleCacheClear(message) {
|
|
231
|
+
const fp = message.fingerprint || 'common';
|
|
232
|
+
if (fp) {
|
|
233
|
+
this.clearFingerprint(fp);
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
this.clearAll();
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Handle initial sync request from a new tab
|
|
241
|
+
*/
|
|
242
|
+
handleInitialSyncRequest() {
|
|
243
|
+
// Send our current cache to the new tab
|
|
244
|
+
this.broadcastUpdate();
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Queue a translation request and return original text immediately
|
|
248
|
+
* This is used during pool loading when translations are not yet available
|
|
249
|
+
* @param request Translation request to queue
|
|
250
|
+
* @returns Original text as the initial response
|
|
251
|
+
*/
|
|
252
|
+
queueTranslationRequest(request) {
|
|
253
|
+
// Return original text immediately for fast fallback
|
|
254
|
+
const initialResponse = {
|
|
255
|
+
originalText: request.text,
|
|
256
|
+
translatedText: request.text,
|
|
257
|
+
provider: 'fast_fallback',
|
|
258
|
+
timestamp: Date.now(),
|
|
259
|
+
finished: true,
|
|
260
|
+
cached: false,
|
|
261
|
+
fromLang: request.fromLang,
|
|
262
|
+
toLang: request.toLang
|
|
263
|
+
};
|
|
264
|
+
// Resolve immediately with original text
|
|
265
|
+
const promise = new Promise((resolve) => {
|
|
266
|
+
resolve(initialResponse);
|
|
267
|
+
});
|
|
268
|
+
// Queue the request for processing after pool loads
|
|
269
|
+
const queuedReq = Object.assign(Object.assign({}, request), { resolveFunction: (result) => {
|
|
270
|
+
// Remove from pending resolutions
|
|
271
|
+
delete this.pendingResolutions[`${request.text}-${request.fingerprint || 'common'}`];
|
|
272
|
+
// Resolve the promise for all listeners
|
|
273
|
+
queuedReq.resolveFunction(result);
|
|
274
|
+
}, rejectFunction: (error) => {
|
|
275
|
+
delete this.pendingResolutions[`${request.text}-${request.fingerprint || 'common'}`];
|
|
276
|
+
queuedReq.rejectFunction(error);
|
|
277
|
+
} });
|
|
278
|
+
this.queuedRequests.push(queuedReq);
|
|
279
|
+
// Store reference to allow later resolution
|
|
280
|
+
const key = `${request.text}-${request.fingerprint || 'common'}`;
|
|
281
|
+
this.pendingResolutions[key] = {
|
|
282
|
+
resolve: (result) => queuedReq.resolveFunction(result),
|
|
283
|
+
reject: (error) => queuedReq.rejectFunction(error)
|
|
284
|
+
};
|
|
285
|
+
// Return original text immediately
|
|
286
|
+
return promise;
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Process all queued translation requests after pool is loaded
|
|
290
|
+
* This should be called when the pool is fully initialized
|
|
291
|
+
* Includes automatic retry mechanism for failed requests
|
|
292
|
+
*/
|
|
293
|
+
async processQueuedRequests(maxRetries = 3, retryDelayMs = 1000) {
|
|
294
|
+
if (this.queuedRequests.length === 0) {
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
console.log(`[TranslationPool] Processing ${this.queuedRequests.length} queued translation requests...`);
|
|
298
|
+
// Copy queued requests and clear the queue
|
|
299
|
+
const requestsToProcess = [...this.queuedRequests];
|
|
300
|
+
this.queuedRequests = [];
|
|
301
|
+
// Process each request with retry mechanism
|
|
302
|
+
const processWithRetry = async (req, attempt = 0) => {
|
|
303
|
+
try {
|
|
304
|
+
// Check if translation is now available in pool
|
|
305
|
+
const lookup = this.lookup(req.text, req.fingerprint);
|
|
306
|
+
if (lookup.found) {
|
|
307
|
+
// Translation is now available in pool, use it
|
|
308
|
+
const translation = await this.client.translate(req.text, req.toLang, req.fromLang, req.fingerprint);
|
|
309
|
+
return { text: req.text, translation, success: true };
|
|
353
310
|
}
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
yield this.loadFingerprintTranslations(fingerprint, fingerprint);
|
|
311
|
+
else {
|
|
312
|
+
// Not in pool, request from backend
|
|
313
|
+
const response = await this.client.translateWithDetails(req.text, req.toLang, req.fromLang, req.fingerprint);
|
|
314
|
+
return { text: req.text, translation: response.translatedText, success: true };
|
|
359
315
|
}
|
|
360
|
-
}
|
|
316
|
+
}
|
|
317
|
+
catch (error) {
|
|
318
|
+
console.warn(`[TranslationPool] Request failed (attempt ${attempt + 1}/${maxRetries}):`, error.message);
|
|
319
|
+
if (attempt < maxRetries - 1) {
|
|
320
|
+
// Wait before retry
|
|
321
|
+
await new Promise(resolve => setTimeout(resolve, retryDelayMs * (attempt + 1)));
|
|
322
|
+
return processWithRetry(req, attempt + 1);
|
|
323
|
+
}
|
|
324
|
+
// All retries failed, return original text as fallback
|
|
325
|
+
console.error(`[TranslationPool] All retries failed for: "${req.text}", using original text`);
|
|
326
|
+
return { text: req.text, translation: req.text, success: false };
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
// Process all requests in parallel
|
|
330
|
+
const promises = requestsToProcess.map(req => processWithRetry(req));
|
|
331
|
+
const results = await Promise.all(promises);
|
|
332
|
+
// Add successful translations to pool
|
|
333
|
+
const successCount = results.filter(r => r.success).length;
|
|
334
|
+
const failCount = results.length - successCount;
|
|
335
|
+
results.forEach(({ text, translation }) => {
|
|
336
|
+
this.addTranslation(text, translation);
|
|
337
|
+
});
|
|
338
|
+
// Broadcast updates to other tabs
|
|
339
|
+
this.broadcastUpdate();
|
|
340
|
+
console.log(`[TranslationPool] Completed processing ${results.length} queued requests (${successCount} success, ${failCount} failed)`);
|
|
341
|
+
// If there were failures, log them
|
|
342
|
+
if (failCount > 0) {
|
|
343
|
+
console.warn(`[TranslationPool] ${failCount} requests failed after ${maxRetries} retries`);
|
|
361
344
|
}
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Check if there are any pending queued requests
|
|
348
|
+
*/
|
|
349
|
+
hasQueuedRequests() {
|
|
350
|
+
return this.queuedRequests.length > 0;
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Clear all queued requests (should be called when pool is being cleared)
|
|
354
|
+
*/
|
|
355
|
+
clearQueuedRequests() {
|
|
356
|
+
console.log(`Clearing ${this.queuedRequests.length} queued requests`);
|
|
357
|
+
this.queuedRequests = [];
|
|
358
|
+
this.pendingResolutions = {};
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Check if currently loading
|
|
362
|
+
*/
|
|
363
|
+
isLoading() {
|
|
364
|
+
return this.loading;
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Initialize the pool - always loads common first, then loads current fingerprint if set
|
|
368
|
+
* If fingerprint is set, loads special translations; common is always preloaded
|
|
369
|
+
* After loading, processes any queued translation requests
|
|
370
|
+
*/
|
|
371
|
+
async initialize() {
|
|
372
|
+
if (this.loading) {
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
this.loading = true;
|
|
376
|
+
try {
|
|
377
|
+
console.log('[TranslationPool] Starting pool initialization...');
|
|
378
|
+
// Always preload common first (required)
|
|
379
|
+
if (!this.loadedFingerprints.has('common')) {
|
|
380
|
+
await this.loadFingerprintTranslations('common', undefined);
|
|
381
|
+
console.log('[TranslationPool] Common translations loaded');
|
|
374
382
|
}
|
|
383
|
+
// Then load current fingerprint if set and not loaded
|
|
384
|
+
if (this.currentFingerprint && !this.loadedFingerprints.has(this.currentFingerprint)) {
|
|
385
|
+
await this.loadFingerprintTranslations(this.currentFingerprint, this.currentFingerprint);
|
|
386
|
+
console.log(`[TranslationPool] ${this.currentFingerprint} translations loaded`);
|
|
387
|
+
}
|
|
388
|
+
// Broadcast to other tabs after full initialization
|
|
389
|
+
this.broadcastUpdate();
|
|
390
|
+
console.log('[TranslationPool] Pool initialization completed');
|
|
391
|
+
// Process queued requests after pool is loaded
|
|
392
|
+
await this.processQueuedRequests();
|
|
375
393
|
}
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
394
|
+
finally {
|
|
395
|
+
this.loading = false;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Load translations for a specific fingerprint
|
|
400
|
+
*/
|
|
401
|
+
async loadFingerprintTranslations(fp, fingerprint) {
|
|
402
|
+
// Already loaded
|
|
403
|
+
if (this.loadedFingerprints.has(fp)) {
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
// Ensure pool exists for this fingerprint
|
|
407
|
+
let pool = this.pools.get(fp);
|
|
408
|
+
if (!pool) {
|
|
409
|
+
pool = new Map();
|
|
410
|
+
this.pools.set(fp, pool);
|
|
411
|
+
}
|
|
412
|
+
// Use streaming for batch loading
|
|
413
|
+
await this.client.translateStream({
|
|
414
|
+
senseId: this.senseId,
|
|
415
|
+
fingerprint,
|
|
416
|
+
batchSize: 500
|
|
417
|
+
}, (response) => {
|
|
418
|
+
// Add all translations from this batch to the fingerprint's pool
|
|
419
|
+
// response.translation is a Record<string, string> (key-value map)
|
|
420
|
+
Object.entries(response.translation).forEach(([text, translate]) => {
|
|
421
|
+
pool.set(text, translate);
|
|
398
422
|
});
|
|
423
|
+
return true; // Continue streaming
|
|
424
|
+
});
|
|
425
|
+
// Mark as loaded
|
|
426
|
+
this.loadedFingerprints.add(fp);
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Switch to a different fingerprint, loads its translations if not already loaded
|
|
430
|
+
* Doesn't clear existing cached translations for other fingerprints
|
|
431
|
+
* @param fingerprint The fingerprint to switch to
|
|
432
|
+
*/
|
|
433
|
+
async switchFingerprint(fingerprint) {
|
|
434
|
+
this.currentFingerprint = fingerprint;
|
|
435
|
+
// Ensure pool exists
|
|
436
|
+
if (!this.pools.has(fingerprint)) {
|
|
437
|
+
this.pools.set(fingerprint, new Map());
|
|
438
|
+
}
|
|
439
|
+
// Load from localStorage first
|
|
440
|
+
this.loadFingerprintFromStorage(fingerprint);
|
|
441
|
+
// Check if we need to load from backend
|
|
442
|
+
if (!this.loadedFingerprints.has(fingerprint)) {
|
|
443
|
+
await this.loadFingerprintTranslations(fingerprint, fingerprint);
|
|
399
444
|
}
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Clear cached translations for a specific fingerprint to free memory
|
|
448
|
+
* Doesn't affect other fingerprints or common
|
|
449
|
+
* @param fingerprint The fingerprint to clear
|
|
450
|
+
*/
|
|
451
|
+
clearFingerprint(fingerprint) {
|
|
452
|
+
this.pools.delete(fingerprint);
|
|
453
|
+
this.loadedFingerprints.delete(fingerprint);
|
|
454
|
+
// Clear localStorage
|
|
455
|
+
if (typeof localStorage !== 'undefined' && this.crossTabOptions.enabled) {
|
|
456
|
+
const storageKey = this.getStorageKey(fingerprint);
|
|
457
|
+
localStorage.removeItem(storageKey);
|
|
409
458
|
}
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Check if a fingerprint has been loaded
|
|
462
|
+
* @param fingerprint Fingerprint to check
|
|
463
|
+
*/
|
|
464
|
+
isLoaded(fingerprint) {
|
|
465
|
+
return this.loadedFingerprints.has(fingerprint);
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Load a fingerprint if not already loaded
|
|
469
|
+
* @param fingerprint Fingerprint to load
|
|
470
|
+
*/
|
|
471
|
+
async loadFingerprintIfNotLoaded(fingerprint) {
|
|
472
|
+
if (this.loadedFingerprints.has(fingerprint)) {
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
// Load from localStorage first
|
|
476
|
+
this.loadFingerprintFromStorage(fingerprint);
|
|
477
|
+
// Check if still not loaded after localStorage
|
|
478
|
+
if (!this.loadedFingerprints.has(fingerprint)) {
|
|
479
|
+
await this.loadFingerprintTranslations(fingerprint, fingerprint);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Clear the current fingerprint to free memory (switch back to common)
|
|
484
|
+
* Current fingerprint becomes null, only common remains active
|
|
485
|
+
*/
|
|
486
|
+
clearCurrentFingerprint() {
|
|
487
|
+
if (this.currentFingerprint) {
|
|
488
|
+
this.clearFingerprint(this.currentFingerprint);
|
|
489
|
+
this.currentFingerprint = null;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Lookup translation
|
|
494
|
+
* Priority: provided fingerprint (if any) → current fingerprint (if set) → common → not found
|
|
495
|
+
* @param text Original text to lookup
|
|
496
|
+
* @param fingerprint Optional specific fingerprint to lookup (overrides current fingerprint)
|
|
497
|
+
* @returns Lookup result
|
|
498
|
+
*/
|
|
499
|
+
lookup(text, fingerprint) {
|
|
500
|
+
// Check provided fingerprint first if given
|
|
501
|
+
if (fingerprint) {
|
|
502
|
+
const targetPool = this.pools.get(fingerprint);
|
|
503
|
+
if (targetPool && targetPool.has(text)) {
|
|
504
|
+
return {
|
|
505
|
+
found: true,
|
|
506
|
+
translation: targetPool.get(text),
|
|
507
|
+
source: 'special'
|
|
508
|
+
};
|
|
439
509
|
}
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
510
|
+
}
|
|
511
|
+
// Check current fingerprint next if we have one and no specific fingerprint provided
|
|
512
|
+
if (!fingerprint && this.currentFingerprint) {
|
|
513
|
+
const currentPool = this.pools.get(this.currentFingerprint);
|
|
514
|
+
if (currentPool && currentPool.has(text)) {
|
|
443
515
|
return {
|
|
444
516
|
found: true,
|
|
445
|
-
translation:
|
|
446
|
-
source: '
|
|
517
|
+
translation: currentPool.get(text),
|
|
518
|
+
source: 'special'
|
|
447
519
|
};
|
|
448
520
|
}
|
|
449
|
-
|
|
521
|
+
}
|
|
522
|
+
// Fallback to common
|
|
523
|
+
const commonPool = this.pools.get('common');
|
|
524
|
+
if (commonPool && commonPool.has(text)) {
|
|
450
525
|
return {
|
|
451
|
-
found:
|
|
452
|
-
translation:
|
|
453
|
-
source:
|
|
526
|
+
found: true,
|
|
527
|
+
translation: commonPool.get(text),
|
|
528
|
+
source: 'common'
|
|
454
529
|
};
|
|
455
530
|
}
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
531
|
+
// Not found
|
|
532
|
+
return {
|
|
533
|
+
found: false,
|
|
534
|
+
translation: '',
|
|
535
|
+
source: null
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* Add a translation to the pool
|
|
540
|
+
* Adds to current fingerprint pool (or common if no fingerprint set)
|
|
541
|
+
* @param text Original text
|
|
542
|
+
* @param translation Translated text
|
|
543
|
+
* @param fingerprint Optional specific fingerprint to add to (overrides current fingerprint)
|
|
544
|
+
*/
|
|
545
|
+
addTranslation(text, translation, fingerprint) {
|
|
546
|
+
const fp = fingerprint || this.currentFingerprint || 'common';
|
|
547
|
+
let pool = this.pools.get(fp);
|
|
548
|
+
if (!pool) {
|
|
549
|
+
pool = new Map();
|
|
550
|
+
this.pools.set(fp, pool);
|
|
551
|
+
}
|
|
552
|
+
pool.set(text, translation);
|
|
553
|
+
this.broadcastUpdate(text, translation);
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* Request translation from backend, automatically adds to pool if found
|
|
557
|
+
*
|
|
558
|
+
* @param text Text to translate
|
|
559
|
+
* @param fromLang Source language
|
|
560
|
+
* @param toLang Target language
|
|
561
|
+
* @returns Translation response
|
|
562
|
+
*/
|
|
563
|
+
async requestTranslation(text, fromLang, toLang) {
|
|
564
|
+
// Use translateStream API - backend will automatically call LLM if translation not found in database
|
|
565
|
+
return new Promise((resolve, reject) => {
|
|
566
|
+
this.client.translateStream({
|
|
567
|
+
senseId: this.senseId,
|
|
568
|
+
text,
|
|
569
|
+
from_lang: fromLang,
|
|
570
|
+
to_lang: toLang
|
|
571
|
+
}, (response) => {
|
|
572
|
+
// Check if we got a translation
|
|
573
|
+
if (response.translation && response.translation[text]) {
|
|
574
|
+
const translatedText = response.translation[text];
|
|
575
|
+
// Add to pool automatically
|
|
576
|
+
this.addTranslation(text, translatedText);
|
|
577
|
+
resolve({
|
|
578
|
+
originalText: text,
|
|
579
|
+
translatedText: translatedText,
|
|
580
|
+
provider: 'translate-stream',
|
|
581
|
+
timestamp: response.timestamp,
|
|
582
|
+
finished: response.finished,
|
|
583
|
+
cached: false,
|
|
584
|
+
fromLang,
|
|
585
|
+
toLang
|
|
586
|
+
});
|
|
587
|
+
return false; // Stop streaming
|
|
492
588
|
}
|
|
493
|
-
|
|
589
|
+
// Check for error
|
|
590
|
+
if (response.translation && response.translation['error']) {
|
|
591
|
+
reject(new Error(response.translation['error']));
|
|
592
|
+
return false;
|
|
593
|
+
}
|
|
594
|
+
// Continue if not finished
|
|
595
|
+
return !response.finished;
|
|
596
|
+
}).catch(reject);
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* Get all translations for current fingerprint (includes common if needed)
|
|
601
|
+
* @returns Array of {text, translation}
|
|
602
|
+
*/
|
|
603
|
+
getAll() {
|
|
604
|
+
const fp = this.currentFingerprint || 'common';
|
|
605
|
+
return this.getAllForFingerprint(fp);
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Get all translations for a specific fingerprint
|
|
609
|
+
* @param fp Fingerprint name
|
|
610
|
+
* @returns Array of {text, translation}
|
|
611
|
+
*/
|
|
612
|
+
getAllForFingerprint(fp) {
|
|
613
|
+
const result = [];
|
|
614
|
+
const pool = this.pools.get(fp);
|
|
615
|
+
if (pool) {
|
|
616
|
+
pool.forEach((translation, text) => {
|
|
617
|
+
result.push({ text, translation });
|
|
494
618
|
});
|
|
495
619
|
}
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
const
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
*/
|
|
522
|
-
clearAll() {
|
|
523
|
-
// Clear all fingerprints from memory
|
|
524
|
-
this.pools.clear();
|
|
525
|
-
this.loadedFingerprints.clear();
|
|
526
|
-
this.currentFingerprint = null;
|
|
527
|
-
// Re-initialize common pool
|
|
528
|
-
this.pools.set('common', new Map());
|
|
529
|
-
// Clear all localStorage for this sense
|
|
530
|
-
if (typeof localStorage !== 'undefined' && this.crossTabOptions.enabled) {
|
|
531
|
-
// We can't easily iterate all fingerprints, but at least clear common
|
|
532
|
-
const storageKey = this.getStorageKey('common');
|
|
533
|
-
localStorage.removeItem(storageKey);
|
|
534
|
-
}
|
|
535
|
-
// Broadcast clear to other tabs
|
|
536
|
-
if (this.broadcastChannel && this.crossTabOptions.enabled) {
|
|
537
|
-
this.broadcastChannel.postMessage({
|
|
538
|
-
type: 'cache_clear',
|
|
539
|
-
senseId: this.senseId,
|
|
540
|
-
fingerprint: undefined
|
|
541
|
-
});
|
|
542
|
-
}
|
|
620
|
+
return result;
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* Clear all cached data to free memory
|
|
624
|
+
*/
|
|
625
|
+
clearAll() {
|
|
626
|
+
// Clear all fingerprints from memory
|
|
627
|
+
this.pools.clear();
|
|
628
|
+
this.loadedFingerprints.clear();
|
|
629
|
+
this.currentFingerprint = null;
|
|
630
|
+
// Re-initialize common pool
|
|
631
|
+
this.pools.set('common', new Map());
|
|
632
|
+
// Clear all localStorage for this sense
|
|
633
|
+
if (typeof localStorage !== 'undefined' && this.crossTabOptions.enabled) {
|
|
634
|
+
// We can't easily iterate all fingerprints, but at least clear common
|
|
635
|
+
const storageKey = this.getStorageKey('common');
|
|
636
|
+
localStorage.removeItem(storageKey);
|
|
637
|
+
}
|
|
638
|
+
// Broadcast clear to other tabs
|
|
639
|
+
if (this.broadcastChannel && this.crossTabOptions.enabled) {
|
|
640
|
+
this.broadcastChannel.postMessage({
|
|
641
|
+
type: 'cache_clear',
|
|
642
|
+
senseId: this.senseId,
|
|
643
|
+
fingerprint: undefined
|
|
644
|
+
});
|
|
543
645
|
}
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Close the broadcast channel to free resources
|
|
649
|
+
* Should be called when the pool is no longer needed
|
|
650
|
+
*/
|
|
651
|
+
destroy() {
|
|
652
|
+
if (this.broadcastChannel) {
|
|
653
|
+
this.broadcastChannel.close();
|
|
654
|
+
this.broadcastChannel = null;
|
|
553
655
|
}
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* Check if cross-tab synchronization is enabled
|
|
659
|
+
*/
|
|
660
|
+
isCrossTabEnabled() {
|
|
661
|
+
return this.crossTabOptions.enabled;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* TranslationService - Main entry point for translation operations
|
|
666
|
+
* Provides a clean API with automatic caching and cross-tab synchronization
|
|
667
|
+
*
|
|
668
|
+
* Features:
|
|
669
|
+
* - Lazy initialization: automatically initializes on first translate() call
|
|
670
|
+
* - Streaming batch load: uses streaming API for efficient initialization
|
|
671
|
+
* - On-demand loading: automatically loads when fingerprint changes
|
|
672
|
+
* - Cross-tab sync: optional Broadcast Channel + localStorage synchronization
|
|
673
|
+
*
|
|
674
|
+
* Usage:
|
|
675
|
+
* - Simple: Just create and call translate() - initialization is automatic
|
|
676
|
+
* - Advanced: Call initialize() explicitly to preload all translations upfront
|
|
677
|
+
*/
|
|
678
|
+
/**
|
|
679
|
+
* Simple LRU Cache implementation for TranslationClient
|
|
680
|
+
* Uses Map with access order for O(1) get/set operations
|
|
681
|
+
*/
|
|
682
|
+
class LRUCache {
|
|
683
|
+
constructor(capacity = 1000) {
|
|
684
|
+
this.capacity = capacity;
|
|
685
|
+
this.cache = new Map();
|
|
686
|
+
}
|
|
687
|
+
/**
|
|
688
|
+
* Get value from cache, moves to end (most recently used)
|
|
689
|
+
*/
|
|
690
|
+
get(key) {
|
|
691
|
+
if (!this.cache.has(key)) {
|
|
692
|
+
return undefined;
|
|
693
|
+
}
|
|
694
|
+
// Move to end (most recently used)
|
|
695
|
+
const value = this.cache.get(key);
|
|
696
|
+
this.cache.delete(key);
|
|
697
|
+
this.cache.set(key, value);
|
|
698
|
+
return value;
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* Set value in cache, evicts oldest if over capacity
|
|
702
|
+
*/
|
|
703
|
+
set(key, value) {
|
|
704
|
+
// Delete if exists (to move to end)
|
|
705
|
+
if (this.cache.has(key)) {
|
|
593
706
|
this.cache.delete(key);
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
this.cache.delete(key);
|
|
604
|
-
}
|
|
605
|
-
// Evict oldest if at capacity
|
|
606
|
-
else if (this.cache.size >= this.capacity) {
|
|
607
|
-
// First key is the oldest (least recently used)
|
|
608
|
-
const oldestKey = this.cache.keys().next().value;
|
|
609
|
-
this.cache.delete(oldestKey);
|
|
610
|
-
}
|
|
611
|
-
this.cache.set(key, value);
|
|
612
|
-
}
|
|
613
|
-
/**
|
|
614
|
-
* Check if key exists in cache
|
|
615
|
-
*/
|
|
616
|
-
has(key) {
|
|
617
|
-
return this.cache.has(key);
|
|
618
|
-
}
|
|
619
|
-
/**
|
|
620
|
-
* Delete key from cache
|
|
621
|
-
*/
|
|
622
|
-
delete(key) {
|
|
623
|
-
return this.cache.delete(key);
|
|
624
|
-
}
|
|
625
|
-
/**
|
|
626
|
-
* Clear all entries
|
|
627
|
-
*/
|
|
628
|
-
clear() {
|
|
629
|
-
this.cache.clear();
|
|
630
|
-
}
|
|
631
|
-
/**
|
|
632
|
-
* Get current cache size
|
|
633
|
-
*/
|
|
634
|
-
get size() {
|
|
635
|
-
return this.cache.size;
|
|
636
|
-
}
|
|
637
|
-
}
|
|
638
|
-
/**
|
|
639
|
-
* Cache key generator for translation requests
|
|
640
|
-
*/
|
|
641
|
-
function generateCacheKey(request) {
|
|
642
|
-
const parts = [
|
|
643
|
-
request.text,
|
|
644
|
-
request.toLang || '',
|
|
645
|
-
request.fromLang || '',
|
|
646
|
-
request.senseId || '',
|
|
647
|
-
request.provider || ''
|
|
648
|
-
];
|
|
649
|
-
return parts.join('|');
|
|
650
|
-
}
|
|
651
|
-
const defaultClientCrossTabOptions = {
|
|
652
|
-
enabled: false,
|
|
653
|
-
channelName: 'laker-translation-client-cache',
|
|
654
|
-
storageKey: 'laker_translation_client_cache'
|
|
655
|
-
};
|
|
707
|
+
}
|
|
708
|
+
// Evict oldest if at capacity
|
|
709
|
+
else if (this.cache.size >= this.capacity) {
|
|
710
|
+
// First key is the oldest (least recently used)
|
|
711
|
+
const oldestKey = this.cache.keys().next().value;
|
|
712
|
+
this.cache.delete(oldestKey);
|
|
713
|
+
}
|
|
714
|
+
this.cache.set(key, value);
|
|
715
|
+
}
|
|
656
716
|
/**
|
|
657
|
-
*
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
*
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
717
|
+
* Check if key exists in cache
|
|
718
|
+
*/
|
|
719
|
+
has(key) {
|
|
720
|
+
return this.cache.has(key);
|
|
721
|
+
}
|
|
722
|
+
/**
|
|
723
|
+
* Delete key from cache
|
|
724
|
+
*/
|
|
725
|
+
delete(key) {
|
|
726
|
+
return this.cache.delete(key);
|
|
727
|
+
}
|
|
728
|
+
/**
|
|
729
|
+
* Clear all entries
|
|
730
|
+
*/
|
|
731
|
+
clear() {
|
|
732
|
+
this.cache.clear();
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* Get current cache size
|
|
736
|
+
*/
|
|
737
|
+
get size() {
|
|
738
|
+
return this.cache.size;
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
742
|
+
* Cache key generator for translation requests
|
|
743
|
+
*/
|
|
744
|
+
function generateCacheKey(request) {
|
|
745
|
+
const parts = [
|
|
746
|
+
request.text,
|
|
747
|
+
request.toLang || '',
|
|
748
|
+
request.fromLang || '',
|
|
749
|
+
request.senseId || '',
|
|
750
|
+
request.provider || ''
|
|
751
|
+
];
|
|
752
|
+
return parts.join('|');
|
|
753
|
+
}
|
|
754
|
+
const defaultClientCrossTabOptions = {
|
|
755
|
+
enabled: false,
|
|
756
|
+
channelName: 'laker-translation-client-cache',
|
|
757
|
+
storageKey: 'laker_translation_client_cache'
|
|
758
|
+
};
|
|
759
|
+
/**
|
|
760
|
+
* TranslationClient - Main entry point for Laker Translation SDK
|
|
761
|
+
*
|
|
762
|
+
* Features:
|
|
763
|
+
* - Automatic cache lookup: preloaded translations → LRU cache → backend request
|
|
764
|
+
* - Fingerprint-based personalized translations support
|
|
765
|
+
* - Optional cross-browser-tab cache synchronization
|
|
766
|
+
* - Lazy initialization: automatically preloads on first use
|
|
767
|
+
* - Simple single-level API, no complex layering
|
|
768
|
+
*/
|
|
769
|
+
export class TranslationClient {
|
|
770
|
+
/**
|
|
771
|
+
* Create a new TranslationClient - the only entry point you need
|
|
772
|
+
* @param config Client configuration
|
|
773
|
+
*/
|
|
774
|
+
constructor(config) {
|
|
775
|
+
this.currentFingerprint = null;
|
|
776
|
+
this.initialized = false;
|
|
777
|
+
this.initPromise = null;
|
|
778
|
+
// Cross-tab for LLM cache
|
|
779
|
+
this.broadcastChannel = null;
|
|
780
|
+
this.config = config;
|
|
781
|
+
this.token = config.token;
|
|
782
|
+
// Default baseUrl includes the API path prefix /api/v1/translate
|
|
783
|
+
this.baseUrl = (config.baseUrl || 'https://api.hottol.com/laker/api/v1/translate').endsWith('/')
|
|
784
|
+
? (config.baseUrl || 'https://api.hottol.com/laker/api/v1/translate').slice(0, -1)
|
|
785
|
+
: (config.baseUrl || 'https://api.hottol.com/laker/api/v1/translate');
|
|
786
|
+
this.timeout = config.timeout || 30000;
|
|
787
|
+
// Configure LRU cache for LLM translations
|
|
788
|
+
this.llmCacheEnabled = config.useCache !== false;
|
|
789
|
+
const llmCacheSize = this.llmCacheEnabled ? (config.cacheSize || 1000) : 0;
|
|
790
|
+
this.llmCache = new LRUCache(llmCacheSize);
|
|
791
|
+
// Configure cross-tab options for translation pool
|
|
792
|
+
const crossTabOptions = {
|
|
793
|
+
enabled: config.crossTab === true,
|
|
794
|
+
};
|
|
795
|
+
if (config.crossTabChannelName) {
|
|
796
|
+
crossTabOptions.channelName = config.crossTabChannelName;
|
|
797
|
+
}
|
|
798
|
+
if (config.crossTabStorageKeyPrefix) {
|
|
799
|
+
crossTabOptions.storageKeyPrefix = config.crossTabStorageKeyPrefix;
|
|
800
|
+
}
|
|
801
|
+
// Create translation pool for pre-loaded translations
|
|
802
|
+
this.pool = new TranslationPool(this, config.senseId, crossTabOptions);
|
|
803
|
+
// Set initial fingerprint if provided
|
|
804
|
+
if (config.fingerprint) {
|
|
805
|
+
this.currentFingerprint = config.fingerprint;
|
|
806
|
+
this.pool['currentFingerprint'] = config.fingerprint;
|
|
807
|
+
}
|
|
808
|
+
// Cross-tab for LLM cache
|
|
809
|
+
this.crossTabOptions = Object.assign({}, defaultClientCrossTabOptions);
|
|
810
|
+
this.storageKey = this.crossTabOptions.storageKey;
|
|
811
|
+
// Initialize cross-tab synchronization if enabled
|
|
812
|
+
if (config.crossTab && typeof BroadcastChannel !== 'undefined') {
|
|
813
|
+
this.initCrossTabSync();
|
|
814
|
+
}
|
|
815
|
+
// Load from localStorage if cross-tab enabled
|
|
816
|
+
this.loadFromStorage();
|
|
817
|
+
}
|
|
818
|
+
/**
|
|
819
|
+
* Initialize cross-tab synchronization via Broadcast Channel
|
|
820
|
+
*/
|
|
821
|
+
initCrossTabSync() {
|
|
822
|
+
this.broadcastChannel = new BroadcastChannel(this.crossTabOptions.channelName);
|
|
823
|
+
this.broadcastChannel.onmessage = (event) => {
|
|
824
|
+
const message = event.data;
|
|
825
|
+
switch (message.type) {
|
|
826
|
+
case 'cache_update':
|
|
827
|
+
if (message.key && message.data) {
|
|
828
|
+
// Update local cache from another tab's update
|
|
829
|
+
this.llmCache.set(message.key, message.data);
|
|
830
|
+
}
|
|
831
|
+
break;
|
|
832
|
+
case 'cache_clear':
|
|
833
|
+
this.llmCache.clear();
|
|
834
|
+
break;
|
|
835
|
+
case 'request_sync':
|
|
836
|
+
// Another tab is requesting our cache, send our data
|
|
837
|
+
this.broadcastFullCache();
|
|
838
|
+
break;
|
|
755
839
|
}
|
|
756
|
-
|
|
757
|
-
|
|
840
|
+
};
|
|
841
|
+
// Request other tabs to share their cache
|
|
842
|
+
this.broadcastChannel.postMessage({ type: 'request_sync' });
|
|
843
|
+
}
|
|
844
|
+
/**
|
|
845
|
+
* Load cache from localStorage
|
|
846
|
+
*/
|
|
847
|
+
loadFromStorage() {
|
|
848
|
+
if (typeof localStorage === 'undefined' || !this.crossTabOptions.enabled) {
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
try {
|
|
852
|
+
const stored = localStorage.getItem(this.storageKey);
|
|
853
|
+
if (stored) {
|
|
854
|
+
const data = JSON.parse(stored);
|
|
855
|
+
data.forEach(({ key, value }) => {
|
|
856
|
+
this.llmCache.set(key, value);
|
|
857
|
+
});
|
|
758
858
|
}
|
|
759
859
|
}
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
*/
|
|
763
|
-
saveToStorage() {
|
|
764
|
-
if (typeof localStorage === 'undefined' || !this.crossTabOptions.enabled) {
|
|
765
|
-
return;
|
|
766
|
-
}
|
|
767
|
-
try {
|
|
768
|
-
const data = this.getAllCacheEntries();
|
|
769
|
-
localStorage.setItem(this.storageKey, JSON.stringify(data));
|
|
770
|
-
}
|
|
771
|
-
catch (e) {
|
|
772
|
-
console.warn('Failed to save translation cache to localStorage:', e);
|
|
773
|
-
}
|
|
860
|
+
catch (e) {
|
|
861
|
+
console.warn('Failed to load translation cache from localStorage:', e);
|
|
774
862
|
}
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
cacheMap.forEach((value, key) => {
|
|
783
|
-
result.push({ key, value });
|
|
784
|
-
});
|
|
785
|
-
return result;
|
|
863
|
+
}
|
|
864
|
+
/**
|
|
865
|
+
* Save cache to localStorage
|
|
866
|
+
*/
|
|
867
|
+
saveToStorage() {
|
|
868
|
+
if (typeof localStorage === 'undefined' || !this.crossTabOptions.enabled) {
|
|
869
|
+
return;
|
|
786
870
|
}
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
broadcastFullCache() {
|
|
791
|
-
if (!this.broadcastChannel || !this.crossTabOptions.enabled) {
|
|
792
|
-
return;
|
|
793
|
-
}
|
|
794
|
-
const entries = this.getAllCacheEntries();
|
|
795
|
-
entries.forEach(({ key, value }) => {
|
|
796
|
-
this.broadcastChannel.postMessage({
|
|
797
|
-
type: 'cache_update',
|
|
798
|
-
key,
|
|
799
|
-
data: value
|
|
800
|
-
});
|
|
801
|
-
});
|
|
871
|
+
try {
|
|
872
|
+
const data = this.getAllCacheEntries();
|
|
873
|
+
localStorage.setItem(this.storageKey, JSON.stringify(data));
|
|
802
874
|
}
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
875
|
+
catch (e) {
|
|
876
|
+
console.warn('Failed to save translation cache to localStorage:', e);
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* Get all cache entries for storage/broadcast
|
|
881
|
+
*/
|
|
882
|
+
getAllCacheEntries() {
|
|
883
|
+
const result = [];
|
|
884
|
+
// Access internal cache map for iteration
|
|
885
|
+
const cacheMap = this.llmCache.cache;
|
|
886
|
+
cacheMap.forEach((value, key) => {
|
|
887
|
+
result.push({ key, value });
|
|
888
|
+
});
|
|
889
|
+
return result;
|
|
890
|
+
}
|
|
891
|
+
/**
|
|
892
|
+
* Broadcast full cache to other tabs
|
|
893
|
+
*/
|
|
894
|
+
broadcastFullCache() {
|
|
895
|
+
if (!this.broadcastChannel || !this.crossTabOptions.enabled) {
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
const entries = this.getAllCacheEntries();
|
|
899
|
+
entries.forEach(({ key, value }) => {
|
|
810
900
|
this.broadcastChannel.postMessage({
|
|
811
901
|
type: 'cache_update',
|
|
812
902
|
key,
|
|
813
903
|
data: value
|
|
814
904
|
});
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
}
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* Broadcast a single cache update to other tabs
|
|
909
|
+
*/
|
|
910
|
+
broadcastCacheUpdate(key, value) {
|
|
911
|
+
if (!this.broadcastChannel || !this.crossTabOptions.enabled) {
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
this.broadcastChannel.postMessage({
|
|
915
|
+
type: 'cache_update',
|
|
916
|
+
key,
|
|
917
|
+
data: value
|
|
918
|
+
});
|
|
919
|
+
this.saveToStorage();
|
|
920
|
+
}
|
|
921
|
+
/**
|
|
922
|
+
* Set or update the JWT authentication token
|
|
923
|
+
* @param token JWT token
|
|
924
|
+
*/
|
|
925
|
+
setToken(token) {
|
|
926
|
+
this.token = token;
|
|
927
|
+
}
|
|
928
|
+
/**
|
|
929
|
+
* Enable or disable LLM cache
|
|
930
|
+
* @param enabled Whether to enable cache
|
|
931
|
+
*/
|
|
932
|
+
setCacheEnabled(enabled) {
|
|
933
|
+
this.llmCacheEnabled = enabled;
|
|
934
|
+
}
|
|
935
|
+
/**
|
|
936
|
+
* Clear the LLM translation cache (also clears localStorage and broadcasts to other tabs)
|
|
937
|
+
*/
|
|
938
|
+
clearCache() {
|
|
939
|
+
this.llmCache.clear();
|
|
940
|
+
// Clear localStorage
|
|
941
|
+
if (typeof localStorage !== 'undefined' && this.crossTabOptions.enabled) {
|
|
942
|
+
localStorage.removeItem(this.storageKey);
|
|
844
943
|
}
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
getCacheSize() {
|
|
849
|
-
return this.llmCache.size;
|
|
850
|
-
}
|
|
851
|
-
/**
|
|
852
|
-
* Check if cross-tab synchronization is enabled
|
|
853
|
-
*/
|
|
854
|
-
isCrossTabEnabled() {
|
|
855
|
-
return this.crossTabOptions.enabled;
|
|
856
|
-
}
|
|
857
|
-
/**
|
|
858
|
-
凤 * GetSenseTranslate - One-shot unary request with pagination
|
|
859
|
-
* @param request Request parameters
|
|
860
|
-
*/
|
|
861
|
-
getSenseTranslate(request) {
|
|
862
|
-
return __awaiter(this, void 0, void 0, function* () {
|
|
863
|
-
const url = `${this.baseUrl}/TranslationService/GetSenseTranslate`;
|
|
864
|
-
const response = yield this.fetchJson(url, request);
|
|
865
|
-
return response;
|
|
866
|
-
});
|
|
944
|
+
// Broadcast clear to other tabs
|
|
945
|
+
if (this.broadcastChannel && this.crossTabOptions.enabled) {
|
|
946
|
+
this.broadcastChannel.postMessage({ type: 'cache_clear' });
|
|
867
947
|
}
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
948
|
+
}
|
|
949
|
+
/**
|
|
950
|
+
* Get current LLM cache size
|
|
951
|
+
*/
|
|
952
|
+
getCacheSize() {
|
|
953
|
+
return this.llmCache.size;
|
|
954
|
+
}
|
|
955
|
+
/**
|
|
956
|
+
* Check if cross-tab synchronization is enabled
|
|
957
|
+
*/
|
|
958
|
+
isCrossTabEnabled() {
|
|
959
|
+
return this.crossTabOptions.enabled;
|
|
960
|
+
}
|
|
961
|
+
/**
|
|
962
|
+
凤 * GetSenseTranslate - One-shot unary request with pagination
|
|
963
|
+
* @param request Request parameters
|
|
964
|
+
*/
|
|
965
|
+
async getSenseTranslate(request) {
|
|
966
|
+
const url = `${this.baseUrl}/api/v1/translate/TranslationService/GetSenseTranslate`;
|
|
967
|
+
const response = await this.fetchJson(url, request);
|
|
968
|
+
return response;
|
|
969
|
+
}
|
|
970
|
+
/**
|
|
971
|
+
* TranslateStream - Server streaming, receives multiple batches progressively
|
|
972
|
+
* @param request Request parameters
|
|
973
|
+
* @param onBatch Callback for each batch received. Return false to stop streaming early.
|
|
974
|
+
*/
|
|
975
|
+
async translateStream(request, onBatch) {
|
|
976
|
+
const url = `${this.baseUrl}/api/v1/translate/TranslationService/TranslateStream`;
|
|
977
|
+
// For gRPC-Web streaming over HTTP, we use POST with streaming response
|
|
978
|
+
const response = await this.fetchWithTimeout(url, {
|
|
979
|
+
method: 'POST',
|
|
980
|
+
body: JSON.stringify(request),
|
|
981
|
+
headers: this.getHeaders()
|
|
982
|
+
});
|
|
983
|
+
if (!response.ok) {
|
|
984
|
+
const text = await response.text();
|
|
985
|
+
throw new Error(`HTTP ${response.status}: ${text}`);
|
|
986
|
+
}
|
|
987
|
+
if (!response.body) {
|
|
988
|
+
throw new Error('No response body for streaming request');
|
|
989
|
+
}
|
|
990
|
+
const decoder = new TextDecoder();
|
|
991
|
+
// Handle both browser ReadableStream and Node.js stream from node-fetch
|
|
992
|
+
// Check for Node.js stream first (node-fetch v2 uses Node.js streams)
|
|
993
|
+
if ('on' in response.body && typeof response.body.on === 'function') {
|
|
994
|
+
// Node.js Stream (for testing)
|
|
995
|
+
await new Promise((resolve, reject) => {
|
|
996
|
+
let buffer = '';
|
|
997
|
+
response.body.on('data', (chunk) => {
|
|
998
|
+
buffer += chunk.toString();
|
|
999
|
+
const lines = buffer.split('\n').filter(line => line.trim().length > 0);
|
|
1000
|
+
// Keep incomplete line in buffer
|
|
1001
|
+
if (!buffer.endsWith('\n')) {
|
|
1002
|
+
buffer = lines.pop() || '';
|
|
1003
|
+
}
|
|
1004
|
+
else {
|
|
1005
|
+
buffer = '';
|
|
891
1006
|
}
|
|
892
|
-
const chunk = decoder.decode(value);
|
|
893
|
-
// Parse each line as a JSON message
|
|
894
|
-
const lines = chunk.split('\n').filter(line => line.trim().length > 0);
|
|
895
1007
|
for (const line of lines) {
|
|
896
1008
|
try {
|
|
897
1009
|
const data = JSON.parse(line);
|
|
898
1010
|
const shouldContinue = onBatch(data);
|
|
899
1011
|
if (shouldContinue === false) {
|
|
900
|
-
|
|
1012
|
+
response.body.destroy();
|
|
1013
|
+
resolve();
|
|
901
1014
|
return;
|
|
902
1015
|
}
|
|
903
1016
|
}
|
|
@@ -905,305 +1018,396 @@
|
|
|
905
1018
|
console.warn('Failed to parse streaming chunk:', line, e);
|
|
906
1019
|
}
|
|
907
1020
|
}
|
|
908
|
-
}
|
|
909
|
-
});
|
|
910
|
-
}
|
|
911
|
-
/**
|
|
912
|
-
* Collect all streaming responses into an array
|
|
913
|
-
* @param request Request parameters
|
|
914
|
-
*/
|
|
915
|
-
translateStreamCollect(request) {
|
|
916
|
-
return __awaiter(this, void 0, void 0, function* () {
|
|
917
|
-
const result = [];
|
|
918
|
-
yield this.translateStream(request, (response) => {
|
|
919
|
-
result.push(response);
|
|
920
|
-
return true;
|
|
921
1021
|
});
|
|
922
|
-
|
|
1022
|
+
response.body.on('end', () => {
|
|
1023
|
+
// Process any remaining data
|
|
1024
|
+
if (buffer.trim().length > 0) {
|
|
1025
|
+
try {
|
|
1026
|
+
const data = JSON.parse(buffer.trim());
|
|
1027
|
+
onBatch(data);
|
|
1028
|
+
}
|
|
1029
|
+
catch (e) {
|
|
1030
|
+
console.warn('Failed to parse final chunk:', buffer, e);
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
resolve();
|
|
1034
|
+
});
|
|
1035
|
+
response.body.on('error', (err) => {
|
|
1036
|
+
reject(err);
|
|
1037
|
+
});
|
|
923
1038
|
});
|
|
924
1039
|
}
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
llmTranslate(request_1) {
|
|
933
|
-
return __awaiter(this, arguments, void 0, function* (request, skipCache = false) {
|
|
934
|
-
const cacheKey = generateCacheKey(request);
|
|
935
|
-
// Check cache first
|
|
936
|
-
if (this.llmCacheEnabled && !skipCache) {
|
|
937
|
-
const cached = this.llmCache.get(cacheKey);
|
|
938
|
-
if (cached) {
|
|
939
|
-
// Return cached response with cached flag set
|
|
940
|
-
return Object.assign(Object.assign({}, cached), { cached: true });
|
|
941
|
-
}
|
|
1040
|
+
else if ('getReader' in response.body && typeof response.body.getReader === 'function') {
|
|
1041
|
+
// Browser ReadableStream (whatwg streams)
|
|
1042
|
+
const reader = response.body.getReader();
|
|
1043
|
+
while (true) {
|
|
1044
|
+
const { done, value } = await reader.read();
|
|
1045
|
+
if (done) {
|
|
1046
|
+
break;
|
|
942
1047
|
}
|
|
943
|
-
|
|
944
|
-
const
|
|
945
|
-
const
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
1048
|
+
const chunk = decoder.decode(value);
|
|
1049
|
+
const lines = chunk.split('\n').filter(line => line.trim().length > 0);
|
|
1050
|
+
for (const line of lines) {
|
|
1051
|
+
try {
|
|
1052
|
+
const data = JSON.parse(line);
|
|
1053
|
+
const shouldContinue = onBatch(data);
|
|
1054
|
+
if (shouldContinue === false) {
|
|
1055
|
+
reader.cancel();
|
|
1056
|
+
return;
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
catch (e) {
|
|
1060
|
+
console.warn('Failed to parse streaming chunk:', line, e);
|
|
1061
|
+
}
|
|
952
1062
|
}
|
|
953
|
-
|
|
954
|
-
});
|
|
1063
|
+
}
|
|
955
1064
|
}
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
* Translate text - this is the main method you need
|
|
959
|
-
*
|
|
960
|
-
* Workflow:
|
|
961
|
-
* 1. Check pre-loaded translation pool (provided fingerprint → current fingerprint → common)
|
|
962
|
-
* 2. If found, return immediately from cache
|
|
963
|
-
* 3. If not found, request LLM translation from backend
|
|
964
|
-
* 4. Auto-initialize on first call
|
|
965
|
-
*
|
|
966
|
-
* @param text Text to translate
|
|
967
|
-
* @param toLang Target language
|
|
968
|
-
* @param fromLang Source language (optional, auto-detected if not provided)
|
|
969
|
-
* @param fingerprint Optional specific fingerprint for this translation (overrides client-level fingerprint)
|
|
970
|
-
* @returns Translated text
|
|
971
|
-
*/
|
|
972
|
-
translate(text, toLang, fromLang, fingerprint) {
|
|
973
|
-
return __awaiter(this, void 0, void 0, function* () {
|
|
974
|
-
const response = yield this.translateWithDetails(text, toLang, fromLang, fingerprint);
|
|
975
|
-
return response.translatedText;
|
|
976
|
-
});
|
|
1065
|
+
else {
|
|
1066
|
+
throw new Error('Unsupported response body stream type');
|
|
977
1067
|
}
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
return
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1068
|
+
}
|
|
1069
|
+
/**
|
|
1070
|
+
* Collect all streaming responses into an array
|
|
1071
|
+
* @param request Request parameters
|
|
1072
|
+
*/
|
|
1073
|
+
async translateStreamCollect(request) {
|
|
1074
|
+
const result = [];
|
|
1075
|
+
await this.translateStream(request, (response) => {
|
|
1076
|
+
result.push(response);
|
|
1077
|
+
return true;
|
|
1078
|
+
});
|
|
1079
|
+
return result;
|
|
1080
|
+
}
|
|
1081
|
+
/**
|
|
1082
|
+
* LLMTranslate - One-shot large language model translation
|
|
1083
|
+
* Uses LRU cache to avoid repeated requests for the same text
|
|
1084
|
+
* With cross-tab enabled, automatically syncs cache across browser tabs
|
|
1085
|
+
* @param request Translation request
|
|
1086
|
+
* @param skipCache If true, bypass cache and always request from backend
|
|
1087
|
+
*/
|
|
1088
|
+
async llmTranslate(request, skipCache = false) {
|
|
1089
|
+
const cacheKey = generateCacheKey(request);
|
|
1090
|
+
// Check cache first
|
|
1091
|
+
if (this.llmCacheEnabled && !skipCache) {
|
|
1092
|
+
const cached = this.llmCache.get(cacheKey);
|
|
1093
|
+
if (cached) {
|
|
1094
|
+
// Return cached response with cached flag set
|
|
1095
|
+
return Object.assign(Object.assign({}, cached), { cached: true });
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
// Request from backend using gRPC-web streaming LLMTranslateStream
|
|
1099
|
+
let finalResponse = null;
|
|
1100
|
+
await this.llmTranslateStream(request, (response) => {
|
|
1101
|
+
finalResponse = response;
|
|
1102
|
+
// Continue until finished
|
|
1103
|
+
return !response.finished;
|
|
1104
|
+
});
|
|
1105
|
+
if (!finalResponse) {
|
|
1106
|
+
throw new Error('No response received from streaming translation');
|
|
1107
|
+
}
|
|
1108
|
+
// Cache the response
|
|
1109
|
+
if (this.llmCacheEnabled && finalResponse.translatedText) {
|
|
1110
|
+
const cachedResponse = Object.assign(Object.assign({}, finalResponse), { cached: true });
|
|
1111
|
+
this.llmCache.set(cacheKey, cachedResponse);
|
|
1112
|
+
// Broadcast to other tabs and save to localStorage
|
|
1113
|
+
this.broadcastCacheUpdate(cacheKey, cachedResponse);
|
|
1114
|
+
}
|
|
1115
|
+
return Object.assign(Object.assign({}, finalResponse), { cached: false });
|
|
1116
|
+
}
|
|
1117
|
+
// ========== High-level translation API ==========
|
|
1118
|
+
/**
|
|
1119
|
+
* Translate text - this is the main method you need
|
|
1120
|
+
*
|
|
1121
|
+
* Workflow:
|
|
1122
|
+
* 1. Check pre-loaded translation pool (provided fingerprint → current fingerprint → common)
|
|
1123
|
+
* 2. If found, return immediately from cache
|
|
1124
|
+
* 3. If not found, request LLM translation from backend
|
|
1125
|
+
* 4. Auto-initialize on first call
|
|
1126
|
+
*
|
|
1127
|
+
* @param text Text to translate
|
|
1128
|
+
* @param toLang Target language
|
|
1129
|
+
* @param fromLang Source language (optional, auto-detected if not provided)
|
|
1130
|
+
* @param fingerprint Optional specific fingerprint for this translation (overrides client-level fingerprint)
|
|
1131
|
+
* @returns Translated text
|
|
1132
|
+
*/
|
|
1133
|
+
async translate(text, toLang, fromLang, fingerprint) {
|
|
1134
|
+
const response = await this.translateWithDetails(text, toLang, fromLang, fingerprint);
|
|
1135
|
+
return response.translatedText;
|
|
1136
|
+
}
|
|
1137
|
+
/**
|
|
1138
|
+
* Translate text with full response details
|
|
1139
|
+
* @param text Text to translate
|
|
1140
|
+
* @param toLang Target language
|
|
1141
|
+
* @param fromLang Source language (optional)
|
|
1142
|
+
* @param fingerprint Optional specific fingerprint for this translation (overrides client-level fingerprint)
|
|
1143
|
+
* @returns Full translation response
|
|
1144
|
+
*/
|
|
1145
|
+
async translateWithDetails(text, toLang, fromLang, fingerprint) {
|
|
1146
|
+
// Auto-initialize if not initialized yet
|
|
1147
|
+
if (!this.initialized && !this.initPromise) {
|
|
1148
|
+
this.initPromise = this.initialize();
|
|
1149
|
+
}
|
|
1150
|
+
// Check if pool is currently loading
|
|
1151
|
+
const isPoolLoading = this.pool.isLoading();
|
|
1152
|
+
// If pool is loading, use observer pattern to queue request and return original immediately
|
|
1153
|
+
if (isPoolLoading) {
|
|
1154
|
+
console.log(`[TranslationClient] Pool is loading, using fast fallback for: "${text}"`);
|
|
1155
|
+
return this.pool.queueTranslationRequest({ text, toLang, fromLang, fingerprint });
|
|
1156
|
+
}
|
|
1157
|
+
// Wait for initialization to complete if still in progress
|
|
1158
|
+
if (this.initPromise) {
|
|
1159
|
+
await this.initPromise;
|
|
1160
|
+
this.initPromise = null;
|
|
1161
|
+
this.initialized = true;
|
|
1162
|
+
}
|
|
1163
|
+
// If specific fingerprint provided and not loaded yet, load it first
|
|
1164
|
+
if (fingerprint && !this.pool.isLoaded(fingerprint)) {
|
|
1165
|
+
await this.pool.loadFingerprintIfNotLoaded(fingerprint);
|
|
1166
|
+
}
|
|
1167
|
+
// Check pre-loaded translation pool first
|
|
1168
|
+
const lookup = this.pool.lookup(text, fingerprint);
|
|
1169
|
+
if (lookup.found) {
|
|
1170
|
+
return {
|
|
1171
|
+
originalText: text,
|
|
1172
|
+
translatedText: lookup.translation,
|
|
1173
|
+
provider: 'preloaded',
|
|
1174
|
+
timestamp: Date.now(),
|
|
1175
|
+
finished: true,
|
|
1176
|
+
cached: true,
|
|
1177
|
+
fromLang,
|
|
1178
|
+
toLang
|
|
1179
|
+
};
|
|
1180
|
+
}
|
|
1181
|
+
// Not found in pool - request from backend via TranslateStream
|
|
1182
|
+
// This will automatically call LLM if translation doesn't exist in database
|
|
1183
|
+
return new Promise((resolve, reject) => {
|
|
1184
|
+
this.translateStream({
|
|
1185
|
+
senseId: this.config.senseId,
|
|
1186
|
+
fingerprint,
|
|
1187
|
+
text,
|
|
1188
|
+
from_lang: fromLang,
|
|
1189
|
+
to_lang: toLang
|
|
1190
|
+
}, (response) => {
|
|
1191
|
+
// Check if we got a translation
|
|
1192
|
+
if (response.translation && response.translation[text]) {
|
|
1193
|
+
resolve({
|
|
1006
1194
|
originalText: text,
|
|
1007
|
-
translatedText:
|
|
1008
|
-
provider: '
|
|
1009
|
-
timestamp:
|
|
1010
|
-
finished:
|
|
1011
|
-
cached:
|
|
1195
|
+
translatedText: response.translation[text],
|
|
1196
|
+
provider: 'translate-stream',
|
|
1197
|
+
timestamp: response.timestamp,
|
|
1198
|
+
finished: response.finished,
|
|
1199
|
+
cached: false,
|
|
1012
1200
|
fromLang,
|
|
1013
1201
|
toLang
|
|
1014
|
-
};
|
|
1202
|
+
});
|
|
1203
|
+
return false; // Stop streaming
|
|
1015
1204
|
}
|
|
1016
|
-
//
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
toLang,
|
|
1021
|
-
senseId: this.config.senseId,
|
|
1022
|
-
fingerprint
|
|
1023
|
-
});
|
|
1024
|
-
});
|
|
1025
|
-
}
|
|
1026
|
-
/**
|
|
1027
|
-
* Translate without using cache (always request from backend)
|
|
1028
|
-
* @param text Text to translate
|
|
1029
|
-
* @param toLang Target language
|
|
1030
|
-
* @param fromLang Source language (optional)
|
|
1031
|
-
* @param fingerprint Optional specific fingerprint for this translation
|
|
1032
|
-
* @returns Translated text
|
|
1033
|
-
*/
|
|
1034
|
-
translateNoCache(text, toLang, fromLang, fingerprint) {
|
|
1035
|
-
return __awaiter(this, void 0, void 0, function* () {
|
|
1036
|
-
const response = yield this.llmTranslate({
|
|
1037
|
-
text,
|
|
1038
|
-
fromLang,
|
|
1039
|
-
toLang,
|
|
1040
|
-
senseId: this.config.senseId,
|
|
1041
|
-
fingerprint
|
|
1042
|
-
}, true);
|
|
1043
|
-
return response.translatedText;
|
|
1044
|
-
});
|
|
1045
|
-
}
|
|
1046
|
-
/**
|
|
1047
|
-
* Batch translate multiple texts
|
|
1048
|
-
* @param texts Array of texts to translate
|
|
1049
|
-
* @param toLang Target language
|
|
1050
|
-
* @param fromLang Source language (optional)
|
|
1051
|
-
* @param fingerprint Optional specific fingerprint for all translations in this batch
|
|
1052
|
-
* @returns Array of translated texts in same order
|
|
1053
|
-
*/
|
|
1054
|
-
translateBatch(texts, toLang, fromLang, fingerprint) {
|
|
1055
|
-
return __awaiter(this, void 0, void 0, function* () {
|
|
1056
|
-
const results = yield Promise.all(texts.map(text => this.translate(text, toLang, fromLang, fingerprint)));
|
|
1057
|
-
return results;
|
|
1058
|
-
});
|
|
1059
|
-
}
|
|
1060
|
-
/**
|
|
1061
|
-
* Initialize and preload all translations
|
|
1062
|
-
* Call this to warm up cache before translating
|
|
1063
|
-
*/
|
|
1064
|
-
preload() {
|
|
1065
|
-
return __awaiter(this, void 0, void 0, function* () {
|
|
1066
|
-
if (!this.initialized && !this.initPromise) {
|
|
1067
|
-
this.initPromise = this.initialize();
|
|
1068
|
-
yield this.initPromise;
|
|
1069
|
-
this.initPromise = null;
|
|
1070
|
-
this.initialized = true;
|
|
1205
|
+
// Check for error
|
|
1206
|
+
if (response.translation && response.translation['error']) {
|
|
1207
|
+
reject(new Error(response.translation['error']));
|
|
1208
|
+
return false;
|
|
1071
1209
|
}
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1210
|
+
// Continue if not finished
|
|
1211
|
+
return !response.finished;
|
|
1212
|
+
}).catch(reject);
|
|
1213
|
+
});
|
|
1214
|
+
}
|
|
1215
|
+
/**
|
|
1216
|
+
* Translate without using cache (always request from backend)
|
|
1217
|
+
* @param text Text to translate
|
|
1218
|
+
* @param toLang Target language
|
|
1219
|
+
* @param fromLang Source language (optional)
|
|
1220
|
+
* @param fingerprint Optional specific fingerprint for this translation
|
|
1221
|
+
* @returns Translated text
|
|
1222
|
+
*/
|
|
1223
|
+
async translateNoCache(text, toLang, fromLang, fingerprint) {
|
|
1224
|
+
// Use TranslateStream which will automatically call LLM if needed
|
|
1225
|
+
return new Promise((resolve, reject) => {
|
|
1226
|
+
this.translateStream({
|
|
1227
|
+
senseId: this.config.senseId,
|
|
1228
|
+
fingerprint,
|
|
1229
|
+
text,
|
|
1230
|
+
from_lang: fromLang,
|
|
1231
|
+
to_lang: toLang
|
|
1232
|
+
}, (response) => {
|
|
1233
|
+
if (response.translation && response.translation[text]) {
|
|
1234
|
+
resolve(response.translation[text]);
|
|
1235
|
+
return false;
|
|
1081
1236
|
}
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
* Check if service is initialized
|
|
1086
|
-
*/
|
|
1087
|
-
isInitialized() {
|
|
1088
|
-
return this.initialized;
|
|
1089
|
-
}
|
|
1090
|
-
// ========== Fingerprint management ==========
|
|
1091
|
-
/**
|
|
1092
|
-
* Set or change the current fingerprint
|
|
1093
|
-
* Automatically loads special translations for this fingerprint
|
|
1094
|
-
* @param fingerprint The fingerprint to use
|
|
1095
|
-
*/
|
|
1096
|
-
setFingerprint(fingerprint) {
|
|
1097
|
-
return __awaiter(this, void 0, void 0, function* () {
|
|
1098
|
-
if (this.currentFingerprint === fingerprint) {
|
|
1099
|
-
return;
|
|
1237
|
+
if (response.translation && response.translation['error']) {
|
|
1238
|
+
reject(new Error(response.translation['error']));
|
|
1239
|
+
return false;
|
|
1100
1240
|
}
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1241
|
+
return !response.finished;
|
|
1242
|
+
}).catch(reject);
|
|
1243
|
+
});
|
|
1244
|
+
}
|
|
1245
|
+
/**
|
|
1246
|
+
* Batch translate multiple texts
|
|
1247
|
+
* @param texts Array of texts to translate
|
|
1248
|
+
* @param toLang Target language
|
|
1249
|
+
* @param fromLang Source language (optional)
|
|
1250
|
+
* @param fingerprint Optional specific fingerprint for all translations in this batch
|
|
1251
|
+
* @returns Array of translated texts in same order
|
|
1252
|
+
*/
|
|
1253
|
+
async translateBatch(texts, toLang, fromLang, fingerprint) {
|
|
1254
|
+
const results = await Promise.all(texts.map(text => this.translate(text, toLang, fromLang, fingerprint)));
|
|
1255
|
+
return results;
|
|
1256
|
+
}
|
|
1257
|
+
/**
|
|
1258
|
+
* Initialize and preload all translations
|
|
1259
|
+
* Call this to warm up cache before translating
|
|
1260
|
+
*/
|
|
1261
|
+
async preload() {
|
|
1262
|
+
if (!this.initialized && !this.initPromise) {
|
|
1263
|
+
this.initPromise = this.initialize();
|
|
1264
|
+
await this.initPromise;
|
|
1265
|
+
this.initPromise = null;
|
|
1266
|
+
this.initialized = true;
|
|
1104
1267
|
}
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
this.pool.
|
|
1112
|
-
}
|
|
1113
|
-
/**
|
|
1114
|
-
* Get the current fingerprint
|
|
1115
|
-
*/
|
|
1116
|
-
getFingerprint() {
|
|
1117
|
-
return this.currentFingerprint;
|
|
1118
|
-
}
|
|
1119
|
-
// ========== Cache management ==========
|
|
1120
|
-
/**
|
|
1121
|
-
* Check if cache is enabled
|
|
1122
|
-
* @returns true if cache is enabled
|
|
1123
|
-
*/
|
|
1124
|
-
isCacheEnabled() {
|
|
1125
|
-
return this.llmCacheEnabled;
|
|
1126
|
-
}
|
|
1127
|
-
/**
|
|
1128
|
-
* Check if a translation exists in pre-loaded pool
|
|
1129
|
-
* @param text Text to check
|
|
1130
|
-
* @param fingerprint Optional specific fingerprint to check
|
|
1131
|
-
* @returns true if translation exists in cache
|
|
1132
|
-
*/
|
|
1133
|
-
hasTranslation(text, fingerprint) {
|
|
1134
|
-
return this.pool.lookup(text, fingerprint).found;
|
|
1135
|
-
}
|
|
1136
|
-
/**
|
|
1137
|
-
* Get translation from pre-loaded cache without requesting from backend
|
|
1138
|
-
* @param text Text to look up
|
|
1139
|
-
* @param fingerprint Optional specific fingerprint to look up
|
|
1140
|
-
* @returns Translation if found, null otherwise
|
|
1141
|
-
*/
|
|
1142
|
-
getCached(text, fingerprint) {
|
|
1143
|
-
const result = this.pool.lookup(text, fingerprint);
|
|
1144
|
-
return result.found ? result.translation : null;
|
|
1145
|
-
}
|
|
1146
|
-
/**
|
|
1147
|
-
* Add a custom translation to the pre-loaded pool
|
|
1148
|
-
* @param text Original text
|
|
1149
|
-
* @param translation Translated text
|
|
1150
|
-
* @param fingerprint Optional specific fingerprint to add to
|
|
1151
|
-
*/
|
|
1152
|
-
addTranslation(text, translation, fingerprint) {
|
|
1153
|
-
if (this.llmCacheEnabled) {
|
|
1154
|
-
this.pool.addTranslation(text, translation, fingerprint);
|
|
1155
|
-
}
|
|
1268
|
+
}
|
|
1269
|
+
/**
|
|
1270
|
+
* Internal initialization - preloads translations via streaming
|
|
1271
|
+
*/
|
|
1272
|
+
async initialize() {
|
|
1273
|
+
if (this.llmCacheEnabled) {
|
|
1274
|
+
await this.pool.initialize();
|
|
1156
1275
|
}
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
}
|
|
1276
|
+
}
|
|
1277
|
+
/**
|
|
1278
|
+
* Check if service is initialized
|
|
1279
|
+
*/
|
|
1280
|
+
isInitialized() {
|
|
1281
|
+
return this.initialized;
|
|
1282
|
+
}
|
|
1283
|
+
// ========== Fingerprint management ==========
|
|
1284
|
+
/**
|
|
1285
|
+
* Set or change the current fingerprint
|
|
1286
|
+
* Automatically loads special translations for this fingerprint
|
|
1287
|
+
* @param fingerprint The fingerprint to use
|
|
1288
|
+
*/
|
|
1289
|
+
async setFingerprint(fingerprint) {
|
|
1290
|
+
if (this.currentFingerprint === fingerprint) {
|
|
1291
|
+
return;
|
|
1174
1292
|
}
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1293
|
+
this.currentFingerprint = fingerprint;
|
|
1294
|
+
await this.pool.switchFingerprint(fingerprint);
|
|
1295
|
+
}
|
|
1296
|
+
/**
|
|
1297
|
+
* Clear the current fingerprint
|
|
1298
|
+
* Falls back to common translations
|
|
1299
|
+
*/
|
|
1300
|
+
clearFingerprint() {
|
|
1301
|
+
this.currentFingerprint = null;
|
|
1302
|
+
this.pool.clearCurrentFingerprint();
|
|
1303
|
+
}
|
|
1304
|
+
/**
|
|
1305
|
+
* Get the current fingerprint
|
|
1306
|
+
*/
|
|
1307
|
+
getFingerprint() {
|
|
1308
|
+
return this.currentFingerprint;
|
|
1309
|
+
}
|
|
1310
|
+
// ========== Cache management ==========
|
|
1311
|
+
/**
|
|
1312
|
+
* Check if cache is enabled
|
|
1313
|
+
* @returns true if cache is enabled
|
|
1314
|
+
*/
|
|
1315
|
+
isCacheEnabled() {
|
|
1316
|
+
return this.llmCacheEnabled;
|
|
1317
|
+
}
|
|
1318
|
+
/**
|
|
1319
|
+
* Check if a translation exists in pre-loaded pool
|
|
1320
|
+
* @param text Text to check
|
|
1321
|
+
* @param fingerprint Optional specific fingerprint to check
|
|
1322
|
+
* @returns true if translation exists in cache
|
|
1323
|
+
*/
|
|
1324
|
+
hasTranslation(text, fingerprint) {
|
|
1325
|
+
return this.pool.lookup(text, fingerprint).found;
|
|
1326
|
+
}
|
|
1327
|
+
/**
|
|
1328
|
+
* Get translation from pre-loaded cache without requesting from backend
|
|
1329
|
+
* @param text Text to look up
|
|
1330
|
+
* @param fingerprint Optional specific fingerprint to look up
|
|
1331
|
+
* @returns Translation if found, null otherwise
|
|
1332
|
+
*/
|
|
1333
|
+
getCached(text, fingerprint) {
|
|
1334
|
+
const result = this.pool.lookup(text, fingerprint);
|
|
1335
|
+
return result.found ? result.translation : null;
|
|
1336
|
+
}
|
|
1337
|
+
/**
|
|
1338
|
+
* Add a custom translation to the pre-loaded pool
|
|
1339
|
+
* @param text Original text
|
|
1340
|
+
* @param translation Translated text
|
|
1341
|
+
* @param fingerprint Optional specific fingerprint to add to
|
|
1342
|
+
*/
|
|
1343
|
+
addTranslation(text, translation, fingerprint) {
|
|
1344
|
+
if (this.llmCacheEnabled) {
|
|
1345
|
+
this.pool.addTranslation(text, translation, fingerprint);
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
/**
|
|
1349
|
+
* Clear all cached translations
|
|
1350
|
+
*/
|
|
1351
|
+
clearAllCache() {
|
|
1352
|
+
this.pool.clearAll();
|
|
1353
|
+
this.pool.clearQueuedRequests(); // Clear waiting translation requests too
|
|
1354
|
+
this.clearCache(); // Clear LLM cache too
|
|
1355
|
+
}
|
|
1356
|
+
/**
|
|
1357
|
+
* Destroy the instance and free resources
|
|
1358
|
+
* Call this when the instance is no longer needed
|
|
1359
|
+
*/
|
|
1360
|
+
destroy() {
|
|
1361
|
+
this.pool.destroy();
|
|
1362
|
+
if (this.broadcastChannel) {
|
|
1363
|
+
this.broadcastChannel.close();
|
|
1364
|
+
this.broadcastChannel = null;
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
/**
|
|
1368
|
+
* LLMTranslateStream - Streaming large language model translation
|
|
1369
|
+
* Note: Streaming requests are not cached
|
|
1370
|
+
* @param request Translation request
|
|
1371
|
+
* @param onResponse Callback for each response chunk
|
|
1372
|
+
*/
|
|
1373
|
+
async llmTranslateStream(request, onResponse) {
|
|
1374
|
+
const url = `${this.baseUrl}/api/v1/translate/TranslationService/LLMTranslateStream`;
|
|
1375
|
+
const response = await this.fetchWithTimeout(url, {
|
|
1376
|
+
method: 'POST',
|
|
1377
|
+
body: JSON.stringify(request),
|
|
1378
|
+
headers: this.getHeaders()
|
|
1379
|
+
});
|
|
1380
|
+
if (!response.ok) {
|
|
1381
|
+
const text = await response.text();
|
|
1382
|
+
throw new Error(`HTTP ${response.status}: ${text}`);
|
|
1383
|
+
}
|
|
1384
|
+
if (!response.body) {
|
|
1385
|
+
throw new Error('No response body for streaming request');
|
|
1386
|
+
}
|
|
1387
|
+
const decoder = new TextDecoder();
|
|
1388
|
+
// Handle both browser ReadableStream and Node.js stream from node-fetch
|
|
1389
|
+
// Check for Node.js stream first (node-fetch v2 uses Node.js streams)
|
|
1390
|
+
if ('on' in response.body && typeof response.body.on === 'function') {
|
|
1391
|
+
// Node.js Stream (for testing)
|
|
1392
|
+
await new Promise((resolve, reject) => {
|
|
1393
|
+
let buffer = '';
|
|
1394
|
+
response.body.on('data', (chunk) => {
|
|
1395
|
+
buffer += chunk.toString();
|
|
1396
|
+
const lines = buffer.split('\n').filter(line => line.trim().length > 0);
|
|
1397
|
+
// Keep incomplete line in buffer
|
|
1398
|
+
if (!buffer.endsWith('\n')) {
|
|
1399
|
+
buffer = lines.pop() || '';
|
|
1400
|
+
}
|
|
1401
|
+
else {
|
|
1402
|
+
buffer = '';
|
|
1198
1403
|
}
|
|
1199
|
-
const chunk = decoder.decode(value);
|
|
1200
|
-
const lines = chunk.split('\n').filter(line => line.trim().length > 0);
|
|
1201
1404
|
for (const line of lines) {
|
|
1202
1405
|
try {
|
|
1203
1406
|
const data = JSON.parse(line);
|
|
1204
1407
|
const shouldContinue = onResponse(data);
|
|
1205
1408
|
if (shouldContinue === false) {
|
|
1206
|
-
|
|
1409
|
+
response.body.destroy();
|
|
1410
|
+
resolve();
|
|
1207
1411
|
return;
|
|
1208
1412
|
}
|
|
1209
1413
|
}
|
|
@@ -1211,67 +1415,124 @@
|
|
|
1211
1415
|
console.warn('Failed to parse streaming chunk:', line, e);
|
|
1212
1416
|
}
|
|
1213
1417
|
}
|
|
1214
|
-
}
|
|
1418
|
+
});
|
|
1419
|
+
response.body.on('end', () => {
|
|
1420
|
+
// Process any remaining data
|
|
1421
|
+
if (buffer.trim().length > 0) {
|
|
1422
|
+
try {
|
|
1423
|
+
const data = JSON.parse(buffer.trim());
|
|
1424
|
+
onResponse(data);
|
|
1425
|
+
}
|
|
1426
|
+
catch (e) {
|
|
1427
|
+
console.warn('Failed to parse final chunk:', buffer, e);
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
resolve();
|
|
1431
|
+
});
|
|
1432
|
+
response.body.on('error', (err) => {
|
|
1433
|
+
reject(err);
|
|
1434
|
+
});
|
|
1215
1435
|
});
|
|
1216
1436
|
}
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
}
|
|
1225
|
-
return headers;
|
|
1226
|
-
}
|
|
1227
|
-
fetchJson(url, body) {
|
|
1228
|
-
return __awaiter(this, void 0, void 0, function* () {
|
|
1229
|
-
const response = yield this.fetchWithTimeout(url, {
|
|
1230
|
-
method: 'POST',
|
|
1231
|
-
body: JSON.stringify(body),
|
|
1232
|
-
headers: this.getHeaders()
|
|
1233
|
-
});
|
|
1234
|
-
if (!response.ok) {
|
|
1235
|
-
throw new Error(`HTTP error! status: ${response.status}`);
|
|
1437
|
+
else if ('getReader' in response.body && typeof response.body.getReader === 'function') {
|
|
1438
|
+
// Browser ReadableStream (whatwg streams)
|
|
1439
|
+
const reader = response.body.getReader();
|
|
1440
|
+
while (true) {
|
|
1441
|
+
const { done, value } = await reader.read();
|
|
1442
|
+
if (done) {
|
|
1443
|
+
break;
|
|
1236
1444
|
}
|
|
1237
|
-
|
|
1238
|
-
|
|
1445
|
+
const chunk = decoder.decode(value);
|
|
1446
|
+
const lines = chunk.split('\n').filter(line => line.trim().length > 0);
|
|
1447
|
+
for (const line of lines) {
|
|
1448
|
+
try {
|
|
1449
|
+
const data = JSON.parse(line);
|
|
1450
|
+
const shouldContinue = onResponse(data);
|
|
1451
|
+
if (shouldContinue === false) {
|
|
1452
|
+
reader.cancel();
|
|
1453
|
+
return;
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
catch (e) {
|
|
1457
|
+
console.warn('Failed to parse streaming chunk:', line, e);
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1239
1461
|
}
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
const controller = new AbortController();
|
|
1243
|
-
const id = setTimeout(() => controller.abort(), this.timeout);
|
|
1244
|
-
const response = yield fetch(url, Object.assign(Object.assign({}, options), { signal: controller.signal }));
|
|
1245
|
-
clearTimeout(id);
|
|
1246
|
-
return response;
|
|
1247
|
-
});
|
|
1462
|
+
else {
|
|
1463
|
+
throw new Error('Unsupported response body type');
|
|
1248
1464
|
}
|
|
1249
1465
|
}
|
|
1466
|
+
getHeaders() {
|
|
1467
|
+
const headers = {
|
|
1468
|
+
'Content-Type': 'application/grpc-web+json',
|
|
1469
|
+
'X-Grpc-Web': '1'
|
|
1470
|
+
};
|
|
1471
|
+
if (this.token) {
|
|
1472
|
+
// Use api-key-token header for API key authentication
|
|
1473
|
+
// (not Bearer token which requires valid JWT)
|
|
1474
|
+
headers['api-key-token'] = this.token;
|
|
1475
|
+
}
|
|
1476
|
+
return headers;
|
|
1477
|
+
}
|
|
1478
|
+
async fetchJson(url, body) {
|
|
1479
|
+
const response = await this.fetchWithTimeout(url, {
|
|
1480
|
+
method: 'POST',
|
|
1481
|
+
body: JSON.stringify(body),
|
|
1482
|
+
headers: this.getHeaders()
|
|
1483
|
+
});
|
|
1484
|
+
if (!response.ok) {
|
|
1485
|
+
throw new Error(`HTTP error! status: ${response.status}`);
|
|
1486
|
+
}
|
|
1487
|
+
return await response.json();
|
|
1488
|
+
}
|
|
1489
|
+
async fetchWithTimeout(url, options) {
|
|
1490
|
+
const controller = new AbortController();
|
|
1491
|
+
const id = setTimeout(() => controller.abort(), this.timeout);
|
|
1492
|
+
const response = await fetch(url, Object.assign(Object.assign({}, options), { signal: controller.signal }));
|
|
1493
|
+
clearTimeout(id);
|
|
1494
|
+
return response;
|
|
1495
|
+
}
|
|
1250
1496
|
/**
|
|
1251
|
-
*
|
|
1252
|
-
* @
|
|
1253
|
-
* @param senseId Translation sense ID
|
|
1254
|
-
* @param options Additional options
|
|
1255
|
-
* @returns TranslationClient instance
|
|
1497
|
+
* Get cache statistics for both pre-loaded translations and LLM translations
|
|
1498
|
+
* @returns Human-readable cache statistics string
|
|
1256
1499
|
*/
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1500
|
+
getStats() {
|
|
1501
|
+
let preloadedCount = 0;
|
|
1502
|
+
const fingerprintCount = [];
|
|
1503
|
+
// Use type assertion to access private pools property
|
|
1504
|
+
const pools = this.pool.pools;
|
|
1505
|
+
pools.forEach((cache, fp) => {
|
|
1506
|
+
const count = cache.size;
|
|
1507
|
+
preloadedCount += count;
|
|
1508
|
+
fingerprintCount.push(`${fp}: ${count}`);
|
|
1509
|
+
});
|
|
1510
|
+
const llmCount = this.llmCache.size;
|
|
1511
|
+
const llmEnabled = this.llmCacheEnabled;
|
|
1512
|
+
return [
|
|
1513
|
+
`Pre-loaded translations: ${preloadedCount} total`,
|
|
1514
|
+
...(fingerprintCount.length > 0 ? [` Breakdown by fingerprint: ${fingerprintCount.join(', ')}`] : []),
|
|
1515
|
+
`LLM translation cache: ${llmEnabled ? `${llmCount} entries` : 'disabled'}`
|
|
1516
|
+
].join('\n');
|
|
1268
1517
|
}
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1518
|
+
}
|
|
1519
|
+
/**
|
|
1520
|
+
* Create a TranslationClient instance with simplified configuration
|
|
1521
|
+
* @param token JWT authentication token
|
|
1522
|
+
* @param senseId Translation sense ID
|
|
1523
|
+
* @param options Additional options
|
|
1524
|
+
* @returns TranslationClient instance
|
|
1525
|
+
*/
|
|
1526
|
+
export function createTranslation(token, senseId, options) {
|
|
1527
|
+
return new TranslationClient(Object.assign({ token,
|
|
1528
|
+
senseId }, options));
|
|
1529
|
+
}
|
|
1530
|
+
export default createTranslation;
|
|
1531
|
+
// Auto-export to global for browser usage
|
|
1532
|
+
if (typeof window !== 'undefined') {
|
|
1533
|
+
window.LakerTranslation = {
|
|
1534
|
+
TranslationClient,
|
|
1535
|
+
createTranslation,
|
|
1536
|
+
default: createTranslation
|
|
1537
|
+
};
|
|
1538
|
+
}
|