From 6b435876e0add69817a2d248beeb2a6cacac6e3a Mon Sep 17 00:00:00 2001 From: NoriDev Date: Fri, 2 Jun 2023 17:45:36 +0900 Subject: [PATCH] =?UTF-8?q?feat(client):=20Cloud=20Translation=20-=20Advan?= =?UTF-8?q?ced(v3)=E3=81=AE=E3=82=B5=E3=83=9D=E3=83=BC=E3=83=88=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG_CHERRYPICK.md | 1 + ...85378242713-multipleTranslationServices.js | 19 + packages/backend/package.json | 1 + packages/backend/src/models/entities/Meta.ts | 30 ++ .../src/server/api/endpoints/admin/meta.ts | 5 + .../server/api/endpoints/admin/update-meta.ts | 25 + .../server/api/endpoints/notes/translate.ts | 156 ++++-- packages/frontend/assets/color-short.svg | 22 + packages/frontend/assets/white-short.svg | 22 + packages/frontend/src/components/MkNote.vue | 7 +- .../src/components/MkNoteDetailed.vue | 7 +- .../frontend/src/pages/admin/settings.vue | 49 +- pnpm-lock.yaml | 499 +++++++++++++++++- 13 files changed, 780 insertions(+), 63 deletions(-) create mode 100644 packages/backend/migration/1685378242713-multipleTranslationServices.js create mode 100644 packages/frontend/assets/color-short.svg create mode 100644 packages/frontend/assets/white-short.svg diff --git a/CHANGELOG_CHERRYPICK.md b/CHANGELOG_CHERRYPICK.md index 6625c952f9..98c9be6f72 100644 --- a/CHANGELOG_CHERRYPICK.md +++ b/CHANGELOG_CHERRYPICK.md @@ -54,6 +54,7 @@ - 업데이트 팝업 개선 (Misskey와 CherryPick의 변경 사항을 직관적으로 볼 수 있도록) - 서버 통계 위젯의 원 그래프 디자인 개선 - 새로운 서버 통계 위젯 추가 +- Cloud Translation - Advanced(v3) 지원 추가 - Fix: (Friendly) 위젯 영역에 safe-area-inset-bottom이 적용되지 않음 - Fix: (Friendly) 플로팅 메뉴를 길게 눌렀을 때 프로필 이미지를 드래그 할 수 있는 문제 - Fix: 위젯 편집 시 헤더 이외의 영역을 눌렀을 때 위젯 설정이 뜨는 문제 diff --git a/packages/backend/migration/1685378242713-multipleTranslationServices.js b/packages/backend/migration/1685378242713-multipleTranslationServices.js new file mode 100644 index 0000000000..d85191e4bb --- /dev/null +++ b/packages/backend/migration/1685378242713-multipleTranslationServices.js @@ -0,0 +1,19 @@ +export class MultipleTranslationServices1685378242713 { + name = 'MultipleTranslationServices1685378242713' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "ctav3SaKey" character varying(5120)`); + await queryRunner.query(`ALTER TABLE "meta" ADD "ctav3ProjectId" character varying(1024)`); + await queryRunner.query(`ALTER TABLE "meta" ADD "ctav3Location" character varying(1024)`); + await queryRunner.query(`ALTER TABLE "meta" ADD "ctav3Model" character varying(1024)`); + await queryRunner.query(`ALTER TABLE "meta" ADD "ctav3Glossary" character varying(1024)`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "ctav3SaKey"`); + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "ctav3ProjectId"`); + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "ctav3Location"`); + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "ctav3Model"`); + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "ctav3Glossary"`); + } +} diff --git a/packages/backend/package.json b/packages/backend/package.json index db862686de..873c5d1e6f 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -65,6 +65,7 @@ "@fastify/multipart": "7.6.0", "@fastify/static": "6.10.1", "@fastify/view": "7.4.1", + "@google-cloud/translate": "^7.2.1", "@nestjs/common": "9.4.2", "@nestjs/core": "9.4.2", "@nestjs/testing": "9.4.2", diff --git a/packages/backend/src/models/entities/Meta.ts b/packages/backend/src/models/entities/Meta.ts index aa780f4f28..de99a3e1ee 100644 --- a/packages/backend/src/models/entities/Meta.ts +++ b/packages/backend/src/models/entities/Meta.ts @@ -283,6 +283,36 @@ export class Meta { }) public deeplIsPro: boolean; + @Column('varchar', { + length: 5120, + nullable: true, + }) + public ctav3SaKey: string | null; + + @Column('varchar', { + length: 1024, + nullable: true, + }) + public ctav3ProjectId: string | null; + + @Column('varchar', { + length: 1024, + nullable: true, + }) + public ctav3Location: string | null; + + @Column('varchar', { + length: 1024, + nullable: true, + }) + public ctav3Model: string | null; + + @Column('varchar', { + length: 1024, + nullable: true, + }) + public ctav3Glossary: string | null; + @Column('varchar', { length: 1024, nullable: true, diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index 6aadcfaa2a..41ad1845dc 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -363,6 +363,11 @@ export default class extends Endpoint { objectStorageS3ForcePathStyle: instance.objectStorageS3ForcePathStyle, deeplAuthKey: instance.deeplAuthKey, deeplIsPro: instance.deeplIsPro, + ctav3SaKey: instance.ctav3SaKey, + ctav3ProjectId: instance.ctav3ProjectId, + ctav3Location: instance.ctav3Location, + ctav3Model: instance.ctav3Model, + ctav3Glossary: instance.ctav3Glossary, enableIpLogging: instance.enableIpLogging, enableActiveEmailValidation: instance.enableActiveEmailValidation, enableChartsForRemoteUser: instance.enableChartsForRemoteUser, diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts index dbac745395..20bdb1d180 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -65,6 +65,11 @@ export const paramDef = { translatorType: { type: 'string', nullable: true }, deeplAuthKey: { type: 'string', nullable: true }, deeplIsPro: { type: 'boolean' }, + ctav3SaKey: { type: 'string', nullable: true }, + ctav3ProjectId: { type: 'string', nullable: true }, + ctav3Location: { type: 'string', nullable: true }, + ctav3Model: { type: 'string', nullable: true }, + ctav3Glossary: { type: 'string', nullable: true }, enableEmail: { type: 'boolean' }, email: { type: 'string', nullable: true }, smtpSecure: { type: 'boolean' }, @@ -382,6 +387,26 @@ export default class extends Endpoint { set.deeplIsPro = ps.deeplIsPro; } + if (ps.ctav3SaKey !== undefined) { + set.ctav3SaKey = ps.ctav3SaKey; + } + + if (ps.ctav3ProjectId !== undefined) { + set.ctav3ProjectId = ps.ctav3ProjectId; + } + + if (ps.ctav3Location !== undefined) { + set.ctav3Location = ps.ctav3Location; + } + + if (ps.ctav3Model !== undefined) { + set.ctav3Model = ps.ctav3Model; + } + + if (ps.ctav3Glossary !== undefined) { + set.ctav3Glossary = ps.ctav3Glossary; + } + if (ps.enableIpLogging !== undefined) { set.enableIpLogging = ps.enableIpLogging; } diff --git a/packages/backend/src/server/api/endpoints/notes/translate.ts b/packages/backend/src/server/api/endpoints/notes/translate.ts index 7fc51abc00..6eff9cbaa0 100644 --- a/packages/backend/src/server/api/endpoints/notes/translate.ts +++ b/packages/backend/src/server/api/endpoints/notes/translate.ts @@ -1,6 +1,8 @@ import { URLSearchParams } from 'node:url'; -import { translate } from '@vitalets/google-translate-api'; +import fs from 'node:fs'; import { Inject, Injectable } from '@nestjs/common'; +import { translate } from '@vitalets/google-translate-api'; +import { TranslationServiceClient } from '@google-cloud/translate'; import type { NotesRepository } from '@/models/index.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { Config } from '@/config.js'; @@ -9,6 +11,7 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { MetaService } from '@/core/MetaService.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; import { GetterService } from '@/server/api/GetterService.js'; +import { createTemp } from '@/misc/create-temp.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -64,10 +67,6 @@ export default class extends Endpoint { if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); throw err; }); - const translatorServices = [ - 'DeepL', - 'GoogleNoAPI', - ]; if (!(await this.noteEntityService.isVisibleForMe(note, me ? me.id : null))) { return 204; // TODO: 良い感じのエラー返す @@ -79,56 +78,131 @@ export default class extends Endpoint { const instance = await this.metaService.fetch(); - if (instance.translatorType === 'DeepL') { + const translatorServices = [ + 'deepl', + 'google_no_api', + 'ctav3', + ]; + + if (instance.translatorType == null || !translatorServices.includes(instance.translatorType)) { + throw new ApiError(meta.errors.noTranslateService); + } + + let targetLang = ps.targetLang; + if (targetLang.includes('-')) targetLang = targetLang.split('-')[0]; + + let translationResult; + if (instance.translatorType === 'deepl') { if (instance.deeplAuthKey == null) { return 204; // TODO: 良い感じのエラー返す } - + translationResult = await this.translateDeepL(note.text, targetLang, instance.deeplAuthKey, instance.deeplIsPro, instance.translatorType); + } else if (instance.translatorType === 'google_no_api') { let targetLang = ps.targetLang; if (targetLang.includes('-')) targetLang = targetLang.split('-')[0]; - const params = new URLSearchParams(); - params.append('auth_key', instance.deeplAuthKey); - params.append('text', note.text); - params.append('target_lang', targetLang); - - const endpoint = instance.deeplIsPro ? 'https://api.deepl.com/v2/translate' : 'https://api-free.deepl.com/v2/translate'; - - const res = await this.httpRequestService.send(endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - Accept: 'application/json, */*', - }, - body: params.toString(), - }); - - const json = (await res.json()) as { - translations: { - detected_source_language: string; - text: string; - }[]; - }; - - return { - sourceLang: json.translations[0].detected_source_language, - text: json.translations[0].text, - translator: translatorServices, - }; - } else if (instance.translatorType === 'GoogleNoAPI') { - let targetLang = ps.targetLang; - if (targetLang.includes('-')) targetLang = targetLang.split('-')[0]; - const { text, raw } = await translate(note.text, { to: targetLang }); - + return { sourceLang: raw.src, text: text, translator: translatorServices, }; + } else if (instance.translatorType === 'ctav3') { + if (instance.ctav3SaKey == null) { return 204; } else if (instance.ctav3ProjectId == null) { return 204; } + else if (instance.ctav3Location == null) { return 204; } + translationResult = await this.apiCloudTranslationAdvanced( + note.text, targetLang, instance.ctav3SaKey, instance.ctav3ProjectId, instance.ctav3Location, instance.ctav3Model, instance.ctav3Glossary, instance.translatorType, + ); } else { - throw new ApiError(meta.errors.noTranslateService); + throw new Error('Unsupported translator type'); } + + return { + sourceLang: translationResult.sourceLang, + text: translationResult.text, + translator: translationResult.translator, + }; }); } + + private async translateDeepL(text: string, targetLang: string, authKey: string, isPro: boolean, provider: string) { + const params = new URLSearchParams(); + params.append('auth_key', authKey); + params.append('text', text); + params.append('target_lang', targetLang); + + const endpoint = isPro ? 'https://api.deepl.com/v2/translate' : 'https://api-free.deepl.com/v2/translate'; + + const res = await this.httpRequestService.send(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json, */*', + }, + body: params.toString(), + }); + + const json = (await res.json()) as { + translations: { + detected_source_language: string; + text: string; + }[]; + }; + + return { + sourceLang: json.translations[0].detected_source_language, + text: json.translations[0].text, + translator: provider, + }; + } + + private async apiCloudTranslationAdvanced(text: string, targetLang: string, saKey: string, projectId: string, location: string, model: string | null, glossary: string | null, provider: string) { + const [path, cleanup] = await createTemp(); + fs.writeFileSync(path, saKey); + process.env.GOOGLE_APPLICATION_CREDENTIALS = path; + + const translationClient = new TranslationServiceClient(); + + const detectRequest = { + parent: `projects/${projectId}/locations/${location}`, + content: text, + }; + + let detectedLanguage = null; + let glossaryConfig = null; + if (glossary !== '' && glossary !== null) { + glossaryConfig = { + glossary: `projects/${projectId}/locations/${location}/glossaries/${glossary}`, + }; + const [detectResponse] = await translationClient.detectLanguage(detectRequest); + detectedLanguage = detectResponse.languages && detectResponse.languages[0]?.languageCode; + } + + let modelConfig = null; + if (model !== '' && model !== null) { + modelConfig = `projects/${projectId}/locations/${location}/models/${model}`; + } + + const translateRequest = { + parent: `projects/${projectId}/locations/${location}`, + contents: [text], + mimeType: 'text/plain', + sourceLanguageCode: null, + targetLanguageCode: detectedLanguage !== null ? detectedLanguage : targetLang, + model: modelConfig, + glossaryConfig: glossaryConfig, + }; + const [translateResponse] = await translationClient.translateText(translateRequest); + const translatedText = translateResponse.translations && translateResponse.translations[0]?.translatedText; + const detectedLanguageCode = translateResponse.translations && translateResponse.translations[0]?.detectedLanguageCode; + + delete process.env.GOOGLE_APPLICATION_CREDENTIALS; + cleanup(); + return { + sourceLang: detectedLanguage !== null ? detectedLanguage : detectedLanguageCode, + text: translatedText, + translator: provider, + }; + } } diff --git a/packages/frontend/assets/color-short.svg b/packages/frontend/assets/color-short.svg new file mode 100644 index 0000000000..b21472d79d --- /dev/null +++ b/packages/frontend/assets/color-short.svg @@ -0,0 +1,22 @@ + + + + Imported Layers Copy + translated by@3x + Created with Sketch. + + + + + + + + + + + + + translated by + + + + diff --git a/packages/frontend/assets/white-short.svg b/packages/frontend/assets/white-short.svg new file mode 100644 index 0000000000..e6357c168e --- /dev/null +++ b/packages/frontend/assets/white-short.svg @@ -0,0 +1,22 @@ + + + + white-short@3x + Created with Sketch. + + + + + + + + + + + + + translated by + + + + diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index c3f0749894..92aa0b32dd 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -59,8 +59,13 @@
- {{ i18n.t('translatedFrom', { x: translation.sourceLang }) }}: + {{ i18n.t('translatedFrom', { x: translation.sourceLang }) }}:
+
+
+ + +
diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index f9e2fce192..0e13f4b5fe 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -67,8 +67,13 @@
- {{ i18n.t('translatedFrom', { x: translation.sourceLang }) }}: + {{ i18n.t('translatedFrom', { x: translation.sourceLang }) }}:
+
+
+ + +
diff --git a/packages/frontend/src/pages/admin/settings.vue b/packages/frontend/src/pages/admin/settings.vue index ac329646ca..0e18594e9a 100644 --- a/packages/frontend/src/pages/admin/settings.vue +++ b/packages/frontend/src/pages/admin/settings.vue @@ -114,14 +114,15 @@ --> - + - - + + + -