diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index c054d46d2..228b846b8 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -91,10 +91,12 @@ public function register(IRegistrationContext $context): void { $context->registerEventListener(BeforeTemplateRenderedEvent::class, BeforeTemplateRenderedListener::class); $context->registerEventListener(LoadAdditionalScriptsEvent::class, LoadAdditionalScriptsListener::class); - $context->registerEventListener(TaskSuccessfulEvent::class, TaskSuccessfulListener::class); - $context->registerEventListener(TaskFailedEvent::class, TaskFailedListener::class); + // the chat listener persists the generated reply before the notify + // listeners run: a ready notification must not beat the message insert $context->registerEventListener(TaskSuccessfulEvent::class, ChattyLLMTaskListener::class); $context->registerEventListener(TaskFailedEvent::class, ChattyLLMTaskListener::class); + $context->registerEventListener(TaskSuccessfulEvent::class, TaskSuccessfulListener::class); + $context->registerEventListener(TaskFailedEvent::class, TaskFailedListener::class); $context->registerEventListener(TaskSuccessfulEvent::class, FileActionTaskSuccessfulListener::class); $context->registerEventListener(TaskSuccessfulEvent::class, NewFileMenuTaskSuccessfulListener::class); $context->registerEventListener(TaskFailedEvent::class, FileActionTaskFailedListener::class); diff --git a/lib/Listener/TaskFailedListener.php b/lib/Listener/TaskFailedListener.php index c7ab6d289..a79b38968 100644 --- a/lib/Listener/TaskFailedListener.php +++ b/lib/Listener/TaskFailedListener.php @@ -47,7 +47,8 @@ public function handle(Event $event): void { // we dispatch an event to ask the app that scheduled the task if it wants a notification // and what the target should be - if ($task->getAppId() !== Application::APP_ID) { + // chat tasks are scheduled by the assistant itself, with a compound appId + if ($task->getAppId() !== Application::APP_ID && $task->getAppId() !== Application::APP_ID . ':chatty-llm') { $beforeAssistantNotificationEvent = new BeforeAssistantNotificationEvent($task); $this->eventDispatcher->dispatchTyped($beforeAssistantNotificationEvent); if (!$beforeAssistantNotificationEvent->getWantsNotification()) { diff --git a/lib/Listener/TaskSuccessfulListener.php b/lib/Listener/TaskSuccessfulListener.php index 17a430519..f1ab4b0f2 100644 --- a/lib/Listener/TaskSuccessfulListener.php +++ b/lib/Listener/TaskSuccessfulListener.php @@ -47,7 +47,8 @@ public function handle(Event $event): void { // we dispatch an event to ask the app that scheduled the task if it wants a notification // and what the target should be - if ($task->getAppId() !== Application::APP_ID) { + // chat tasks are scheduled by the assistant itself, with a compound appId + if ($task->getAppId() !== Application::APP_ID && $task->getAppId() !== Application::APP_ID . ':chatty-llm') { $beforeAssistantNotificationEvent = new BeforeAssistantNotificationEvent($task); $this->eventDispatcher->dispatchTyped($beforeAssistantNotificationEvent); if (!$beforeAssistantNotificationEvent->getWantsNotification()) { diff --git a/lib/Notification/Notifier.php b/lib/Notification/Notifier.php index 9ea93bfa8..dc366cb0f 100644 --- a/lib/Notification/Notifier.php +++ b/lib/Notification/Notifier.php @@ -71,7 +71,8 @@ public function prepare(INotification $notification, string $languageCode): INot if (in_array($notification->getSubject(), ['success', 'failure'], true)) { // ignore old notifications (before meta tasks were introduced) - if (!isset($params['target'], $params['inputs'])) { + // the target is optional: the link is generated at render time + if (!isset($params['inputs'])) { throw new UnknownNotificationException(); } $schedulingAppId = $params['appId']; @@ -136,7 +137,7 @@ public function prepare(INotification $notification, string $languageCode): INot $content .= $l->t('Result: %1$s', [$params['result']]); } - $link = $params['target']; + $link = $params['target'] ?? $this->url->linkToRouteAbsolute(Application::APP_ID . '.assistant.getAssistantTaskResultPage', ['taskId' => $params['id']]); $iconUrl = $this->url->getAbsoluteURL($this->url->imagePath(Application::APP_ID, 'app-dark.svg')); $notification @@ -409,6 +410,50 @@ public function prepare(INotification $notification, string $languageCode): INot $notification->addParsedAction($action); + return $notification; + case 'chat_success': + $subject = $l->t('Your assistant reply is ready'); + + $link = $params['target'] + ?? $this->url->linkToRouteAbsolute(Application::APP_ID . '.assistant.getAssistantStandalonePage', ['sessionId' => $params['sessionId']]); + $iconUrl = $this->url->getAbsoluteURL($this->url->imagePath(Application::APP_ID, 'app-dark.svg')); + + $notification + ->setParsedSubject($subject) + ->setLink($link) + ->setIcon($iconUrl); + + $actionLabel = $params['actionLabel'] ?? $l->t('View reply'); + $action = $notification->createAction(); + $action->setLabel($actionLabel) + ->setParsedLabel($actionLabel) + ->setLink($notification->getLink(), IAction::TYPE_WEB) + ->setPrimary(true); + + $notification->addParsedAction($action); + + return $notification; + case 'chat_failure': + $subject = $l->t('Your assistant reply could not be generated'); + + $link = $params['target'] + ?? $this->url->linkToRouteAbsolute(Application::APP_ID . '.assistant.getAssistantStandalonePage', ['sessionId' => $params['sessionId']]); + $iconUrl = $this->url->getAbsoluteURL($this->url->imagePath('core', 'actions/error.svg')); + + $notification + ->setParsedSubject($subject) + ->setLink($link) + ->setIcon($iconUrl); + + $actionLabel = $params['actionLabel'] ?? $l->t('View chat'); + $action = $notification->createAction(); + $action->setLabel($actionLabel) + ->setParsedLabel($actionLabel) + ->setLink($notification->getLink(), IAction::TYPE_WEB) + ->setPrimary(true); + + $notification->addParsedAction($action); + return $notification; default: // Unknown subject => Unknown notification => throw diff --git a/lib/Service/NotificationService.php b/lib/Service/NotificationService.php index 5fc3ab147..a5048f5b8 100644 --- a/lib/Service/NotificationService.php +++ b/lib/Service/NotificationService.php @@ -35,25 +35,45 @@ public function sendNotification(Task $task, ?string $customTarget = null, ?stri $manager = $this->notificationManager; $notification = $manager->createNotification(); + $chatSessionId = $this->getChatMessageSessionId($task); + $params = [ 'appId' => $task->getAppId(), 'id' => $task->getId(), - 'inputs' => $task->getInput(), - 'target' => $customTarget ?? $this->getDefaultTarget($task->getId()), + 'target' => $customTarget, 'actionLabel' => $actionLabel, 'result' => $resultPreview, ]; + if ($chatSessionId === null) { + // chat notifications never render the task input and it can be + // large enough to overflow the notifications table column + $params['inputs'] = $task->getInput(); + } $params['taskTypeId'] = $task->getTaskTypeId(); + if ($chatSessionId !== null) { + $params['sessionId'] = (string)$chatSessionId; + } $taskSuccessful = $task->getStatus() === Task::STATUS_SUCCESSFUL; - $subject = $taskSuccessful - ? 'success' - : 'failure'; + if ($chatSessionId !== null) { + $subject = $taskSuccessful + ? 'chat_success' + : 'chat_failure'; + } else { + $subject = $taskSuccessful + ? 'success' + : 'failure'; + } - $objectType = $customTarget === null - ? 'task' - : 'task-with-custom-target'; + // chat conversations must navigate to the session, not open the task + // result modal: the frontend notification action interceptor cancels + // WEB actions for notifications with the object type "task" + $objectType = $chatSessionId !== null + ? 'chat' + : ($customTarget === null + ? 'task' + : 'task-with-custom-target'); $notification->setApp(Application::APP_ID) ->setUser($task->getUserId()) @@ -68,6 +88,20 @@ private function getDefaultTarget(int $taskId): string { return $this->url->linkToRouteAbsolute(Application::APP_ID . '.assistant.getAssistantTaskResultPage', ['taskId' => $taskId]); } + /** + * Chat message generation tasks share their appId with title generation tasks, + * only message tasks (including regenerations) use a "chatty-llm:" prefixed customId + */ + private function getChatMessageSessionId(Task $task): ?int { + if ($task->getAppId() !== Application::APP_ID . ':chatty-llm') { + return null; + } + if (preg_match('/^chatty-llm:(\d+)/', $task->getCustomId() ?? '', $matches)) { + return (int)$matches[1]; + } + return null; + } + public function sendFileActionNotification( string $userId, string $taskTypeId, int $taskId, int $sourceFileId, string $sourceFileName, string $sourceFilePath, diff --git a/src/components/AssistantTextProcessingForm.vue b/src/components/AssistantTextProcessingForm.vue index b3433ff82..8e1b5b0da 100644 --- a/src/components/AssistantTextProcessingForm.vue +++ b/src/components/AssistantTextProcessingForm.vue @@ -342,10 +342,14 @@ export default { 'background-notify', ], data() { - const urlIsAssignment = new URLSearchParams(window.location.search).get('isAssignment') + const urlParams = new URLSearchParams(window.location.search) + const urlIsAssignment = urlParams.get('isAssignment') + const urlSessionId = urlParams.get('sessionId') + // a session deep-link (chat notification) only makes sense in the chat view; + // isAssignment links also carry a sessionId and must keep winning const urlSelectedTaskTypeId = urlIsAssignment !== null && urlIsAssignment !== '0' && urlIsAssignment !== 'false' ? 'assignments' - : null + : (urlSessionId !== null ? CHAT_TASK_TYPE_ID : null) return { myInputs: this.inputs, myOutputs: this.outputs, diff --git a/src/components/ChattyLLM/ChattyLLMInputForm.vue b/src/components/ChattyLLM/ChattyLLMInputForm.vue index 23f391a39..12f374801 100644 --- a/src/components/ChattyLLM/ChattyLLMInputForm.vue +++ b/src/components/ChattyLLM/ChattyLLMInputForm.vue @@ -229,8 +229,10 @@ v-model:chat-content="chatContent" class="session-area__input-area" :loading="loading" + :is-notify-enabled="isNotifyEnabled" @submit="handleSubmit" @submit-audio="handleSubmitAudio" + @background-notify="onBackgroundNotify" @cancel-generation="cancelGeneration" /> { + if (this.generationTaskId === checkSessionResponseData.messageTaskId + && this.notifyStateSyncId === notifyStateSyncId) { + this.isNotifyEnabled = !!response.data?.ocs?.data?.id + } + }).catch(error => { + console.error('Failed to get the notification state of the generation task', error) + }) this.userScrolled = false const message = await this.pollGenerationTask(checkSessionResponseData.messageTaskId, sessionId) console.debug('checkTaskPolling result:', message) @@ -686,6 +709,7 @@ export default { if (ownsGeneration) { this.generationTaskId = null } + this.isNotifyEnabled = false this.loading.llmGeneration = false this.loading.llmRunning = false this.loading.taskPosition = null @@ -929,6 +953,7 @@ export default { /** * Track the running generation task of a session. A stop request made * before the task id was known is executed once the id is available. + * A fresh generation also resets the notify-when-ready toggle. * * @param {number} taskId Id of the scheduled generation task * @param {number} sessionId Id of the session the task belongs to @@ -942,6 +967,7 @@ export default { return false } this.generationTaskId = taskId + this.isNotifyEnabled = false if (this.pendingCancelGeneration && this.pendingCancelSeq === requestSeq) { this.pendingCancelGeneration = false this.pendingCancelSeq = null @@ -1246,6 +1272,7 @@ export default { if (taskId !== null) { this.generationTaskId = null } + this.isNotifyEnabled = false this.loading.llmGeneration = false this.loading.llmRunning = false this.loading.taskPosition = null @@ -1314,6 +1341,7 @@ export default { if (taskId !== null) { this.generationTaskId = null } + this.isNotifyEnabled = false this.loading.llmGeneration = false this.loading.llmRunning = false this.loading.taskPosition = null @@ -1358,6 +1386,66 @@ export default { return hasPush }, + /** + * Enable or disable the notification for the currently running generation task. + */ + async onBackgroundNotify() { + if (this.generationTaskId === null || !this.loading.llmGeneration || this.notifyTogglePending) { + return + } + // capture before the optional permission prompt: it can stay open long + // enough for the generation to end or the session to switch + const taskId = this.generationTaskId + // one id per toggle request: in-flight sync responses, stale request + // responses and the reload timer must not apply after a newer toggle + const notifyStateSyncId = ++this.notifyStateSyncId + const enable = !this.isNotifyEnabled + let reloadForPushSubscription = false + // serialize: overlapping requests could complete out of order server + // side and desync the toggle state + this.notifyTogglePending = true + try { + if (enable) { + // user gesture context: ask for browser notifications so the reply can also + // reach the user when the tab is closed + reloadForPushSubscription = await requestBrowserNotificationPermission() + // the prompt can outlive the generation: if the tracked task ended or + // was replaced while it was open, applying the toggle would target + // the wrong task and desync the banner state + if (this.generationTaskId !== taskId) { + return + } + } + await setNotifyReady(taskId, enable) + // a slow request can outlive its generation or be superseded by a + // newer state sync: applying it then would flip the state of + // whatever is tracked now + if (this.generationTaskId !== taskId || this.notifyStateSyncId !== notifyStateSyncId) { + return + } + this.isNotifyEnabled = enable + // the notifications app registers web push on page load only: + // reload once after a fresh grant so browser delivery actually starts. + // The generation survives the reload (check_session resumes it). + if (reloadForPushSubscription && enable) { + setTimeout(() => { + // a toggle back to disabled may still be in flight: don't + // reload over it + if (this.isNotifyEnabled && this.notifyStateSyncId === notifyStateSyncId) { + window.location.reload() + } + }, 1000) + } + } catch (error) { + console.error('Failed to toggle the notification of the generation task', error) + if (enable) { + showError(t('assistant', 'Failed to enable the notification')) + } + } finally { + this.notifyTogglePending = false + } + }, + startTaskPositionPolling(taskId, sessionId) { if (this.pollPositionTaskId === taskId && this.pollPositionSessionId === sessionId) { return diff --git a/src/components/ChattyLLM/InputArea.vue b/src/components/ChattyLLM/InputArea.vue index 7c5c8ba8c..5dc3d8e9b 100644 --- a/src/components/ChattyLLM/InputArea.vue +++ b/src/components/ChattyLLM/InputArea.vue @@ -4,6 +4,34 @@ -->