@goenhance/strapi-plugins-translate 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +88 -0
- package/dist/_chunks/App-DvJ8tPer.js +674 -0
- package/dist/_chunks/App-uB_CPrcd.mjs +674 -0
- package/dist/_chunks/en-BOBGCAB6.mjs +65 -0
- package/dist/_chunks/en-BaJyCZ_c.js +65 -0
- package/dist/_chunks/index-B8MBOdIV.js +276 -0
- package/dist/_chunks/index-DkZZ45sW.mjs +277 -0
- package/dist/_chunks/zh-CtGwhmjc.mjs +65 -0
- package/dist/_chunks/zh-DUCnaWvE.js +65 -0
- package/dist/_chunks/zh-Hans-CJW3RUKL.js +65 -0
- package/dist/_chunks/zh-Hans-pTiPqgIS.mjs +65 -0
- package/dist/admin/index.js +3 -0
- package/dist/admin/index.mjs +4 -0
- package/dist/admin/src/components/Initializer.d.ts +5 -0
- package/dist/admin/src/components/LLMButton.d.ts +2 -0
- package/dist/admin/src/components/PluginIcon.d.ts +18 -0
- package/dist/admin/src/index.d.ts +11 -0
- package/dist/admin/src/pages/App.d.ts +2 -0
- package/dist/admin/src/pages/BatchTranslatePage.d.ts +2 -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/constants.d.ts +4 -0
- package/dist/admin/src/utils/getLocaleFromUrl.d.ts +1 -0
- package/dist/admin/src/utils/getTranslation.d.ts +2 -0
- package/dist/server/index.js +754 -0
- package/dist/server/index.mjs +755 -0
- package/dist/server/src/bootstrap.d.ts +5 -0
- package/dist/server/src/config/constants.d.ts +7 -0
- package/dist/server/src/config/index.d.ts +11 -0
- package/dist/server/src/content-types/index.d.ts +2 -0
- package/dist/server/src/controllers/admin.controller.d.ts +21 -0
- package/dist/server/src/controllers/index.d.ts +43 -0
- package/dist/server/src/destroy.d.ts +5 -0
- package/dist/server/src/index.d.ts +2 -0
- package/dist/server/src/middlewares/index.d.ts +2 -0
- package/dist/server/src/policies/index.d.ts +2 -0
- package/dist/server/src/register.d.ts +5 -0
- package/dist/server/src/routes/admin.d.ts +9 -0
- package/dist/server/src/routes/index.d.ts +14 -0
- package/dist/server/src/services/index.d.ts +2 -0
- package/dist/server/src/services/llm-service.d.ts +6 -0
- package/dist/server/src/types/controllers.d.ts +22 -0
- package/dist/server/src/types/index.d.ts +53 -0
- package/dist/server/src/utils/json-utils.d.ts +4 -0
- package/package.json +88 -0
|
@@ -0,0 +1,755 @@
|
|
|
1
|
+
import { OpenAI } from "openai";
|
|
2
|
+
const bootstrap = ({ strapi: strapi2 }) => {
|
|
3
|
+
};
|
|
4
|
+
const destroy = ({ strapi: strapi2 }) => {
|
|
5
|
+
};
|
|
6
|
+
const register = ({ strapi: strapi2 }) => {
|
|
7
|
+
};
|
|
8
|
+
const DEFAULT_SYSTEM_PROMPT = "You are a professional translator. Your task is to translate the provided content accurately while preserving the original meaning and tone.";
|
|
9
|
+
const SYSTEM_PROMPT_APPENDIX = `The user asks you to translate the text to a specific language, the language is provided via short code like "en", "fr", "de", etc.`;
|
|
10
|
+
const DEFAULT_LLM_TEMPERATURE = 0.3;
|
|
11
|
+
const DEFAULT_LLM_MODEL = "gpt-4o";
|
|
12
|
+
const DEFAULT_LLM_BASE_URL = "https://api.openai.com/v1";
|
|
13
|
+
const SYSTEM_PROMPT_FIX = `You are a JSON correction assistant. Only return valid, corrected JSON.`;
|
|
14
|
+
const USER_PROMPT_FIX_PREFIX = "Fix this invalid JSON and return ONLY the corrected JSON. No explanations allowed. The JSON is:";
|
|
15
|
+
const config = {
|
|
16
|
+
default: ({ env }) => ({
|
|
17
|
+
llmApiKey: env("LLM_TRANSLATOR_LLM_API_KEY"),
|
|
18
|
+
llmEndpoint: env("STRAPI_ADMIN_LLM_TRANSLATOR_LLM_BASE_URL"),
|
|
19
|
+
llmModel: env("STRAPI_ADMIN_LLM_TRANSLATOR_LLM_MODEL")
|
|
20
|
+
}),
|
|
21
|
+
validator(config2) {
|
|
22
|
+
const PLUGIN_NAME = "Strapi LLM Translator";
|
|
23
|
+
console.info(`
|
|
24
|
+
==== ${PLUGIN_NAME} Configuration Validation ====`);
|
|
25
|
+
if (!config2?.llmApiKey) {
|
|
26
|
+
console.warn("⚠️ LLM API Key: Missing");
|
|
27
|
+
console.info(" → Translation features requiring API keys will be disabled");
|
|
28
|
+
} else {
|
|
29
|
+
console.info("✅ LLM API Key: Configured");
|
|
30
|
+
}
|
|
31
|
+
const endpoint = config2?.llmEndpoint || DEFAULT_LLM_BASE_URL;
|
|
32
|
+
if (!config2?.llmEndpoint) {
|
|
33
|
+
console.warn(`⚠️ API Endpoint: Using default (${DEFAULT_LLM_BASE_URL})`);
|
|
34
|
+
} else {
|
|
35
|
+
console.info(`✅ API Endpoint: Configured (${endpoint})`);
|
|
36
|
+
}
|
|
37
|
+
const model = config2?.llmModel || DEFAULT_LLM_MODEL;
|
|
38
|
+
if (!config2?.llmModel) {
|
|
39
|
+
console.warn(`⚠️ LLM Model: Using default (${DEFAULT_LLM_MODEL})`);
|
|
40
|
+
} else {
|
|
41
|
+
console.info(`✅ LLM Model: Configured (${model})`);
|
|
42
|
+
}
|
|
43
|
+
console.info("========================================================\n");
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
const contentTypes = {};
|
|
47
|
+
const controllers$1 = ({ strapi: strapi2 }) => ({
|
|
48
|
+
// Genertate translations
|
|
49
|
+
async generate(ctx) {
|
|
50
|
+
try {
|
|
51
|
+
const { fields, components, targetLanguage, contentType } = ctx.request.body;
|
|
52
|
+
const result = await strapi2.plugin("strapi-plugins-translate").service("llm-service").generateWithLLM(contentType, fields, components, {
|
|
53
|
+
targetLanguage
|
|
54
|
+
});
|
|
55
|
+
ctx.status = result.meta.status;
|
|
56
|
+
ctx.body = result;
|
|
57
|
+
} catch (error) {
|
|
58
|
+
console.error("Error in generate controller:", error);
|
|
59
|
+
ctx.status = 500;
|
|
60
|
+
ctx.body = {
|
|
61
|
+
meta: {
|
|
62
|
+
ok: false,
|
|
63
|
+
status: 500,
|
|
64
|
+
message: "Internal server error"
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
// Get the configuration
|
|
70
|
+
async getConfig(ctx) {
|
|
71
|
+
const pluginStore = strapi2.store({
|
|
72
|
+
environment: strapi2.config.environment,
|
|
73
|
+
type: "plugin",
|
|
74
|
+
name: "strapi-plugins-translate"
|
|
75
|
+
// replace with your plugin name
|
|
76
|
+
});
|
|
77
|
+
const config2 = await pluginStore.get({ key: "configuration" });
|
|
78
|
+
ctx.body = config2 || {};
|
|
79
|
+
},
|
|
80
|
+
// Save the configuration
|
|
81
|
+
async setConfig(ctx) {
|
|
82
|
+
const { body } = ctx.request;
|
|
83
|
+
const pluginStore = strapi2.store({
|
|
84
|
+
environment: strapi2.config.environment,
|
|
85
|
+
type: "plugin",
|
|
86
|
+
name: "strapi-plugins-translate"
|
|
87
|
+
// replace with your plugin name
|
|
88
|
+
});
|
|
89
|
+
await pluginStore.set({
|
|
90
|
+
key: "configuration",
|
|
91
|
+
value: { ...body }
|
|
92
|
+
});
|
|
93
|
+
ctx.body = await pluginStore.get({ key: "configuration" });
|
|
94
|
+
},
|
|
95
|
+
// Get all content types that support i18n
|
|
96
|
+
async getContentTypes(ctx) {
|
|
97
|
+
try {
|
|
98
|
+
const contentTypes2 = strapi2.contentTypes;
|
|
99
|
+
const i18nContentTypes = [];
|
|
100
|
+
Object.entries(contentTypes2).forEach(([uid, contentType]) => {
|
|
101
|
+
const isI18nEnabled = contentType.pluginOptions?.i18n?.localized === true;
|
|
102
|
+
const isApiContentType = uid.startsWith("api::");
|
|
103
|
+
if (isI18nEnabled && isApiContentType) {
|
|
104
|
+
i18nContentTypes.push({
|
|
105
|
+
uid,
|
|
106
|
+
displayName: contentType.info?.displayName || uid,
|
|
107
|
+
apiID: contentType.info?.singularName || uid.split(".").pop() || uid,
|
|
108
|
+
kind: contentType.kind || "collectionType"
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
ctx.body = {
|
|
113
|
+
data: i18nContentTypes,
|
|
114
|
+
meta: { ok: true }
|
|
115
|
+
};
|
|
116
|
+
} catch (error) {
|
|
117
|
+
console.error("Error getting content types:", error);
|
|
118
|
+
ctx.status = 500;
|
|
119
|
+
ctx.body = {
|
|
120
|
+
data: [],
|
|
121
|
+
meta: { ok: false, message: "Failed to get content types" }
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
// Get all available locales
|
|
126
|
+
async getLocales(ctx) {
|
|
127
|
+
try {
|
|
128
|
+
const locales = await strapi2.plugin("i18n").service("locales").find();
|
|
129
|
+
ctx.body = {
|
|
130
|
+
data: locales,
|
|
131
|
+
meta: { ok: true }
|
|
132
|
+
};
|
|
133
|
+
} catch (error) {
|
|
134
|
+
console.error("Error getting locales:", error);
|
|
135
|
+
ctx.status = 500;
|
|
136
|
+
ctx.body = {
|
|
137
|
+
data: [],
|
|
138
|
+
meta: { ok: false, message: "Failed to get locales" }
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
// Get entries for a specific content type and locale
|
|
143
|
+
async getEntries(ctx) {
|
|
144
|
+
try {
|
|
145
|
+
const { contentType } = ctx.params;
|
|
146
|
+
const { locale, page = "1", pageSize = "25" } = ctx.query;
|
|
147
|
+
const decodedContentType = decodeURIComponent(contentType);
|
|
148
|
+
const contentTypeSchema = strapi2.contentTypes[decodedContentType];
|
|
149
|
+
if (!contentTypeSchema) {
|
|
150
|
+
ctx.status = 404;
|
|
151
|
+
ctx.body = {
|
|
152
|
+
data: [],
|
|
153
|
+
meta: { ok: false, message: "Content type not found" }
|
|
154
|
+
};
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const mainField = contentTypeSchema.info?.mainField || Object.keys(contentTypeSchema.attributes).find(
|
|
158
|
+
(key) => contentTypeSchema.attributes[key].type === "string" && ["title", "name", "label"].includes(key.toLowerCase())
|
|
159
|
+
) || "id";
|
|
160
|
+
const idField = Object.keys(contentTypeSchema.attributes).find(
|
|
161
|
+
(key) => (key.endsWith("_id") || key.endsWith("Id") || contentTypeSchema.attributes[key].type === "uid" && key !== "slug") && key != "category_id"
|
|
162
|
+
);
|
|
163
|
+
const query = {
|
|
164
|
+
page: parseInt(page, 10),
|
|
165
|
+
pageSize: parseInt(pageSize, 10),
|
|
166
|
+
populate: "*"
|
|
167
|
+
};
|
|
168
|
+
if (locale) {
|
|
169
|
+
query.locale = locale;
|
|
170
|
+
}
|
|
171
|
+
const entries = await strapi2.documents(decodedContentType).findMany(query);
|
|
172
|
+
const count = await strapi2.documents(decodedContentType).count(
|
|
173
|
+
locale ? { locale } : {}
|
|
174
|
+
);
|
|
175
|
+
const getDisplayValue = (value) => {
|
|
176
|
+
if (value === null || value === void 0) {
|
|
177
|
+
return "";
|
|
178
|
+
}
|
|
179
|
+
if (typeof value === "string" || typeof value === "number") {
|
|
180
|
+
return String(value);
|
|
181
|
+
}
|
|
182
|
+
if (typeof value === "object") {
|
|
183
|
+
return value.title || value.name || value.label || value.id || JSON.stringify(value);
|
|
184
|
+
}
|
|
185
|
+
return String(value);
|
|
186
|
+
};
|
|
187
|
+
const entriesWithLocaleInfo = await Promise.all(
|
|
188
|
+
entries.map(async (entry) => {
|
|
189
|
+
const localizations = entry.localizations || [];
|
|
190
|
+
const existingLocales = [entry.locale, ...localizations.map((l) => l.locale)];
|
|
191
|
+
const titleValue = entry[mainField];
|
|
192
|
+
const displayTitle = getDisplayValue(titleValue) || getDisplayValue(entry.id);
|
|
193
|
+
const customIdValue = idField ? entry[idField] : null;
|
|
194
|
+
const displayCustomId = customIdValue ? getDisplayValue(customIdValue) : null;
|
|
195
|
+
return {
|
|
196
|
+
id: entry.id,
|
|
197
|
+
documentId: entry.documentId,
|
|
198
|
+
customId: displayCustomId,
|
|
199
|
+
title: displayTitle,
|
|
200
|
+
locale: entry.locale,
|
|
201
|
+
publishedAt: entry.publishedAt,
|
|
202
|
+
existingLocales,
|
|
203
|
+
createdAt: entry.createdAt,
|
|
204
|
+
updatedAt: entry.updatedAt
|
|
205
|
+
};
|
|
206
|
+
})
|
|
207
|
+
);
|
|
208
|
+
ctx.body = {
|
|
209
|
+
data: entriesWithLocaleInfo,
|
|
210
|
+
meta: {
|
|
211
|
+
ok: true,
|
|
212
|
+
pagination: {
|
|
213
|
+
page: parseInt(page, 10),
|
|
214
|
+
pageSize: parseInt(pageSize, 10),
|
|
215
|
+
total: count,
|
|
216
|
+
pageCount: Math.ceil(count / parseInt(pageSize, 10))
|
|
217
|
+
},
|
|
218
|
+
mainField,
|
|
219
|
+
idField
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
} catch (error) {
|
|
223
|
+
console.error("Error getting entries:", error);
|
|
224
|
+
ctx.status = 500;
|
|
225
|
+
ctx.body = {
|
|
226
|
+
data: [],
|
|
227
|
+
meta: { ok: false, message: "Failed to get entries" }
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
// Batch translate entries
|
|
232
|
+
async batchTranslate(ctx) {
|
|
233
|
+
try {
|
|
234
|
+
const { contentType, entries, sourceLocale, targetLocales, publish = false } = ctx.request.body;
|
|
235
|
+
const results = [];
|
|
236
|
+
const contentTypeSchema = strapi2.contentTypes[contentType];
|
|
237
|
+
if (!contentTypeSchema) {
|
|
238
|
+
ctx.status = 404;
|
|
239
|
+
ctx.body = {
|
|
240
|
+
data: results,
|
|
241
|
+
meta: { ok: false, message: "Content type not found" }
|
|
242
|
+
};
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const components = {};
|
|
246
|
+
Object.entries(contentTypeSchema.attributes || {}).forEach(
|
|
247
|
+
([, attr]) => {
|
|
248
|
+
if (attr.type === "component" && attr.component) {
|
|
249
|
+
components[attr.component] = strapi2.components[attr.component];
|
|
250
|
+
} else if (attr.type === "dynamiczone" && attr.components) {
|
|
251
|
+
attr.components.forEach((comp) => {
|
|
252
|
+
components[comp] = strapi2.components[comp];
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
);
|
|
257
|
+
for (const entryId of entries) {
|
|
258
|
+
const sourceEntry = await strapi2.documents(contentType).findOne({
|
|
259
|
+
documentId: entryId,
|
|
260
|
+
locale: sourceLocale,
|
|
261
|
+
populate: "*"
|
|
262
|
+
});
|
|
263
|
+
if (!sourceEntry) {
|
|
264
|
+
results.push({
|
|
265
|
+
documentId: entryId,
|
|
266
|
+
locale: sourceLocale,
|
|
267
|
+
success: false,
|
|
268
|
+
message: "Source entry not found"
|
|
269
|
+
});
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
for (const targetLocale of targetLocales) {
|
|
273
|
+
try {
|
|
274
|
+
const existingTranslation = await strapi2.documents(contentType).findOne({
|
|
275
|
+
documentId: entryId,
|
|
276
|
+
locale: targetLocale
|
|
277
|
+
});
|
|
278
|
+
if (existingTranslation) {
|
|
279
|
+
results.push({
|
|
280
|
+
documentId: entryId,
|
|
281
|
+
locale: targetLocale,
|
|
282
|
+
success: false,
|
|
283
|
+
message: "Translation already exists"
|
|
284
|
+
});
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
const translationResult = await strapi2.plugin("strapi-plugins-translate").service("llm-service").generateWithLLM(contentTypeSchema, sourceEntry, components, {
|
|
288
|
+
targetLanguage: targetLocale
|
|
289
|
+
});
|
|
290
|
+
if (!translationResult.meta.ok) {
|
|
291
|
+
results.push({
|
|
292
|
+
documentId: entryId,
|
|
293
|
+
locale: targetLocale,
|
|
294
|
+
success: false,
|
|
295
|
+
message: translationResult.meta.message || "Translation failed"
|
|
296
|
+
});
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
const translatedData = { ...translationResult.data };
|
|
300
|
+
delete translatedData.id;
|
|
301
|
+
delete translatedData.documentId;
|
|
302
|
+
delete translatedData.createdAt;
|
|
303
|
+
delete translatedData.updatedAt;
|
|
304
|
+
delete translatedData.publishedAt;
|
|
305
|
+
delete translatedData.locale;
|
|
306
|
+
delete translatedData.localizations;
|
|
307
|
+
delete translatedData.createdBy;
|
|
308
|
+
delete translatedData.updatedBy;
|
|
309
|
+
await strapi2.documents(contentType).update({
|
|
310
|
+
documentId: entryId,
|
|
311
|
+
locale: targetLocale,
|
|
312
|
+
data: translatedData,
|
|
313
|
+
status: publish ? "published" : "draft"
|
|
314
|
+
});
|
|
315
|
+
results.push({
|
|
316
|
+
documentId: entryId,
|
|
317
|
+
locale: targetLocale,
|
|
318
|
+
success: true
|
|
319
|
+
});
|
|
320
|
+
} catch (error) {
|
|
321
|
+
console.error(`Error translating entry ${entryId} to ${targetLocale}:`, error);
|
|
322
|
+
results.push({
|
|
323
|
+
documentId: entryId,
|
|
324
|
+
locale: targetLocale,
|
|
325
|
+
success: false,
|
|
326
|
+
message: error instanceof Error ? error.message : "Unknown error"
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
const successCount = results.filter((r) => r.success).length;
|
|
332
|
+
const failureCount = results.filter((r) => !r.success).length;
|
|
333
|
+
ctx.body = {
|
|
334
|
+
data: results,
|
|
335
|
+
meta: {
|
|
336
|
+
ok: true,
|
|
337
|
+
successCount,
|
|
338
|
+
failureCount,
|
|
339
|
+
message: `Translated ${successCount} entries, ${failureCount} failed`
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
} catch (error) {
|
|
343
|
+
console.error("Error in batch translate:", error);
|
|
344
|
+
ctx.status = 500;
|
|
345
|
+
ctx.body = {
|
|
346
|
+
data: [],
|
|
347
|
+
meta: {
|
|
348
|
+
ok: false,
|
|
349
|
+
message: error instanceof Error ? error.message : "Batch translation failed"
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
const controllers = {
|
|
356
|
+
admin: controllers$1
|
|
357
|
+
};
|
|
358
|
+
const middlewares = {};
|
|
359
|
+
const policies = {};
|
|
360
|
+
const adminRoutes = [
|
|
361
|
+
{
|
|
362
|
+
method: "POST",
|
|
363
|
+
path: "/generate",
|
|
364
|
+
handler: "admin.generate",
|
|
365
|
+
config: {
|
|
366
|
+
policies: []
|
|
367
|
+
}
|
|
368
|
+
},
|
|
369
|
+
{
|
|
370
|
+
method: "GET",
|
|
371
|
+
path: "/config",
|
|
372
|
+
handler: "admin.getConfig",
|
|
373
|
+
config: {
|
|
374
|
+
policies: []
|
|
375
|
+
}
|
|
376
|
+
},
|
|
377
|
+
{
|
|
378
|
+
method: "POST",
|
|
379
|
+
path: "/config",
|
|
380
|
+
handler: "admin.setConfig",
|
|
381
|
+
config: {
|
|
382
|
+
policies: []
|
|
383
|
+
}
|
|
384
|
+
},
|
|
385
|
+
{
|
|
386
|
+
method: "GET",
|
|
387
|
+
path: "/content-types",
|
|
388
|
+
handler: "admin.getContentTypes",
|
|
389
|
+
config: {
|
|
390
|
+
policies: []
|
|
391
|
+
}
|
|
392
|
+
},
|
|
393
|
+
{
|
|
394
|
+
method: "GET",
|
|
395
|
+
path: "/locales",
|
|
396
|
+
handler: "admin.getLocales",
|
|
397
|
+
config: {
|
|
398
|
+
policies: []
|
|
399
|
+
}
|
|
400
|
+
},
|
|
401
|
+
{
|
|
402
|
+
method: "GET",
|
|
403
|
+
path: "/entries/:contentType",
|
|
404
|
+
handler: "admin.getEntries",
|
|
405
|
+
config: {
|
|
406
|
+
policies: []
|
|
407
|
+
}
|
|
408
|
+
},
|
|
409
|
+
{
|
|
410
|
+
method: "POST",
|
|
411
|
+
path: "/batch-translate",
|
|
412
|
+
handler: "admin.batchTranslate",
|
|
413
|
+
config: {
|
|
414
|
+
policies: []
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
];
|
|
418
|
+
const routes = {
|
|
419
|
+
admin: {
|
|
420
|
+
type: "admin",
|
|
421
|
+
routes: adminRoutes
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
const cleanJSONString = (content) => {
|
|
425
|
+
return content.replace(/^```json\s*\n/, "").replace(/^```\s*\n/, "").replace(/\n\s*```$/, "").replace(/\u200B/g, "").replace(/[\u2018\u2019]/g, "'").replace(/[\u201C\u201D]/g, '"').trim();
|
|
426
|
+
};
|
|
427
|
+
const balanceJSONBraces = (content) => {
|
|
428
|
+
let openBraces = 0;
|
|
429
|
+
let closeBraces = 0;
|
|
430
|
+
let inString = false;
|
|
431
|
+
for (let i = 0; i < content.length; i += 1) {
|
|
432
|
+
const char = content[i];
|
|
433
|
+
if (char === '"' && content[i - 1] !== "\\") {
|
|
434
|
+
inString = !inString;
|
|
435
|
+
}
|
|
436
|
+
if (!inString) {
|
|
437
|
+
if (char === "{") openBraces += 1;
|
|
438
|
+
if (char === "}") closeBraces += 1;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
if (openBraces > closeBraces) {
|
|
442
|
+
return content + "}".repeat(openBraces - closeBraces);
|
|
443
|
+
}
|
|
444
|
+
return content;
|
|
445
|
+
};
|
|
446
|
+
const extractJSONObject = (content) => {
|
|
447
|
+
const firstBrace = content.indexOf("{");
|
|
448
|
+
const lastBrace = content.lastIndexOf("}");
|
|
449
|
+
if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) {
|
|
450
|
+
return content.slice(firstBrace, lastBrace + 1);
|
|
451
|
+
}
|
|
452
|
+
return content;
|
|
453
|
+
};
|
|
454
|
+
const safeJSONParse = (content) => {
|
|
455
|
+
const parsed = JSON.parse(content);
|
|
456
|
+
if (typeof parsed === "object" && parsed !== null) {
|
|
457
|
+
return parsed;
|
|
458
|
+
}
|
|
459
|
+
throw new Error("Invalid response format - not an object");
|
|
460
|
+
};
|
|
461
|
+
const llmClient = new OpenAI({
|
|
462
|
+
baseURL: process.env.STRAPI_ADMIN_LLM_TRANSLATOR_LLM_BASE_URL ?? DEFAULT_LLM_BASE_URL,
|
|
463
|
+
apiKey: process.env.LLM_TRANSLATOR_LLM_API_KEY ?? "not_set"
|
|
464
|
+
});
|
|
465
|
+
const LLM_MODEL = process.env.STRAPI_ADMIN_LLM_TRANSLATOR_LLM_MODEL ?? DEFAULT_LLM_MODEL;
|
|
466
|
+
const extractTranslatableFields = (contentType, fields, components = {}) => {
|
|
467
|
+
const translatableFields = [];
|
|
468
|
+
const isTranslatableFieldSchema = (schema, value) => {
|
|
469
|
+
if (!schema) {
|
|
470
|
+
return false;
|
|
471
|
+
}
|
|
472
|
+
const { type } = schema;
|
|
473
|
+
const isStringType = ["string", "text"].includes(type) && typeof value === "string";
|
|
474
|
+
const isRichTextType = ["richtext", "richText", "blocks"].includes(type) && (typeof value === "string" || typeof value === "object");
|
|
475
|
+
const isJSONType = type === "json" && typeof value === "object";
|
|
476
|
+
const isNotUID = type !== "uid";
|
|
477
|
+
const isLocalizable = schema.pluginOptions?.i18n?.localized !== false;
|
|
478
|
+
return (isStringType || isRichTextType || isJSONType) && isNotUID && isLocalizable;
|
|
479
|
+
};
|
|
480
|
+
const traverse = (schema, data, path = [], originalPath = []) => {
|
|
481
|
+
Object.entries(schema.attributes || {}).forEach(([fieldName, fieldSchemaRaw]) => {
|
|
482
|
+
const fieldSchema = fieldSchemaRaw;
|
|
483
|
+
const value = data?.[fieldName];
|
|
484
|
+
if (value === void 0 || value === null) {
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
if (isTranslatableFieldSchema(fieldSchema, value)) {
|
|
488
|
+
translatableFields.push({
|
|
489
|
+
path: [...path, fieldName],
|
|
490
|
+
value,
|
|
491
|
+
originalPath: [...originalPath, fieldName]
|
|
492
|
+
});
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
if (fieldSchema.type === "component") {
|
|
496
|
+
const componentSchema = components[fieldSchema.component];
|
|
497
|
+
if (!componentSchema) return;
|
|
498
|
+
if (fieldSchema.repeatable && Array.isArray(value)) {
|
|
499
|
+
value.forEach(
|
|
500
|
+
(item, index) => traverse(
|
|
501
|
+
componentSchema,
|
|
502
|
+
item,
|
|
503
|
+
[...path, fieldName, String(index)],
|
|
504
|
+
[...originalPath, fieldName, String(index)]
|
|
505
|
+
)
|
|
506
|
+
);
|
|
507
|
+
} else if (typeof value === "object") {
|
|
508
|
+
traverse(componentSchema, value, [...path, fieldName], [...originalPath, fieldName]);
|
|
509
|
+
}
|
|
510
|
+
} else if (fieldSchema.type === "dynamiczone" && Array.isArray(value)) {
|
|
511
|
+
value.forEach((item, index) => {
|
|
512
|
+
const compSchema = components[item.__component];
|
|
513
|
+
if (compSchema) {
|
|
514
|
+
traverse(
|
|
515
|
+
compSchema,
|
|
516
|
+
item,
|
|
517
|
+
[...path, fieldName, String(index)],
|
|
518
|
+
[...originalPath, fieldName, String(index)]
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
});
|
|
524
|
+
};
|
|
525
|
+
traverse(contentType, fields, [], []);
|
|
526
|
+
return translatableFields;
|
|
527
|
+
};
|
|
528
|
+
const prepareTranslationPayload = (fields) => {
|
|
529
|
+
const payload = {};
|
|
530
|
+
fields.forEach((field) => {
|
|
531
|
+
let current = payload;
|
|
532
|
+
field.path.forEach((part, index) => {
|
|
533
|
+
if (index === field.path.length - 1) {
|
|
534
|
+
current[part] = field.value;
|
|
535
|
+
} else {
|
|
536
|
+
current[part] = current[part] || {};
|
|
537
|
+
current = current[part];
|
|
538
|
+
}
|
|
539
|
+
});
|
|
540
|
+
});
|
|
541
|
+
return payload;
|
|
542
|
+
};
|
|
543
|
+
const mergeTranslatedContent = (originalData, translatedData, translatableFields) => {
|
|
544
|
+
const result = JSON.parse(JSON.stringify(originalData));
|
|
545
|
+
translatableFields.forEach((field) => {
|
|
546
|
+
let translatedValue = translatedData;
|
|
547
|
+
for (const part of field.path) {
|
|
548
|
+
translatedValue = translatedValue?.[part];
|
|
549
|
+
if (translatedValue === void 0) break;
|
|
550
|
+
}
|
|
551
|
+
if (translatedValue !== void 0) {
|
|
552
|
+
let current = result;
|
|
553
|
+
field.originalPath.forEach((part, index) => {
|
|
554
|
+
if (index === field.originalPath.length - 1) {
|
|
555
|
+
current[part] = translatedValue;
|
|
556
|
+
} else {
|
|
557
|
+
current = current[part];
|
|
558
|
+
}
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
});
|
|
562
|
+
return result;
|
|
563
|
+
};
|
|
564
|
+
const generateSlug = async (data, field, contentTypeUID) => {
|
|
565
|
+
const uidService = strapi.service("plugin::content-manager.uid");
|
|
566
|
+
const slug = await uidService.generateUIDField({
|
|
567
|
+
contentTypeUID,
|
|
568
|
+
field,
|
|
569
|
+
data
|
|
570
|
+
});
|
|
571
|
+
return slug;
|
|
572
|
+
};
|
|
573
|
+
const findUIDFields = (contentType) => {
|
|
574
|
+
const uidFields = [];
|
|
575
|
+
Object.entries(contentType.attributes || {}).forEach(([fieldName, schema]) => {
|
|
576
|
+
if (schema.type === "uid" && schema.targetField) {
|
|
577
|
+
uidFields.push({
|
|
578
|
+
fieldName,
|
|
579
|
+
targetField: schema.targetField
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
});
|
|
583
|
+
return uidFields;
|
|
584
|
+
};
|
|
585
|
+
const generateUIDsForTranslatedFields = async (uidFields, translatedData, contentTypeUID, mergedContent) => {
|
|
586
|
+
const translatedUIDs = {};
|
|
587
|
+
for (const { fieldName, targetField } of uidFields) {
|
|
588
|
+
if (translatedData[targetField] !== void 0) {
|
|
589
|
+
try {
|
|
590
|
+
const newUID = await generateSlug(
|
|
591
|
+
{
|
|
592
|
+
...mergedContent,
|
|
593
|
+
[targetField]: translatedData[targetField]
|
|
594
|
+
},
|
|
595
|
+
fieldName,
|
|
596
|
+
contentTypeUID
|
|
597
|
+
);
|
|
598
|
+
translatedUIDs[fieldName] = newUID;
|
|
599
|
+
} catch (error) {
|
|
600
|
+
console.error(`Failed to generate UID for field ${fieldName}:`, error);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
return translatedUIDs;
|
|
605
|
+
};
|
|
606
|
+
const llmService = ({ strapi: strapi2 }) => ({
|
|
607
|
+
async generateWithLLM(contentType, fields, components, config2) {
|
|
608
|
+
try {
|
|
609
|
+
const userConfig = await getUserConfig();
|
|
610
|
+
const translatableFields = extractTranslatableFields(contentType, fields, components);
|
|
611
|
+
const translationPayload = prepareTranslationPayload(translatableFields);
|
|
612
|
+
const prompt = buildPrompt(translationPayload, config2.targetLanguage);
|
|
613
|
+
const systemPrompt = await buildSystemPrompt(userConfig);
|
|
614
|
+
const response = await callLLMProvider(prompt, systemPrompt, userConfig);
|
|
615
|
+
const translatedData = await parseLLMResponse(response);
|
|
616
|
+
const mergedContent = mergeTranslatedContent(fields, translatedData, translatableFields);
|
|
617
|
+
const uidFields = findUIDFields(contentType);
|
|
618
|
+
const translatedUIDs = await generateUIDsForTranslatedFields(
|
|
619
|
+
uidFields,
|
|
620
|
+
translatedData,
|
|
621
|
+
contentType.uid,
|
|
622
|
+
mergedContent
|
|
623
|
+
);
|
|
624
|
+
return {
|
|
625
|
+
data: {
|
|
626
|
+
...mergedContent,
|
|
627
|
+
...translatedUIDs
|
|
628
|
+
},
|
|
629
|
+
meta: {
|
|
630
|
+
ok: true,
|
|
631
|
+
status: 200,
|
|
632
|
+
message: "Translation completed successfully"
|
|
633
|
+
}
|
|
634
|
+
};
|
|
635
|
+
} catch (error) {
|
|
636
|
+
strapi2.log.error("LLM translation error:", error);
|
|
637
|
+
return {
|
|
638
|
+
data: fields,
|
|
639
|
+
// Return original fields in case of error
|
|
640
|
+
meta: {
|
|
641
|
+
ok: false,
|
|
642
|
+
status: 500,
|
|
643
|
+
message: error instanceof Error ? error.message : "Translation failed"
|
|
644
|
+
}
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
});
|
|
649
|
+
const buildPrompt = (fields, targetLanguage) => {
|
|
650
|
+
return `You are translating content from a CMS. Please translate the following JSON data to ${targetLanguage}.
|
|
651
|
+
|
|
652
|
+
IMPORTANT RULES:
|
|
653
|
+
1. Preserve all JSON structure and keys exactly as provided
|
|
654
|
+
2. Only translate string values
|
|
655
|
+
3. Maintain any markdown formatting within the text
|
|
656
|
+
4. Keep HTML tags intact if present
|
|
657
|
+
5. Preserve any special characters or placeholders
|
|
658
|
+
6. Return ONLY the translated JSON object
|
|
659
|
+
7. Ensure the JSON is valid and well-formed. Keep arrays and nested objects intact
|
|
660
|
+
8. Do not add any explanations or comments
|
|
661
|
+
9. Ensure professional and culturally appropriate translations
|
|
662
|
+
|
|
663
|
+
SOURCE JSON:
|
|
664
|
+
${JSON.stringify(fields, null, 2)}`;
|
|
665
|
+
};
|
|
666
|
+
const getUserConfig = async () => {
|
|
667
|
+
const pluginStore = strapi.store({
|
|
668
|
+
environment: strapi.config.environment,
|
|
669
|
+
type: "plugin",
|
|
670
|
+
name: "strapi-plugins-translate"
|
|
671
|
+
});
|
|
672
|
+
const config2 = await pluginStore.get({ key: "configuration" });
|
|
673
|
+
return config2;
|
|
674
|
+
};
|
|
675
|
+
const buildSystemPrompt = async (userConfig) => {
|
|
676
|
+
return `${userConfig?.systemPrompt || DEFAULT_SYSTEM_PROMPT} ${SYSTEM_PROMPT_APPENDIX}`;
|
|
677
|
+
};
|
|
678
|
+
const createLLMRequest = (messages, temperature = 0.1) => {
|
|
679
|
+
return llmClient.chat.completions.create({
|
|
680
|
+
model: LLM_MODEL,
|
|
681
|
+
messages,
|
|
682
|
+
temperature,
|
|
683
|
+
response_format: { type: "json_object" }
|
|
684
|
+
});
|
|
685
|
+
};
|
|
686
|
+
const callLLMProvider = async (prompt, systemPrompt, userConfig) => {
|
|
687
|
+
return createLLMRequest(
|
|
688
|
+
[
|
|
689
|
+
{
|
|
690
|
+
role: "system",
|
|
691
|
+
content: systemPrompt
|
|
692
|
+
},
|
|
693
|
+
{
|
|
694
|
+
role: "user",
|
|
695
|
+
content: prompt
|
|
696
|
+
}
|
|
697
|
+
],
|
|
698
|
+
userConfig?.temperature ?? DEFAULT_LLM_TEMPERATURE
|
|
699
|
+
);
|
|
700
|
+
};
|
|
701
|
+
const requestJSONCorrection = async (invalidJson) => {
|
|
702
|
+
const response = await createLLMRequest([
|
|
703
|
+
{
|
|
704
|
+
role: "system",
|
|
705
|
+
content: SYSTEM_PROMPT_FIX
|
|
706
|
+
},
|
|
707
|
+
{
|
|
708
|
+
role: "user",
|
|
709
|
+
content: `${USER_PROMPT_FIX_PREFIX} ${invalidJson}`
|
|
710
|
+
}
|
|
711
|
+
]);
|
|
712
|
+
const correctedContent = response.choices[0]?.message?.content;
|
|
713
|
+
if (!correctedContent) throw new Error("No content in correction response");
|
|
714
|
+
return safeJSONParse(correctedContent.trim());
|
|
715
|
+
};
|
|
716
|
+
const parseLLMResponse = async (response) => {
|
|
717
|
+
try {
|
|
718
|
+
const content = response.choices[0]?.message?.content;
|
|
719
|
+
if (!content) throw new Error("No content in response");
|
|
720
|
+
const cleanContent = cleanJSONString(content);
|
|
721
|
+
const jsonContent = extractJSONObject(cleanContent);
|
|
722
|
+
try {
|
|
723
|
+
return safeJSONParse(jsonContent);
|
|
724
|
+
} catch (parseError) {
|
|
725
|
+
const balancedContent = balanceJSONBraces(jsonContent);
|
|
726
|
+
try {
|
|
727
|
+
return safeJSONParse(balancedContent);
|
|
728
|
+
} catch (secondError) {
|
|
729
|
+
console.error("Second parse attempt failed:", secondError);
|
|
730
|
+
return await requestJSONCorrection(cleanContent);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
} catch (error) {
|
|
734
|
+
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
735
|
+
throw new Error(`Translation failed: ${errorMessage}`);
|
|
736
|
+
}
|
|
737
|
+
};
|
|
738
|
+
const services = {
|
|
739
|
+
"llm-service": llmService
|
|
740
|
+
};
|
|
741
|
+
const plugin = {
|
|
742
|
+
register,
|
|
743
|
+
bootstrap,
|
|
744
|
+
destroy,
|
|
745
|
+
config,
|
|
746
|
+
controllers,
|
|
747
|
+
routes,
|
|
748
|
+
services,
|
|
749
|
+
contentTypes,
|
|
750
|
+
policies,
|
|
751
|
+
middlewares
|
|
752
|
+
};
|
|
753
|
+
export {
|
|
754
|
+
plugin as default
|
|
755
|
+
};
|