From c14655e588d1daf0b8ba99e954cfd4b8c9acac35 Mon Sep 17 00:00:00 2001 From: WSHAPER <42714629+WSHAPER@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:34:56 +0200 Subject: [PATCH 1/5] feat(chat): offer a notification when a reply is ready Fixes #695 Assisted-by: opencode:glm-5.3 Signed-off-by: WSHAPER <42714629+WSHAPER@users.noreply.github.com> --- lib/Listener/TaskFailedListener.php | 3 +- lib/Listener/TaskSuccessfulListener.php | 3 +- lib/Notification/Notifier.php | 44 +++++ lib/Service/NotificationService.php | 63 ++++++-- .../AssistantTextProcessingForm.vue | 8 +- .../ChattyLLM/ChattyLLMInputForm.vue | 103 +++++++++++- src/components/ChattyLLM/InputArea.vue | 106 ++++++++++++ src/utils.js | 23 +++ tests/unit/Notification/NotifierTest.php | 151 +++++++++++++++++ .../unit/Service/NotificationServiceTest.php | 153 ++++++++++++++++++ 10 files changed, 638 insertions(+), 19 deletions(-) create mode 100644 tests/unit/Notification/NotifierTest.php create mode 100644 tests/unit/Service/NotificationServiceTest.php 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..a2fe31dbd 100644 --- a/lib/Notification/Notifier.php +++ b/lib/Notification/Notifier.php @@ -409,6 +409,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..acce2cca7 100644 --- a/lib/Service/NotificationService.php +++ b/lib/Service/NotificationService.php @@ -35,31 +35,54 @@ public function sendNotification(Task $task, ?string $customTarget = null, ?stri $manager = $this->notificationManager; $notification = $manager->createNotification(); + $chatSessionId = $this->getChatMessageSessionId($task); + + if ($chatSessionId !== null) { + $defaultTarget = $this->getChatTarget($chatSessionId); + } else { + $defaultTarget = $this->getDefaultTarget($task->getId()); + } + $params = [ 'appId' => $task->getAppId(), 'id' => $task->getId(), 'inputs' => $task->getInput(), - 'target' => $customTarget ?? $this->getDefaultTarget($task->getId()), + 'target' => $customTarget ?? $defaultTarget, 'actionLabel' => $actionLabel, 'result' => $resultPreview, ]; $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()) ->setDateTime(new DateTime()) ->setObject($objectType, (string)($task->getId() ?? 0)) - ->setSubject($subject, $params); + ->setSubject($subject, $params) + ->setLink($params['target']); $manager->notify($notification); } @@ -68,6 +91,24 @@ private function getDefaultTarget(int $taskId): string { return $this->url->linkToRouteAbsolute(Application::APP_ID . '.assistant.getAssistantTaskResultPage', ['taskId' => $taskId]); } + private function getChatTarget(int $sessionId): string { + return $this->url->linkToRouteAbsolute(Application::APP_ID . '.assistant.getAssistantStandalonePage', ['sessionId' => $sessionId]); + } + + /** + * 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, @@ -97,7 +138,8 @@ public function sendFileActionNotification( ->setUser($userId) ->setDateTime(new DateTime()) ->setObject('task', (string)$taskId) - ->setSubject($subject, $params); + ->setSubject($subject, $params) + ->setLink($params['target']); $manager->notify($notification); } @@ -130,7 +172,8 @@ public function sendNewImageFileNotification( ->setUser($userId) ->setDateTime(new DateTime()) ->setObject('task', (string)$taskId) - ->setSubject($subject, $params); + ->setSubject($subject, $params) + ->setLink($params['target']); $manager->notify($notification); } 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..74c24f7fe 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,66 @@ 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 + } + // 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(this.currentGenerationTaskId, 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..c063e37d2 100644 --- a/src/components/ChattyLLM/InputArea.vue +++ b/src/components/ChattyLLM/InputArea.vue @@ -4,6 +4,31 @@ -->