Signed-off-by: hamza221 <hamzamahjoubi221@gmail.com>
This commit is contained in:
hamza221 2023-07-26 19:25:33 +02:00
Родитель 5066737482
Коммит 0cb4ed5e8c
15 изменённых файлов: 408 добавлений и 12 удалений

Просмотреть файл

@ -320,6 +320,11 @@ return [
'url' => '/api/settings/allownewaccounts',
'verb' => 'POST'
],
[
'name' => 'settings#setEnabledThreadSummary',
'url' => '/api/settings/threadsummary',
'verb' => 'PUT'
],
[
'name' => 'trusted_senders#setTrusted',
'url' => '/api/trustedsenders/{email}',
@ -360,6 +365,11 @@ return [
'url' => '/api/thread/{id}',
'verb' => 'POST'
],
[
'name' => 'thread#summarize',
'url' => '/api/thread/{id}/summary',
'verb' => 'GET'
],
[
'name' => 'outbox#send',
'url' => '/api/outbox/{id}',

Просмотреть файл

@ -33,6 +33,7 @@ use OCA\Mail\Contracts\IUserPreferences;
use OCA\Mail\Db\SmimeCertificate;
use OCA\Mail\Db\TagMapper;
use OCA\Mail\Service\AccountService;
use OCA\Mail\Service\AiIntegrationsService;
use OCA\Mail\Service\AliasesService;
use OCA\Mail\Service\OutboxService;
use OCA\Mail\Service\SmimeService;
@ -73,6 +74,7 @@ class PageController extends Controller {
private IEventDispatcher $dispatcher;
private ICredentialstore $credentialStore;
private SmimeService $smimeService;
private AiIntegrationsService $aiIntegrationsService;
public function __construct(string $appName,
IRequest $request,
@ -90,7 +92,8 @@ class PageController extends Controller {
OutboxService $outboxService,
IEventDispatcher $dispatcher,
ICredentialStore $credentialStore,
SmimeService $smimeService) {
SmimeService $smimeService,
AiIntegrationsService $aiIntegrationsService) {
parent::__construct($appName, $request);
$this->urlGenerator = $urlGenerator;
@ -108,6 +111,7 @@ class PageController extends Controller {
$this->dispatcher = $dispatcher;
$this->credentialStore = $credentialStore;
$this->smimeService = $smimeService;
$this->aiIntegrationsService = $aiIntegrationsService;
}
/**
@ -244,6 +248,11 @@ class PageController extends Controller {
$this->config->getAppValue('mail', 'allow_new_mail_accounts', 'yes') === 'yes'
);
$this->initialStateService->provideInitialState(
'enabled_thread_summary',
$this->config->getAppValue('mail', 'enabled_thread_summary', 'no') === 'yes' && $this->aiIntegrationsService->isLlmAvailable()
);
$this->initialStateService->provideInitialState(
'smime-certificates',
array_map(

Просмотреть файл

@ -34,22 +34,29 @@ use OCP\AppFramework\Controller;
use OCP\AppFramework\Http\JSONResponse;
use OCP\IConfig;
use OCP\IRequest;
use OCP\TextProcessing\IManager;
use OCP\TextProcessing\SummaryTaskType;
use Psr\Container\ContainerInterface;
use function array_merge;
class SettingsController extends Controller {
private ProvisioningManager $provisioningManager;
private AntiSpamService $antiSpamService;
private ContainerInterface $container;
private IConfig $config;
public function __construct(IRequest $request,
ProvisioningManager $provisioningManager,
AntiSpamService $antiSpamService,
IConfig $config) {
IConfig $config,
ContainerInterface $container) {
parent::__construct(Application::APP_ID, $request);
$this->provisioningManager = $provisioningManager;
$this->antiSpamService = $antiSpamService;
$this->config = $config;
$this->container = $container;
}
public function index(): JSONResponse {
@ -119,4 +126,17 @@ class SettingsController extends Controller {
public function setAllowNewMailAccounts(bool $allowed) {
$this->config->setAppValue('mail', 'allow_new_mail_accounts', $allowed ? 'yes' : 'no');
}
public function setEnabledThreadSummary(bool $enabled) {
$this->config->setAppValue('mail', 'enabled_thread_summary', $enabled ? 'yes' : 'no');
}
public function isLlmConfigured() {
try {
$manager = $this->container->get(IManager::class);
} catch (\Throwable $e) {
return new JSONResponse(['data' => false]);
}
return new JSONResponse(['data' => in_array(SummaryTaskType::class, $manager->getAvailableTaskTypes(), true)]);
}
}

Просмотреть файл

@ -28,27 +28,35 @@ use OCA\Mail\Exception\ClientException;
use OCA\Mail\Exception\ServiceException;
use OCA\Mail\Http\TrapError;
use OCA\Mail\Service\AccountService;
use OCA\Mail\Service\AiIntegrationsService;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\JSONResponse;
use OCP\IRequest;
use Psr\Log\LoggerInterface;
class ThreadController extends Controller {
private string $currentUserId;
private AccountService $accountService;
private IMailManager $mailManager;
private AiIntegrationsService $aiIntergrationsService;
private LoggerInterface $logger;
public function __construct(string $appName,
IRequest $request,
string $UserId,
AccountService $accountService,
IMailManager $mailManager) {
IMailManager $mailManager,
AiIntegrationsService $aiIntergrationsService,
LoggerInterface $logger) {
parent::__construct($appName, $request);
$this->currentUserId = $UserId;
$this->accountService = $accountService;
$this->mailManager = $mailManager;
$this->aiIntergrationsService = $aiIntergrationsService;
$this->logger = $logger;
}
/**
@ -111,4 +119,40 @@ class ThreadController extends Controller {
return new JSONResponse();
}
/**
* @NoAdminRequired
*
* @param int $id
*
* @return JSONResponse
*/
public function summarize(int $id): JSONResponse {
try {
$message = $this->mailManager->getMessage($this->currentUserId, $id);
$mailbox = $this->mailManager->getMailbox($this->currentUserId, $message->getMailboxId());
$account = $this->accountService->find($this->currentUserId, $mailbox->getAccountId());
} catch (DoesNotExistException $e) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
if (empty($message->getThreadRootId())) {
return new JSONResponse([], Http::STATUS_NOT_FOUND);
}
$thread = $this->mailManager->getThread($account, $message->getThreadRootId());
try {
$summary = $this->aiIntergrationsService->summarizeThread(
$message->getThreadRootId(),
$thread,
$this->currentUserId,
);
} catch (\Throwable $e) {
$this->logger->error('Summarizing thread failed: ' . $e->getMessage(), [
'exception' => $e,
]);
return new JSONResponse([], Http::STATUS_NO_CONTENT);
}
return new JSONResponse(['data' => $summary]);
}
}

Просмотреть файл

@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
/**
* @author Hamza Mahjoubi <hamzamahjoubi22@proton.me>
*
* Mail
*
* This code is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, version 3,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License, version 3,
* along with this program. If not, see <http://www.gnu.org/licenses/>
*
*/
namespace OCA\Mail\Service;
use OCA\Mail\Exception\ServiceException;
use OCP\TextProcessing\IManager;
use OCP\TextProcessing\SummaryTaskType;
use OCP\TextProcessing\Task;
use Psr\Container\ContainerInterface;
use function array_map;
class AiIntegrationsService {
/** @var ContainerInterface */
private ContainerInterface $container;
public function __construct(ContainerInterface $container) {
$this->container = $container;
}
/**
* @param string $threadId
* @param array $messages
* @param string $currentUserId
*
* @return null|string
*
* @throws ServiceException
*/
public function summarizeThread(string $threadId, array $messages, string $currentUserId): null|string {
try {
$manager = $this->container->get(IManager::class);
} catch (\Throwable $e) {
throw new ServiceException('Text processing is not available in your current Nextcloud version', $e);
}
if(in_array(SummaryTaskType::class, $manager->getAvailableTaskTypes(), true)) {
$messagesBodies = array_map(function ($message) {
return $message->getPreviewText();
}, $messages);
$taskPrompt = implode("\n", $messagesBodies);
$summaryTask = new Task(SummaryTaskType::class, $taskPrompt, "mail", $currentUserId, $threadId);
$manager->runTask($summaryTask);
return $summaryTask->getOutput();
} else {
throw new ServiceException('No language model available for summary');
}
}
public function isLlmAvailable(): bool {
try {
$manager = $this->container->get(IManager::class);
} catch (\Throwable $e) {
return false;
}
return in_array(SummaryTaskType::class, $manager->getAvailableTaskTypes(), true);
}
}

Просмотреть файл

@ -28,6 +28,7 @@ namespace OCA\Mail\Settings;
use OCA\Mail\AppInfo\Application;
use OCA\Mail\Integration\GoogleIntegration;
use OCA\Mail\Integration\MicrosoftIntegration;
use OCA\Mail\Service\AiIntegrationsService;
use OCA\Mail\Service\AntiSpamService;
use OCA\Mail\Service\Provisioning\Manager as ProvisioningManager;
use OCP\AppFramework\Http\TemplateResponse;
@ -49,19 +50,22 @@ class AdminSettings implements ISettings {
private GoogleIntegration $googleIntegration;
private MicrosoftIntegration $microsoftIntegration;
private IConfig $config;
private AiIntegrationsService $aiIntegrationsService;
public function __construct(IInitialStateService $initialStateService,
ProvisioningManager $provisioningManager,
AntiSpamService $antiSpamService,
GoogleIntegration $googleIntegration,
MicrosoftIntegration $microsoftIntegration,
IConfig $config) {
IConfig $config,
AiIntegrationsService $aiIntegrationsService) {
$this->initialStateService = $initialStateService;
$this->provisioningManager = $provisioningManager;
$this->antiSpamService = $antiSpamService;
$this->googleIntegration = $googleIntegration;
$this->microsoftIntegration = $microsoftIntegration;
$this->config = $config;
$this->aiIntegrationsService = $aiIntegrationsService;
}
public function getForm() {
@ -85,6 +89,19 @@ class AdminSettings implements ISettings {
'allow_new_mail_accounts',
$this->config->getAppValue('mail', 'allow_new_mail_accounts', 'yes') === 'yes'
);
$this->initialStateService->provideInitialState(
Application::APP_ID,
'enabled_thread_summary',
$this->config->getAppValue('mail', 'enabled_thread_summary', 'no') === 'yes'
);
$this->initialStateService->provideInitialState(
Application::APP_ID,
'enabled_llm_backend',
$this->aiIntegrationsService->isLlmAvailable()
);
$this->initialStateService->provideLazyInitialState(
Application::APP_ID,
'ldap_aliases_integration',

Просмотреть файл

@ -42,6 +42,10 @@
<referencedClass name="Symfony\Component\Console\Input\InputInterface" />
<referencedClass name="Symfony\Component\Console\Input\InputOption" />
<referencedClass name="Symfony\Component\Console\Output\OutputInterface" />
<referencedClass name="OCP\TextProcessing\IManager" />
<referencedClass name="OCP\TextProcessing\SummaryTaskType" />
<referencedClass name="OCP\TextProcessing\Task" />
<referencedClass name="OCP\TextProcessing\SummaryTaskType" />
</errorLevel>
</UndefinedClass>
<UndefinedDocblockClass>

Просмотреть файл

@ -38,6 +38,7 @@
</div>
</div>
</div>
<ThreadSummary v-if="showSummaryBox" :loading="summaryLoading" :summary="summaryText" />
<ThreadEnvelope v-for="env in thread"
:key="env.databaseId"
:envelope="env"
@ -54,21 +55,26 @@
<script>
import { NcAppContentDetails as AppContentDetails, NcPopover as Popover } from '@nextcloud/vue'
import { showError } from '@nextcloud/dialogs'
import { prop, uniqBy } from 'ramda'
import debounce from 'lodash/fp/debounce'
import { loadState } from '@nextcloud/initial-state'
import { summarizeThread } from '../service/AiIntergrationsService'
import { getRandomMessageErrorMessage } from '../util/ErrorMessageFactory'
import Loading from './Loading'
import logger from '../logger'
import Error from './Error'
import RecipientBubble from './RecipientBubble'
import ThreadEnvelope from './ThreadEnvelope'
import ThreadSummary from './ThreadSummary'
export default {
name: 'Thread',
components: {
RecipientBubble,
ThreadSummary,
AppContentDetails,
Error,
Loading,
@ -78,6 +84,7 @@ export default {
data() {
return {
summaryLoading: false,
loading: true,
message: undefined,
errorMessage: '',
@ -85,6 +92,9 @@ export default {
expandedThreads: [],
participantsToDisplay: 999,
resizeDebounced: debounce(500, this.updateParticipantsToDisplay),
enabledThreadSummary: loadState('mail', 'enabled_thread_summary', false),
summaryText: '',
summaryError: false,
}
},
@ -134,6 +144,9 @@ export default {
}
return thread[0].subject || this.t('mail', 'No subject')
},
showSummaryBox() {
return this.thread.length > 2 && this.enabledThreadSummary && !this.summaryError
},
},
watch: {
$route(to, from) {
@ -158,6 +171,20 @@ export default {
window.removeEventListener('resize', this.resizeDebounced)
},
methods: {
async updateSummary() {
if (this.thread.length < 2 || !this.enabledThreadSummary) return
this.summaryLoading = true
try {
this.summaryText = await summarizeThread(this.thread[0].databaseId)
} catch (error) {
this.summaryError = true
showError(t('mail', 'Summarizing thread failed.'))
logger.error('Summarizing thread failed', { error })
} finally {
this.summaryLoading = false
}
},
updateParticipantsToDisplay() {
// Wait until everything is in place
if (!this.$refs.avatarHeader || !this.threadParticipants) {
@ -226,6 +253,7 @@ export default {
this.error = undefined
await this.fetchThread()
this.updateParticipantsToDisplay()
this.updateSummary()
},
async fetchThread() {

Просмотреть файл

@ -0,0 +1,107 @@
<template>
<div class="summary">
<div class="summary__header">
<div class="summary__header__title">
<span class="summary__header__title__brand">
<CreationIcon class="summary__header__title__brand__icon" />
<p>{{ brand }}</p>
</span>
<h2>{{ t('mail', 'Thread Summary') }}</h2>
</div>
<div class="summary__header__actions">
<NcActions />
<NcButton
:aria-label=" t('mail', 'Go to latest message')"
type="secondary"
@click="onScroll">
{{ t('mail', 'Go to newest message') }}
<template #icon>
<ArrowDownIcon
:size="20" />
</template>
</NcButton>
</div>
</div>
<div class="summary__body">
<LoadingSkeleton v-if="loading" :number-of-lines="1" :with-avatar="false" />
<p v-else>
{{ summary }}
</p>
</div>
</div>
</template>
<script>
import NcButton from '@nextcloud/vue/dist/Components/NcButton'
import NcActions from '@nextcloud/vue/dist/Components/NcActions'
import CreationIcon from 'vue-material-design-icons/Creation'
import ArrowDownIcon from 'vue-material-design-icons/ArrowDown'
import LoadingSkeleton from './LoadingSkeleton'
export default {
name: 'ThreadSummary',
components: {
LoadingSkeleton,
NcButton,
NcActions,
CreationIcon,
ArrowDownIcon,
},
props: {
summary: {
type: String,
required: true,
},
loading: {
type: Boolean,
required: true,
},
},
computed: {
brand() {
if (OCA.Theming) {
return t('mail', '{name} Assistant', { name: OCA.Theming.name })
}
return t('mail', '{name} Assistant', { name: 'Nextcloud' })
},
},
methods: {
onScroll() {
const pane = document.querySelector('.splitpanes__pane-details')
pane.scrollTo({ top: pane.scrollHeight, left: 0, behavior: 'smooth' })
},
},
}
</script>
<style lang="scss" scoped>
.summary{
border: 2px solid var(--color-primary-element);
border-radius:var( --border-radius-large) ;
margin: 0 10px 20px 10px;
padding: 28px;
display: flex;
flex-direction: column;
&__header{
display: flex;
justify-content: space-between;
&__title{
&__brand{
display: flex;
align-items: center;
background-color: var(--color-primary-light);
border-radius: var(--border-radius-pill);
width: fit-content;
padding-right: 5px;
&__icon{
color:var(--color-primary-element);
margin-right: 5px;
}
}
}
}
}
</style>

Просмотреть файл

@ -150,6 +150,20 @@
</p>
</article>
</div>
<div v-if="isLlmConfigured"
class="app-description">
<h3>{{ t('mail', 'Enable thread summary') }}</h3>
<article>
<p>
<NcCheckboxRadioSwitch
:checked.sync="enabledThreadSummary"
type="switch"
@update:checked="updateEnabledThreadSummary">
{{ t('mail','Enable thread summaries') }}
</NcCheckboxRadioSwitch>
</p>
</article>
</div>
<div class="app-description">
<h3>
{{
@ -264,7 +278,7 @@ import {
updateProvisioningSettings,
provisionAll,
updateAllowNewMailAccounts,
updateEnabledThreadSummary,
} from '../../service/SettingsService'
const googleOauthClientId = loadState('mail', 'google_oauth_client_id', null) ?? undefined
@ -324,6 +338,8 @@ export default {
loading: false,
},
allowNewMailAccounts: loadState('mail', 'allow_new_mail_accounts', true),
enabledThreadSummary: loadState('mail', 'enabled_thread_summary', false),
isLlmConfigured: loadState('mail', 'enabled_llm_backend'),
}
},
methods: {
@ -377,6 +393,9 @@ export default {
async updateAllowNewMailAccounts(checked) {
await updateAllowNewMailAccounts(checked)
},
async updateEnabledThreadSummary(checked) {
await updateEnabledThreadSummary(checked)
},
},
}
</script>

Просмотреть файл

@ -0,0 +1,17 @@
import { generateUrl } from '@nextcloud/router'
import axios from '@nextcloud/axios'
import { convertAxiosError } from '../errors/convert'
export const summarizeThread = async (threadId) => {
const url = generateUrl('/apps/mail/api/thread/{threadId}/summary', {
threadId,
})
try {
const resp = await axios.get(url)
if (resp.status === 204) throw convertAxiosError()
return resp.data.data
} catch (e) {
throw convertAxiosError(e)
}
}

Просмотреть файл

@ -76,3 +76,12 @@ export const updateAllowNewMailAccounts = (allowed) => {
}
return axios.post(url, data).then((resp) => resp.data)
}
export const updateEnabledThreadSummary = async (enabled) => {
const url = generateUrl('/apps/mail/api/settings/threadsummary')
const data = {
enabled,
}
const resp = await axios.put(url, data)
return resp.data
}

Просмотреть файл

@ -31,6 +31,7 @@ use OCA\Mail\Controller\PageController;
use OCA\Mail\Db\Mailbox;
use OCA\Mail\Db\TagMapper;
use OCA\Mail\Service\AccountService;
use OCA\Mail\Service\AiIntegrationsService;
use OCA\Mail\Service\AliasesService;
use OCA\Mail\Service\MailManager;
use OCA\Mail\Service\OutboxService;
@ -69,6 +70,9 @@ class PageControllerTest extends TestCase {
/** @var AccountService|MockObject */
private $accountService;
/** @var AiIntegrationsService|MockObject */
private $aiIntegrationsService;
/** @var AliasesService|MockObject */
private $aliasesService;
@ -113,6 +117,7 @@ class PageControllerTest extends TestCase {
$this->urlGenerator = $this->createMock(IURLGenerator::class);
$this->config = $this->createMock(IConfig::class);
$this->accountService = $this->createMock(AccountService::class);
$this->aiIntegrationsService = $this->createMock(AiIntegrationsService::class);
$this->aliasesService = $this->createMock(AliasesService::class);
$this->userSession = $this->createMock(IUserSession::class);
$this->preferences = $this->createMock(IUserPreferences::class);
@ -143,6 +148,7 @@ class PageControllerTest extends TestCase {
$this->eventDispatcher,
$this->credentialStore,
$this->smimeService,
$this->aiIntegrationsService,
);
}
@ -231,7 +237,7 @@ class PageControllerTest extends TestCase {
['version', '0.0.0', '26.0.0'],
['app.mail.attachment-size-limit', 0, 123],
]);
$this->config->expects($this->exactly(6))
$this->config->expects($this->exactly(7))
->method('getAppValue')
->withConsecutive(
[ 'mail', 'installed_version' ],
@ -239,14 +245,16 @@ class PageControllerTest extends TestCase {
['mail', 'microsoft_oauth_client_id' ],
['mail', 'microsoft_oauth_tenant_id' ],
['core', 'backgroundjobs_mode', 'ajax' ],
['mail', 'allow_new_mail_accounts', 'yes']
['mail', 'allow_new_mail_accounts', 'yes'],
['mail', 'enabled_thread_summary', 'no'],
)->willReturnOnConsecutiveCalls(
$this->returnValue('1.2.3'),
$this->returnValue(''),
$this->returnValue(''),
$this->returnValue(''),
$this->returnValue('cron'),
$this->returnValue('yes')
$this->returnValue('yes'),
$this->returnValue('no')
);
$user->expects($this->once())
->method('getDisplayName')
@ -267,7 +275,7 @@ class PageControllerTest extends TestCase {
->method('getLoginCredentials')
->willReturn($loginCredentials);
$this->initialState->expects($this->exactly(12))
$this->initialState->expects($this->exactly(13))
->method('provideInitialState')
->withConsecutive(
['debug', true],
@ -281,6 +289,7 @@ class PageControllerTest extends TestCase {
['outbox-messages', []],
['disable-scheduled-send', false],
['allow-new-accounts', true],
['enabled_thread_summary', false],
['smime-certificates', []],
);

Просмотреть файл

@ -31,10 +31,12 @@ use OCA\Mail\Db\MailAccount;
use OCA\Mail\Db\Mailbox;
use OCA\Mail\Db\Message;
use OCA\Mail\Service\AccountService;
use OCA\Mail\Service\AiIntegrationsService;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
use OCP\IRequest;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Log\LoggerInterface;
class ThreadControllerTest extends TestCase {
/** @var string */
@ -49,12 +51,19 @@ class ThreadControllerTest extends TestCase {
/** @var AccountService|MockObject */
private $accountService;
/** @var AiIntegrationsService|MockObject */
private $aiIntergrationsService;
/** @var IMailManager|MockObject */
private $mailManager;
/** @var ThreadController */
private $controller;
/** @var LoggerInterface|MockObject */
private $logger;
protected function setUp(): void {
parent::setUp();
@ -62,14 +71,18 @@ class ThreadControllerTest extends TestCase {
$this->request = $this->getMockBuilder(IRequest::class)->getMock();
$this->userId = 'john';
$this->accountService = $this->createMock(AccountService::class);
$this->aiIntergrationsService = $this->createMock(AiIntegrationsService::class);
$this->mailManager = $this->createMock(IMailManager::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->controller = new ThreadController(
$this->appName,
$this->request,
$this->userId,
$this->accountService,
$this->mailManager
$this->mailManager,
$this->aiIntergrationsService,
$this->logger
);
}

Просмотреть файл

@ -53,7 +53,7 @@ class AdminSettingsTest extends TestCase {
}
public function testGetForm() {
$this->serviceMock->getParameter('initialStateService')->expects($this->exactly(8))
$this->serviceMock->getParameter('initialStateService')->expects($this->exactly(10))
->method('provideInitialState')
->withConsecutive(
[
@ -71,6 +71,16 @@ class AdminSettingsTest extends TestCase {
'allow_new_mail_accounts',
$this->anything()
],
[
Application::APP_ID,
'enabled_thread_summary',
$this->anything()
],
[
Application::APP_ID,
'enabled_llm_backend',
$this->anything()
],
[
Application::APP_ID,
'google_oauth_client_id',