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"> <MkSwitch v-model="enableHapticFeedback">
<template #label>Enable haptic feedback</template> <template #label>Enable haptic feedback</template>
</MkSwitch> </MkSwitch>
<MkSwitch v-model="enableWebTranslatorApi">
<template #label>Enable in-browser translator API</template>
</MkSwitch>
</div> </div>
</MkFolder> </MkFolder>
</SearchMarker> </SearchMarker>
@ -182,6 +185,7 @@ const devMode = prefer.model('devMode');
const stackingRouterView = prefer.model('experimental.stackingRouterView'); const stackingRouterView = prefer.model('experimental.stackingRouterView');
const enableFolderPageView = prefer.model('experimental.enableFolderPageView'); const enableFolderPageView = prefer.model('experimental.enableFolderPageView');
const enableHapticFeedback = prefer.model('experimental.enableHapticFeedback'); const enableHapticFeedback = prefer.model('experimental.enableHapticFeedback');
const enableWebTranslatorApi = prefer.model('experimental.enableWebTranslatorApi');
watch(skipNoteRender, () => { watch(skipNoteRender, () => {
suggestReload(); suggestReload();

View File

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

View File

@ -3,7 +3,6 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { defineAsyncComponent } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { url } from '@@/js/config.js'; import { url } from '@@/js/config.js';
import { claimAchievement } from './achievements.js'; import { claimAchievement } from './achievements.js';
@ -27,6 +26,11 @@ import { prefer } from '@/preferences.js';
import { getPluginHandlers } from '@/plugin.js'; import { getPluginHandlers } from '@/plugin.js';
import { globalEvents } from '@/events.js'; import { globalEvents } from '@/events.js';
const isInBrowserTranslationAvailable = (
'LanguageDetector' in window &&
'Translator' in window
);
export async function getNoteClipMenu(props: { export async function getNoteClipMenu(props: {
note: Misskey.entities.Note; note: Misskey.entities.Note;
currentClip?: Misskey.entities.Clip; currentClip?: Misskey.entities.Clip;
@ -285,13 +289,48 @@ export function getNoteMenu(props: {
async function translate(): Promise<void> { async function translate(): Promise<void> {
if (props.translation.value != null) return; if (props.translation.value != null) return;
props.translating.value = true; if (prefer.s['experimental.enableWebTranslatorApi'] && isInBrowserTranslationAvailable && appearNote.text != null) {
const res = await misskeyApi('notes/translate', { props.translating.value = true;
noteId: appearNote.id, try {
targetLang: miLocalStorage.getItem('lang') ?? navigator.language, // @ts-expect-error 実験的なAPIなので型定義がない
}); const detector = await LanguageDetector.create();
props.translating.value = false; const langResult = await detector.detect(appearNote.text);
props.translation.value = res; 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[] = []; 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({ menuItems.push({
icon: 'ti ti-language-hiragana', icon: 'ti ti-language-hiragana',
text: i18n.ts.translate, text: i18n.ts.translate,