enhance(frontend): 実験的機能としてTranslator APIを用いた翻訳を実装 (#16600)

* enhance(frontend): 実験的機能としてTranslator APIを用いた翻訳を実装

* remove unused imports

* remove unnecessary console.log

* fix 表記揺れ

* fix lint
This commit is contained in:
かっこかり 2025-10-05 15:43:13 +09:00 committed by GitHub
parent 7796fce779
commit 46b0e8115a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 55 additions and 9 deletions

View File

@ -102,6 +102,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSwitch v-model="enableHapticFeedback">
<template #label>Enable haptic feedback</template>
</MkSwitch>
<MkSwitch v-model="enableWebTranslatorApi">
<template #label>Enable in-browser translator API</template>
</MkSwitch>
</div>
</MkFolder>
</SearchMarker>
@ -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();

View File

@ -516,4 +516,7 @@ export const PREF_DEF = definePreferences({
'experimental.enableHapticFeedback': {
default: false,
},
'experimental.enableWebTranslatorApi': {
default: false,
},
});

View File

@ -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,6 +289,40 @@ export function getNoteMenu(props: {
async function translate(): Promise<void> {
if (props.translation.value != null) return;
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,
@ -293,6 +331,7 @@ export function getNoteMenu(props: {
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,