UNPKG

i18n-translate-agent

Version:

An intelligent i18n translation agent powered by OpenAI, supporting automatic translation of JSON files with caching and progress tracking

400 lines (360 loc) 12 kB
import path from "path"; import fs from "fs"; import colors from "ansi-colors"; import { OpenAI, ClientOptions } from "openai"; import cliProgress from "cli-progress"; import { ICwalletTranslateParams, IJson, IOutputLanguageFile, ISingleTranslate, ITranslateChat, ITranslateChatResponse, SupportLanguageType, } from "./types"; import { chunkArray, getRandomNumber, notExistsToCreateFile, readFileOfDirSync, readJsonFileSync, flattenJson, unflattenJson, } from "./lib/utils.js"; import { getCacheFileSync, registerLanguageCacheFile, translateJSONDiffToJson, } from "./lib/cache/index.js"; import { logErrorToFile } from "./lib/log/index.js"; import { SUPPORT_LANGUAGE_MAP } from "./lib/support.js"; import { ChatCompletionCreateParams } from "openai/resources"; export { generateCache, deleteBatchCache } from "./lib/cache/index.js"; const DEFAULT_OPENAI_CONFIG: ClientOptions = {}; export class CwalletTranslate { /** open ai api key */ /** */ CACHE_ROOT_PATH: string; ENTRY_ROOT_PATH: string; /** default en */ SOURCE_LANGUAGE: SupportLanguageType; OUTPUT_ROOT_PATH: string | undefined; languages: SupportLanguageType[]; client: OpenAI | null = null; /** default model gpt-4o */ openaiClientConfig: ClientOptions; fineTune: string[]; chatCompletionCreateParams: Partial<ChatCompletionCreateParams>; constructor(params: ICwalletTranslateParams) { this.CACHE_ROOT_PATH = params.cacheFileRootPath; this.ENTRY_ROOT_PATH = params.fileRootPath; this.openaiClientConfig = params.openaiClientConfig ?? DEFAULT_OPENAI_CONFIG; this.chatCompletionCreateParams = params.chatCompletionCreateParams ?? ({ model: "gpt-4o", } as Partial<ChatCompletionCreateParams>); this.SOURCE_LANGUAGE = params.sourceLanguage ?? "en"; this.OUTPUT_ROOT_PATH = params.outputRootPath; this.fineTune = params.fineTune; this.languages = params.languages ?? []; this.createOpenAIClient(); } get supportLanguages() { return Object.entries(SUPPORT_LANGUAGE_MAP) .map(([key, val]) => val) .filter( ({ code }) => this.languages.includes(code) || code === this.SOURCE_LANGUAGE ); } get outputPath() { return this.OUTPUT_ROOT_PATH ?? this.ENTRY_ROOT_PATH; } searchLanguage(code: SupportLanguageType) { return this.supportLanguages.find((item) => item.code === code); } createOpenAIClient = () => { /** Initialize OpenAI */ const client = new OpenAI(this.openaiClientConfig); this.client = client; }; /** * Translate all supported language folders and files in the entry file */ translate = async () => { console.log("🚀 Starting translation"); console.log( `🚀 Model being used: ${this.chatCompletionCreateParams.model} 🚀` ); console.log(`🚀 Fine-tuning: ${this.fineTune} 🚀`); const translateFolderPath = path.join( this.ENTRY_ROOT_PATH, this.SOURCE_LANGUAGE ); console.log("🚀 ~ translateFolderPath:", translateFolderPath); // Translate all json files under the source language folder const translateFolders = await readFileOfDirSync(translateFolderPath); console.log("🚀 ~ Files to be translated:", translateFolders); // Create progress bar const multiBar = new cliProgress.MultiBar( { clearOnComplete: false, hideCursor: true, format: colors.cyan("{bar}") + "| {percentage}% || {filename} {value}/{total} ", }, cliProgress.Presets.legacy ); let promises = []; const arr: (() => Promise<void>)[] = []; for (const item of this.supportLanguages) { // Source language does not need translation if (item.code === this.SOURCE_LANGUAGE) continue; for (const fileName of translateFolders) { // 直接读取源语言文件 const sourceFilePath = path.join( this.ENTRY_ROOT_PATH, this.SOURCE_LANGUAGE, fileName ); if (!fs.existsSync(sourceFilePath)) { console.log(`Source file not found: ${sourceFilePath}`); continue; } const sourceContent = await readJsonFileSync(sourceFilePath); if (!sourceContent || Object.keys(sourceContent).length === 0) { console.log(`${item.code}:${fileName} has no content to translate`); continue; } arr.push(() => this.singleTranslate({ language: item.code, fileName, multiBar, translateJson: sourceContent, }) ); } } promises = chunkArray(arr, 8); for (const chunk of promises) { await Promise.all(chunk.map((fn) => fn())); } multiBar.stop(); console.log("🚀 Translation completed"); }; /** * Translate a single file * @param params * @returns */ singleTranslate = async (params: ISingleTranslate) => { const { /** Language to be translated */ language, /** File name to be translated */ fileName, translateJson, multiBar, callback, } = params; try { // 扁平化嵌套 JSON 对象 const flattenedJson = flattenJson(translateJson); // Array waiting for translation const jsonMap: IJson = {}; // 获取缓存对象以区分新翻译和缓存命中的内容 const cacheFilePath = path.join(this.CACHE_ROOT_PATH, language, fileName); const cacheObject = await getCacheFileSync(cacheFilePath); const flattenedCacheObject = flattenJson(cacheObject); // 分离需要新翻译的内容和缓存命中的内容 const needsTranslation: IJson = {}; const cachedContent: IJson = {}; Object.entries(flattenedJson).forEach(([key, value]) => { if (flattenedCacheObject.hasOwnProperty(key)) { // 缓存命中,使用缓存中的译文 cachedContent[key] = flattenedCacheObject[key]; } else { // 需要新翻译 needsTranslation[key] = value; } }); // 处理需要API翻译的内容 if (Object.keys(needsTranslation).length > 0) { console.log( `🔄 Translating ${Object.keys(needsTranslation).length} new items` ); const promiseList = Object.entries(needsTranslation).map( ([key, value], index) => () => this.translateChat({ key, value, language, index, fileName, }) ); const progressBar = multiBar.create(promiseList.length, 0); for (const fn of promiseList) { const result = await fn(); jsonMap[result.key] = result.value; progressBar.update(result.index + 1, { filename: `${language}:${fileName}`, }); } } // 添加缓存命中的内容(使用缓存中的译文) if (Object.keys(cachedContent).length > 0) { console.log( `📋 Using ${Object.keys(cachedContent).length} cached translations` ); // 直接使用缓存中的译文,而不是源文本 Object.assign(jsonMap, cachedContent); } // 由于存在路径冲突,直接使用扁平化结构,不进行unflattenJson转换 // 只有新翻译的内容需要写入缓存(也保持扁平化) const newTranslationsForCache = Object.keys(needsTranslation).length > 0 ? Object.fromEntries( Object.entries(jsonMap).filter(([key]) => needsTranslation.hasOwnProperty(key) ) ) : {}; this.outputLanguageFile({ jsonMap: jsonMap, // 直接使用扁平化的jsonMap newTranslations: newTranslationsForCache, // 只有新翻译的内容 folderName: language, fileName, }); callback && callback(); } catch (error) { logErrorToFile({ error: error as Error, language, fileName, key: "", }); return; } }; /** * Use OpenAI for translation * @param {string} key * @param {string} value * @param {OpenAI} client * @param {string} language * @returns */ translateChat = (params: ITranslateChat): Promise<ITranslateChatResponse> => { return new Promise((resolve) => { const { key, value, language, index, fileName } = params; try { if (!this.client) throw new Error("Connection failed"); const targetLanguage = this.searchLanguage(language); const originLanguage = this.searchLanguage(this.SOURCE_LANGUAGE); if (!targetLanguage) { throw new Error(`Unsupported language: ${language}`); } if (!originLanguage) { throw new Error(`Unsupported language: ${this.SOURCE_LANGUAGE}`); } setTimeout(async () => { const chatCompletion = await this.client!.chat.completions.create({ model: "gpt-4o", ...this.chatCompletionCreateParams, stream: false, messages: [ ...this.fineTune.map( (val) => ({ role: "system", content: val, } as OpenAI.Chat.Completions.ChatCompletionMessageParam) ), { role: "system", content: `Please translate ${originLanguage!.name} to ${ targetLanguage!.name }`, }, { role: "system", content: `After translation is complete, directly output the corresponding meaning without any irrelevant content`, }, { role: "user", content: value, }, ], }); resolve({ key, value: chatCompletion?.choices[0]?.message.content ?? value, index, }); }, getRandomNumber(200, 300)); } catch (error) { logErrorToFile({ error: error as Error, key, fileName, language }); resolve({ key, value, index, error: error as Error, }); } }); }; /** * Output language file * @param {Object} jsonMap */ outputLanguageFile = async (params: IOutputLanguageFile) => { const { folderName, fileName, jsonMap, newTranslations } = params; const outputFilePath = path.join(this.outputPath, folderName, fileName); // Create output folder notExistsToCreateFile(this.outputPath); // Create output language folder notExistsToCreateFile(`${this.outputPath}/${folderName}`); let oldJsonData: string = ""; // Check if file exists if (!fs.existsSync(outputFilePath)) { oldJsonData = await fs.readFileSync( path.join(this.ENTRY_ROOT_PATH, this.SOURCE_LANGUAGE, fileName), "utf8" ); } else { oldJsonData = await fs.readFileSync(outputFilePath, "utf8"); } const oldJsonMap: IJson = JSON.parse(oldJsonData); const newJsonMap: IJson = Object.assign(oldJsonMap, jsonMap); await fs.writeFileSync( path.resolve(outputFilePath), JSON.stringify(newJsonMap, null, 2), "utf8" ); // 只有新翻译的内容才写入缓存 if (newTranslations && Object.keys(newTranslations).length > 0) { console.log( `💾 Caching ${Object.keys(newTranslations).length} new translations` ); registerLanguageCacheFile({ sourceFilePath: path.join( this.ENTRY_ROOT_PATH, this.SOURCE_LANGUAGE, fileName ), jsonMap: newTranslations, fileName, language: folderName, folderName: this.CACHE_ROOT_PATH, }); } else { console.log(`📋 No new translations to cache (all from cache)`); } }; }