From 638ecc5df4a7d63a7b76e0abd1721a895bd80146 Mon Sep 17 00:00:00 2001 From: Grigory Vodyanov Date: Sun, 8 Dec 2024 18:21:08 +0100 Subject: [PATCH] feat: Add translation capabilities to mail Signed-off-by: Grigory Vodyanov --- appinfo/info.xml | 2 +- lib/Controller/PageController.php | 5 + .../AiIntegrations/AiIntegrationsService.php | 7 +- src/components/Envelope.vue | 4 +- src/components/MenuEnvelope.vue | 17 ++ src/components/ThreadEnvelope.vue | 29 ++- src/components/TranslationModal.vue | 224 ++++++++++++++++++ src/main.js | 7 + src/service/translationService.js | 44 ++++ src/store/getters.js | 3 + src/store/index.js | 3 + src/store/mutations.js | 9 + src/util/text.js | 2 +- tests/Unit/Controller/PageControllerTest.php | 3 +- 14 files changed, 352 insertions(+), 7 deletions(-) create mode 100644 src/components/TranslationModal.vue create mode 100644 src/service/translationService.js diff --git a/appinfo/info.xml b/appinfo/info.xml index 90e007313b..0c0c427004 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -34,7 +34,7 @@ The rating depends on the installed text processing backend. See [the rating ove Learn more about the Nextcloud Ethical AI Rating [in our blog](https://nextcloud.com/blog/nextcloud-ethical-ai-rating/). ]]> - 4.2.0-alpha.1 + 4.2.0-alpha.2 agpl Christoph Wurst GretaD diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index 695265ee59..1907006ccd 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -287,6 +287,11 @@ public function index(): TemplateResponse { $this->aiIntegrationsService->isLlmProcessingEnabled() && $this->aiIntegrationsService->isLlmAvailable(SummaryTaskType::class) ); + $this->initialStateService->provideInitialState( + 'llm_translation_enabled', + $this->aiIntegrationsService->isTaskAvailable('core:text2text:translate') + ); + $this->initialStateService->provideInitialState( 'llm_freeprompt_available', $this->aiIntegrationsService->isLlmProcessingEnabled() && $this->aiIntegrationsService->isLlmAvailable(FreePromptTaskType::class) diff --git a/lib/Service/AiIntegrations/AiIntegrationsService.php b/lib/Service/AiIntegrations/AiIntegrationsService.php index 6c72adaaf5..b7e0056862 100644 --- a/lib/Service/AiIntegrations/AiIntegrationsService.php +++ b/lib/Service/AiIntegrations/AiIntegrationsService.php @@ -72,7 +72,7 @@ public function summarizeMessages(Account $account, array $messages): void { $this->logger->info('No text summary provider available'); return; } - + $client = $this->clientFactory->getClient($account); try { foreach ($messages as $entry) { @@ -356,6 +356,11 @@ public function isLlmAvailable(string $taskType): bool { return in_array($taskType, $manager->getAvailableTaskTypes(), true); } + public function isTaskAvailable(string $taskName): bool { + $availableTasks = $this->taskProcessingManager->getAvailableTaskTypes(); + return array_key_exists($taskName, $availableTasks); + } + /** * Whether the llm_processing admin setting is enabled globally on this instance. */ diff --git a/src/components/Envelope.vue b/src/components/Envelope.vue index b4666ec3a9..e20c87d9dc 100644 --- a/src/components/Envelope.vue +++ b/src/components/Envelope.vue @@ -249,13 +249,13 @@ - - {{ t('spreed', 'Set custom snooze') }} + {{ t('mail', 'Set custom snooze') }} {{ t('mail', 'Unsnooze') }} + + + {{ t('mail', 'Translate') }} + @@ -299,6 +304,7 @@ import TagModal from './TagModal.vue' import MoveModal from './MoveModal.vue' import TaskModal from './TaskModal.vue' import EventModal from './EventModal.vue' +import TranslationModal from './TranslationModal.vue' import axios from '@nextcloud/axios' import { generateUrl } from '@nextcloud/router' import { loadState } from '@nextcloud/initial-state' @@ -307,6 +313,7 @@ import { mapStores } from 'pinia' import moment from '@nextcloud/moment' import { translateTagDisplayName } from '../util/tag.js' import { FOLLOW_UP_TAG_LABEL } from '../store/constants.js' +import { Text, toPlain } from '../util/text.js' // Ternary loading state const LOADING_DONE = 0 @@ -321,6 +328,7 @@ export default { TaskModal, MoveModal, TagModal, + TranslationModal, ConfirmModal, Avatar, NcActionButton, @@ -398,6 +406,8 @@ export default { showEventModal: false, showTaskModal: false, showTagModal: false, + showTranslationModal: false, + plainTextBody: '', rawMessage: '', // Will hold the raw source of the message when requested isInternal: true, enabledSmartReply: loadState('mail', 'llm_freeprompt_available', false), @@ -863,6 +873,23 @@ export default { onCloseTagModal() { this.showTagModal = false }, + onOpenTranslationModal() { + try { + if (this.message.hasHtmlBody) { + let text = new Text('html', this.message.body) + text = toPlain(text) + this.plainTextBody = text.value + } else { + this.plainTextBody = this.message.body + } + this.showTranslationModal = true + } catch (error) { + showError(t('mail', 'Please wait for the message to load')) + } + }, + onCloseTranslationModal() { + this.showTranslationModal = false + }, async onShowSourceModal() { if (this.rawMessage.length === 0) { const resp = await axios.get( diff --git a/src/components/TranslationModal.vue b/src/components/TranslationModal.vue new file mode 100644 index 0000000000..5e69120ba1 --- /dev/null +++ b/src/components/TranslationModal.vue @@ -0,0 +1,224 @@ + + + + + + + diff --git a/src/main.js b/src/main.js index dbe3fa7738..d114fd65de 100644 --- a/src/main.js +++ b/src/main.js @@ -21,6 +21,7 @@ import { fixAccountId } from './service/AccountService.js' import { loadState } from '@nextcloud/initial-state' import { createPinia, PiniaVuePlugin } from 'pinia' import useOutboxStore from './store/outboxStore.js' +import { fetchAvailableLanguages } from './service/translationService.js' // eslint-disable-next-line camelcase __webpack_nonce__ = btoa(getRequestToken()) @@ -149,6 +150,12 @@ store.commit('setFollowUpFeatureAvailable', followUpFeatureAvailable) const smimeCertificates = loadState('mail', 'smime-certificates', []) store.commit('setSmimeCertificates', smimeCertificates) +const llmTranslationEnabled = loadState('mail', 'llm_translation_enabled', false) +store.commit('enableTranslation', llmTranslationEnabled) +if (llmTranslationEnabled) { + fetchAvailableLanguages() +} + /* eslint-disable vue/match-component-file-name */ export default new Vue({ el: '#content', diff --git a/src/service/translationService.js b/src/service/translationService.js new file mode 100644 index 0000000000..153b5b2ba3 --- /dev/null +++ b/src/service/translationService.js @@ -0,0 +1,44 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import axios from '@nextcloud/axios' +import { generateOcsUrl } from '@nextcloud/router' +import store from '../store/index.js' + +const fetchAvailableLanguages = async function() { + try { + const response = await axios.get(generateOcsUrl('taskprocessing/tasktypes')) + const inputLanguages = response.data.ocs.data.types['core:text2text:translate'].inputShapeEnumValues[0] + const outputLanguages = response.data.ocs.data.types['core:text2text:translate'].inputShapeEnumValues[1] + store.commit('setTranslationInputLanguages', inputLanguages) + store.commit('setTranslationOutputLanguages', outputLanguages) + } catch (e) { + console.error('Failed to fetch available languages', e) + } +} + +const translateText = async function(text, fromLanguage, toLanguage) { + const scheduleResponse = await axios.post(generateOcsUrl('taskprocessing/schedule'), { + input: { + origin_language: fromLanguage ?? null, + input: text, + target_language: toLanguage, + }, + type: 'core:text2text:translate', + appId: 'mail', + }) + const task = scheduleResponse.data.ocs.data.task + const getTaskOutput = async (task) => { + if (task.output) { + return task.output.output + } + await new Promise(resolve => setTimeout(resolve, 2000)) + const taskResponse = await axios.get(generateOcsUrl(`taskprocessing/task/${task.id}`)) + return getTaskOutput(taskResponse.data.ocs.data.task) + } + return await getTaskOutput(task) +} + +export { fetchAvailableLanguages, translateText } diff --git a/src/store/getters.js b/src/store/getters.js index f0b8351b88..daa0f4d0fd 100644 --- a/src/store/getters.js +++ b/src/store/getters.js @@ -160,4 +160,7 @@ export const getters = { getInternalAddresses: (state) => state.internalAddress?.filter(internalAddress => internalAddress !== undefined), hasCurrentUserPrincipalAndCollections: (state) => state.hasCurrentUserPrincipalAndCollections, showSettingsForAccount: (state) => (accountId) => state.showAccountSettings === accountId, + isTranslationEnabled: (state) => state.isTranslationEnabled, + getTranslationInputLanguages: (state) => state.translationInputLanguages, + getTranslationOutputLanguages: (state) => state.translationOutputLanguages, } diff --git a/src/store/index.js b/src/store/index.js index 55bfb36d5a..5f2ae3c81e 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -109,6 +109,9 @@ export default new Store({ internalAddress: [], hasCurrentUserPrincipalAndCollections: false, showAccountSettings: null, + isTranslationEnabled: false, + translationInputLanguages: [], + translationOutputLanguages: [], }, getters, mutations, diff --git a/src/store/mutations.js b/src/store/mutations.js index 60214361e5..fbe127ef28 100644 --- a/src/store/mutations.js +++ b/src/store/mutations.js @@ -519,4 +519,13 @@ export default { showSettingsForAccount(state, accountId) { state.showAccountSettings = accountId }, + enableTranslation(state, enabled) { + state.isTranslationEnabled = enabled + }, + setTranslationInputLanguages(state, languages) { + state.translationInputLanguages = languages + }, + setTranslationOutputLanguages(state, languages) { + state.translationOutputLanguages = languages + }, } diff --git a/src/util/text.js b/src/util/text.js index 5d32878a40..fb4f86cef6 100644 --- a/src/util/text.js +++ b/src/util/text.js @@ -10,7 +10,7 @@ import { convert } from 'html-to-text' /** * @type {Text} */ -class Text { +export class Text { constructor(format, value) { this.format = format diff --git a/tests/Unit/Controller/PageControllerTest.php b/tests/Unit/Controller/PageControllerTest.php index 5ea42fde9b..c5245a74d4 100644 --- a/tests/Unit/Controller/PageControllerTest.php +++ b/tests/Unit/Controller/PageControllerTest.php @@ -299,7 +299,7 @@ public function testIndex(): void { ->method('getLoginCredentials') ->willReturn($loginCredentials); - $this->initialState->expects($this->exactly(19)) + $this->initialState->expects($this->exactly(20)) ->method('provideInitialState') ->withConsecutive( ['debug', true], @@ -318,6 +318,7 @@ public function testIndex(): void { ['disable-snooze', false], ['allow-new-accounts', true], ['llm_summaries_available', false], + ['llm_translation_enabled', false], ['llm_freeprompt_available', false], ['llm_followup_available', false], ['smime-certificates', []],