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 + + + @@ -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,