From 46b0e8115a9582f60aa5798291199f51b91f1993 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E3=81=8B=E3=81=A3=E3=81=93=E3=81=8B=E3=82=8A?=
<67428053+kakkokari-gtyih@users.noreply.github.com>
Date: Sun, 5 Oct 2025 15:43:13 +0900
Subject: [PATCH] =?UTF-8?q?enhance(frontend):=20=E5=AE=9F=E9=A8=93?=
=?UTF-8?q?=E7=9A=84=E6=A9=9F=E8=83=BD=E3=81=A8=E3=81=97=E3=81=A6Translato?=
=?UTF-8?q?r=20API=E3=82=92=E7=94=A8=E3=81=84=E3=81=9F=E7=BF=BB=E8=A8=B3?=
=?UTF-8?q?=E3=82=92=E5=AE=9F=E8=A3=85=20(#16600)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* enhance(frontend): 実験的機能としてTranslator APIを用いた翻訳を実装
* remove unused imports
* remove unnecessary console.log
* fix 表記揺れ
* fix lint
---
.../frontend/src/pages/settings/other.vue | 4 ++
packages/frontend/src/preferences/def.ts | 3 +
.../frontend/src/utility/get-note-menu.ts | 57 ++++++++++++++++---
3 files changed, 55 insertions(+), 9 deletions(-)
diff --git a/packages/frontend/src/pages/settings/other.vue b/packages/frontend/src/pages/settings/other.vue
index 41b799bead..c4c76884e4 100644
--- a/packages/frontend/src/pages/settings/other.vue
+++ b/packages/frontend/src/pages/settings/other.vue
@@ -102,6 +102,9 @@ SPDX-License-Identifier: AGPL-3.0-only
Enable haptic feedback
+
+ Enable in-browser translator API
+
@@ -182,6 +185,7 @@ const devMode = prefer.model('devMode');
const stackingRouterView = prefer.model('experimental.stackingRouterView');
const enableFolderPageView = prefer.model('experimental.enableFolderPageView');
const enableHapticFeedback = prefer.model('experimental.enableHapticFeedback');
+const enableWebTranslatorApi = prefer.model('experimental.enableWebTranslatorApi');
watch(skipNoteRender, () => {
suggestReload();
diff --git a/packages/frontend/src/preferences/def.ts b/packages/frontend/src/preferences/def.ts
index cc270229e5..ebd031b240 100644
--- a/packages/frontend/src/preferences/def.ts
+++ b/packages/frontend/src/preferences/def.ts
@@ -516,4 +516,7 @@ export const PREF_DEF = definePreferences({
'experimental.enableHapticFeedback': {
default: false,
},
+ 'experimental.enableWebTranslatorApi': {
+ default: false,
+ },
});
diff --git a/packages/frontend/src/utility/get-note-menu.ts b/packages/frontend/src/utility/get-note-menu.ts
index 90de952a91..fc165ea898 100644
--- a/packages/frontend/src/utility/get-note-menu.ts
+++ b/packages/frontend/src/utility/get-note-menu.ts
@@ -3,7 +3,6 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import { defineAsyncComponent } from 'vue';
import * as Misskey from 'misskey-js';
import { url } from '@@/js/config.js';
import { claimAchievement } from './achievements.js';
@@ -27,6 +26,11 @@ import { prefer } from '@/preferences.js';
import { getPluginHandlers } from '@/plugin.js';
import { globalEvents } from '@/events.js';
+const isInBrowserTranslationAvailable = (
+ 'LanguageDetector' in window &&
+ 'Translator' in window
+);
+
export async function getNoteClipMenu(props: {
note: Misskey.entities.Note;
currentClip?: Misskey.entities.Clip;
@@ -285,13 +289,48 @@ export function getNoteMenu(props: {
async function translate(): Promise {
if (props.translation.value != null) return;
- props.translating.value = true;
- const res = await misskeyApi('notes/translate', {
- noteId: appearNote.id,
- targetLang: miLocalStorage.getItem('lang') ?? navigator.language,
- });
- props.translating.value = false;
- props.translation.value = res;
+ if (prefer.s['experimental.enableWebTranslatorApi'] && isInBrowserTranslationAvailable && appearNote.text != null) {
+ props.translating.value = true;
+ try {
+ // @ts-expect-error 実験的なAPIなので型定義がない
+ const detector = await LanguageDetector.create();
+ const langResult = await detector.detect(appearNote.text);
+ let localStorageLang = miLocalStorage.getItem('lang');
+ if (localStorageLang != null) {
+ localStorageLang = localStorageLang.split('-')[0];
+ }
+
+ // 翻訳元と翻訳先の言語が同じ場合はTranslatorがthrowするのでそのまま返す
+ if (langResult[0]?.detectedLanguage === localStorageLang || langResult[0]?.detectedLanguage === navigator.language) {
+ props.translation.value = {
+ sourceLang: langResult[0]?.detectedLanguage ?? 'unknown',
+ text: appearNote.text,
+ };
+ return;
+ }
+
+ // @ts-expect-error 実験的なAPIなので型定義がない
+ const translator = await Translator.create({
+ sourceLanguage: langResult[0]?.detectedLanguage,
+ targetLanguage: localStorageLang ?? navigator.language,
+ });
+ const translated = await translator.translate(appearNote.text);
+ props.translation.value = {
+ sourceLang: langResult[0]?.detectedLanguage ?? 'unknown',
+ text: translated,
+ };
+ } finally {
+ props.translating.value = false;
+ }
+ } else if ($i?.policies.canUseTranslator && instance.translatorAvailable) {
+ props.translating.value = true;
+ const res = await misskeyApi('notes/translate', {
+ noteId: appearNote.id,
+ targetLang: miLocalStorage.getItem('lang') ?? navigator.language,
+ });
+ props.translating.value = false;
+ props.translation.value = res;
+ }
}
const menuItems: MenuItem[] = [];
@@ -349,7 +388,7 @@ export function getNoteMenu(props: {
});
}
- if ($i.policies.canUseTranslator && instance.translatorAvailable) {
+ if ((prefer.s['experimental.enableWebTranslatorApi'] && isInBrowserTranslationAvailable) || ($i.policies.canUseTranslator && instance.translatorAvailable)) {
menuItems.push({
icon: 'ti ti-language-hiragana',
text: i18n.ts.translate,