@strapi-ai/translator 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +17 -0
- package/dist/admin/SettingsPage-C5nm32bD.mjs +466 -0
- package/dist/admin/SettingsPage-CD90jfwT.js +466 -0
- package/dist/admin/en-CU6ocWlA.mjs +97 -0
- package/dist/admin/en-I9cvzxfF.js +97 -0
- package/dist/admin/index-CS0LCA35.mjs +1694 -0
- package/dist/admin/index-DQ3-ym--.js +1693 -0
- package/dist/admin/index.js +4 -0
- package/dist/admin/index.mjs +4 -0
- package/dist/admin/src/components/DiffView.d.ts +31 -0
- package/dist/admin/src/components/Initializer.d.ts +3 -0
- package/dist/admin/src/components/PluginIcon.d.ts +4 -0
- package/dist/admin/src/components/ProgressBar.d.ts +7 -0
- package/dist/admin/src/components/TranslationModal.d.ts +23 -0
- package/dist/admin/src/components/TranslationPanel.d.ts +4 -0
- package/dist/admin/src/components/TranslationWorkflow.d.ts +2 -0
- package/dist/admin/src/components/settings/ProviderConnectionFields.d.ts +12 -0
- package/dist/admin/src/components/settings/TranslationSettingsFields.d.ts +7 -0
- package/dist/admin/src/hooks/useProviderModels.d.ts +13 -0
- package/dist/admin/src/hooks/useTranslationConfig.d.ts +8 -0
- package/dist/admin/src/index.d.ts +12 -0
- package/dist/admin/src/pages/SettingsPage.d.ts +2 -0
- package/dist/admin/src/pluginId.d.ts +1 -0
- package/dist/admin/src/utils/clampNumber.d.ts +1 -0
- package/dist/admin/src/utils/configPayload.d.ts +23 -0
- package/dist/admin/src/utils/contentManagerLocale.d.ts +1 -0
- package/dist/admin/src/utils/getTranslation.d.ts +1 -0
- package/dist/admin/src/utils/providerDefaults.d.ts +10 -0
- package/dist/admin/src/utils/sse.d.ts +2 -0
- package/dist/admin/src/utils/translatableFields.d.ts +3 -0
- package/dist/admin/src/utils/translationPatch.d.ts +13 -0
- package/dist/server/index.js +1217 -0
- package/dist/server/index.mjs +1217 -0
- package/dist/server/src/application/assemble-translation-patch.d.ts +3 -0
- package/dist/server/src/application/extract-translatable-units.d.ts +9 -0
- package/dist/server/src/application/translate-entry.d.ts +17 -0
- package/dist/server/src/application/translation-provider.d.ts +33 -0
- package/dist/server/src/bootstrap.d.ts +5 -0
- package/dist/server/src/config/index.d.ts +5 -0
- package/dist/server/src/controllers/index.d.ts +27 -0
- package/dist/server/src/controllers/translation.controller.d.ts +28 -0
- package/dist/server/src/domain/field-path.d.ts +6 -0
- package/dist/server/src/domain/translation-config.d.ts +27 -0
- package/dist/server/src/domain/translation-event.d.ts +59 -0
- package/dist/server/src/domain/translation-patch.d.ts +11 -0
- package/dist/server/src/domain/translation-unit.d.ts +28 -0
- package/dist/server/src/domain/value-codecs/index.d.ts +3 -0
- package/dist/server/src/domain/value-codecs/string-codec.d.ts +2 -0
- package/dist/server/src/domain/value-codecs/structured-value-codec.d.ts +3 -0
- package/dist/server/src/domain/value-codecs/types.d.ts +11 -0
- package/dist/server/src/index.d.ts +64 -0
- package/dist/server/src/infrastructure/llm/chat-completion-client.d.ts +18 -0
- package/dist/server/src/infrastructure/llm/llm-translation-provider.d.ts +15 -0
- package/dist/server/src/infrastructure/llm/model-list-client.d.ts +19 -0
- package/dist/server/src/infrastructure/llm/openai-chat-completion-client.d.ts +6 -0
- package/dist/server/src/infrastructure/strapi/reconcile-schema.d.ts +7 -0
- package/dist/server/src/infrastructure/strapi/secret-cipher.d.ts +8 -0
- package/dist/server/src/infrastructure/strapi/translation-config-repository.d.ts +16 -0
- package/dist/server/src/infrastructure/strapi/translation-session-service.d.ts +37 -0
- package/dist/server/src/routes/index.d.ts +19 -0
- package/dist/server/src/services/index.d.ts +7 -0
- package/package.json +87 -0
|
@@ -0,0 +1,1217 @@
|
|
|
1
|
+
import { createHash, createDecipheriv, randomBytes, createCipheriv } from "node:crypto";
|
|
2
|
+
import { errors } from "@strapi/utils";
|
|
3
|
+
import { OpenAI } from "openai";
|
|
4
|
+
const DEFAULT_TRANSLATION_SYSTEM_PROMPT = "You are a CMS content translator. Output only the translated text, with no explanations, no quotes, and no metadata. Preserve formatting and markup exactly: Markdown, HTML, custom tags, links, images. Leave code blocks, URLs, query parameters, variables, and placeholders unchanged. Keep brand names, product names, and currency values in their original form. Translate meaning by meaning, using natural, concise wording and the standard terminology of the target locale.";
|
|
5
|
+
const DEFAULT_TRANSLATION_CONFIG = {
|
|
6
|
+
apiKey: "",
|
|
7
|
+
baseUrl: "",
|
|
8
|
+
model: "",
|
|
9
|
+
fallbackModel: "",
|
|
10
|
+
systemPrompt: DEFAULT_TRANSLATION_SYSTEM_PROMPT,
|
|
11
|
+
temperature: 0.3,
|
|
12
|
+
maxTokens: 0,
|
|
13
|
+
concurrency: 3,
|
|
14
|
+
maxRetries: 3,
|
|
15
|
+
retryBaseDelayMs: 1e3,
|
|
16
|
+
retryMaxDelayMs: 3e4
|
|
17
|
+
};
|
|
18
|
+
const CONFIG_LIMITS = {
|
|
19
|
+
temperature: { min: 0, max: 2 },
|
|
20
|
+
maxTokens: { min: 0, max: 2e5 },
|
|
21
|
+
concurrency: { min: 1, max: 8 },
|
|
22
|
+
maxRetries: { min: 1, max: 10 },
|
|
23
|
+
retryBaseDelayMs: { min: 100, max: 6e4 },
|
|
24
|
+
retryMaxDelayMs: { min: 100, max: 3e5 }
|
|
25
|
+
};
|
|
26
|
+
const editableKeys = [
|
|
27
|
+
"baseUrl",
|
|
28
|
+
"model",
|
|
29
|
+
"fallbackModel",
|
|
30
|
+
"systemPrompt",
|
|
31
|
+
"temperature",
|
|
32
|
+
"maxTokens",
|
|
33
|
+
"concurrency",
|
|
34
|
+
"maxRetries",
|
|
35
|
+
"retryBaseDelayMs",
|
|
36
|
+
"retryMaxDelayMs"
|
|
37
|
+
];
|
|
38
|
+
const normalizeString = (value, fallback) => {
|
|
39
|
+
if (typeof value !== "string") {
|
|
40
|
+
return fallback;
|
|
41
|
+
}
|
|
42
|
+
const trimmed = value.trim();
|
|
43
|
+
return trimmed.length > 0 ? trimmed : fallback;
|
|
44
|
+
};
|
|
45
|
+
const normalizeOptionalString = (value) => {
|
|
46
|
+
if (typeof value !== "string") {
|
|
47
|
+
return "";
|
|
48
|
+
}
|
|
49
|
+
return value.trim();
|
|
50
|
+
};
|
|
51
|
+
const toNumber = (value, fallback) => {
|
|
52
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
56
|
+
const parsed = Number(value);
|
|
57
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
58
|
+
}
|
|
59
|
+
return fallback;
|
|
60
|
+
};
|
|
61
|
+
const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
|
|
62
|
+
const normalizeNumber = (value, fallback, min, max) => clamp(toNumber(value, fallback), min, max);
|
|
63
|
+
const normalizeInteger = (value, fallback, min, max) => Math.floor(normalizeNumber(value, fallback, min, max));
|
|
64
|
+
const pickEditableTranslationConfig = (input) => {
|
|
65
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
66
|
+
return {};
|
|
67
|
+
}
|
|
68
|
+
return editableKeys.reduce((result, key) => {
|
|
69
|
+
if (Object.prototype.hasOwnProperty.call(input, key)) {
|
|
70
|
+
result[key] = input[key];
|
|
71
|
+
}
|
|
72
|
+
return result;
|
|
73
|
+
}, {});
|
|
74
|
+
};
|
|
75
|
+
const normalizeTranslationConfig = (input = {}) => {
|
|
76
|
+
const retryBaseDelayMs = normalizeInteger(
|
|
77
|
+
input.retryBaseDelayMs,
|
|
78
|
+
DEFAULT_TRANSLATION_CONFIG.retryBaseDelayMs,
|
|
79
|
+
CONFIG_LIMITS.retryBaseDelayMs.min,
|
|
80
|
+
CONFIG_LIMITS.retryBaseDelayMs.max
|
|
81
|
+
);
|
|
82
|
+
const retryMaxDelayMs = Math.max(
|
|
83
|
+
retryBaseDelayMs,
|
|
84
|
+
normalizeInteger(
|
|
85
|
+
input.retryMaxDelayMs,
|
|
86
|
+
DEFAULT_TRANSLATION_CONFIG.retryMaxDelayMs,
|
|
87
|
+
CONFIG_LIMITS.retryMaxDelayMs.min,
|
|
88
|
+
CONFIG_LIMITS.retryMaxDelayMs.max
|
|
89
|
+
)
|
|
90
|
+
);
|
|
91
|
+
return {
|
|
92
|
+
apiKey: normalizeOptionalString(input.apiKey),
|
|
93
|
+
baseUrl: normalizeString(input.baseUrl, DEFAULT_TRANSLATION_CONFIG.baseUrl),
|
|
94
|
+
model: normalizeString(input.model, DEFAULT_TRANSLATION_CONFIG.model),
|
|
95
|
+
fallbackModel: normalizeOptionalString(input.fallbackModel),
|
|
96
|
+
systemPrompt: normalizeString(input.systemPrompt, DEFAULT_TRANSLATION_CONFIG.systemPrompt),
|
|
97
|
+
temperature: normalizeNumber(
|
|
98
|
+
input.temperature,
|
|
99
|
+
DEFAULT_TRANSLATION_CONFIG.temperature,
|
|
100
|
+
CONFIG_LIMITS.temperature.min,
|
|
101
|
+
CONFIG_LIMITS.temperature.max
|
|
102
|
+
),
|
|
103
|
+
maxTokens: normalizeInteger(
|
|
104
|
+
input.maxTokens,
|
|
105
|
+
DEFAULT_TRANSLATION_CONFIG.maxTokens,
|
|
106
|
+
CONFIG_LIMITS.maxTokens.min,
|
|
107
|
+
CONFIG_LIMITS.maxTokens.max
|
|
108
|
+
),
|
|
109
|
+
concurrency: normalizeInteger(
|
|
110
|
+
input.concurrency,
|
|
111
|
+
DEFAULT_TRANSLATION_CONFIG.concurrency,
|
|
112
|
+
CONFIG_LIMITS.concurrency.min,
|
|
113
|
+
CONFIG_LIMITS.concurrency.max
|
|
114
|
+
),
|
|
115
|
+
maxRetries: normalizeInteger(
|
|
116
|
+
input.maxRetries,
|
|
117
|
+
DEFAULT_TRANSLATION_CONFIG.maxRetries,
|
|
118
|
+
CONFIG_LIMITS.maxRetries.min,
|
|
119
|
+
CONFIG_LIMITS.maxRetries.max
|
|
120
|
+
),
|
|
121
|
+
retryBaseDelayMs,
|
|
122
|
+
retryMaxDelayMs
|
|
123
|
+
};
|
|
124
|
+
};
|
|
125
|
+
const selectEditableTranslationConfig = (config2) => ({
|
|
126
|
+
baseUrl: config2.baseUrl,
|
|
127
|
+
model: config2.model,
|
|
128
|
+
fallbackModel: config2.fallbackModel,
|
|
129
|
+
systemPrompt: config2.systemPrompt,
|
|
130
|
+
temperature: config2.temperature,
|
|
131
|
+
maxTokens: config2.maxTokens,
|
|
132
|
+
concurrency: config2.concurrency,
|
|
133
|
+
maxRetries: config2.maxRetries,
|
|
134
|
+
retryBaseDelayMs: config2.retryBaseDelayMs,
|
|
135
|
+
retryMaxDelayMs: config2.retryMaxDelayMs
|
|
136
|
+
});
|
|
137
|
+
const normalizeEditableTranslationConfig = (input = {}) => selectEditableTranslationConfig(normalizeTranslationConfig(input));
|
|
138
|
+
const maskSecret = (value) => {
|
|
139
|
+
if (!value) {
|
|
140
|
+
return "";
|
|
141
|
+
}
|
|
142
|
+
if (value.length <= 8) {
|
|
143
|
+
return "****";
|
|
144
|
+
}
|
|
145
|
+
return `********${value.slice(-4)}`;
|
|
146
|
+
};
|
|
147
|
+
const toPublicTranslationConfig = (config2) => ({
|
|
148
|
+
...selectEditableTranslationConfig(config2),
|
|
149
|
+
apiKeyConfigured: config2.apiKey.length > 0,
|
|
150
|
+
apiKeyPreview: maskSecret(config2.apiKey)
|
|
151
|
+
});
|
|
152
|
+
const ALGORITHM = "aes-256-gcm";
|
|
153
|
+
const IV_LENGTH = 12;
|
|
154
|
+
const AUTH_TAG_LENGTH = 16;
|
|
155
|
+
const VERSION_TAG = "v1";
|
|
156
|
+
const deriveKey = (key) => {
|
|
157
|
+
if (!key || key.trim() === "") {
|
|
158
|
+
throw new Error("A non-empty secret key is required for the API key cipher.");
|
|
159
|
+
}
|
|
160
|
+
return createHash("sha256").update(key).digest();
|
|
161
|
+
};
|
|
162
|
+
const createSecretCipher = (config2) => {
|
|
163
|
+
const key = deriveKey(config2.key);
|
|
164
|
+
const encrypt = (plain) => {
|
|
165
|
+
if (!plain) return "";
|
|
166
|
+
const iv = randomBytes(IV_LENGTH);
|
|
167
|
+
const cipher = createCipheriv(ALGORITHM, key, iv);
|
|
168
|
+
const encrypted = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]);
|
|
169
|
+
const authTag = cipher.getAuthTag();
|
|
170
|
+
return [
|
|
171
|
+
VERSION_TAG,
|
|
172
|
+
iv.toString("base64"),
|
|
173
|
+
authTag.toString("base64"),
|
|
174
|
+
encrypted.toString("base64")
|
|
175
|
+
].join(":");
|
|
176
|
+
};
|
|
177
|
+
const decrypt = (payload) => {
|
|
178
|
+
if (!payload) return "";
|
|
179
|
+
const parts = payload.split(":");
|
|
180
|
+
if (parts.length !== 4 || parts[0] !== VERSION_TAG) {
|
|
181
|
+
throw new Error("Cannot decrypt API key: unrecognized cipher payload format.");
|
|
182
|
+
}
|
|
183
|
+
const iv = Buffer.from(parts[1], "base64");
|
|
184
|
+
const authTag = Buffer.from(parts[2], "base64");
|
|
185
|
+
const encrypted = Buffer.from(parts[3], "base64");
|
|
186
|
+
if (iv.length !== IV_LENGTH || authTag.length !== AUTH_TAG_LENGTH) {
|
|
187
|
+
throw new Error("Cannot decrypt API key: cipher payload has wrong length.");
|
|
188
|
+
}
|
|
189
|
+
const decipher = createDecipheriv(ALGORITHM, key, iv);
|
|
190
|
+
decipher.setAuthTag(authTag);
|
|
191
|
+
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
|
192
|
+
return decrypted.toString("utf8");
|
|
193
|
+
};
|
|
194
|
+
return { encrypt, decrypt };
|
|
195
|
+
};
|
|
196
|
+
const PLUGIN_ID$1 = "ai-translator";
|
|
197
|
+
const TRANSLATION_SETTINGS_STORE_KEY = "translation-settings";
|
|
198
|
+
const resolveCipherKey = (strapi) => {
|
|
199
|
+
const strapiConfigGet = strapi.config?.get;
|
|
200
|
+
if (typeof strapiConfigGet === "function") {
|
|
201
|
+
const encryptionKey = strapiConfigGet.call(strapi.config, "admin.secrets.encryptionKey");
|
|
202
|
+
if (typeof encryptionKey === "string" && encryptionKey.trim() !== "") {
|
|
203
|
+
return encryptionKey;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (typeof strapiConfigGet === "function") {
|
|
207
|
+
const appKeys = strapiConfigGet.call(strapi.config, "server.app.keys");
|
|
208
|
+
if (Array.isArray(appKeys) && typeof appKeys[0] === "string" && appKeys[0].trim() !== "") {
|
|
209
|
+
return appKeys[0];
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
throw new Error(
|
|
213
|
+
"AI Translator cannot persist API keys: configure ENCRYPTION_KEY or APP_KEYS in your Strapi environment."
|
|
214
|
+
);
|
|
215
|
+
};
|
|
216
|
+
const readNonSecretPluginConfig = (strapi, pluginId) => {
|
|
217
|
+
const pluginGetter = strapi.plugin;
|
|
218
|
+
const plugin = typeof pluginGetter === "function" ? pluginGetter.call(strapi, pluginId) : void 0;
|
|
219
|
+
const configGetter = plugin?.config;
|
|
220
|
+
const fromPluginConfig = {};
|
|
221
|
+
for (const key of Object.keys(DEFAULT_TRANSLATION_CONFIG)) {
|
|
222
|
+
if (key === "apiKey") {
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
if (typeof configGetter === "function") {
|
|
226
|
+
const value = configGetter.call(plugin, key);
|
|
227
|
+
if (value !== void 0) {
|
|
228
|
+
fromPluginConfig[key] = value;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (Object.keys(fromPluginConfig).length > 0) {
|
|
233
|
+
return pickEditableTranslationConfig(fromPluginConfig);
|
|
234
|
+
}
|
|
235
|
+
const strapiConfigGetter = strapi.config?.get;
|
|
236
|
+
if (typeof strapiConfigGetter !== "function") {
|
|
237
|
+
return {};
|
|
238
|
+
}
|
|
239
|
+
const config2 = strapiConfigGetter.call(strapi.config, `plugin::${pluginId}`);
|
|
240
|
+
return pickEditableTranslationConfig(config2);
|
|
241
|
+
};
|
|
242
|
+
const readEncryptedStoredApiKey = (input) => {
|
|
243
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
244
|
+
return "";
|
|
245
|
+
}
|
|
246
|
+
const value = input.apiKey;
|
|
247
|
+
return typeof value === "string" ? value : "";
|
|
248
|
+
};
|
|
249
|
+
const readPlainApiKeyUpdate = (input) => {
|
|
250
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
251
|
+
return "";
|
|
252
|
+
}
|
|
253
|
+
const value = input.apiKey;
|
|
254
|
+
return typeof value === "string" ? value.trim() : "";
|
|
255
|
+
};
|
|
256
|
+
const createTranslationConfigRepository = (strapi, options = {}) => {
|
|
257
|
+
const pluginId = options.pluginId ?? PLUGIN_ID$1;
|
|
258
|
+
const storeKey = options.storeKey ?? TRANSLATION_SETTINGS_STORE_KEY;
|
|
259
|
+
const cipher = options.cipher ?? createSecretCipher({ key: resolveCipherKey(strapi) });
|
|
260
|
+
const getStore = () => strapi.store({
|
|
261
|
+
environment: strapi.config.environment,
|
|
262
|
+
type: "plugin",
|
|
263
|
+
name: pluginId
|
|
264
|
+
});
|
|
265
|
+
const getStoredConfig = async () => getStore().get({ key: storeKey });
|
|
266
|
+
const decryptStoredApiKey = (storedConfig) => {
|
|
267
|
+
const encrypted = readEncryptedStoredApiKey(storedConfig);
|
|
268
|
+
if (!encrypted) return "";
|
|
269
|
+
try {
|
|
270
|
+
return cipher.decrypt(encrypted);
|
|
271
|
+
} catch {
|
|
272
|
+
strapi.log?.warn?.(
|
|
273
|
+
"[ai-translator] Stored API key could not be decrypted (was ENCRYPTION_KEY or APP_KEYS rotated?). Re-save the API key in the plugin settings."
|
|
274
|
+
);
|
|
275
|
+
return "";
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
const getConfig = async () => {
|
|
279
|
+
const pluginConfig = readNonSecretPluginConfig(strapi, pluginId);
|
|
280
|
+
const storedConfig = await getStoredConfig();
|
|
281
|
+
const storedEditableConfig = pickEditableTranslationConfig(
|
|
282
|
+
storedConfig
|
|
283
|
+
);
|
|
284
|
+
return normalizeTranslationConfig({
|
|
285
|
+
...DEFAULT_TRANSLATION_CONFIG,
|
|
286
|
+
...pluginConfig,
|
|
287
|
+
...storedEditableConfig,
|
|
288
|
+
apiKey: decryptStoredApiKey(storedConfig)
|
|
289
|
+
});
|
|
290
|
+
};
|
|
291
|
+
const getPublicConfig = async () => toPublicTranslationConfig(await getConfig());
|
|
292
|
+
const setEditableConfig = async (input) => {
|
|
293
|
+
const store = getStore();
|
|
294
|
+
const existingStoredConfig = await store.get({ key: storeKey });
|
|
295
|
+
const existing = pickEditableTranslationConfig(existingStoredConfig);
|
|
296
|
+
const updates = pickEditableTranslationConfig(input);
|
|
297
|
+
const existingEncryptedApiKey = readEncryptedStoredApiKey(existingStoredConfig);
|
|
298
|
+
const updatedPlainApiKey = readPlainApiKeyUpdate(input);
|
|
299
|
+
const clearApiKey = input?.apiKey === null;
|
|
300
|
+
const apiKeyCiphertext = clearApiKey ? "" : updatedPlainApiKey ? cipher.encrypt(updatedPlainApiKey) : existingEncryptedApiKey;
|
|
301
|
+
const value = normalizeEditableTranslationConfig({
|
|
302
|
+
...existing,
|
|
303
|
+
...updates
|
|
304
|
+
});
|
|
305
|
+
await store.set({
|
|
306
|
+
key: storeKey,
|
|
307
|
+
value: apiKeyCiphertext ? { ...value, apiKey: apiKeyCiphertext } : value
|
|
308
|
+
});
|
|
309
|
+
return getPublicConfig();
|
|
310
|
+
};
|
|
311
|
+
return {
|
|
312
|
+
getConfig,
|
|
313
|
+
getPublicConfig,
|
|
314
|
+
setEditableConfig
|
|
315
|
+
};
|
|
316
|
+
};
|
|
317
|
+
const SETTINGS_MANAGE_ACTION = {
|
|
318
|
+
section: "plugins",
|
|
319
|
+
displayName: "Manage settings",
|
|
320
|
+
uid: "settings.manage",
|
|
321
|
+
pluginName: PLUGIN_ID$1
|
|
322
|
+
};
|
|
323
|
+
const bootstrap = ({ strapi }) => {
|
|
324
|
+
const actionProvider = strapi.admin?.services?.permission?.actionProvider;
|
|
325
|
+
if (actionProvider && typeof actionProvider.registerMany === "function") {
|
|
326
|
+
actionProvider.registerMany([SETTINGS_MANAGE_ACTION]);
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
const NUMERIC_CONFIG_KEYS = [
|
|
330
|
+
"temperature",
|
|
331
|
+
"maxTokens",
|
|
332
|
+
"concurrency",
|
|
333
|
+
"maxRetries",
|
|
334
|
+
"retryBaseDelayMs",
|
|
335
|
+
"retryMaxDelayMs"
|
|
336
|
+
];
|
|
337
|
+
const config = {
|
|
338
|
+
default: () => selectEditableTranslationConfig(DEFAULT_TRANSLATION_CONFIG),
|
|
339
|
+
validator(config2) {
|
|
340
|
+
const normalized = normalizeEditableTranslationConfig(config2);
|
|
341
|
+
const invalidKeys = NUMERIC_CONFIG_KEYS.filter(
|
|
342
|
+
(key) => key in config2 && normalized[key] !== config2[key]
|
|
343
|
+
);
|
|
344
|
+
if (invalidKeys.length > 0) {
|
|
345
|
+
throw new Error(
|
|
346
|
+
`ai-translator plugin config has an invalid value for: ${invalidKeys.join(", ")}. Check config/plugins.(js|ts) for this plugin.`
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
const { ApplicationError } = errors;
|
|
352
|
+
const PLUGIN_ID = "ai-translator";
|
|
353
|
+
const writeSSE = (ctx, event) => {
|
|
354
|
+
ctx.res.write(`event: ${event.type}
|
|
355
|
+
data: ${JSON.stringify(event)}
|
|
356
|
+
|
|
357
|
+
`);
|
|
358
|
+
};
|
|
359
|
+
const getErrorMessage$1 = (error) => error instanceof Error ? error.message : String(error);
|
|
360
|
+
const controllers$1 = ({ strapi }) => ({
|
|
361
|
+
async getConfig(ctx) {
|
|
362
|
+
const translationService = strapi.plugin(PLUGIN_ID).service("translation-session-service");
|
|
363
|
+
ctx.body = await translationService.getConfig();
|
|
364
|
+
},
|
|
365
|
+
async setConfig(ctx) {
|
|
366
|
+
const translationService = strapi.plugin(PLUGIN_ID).service("translation-session-service");
|
|
367
|
+
ctx.body = await translationService.updateConfig(ctx.request.body);
|
|
368
|
+
},
|
|
369
|
+
async listModels(ctx) {
|
|
370
|
+
const body = ctx.request.body || {};
|
|
371
|
+
const draft = {
|
|
372
|
+
apiKey: typeof body.apiKey === "string" ? body.apiKey : void 0,
|
|
373
|
+
baseUrl: typeof body.baseUrl === "string" ? body.baseUrl : void 0
|
|
374
|
+
};
|
|
375
|
+
try {
|
|
376
|
+
const translationService = strapi.plugin(PLUGIN_ID).service("translation-session-service");
|
|
377
|
+
ctx.body = {
|
|
378
|
+
models: await translationService.listModels(draft)
|
|
379
|
+
};
|
|
380
|
+
} catch (error) {
|
|
381
|
+
throw new ApplicationError(getErrorMessage$1(error));
|
|
382
|
+
}
|
|
383
|
+
},
|
|
384
|
+
async translate(ctx) {
|
|
385
|
+
const { contentType, entry, components, sourceLocale, targetLocale, include } = ctx.request.body ?? {};
|
|
386
|
+
if (!contentType || !entry || !targetLocale) {
|
|
387
|
+
throw new ApplicationError("Missing required parameters: contentType, entry, targetLocale");
|
|
388
|
+
}
|
|
389
|
+
ctx.res.writeHead(200, {
|
|
390
|
+
"Content-Type": "text/event-stream",
|
|
391
|
+
"Cache-Control": "no-cache",
|
|
392
|
+
Connection: "keep-alive"
|
|
393
|
+
});
|
|
394
|
+
const abortController = new AbortController();
|
|
395
|
+
ctx.req.on("close", () => abortController.abort());
|
|
396
|
+
const keepalive = setInterval(() => {
|
|
397
|
+
if (abortController.signal.aborted) return;
|
|
398
|
+
try {
|
|
399
|
+
ctx.res.write(": keepalive\n\n");
|
|
400
|
+
} catch {
|
|
401
|
+
abortController.abort();
|
|
402
|
+
clearInterval(keepalive);
|
|
403
|
+
}
|
|
404
|
+
}, 15e3);
|
|
405
|
+
try {
|
|
406
|
+
const translationService = strapi.plugin(PLUGIN_ID).service("translation-session-service");
|
|
407
|
+
const generator = translationService.createTranslationStream({
|
|
408
|
+
contentType,
|
|
409
|
+
entry,
|
|
410
|
+
components,
|
|
411
|
+
sourceLocale,
|
|
412
|
+
targetLocale,
|
|
413
|
+
include,
|
|
414
|
+
signal: abortController.signal
|
|
415
|
+
});
|
|
416
|
+
for await (const event of generator) {
|
|
417
|
+
if (abortController.signal.aborted) break;
|
|
418
|
+
writeSSE(ctx, event);
|
|
419
|
+
}
|
|
420
|
+
} catch (error) {
|
|
421
|
+
writeSSE(ctx, {
|
|
422
|
+
type: "session_failed",
|
|
423
|
+
error: getErrorMessage$1(error)
|
|
424
|
+
});
|
|
425
|
+
} finally {
|
|
426
|
+
clearInterval(keepalive);
|
|
427
|
+
ctx.res.end();
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
const controllers = {
|
|
432
|
+
translation: controllers$1
|
|
433
|
+
};
|
|
434
|
+
const settingsPolicies = [
|
|
435
|
+
{
|
|
436
|
+
name: "admin::hasPermissions",
|
|
437
|
+
config: { actions: ["plugin::ai-translator.settings.manage"] }
|
|
438
|
+
}
|
|
439
|
+
];
|
|
440
|
+
const adminRoutes = [
|
|
441
|
+
{
|
|
442
|
+
method: "POST",
|
|
443
|
+
path: "/translate",
|
|
444
|
+
handler: "translation.translate",
|
|
445
|
+
config: {
|
|
446
|
+
policies: []
|
|
447
|
+
}
|
|
448
|
+
},
|
|
449
|
+
{
|
|
450
|
+
method: "GET",
|
|
451
|
+
path: "/config",
|
|
452
|
+
handler: "translation.getConfig",
|
|
453
|
+
config: {
|
|
454
|
+
policies: []
|
|
455
|
+
}
|
|
456
|
+
},
|
|
457
|
+
{
|
|
458
|
+
method: "POST",
|
|
459
|
+
path: "/config",
|
|
460
|
+
handler: "translation.setConfig",
|
|
461
|
+
config: {
|
|
462
|
+
policies: settingsPolicies
|
|
463
|
+
}
|
|
464
|
+
},
|
|
465
|
+
{
|
|
466
|
+
method: "POST",
|
|
467
|
+
path: "/models",
|
|
468
|
+
handler: "translation.listModels",
|
|
469
|
+
config: {
|
|
470
|
+
policies: settingsPolicies
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
];
|
|
474
|
+
const routes = {
|
|
475
|
+
admin: {
|
|
476
|
+
type: "admin",
|
|
477
|
+
routes: adminRoutes
|
|
478
|
+
}
|
|
479
|
+
};
|
|
480
|
+
const escapeSegment = (segment) => segment.replace(/~/g, "~0").replace(/\./g, "~1").replace(/:/g, "~2");
|
|
481
|
+
const encodeSegment = (segment) => {
|
|
482
|
+
if (typeof segment === "number") {
|
|
483
|
+
if (!Number.isInteger(segment) || segment < 0) {
|
|
484
|
+
throw new Error(`Invalid numeric path segment: ${segment}`);
|
|
485
|
+
}
|
|
486
|
+
return `n:${segment}`;
|
|
487
|
+
}
|
|
488
|
+
return `s:${escapeSegment(segment)}`;
|
|
489
|
+
};
|
|
490
|
+
const pathToId = (path) => path.map(encodeSegment).join(".");
|
|
491
|
+
const sanitizeFieldPaths = (value) => {
|
|
492
|
+
if (!Array.isArray(value)) {
|
|
493
|
+
return void 0;
|
|
494
|
+
}
|
|
495
|
+
return value.filter(
|
|
496
|
+
(path) => Array.isArray(path) && path.length > 0 && path.every((segment) => typeof segment === "string" || typeof segment === "number")
|
|
497
|
+
);
|
|
498
|
+
};
|
|
499
|
+
const pathsEqual = (left, right) => left.length === right.length && left.every((segment, index2) => segment === right[index2]);
|
|
500
|
+
const createEmptyTranslationPatch = () => ({
|
|
501
|
+
operations: []
|
|
502
|
+
});
|
|
503
|
+
const assembleTranslationPatch = (sourceUnits, translatedUnits) => {
|
|
504
|
+
const sourceById = new Map(sourceUnits.map((unit) => [unit.id, unit]));
|
|
505
|
+
return {
|
|
506
|
+
operations: translatedUnits.map((translatedUnit) => {
|
|
507
|
+
const sourceUnit = sourceById.get(translatedUnit.id);
|
|
508
|
+
if (!sourceUnit) {
|
|
509
|
+
throw new Error(`Translated unit does not match a source unit: ${translatedUnit.id}`);
|
|
510
|
+
}
|
|
511
|
+
if (!pathsEqual(sourceUnit.path, translatedUnit.path)) {
|
|
512
|
+
throw new Error(`Translated unit path mismatch: ${translatedUnit.id}`);
|
|
513
|
+
}
|
|
514
|
+
return {
|
|
515
|
+
unitId: sourceUnit.id,
|
|
516
|
+
path: sourceUnit.path,
|
|
517
|
+
value: translatedUnit.translatedText
|
|
518
|
+
};
|
|
519
|
+
})
|
|
520
|
+
};
|
|
521
|
+
};
|
|
522
|
+
const createTranslatableUnit = (input) => ({
|
|
523
|
+
id: pathToId(input.path),
|
|
524
|
+
path: [...input.path],
|
|
525
|
+
fieldName: input.fieldName,
|
|
526
|
+
fieldType: input.fieldType,
|
|
527
|
+
sourceValue: input.sourceValue,
|
|
528
|
+
encodedText: input.encodedText,
|
|
529
|
+
metadata: input.metadata ?? { schemaType: input.fieldType }
|
|
530
|
+
});
|
|
531
|
+
const createStringValueCodec = () => ({
|
|
532
|
+
fieldTypes: ["string", "text", "richtext"],
|
|
533
|
+
extract(value) {
|
|
534
|
+
if (typeof value !== "string" || value.trim().length === 0) return [];
|
|
535
|
+
return [
|
|
536
|
+
{
|
|
537
|
+
pathSuffix: [],
|
|
538
|
+
sourceValue: value,
|
|
539
|
+
encodedText: value
|
|
540
|
+
}
|
|
541
|
+
];
|
|
542
|
+
}
|
|
543
|
+
});
|
|
544
|
+
const isPlainRecord = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
545
|
+
const FORBIDDEN_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
|
|
546
|
+
const MAX_DEPTH = 64;
|
|
547
|
+
const shouldTranslateStringLeaf = (fieldType, pathSuffix) => {
|
|
548
|
+
if (fieldType === "blocks") {
|
|
549
|
+
return pathSuffix[pathSuffix.length - 1] === "text";
|
|
550
|
+
}
|
|
551
|
+
return fieldType === "json";
|
|
552
|
+
};
|
|
553
|
+
const collectStringLeaves = (fieldType, value, pathSuffix = []) => {
|
|
554
|
+
if (pathSuffix.length > MAX_DEPTH) return [];
|
|
555
|
+
if (typeof value === "string") {
|
|
556
|
+
if (!value.trim() || !shouldTranslateStringLeaf(fieldType, pathSuffix)) return [];
|
|
557
|
+
return [
|
|
558
|
+
{
|
|
559
|
+
pathSuffix,
|
|
560
|
+
sourceValue: value,
|
|
561
|
+
encodedText: value
|
|
562
|
+
}
|
|
563
|
+
];
|
|
564
|
+
}
|
|
565
|
+
if (Array.isArray(value)) {
|
|
566
|
+
return value.flatMap(
|
|
567
|
+
(item, index2) => collectStringLeaves(fieldType, item, [...pathSuffix, index2])
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
if (isPlainRecord(value)) {
|
|
571
|
+
return Object.entries(value).flatMap(
|
|
572
|
+
([key, nestedValue]) => FORBIDDEN_KEYS.has(key) ? [] : collectStringLeaves(fieldType, nestedValue, [...pathSuffix, key])
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
return [];
|
|
576
|
+
};
|
|
577
|
+
const createStructuredValueCodec = (fieldType) => ({
|
|
578
|
+
fieldTypes: [fieldType],
|
|
579
|
+
extract(value) {
|
|
580
|
+
return collectStringLeaves(fieldType, value);
|
|
581
|
+
}
|
|
582
|
+
});
|
|
583
|
+
const createDefaultValueCodecs = () => [
|
|
584
|
+
createStringValueCodec(),
|
|
585
|
+
createStructuredValueCodec("blocks"),
|
|
586
|
+
createStructuredValueCodec("json")
|
|
587
|
+
];
|
|
588
|
+
const DEFAULT_EXCLUDE_FIELD_NAMES = [
|
|
589
|
+
"id",
|
|
590
|
+
"documentId",
|
|
591
|
+
"locale",
|
|
592
|
+
"createdAt",
|
|
593
|
+
"updatedAt",
|
|
594
|
+
"publishedAt",
|
|
595
|
+
"createdBy",
|
|
596
|
+
"updatedBy",
|
|
597
|
+
"slug",
|
|
598
|
+
"uid"
|
|
599
|
+
];
|
|
600
|
+
const isLocalized = (schema) => schema.pluginOptions?.i18n?.localized !== false;
|
|
601
|
+
const MAX_SCHEMA_DEPTH = 32;
|
|
602
|
+
const extractTranslatableUnits = (contentType, entry, components = {}, options = {}) => {
|
|
603
|
+
const units = [];
|
|
604
|
+
const excludeFieldNames = new Set(options.excludeFieldNames ?? DEFAULT_EXCLUDE_FIELD_NAMES);
|
|
605
|
+
const valueCodecs = options.valueCodecs ?? createDefaultValueCodecs();
|
|
606
|
+
const traverse = (schema, data, path = [], dynamicZoneComponent) => {
|
|
607
|
+
if (!data || path.length > MAX_SCHEMA_DEPTH) return;
|
|
608
|
+
for (const [fieldName, rawFieldSchema] of Object.entries(schema.attributes || {})) {
|
|
609
|
+
const fieldSchema = rawFieldSchema;
|
|
610
|
+
const value = data[fieldName];
|
|
611
|
+
if (value == null || excludeFieldNames.has(fieldName) || !isLocalized(fieldSchema)) continue;
|
|
612
|
+
const fieldPath = [...path, fieldName];
|
|
613
|
+
const fieldType = fieldSchema.type;
|
|
614
|
+
const valueCodec = valueCodecs.find((codec) => codec.fieldTypes.includes(fieldType));
|
|
615
|
+
if (valueCodec) {
|
|
616
|
+
const encodedUnits = valueCodec.extract(value);
|
|
617
|
+
for (const encodedUnit of encodedUnits) {
|
|
618
|
+
const unitPath = [...fieldPath, ...encodedUnit.pathSuffix];
|
|
619
|
+
const leaf = unitPath[unitPath.length - 1];
|
|
620
|
+
units.push(
|
|
621
|
+
createTranslatableUnit({
|
|
622
|
+
path: unitPath,
|
|
623
|
+
fieldName: typeof leaf === "string" ? leaf : fieldName,
|
|
624
|
+
fieldType,
|
|
625
|
+
sourceValue: encodedUnit.sourceValue,
|
|
626
|
+
encodedText: encodedUnit.encodedText,
|
|
627
|
+
metadata: {
|
|
628
|
+
schemaType: fieldSchema.type,
|
|
629
|
+
component: fieldSchema.component,
|
|
630
|
+
dynamicZoneComponent
|
|
631
|
+
}
|
|
632
|
+
})
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
if (encodedUnits.length > 0) continue;
|
|
636
|
+
}
|
|
637
|
+
if (fieldSchema.type === "component") {
|
|
638
|
+
const componentSchema = components[fieldSchema.component];
|
|
639
|
+
if (!componentSchema) continue;
|
|
640
|
+
if (fieldSchema.repeatable && Array.isArray(value)) {
|
|
641
|
+
value.forEach((item, index2) => {
|
|
642
|
+
traverse(componentSchema, item, [...fieldPath, index2]);
|
|
643
|
+
});
|
|
644
|
+
} else if (typeof value === "object") {
|
|
645
|
+
traverse(componentSchema, value, fieldPath);
|
|
646
|
+
}
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
649
|
+
if (fieldSchema.type === "dynamiczone" && Array.isArray(value)) {
|
|
650
|
+
value.forEach((item, index2) => {
|
|
651
|
+
const componentUid = item?.__component;
|
|
652
|
+
const componentSchema = componentUid ? components[componentUid] : void 0;
|
|
653
|
+
if (componentSchema) {
|
|
654
|
+
traverse(componentSchema, item, [...fieldPath, index2], componentUid);
|
|
655
|
+
}
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
};
|
|
660
|
+
traverse(contentType, entry);
|
|
661
|
+
return units;
|
|
662
|
+
};
|
|
663
|
+
class TranslationCredentialError extends Error {
|
|
664
|
+
constructor(message, options) {
|
|
665
|
+
super(message);
|
|
666
|
+
this.name = "TranslationCredentialError";
|
|
667
|
+
this.cause = options?.cause;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
const RATE_LIMIT_STATUS = 429;
|
|
671
|
+
const errorMessage = (error) => error instanceof Error ? error.message : String(error);
|
|
672
|
+
const createSummary = (totalUnits, translatedUnits, failedUnitIds) => ({
|
|
673
|
+
totalUnits,
|
|
674
|
+
succeededUnits: translatedUnits.length,
|
|
675
|
+
failedUnits: failedUnitIds.length,
|
|
676
|
+
failedUnitIds
|
|
677
|
+
});
|
|
678
|
+
const isPathPrefix = (prefix, path) => prefix.length > 0 && prefix.length <= path.length && prefix.every((segment, index2) => segment === path[index2]);
|
|
679
|
+
const filterUnits = (units, include) => include ? units.filter((unit) => include.some((prefix) => isPathPrefix(prefix, unit.path))) : units;
|
|
680
|
+
async function* translateEntry(input) {
|
|
681
|
+
const units = filterUnits(
|
|
682
|
+
extractTranslatableUnits(input.contentType, input.entry, input.components ?? {}),
|
|
683
|
+
input.include
|
|
684
|
+
);
|
|
685
|
+
const translatedUnits = [];
|
|
686
|
+
const failedUnitIds = [];
|
|
687
|
+
yield {
|
|
688
|
+
type: "session_started",
|
|
689
|
+
sourceLocale: input.sourceLocale,
|
|
690
|
+
targetLocale: input.targetLocale,
|
|
691
|
+
totalUnits: units.length,
|
|
692
|
+
unitPaths: units.map((unit) => unit.path)
|
|
693
|
+
};
|
|
694
|
+
const pool = new AbortController();
|
|
695
|
+
const onAbort = () => pool.abort();
|
|
696
|
+
input.signal?.addEventListener("abort", onAbort, { once: true });
|
|
697
|
+
if (input.signal?.aborted) pool.abort();
|
|
698
|
+
const queue = [];
|
|
699
|
+
let wake;
|
|
700
|
+
const push = (event) => {
|
|
701
|
+
queue.push(event);
|
|
702
|
+
wake?.();
|
|
703
|
+
wake = void 0;
|
|
704
|
+
};
|
|
705
|
+
let credentialError;
|
|
706
|
+
let pauseUntil = 0;
|
|
707
|
+
const waitForDispatchWindow = async () => {
|
|
708
|
+
while (pauseUntil > Date.now() && !pool.signal.aborted) {
|
|
709
|
+
const delay = Math.min(500, pauseUntil - Date.now());
|
|
710
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
711
|
+
}
|
|
712
|
+
};
|
|
713
|
+
const runUnit = async (unit) => {
|
|
714
|
+
push({ type: "unit_started", unit });
|
|
715
|
+
try {
|
|
716
|
+
let translatedText;
|
|
717
|
+
for await (const providerEvent of input.translator.translateUnit(
|
|
718
|
+
unit,
|
|
719
|
+
{
|
|
720
|
+
sourceLocale: input.sourceLocale,
|
|
721
|
+
targetLocale: input.targetLocale
|
|
722
|
+
},
|
|
723
|
+
pool.signal
|
|
724
|
+
)) {
|
|
725
|
+
if (providerEvent.type === "delta") {
|
|
726
|
+
push({
|
|
727
|
+
type: "unit_delta",
|
|
728
|
+
unitId: unit.id,
|
|
729
|
+
path: unit.path,
|
|
730
|
+
delta: providerEvent.delta,
|
|
731
|
+
content: providerEvent.content
|
|
732
|
+
});
|
|
733
|
+
} else if (providerEvent.type === "completed") {
|
|
734
|
+
translatedText = providerEvent.translatedText;
|
|
735
|
+
} else if (providerEvent.type === "retry_scheduled") {
|
|
736
|
+
if (providerEvent.status === RATE_LIMIT_STATUS) {
|
|
737
|
+
pauseUntil = Math.max(pauseUntil, Date.now() + providerEvent.delayMs);
|
|
738
|
+
}
|
|
739
|
+
push({
|
|
740
|
+
type: "unit_retry_scheduled",
|
|
741
|
+
unitId: unit.id,
|
|
742
|
+
path: unit.path,
|
|
743
|
+
attempt: providerEvent.attempt,
|
|
744
|
+
delayMs: providerEvent.delayMs,
|
|
745
|
+
reason: providerEvent.reason
|
|
746
|
+
});
|
|
747
|
+
} else {
|
|
748
|
+
push({
|
|
749
|
+
type: "unit_fallback_used",
|
|
750
|
+
unitId: unit.id,
|
|
751
|
+
path: unit.path,
|
|
752
|
+
fromModel: providerEvent.fromModel,
|
|
753
|
+
toModel: providerEvent.toModel,
|
|
754
|
+
reason: providerEvent.reason
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
if (translatedText === void 0) {
|
|
759
|
+
throw new Error(`Translator did not complete unit: ${unit.id}`);
|
|
760
|
+
}
|
|
761
|
+
translatedUnits.push({ id: unit.id, path: unit.path, translatedText });
|
|
762
|
+
push({ type: "unit_completed", unitId: unit.id, path: unit.path, translatedText });
|
|
763
|
+
} catch (error) {
|
|
764
|
+
if (pool.signal.aborted) return;
|
|
765
|
+
if (error instanceof TranslationCredentialError) {
|
|
766
|
+
credentialError ??= error;
|
|
767
|
+
pool.abort();
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
failedUnitIds.push(unit.id);
|
|
771
|
+
push({ type: "unit_failed", unitId: unit.id, path: unit.path, error: errorMessage(error) });
|
|
772
|
+
}
|
|
773
|
+
};
|
|
774
|
+
let cursor = 0;
|
|
775
|
+
const worker = async () => {
|
|
776
|
+
while (!pool.signal.aborted) {
|
|
777
|
+
await waitForDispatchWindow();
|
|
778
|
+
if (pool.signal.aborted) return;
|
|
779
|
+
const unit = units[cursor++];
|
|
780
|
+
if (!unit) return;
|
|
781
|
+
await runUnit(unit);
|
|
782
|
+
}
|
|
783
|
+
};
|
|
784
|
+
const concurrency = Math.min(
|
|
785
|
+
Math.max(1, Math.floor(input.concurrency ?? 1)),
|
|
786
|
+
Math.max(1, units.length)
|
|
787
|
+
);
|
|
788
|
+
let settled = false;
|
|
789
|
+
const workers = Promise.all(Array.from({ length: concurrency }, () => worker())).finally(() => {
|
|
790
|
+
settled = true;
|
|
791
|
+
wake?.();
|
|
792
|
+
wake = void 0;
|
|
793
|
+
});
|
|
794
|
+
try {
|
|
795
|
+
while (!settled || queue.length > 0) {
|
|
796
|
+
const event = queue.shift();
|
|
797
|
+
if (event) {
|
|
798
|
+
yield event;
|
|
799
|
+
} else {
|
|
800
|
+
await new Promise((resolve) => {
|
|
801
|
+
wake = resolve;
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
await workers;
|
|
806
|
+
} finally {
|
|
807
|
+
input.signal?.removeEventListener("abort", onAbort);
|
|
808
|
+
pool.abort();
|
|
809
|
+
}
|
|
810
|
+
if (input.signal?.aborted) {
|
|
811
|
+
yield {
|
|
812
|
+
type: "session_aborted",
|
|
813
|
+
summary: createSummary(units.length, translatedUnits, failedUnitIds)
|
|
814
|
+
};
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
if (credentialError) {
|
|
818
|
+
yield { type: "session_failed", error: errorMessage(credentialError) };
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
const patch = translatedUnits.length > 0 ? assembleTranslationPatch(units, translatedUnits) : createEmptyTranslationPatch();
|
|
822
|
+
yield { type: "session_completed", patch, summary: createSummary(units.length, translatedUnits, failedUnitIds) };
|
|
823
|
+
}
|
|
824
|
+
const DEFAULT_RETRY_BASE_DELAY_MS = 1e3;
|
|
825
|
+
const DEFAULT_RETRY_MAX_DELAY_MS = 3e4;
|
|
826
|
+
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
827
|
+
const abortableSleep = async (sleep, ms, signal) => {
|
|
828
|
+
if (!signal) {
|
|
829
|
+
return sleep(ms);
|
|
830
|
+
}
|
|
831
|
+
let onAbort;
|
|
832
|
+
try {
|
|
833
|
+
await Promise.race([
|
|
834
|
+
sleep(ms),
|
|
835
|
+
new Promise((resolve) => {
|
|
836
|
+
onAbort = () => resolve();
|
|
837
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
838
|
+
})
|
|
839
|
+
]);
|
|
840
|
+
} finally {
|
|
841
|
+
if (onAbort) {
|
|
842
|
+
signal.removeEventListener("abort", onAbort);
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
};
|
|
846
|
+
const getErrorMessage = (error) => error instanceof Error ? error.message : String(error);
|
|
847
|
+
const retryDelay = (attempt, baseDelayMs, maxDelayMs) => Math.min(baseDelayMs * Math.pow(2, attempt - 1), maxDelayMs);
|
|
848
|
+
const getStatusCode = (error) => {
|
|
849
|
+
if (!error || typeof error !== "object") return void 0;
|
|
850
|
+
const candidate = error;
|
|
851
|
+
const status = candidate.status ?? candidate.statusCode;
|
|
852
|
+
return typeof status === "number" ? status : void 0;
|
|
853
|
+
};
|
|
854
|
+
const classifyError = (error) => {
|
|
855
|
+
const status = getStatusCode(error);
|
|
856
|
+
if (status === 401 || status === 403) return "credential";
|
|
857
|
+
if (status === 400 || status === 404 || status === 422) return "request";
|
|
858
|
+
return "transient";
|
|
859
|
+
};
|
|
860
|
+
const buildMessages = (unit, context, systemPrompt) => [
|
|
861
|
+
{
|
|
862
|
+
role: "system",
|
|
863
|
+
content: [
|
|
864
|
+
systemPrompt,
|
|
865
|
+
`Target locale: ${context.targetLocale}.`,
|
|
866
|
+
context.sourceLocale ? `Source locale: ${context.sourceLocale}.` : "",
|
|
867
|
+
"Translate only the user-provided content.",
|
|
868
|
+
"Return only the translated content with no labels, wrappers, or explanation."
|
|
869
|
+
].filter(Boolean).join(" ")
|
|
870
|
+
},
|
|
871
|
+
{
|
|
872
|
+
role: "user",
|
|
873
|
+
content: unit.encodedText
|
|
874
|
+
}
|
|
875
|
+
];
|
|
876
|
+
const createLLMTranslationProvider = (config2) => {
|
|
877
|
+
const sleep = config2.sleep ?? defaultSleep;
|
|
878
|
+
const retryBaseDelayMs = config2.retryBaseDelayMs ?? DEFAULT_RETRY_BASE_DELAY_MS;
|
|
879
|
+
const retryMaxDelayMs = config2.retryMaxDelayMs ?? DEFAULT_RETRY_MAX_DELAY_MS;
|
|
880
|
+
const maxRetries = Number.isFinite(config2.maxRetries) ? Math.max(1, config2.maxRetries) : 1;
|
|
881
|
+
async function* translateWithModel(unit, context, model, signal) {
|
|
882
|
+
let accumulated = "";
|
|
883
|
+
let finishReason = "unknown";
|
|
884
|
+
for await (const chunk of config2.client.streamCompletion({
|
|
885
|
+
model,
|
|
886
|
+
messages: buildMessages(unit, context, config2.systemPrompt),
|
|
887
|
+
temperature: config2.temperature,
|
|
888
|
+
maxTokens: config2.maxTokens && config2.maxTokens > 0 ? config2.maxTokens : void 0,
|
|
889
|
+
signal
|
|
890
|
+
})) {
|
|
891
|
+
if (chunk.delta) {
|
|
892
|
+
accumulated += chunk.delta;
|
|
893
|
+
yield {
|
|
894
|
+
type: "delta",
|
|
895
|
+
delta: chunk.delta,
|
|
896
|
+
content: accumulated
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
if (chunk.finishReason) {
|
|
900
|
+
finishReason = chunk.finishReason;
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
if (!accumulated.trim()) {
|
|
904
|
+
throw new Error("LLM returned empty translation");
|
|
905
|
+
}
|
|
906
|
+
if (finishReason === "length") {
|
|
907
|
+
throw new Error("LLM translation was truncated");
|
|
908
|
+
}
|
|
909
|
+
yield {
|
|
910
|
+
type: "completed",
|
|
911
|
+
translatedText: accumulated.trim()
|
|
912
|
+
};
|
|
913
|
+
}
|
|
914
|
+
async function* translateWithRetries(unit, context, model, signal) {
|
|
915
|
+
let lastError;
|
|
916
|
+
for (let attempt = 1; attempt <= maxRetries; attempt += 1) {
|
|
917
|
+
try {
|
|
918
|
+
yield* translateWithModel(unit, context, model, signal);
|
|
919
|
+
return;
|
|
920
|
+
} catch (error) {
|
|
921
|
+
lastError = error;
|
|
922
|
+
if (signal?.aborted) {
|
|
923
|
+
throw error;
|
|
924
|
+
}
|
|
925
|
+
if (classifyError(error) !== "transient") {
|
|
926
|
+
throw error;
|
|
927
|
+
}
|
|
928
|
+
if (attempt < maxRetries) {
|
|
929
|
+
const delayMs = retryDelay(attempt, retryBaseDelayMs, retryMaxDelayMs);
|
|
930
|
+
yield {
|
|
931
|
+
type: "retry_scheduled",
|
|
932
|
+
attempt,
|
|
933
|
+
delayMs,
|
|
934
|
+
reason: getErrorMessage(error),
|
|
935
|
+
status: getStatusCode(error)
|
|
936
|
+
};
|
|
937
|
+
await abortableSleep(sleep, delayMs, signal);
|
|
938
|
+
if (signal?.aborted) {
|
|
939
|
+
throw error;
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
throw lastError instanceof Error ? lastError : new Error(getErrorMessage(lastError));
|
|
945
|
+
}
|
|
946
|
+
async function* translateWithFallback(unit, context, signal) {
|
|
947
|
+
try {
|
|
948
|
+
yield* translateWithRetries(unit, context, config2.model, signal);
|
|
949
|
+
} catch (primaryError) {
|
|
950
|
+
if (!config2.fallbackModel || signal?.aborted || classifyError(primaryError) === "credential") {
|
|
951
|
+
throw primaryError;
|
|
952
|
+
}
|
|
953
|
+
yield {
|
|
954
|
+
type: "fallback_used",
|
|
955
|
+
fromModel: config2.model,
|
|
956
|
+
toModel: config2.fallbackModel,
|
|
957
|
+
reason: getErrorMessage(primaryError)
|
|
958
|
+
};
|
|
959
|
+
yield* translateWithRetries(unit, context, config2.fallbackModel, signal);
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
return {
|
|
963
|
+
async *translateUnit(unit, context, signal) {
|
|
964
|
+
try {
|
|
965
|
+
yield* translateWithFallback(unit, context, signal);
|
|
966
|
+
} catch (error) {
|
|
967
|
+
if (!signal?.aborted && classifyError(error) === "credential") {
|
|
968
|
+
throw new TranslationCredentialError(getErrorMessage(error), { cause: error });
|
|
969
|
+
}
|
|
970
|
+
throw error;
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
};
|
|
974
|
+
};
|
|
975
|
+
const SDK_MAX_RETRIES = 0;
|
|
976
|
+
const REQUEST_TIMEOUT_MS$1 = 12e4;
|
|
977
|
+
const createClient = (config2) => new OpenAI({
|
|
978
|
+
apiKey: config2.apiKey,
|
|
979
|
+
baseURL: config2.baseUrl,
|
|
980
|
+
maxRetries: SDK_MAX_RETRIES,
|
|
981
|
+
timeout: REQUEST_TIMEOUT_MS$1
|
|
982
|
+
});
|
|
983
|
+
const buildParams = (request) => {
|
|
984
|
+
const params = {
|
|
985
|
+
model: request.model,
|
|
986
|
+
messages: request.messages,
|
|
987
|
+
temperature: request.temperature,
|
|
988
|
+
stream: true
|
|
989
|
+
};
|
|
990
|
+
if (request.maxTokens && request.maxTokens > 0) {
|
|
991
|
+
params.max_tokens = request.maxTokens;
|
|
992
|
+
}
|
|
993
|
+
return params;
|
|
994
|
+
};
|
|
995
|
+
const createOpenAIChatCompletionClient = (config2) => {
|
|
996
|
+
const client = createClient(config2);
|
|
997
|
+
return {
|
|
998
|
+
async *streamCompletion(request) {
|
|
999
|
+
if (request.signal?.aborted) {
|
|
1000
|
+
throw new Error("Chat completion aborted");
|
|
1001
|
+
}
|
|
1002
|
+
const params = buildParams(request);
|
|
1003
|
+
const options = request.signal ? { signal: request.signal } : void 0;
|
|
1004
|
+
const stream = options ? await client.chat.completions.create(params, options) : await client.chat.completions.create(params);
|
|
1005
|
+
for await (const chunk of stream) {
|
|
1006
|
+
if (request.signal?.aborted) {
|
|
1007
|
+
throw new Error("Chat completion aborted");
|
|
1008
|
+
}
|
|
1009
|
+
const delta = chunk.choices?.[0]?.delta?.content || "";
|
|
1010
|
+
const finishReason = chunk.choices?.[0]?.finish_reason || void 0;
|
|
1011
|
+
if (delta || finishReason) {
|
|
1012
|
+
yield { delta, finishReason };
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
};
|
|
1017
|
+
};
|
|
1018
|
+
const REQUEST_TIMEOUT_MS = 1e4;
|
|
1019
|
+
const normalizeBaseUrl = (baseUrl) => baseUrl.trim().replace(/\/+$/, "");
|
|
1020
|
+
const buildModelsUrl = (baseUrl) => `${normalizeBaseUrl(baseUrl)}/models`;
|
|
1021
|
+
const hasVersionSuffix = (baseUrl) => /\/v\d+$/.test(normalizeBaseUrl(baseUrl));
|
|
1022
|
+
const parseModels = (payload) => {
|
|
1023
|
+
const items = payload && typeof payload === "object" && Array.isArray(payload.data) ? payload.data : [];
|
|
1024
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1025
|
+
const models = [];
|
|
1026
|
+
for (const item of items) {
|
|
1027
|
+
if (!item || typeof item !== "object") {
|
|
1028
|
+
continue;
|
|
1029
|
+
}
|
|
1030
|
+
const id = item.id;
|
|
1031
|
+
if (typeof id !== "string" || id.trim() === "" || seen.has(id)) {
|
|
1032
|
+
continue;
|
|
1033
|
+
}
|
|
1034
|
+
seen.add(id);
|
|
1035
|
+
models.push({ id, label: id });
|
|
1036
|
+
}
|
|
1037
|
+
return models;
|
|
1038
|
+
};
|
|
1039
|
+
const listOpenAICompatibleModels = async (config2) => {
|
|
1040
|
+
const fetchImpl = config2.fetch ?? globalThis.fetch;
|
|
1041
|
+
if (!fetchImpl) {
|
|
1042
|
+
throw new Error("Runtime fetch API is not available for loading provider models.");
|
|
1043
|
+
}
|
|
1044
|
+
let response;
|
|
1045
|
+
try {
|
|
1046
|
+
response = await fetchImpl(buildModelsUrl(config2.baseUrl), {
|
|
1047
|
+
headers: {
|
|
1048
|
+
Accept: "application/json",
|
|
1049
|
+
Authorization: `Bearer ${config2.apiKey}`
|
|
1050
|
+
},
|
|
1051
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
1052
|
+
});
|
|
1053
|
+
} catch (error) {
|
|
1054
|
+
if (error instanceof Error && error.name === "TimeoutError") {
|
|
1055
|
+
throw new Error(
|
|
1056
|
+
`Provider did not respond within ${REQUEST_TIMEOUT_MS / 1e3}s. Check the base URL.`
|
|
1057
|
+
);
|
|
1058
|
+
}
|
|
1059
|
+
throw error;
|
|
1060
|
+
}
|
|
1061
|
+
if (!response.ok) {
|
|
1062
|
+
const base = `Failed to load provider models: ${response.status} ${response.statusText}`.trim();
|
|
1063
|
+
const hint = response.status === 404 && !hasVersionSuffix(config2.baseUrl) ? ". Try appending /v1 to the base URL (most OpenAI-compatible APIs need a version segment)." : "";
|
|
1064
|
+
throw new Error(`${base}${hint}`);
|
|
1065
|
+
}
|
|
1066
|
+
const models = parseModels(await response.json());
|
|
1067
|
+
if (models.length === 0) {
|
|
1068
|
+
throw new Error("Provider did not return any usable models.");
|
|
1069
|
+
}
|
|
1070
|
+
return models;
|
|
1071
|
+
};
|
|
1072
|
+
const getRegisteredSchema = (strapi, uid) => {
|
|
1073
|
+
if (typeof uid !== "string" || uid.trim() === "") return void 0;
|
|
1074
|
+
try {
|
|
1075
|
+
const getModel = strapi.getModel;
|
|
1076
|
+
const model = typeof getModel === "function" ? getModel.call(strapi, uid) : void 0;
|
|
1077
|
+
return model && typeof model === "object" ? model : void 0;
|
|
1078
|
+
} catch {
|
|
1079
|
+
return void 0;
|
|
1080
|
+
}
|
|
1081
|
+
};
|
|
1082
|
+
const reconcileAttributes = (clientSchema, registeredSchema) => {
|
|
1083
|
+
const registeredAttributes = registeredSchema?.attributes;
|
|
1084
|
+
if (!registeredAttributes || typeof clientSchema?.attributes !== "object") {
|
|
1085
|
+
return clientSchema;
|
|
1086
|
+
}
|
|
1087
|
+
const attributes = {};
|
|
1088
|
+
for (const [fieldName, clientField] of Object.entries(
|
|
1089
|
+
clientSchema.attributes
|
|
1090
|
+
)) {
|
|
1091
|
+
const registeredField = registeredAttributes[fieldName];
|
|
1092
|
+
attributes[fieldName] = registeredField && typeof registeredField === "object" ? { ...clientField, pluginOptions: registeredField.pluginOptions } : clientField;
|
|
1093
|
+
}
|
|
1094
|
+
return { ...clientSchema, attributes };
|
|
1095
|
+
};
|
|
1096
|
+
const reconcileContentTypeSchema = (strapi, contentType, components = {}) => {
|
|
1097
|
+
const reconciledContentType = reconcileAttributes(
|
|
1098
|
+
contentType,
|
|
1099
|
+
getRegisteredSchema(strapi, contentType?.uid)
|
|
1100
|
+
);
|
|
1101
|
+
const reconciledComponents = {};
|
|
1102
|
+
for (const [componentUid, componentSchema] of Object.entries(components)) {
|
|
1103
|
+
reconciledComponents[componentUid] = reconcileAttributes(
|
|
1104
|
+
componentSchema,
|
|
1105
|
+
getRegisteredSchema(strapi, componentUid)
|
|
1106
|
+
);
|
|
1107
|
+
}
|
|
1108
|
+
return { contentType: reconciledContentType, components: reconciledComponents };
|
|
1109
|
+
};
|
|
1110
|
+
const assertApiKeyConfigured = (config2) => {
|
|
1111
|
+
if (!config2.apiKey) {
|
|
1112
|
+
throw new Error(
|
|
1113
|
+
"Translator API key is not configured. Configure an API key in the plugin settings."
|
|
1114
|
+
);
|
|
1115
|
+
}
|
|
1116
|
+
if (!config2.baseUrl) {
|
|
1117
|
+
throw new Error(
|
|
1118
|
+
"Provider base URL is not configured. Set a base URL in the plugin settings."
|
|
1119
|
+
);
|
|
1120
|
+
}
|
|
1121
|
+
if (!config2.model) {
|
|
1122
|
+
throw new Error("Translation model is not configured. Select a model in the plugin settings.");
|
|
1123
|
+
}
|
|
1124
|
+
};
|
|
1125
|
+
const createProvider = (config2, createChatClient, createTranslationProvider) => {
|
|
1126
|
+
assertApiKeyConfigured(config2);
|
|
1127
|
+
const client = createChatClient({
|
|
1128
|
+
apiKey: config2.apiKey,
|
|
1129
|
+
baseUrl: config2.baseUrl
|
|
1130
|
+
});
|
|
1131
|
+
return createTranslationProvider({
|
|
1132
|
+
client,
|
|
1133
|
+
model: config2.model,
|
|
1134
|
+
fallbackModel: config2.fallbackModel || void 0,
|
|
1135
|
+
systemPrompt: config2.systemPrompt,
|
|
1136
|
+
temperature: config2.temperature,
|
|
1137
|
+
maxTokens: config2.maxTokens,
|
|
1138
|
+
maxRetries: config2.maxRetries,
|
|
1139
|
+
retryBaseDelayMs: config2.retryBaseDelayMs,
|
|
1140
|
+
retryMaxDelayMs: config2.retryMaxDelayMs
|
|
1141
|
+
});
|
|
1142
|
+
};
|
|
1143
|
+
const createTranslationSessionService = (strapi, dependencies = {}) => {
|
|
1144
|
+
const configRepository = dependencies.configRepository ?? createTranslationConfigRepository(strapi);
|
|
1145
|
+
const createChatClient = dependencies.createChatClient ?? createOpenAIChatCompletionClient;
|
|
1146
|
+
const createTranslationProvider = dependencies.createTranslationProvider ?? createLLMTranslationProvider;
|
|
1147
|
+
const loadModels = dependencies.listModels ?? listOpenAICompatibleModels;
|
|
1148
|
+
const listModels = async (draft) => {
|
|
1149
|
+
const config2 = await configRepository.getConfig();
|
|
1150
|
+
const draftApiKey = typeof draft?.apiKey === "string" ? draft.apiKey.trim() : "";
|
|
1151
|
+
const draftBaseUrl = typeof draft?.baseUrl === "string" ? draft.baseUrl.trim() : "";
|
|
1152
|
+
const effectiveBaseUrl = draftBaseUrl || config2.baseUrl;
|
|
1153
|
+
const isSavedBaseUrl = draftBaseUrl.length === 0 || draftBaseUrl === config2.baseUrl;
|
|
1154
|
+
const effectiveApiKey = draftApiKey || (isSavedBaseUrl ? config2.apiKey : "");
|
|
1155
|
+
if (!effectiveBaseUrl) {
|
|
1156
|
+
throw new Error("Provider base URL is not configured. Set a base URL in the plugin settings.");
|
|
1157
|
+
}
|
|
1158
|
+
if (!effectiveApiKey) {
|
|
1159
|
+
throw new Error(
|
|
1160
|
+
isSavedBaseUrl ? "Translator API key is not configured. Configure an API key in the plugin settings." : "An API key is required to list models for a base URL that differs from the saved configuration."
|
|
1161
|
+
);
|
|
1162
|
+
}
|
|
1163
|
+
return loadModels({
|
|
1164
|
+
apiKey: effectiveApiKey,
|
|
1165
|
+
baseUrl: effectiveBaseUrl
|
|
1166
|
+
});
|
|
1167
|
+
};
|
|
1168
|
+
const createTranslationStream = async function* (input) {
|
|
1169
|
+
const config2 = await configRepository.getConfig();
|
|
1170
|
+
const translator = createProvider(config2, createChatClient, createTranslationProvider);
|
|
1171
|
+
const { contentType, components } = reconcileContentTypeSchema(
|
|
1172
|
+
strapi,
|
|
1173
|
+
input.contentType,
|
|
1174
|
+
input.components
|
|
1175
|
+
);
|
|
1176
|
+
yield* translateEntry({
|
|
1177
|
+
contentType,
|
|
1178
|
+
entry: input.entry,
|
|
1179
|
+
components,
|
|
1180
|
+
sourceLocale: input.sourceLocale,
|
|
1181
|
+
targetLocale: input.targetLocale,
|
|
1182
|
+
translator,
|
|
1183
|
+
signal: input.signal,
|
|
1184
|
+
include: sanitizeFieldPaths(input.include),
|
|
1185
|
+
concurrency: config2.concurrency
|
|
1186
|
+
});
|
|
1187
|
+
};
|
|
1188
|
+
return {
|
|
1189
|
+
getConfig: configRepository.getPublicConfig,
|
|
1190
|
+
updateConfig: configRepository.setEditableConfig,
|
|
1191
|
+
listModels,
|
|
1192
|
+
createTranslationStream
|
|
1193
|
+
};
|
|
1194
|
+
};
|
|
1195
|
+
const translationSessionService = ({ strapi }) => {
|
|
1196
|
+
return createTranslationSessionService(strapi);
|
|
1197
|
+
};
|
|
1198
|
+
const services = {
|
|
1199
|
+
"translation-session-service": translationSessionService
|
|
1200
|
+
};
|
|
1201
|
+
const index = {
|
|
1202
|
+
register() {
|
|
1203
|
+
},
|
|
1204
|
+
bootstrap,
|
|
1205
|
+
destroy() {
|
|
1206
|
+
},
|
|
1207
|
+
config,
|
|
1208
|
+
controllers,
|
|
1209
|
+
routes,
|
|
1210
|
+
services,
|
|
1211
|
+
contentTypes: {},
|
|
1212
|
+
policies: {},
|
|
1213
|
+
middlewares: {}
|
|
1214
|
+
};
|
|
1215
|
+
export {
|
|
1216
|
+
index as default
|
|
1217
|
+
};
|