Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion lib/Listener/TaskFailedListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down
3 changes: 2 additions & 1 deletion lib/Listener/TaskSuccessfulListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down
49 changes: 47 additions & 2 deletions lib/Notification/Notifier.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
Expand Down Expand Up @@ -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']]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
$iconUrl = $this->url->getAbsoluteURL($this->url->imagePath(Application::APP_ID, 'app-dark.svg'));

$notification
Expand Down Expand Up @@ -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
Expand Down
50 changes: 42 additions & 8 deletions lib/Service/NotificationService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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,
Expand Down
8 changes: 6 additions & 2 deletions src/components/AssistantTextProcessingForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
106 changes: 101 additions & 5 deletions src/components/ChattyLLM/ChattyLLMInputForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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" />
</NcAppContent>
<NcDialog :key="deletionDialogKey"
v-model:open="deletionDialogOpen"
Expand Down Expand Up @@ -295,11 +297,14 @@ import formatRecurrenceRule from './recurrenceRule.js'
import { getLanguage } from '@nextcloud/l10n'
import {
cancelTaskPositionPolling,
getNotifyReady,
pollTaskPosition,
setNotifyReady,
TaskPollCancelledError,
} from '../../assistant.js'

import navAutoCollapse from '../../mixins/navAutoCollapse.js'
import { requestBrowserNotificationPermission } from '../../utils.js'

// future: type (text, image, file, etc), attachments, etc support

Expand Down Expand Up @@ -369,6 +374,11 @@ export default {
pollCheckSessionTimeout: null,
pollPositionTaskId: null,
pollPositionSessionId: null,
// task id of the generation currently being tracked for the notify-when-ready toggle
currentGenerationTaskId: null,
isNotifyEnabled: false,
// bumped when the notify state is synced or toggled, to discard stale sync responses
notifyStateSyncId: 0,
// [{ id: number, session_id: number, role: string, content: string, timestamp: number, sources:string }]
messages: [], // null when failed to fetch
streamingMessage: null,
Expand Down Expand Up @@ -517,6 +527,7 @@ export default {
cancelTaskPositionPolling()
this.pollPositionTaskId = null
this.pollPositionSessionId = null
this.stopGenerationTracking()
this.allMessagesLoaded = false
this.loading.llmGeneration = false
this.loading.llmRunning = false
Expand Down Expand Up @@ -610,7 +621,18 @@ export default {
try {
this.loading.llmGeneration = true
this.loading.llmRunning = false
this.startGenerationTracking(checkSessionResponseData.messageTaskId)
this.startTaskPositionPolling(checkSessionResponseData.messageTaskId, sessionId)
// resume case: sync the toggle with a notification that was enabled before reloading, don't block the polling
const notifyStateSyncId = ++this.notifyStateSyncId
getNotifyReady(checkSessionResponseData.messageTaskId).then(response => {
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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1070,6 +1095,7 @@ export default {
},

async runGenerationTask(sessionId, agencyConfirm = null) {
let taskId = null
try {
this.scrollToLastMessage()
this.slowPickup = false
Expand All @@ -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) {
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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) {
Expand All @@ -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
Expand Down Expand Up @@ -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'))
}
})
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* 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
Expand Down
Loading
Loading