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 401345e99..7c875a7e2 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" /> + @submit-audio="handleSubmitAudio" + @background-notify="onBackgroundNotify" /> { + if (this.currentGenerationTaskId === 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) @@ -658,6 +680,9 @@ export default { this.pollPositionTaskId = null this.pollPositionSessionId = null } + if (this.active?.id === sessionId) { + this.stopGenerationTracking() + } this.loading.llmGeneration = false this.loading.llmRunning = false this.loading.taskPosition = null @@ -1070,6 +1095,7 @@ export default { }, async runGenerationTask(sessionId, agencyConfirm = null) { + let taskId = null try { this.scrollToLastMessage() this.slowPickup = false @@ -1087,8 +1113,10 @@ export default { const generationResponse = await axios.get(getChatURL('/generate'), { params }) const generationResponseData = generationResponse.data console.debug('scheduleGenerationTask response:', generationResponseData) - this.startTaskPositionPolling(generationResponseData.taskId, sessionId) - const message = await this.pollGenerationTask(generationResponseData.taskId, sessionId) + taskId = generationResponseData.taskId + this.startGenerationTracking(taskId) + this.startTaskPositionPolling(taskId, sessionId) + const message = await this.pollGenerationTask(taskId, sessionId) console.debug('checkTaskPolling result:', message) this.messages.push(message) if (this.streamingMessage === null) { @@ -1108,6 +1136,7 @@ export default { this.pollPositionTaskId = null this.pollPositionSessionId = null } + this.stopGenerationTracking(taskId) this.loading.llmGeneration = false this.loading.llmRunning = false this.loading.taskPosition = null @@ -1118,6 +1147,7 @@ export default { async runRegenerationTask(messageId) { const sessionId = this.active.id + let taskId = null try { this.loading.llmGeneration = true this.loading.llmRunning = false @@ -1126,8 +1156,10 @@ export default { const regenerationResponse = await axios.get(getChatURL('/regenerate'), { params: { messageId, sessionId } }) const regenerationResponseData = regenerationResponse.data console.debug('scheduleRegenerationTask response:', regenerationResponse) - this.startTaskPositionPolling(regenerationResponseData.taskId, sessionId) - const message = await this.pollGenerationTask(regenerationResponseData.taskId, sessionId) + taskId = regenerationResponseData.taskId + this.startGenerationTracking(taskId) + this.startTaskPositionPolling(taskId, sessionId) + const message = await this.pollGenerationTask(taskId, sessionId) console.debug('checkTaskPolling result:', message) this.messages[this.messages.length - 1] = message if (this.streamingMessage === null) { @@ -1147,6 +1179,7 @@ export default { this.pollPositionTaskId = null this.pollPositionSessionId = null } + this.stopGenerationTracking(taskId) this.loading.llmGeneration = false this.loading.llmRunning = false this.loading.taskPosition = null @@ -1183,6 +1216,69 @@ export default { return hasPush }, + /** + * Enable or disable the notification for the currently running generation task. + */ + async onBackgroundNotify() { + if (this.currentGenerationTaskId === null || !this.loading.llmGeneration) { + 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.currentGenerationTaskId + // any in-flight notify state sync is now outdated, its response must not overwrite the toggle + this.notifyStateSyncId++ + const enable = !this.isNotifyEnabled + let reloadForPushSubscription = false + 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() + } + setNotifyReady(taskId, enable).then(() => { + 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(() => { + if (this.isNotifyEnabled) { + 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')) + } + }) + }, + + /** + * Track the running generation task and reset the notify toggle state. + * + * @param {number} taskId Id of the scheduled generation task + */ + startGenerationTracking(taskId) { + this.currentGenerationTaskId = taskId + this.isNotifyEnabled = false + }, + + /** + * Stop tracking the generation task. + * If taskId is provided, only stop if it is still the tracked one. + * + * @param {number|null} taskId Id of the task that wants to stop the tracking + */ + stopGenerationTracking(taskId = null) { + if (taskId !== null && this.currentGenerationTaskId !== taskId) { + return + } + this.currentGenerationTaskId = null + this.isNotifyEnabled = 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 53e976335..ec22ed35f 100644 --- a/src/components/ChattyLLM/InputArea.vue +++ b/src/components/ChattyLLM/InputArea.vue @@ -4,6 +4,34 @@ -->